Skip to content
mogoxPublic

About

MCP Sidecar for Rails to let agents talk to your rails app faster

Resources

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

Coatepec

Coatepec gives coding agents a bounded way to run targeted RSpec examples against a Rails application, over MCP, without exposing a general Rails console.

Quickstart

Coatepec is installed once, outside of any Rails app's own bundle, and points at the app via --root:

gem install coatepec mcp
coatepec --version

Do 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", "."]
    }
  }
}

Architecture

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)

Configuration

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), only

A 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.

macOS fork (default since 0.9.0)

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.

Tools

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

Example queries

rails_routes:

  • "What are all the routes in this app?" -- rails_routes(), the application's own; add engines: "include" for the mounted engines' too.
  • "What's the URL for widgets?" -- rails_routes(query: "widget"). query is 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 like query: "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's engines_excluded says how many engine routes it held back. Pass engines: "include" to see them too, or engines: "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 the engine column 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 (with engines: "include") reads the way bin/rails routes does.
  • engines_excluded is a correctness signal, not only a saving. Engine routes print their paths relative to the mount point, so bin/rails routes -g admin cannot 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 plus engines_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_offset says so), where the default returned the application's routes complete. Reach for engines: "only" with the engine's class name when the engine's routes are the question.
  • An engine route's name is 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 as widget_admin.audits_path -- <mount name>.<name>_path, where the mount name is the name of the mount route itself -- and audits_path alone 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 routes keys engines by endpoint and so lists such an engine only once.
  • Paging: when next_offset is not null, call again with offset: next_offset to get the next page.

rails_model:

  • "What columns does Widget have, and which are nullable?" -- rails_model(name: "Widget", fields: ["columns"]) -- see columns[].null, columns[].sql_type, columns[].default.
  • "What validations and associations does Widget enforce?" -- rails_model(name: "Widget", fields: ["validators", "associations"]) -- see validators and associations.
  • "How many columns, associations, validators and enums does Event have?" -- rails_model(name: "Event") -- see counts; ask for the lists with fields when 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": 249 and "in_truncated": true. A list of 20 or fewer has no _count/_truncated siblings.
  • "What happens if I ask about a non-model class, like a controller?" -- rails_model(name: "ApplicationController") raises not_active_record_model rather than introspecting it (a nonexistent constant raises model_not_found instead) -- the tool only ever reflects on ActiveRecord::Base descendants.

rails_controller:

  • "What actions does WidgetsController define, and what routes reach them?" -- rails_controller(name: "WidgetsController") -- see actions[].routes, each with verb, path (without the (.:format) suffix -- byte-identical to the same route's path from rails_routes), route_name, and engine (null for 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) and routes_without_action (action names the route table expects but the controller doesn't define -- a request to that route raises AbstractController::ActionNotFound in 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, or nil if unrestricted -- nil and [] mean different things, so both are preserved: nil means no only:/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 -- so only: [] never runs and except: [] never skips), and if/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::Base and 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_request from the default forgery protection, and a "(block)" entry for macros like allow_browser that register a Proc. Read the list as "everything that runs around an action", not "everything this app wrote".
  • rails_controller admits ActionController::API controllers as well as ActionController::Base ones. A malformed constant name raises invalid_controller_name; a name that doesn't resolve raises controller_not_found; a name that resolves but isn't an ActionController descendant (a plain class, a model) raises not_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 (see ROADMAP.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 under flaky_examples -- distinct from consistently_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 plain rails_spec_run(seed: <that seed>).
  • timeout_seconds is a per-round budget, not a total; runs * timeout_seconds is capped at 1800s combined (flaky_check_budget_exceeded if exceeded) since this tool can run for a while.
  • Selections over 500 examples are capped per round (the same limit rails_spec_run already has), and each round samples a different subset, since execution order varies by design -- for suites this large, narrow paths/example rather than passing a very broad directory selection.
  • description is only present when it differs from id (RSpec); Minitest entries carry id only.
  • statuses[] only aligns positionally with rounds[] when no round crashed -- a round whose process itself failed contributes no entry to statuses[] (though it still appears in rounds[]), so treat positional correspondence as best-effort, not guaranteed, when a round's status in rounds[] 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:12 runs the one test whose definition spans line 12, and a directory runs every _test.rb under it.
  • example: is a substring match on the test's method name (example: "reaches the" matches test_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 toward summary.failure_count -- which is why that number can exceed the failures Minitest prints in stdout; summary.error_count says how many of them were errors, and summary.assertion_count is the assertion total Minitest prints; id is ClassName#test_method.
  • One call may not mix spec/ and test/ 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:LINE works even when the app was generated with --skip-test (no rails/test_unit/railtie) or runs Rails 7.1 with Minitest 6, both of which break Rails' own line filtering; Coatepec installs its own.

Restarts

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.

Security boundary

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.

Compared to Rails Active MCP

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.

Compatibility

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).

Development

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 + rubocop

License

Coatepec is available under the MIT License. See LICENSE.txt.

About

MCP Sidecar for Rails to let agents talk to your rails app faster

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages