talk_to_your_app
Let an AI agent talk to your running Rails app β safely. talk_to_your_app mounts a Model Context Protocol endpoint on your app, so a tool like Claude can query your database, inspect background jobs, flip feature flags, and call tools you write yourself β all behind your auth, all audit-logged, and read-only unless you opt into a write-capable plugin.
It's a thin, Rails-native layer over the official MCP Ruby SDK: the SDK handles the wire protocol; this gem adds everything Rails β your replicas, your jobs backend, your feature flags β plus the guardrails that make pointing an agent at your app something you can actually ship.
What you get
- π A Streamable HTTP MCP endpoint in two lines β
mount TalkToYourApp.rack_app, and you're live. - π Fail-closed by design β API-key or HTTP Basic auth required, optional per-tool authorization, and a read-only DB role the app refuses to boot without. Misconfiguration fails at deploy, never on the first request.
- π§° Five batteries-included plugins β
db(read-only SQL + schema introspection),jobs(Sidekiq / Solid Queue metrics),flipper(feature flags),rake(allow-listed tasks), andcustom_tools(your own tools, with a generator). - π Every call audit-logged β principal, IP, params, outcome, duration. Subscribe to persist your own trail.
- βοΈ A Ruby DSL + generators for writing first-class tools of your own in a few lines.
Everything is off by default and opt-in per plugin β an agent can only touch what you explicitly turn on.
Installation
# Gemfile
gem "talk_to_your_app"
bundle install
bin/rails generate talk_to_your_app:install
The generator writes a commented config/initializers/talk_to_your_app.rb. Then mount the endpoint in config/routes.rb:
mount TalkToYourApp.rack_app, at: TalkToYourApp.configuration.mount_at
Your first query
- Declare a read-only connection and enable the DB plugin in
config/initializers/talk_to_your_app.rb:
TalkToYourApp.configure do |config|
config.api_keys = { "my-agent" => ENV.fetch("TTYA_KEY") }
config.connection :replica_readonly, database: "primary", role: :reading
config.plugin :db
end
"my-agent" is the principal β the name this token authenticates as, recorded on every audit line. The endpoint is secure by default: no tool responds until auth is configured, and the app refuses to boot with a plugin enabled and no auth.
In production, point database: at a genuinely read-only replica (or a Postgres role with GRANT SELECT only). The gem enforces read-only at the Rails layer too, but the database role is the real backstop. See docs/read_only_connections.md for step-by-step setup on PostgreSQL, MySQL, and SQLite.
Mount it (see above) and boot the app. If the
:replica_readonlyconnection were missing, boot would fail with a clear error.Point your MCP client (e.g. Claude Code) at
http://localhost:3000/mcpwith the bearer tokenTTYA_KEY.Ask in plain English. The agent translates your question into SQL, calls
db.query, and answers:
You: How many users registered this week?
Claude: 1,284 β up 12% from last week.
(db.query β SELECT count(*) FROM users WHERE created_at >= '2026-05-25')
Rows come back as JSON, plain text, or an HTML table β the agent picks what it needs.
Try it locally
Want to see it end to end before wiring it into your own app? The bundled
test/dummy app runs as a real MCP server with HTTP Basic auth, the bundled
plugins, and seeded data. From a clone of this repo (needs a local PostgreSQL β
see LOCAL_DEVELOPMENT.md):
cd test/dummy
RAILS_ENV=development bundle exec ruby bin/rails db:prepare # creates the DB + a read-only role, seeds data
RAILS_ENV=development bundle exec ruby bin/rails server -p 3000 # MCP endpoint at http://localhost:3000/mcp
Then connect Claude Code with one command (Basic auth dev / secret, where
ZGV2OnNlY3JldA== is base64("dev:secret")):
claude mcp add --transport http talk-to-your-app http://localhost:3000/mcp \
--header "Authorization: Basic ZGV2OnNlY3JldA=="
Run /mcp in a Claude Code session to list the tools β e.g. "use
talk-to-your-app's db.query to count comments per user." The full walkthrough
(per-user tokens, background jobs, curl examples) is in
LOCAL_DEVELOPMENT.md.
Configuration reference
Every option lives inside TalkToYourApp.configure:
| Option | Description |
|---|---|
config.mount_at |
Path the endpoint serves from. Default "/mcp". |
config.server_name |
Server name in the initialize handshake. Default "talk_to_your_app". |
config.server_title |
Human-friendly server title shown to clients (optional). |
config.server_version |
Reported server version. Defaults to the gem version. |
config.server_description |
One-line description of this MCP server, shown to clients (optional). |
config.instructions |
Guidance shown to the LLM on how to use this server's tools (optional). |
config.api_keys |
{ "principal-name" => "secret" }. The name is logged as the principal. Multiple keys supported for rotation. |
| `config.basic_auth { \ | user, pass\ |
| `config.authorize { \ | principal, tool\ |
config.allowed_origins |
Origins permitted for browser requests (DNS-rebinding protection). Empty allows non-browser clients. |
config.connection :name, database:, role:, replica:, statement_timeout: |
Declares a named connection. role: is :reading or :writing. |
config.plugin :name, **opts |
Enables a plugin (off by default). |
config.logger |
Audit logger. Defaults to Rails.logger. Swappable to any Logger-compatible object. |
config.log_level |
Global audit level (default :info); overridable per plugin. |
At least one of api_keys / basic_auth must be configured once any plugin is enabled, or the app refuses to boot.
Authentication & per-user tokens
What's a principal? The identity behind a request β the name of the API key that authenticated (or the HTTP Basic username). It's what gets written to every audit line and what
config.authorizereceives, so giving each user or client its own named token gives you per-user attribution, scoping, and revocation.
config.api_keys is a map of principal name β secret token. The name is what gets logged as the principal and what config.authorize receives β so give each user or client its own named token rather than sharing one. That buys you per-user attribution in the audit log, per-user revocation, and per-user scoping:
TalkToYourApp.configure do |config|
# One named token per client/user β the key NAME is the principal.
config.api_keys = {
"claude-desktop" => ENV.fetch("TTYA_KEY_CLAUDE"),
"alice@acme.com" => ENV.fetch("TTYA_KEY_ALICE"),
"ci-readonly-bot" => ENV.fetch("TTYA_KEY_CI"),
}
# Optionally scope what each principal may call.
config. { |principal, tool| principal == "ci-readonly-bot" ? tool.start_with?("db.") : true }
end
Clients send Authorization: Bearer <token>. (HTTP Basic via config.basic_auth is the alternative; the username becomes the principal.)
Generating per-user tokens from your own User model is straightforward β give each user a high-entropy token (Rails' has_secure_token works well) and build the map:
config.api_keys = User.where.not(api_token: nil).pluck(:email, :api_token).to_h
(The map is read at configure time; rebuild and redeploy β or rebuild in a to_prepare block β when the set of users changes. See test/dummy for a worked example.)
Keeping tokens secure
- Never commit tokens. Pull them from
ENVor Rails credentials, not source. - Use HTTPS in production so Bearer tokens aren't sent in clear text. Restrict
config.allowed_originsfor any browser-originated traffic. - Use high-entropy tokens (e.g.
SecureRandom.hex(32)orhas_secure_token), one per principal β never a shared secret. - Rotate by adding the new named token and removing the old; multiple keys can be valid at once, so rotation needs no downtime.
- Revoke a user by dropping their key (or nulling their
api_token) and redeploying. - Comparison is constant-time, and tokens are never written to the audit log. Mark any sensitive tool argument
redact: trueso it is masked too. - Scope blast radius with
config.authorize(per-principal tool allow-lists) and read-only DB roles, so a leaked token is bounded.
Plugins
All plugins are off by default β enable them explicitly, and an agent can only reach the ones you turn on.
| Plugin | What an agent can do | Enable with |
|---|---|---|
| DB | Run read-only SQL; introspect tables, columns, indexes, FKs | config.plugin :db |
| Jobs | Read queue sizes, recent/failed jobs, rates | config.plugin :jobs, adapter: :sidekiq |
| Flipper | Read and toggle feature flags (global, actor, group, %) | config.plugin :flipper |
| Rake | Run allow-listed rake tasks and read their output | config.plugin :rake, allowed: [...] |
| Custom Tools | Call tools you write yourself (writes allowed) | config.plugin :custom_tools |
DB
A single read-only SQL tool.
# `database:` must map to a SELECT-only DB user or a replica β not your
# writable primary. That read-only role is the security boundary.
config.connection :replica_readonly, database: "readonly", role: :reading
config.plugin :db
db.queryβsql(required),format(json|text|html, defaultjson). Runs inside a transaction with a per-query statement timeout (default 30s, override withstatement_timeout:on the connection). The timeout is enforced on PostgreSQL (statement_timeout) and MySQL (max_execution_time); SQLite has no per-statement timeout. Writes are rejected by the read-only DB role β the gem does not parse SQL (see Read-only is enforced by the database). Results are capped at 2000 rows by default β raise or lower it withconfig.plugin :db, max_rows: 5000, or remove the cap withmax_rows: nil(also acceptsfalseor:unlimited); when a query exceeds the cap the response is truncated and flagged ("truncated": true, "max_rows": N). Invalid SQL comes back as a tool error (isError) carrying the database's message β it never crashes the request or leaks a stack trace.db.tablesβ lists the table names in the read-only database.db.schemaβtable(required): the table's columns, primary key, indexes, and foreign keys.
Discovering the schema. Point the model at your
db/schema.rbordb/structure.sqlso it knows the tables and columns before querying β or let it calldb.tables/db.schemato introspect the live database directly.
Setting up the read-only connection for each database engine is covered in docs/read_only_connections.md.
Read-only is enforced by the database
db.query does not parse or sanitize SQL, and you should not treat Rails' own write-protection as a security boundary. Rails decides "is this a write?" with a leading-keyword check, so statements that start with a read keyword but modify data slip through it:
-- starts with WITH/SELECT, so Rails classifies it as a read β but it writes:
WITH gone AS (DELETE FROM users RETURNING *) SELECT count(*) FROM gone;
SELECT 1; UPDATE accounts SET balance = 0; -- stacked statement (PostgreSQL)
The only thing that reliably stops these is the database itself. Point the :reading connection at a genuinely read-only DB account β then a write fails at the server no matter how the SQL is shaped:
- PostgreSQL β create a role with no write grants and use it for the reader:
sql CREATE ROLE app_readonly LOGIN PASSWORD 'β¦'; GRANT CONNECT ON DATABASE app_production TO app_readonly; GRANT USAGE ON SCHEMA public TO app_readonly; GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO app_readonly; -- optional belt-and-suspenders: ALTER ROLE app_readonly SET default_transaction_read_only = on;β¦or simply point:readingat a physical read replica, which is read-only by construction. - MySQL β
GRANT SELECT ON app_production.* TO 'app_readonly'@'%';(grantSELECTonly), or use a replica.
Then declare that account as the reader and add the db plugin:
# database.yml has a `readonly` entry using the SELECT-only credentials above
config.connection :replica_readonly, database: "readonly", role: :reading
config.plugin :db
β οΈ Do not point
:readingat your writable primary. If the account can write, a crafted CTE or stacked statement will write β the gem cannot prevent it. The read-only role is the boundary; everything else is convenience.
Jobs (read-only)
Common metrics across job backends. Declare which adapter you run:
config.plugin :jobs, adapter: :sidekiq # or :solid_queue
jobs.queue_sizes,jobs.recent_jobs(limit, β€500),jobs.failed_jobs(limit, β€500),jobs.rate_metrics(windowseconds, default 1800).
Adapters return a stable shape regardless of backend. Boot fails if the adapter is unset, unknown, or its gem is missing.
Flipper
Read and toggle feature flags, globally or per actor.
config.connection :flipper_writer, database: "primary", role: :writing
config.plugin :flipper
flipper.list_flagsβ names of all configured flags.flipper.read_flagβ a flag's effective state plus its full per-gate configuration. Optionalactor_class+actor_idreads the state for that actor.flipper.enable_flag/flipper.disable_flagβ toggle a flag across a gate: global (default), an actor (actor_class+actor_id), a registeredgroup, or apercentagerollout (percentage_type:actorsortime). Each returns{ name, enabled, gate_type, gates }βgate_typeis the targeted gate (boolean,actor,group,percentage_of_actors,percentage_of_time) andgatesis the flag's full per-gate configuration.flipper.enabled_flagsβ the currently-enabled flags with their gates and last-change timestamps, for inspection. (Flipper stores no enable/disable history;updated_atis the last feature change, available with the ActiveRecord adapter.)
Declaring :flipper_writer is required (the gem refuses to boot without it) and documents that flag writes need a writable connection, kept separate from the DB plugin's read-only role. Flipper itself reads and writes through whatever adapter you configure for it (e.g. flipper-active_record); point that adapter at the same writable database.
Rake (allow-listed task runner)
Runs operator-approved rake tasks and returns their status and output.
config.plugin :rake, allowed: ["stats", "report:generate"]
config.plugin :rake, allowed: [...], timeout: 60 # per-task seconds, default 20
rake.runβtask(required, must be on theallowed:list),args(optional array of positional arguments βtask[arg1,arg2]). Returns JSON{ task, status, exit_code, output, error }. The task runs in a subprocess (bundle exec rake), so arguments cannot inject shell commands. A task that runs longer thantimeout:(default 20s) is hard-killed and returned as a tool error, so a hung task can't pin the web thread.
β οΈ Security. Rake tasks can do anything, so this plugin is fail-closed and allow-list-only: it refuses to boot without a non-empty
allowed:list, and refuses any task not on it. The allow-list is the security boundary β keep it tight and prefer read-only/reporting tasks. Combine withconfig.authorizeto scope it to specific principals.
Custom Tools
Write your own tools by subclassing TalkToYourApp::Tool β the same base class the bundled tools use, with typed arguments and writes allowed (unlike the read-only DB plugin). Drop one per file in app/talk_to_your_app/custom_tools/ and it's exposed automatically; no explicit registration.
config.plugin :custom_tools
Scaffold one with the generator (creates app/talk_to_your_app/custom_tools/<name>.rb, exposed as custom.<name>):
bin/rails generate talk_to_your_app:custom_tool MakeAdmin
# app/talk_to_your_app/custom_tools/make_admin.rb
class MakeAdmin < TalkToYourApp::Tool
name "custom.make_admin"
description "Grant admin to a user by id."
argument :user_id, :integer, required: true
def call(args, _ctx)
user = User.find(args[:user_id])
user.update!(admin: true)
json(id: user.id, admin: user.admin)
end
end
Namespaced generator paths are reflected in the tool name. For example,
bin/rails generate talk_to_your_app:custom_tool Admin/MakeAdmin creates
app/talk_to_your_app/custom_tools/admin/make_admin.rb and exposes
custom.admin.make_admin.
- The tool list is dynamic β one
Toolsubclass per file inapp/talk_to_your_app/custom_tools/, loaded automatically (restart to pick up new or edited tools). Only tools in that directory are exposed by this plugin;Toolsubclasses defined elsewhere are not. - Because custom tools can do anything the host app allows, including writes, enable them only when you trust the authenticated principals and scope with
config.authorize. Every call is still audit-logged.
Writing your own plugin
See docs/plugin_authoring.md for a full walkthrough. The short version:
class CacheStatsTool < TalkToYourApp::Tool
name "cache.stats"
description "Rails cache statistics."
def call(_args, _ctx)
json(Rails.cache.stats)
end
end
class CachePlugin < TalkToYourApp::Plugin
tools CacheStatsTool
end
TalkToYourApp.register_plugin(:cache, CachePlugin)
Then config.plugin :cache. Third-party plugins use the exact same DSL and lifecycle as the bundled ones.
Custom audit logging
Every tool invocation produces one audit record. There are two ways to consume it:
1. Swap the logger. config.logger accepts any object with a Logger interface; the gem writes one line per call to it at config.log_level.
2. Subscribe to the structured event (recommended for a durable, queryable trail). The gem emits an ActiveSupport::Notifications event β talk_to_your_app.tool_call β for every invocation, with a structured payload: ts, principal, ip, session_id, plugin, tool, params (redacted), outcome, duration_ms, and error_class (on failure). Subscribe and persist it to your own table:
# An Activity model: t.string :principal, :ip, :plugin, :tool, :outcome;
# t.text :params; t.float :duration_ms; t.timestamps
ActiveSupport::Notifications.subscribe("talk_to_your_app.tool_call") do |*args|
e = ActiveSupport::Notifications::Event.new(*args).payload
Activity.create!(
principal: e[:principal], # the authenticated key name / Basic username
ip: e[:ip], # client IP (from the request)
plugin: e[:plugin].to_s,
tool: e[:tool], # e.g. "db.query"
params: e[:params].to_json,
outcome: e[:outcome], # "success" | "error"
duration_ms: e[:duration_ms],
)
rescue => err
Rails.logger.warn("activity log failed: #{err.}") # never break the tool call
end
The client IP comes from the request; the principal is the authenticated identity (so per-user tokens give you per-user attribution). Sensitive arguments marked redact: true are already masked in the payload. Put the subscriber in an initializer. See test/dummy for a working Activity-table example surfaced on its home page.
Connecting an MCP client
The endpoint is Streamable HTTP at config.mount_at (default /mcp). Point any MCP client at https://your-app.example.com/mcp with an Authorization header (a per-user Bearer token or HTTP Basic). The snippets below cover Claude, Gemini CLI, and Codex CLI; config keys for the CLIs evolve, so check your version's docs if a key differs.
Claude Code β add the server with a header (--scope user makes it available across projects):
# Per-user API key (Bearer) β recommended
claude mcp add --transport http my-app https://your-app.example.com/mcp \
--header "Authorization: Bearer $TTYA_TOKEN"
# HTTP Basic instead
claude mcp add --transport http my-app https://your-app.example.com/mcp \
--header "Authorization: Basic $(printf 'user:pass' | base64)"
List with claude mcp list, inspect in a session with /mcp, and remove with claude mcp remove my-app.
Claude Desktop β add to claude_desktop_config.json (Settings β Developer β Edit Config) and restart:
{
"mcpServers": {
"my-app": {
"url": "https://your-app.example.com/mcp",
"headers": { "Authorization": "Bearer YOUR_TOKEN" }
}
}
}
(You can also add it through Settings β Connectors with the same URL and Authorization header.) Remove the server by deleting its entry and restarting.
Gemini CLI β add the server to ~/.gemini/settings.json (or a project .gemini/settings.json). Use httpUrl for the Streamable HTTP transport, with headers:
{
"mcpServers": {
"my-app": {
"httpUrl": "https://your-app.example.com/mcp",
"headers": { "Authorization": "Bearer YOUR_TOKEN" }
}
}
}
Recent Gemini CLI versions can also add it from the command line: gemini mcp add --transport http my-app https://your-app.example.com/mcp --header "Authorization: Bearer YOUR_TOKEN". Manage with gemini mcp list / gemini mcp remove my-app, and /mcp inside a session.
Codex CLI β add the server to ~/.codex/config.toml under [mcp_servers.<name>]. Recent Codex versions support Streamable HTTP servers directly:
[mcp_servers.my-app]
url = "https://your-app.example.com/mcp"
http_headers = { Authorization = "Bearer YOUR_TOKEN" }
You can also add it with codex mcp add. (If your Codex version only supports stdio MCP servers, point it at a stdioβHTTP bridge instead.)
For a local end-to-end walkthrough (run the bundled dummy app, connect a client, curl examples), see LOCAL_DEVELOPMENT.md.
Security model
- Fail-closed. Missing required config (auth, a required connection, a missing adapter gem) raises at boot, not at the first request.
- Per-tool database roles. Tools run on the connection their plugin declares, switched via Rails'
connected_to. The DB plugin can only use a:readingconnection. - One audit line per invocation through
Rails.logger: timestamp, principal, plugin, tool, params, outcome, duration. Mark sensitive argumentsredact: true. - What the gem does NOT do: no "execute arbitrary Ruby" tool; no writes through the DB plugin; no OAuth/JWT (static API keys and HTTP Basic only); no stdio transport; no web admin UI.
Compatibility
| Supported | |
|---|---|
| Ruby | 3.2+ |
| Rails | 7.1+ |
| MCP spec | 2025-11-25 (Streamable HTTP) |
MCP SDK (mcp gem) |
~> 0.18 |
Tested against Rails 7.1, 7.2, and 8.0 in CI.
Upgrade discipline
The mcp SDK is pre-1.0; minor releases may break. This gem pins ~> 0.18 and isolates all SDK touch points to the transport mount and tool compilation. Watch the SDK's releases before bumping, and pin it in your own Gemfile.lock.
Development
Working on the gem itself? See LOCAL_DEVELOPMENT.md for setup, how to run the test suite (and what PostgreSQL/Redis it optionally uses), and how to test against the Rails version matrix.
License
MIT. See LICENSE.