docs/CONSOLE_MCP_SETUP.md
The Console MCP Server gives AI tools (Claude Code, Cursor, Windsurf) live access to your Rails application: real database counts, record lookups, schema inspection, and job monitoring — all inside rolled-back transactions.
| Option | How it works | When to use |
|---|---|---|
| Stdio via rake | Rake task boots Rails, runs MCP in-process | Local dev, simplest setup |
| Docker | Same rake task, piped through docker exec -i | Docker/Compose environments |
| HTTP/Rack middleware | Middleware mounts /mcp/console endpoint | Shared access, multiple clients |
| SSH remote bridge | Separate bridge process over stdio | Remote servers, production-adjacent |
The simplest setup. The woods:console rake task boots Rails, then starts the embedded MCP server using stdio transport. All queries run in-process via ActiveRecord — no separate bridge process needed.
gem 'woods' in your Gemfilebundle installThe rake task does two things before starting the MCP server:
Rails.application.eager_load! to load all application models. Without eager loading, only the models that happen to be autoloaded before the first query appear in the registry.Claude Code (.mcp.json or claude_desktop_config.json):
{
"mcpServers": {
"rails-console": {
"command": "bundle",
"args": ["exec", "rake", "woods:console"],
"cwd": "/path/to/your/rails-app"
}
}
}
Cursor / Windsurf (.cursor/mcp.json):
{
"mcpServers": {
"rails-console": {
"command": "bundle",
"args": ["exec", "rake", "woods:console"],
"cwd": "/path/to/your/rails-app"
}
}
}
MCP client (Claude Code)
│
│ spawns via stdio
│
▼
rake woods:console
│
├─ capture $stdout before boot
├─ Rake::Task[:environment].invoke (Rails boots)
├─ load exe/codebase-console
│ ├─ Rails.application.eager_load!
│ ├─ build model registry from ActiveRecord::Base.descendants
│ ├─ Server.build_embedded(model_validator:, safe_context:, ...)
│ └─ MCP::Server::Transports::StdioTransport.new(server).open
│
└─ MCP server responds to tool calls via stdin/stdout
Same embedded approach as Option A, but piped through docker exec -i. The -i flag keeps stdin open for the MCP protocol. The container must be running before the MCP client starts.
woods gem in the container's GemfileClaude Code:
{
"mcpServers": {
"rails-console": {
"command": "docker",
"args": [
"exec", "-i",
"your_app_web_1",
"bundle", "exec", "rake", "woods:console"
]
}
}
}
Docker Compose (when the service name is web):
{
"mcpServers": {
"rails-console": {
"command": "docker",
"args": [
"exec", "-i",
"compose-dev-web-1",
"bundle", "exec", "rake", "woods:console"
]
}
}
}
Note: The container name in MCP config must match exactly what
docker psshows. Docker Compose generates names like<project>-<service>-<index>. Check withdocker ps --format '{{.Names}}'.
If your Rails app requires environment variables at boot (credentials, database URL), pass them via docker exec -e or ensure they are set in the container already:
{
"mcpServers": {
"rails-console": {
"command": "docker",
"args": [
"exec", "-i",
"-e", "RAILS_ENV=development",
"your_app_web_1",
"bundle", "exec", "rake", "woods:console"
]
}
}
}
Mount the console as a Rack middleware endpoint. The MCP client connects over HTTP using the streamable-http transport instead of spawning a subprocess. Useful when multiple clients need shared access, or when stdio subprocess spawning is not practical.
gem 'woods' in Gemfilebundle installIn an initializer (config/initializers/woods.rb):
Woods.configure do |config|
config.console_mcp_enabled = true
config.console_mcp_path = '/mcp/console' # default
config.console_redacted_columns = %w[password_digest api_key ssn]
end
The middleware registers itself automatically via the gem's Railtie when console_mcp_enabled is true. To mount it manually:
# config/application.rb
config.middleware.use Woods::Console::RackMiddleware, path: '/mcp/console'
Claude Code (streamable-http transport):
{
"mcpServers": {
"rails-console": {
"type": "streamable-http",
"url": "http://localhost:3000/mcp/console"
}
}
}
For production or staging, use HTTPS and restrict the path with authentication middleware upstream.
The middleware lazy-initializes the MCP server on first request:
First HTTP request to /mcp/console
│
├─ mutex-locked initialization
│ ├─ Rails.application.eager_load!
│ ├─ build model registry from ActiveRecord::Base.descendants
│ └─ Server.build_embedded(...)
│ └─ StreamableHTTPTransport wraps the server
│
└─ subsequent requests: transport.handle_request(rack_request)
Each request gets its own database connection from the connection pool. SafeContext wraps that connection in a rolled-back transaction.
The HTTP endpoint grants read access to live database data. In production environments:
console_redacted_columns to redact sensitive fields (see Configuration Options)development and staging environmentsThe original bridge architecture for cases where the MCP client cannot spawn a subprocess directly into the Rails environment (remote servers, production-adjacent access, air-gapped apps). The woods-console-mcp binary runs on the client side and connects to a bridge process inside the Rails environment.
MCP client
│
├─ spawns: woods-console-mcp (reads console.yml)
│
▼
ConnectionManager (on client)
│
│ JSON-lines over stdio (ssh or docker exec)
│
▼
Bridge process (inside Rails environment)
│
└─ evaluates queries in Rails console
Create ~/.woods/console.yml (or point CODEBASE_CONSOLE_CONFIG to any YAML file):
# Direct process (same machine, different process)
connection:
mode: direct
# Docker
connection:
mode: docker
service: web
compose_file: docker-compose.yml
# SSH
connection:
mode: ssh
host: app.example.com
user: deploy
command: cd /app && bundle exec rails runner -
Override config path with environment variable:
CODEBASE_CONSOLE_CONFIG=/path/to/console.yml woods-console-mcp
{
"mcpServers": {
"rails-console": {
"command": "woods-console-mcp",
"env": {
"CODEBASE_CONSOLE_CONFIG": "/path/to/console.yml"
}
}
}
}
Tier support: The bridge architecture supports all 31 tools across all 4 tiers. The embedded approach (Options A–C) supports only Tier 1 tools — see Tool Support by Mode.
All 31 tools are registered and visible in the MCP server regardless of transport. However, Tier 2–4 tools return an "unsupported in embedded mode" error when called via Options A–C (embedded executor). Only the bridge architecture (Option D) supports those tiers.
| Tool | Description |
|---|---|
console_status | Health check: available models and database adapter |
console_schema | Column types, nullability, defaults; optionally includes indexes |
console_count | Record count matching scope conditions |
console_sample | Random sample of records (max 25) |
console_find | Find a record by primary key or unique column |
console_pluck | Extract column values with optional distinct (max 1000 rows) |
console_aggregate | Run sum, average, minimum, or maximum on a column |
console_association_count | Count associated records for a specific record |
console_recent | Recently created/updated records (max 50) |
| Tool | Description |
|---|---|
console_diagnose_model | Full model diagnostic: counts, recent records, aggregates |
console_data_snapshot | Record with associations for debugging (depth 1–3) |
console_validate_record | Run validations on an existing record with optional changes |
console_validate_with | Validate attributes against a model without persisting |
console_check_setting | Check a configuration setting value |
console_update_setting | Update a setting (requires confirmation) |
console_check_policy | Check authorization policy for a record and user |
console_check_eligibility | Check feature eligibility for a record |
console_decorate | Invoke a decorator and return computed attributes |
| Tool | Description |
|---|---|
console_slow_endpoints | Slowest endpoints by response time |
console_error_rates | Error rates by controller or overall |
console_throughput | Request throughput over time |
console_job_queues | Job queue statistics |
console_job_failures | Recent job failures |
console_job_find | Find a job by ID, optionally retry (requires confirmation) |
console_job_schedule | Scheduled/upcoming jobs |
console_redis_info | Redis server information by section |
console_cache_stats | Cache store statistics |
console_channel_status | ActionCable channel status |
| Tool | Description |
|---|---|
console_eval | Execute arbitrary Ruby code (requires confirmation, 10s timeout) |
console_sql | Execute read-only SQL — SELECT and WITH...SELECT only |
console_query | Enhanced query builder with joins, grouping, and HAVING |
Set these in your Rails initializer:
Woods.configure do |config|
# Enable HTTP/Rack transport. Default: false.
# Has no effect on stdio (rake) or bridge transports.
config.console_mcp_enabled = true
# URL path for the Rack middleware endpoint. Default: '/mcp/console'.
config.console_mcp_path = '/mcp/console'
# Column names to redact from all query results. Default: [].
# Replaced with "[REDACTED]" in output.
config.console_redacted_columns = %w[password_digest encrypted_password api_key ssn token]
end
console_redacted_columnsRedaction applies to all tool results regardless of transport. When a result hash contains a redacted column, the value is replaced with "[REDACTED]" before the MCP response is sent.
# Example: redact PII
config.console_redacted_columns = %w[email phone_number date_of_birth ssn]
The column names are matched by string, case-sensitive. Use the exact column names from your database schema.
The embedded console implements multiple defense-in-depth layers. None of them depend on the transport option — they apply equally to stdio, Docker, and HTTP modes.
Every tool invocation runs inside a database transaction that is always rolled back:
@connection.transaction do
set_timeout # statement timeout before any query
result = yield # run the tool
raise ActiveRecord::Rollback # always roll back
end
This means:
console_eval running User.create!(...) silently discards the writeEach transaction sets a statement timeout before any query runs. The default is 5000ms (5 seconds). Timeout enforcement is adapter-specific:
| Adapter | Mechanism | Scope |
|---|---|---|
| PostgreSQL | SET statement_timeout = '5000ms' | All statement types |
| MySQL | SET max_execution_time = 5000 | SELECT only (MySQL limitation) |
| Other | Best-effort (skipped gracefully) | — |
console_sql)SqlValidator rejects non-read-only SQL at the string level, before any database interaction:
SELECT, WITH...SELECT, EXPLAININSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, CREATE, GRANT, REVOKEUNION, INTO, COPYpg_sleep, lo_import, lo_export, pg_read_file, pg_write_file, load_file, sleep, benchmarkWITH ... AS (DELETE/UPDATE/INSERT ...)), comment-hidden injectionsBefore any query runs, the model name is checked against the registry built from ActiveRecord::Base.descendants. Unrecognized model names raise ValidationError without touching the database. Column names are validated against the model's column_names before pluck, aggregate, and recent operations.
cwd in MCP config points to the Rails app root (where Rakefile lives).curl http://localhost:3000/mcp/console — a 200 or 405 means the middleware is mounted.bundle exec rake woods:console directly in a terminal. It should hang (waiting for MCP protocol input) rather than exit immediately. If it exits, check the error output.The rake task redirects stdout to stderr before Rails boots specifically to prevent this. If you see JSON parse errors from the MCP client, check:
bundle exec rake woods:console, not rails runner exe/codebase-console directly (the runner path handles this too, but via a different mechanism).puts or print calls run at boot in your initializers before the task can capture stdout.bundle exec rake woods:console 2>/dev/null to isolate — the MCP protocol output goes to stdout, Rails noise goes to stderr.console_statusconsole_status returns the list of models registered at startup. If a model is missing:
ActiveRecord::Base (not from an intermediate abstract class that doesn't itself inherit AR).model.table_exists? returns true — models for tables that don't exist are excluded.eager_load! succeeds. If your app has a directory that fails to load (e.g., app/graphql/ requiring an uninstalled gem), Zeitwerk may abort early and skip models defined later alphabetically. Look for NameError in the boot output.console_sql rejects my querySqlValidator is conservative by design. If a valid read-only query is rejected:
UNION in any position is blocked — use console_query with joins instead.EXPLAIN is allowed; EXPLAIN ANALYZE runs the query and is also allowed.This is expected. The embedded executor (used in Options A–C) only implements the 9 Tier 1 tools. To use Tier 2–4 tools (console_diagnose_model, console_eval, console_sql, etc.), switch to the bridge architecture (Option D).
The middleware lazy-initializes the MCP server on the first request, which includes Rails.application.eager_load!. This can take several seconds on large apps. Subsequent requests are fast. If you want to pre-warm, call a health check endpoint that touches the middleware path at app startup.
The default statement timeout is 5000ms (5 seconds). If you are hitting timeouts on models with millions of rows, use scope to narrow the query:
console_count(model: "Order", scope: { status: "pending" })
The timeout is set per-transaction in SafeContext and is not currently configurable via Woods.configure. To change it, pass timeout_ms: to SafeContext.new directly if you are constructing the server programmatically.