45 MCP tools that give AI agents live access to your Rails schema, models, routes & conventions.
Read-only by design. Defense in depth. Every tool is non-destructive.
[!CAUTION] This gem is designed for development environments. The query tool is disabled in production by default. Sensitive files are blocked. All 45 tools are read-only.
rails_search_docs with fetch: trueThe rails_query tool uses a 4-layer security model:
flowchart LR
Q[SQL Query] --> L1{Layer 1\nRegex Validation}
L1 -->|"Blocked:\nINSERT, DROP,\nUNION SELECT..."| R1[Rejected]
L1 -->|SELECT only| L2{Layer 2\nDatabase Read-Only}
L2 -->|"SET TRANSACTION\nREAD ONLY\n+ timeout"| L3{Layer 3\nRow Limit}
L3 -->|"Cap: 1000 rows\nDefault: 100"| L4{Layer 4\nSensitive Columns}
L4 -->|"names password_digest,\napi_key, ..."| R1
L4 -->|"no sensitive column"| OK[Safe Result]
style R1 fill:#e74c3c,stroke:#c0392b,color:#fff
style OK fill:#27ae60,stroke:#1e8449,color:#fff
style L1 fill:#e67e22,stroke:#d35400,color:#fff
style L2 fill:#f39c12,stroke:#e67e22,color:#fff
style L3 fill:#3498db,stroke:#2980b9,color:#fff
style L4 fill:#9b59b6,stroke:#8e44ad,color:#fff
Before any query reaches the database:
/* */), line (--), and on MySQL #. A comment marker inside a quoted string or identifier is data and stays. The query that runs is this stripped text, never the raw input, so nothing the checks did not read reaches the databaseINTO OUTFILE / INTO DUMPFILE writes to diskpg_read_file, pg_read_binary_file, pg_ls_dir, pg_stat_file, lo_import, lo_export, dblink*, LOAD DATA, LOAD_FILE, load_extension and their relatives, checked before and after comment strippingAfter validation, the query runs inside a transaction:
| Database | Mechanism |
|---|---|
| PostgreSQL | SET TRANSACTION READ ONLY + SET LOCAL statement_timeout |
| MySQL | SET TRANSACTION READ ONLY + MAX_EXECUTION_TIME hint |
| SQLite | A read-only connection in a child process, killed at timeout |
On PostgreSQL and MySQL the query executes inside a transaction, then rolls back (even if it could write, it can’t). Any other adapter has no database-level guard: the query runs with Layer 1 validation and the row limit only.
A SQLite query runs in-process under PRAGMA query_only = ON, with no time
limit, for an in-memory database, on a platform without fork, or when it
needs a function, virtual-table module, collation or encryption key only the
app’s own connection has. The table and EXPLAIN answers then say so; CSV output
stays plain data.
On sqlite3 1.x a query fails at its timeout, with the statement-timeout error,
when another connection in the same process held an exclusive lock on a
rollback-journal database as it started (BEGIN EXCLUSIVE): the child
inherits that lock record and it never clears. A lock held
by another process is waited on as usual, and an open write transaction or any
lock in WAL mode does not block it.
config.query_row_limit (hard cap: 1000)LIMIT clause appended to the query. A LIMIT or FETCH FIRST that ends the query is lowered to the cap, and one inside a subquery is left as writtenA query that names a sensitive column is rejected before execution, not redacted after it:
Default redacted patterns: password_digest, encrypted_password, password_hash, reset_password_token, confirmation_token, unlock_token, otp_secret, session_data, secret_key, api_key, api_secret, access_token, refresh_token, jti
Those are the defaults of config.query_redacted_columns. A fixed built-in list is checked as well, which adds password_reset_token, remember_token, secret and private_key. Matching is by name, case-insensitive and word-bounded, against both lists. SELECT password_digest AS pd FROM users is blocked outright: post-execution redaction reads the column names the database returns, which the caller controls through aliases and expressions, so it cannot be relied on.
If one of your own columns merely looks sensitive (an oauth_applications.secret, say), exempt it by name:
config.query_allowed_columns = %w[secret]
Results are redacted as well: a returned column comes back as [FILTERED] when
its name is in config.query_redacted_columns, contains password, secret or
token, or ends in key, digest or hash. SHOW, DESCRIBE and EXPLAIN
output is not redacted.
The exemption covers the results too: an allowed name comes back unredacted. A
column declared with encrypts stays [FILTERED] either way.
[!WARNING] Disabled in production by default. Only enable with
config.allow_query_in_production = trueif you understand the implications.
The rails_search_code and file-reading tools block access to sensitive files:
.env .env.* *.env .envrc
config/master.key
config/credentials.yml.enc config/credentials/*.yml.enc
config/database.yml config/secrets*.yml config/secrets*.yml.enc
config/application.yml
config/settings.local.yml config/settings/*.local.yml
config/cable.yml config/storage.yml
config/mongoid.yml config/redis.yml
*.pem *.key *.p8 *.p12 *.pfx *.jks *.keystore
**/id_rsa **/id_ed25519 **/id_ecdsa **/id_dsa
.ssh/* .aws/credentials .aws/config .netrc .pgpass .my.cnf
A placeholder whose name ends in .example, .sample, .template or
.dist (.env.example) is committed to be read, so a basename glob such as
.env.* does not block it. A pattern that names it exactly, with no glob
characters, still does, and so does a path pattern (one with a /, such as
.ssh/*), which covers everything under it.
Search also excludes generated AI context files to prevent circular references:
CLAUDE.md, .claude/, .mcp.json
.cursor/, .cursorrules
.github/copilot-instructions.md, .github/instructions/, .vscode/mcp.json
AGENTS.md, opencode.json
.codex/
.ai-context.json
config.sensitive_patterns = %w[.env* *.key *.pem credentials.yml.enc]
All file-reading operations validate paths against Rails.root:
real_path = File.realpath(requested_path)
root = File.realpath(Rails.root.to_s)
raise unless real_path == root || real_path.start_with?(root + File::SEPARATOR)
The VFS (rails-ai-context://views/{path}) applies the same protection for view template reads.
Two directories outside Rails.root are read, for frontend manifests only: each
frontend_paths entry you configure (../web-client), and the JS workspace root
above the app (the nearest ancestor holding a lockfile or declaring workspaces,
never above the git root and never outside a git repository). Only package.json,
lockfiles and the presence of a bundler config (vite.config.* and similar) are
read there, plus a configured entry’s tsconfig.json and the configs it extends
inside that same directory. Sensitive patterns and symlink containment apply relative to that
directory, and frontend_stack names it in its answer.
An app with no lockfile of its own (an engine’s test/dummy) has its gems in the
Gemfile its config/boot.rb sets as BUNDLE_GEMFILE (../../Gemfile). That
Gemfile and its lockfile (gems.rb and gems.locked alike) are read with the same
trust as the app’s own Gemfile.lock, only when their directory is inside the app’s
git repository, never above its root and never outside a repository. Besides those two,
only a file that Gemfile names with eval_gemfile inside that directory, and the lib/
of a path gem the lockfile names inside the repository (the engine’s own remote: .),
are read. A file there that links out of the directory is refused.
Booted from an engine’s test/dummy, the engine whose root holds the app root (a loaded
Rails::Engine, never this gem) is the project’s own source: its app/ code, its
app/views layouts and templates, and, when the dummy keeps no test/ or spec/ of its
own, its test suite are read. Paths into it are printed relative to the app root
(../../app/models/...), and symlink containment applies relative to the engine root.
Without booting, only the test suite is read this way, and only from the directory of the
bundle config/boot.rb declares (read under the rule above) when it holds a *.gemspec
and contains the app root.
A path refused on policy - outside the app, a traversal, a sensitive file - comes back as an
error result: isError: true over MCP, exit 1 from the CLI. A path that is simply not there
is an ordinary answer and exits 0, so a script can tell the two apart.
Search tools use array-based command execution (never shell strings):
# Safe: array form
Open3.capture3("rg", "--no-heading", "--", pattern, directory)
# Pattern injection prevented by -- separator
File type parameters accept only alphanumeric characters.
On Ruby 3.2 and newer, user-supplied regex patterns have a 1-second timeout (2 seconds in the Ruby search fallback):
Regexp.new(pattern, timeout: 1)
Complex patterns that would cause catastrophic backtracking raise RegexpError instead of hanging.
[!WARNING]
Regexp.timeoutdoes not exist on Ruby 3.1, which this gem still supports. There the timeout is skipped, and a pattern crafted to backtrack catastrophically can hang the process serving the tool. If you exposerails_search_codeto input you do not control, run it on Ruby 3.2 or newer.
SafeFile.read provides drop-in safety for all file reads:
config.max_file_size, default: 5 MB)nil on any failure (no exceptions leak)Everything that leaves your app through this gem - log lines, query rows, environment values, config source slices - passes through one redaction module before anything else can touch it. Redacting and shortening are a single operation there, so a long credential cannot be cut apart in a way that hides it from the pattern that would have caught it.
What gets redacted:
redis://user:pass@host)Redacted values carry one of two markers, and only these two:
| Marker | Means |
|---|---|
[FILTERED] |
A value was removed because it is or may be a secret |
[EMAIL] |
An email address was removed |
Marker presence and this vocabulary are part of the output contract (see below) - you can pattern-match on them.
Contract, safe to depend on:
[FILTERED] / [EMAIL] marker vocabularyIncidental, expected to change between releases:
Pin your assertions to the first list. When something in the second list changes in a way that could break a pinned assertion, the CHANGELOG says so.
The rails_migration_advisor tool validates input:
When using HTTP transport (Rack middleware or McpController):
127.0.0.1 (localhost only - not exposed to network)auto_mount is false by default - must be explicitly enabledauto_mount is true in production, and report it as enabled elsewhereThe McpController uses thread-safe transport initialization with mutex synchronization.
rails_get_env returns credential keys, never values.env.example, .env.sample or .env.template, is shown after redactionconfig/credentials/*.yml.enc is in the sensitive patterns listEmail crisjosephnahine@gmail.com. Response within 48 hours.
Supported versions: only the latest 5.x release gets security fixes. See the repo root SECURITY.md for the full policy.