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, run health checks, 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.
  • 🧰 Six batteries-included plugins β€” db (read-only SQL + schema introspection), jobs (Sidekiq / Solid Queue metrics + health), flipper (feature flags), health (checks you register), rake (allow-listed tasks), and custom_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

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

  1. Mount it (see above) and boot the app. If the :replica_readonly connection were missing, boot would fail with a clear error.

  2. Point your MCP client (e.g. Claude Code) at http://localhost:3000/mcp with the bearer token TTYA_KEY.

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

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.authorize receives, 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.authorize { |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 ENV or Rails credentials, not source.
  • Use HTTPS in production so Bearer tokens aren't sent in clear text. Restrict config.allowed_origins for any browser-originated traffic.
  • Use high-entropy tokens (e.g. SecureRandom.hex(32) or has_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: true so 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, worker health config.plugin :jobs, adapter: :sidekiq
Flipper Read and toggle feature flags (global, actor, group, %) config.plugin :flipper
Health Checks Run checks you register β€” liveness, dependencies, queues config.plugin :health
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.

config.connection :replica_readonly, database: "primary", role: :reading
config.plugin :db
  • db.query β€” sql (required), format (json | text | html, default json). Runs inside a transaction with a per-query statement timeout (default 30s, override with statement_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. Results are capped at 2000 rows by default β€” raise or lower it with config.plugin :db, max_rows: 5000, or remove the cap with max_rows: nil (also accepts false or :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.rb or db/structure.sql so it knows the tables and columns before querying β€” or let it call db.tables / db.schema to introspect the live database directly.

Setting up the read-only connection for each database engine is covered in docs/read_only_connections.md.

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 (window seconds, default 1800).
  • jobs.health β€” worker/queue health: running processes, threads/concurrency, and pending/claimed counts. The shape is adapter-specific (Sidekiq reports processes + concurrency + set sizes; Solid Queue reports processes + pending/claimed/scheduled/failed), with an adapter key to branch on.

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. Optional actor_class + actor_id reads 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 registered group, or a percentage rollout (percentage_type: actors or time). Each returns { name, enabled, gate_type, gates } β€” gate_type is the targeted gate (boolean, actor, group, percentage_of_actors, percentage_of_time) and gates is 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_at is 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.

Health Checks

On-demand checks you register in Ruby β€” liveness, dependency reachability, queue depth, anything you can express. Each returns JSON (a Hash) with a status: plus whatever values you want to surface (or a bare true/false).

config.plugin :health

Scaffold a check with the generator (creates app/talk_to_your_app/health/<name>.rb):

bin/rails generate talk_to_your_app:health_check Database
# app/talk_to_your_app/health/database.rb
TalkToYourApp::Health.register(:database, description: "Primary DB connectivity and row counts") do
  {
    status:  :pass,
    adapter: ActiveRecord::Base.connection.adapter_name,
    users:   User.count,
    posts:   Post.count,
  }
end
  • health.list β€” the registered checks with their descriptions.
  • health.run (name) β€” runs one check and returns its result. A check that raises reports status: "error" rather than crashing the request. No scheduling, aggregation, or alerting β€” just on-demand evaluation.
  • Drop one check per file under app/talk_to_your_app/health/ and the plugin loads them automatically. Checks registered anywhere else at boot (e.g. an initializer) work too.
  • Files there load with require (not Zeitwerk-reloaded), so restart to pick up new or edited checks. A file that fails to load is logged and skipped β€” the others still register.

Rake (allow-listed task runner)

Runs operator-approved rake tasks and returns their status and output.

config.plugin :rake, allowed: ["stats", "report:generate"]
  • rake.run β€” task (required, must be on the allowed: 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.

⚠️ 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 with config.authorize to scope it to specific principals.

Custom Tools

Write your own tools by subclassing TalkToYourApp::CustomTool β€” the full Tool DSL, with typed arguments, and writes allowed (unlike the read-only DB plugin). Every subclass is 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::CustomTool
  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
  • The tool list is dynamic β€” whatever CustomTool subclasses are loaded. Files in app/talk_to_your_app/custom_tools/ load automatically (restart to pick up edits); tools defined elsewhere just need to be loaded at boot so they register before the endpoint is built.
  • 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.message}")  # 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 :reading connection.
  • One audit line per invocation through Rails.logger: timestamp, principal, plugin, tool, params, outcome, duration. Mark sensitive arguments redact: 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.