Coatepec gives coding agents a bounded way to run targeted RSpec examples against a Rails application, over MCP, without exposing a general Rails console.
Coatepec is installed once, outside of any Rails app's own bundle, and
points at the app via --root:
gem install coatepec mcp
coatepec --versionDo not add coatepec to the target app's Gemfile. Worker::Client#spawn_worker
resolves both the worker executable and its RUBYLIB from Coatepec's own
installation, not from the app's bundle -- so the worker gets Coatepec's
lib regardless of what the app's Gemfile says. Adding it there is not just
redundant, it's a version-skew hazard: Bundler would activate whatever
version is in the app's lockfile while Coatepec itself keeps running the
version on RUBYLIB, and a drift between the two surfaces as a confusing
"already activated" failure. The only thing Coatepec needs from the target
app is railties, which is definitionally present in any Rails app you'd
point it at. Host app footprint is zero: no Gemfile line, no lockfile
change, no dependency resolution.
With the Claude Code CLI, from the Rails app's own root:
claude mcp add coatepec --scope project -- coatepec --root .That writes a project-scoped .mcp.json you can commit so the whole team
gets it. For any other MCP-compatible client, or to write it by hand, the
same thing looks like:
{
"mcpServers": {
"coatepec": {
"command": "coatepec",
"args": ["--root", "."]
}
}
}MCP client
|
| JSON-RPC over stdio
v
Coatepec parent (Rails-free)
`-- private NDJSON --> test worker (Rails "test", booted lazily, kept warm)
|-- Linux: Process.fork --> isolated RSpec/Minitest child
`-- macOS: Process.fork, guarded --> isolated RSpec/Minitest child (default)
Process.spawn --> fresh process, see Configuration (macos_fork: false)
An optional .coatepec.yml at the target Rails app's root enables
per-project settings:
macos_fork: false # opt out of forking on macOS (default true; see below)
macos_fork_unsafe_gems: [some_gem] # extends the built-in fork-unsafe denylist
defaults:
spec_run:
include_passing: true # true or false (default false)
include_stdout: always # failures (default), always, never
timeout_seconds: 300 # 1..900 (default 120)
routes:
engines: include # include, exclude (default), onlyA missing file means every setting takes its default -- this file is never required.
defaults sets project-wide values for those four inputs; a call argument
always wins, an omitted key falls back to the built-in default, and an
unknown key or value fails the call with invalid_config naming it. The
file is re-read on every call, so edits apply immediately.
defaults.spec_run applies to rails_spec_run only; rails_spec_flaky_check
keeps its own per-round budget.
Tool responses are compact JSON -- no indentation, nothing downstream reads
it. Set COATEPEC_PRETTY=1 in the MCP server's env (the same place as
OBJC_DISABLE_INITIALIZE_FORK_SAFETY in the JSON example below) to
pretty-print them when you are reading the sidecar by hand.
On macOS, rails_spec_run forks the warm worker for each call, reusing the
boot the way it always has on Linux. Setting macos_fork: false in
.coatepec.yml opts the project out: every call then spawns a fresh
bundle exec rspec process and re-boots Rails, so only the Linux lane
gets the warm-worker speedup described above.
The fork is guarded, because forking a process with native extensions
loaded isn't universally safe. Before each fork, Coatepec checks the worker's
live thread count against its post-boot baseline and its loaded gems
against a denylist, falling back to a fresh spawn for that one call if
either check looks risky. The built-in denylist ships empty -- no single
gem has been confirmed as the culprit yet -- so the guard is effectively
thread-count-only until a project adds its own
macos_fork_unsafe_gems. If a fork is attempted and the child crashes
anyway, Coatepec transparently retries via spawn and returns that result
-- fork stays enabled for later calls. Every rails_spec_run result
includes an execution_mode field (fork, spawn, spawn_fallback,
or spawn_after_crash) so you can see which path actually ran for a given
call; spawn_after_crash results also carry the crashed fork's own stderr
under crashed_fork_stderr so the crash can be diagnosed.
OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES in Coatepec's own environment is
recommended if you ever see spawn_after_crash. Without it, a forked child
that touches an Objective-C-initialized class aborts -- Coatepec retries via
spawn, so it degrades to the slow path for that call (no crash, no error
surfaced) rather than failing loudly, and you simply never get the speedup.
Set it on the MCP server process itself:
{
"mcpServers": {
"coatepec": {
"command": "coatepec",
"args": ["--root", "."],
"env": { "OBJC_DISABLE_INITIALIZE_FORK_SAFETY": "YES" }
}
}
}Be aware this flag disables a real safety check in Apple's Objective-C runtime; it's a reasonable trade for a local dev/test sidecar, but it's not a no-op.
| Tool | Input | Notes |
|---|---|---|
rails_spec_run |
paths: string[1..100], example?, seed?, fail_fast?, timeout_seconds? (1..900, default 120), include_passing?, include_stdout? (failures default, always, never) (all three default-able in .coatepec.yml) |
RSpec (spec/**/*_spec.rb) or Minitest (test/**/*_test.rb), chosen from the paths; isolated per run; output capped at 256 KiB per stream; failure blocks in stdout that repeat an earlier block's error verbatim are replaced by a roll-up line naming the tests; returns summary counts (example_count, failure_count, error_count, pending_count, assertion_count, duration; the error and assertion counts come from Minitest and are null for RSpec) plus only the failed/pending examples, pass include_passing: true for the full roster (still capped at 500 examples); stdout is returned only for failing runs by default; include_stdout: "always" keeps it for green runs, "never" drops it always (the key stays, null); stderr is always returned; child_pid/signaled/termsig/stopsig/coredump appear only when the child did not exit normally (a timeout kill or a crash); every result carries execution_mode (fork, spawn, spawn_fallback, spawn_after_crash) |
rails_runtime_status |
{} |
Reports Ruby/Rails versions, worker PID, boot_id, lifecycle state, the project root, spec_strategy (fork, guarded_fork, spawn), fallbacks (guarded-fork runs that fell back to spawn; null unless guarded) and defaults (the effective rails_spec_run/rails_routes defaults after .coatepec.yml) |
rails_runtime_restart |
{} |
Unconditionally respawns the worker, discarding its warm boot; returns the fresh worker's status, including spec_strategy and fallbacks |
rails_routes |
query?, limit? (1..200, default 100), offset?, engines? (exclude default, include, only) (default-able in .coatepec.yml) |
Returns columns (name, verb, path, controller, action, engine) and rows in that order; paths omit the (.:format) suffix Rails appends. Case-insensitive filter across name/verb/path/controller/action/engine. Application routes only by default; engines: "include" adds the routes of mounted engines (one level deep, paths prefixed with the mount point, engine naming the engine class; null for an application route), "only" returns just those. Every response carries engines (the filter applied) and engines_excluded (how many routes matching query the filter withheld), so nothing is hidden silently. The filter applies after query and before paging, so matched/next_offset describe the kept set. The engine's mount route counts as an application route. Routes Rails marks internal are omitted, like bin/rails routes. next_offset is the offset of the next page, or null on the last one |
rails_model |
name (constant path, e.g. Widget or Admin::Widget), fields? (any of columns, associations, validators, enums; omitted or [] means none) |
Returns name, table_name, primary_key, abstract_class and counts (the size of each of the four lists, each capped at 200) by default and only the lists named in fields -- so a how-many question costs a few dozen bytes and fields: ["columns"] asks for the one list you need; ActiveRecord models only; columns, associations, validators, enums -- no row data; validators are de-duplicated by class, attributes and options (a concern and the model body declaring the same validation count once), so the count can be lower than klass.validators.size; an array-valued validator option longer than 20 entries (a country-code inclusion list, say) is cut to its first 20 with <option>_count and <option>_truncated: true beside it |
rails_controller |
name (constant path, e.g. WidgetsController or Admin::ReportsController) |
Actions, action callbacks, concerns, and the routes reaching each action -- no request dispatch |
rails_spec_flaky_check |
paths, example?, timeout_seconds? (per round, 1..900), runs? (2..20, default 5) |
Runs the selection runs times with a fresh random seed each round; reports tests whose status was inconsistent across runs; RSpec or Minitest, chosen from the paths |
rails_routes:
- "What are all the routes in this app?" --
rails_routes(), the application's own; addengines: "include"for the mounted engines' too. - "What's the URL for widgets?" --
rails_routes(query: "widget").queryis a case-insensitive substring match across name, verb, path, controller, and action -- not just the path -- so a resource name alone typically returns every route for that resource (index/create/new/...); narrow further with something likequery: "new_widget"to hit one route by name. The call already leaves out the admin engine's scaffolding, which is often two thirds of every match; the response'sengines_excludedsays how many engine routes it held back. Passengines: "include"to see them too, orengines: "only"for just the engine's. - "Which routes accept POST?" --
rails_routes(query: "POST"), the same substring match applied to the verb column. - "Which routes does the Avo engine add?" --
rails_routes(query: "Avo::Engine", engines: "only")-- the substring match covers theenginecolumn too, so an engine's class name returns exactly the routes mounted from it. Application routes always sort before engine routes, so an unfiltered listing (withengines: "include") reads the waybin/rails routesdoes. engines_excludedis a correctness signal, not only a saving. Engine routes print their paths relative to the mount point, sobin/rails routes -g admincannot find an admin engine's routes at all: it matches the mount line and answers "one admin route" with no hint that anything is missing.rails_routes(query: "admin")returns that one row plusengines_excluded: 243, so the reader knows the rest exists.- Opting in is the expensive path, and it paginates. A broad query with
engines: "include"on an app with a mounted admin engine costs several times the default response and can still stop at the 100-row page of a larger match (next_offsetsays so), where the default returned the application's routes complete. Reach forengines: "only"with the engine's class name when the engine's routes are the question. - An engine route's
nameis relative to its engine, not a top-level url helper: the row["audits", "GET", "/widget_admin/audits", "widget_admin/audits", "index", "WidgetAdmin::Engine"]is reached aswidget_admin.audits_path--<mount name>.<name>_path, where the mount name is thenameof the mount route itself -- andaudits_pathalone does not exist on the application. - An engine mounted at two paths is listed under both mount points, once per
prefixed path, whenever engine routes are in scope, since each of those
paths is a real, reachable URL.
bin/rails routeskeys engines by endpoint and so lists such an engine only once. - Paging: when
next_offsetis notnull, call again withoffset: next_offsetto get the next page.
rails_model:
- "What columns does Widget have, and which are nullable?" --
rails_model(name: "Widget", fields: ["columns"])-- seecolumns[].null,columns[].sql_type,columns[].default. - "What validations and associations does Widget enforce?" --
rails_model(name: "Widget", fields: ["validators", "associations"])-- seevalidatorsandassociations. - "How many columns, associations, validators and enums does Event have?" --
rails_model(name: "Event")-- seecounts; ask for the lists withfieldswhen you need them. - A long allow-list is summarised, not enumerated:
validates :country_code, inclusion: { in: ISO_CODES }with 249 codes comes back as"in"holding the first 20,"in_count": 249and"in_truncated": true. A list of 20 or fewer has no_count/_truncatedsiblings. - "What happens if I ask about a non-model class, like a controller?" --
rails_model(name: "ApplicationController")raisesnot_active_record_modelrather than introspecting it (a nonexistent constant raisesmodel_not_foundinstead) -- the tool only ever reflects onActiveRecord::Basedescendants.
rails_controller:
- "What actions does WidgetsController define, and what routes reach them?" --
rails_controller(name: "WidgetsController")-- seeactions[].routes, each withverb,path(without the(.:format)suffix -- byte-identical to the same route'spathfromrails_routes),route_name, andengine(nullfor an application route, the engine's class name for a route that reaches this controller through a mount). - "Does this controller have dead code, or a route that will 500?" -- same
call -- see
unroutable_actions(action methods no route reaches -- probably dead code) androutes_without_action(action names the route table expects but the controller doesn't define -- a request to that route raisesAbstractController::ActionNotFoundin production; this is the tool's most actionable output). - "What before/after/around filters run on this controller's actions, and
under what conditions?" -- same call -- see
callbacks[]:kind("before"/"after"/"around"),filter(the method name, or"(block)"for a Proc),only/except(arrays of action names the filter is restricted to/excluded from, ornilif unrestricted --niland[]mean different things, so both are preserved:nilmeans noonly:/except:was given at all, while[]means one was given but names no action this controller actually defines -- e.g. a typo or an action that was since removed -- soonly: []never runs andexcept: []never skips), andif/unless(any remaining conditional, by symbol name or"(block)"). - "What concerns does this controller pull in?" -- same call -- see
concerns: app-defined modules only, whether included directly or inherited from a base class; framework modules (ActionController::Baseand everything above it in the ancestor chain) are excluded. - Unlike
concerns,callbacks[]is not filtered to app code: it is the controller's full callback chain, so callbacks Rails itself installs show up too --verify_authenticity_token/verify_same_origin_requestfrom the default forgery protection, and a"(block)"entry for macros likeallow_browserthat register a Proc. Read the list as "everything that runs around an action", not "everything this app wrote". rails_controlleradmitsActionController::APIcontrollers as well asActionController::Baseones. A malformed constant name raisesinvalid_controller_name; a name that doesn't resolve raisescontroller_not_found; a name that resolves but isn't anActionControllerdescendant (a plain class, a model) raisesnot_action_controller.
rails_controller has one deliberate limitation:
- Strong parameters are not reported.
params.require(:widget).permit(:name, :size)exists only as code inside a private method body, never as queryable class metadata -- the only way to recover a permit-list is to parse source, which this gem does not do (seeROADMAP.md's "Considered and set aside" section for why source parsing is out of scope generally).
Routes drawn by an engine mounted in the application are cross-referenced
like any other, with the mount point on the path and the engine's class name
in engine -- so a controller that lives inside an engine reports its real
routes rather than listing every action as unroutable. Their route_name is
engine-local, reached as <mount name>.<route_name>_path, exactly as in
rails_routes. Expansion goes one level deep: an engine mounted inside
another engine stays an opaque mount route, the same boundary bin/rails routes (and therefore rails_routes) draws.
rails_spec_flaky_check:
- "Is this spec flaky?" --
rails_spec_flaky_check(paths: ["spec/models/widget_spec.rb"])runs it 5 times (default), each with an independently random seed (equivalent to RSpec's--order rand:SEED), and reports any example whose pass/fail status wasn't the same every time underflaky_examples-- distinct fromconsistently_failing(fails every run: broken, not flaky) and examples that passed every run (omitted -- nothing to report). - Each round's seed is included in the response (
rounds[].seed), so a specific divergence can be reproduced afterward with a plainrails_spec_run(seed: <that seed>). timeout_secondsis a per-round budget, not a total;runs * timeout_secondsis capped at 1800s combined (flaky_check_budget_exceededif exceeded) since this tool can run for a while.- Selections over 500 examples are capped per round (the same limit
rails_spec_runalready has), and each round samples a different subset, since execution order varies by design -- for suites this large, narrowpaths/examplerather than passing a very broad directory selection. descriptionis only present when it differs fromid(RSpec); Minitest entries carryidonly.statuses[]only aligns positionally withrounds[]when no round crashed -- a round whose process itself failed contributes no entry tostatuses[](though it still appears inrounds[]), so treat positional correspondence as best-effort, not guaranteed, when a round'sstatusinrounds[]looks like an outright crash rather than a normal pass/fail.
The warm test worker forces Rails' reload-checking on for its own boot,
regardless of the target app's own test.rb setting (which disables it by
default) -- so editing a model file takes effect on the next tool call
without needing to restart Coatepec. This is not limited to metadata reads:
because the setting is applied to the whole worker process, rails_spec_run
also executes your specs with enable_reloading = true and cache_classes = false rather than whatever your own config/environments/test.rb asks for
(the file watcher is additionally pinned to the polling
ActiveSupport::FileUpdateChecker, so no listen threads are started in the
worker). For most apps this is invisible, but if you ever see behavior differ
between Coatepec and your own bundle exec rspec, this is the first thing to
suspect.
Minitest through rails_spec_run:
- "Run this Minitest file" --
rails_spec_run(paths: ["test/models/widget_test.rb"]); the framework is decided by the path, and there is no separate Minitest-named tool.test/models/widget_test.rb:12runs the one test whose definition spans line 12, and a directory runs every_test.rbunder it. example:is a substring match on the test's method name (example: "reaches the"matchestest_reaches_the_database), passed to Minitest as an escaped regex.- Results use RSpec's vocabulary so the shape is identical: a Minitest skip
is
"pending", an error is"failed"and counts towardsummary.failure_count-- which is why that number can exceed thefailuresMinitest prints instdout;summary.error_countsays how many of them were errors, andsummary.assertion_countis the assertion total Minitest prints;idisClassName#test_method. - One call may not mix
spec/andtest/paths (mixed_test_frameworks). - Rails' parallel testing is disabled in the child (
PARALLEL_WORKERS=1), so a large directory selection runs serially under the one timeout budget rather than forking a worker tree. file:LINEworks even when the app was generated with--skip-test(norails/test_unit/railtie) or runs Rails 7.1 with Minitest 6, both of which break Rails' own line filtering; Coatepec installs its own.
If any tool call fails with sidecar_restart_required, the target app's
Gemfile/Gemfile.lock changed since Coatepec's own parent process (the
"sidecar") started -- not just the warm test worker, which Coatepec restarts
on its own. This is expected any time you switch branches, pull, or rebase
across a commit that touches the Gemfile, since the sidecar is managed by
your MCP client rather than by Coatepec itself: restart your MCP client (or
however it manages the Coatepec process) to pick up the change.
A dead worker (as opposed to a dead sidecar) recovers on its own: the
next tool call detects it and transparently boots a fresh one before
retrying, whether the worker exited outright or a request to it failed
with a broken pipe. If you want a fresh worker without waiting for a
failure -- or one keeps recurring -- call rails_runtime_restart directly;
it always respawns, even if the current worker looks healthy.
No eval, console, SQL/record access, shell, Rake, or file-write tool. Spec
selectors must resolve inside an allowed spec or test root (spec/, test/,
and the packs/*/, engines/*/, gems/*/ variants of each); absolute
paths, .., symlink escapes, files that are neither _spec.rb under a spec
root nor _test.rb under a test root, and more than 100 selectors are
rejected. RSpec and Minitest still execute application-controlled code; only
run Coatepec against a trusted checkout.
Rails Active MCP is a
different MCP server for Rails apps built around a console_execute tool:
arbitrary Ruby runs in your Rails console, gated by pattern-based
"dangerous operation" detection (blocking things like mass deletions,
eval, or raw SQL) rather than by not offering code execution at all --
sophisticated bypasses of a denylist like that are always possible in
principle.
Coatepec takes the opposite approach: there's no eval, console, or SQL
tool to begin with. rails_spec_run only ever executes RSpec or Minitest
files that already exist under the app's own allowed spec/test roots, and rails_routes/
rails_model/rails_controller only ever call structured, read-only Rails
APIs (Rails.application.routes.routes, ActiveRecord reflection,
ActionController callback/action-method metadata) -- never eval,
const_get on unvalidated input, or arbitrary method dispatch. If
you genuinely need a Rails console over MCP, Rails Active MCP is built for
that; Coatepec is for teams who want an agent to run specs and read
structure without ever handing it a REPL.
Ruby >= 3.2, Rails >= 7.1, < 8.2, Minitest 5.x and 6.x (the fixture
apps pin 6.0.6; the 5.x name-filter flag is unit-tested). CI tests three
lanes: Rails 8.1 on Linux (primary), Rails 7.1 on Linux (compat), and Rails
8.1 on macOS (the lane that exercises the guarded-fork path above).
bundle install
bundle exec rake spec:unit # fast, no Rails boot
bundle exec rake spec:integration # boots the fixture Rails app
bundle exec rake # full suite + rubocopCoatepec is available under the MIT License. See LICENSE.txt.