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
# Gemfile
group :development, :test do
gem "coatepec", require: false
end
bundle install
bundle exec coatepec --version
Configure your MCP client to run bundle exec coatepec --root /absolute/path/to/app
from the Rails application's own bundle.
With the Claude Code CLI, from the Rails app's own root:
claude mcp add coatepec --scope project -- bundle exec 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": "bundle",
"args": ["exec", "coatepec", "--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 child
`-- macOS: Process.spawn --> fresh RSpec process (default)
Process.fork, guarded --> opt-in, see Configuration
Configuration
An optional .coatepec.yml at the target Rails app's root enables
per-project settings:
macos_fork: true # opt into forking on macOS (see below)
macos_fork_unsafe_gems: [some_gem] # extends the built-in fork-unsafe denylist
A missing file means every setting takes its default -- this file is never required.
macOS fork (experimental, opt-in)
On macOS, rails_spec_run normally spawns a fresh bundle exec rspec
process per call, re-booting Rails every time -- the warm-worker speedup
described above only applies on Linux by default. Setting macos_fork: true lets Coatepec attempt Process.fork on macOS too, reusing the warm
boot the way Linux does.
This is opt-in 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_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.
Tools
| Tool | Input | Notes |
|---|---|---|
rails_spec_run |
paths: string[1..100], example?, seed?, fail_fast?, timeout_seconds? (1..900, default 120) |
Isolated per run; output capped at 256 KiB per stream |
rails_runtime_status |
{} |
Reports Ruby/Rails versions, worker PID, boot_id, lifecycle state |
rails_routes |
query?, limit? (1..200, default 50), offset? |
Case-insensitive filter across name/verb/path/controller/action |
rails_model |
name (constant path, e.g. Widget or Admin::Widget) |
ActiveRecord models only; columns, associations, validators, enums -- no row data |
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.
Security boundary
No eval, console, SQL/record access, shell, Rake, or file-write tool. Spec
selectors must resolve inside an allowed spec root (spec/, packs/*/spec/,
engines/*/spec/, gems/*/spec/); absolute paths, .., symlink escapes,
non-_spec.rb files, and more than 100 selectors are rejected. RSpec still
executes 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 files that
already exist under the app's own allowed spec roots, and rails_routes/
rails_model only ever call structured, read-only Rails APIs
(Rails.application.routes.routes, ActiveRecord reflection) -- 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. CI tests three lanes: Rails 8.1 on
Linux (primary), Rails 7.1 on Linux (compat), and Rails 8.1 on macOS (which
is where the guarded-fork path above actually forks).
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.