talk_to_your_app 0.1.0.pre.3 → 0.1.0.pre.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +137 -0
- data/LOCAL_DEVELOPMENT.md +9 -9
- data/README.md +119 -39
- data/SECURITY.md +56 -0
- data/docs/plugin_authoring.md +22 -11
- data/docs/read_only_connections.md +51 -25
- data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +68 -21
- data/lib/talk_to_your_app/auth/middleware.rb +16 -18
- data/lib/talk_to_your_app/configuration.rb +72 -15
- data/lib/talk_to_your_app/connection_registry.rb +8 -4
- data/lib/talk_to_your_app/plugin.rb +27 -10
- data/lib/talk_to_your_app/plugin_registry.rb +13 -0
- data/lib/talk_to_your_app/plugins/cache/plugin.rb +32 -0
- data/lib/talk_to_your_app/plugins/db/plugin.rb +41 -16
- data/lib/talk_to_your_app/plugins/db/tools/query.rb +17 -13
- data/lib/talk_to_your_app/plugins/db/tools/schema.rb +0 -3
- data/lib/talk_to_your_app/plugins/db/tools/tables.rb +4 -6
- data/lib/talk_to_your_app/plugins/flipper/plugin.rb +21 -3
- data/lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb +0 -1
- data/lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb +0 -1
- data/lib/talk_to_your_app/plugins/flipper/tools/enabled_flags.rb +0 -1
- data/lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb +0 -1
- data/lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb +0 -1
- data/lib/talk_to_your_app/plugins/jobs/plugin.rb +53 -28
- data/lib/talk_to_your_app/plugins/jobs/tools/base.rb +28 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/failed_jobs.rb +3 -4
- data/lib/talk_to_your_app/plugins/jobs/tools/queue_sizes.rb +4 -4
- data/lib/talk_to_your_app/plugins/jobs/tools/rate_metrics.rb +3 -4
- data/lib/talk_to_your_app/plugins/jobs/tools/recent_jobs.rb +3 -4
- data/lib/talk_to_your_app/railtie.rb +22 -2
- data/lib/talk_to_your_app/tool.rb +64 -15
- data/lib/talk_to_your_app/transport/rails_mount.rb +26 -2
- data/lib/talk_to_your_app/version.rb +1 -1
- data/lib/talk_to_your_app.rb +27 -5
- metadata +20 -19
- data/docs/brainstorms/talk-to-your-app-gem-v1-requirements.md +0 -158
- data/docs/plans/2026-06-01-001-feat-talk-to-your-app-gem-v1-plan.md +0 -772
- data/docs/residual-review-findings/fd33390.md +0 -19
- data/docs/solutions/architecture-patterns/mcp-ruby-sdk-rails-integration-2026-06-01.md +0 -180
- data/docs/solutions/runtime-errors/sidekiq-adapter-requires-sidekiq-api-2026-06-16.md +0 -76
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e8e8e799ed5e62176fe7360d42621c30839e07a5b1d4f36d4765c97d2af10db
|
|
4
|
+
data.tar.gz: a23bdf3c5ec3bda0ea5f63826d0627090044696f8f5234606d0e364387c7bfa0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b1cdf47aba4228d43bb6aa3d624bfb982fad55ae36b8dd2631a653b2ab77844b3c3199ea79a209361a5999153c3045a9e5a51bd7d249c03baa32fd5c8c8ada1
|
|
7
|
+
data.tar.gz: 623ee1d72a5806e0ef0ff5db152c67f3d389bc67a71af8ad0a6ce7be7fe1225da7c1f215a9afcddbaf4957e3e818f06c9ac94fee2296190c04b6b950ad93e742
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. This project follows
|
|
4
|
+
[Semantic Versioning](https://semver.org). Pre-1.0 minor releases may include
|
|
5
|
+
breaking changes.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0.pre.5] - 2026-07-18
|
|
10
|
+
|
|
11
|
+
Requires Ruby >= 3.3 and Rails >= 7.2 (the tested CI matrix: Ruby 3.3/4.0 ×
|
|
12
|
+
Rails 7.2/8.0/8.1); the `mcp` SDK is pinned `~> 0.25.0`.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- Cache plugin: `config.plugin :cache, connection: false` exposes `cache.clear`
|
|
16
|
+
(`Rails.cache.clear`). Destructive (drops every cached entry) — scope it to
|
|
17
|
+
trusted principals with `config.authorize`.
|
|
18
|
+
- `config.authorize` now receives the tool's arguments as a third parameter:
|
|
19
|
+
`{ |principal, tool_name, args| ... }` (symbol keys, before defaults are
|
|
20
|
+
applied). Enables per-flag/per-task/per-table authorization. Two-parameter
|
|
21
|
+
blocks keep working (Ruby blocks ignore extra arguments); an explicit lambda
|
|
22
|
+
passed via `authorize(&my_lambda)` must accept all three.
|
|
23
|
+
- Boot warns (does not fail) when plugins are enabled without `config.authorize`
|
|
24
|
+
— every authenticated principal can otherwise call every enabled tool.
|
|
25
|
+
- `SECURITY.md` with private reporting instructions and an operator misconfiguration
|
|
26
|
+
checklist. Packaged in the gem; linked from the README security model.
|
|
27
|
+
- `config.allowed_hosts` (default `[]`). Extra `Host` header values accepted by
|
|
28
|
+
the transport's DNS-rebinding protection, beyond the loopback defaults
|
|
29
|
+
(`127.0.0.1`, `::1`, `localhost`). Required for any non-loopback deployment
|
|
30
|
+
(endpoint served from a real domain) — without it the transport rejects every
|
|
31
|
+
request with "Forbidden: Invalid Host header". Forwarded to the mcp SDK's
|
|
32
|
+
`StreamableHTTPTransport(allowed_hosts:)`. The generated initializer defaults
|
|
33
|
+
it to `TalkToYourApp.rails_hosts` — the host app's own `config.hosts` (literal
|
|
34
|
+
host strings only; regexp/IPAddr/dotted-wildcard entries are skipped).
|
|
35
|
+
- `config.enabled` (default true). A global on/off switch: when false the
|
|
36
|
+
mounted endpoint serves `503` and boot validation is skipped, so an operator
|
|
37
|
+
can ship the initializer and disable it per-environment without the gem
|
|
38
|
+
refusing to boot on otherwise-incomplete configuration.
|
|
39
|
+
- Opt-in DB writes: wiring a `role: :writing` connection into the DB plugin
|
|
40
|
+
(`config.plugin :db, connection: :writer`) lets `db.query` execute writes. It
|
|
41
|
+
is read-only on a `:reading` connection (the default) and logs a loud warning
|
|
42
|
+
at boot on a writable one. `config.authorize` cannot distinguish reads from
|
|
43
|
+
writes, and full SQL is written to the audit log — scope the DB user and log
|
|
44
|
+
sinks accordingly (see the README).
|
|
45
|
+
- `config.stateless` (default false). When true the Streamable HTTP transport
|
|
46
|
+
runs stateless — every request is self-contained with no per-session state in
|
|
47
|
+
the transport — so any worker or replica can serve any request. Set it when
|
|
48
|
+
the host app runs more than one Puma/Unicorn worker, where a follow-up request
|
|
49
|
+
can otherwise land on a process that never saw `initialize` and fail with
|
|
50
|
+
"Session not found". Stateless mode does not support SSE streaming or
|
|
51
|
+
server-initiated notifications.
|
|
52
|
+
- `app/talk_to_your_app/` convention directory: custom tools live in
|
|
53
|
+
`custom_tools/` (one `TalkToYourApp::Tool` subclass per file, loaded by
|
|
54
|
+
`:custom_tools`). The gem ignores the tree in Zeitwerk and requires the files
|
|
55
|
+
itself. The custom-tool generator writes here.
|
|
56
|
+
- `rails g talk_to_your_app:custom_tool NAME` generator — scaffolds a
|
|
57
|
+
`TalkToYourApp::Tool` subclass in `app/talk_to_your_app/custom_tools/`,
|
|
58
|
+
exposed as `custom.<name>`.
|
|
59
|
+
- Rake plugin per-task timeout: `config.plugin :rake, allowed: [...], timeout: 60`
|
|
60
|
+
(seconds, default 20). A task exceeding it is hard-killed (process group) and
|
|
61
|
+
returned as a tool error, so a hung task can't pin the web thread.
|
|
62
|
+
- `Tool::Context#ip` exposes the request IP (from `Current.ip`), completing
|
|
63
|
+
parity with `#principal` and `#session_id` for custom tools.
|
|
64
|
+
- Per-principal tool authorization: `config.authorize { |principal, tool_name| ... }`.
|
|
65
|
+
- DB plugin statement timeout on MySQL (`max_execution_time`) in addition to
|
|
66
|
+
PostgreSQL. SQLite and MariaDB have no per-statement timeout (the read-only
|
|
67
|
+
role still applies).
|
|
68
|
+
- Flipper plugin: enable/disable across actor, group, and percentage
|
|
69
|
+
(`percentage_of_actors` / `percentage_of_time`) gates, plus a
|
|
70
|
+
`flipper.enabled_flags` tool that lists active flags with their gates and
|
|
71
|
+
last-change timestamps.
|
|
72
|
+
|
|
73
|
+
### Changed
|
|
74
|
+
- **BREAKING — minimum `mcp` SDK raised to `~> 0.25.0`.** 0.23 added `Host`-header
|
|
75
|
+
DNS-rebinding protection (`allowed_hosts:`); 0.24/0.25 add transport fixes
|
|
76
|
+
(SSE write synchronization, header normalization) with no further breaking
|
|
77
|
+
changes to this gem's SDK touch points. Earlier SDK versions reject
|
|
78
|
+
`allowed_hosts:` at boot.
|
|
79
|
+
- `config.allowed_origins` now works: it is forwarded to the mcp SDK transport,
|
|
80
|
+
which owns Origin validation (same-origin allowed, case-insensitive matching,
|
|
81
|
+
no-Origin non-browser clients allowed). Previously the gem's own middleware
|
|
82
|
+
rejected every cross-origin request regardless of the setting — the gem-side
|
|
83
|
+
check is removed in favor of the SDK's.
|
|
84
|
+
- Connections use `with_connection` inside `connected_to` (with explicit
|
|
85
|
+
`prevent_writes:` from the connection spec) so checkouts return to the pool
|
|
86
|
+
when the tool block ends.
|
|
87
|
+
- `401` `WWW-Authenticate` lists only the configured schemes (`Bearer`, `Basic`,
|
|
88
|
+
or both) instead of always advertising Bearer.
|
|
89
|
+
- Install generator: sets `config.stateless = true` in production; stronger
|
|
90
|
+
production comments for `enabled`, `authorize`, and the security checklist.
|
|
91
|
+
- Gem package includes `CHANGELOG.md` and `SECURITY.md`; ships only operator
|
|
92
|
+
docs (`docs/read_only_connections.md`, `docs/plugin_authoring.md`) rather than
|
|
93
|
+
internal plans/brainstorms.
|
|
94
|
+
- README security model updated for opt-in writable DB and the authorize warning.
|
|
95
|
+
- **BREAKING — the `:jobs` plugin is split into `:sidekiq` and `:solid_queue`.**
|
|
96
|
+
Enable the backend you run (or both, e.g. mid-migration):
|
|
97
|
+
`config.plugin :sidekiq, connection: false`. The `adapter:` option is gone.
|
|
98
|
+
Tools are now adapter-namespaced — `sidekiq.queue_sizes` / `solid_queue.queue_sizes`
|
|
99
|
+
(etc.) instead of `jobs.queue_sizes` — so both backends can be exposed at once.
|
|
100
|
+
- **BREAKING — every plugin must declare `connection:`.** Pass a declared
|
|
101
|
+
connection name, or `connection: false` to opt out (`:sidekiq`, `:solid_queue`,
|
|
102
|
+
`:rake`, and connection-less `:custom_tools` use `false`). Enabling any plugin
|
|
103
|
+
without the option fails at boot. `:db` and `:flipper` require a real
|
|
104
|
+
connection (`connection: false` is rejected).
|
|
105
|
+
- **BREAKING — connections are wired into plugins by name.** Plugins no longer
|
|
106
|
+
hardcode a connection name; you declare connections and wire one into each
|
|
107
|
+
plugin that needs a database: `config.plugin :db, connection: :readonly` and
|
|
108
|
+
`config.plugin :flipper, connection: :writer`. Enabling `:db` or `:flipper`
|
|
109
|
+
without `connection:` now fails at boot with an actionable error. The per-tool
|
|
110
|
+
hardcoded connection names were removed. `config.connection`'s `role:` now
|
|
111
|
+
defaults to `:reading`. **Existing initializers must add `connection:` to
|
|
112
|
+
`config.plugin :db`/`:flipper`.** **Plugin authors:** the `requires_connection`
|
|
113
|
+
DSL and the `Plugin.required_connections` alias are removed entirely — the
|
|
114
|
+
framework now enforces `connection:` universally (see above), so plugins no
|
|
115
|
+
longer mark themselves. `Tool::Context#connection_name` is now a no-arg reader
|
|
116
|
+
(it reports the connection the call ran on); pass an explicit override to
|
|
117
|
+
`ctx.connection(:name)`, not to `connection_name`. Connection resolution is
|
|
118
|
+
most-specific-first: an explicit `ctx.connection(:name)` arg, then the tool's
|
|
119
|
+
own static `connection` DSL, then the plugin-wired `connection:` default. A
|
|
120
|
+
tool that declares its own connection is no longer overridden by the
|
|
121
|
+
plugin-wired one (custom tools), and a tool's declared connection is now
|
|
122
|
+
validated at boot, not at first call.
|
|
123
|
+
- **BREAKING — Flipper enforces `role: :writing` at boot.** A Flipper connection
|
|
124
|
+
declared `role: :reading` previously booted and failed only on the first write;
|
|
125
|
+
it now fails closed at boot.
|
|
126
|
+
- **Jobs adapter contract:** the `required_gem` hash key was renamed from
|
|
127
|
+
`name:` to `gem_name:` (e.g. `{ const: "Sidekiq", gem_name: "sidekiq" }`),
|
|
128
|
+
matching the Plugin DSL's `requires_gem` option. **Third-party jobs adapters
|
|
129
|
+
must update their `REQUIRED_GEM`/`required_gem` to use `gem_name:`** — with the
|
|
130
|
+
old `name:` key the boot-time gem check still runs but its error message shows
|
|
131
|
+
a blank gem name.
|
|
132
|
+
- **Jobs adapter response shape:** `enqueued_at` is now an ISO-8601 string
|
|
133
|
+
across all adapters, and every job hash carries the same keys (`jid`, `class`,
|
|
134
|
+
`queue`, `args`, `enqueued_at`, `error_message`), with `error_message` nil for
|
|
135
|
+
jobs that have not failed.
|
|
136
|
+
- **Flipper enable/disable response shape:** now `{ name, enabled, gate_type,
|
|
137
|
+
gates }` (previously `{ name, enabled, actor }`), consistent with `read_flag`.
|
data/LOCAL_DEVELOPMENT.md
CHANGED
|
@@ -4,11 +4,11 @@ How to work on the `talk_to_your_app` gem and run its test suite.
|
|
|
4
4
|
|
|
5
5
|
## Prerequisites
|
|
6
6
|
|
|
7
|
-
- **Ruby** >= 3.
|
|
7
|
+
- **Ruby** >= 3.3 (CI runs 3.3 and 4.0).
|
|
8
8
|
- **Bundler** (`gem install bundler`).
|
|
9
9
|
- **SQLite** — the default test database; the `sqlite3` gem is bundled, no server needed.
|
|
10
10
|
- **PostgreSQL** — _optional_ for the test suite (DB-plugin read-only-role and timeout tests skip without it) but **required to run the dummy app as a local MCP server** (see below). A running server on `localhost:5432` reachable as the `postgres` superuser (DBngin, Postgres.app, Homebrew, or Docker all work).
|
|
11
|
-
- **Redis** _(optional)_ — exercises the
|
|
11
|
+
- **Redis** _(optional)_ — exercises the Sidekiq plugin. A running server on `localhost:6379`.
|
|
12
12
|
|
|
13
13
|
Tests that need PostgreSQL or Redis **skip cleanly** when the service is not reachable, so `bundle exec rake test` always runs; you just get fewer assertions without the services.
|
|
14
14
|
|
|
@@ -52,7 +52,7 @@ bundle exec rake test TEST=test/talk_to_your_app/configuration_test.rb TESTOPTS=
|
|
|
52
52
|
|
|
53
53
|
## Testing against multiple Rails versions
|
|
54
54
|
|
|
55
|
-
The gem supports Rails 7.
|
|
55
|
+
The gem supports Rails 7.2, 8.0, and 8.1 via [Appraisal](https://github.com/thoughtbot/appraisal). The version matrix lives in `Appraisals`.
|
|
56
56
|
|
|
57
57
|
```sh
|
|
58
58
|
# Generate/install the per-version gemfiles under gemfiles/ (needs network)
|
|
@@ -117,11 +117,11 @@ The MCP endpoint is now at **`http://localhost:3000/mcp`**.
|
|
|
117
117
|
that user's name. (Restart the server after seeding new users so their
|
|
118
118
|
tokens are picked up.)
|
|
119
119
|
- **Root page:** `http://localhost:3000/` shows DB stats and the per-user tokens.
|
|
120
|
-
- **Plugins enabled:** DB, **
|
|
120
|
+
- **Plugins enabled:** DB, **Solid Queue**, **Flipper**, and **Rake** (allow-listed tasks `demo:stats`, `demo:echo`).
|
|
121
121
|
- **Tools available:** `db.query` (read-only SQL over `users` / `posts` /
|
|
122
122
|
`comments` / `widgets`), `db.tables`, `db.schema` (columns/indexes/FKs);
|
|
123
|
-
`
|
|
124
|
-
`
|
|
123
|
+
`solid_queue.queue_sizes` / `solid_queue.recent_jobs` /
|
|
124
|
+
`solid_queue.failed_jobs` / `solid_queue.rate_metrics`; `flipper.list_flags` /
|
|
125
125
|
`read_flag` / `enable_flag` / `disable_flag` / `enabled_flags`; `rake.run`
|
|
126
126
|
(allow-listed `demo:stats` and `demo:echo[message]`); and custom tools
|
|
127
127
|
`custom.make_admin` / `custom.toggle_active` (which write user state).
|
|
@@ -142,7 +142,7 @@ cd test/dummy
|
|
|
142
142
|
RAILS_ENV=development bundle exec ruby bin/jobs
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
-
Watch the queue with the `
|
|
145
|
+
Watch the queue with the `solid_queue.*` MCP tools (or `bin/rails console`).
|
|
146
146
|
|
|
147
147
|
### 3. Connect Claude Code
|
|
148
148
|
|
|
@@ -257,7 +257,7 @@ lib/talk_to_your_app/ # the gem
|
|
|
257
257
|
auth/ # Bearer/Basic middleware + validators
|
|
258
258
|
transport/rails_mount.rb # builds the MCP::Server + Rack app
|
|
259
259
|
audit_logger.rb # one log line per tool call
|
|
260
|
-
plugins/ # db, jobs, flipper, rake, custom_tools
|
|
260
|
+
plugins/ # db, jobs (sidekiq + solid_queue), flipper, rake, custom_tools
|
|
261
261
|
lib/generators/talk_to_your_app/ # install, custom_tool generators
|
|
262
262
|
test/
|
|
263
263
|
dummy/ # minimal Rails app for integration tests
|
|
@@ -281,4 +281,4 @@ Writing a plugin uses the same public DSL as the bundled ones — see
|
|
|
281
281
|
`test/support/mcp_driver.rb` (the `initialize` handshake → `tools/list` →
|
|
282
282
|
`tools/call`).
|
|
283
283
|
- Keep SDK touch points isolated to `transport/rails_mount.rb` and tool
|
|
284
|
-
compilation; the `mcp` gem is pinned `~> 0.
|
|
284
|
+
compilation; the `mcp` gem is pinned `~> 0.25.0` (see the README upgrade note).
|
data/README.md
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
**Let an AI agent talk to your running Rails app — safely.** `talk_to_your_app` mounts a [Model Context Protocol](https://modelcontextprotocol.io) 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.
|
|
4
4
|
|
|
5
|
+
[](docs/demo.mp4)
|
|
6
|
+
|
|
7
|
+
> ▶️ **[Watch the 20-second demo](docs/demo.mp4)** — setup, live `db.query`, and read-only enforcement.
|
|
8
|
+
|
|
5
9
|
It's a thin, Rails-native layer over the official [MCP Ruby SDK](https://github.com/modelcontextprotocol/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.
|
|
6
10
|
|
|
7
11
|
**What you get**
|
|
8
12
|
|
|
9
13
|
- 🔌 **A Streamable HTTP MCP endpoint in two lines** — `mount TalkToYourApp.rack_app`, and you're live.
|
|
10
14
|
- 🔒 **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.
|
|
11
|
-
- 🧰 **
|
|
15
|
+
- 🧰 **Batteries-included plugins** — `db` (read-only SQL + schema introspection), `sidekiq` and `solid_queue` (background-job metrics), `flipper` (feature flags), `rake` (allow-listed tasks), `cache` (clear the Rails cache), and `custom_tools` (your own tools, with a generator).
|
|
12
16
|
- 📝 **Every call audit-logged** — principal, IP, params, outcome, duration. Subscribe to persist your own trail.
|
|
13
17
|
- ✍️ **A Ruby DSL + generators** for writing first-class tools of your own in a few lines.
|
|
14
18
|
|
|
@@ -39,16 +43,16 @@ mount TalkToYourApp.rack_app, at: TalkToYourApp.configuration.mount_at
|
|
|
39
43
|
```ruby
|
|
40
44
|
TalkToYourApp.configure do |config|
|
|
41
45
|
config.api_keys = { "my-agent" => ENV.fetch("TTYA_KEY") }
|
|
42
|
-
config.connection :
|
|
43
|
-
config.plugin :db
|
|
46
|
+
config.connection :readonly, database: "primary" # role: defaults to :reading
|
|
47
|
+
config.plugin :db, connection: :readonly
|
|
44
48
|
end
|
|
45
49
|
```
|
|
46
50
|
|
|
47
51
|
`"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.
|
|
48
52
|
|
|
49
|
-
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](docs/read_only_connections.md) for step-by-step setup on PostgreSQL, MySQL, and SQLite.
|
|
53
|
+
You declare connections by name and **wire one into each plugin** that needs a database (`connection: :readonly`). `role:` defaults to `:reading`. 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](docs/read_only_connections.md) for step-by-step setup on PostgreSQL, MySQL, and SQLite.
|
|
50
54
|
|
|
51
|
-
2. **Mount it** (see above) and boot the app. If
|
|
55
|
+
2. **Mount it** (see above) and boot the app. If you enable `:db` without wiring a `connection:`, or name one you never declared, boot fails with a clear error.
|
|
52
56
|
|
|
53
57
|
3. **Point your MCP client** (e.g. Claude Code) at `http://localhost:3000/mcp` with the bearer token `TTYA_KEY`.
|
|
54
58
|
|
|
@@ -94,6 +98,7 @@ Every option lives inside `TalkToYourApp.configure`:
|
|
|
94
98
|
|
|
95
99
|
| Option | Description |
|
|
96
100
|
| --- | --- |
|
|
101
|
+
| `config.enabled` | Global on/off switch. When `false`, the endpoint serves `503` and boot validation is skipped, so you can ship the initializer and disable per-environment. Default `true`. |
|
|
97
102
|
| `config.mount_at` | Path the endpoint serves from. Default `"/mcp"`. |
|
|
98
103
|
| `config.server_name` | Server name in the `initialize` handshake. Default `"talk_to_your_app"`. |
|
|
99
104
|
| `config.server_title` | Human-friendly server title shown to clients (optional). |
|
|
@@ -102,10 +107,12 @@ Every option lives inside `TalkToYourApp.configure`:
|
|
|
102
107
|
| `config.instructions` | Guidance shown to the LLM on how to use this server's tools (optional). |
|
|
103
108
|
| `config.api_keys` | `{ "principal-name" => "secret" }`. The name is logged as the principal. Multiple keys supported for rotation. |
|
|
104
109
|
| `config.basic_auth { \|user, pass\| ... }` | HTTP Basic callable returning truthy to authenticate. Wire it to your own user model. |
|
|
105
|
-
| `config.authorize { \|principal, tool\| ... }` | Optional per-principal tool authorization. Returns truthy to allow. Without it, any authenticated principal may call any enabled tool. |
|
|
106
|
-
| `config.allowed_origins` |
|
|
107
|
-
| `config.
|
|
108
|
-
| `config.
|
|
110
|
+
| `config.authorize { \|principal, tool, args\| ... }` | Optional per-principal tool authorization. Returns truthy to allow. `args` is the tool's argument hash (symbol keys), so you can authorize per flag/task/argument, not just per tool — two-parameter blocks keep working. Without it, any authenticated principal may call any enabled tool. |
|
|
111
|
+
| `config.allowed_origins` | Cross-origin browser `Origin` values to allow (DNS-rebinding protection, enforced by the MCP SDK transport). Requests without an `Origin` header (non-browser clients) and same-origin requests always pass; matching is case-insensitive. |
|
|
112
|
+
| `config.allowed_hosts` | Extra `Host` header values accepted by the transport (DNS-rebinding protection), beyond the always-allowed loopback defaults (`127.0.0.1`, `::1`, `localhost`). A non-loopback deployment **must** list its host here or every request is rejected with "Forbidden: Invalid Host header". Each entry matches a bare host (any port) or full `host:port`. The generated initializer defaults this to `TalkToYourApp.rails_hosts` (your app's own `config.hosts`, literal strings only — add wildcard/regexp hosts explicitly). |
|
|
113
|
+
| `config.stateless` | Run the Streamable HTTP transport stateless — every request is self-contained, so any worker or replica can serve it. **Set to `true` whenever the app runs more than one Puma/Unicorn worker or replica** (i.e. most production deployments): with the default stateful mode, a follow-up request can land on a process that never saw `initialize` and fail with **"Session not found"**. Stateless mode does not support SSE streaming or server-initiated notifications. Default `false`. |
|
|
114
|
+
| `config.connection :name, database:, role:, replica:, statement_timeout:` | Declares a named connection. `role:` defaults to `:reading`; pass `:writing` for a writer. |
|
|
115
|
+
| `config.plugin :name, connection: :conn, **opts` | Enables a plugin (off by default). **Every plugin must declare `connection:`** — a declared connection name, or `connection: false` to opt out (e.g. `:sidekiq`, `:rake`, `:custom_tools` that don't read a wired SQL connection). |
|
|
109
116
|
| `config.logger` | Audit logger. Defaults to `Rails.logger`. Swappable to any Logger-compatible object. |
|
|
110
117
|
| `config.log_level` | Global audit level (default `:info`); overridable per plugin. |
|
|
111
118
|
|
|
@@ -128,6 +135,10 @@ TalkToYourApp.configure do |config|
|
|
|
128
135
|
|
|
129
136
|
# Optionally scope what each principal may call.
|
|
130
137
|
config.authorize { |principal, tool| principal == "ci-readonly-bot" ? tool.start_with?("db.") : true }
|
|
138
|
+
|
|
139
|
+
# The block also receives the tool's arguments (third parameter), so you can
|
|
140
|
+
# authorize per flag/task/argument, not just per tool:
|
|
141
|
+
# config.authorize { |_p, tool, args| tool != "flipper.enable_flag" || args[:name] != "require_2fa" }
|
|
131
142
|
end
|
|
132
143
|
```
|
|
133
144
|
|
|
@@ -157,25 +168,27 @@ All plugins are **off by default** — enable them explicitly, and an agent can
|
|
|
157
168
|
|
|
158
169
|
| Plugin | What an agent can do | Enable with |
|
|
159
170
|
| --- | --- | --- |
|
|
160
|
-
| [DB](#db) | Run read-only
|
|
161
|
-
| [
|
|
162
|
-
| [
|
|
163
|
-
| [
|
|
164
|
-
| [
|
|
171
|
+
| [DB](#db) | Run SQL (read-only by default); introspect tables, columns, indexes, FKs | `config.plugin :db, connection: :readonly` |
|
|
172
|
+
| [Sidekiq](#jobs-read-only) | Read Sidekiq queue sizes, recent/failed jobs, rates | `config.plugin :sidekiq, connection: false` |
|
|
173
|
+
| [Solid Queue](#jobs-read-only) | Read Solid Queue queue sizes, recent/failed jobs, rates | `config.plugin :solid_queue, connection: false` |
|
|
174
|
+
| [Flipper](#flipper) | Read and toggle feature flags (global, actor, group, %) | `config.plugin :flipper, connection: :writer` |
|
|
175
|
+
| [Rake](#rake-allow-listed-task-runner) | Run allow-listed rake tasks and read their output | `config.plugin :rake, connection: false, allowed: [...]` |
|
|
176
|
+
| [Cache](#cache) | Clear the Rails cache | `config.plugin :cache, connection: false` |
|
|
177
|
+
| [Custom Tools](#custom-tools) | Call tools you write yourself (writes allowed) | `config.plugin :custom_tools, connection: false` |
|
|
165
178
|
|
|
166
179
|
### DB
|
|
167
180
|
|
|
168
|
-
A single read-only
|
|
181
|
+
A single SQL tool — read-only by default.
|
|
169
182
|
|
|
170
183
|
```ruby
|
|
171
184
|
# `database:` must map to a SELECT-only DB user or a replica — not your
|
|
172
185
|
# writable primary. That read-only role is the security boundary.
|
|
173
|
-
config.connection :
|
|
174
|
-
config.plugin :db
|
|
186
|
+
config.connection :readonly, database: "readonly" # role: defaults to :reading
|
|
187
|
+
config.plugin :db, connection: :readonly
|
|
175
188
|
```
|
|
176
189
|
|
|
177
|
-
- **`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. **
|
|
178
|
-
- **`db.tables`** — lists the table names in the
|
|
190
|
+
- **`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. **On a `:reading` connection (the default) writes are rejected by the read-only DB role** — the gem does not parse SQL (see [Read-only is enforced by the database](#read-only-is-enforced-by-the-database)). Results are capped at **2000 rows by default** — raise or lower it with `config.plugin :db, connection: :readonly, 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.
|
|
191
|
+
- **`db.tables`** — lists the table names in the database.
|
|
179
192
|
- **`db.schema`** — `table` (required): the table's columns, primary key, indexes, and foreign keys.
|
|
180
193
|
|
|
181
194
|
> **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.
|
|
@@ -210,23 +223,47 @@ Then declare that account as the reader and add the `db` plugin:
|
|
|
210
223
|
|
|
211
224
|
```ruby
|
|
212
225
|
# database.yml has a `readonly` entry using the SELECT-only credentials above
|
|
213
|
-
config.connection :
|
|
214
|
-
config.plugin :db
|
|
226
|
+
config.connection :readonly, database: "readonly" # role: defaults to :reading
|
|
227
|
+
config.plugin :db, connection: :readonly
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
> ⚠️ Do **not** point a `:reading` connection at 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.
|
|
231
|
+
|
|
232
|
+
#### Running `:db` against a writable connection
|
|
233
|
+
|
|
234
|
+
`db.query` runs read-only as shipped. If you deliberately wire a **`:writing`** connection, `db.query` can execute arbitrary writes — `UPDATE`, `INSERT`, `DELETE`, even DDL:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
config.connection :writer, database: "primary", role: :writing
|
|
238
|
+
config.plugin :db, connection: :writer # db.query can now write — logs a loud boot warning
|
|
215
239
|
```
|
|
216
240
|
|
|
217
|
-
|
|
241
|
+
This is a deliberate, two-step opt-in (declare a `role: :writing` connection **and** wire it in); the gem logs a warning at boot but does not stop you. Before enabling it, understand what it does **not** give you:
|
|
242
|
+
|
|
243
|
+
- **`config.authorize` cannot distinguish reads from writes.** The tool is always `"db.query"`, so an authorizer like `tool.start_with?("db.")` permits `SELECT` and `DELETE` identically. The real access control is the **database user's privileges** — grant only what the writes you intend require.
|
|
244
|
+
- **Full SQL, including literal values, is written to the audit log.** Write statements with sensitive literals (`UPDATE users SET token = '…'`) appear in every audit destination. Scope or filter your log sinks accordingly.
|
|
245
|
+
- The gem still does **not** parse SQL. Nothing inspects or restricts the statement beyond the connection's role.
|
|
246
|
+
|
|
247
|
+
Prefer a read-only connection unless you specifically need writes.
|
|
218
248
|
|
|
219
249
|
### Jobs (read-only)
|
|
220
250
|
|
|
221
|
-
|
|
251
|
+
Background-job metrics, as one plugin per backend — `:sidekiq` and
|
|
252
|
+
`:solid_queue`. Enable whichever you run (or both, e.g. mid-migration). The
|
|
253
|
+
backends read Redis / their own tables, not a wired SQL connection, so enable
|
|
254
|
+
them with `connection: false`:
|
|
222
255
|
|
|
223
256
|
```ruby
|
|
224
|
-
config.plugin :
|
|
257
|
+
config.plugin :sidekiq, connection: false
|
|
258
|
+
config.plugin :solid_queue, connection: false # both may run at once
|
|
225
259
|
```
|
|
226
260
|
|
|
227
|
-
|
|
261
|
+
Each plugin exposes four adapter-namespaced tools:
|
|
262
|
+
|
|
263
|
+
- **Sidekiq** — `sidekiq.queue_sizes`, `sidekiq.recent_jobs` (`limit`, ≤500), `sidekiq.failed_jobs` (`limit`, ≤500), `sidekiq.rate_metrics` (`window` seconds, default 1800).
|
|
264
|
+
- **Solid Queue** — `solid_queue.queue_sizes`, `solid_queue.recent_jobs`, `solid_queue.failed_jobs`, `solid_queue.rate_metrics` (same arguments).
|
|
228
265
|
|
|
229
|
-
|
|
266
|
+
Both backends return the same response shape. Boot fails if the backend's gem is missing.
|
|
230
267
|
|
|
231
268
|
### Flipper
|
|
232
269
|
|
|
@@ -234,9 +271,11 @@ Read and toggle feature flags, globally or per actor.
|
|
|
234
271
|
|
|
235
272
|
```ruby
|
|
236
273
|
config.connection :flipper_writer, database: "primary", role: :writing
|
|
237
|
-
config.plugin :flipper
|
|
274
|
+
config.plugin :flipper, connection: :flipper_writer
|
|
238
275
|
```
|
|
239
276
|
|
|
277
|
+
Flipper writes flag state, so it requires a connection declared `role: :writing` — boot fails on a `:reading` connection.
|
|
278
|
+
|
|
240
279
|
- **`flipper.list_flags`** — names of all configured flags.
|
|
241
280
|
- **`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.
|
|
242
281
|
- **`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.
|
|
@@ -244,25 +283,38 @@ config.plugin :flipper
|
|
|
244
283
|
|
|
245
284
|
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.
|
|
246
285
|
|
|
286
|
+
> ⚠️ Enabling the Flipper write tools lets any permitted principal toggle **every** flag. To restrict security-sensitive flags, use the authorizer's `args` parameter — e.g. `config.authorize { |_p, tool, args| !tool.start_with?("flipper.enable", "flipper.disable") || %w[new_dashboard beta_search].include?(args[:name]) }`.
|
|
287
|
+
|
|
247
288
|
### Rake (allow-listed task runner)
|
|
248
289
|
|
|
249
290
|
Runs operator-approved rake tasks and returns their status and output.
|
|
250
291
|
|
|
251
292
|
```ruby
|
|
252
|
-
config.plugin :rake, allowed: ["stats", "report:generate"]
|
|
253
|
-
config.plugin :rake, allowed: [...], timeout: 60 # per-task seconds, default 20
|
|
293
|
+
config.plugin :rake, connection: false, allowed: ["stats", "report:generate"]
|
|
294
|
+
config.plugin :rake, connection: false, allowed: [...], timeout: 60 # per-task seconds, default 20
|
|
254
295
|
```
|
|
255
296
|
|
|
256
297
|
- **`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. A task that runs longer than `timeout:` (default 20s) is hard-killed and returned as a tool error, so a hung task can't pin the web thread.
|
|
257
298
|
|
|
258
299
|
> ⚠️ **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.
|
|
259
300
|
|
|
301
|
+
### Cache
|
|
302
|
+
|
|
303
|
+
One destructive-but-recoverable tool: wipe the Rails cache.
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
config.plugin :cache, connection: false
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
- **`cache.clear`** — calls `Rails.cache.clear` and returns `{ cleared, store }`. Every cached entry is dropped and the app re-warms from cold, so scope it to trusted principals: `config.authorize { |principal, tool, _args| tool != "cache.clear" || principal == "admin" }`.
|
|
310
|
+
|
|
260
311
|
### Custom Tools
|
|
261
312
|
|
|
262
313
|
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.
|
|
263
314
|
|
|
264
315
|
```ruby
|
|
265
|
-
config.plugin :custom_tools
|
|
316
|
+
config.plugin :custom_tools, connection: false # tools call ctx.connection(:name) explicitly
|
|
317
|
+
config.plugin :custom_tools, connection: :read # ...or wire a default; ctx.connection (no arg) uses it
|
|
266
318
|
```
|
|
267
319
|
|
|
268
320
|
Scaffold one with the generator (creates `app/talk_to_your_app/custom_tools/<name>.rb`, exposed as `custom.<name>`):
|
|
@@ -408,30 +460,58 @@ For a local end-to-end walkthrough (run the bundled dummy app, connect a client,
|
|
|
408
460
|
|
|
409
461
|
## Security model
|
|
410
462
|
|
|
411
|
-
- **Fail-closed.** Missing required config (auth, a required connection, a missing adapter gem) raises at boot, not at the first request.
|
|
412
|
-
- **Per-
|
|
413
|
-
- **One audit line per invocation** through `Rails.logger`: timestamp, principal, plugin, tool, params, outcome, duration. Mark sensitive arguments `redact: true`.
|
|
414
|
-
- **What the gem does NOT do:** no "execute arbitrary Ruby" tool; no
|
|
463
|
+
- **Fail-closed.** Missing required config (auth, a required connection, a missing adapter gem) raises at boot, not at the first request. Enabling plugins without `config.authorize` boots with a warning — every authenticated principal can then call every enabled tool.
|
|
464
|
+
- **Per-plugin database roles.** Tools run on the connection their plugin wires, switched via Rails' `connected_to`. The DB plugin defaults to a `:reading` connection (Rails `prevent_writes` + your DB grants). Wiring a `:writing` connection is an explicit opt-in that lets `db.query` execute writes — the **database role / replica is the real write boundary**, not SQL parsing in the gem. See [Read-only is enforced by the database](#read-only-is-enforced-by-the-database).
|
|
465
|
+
- **One audit line per invocation** through `Rails.logger`: timestamp, principal, plugin, tool, params, outcome, duration. Mark sensitive arguments `redact: true`. Full SQL is logged as submitted.
|
|
466
|
+
- **What the gem does NOT do:** no "execute arbitrary Ruby" tool; no OAuth/JWT (static API keys and HTTP Basic only); no stdio transport; no web admin UI; no built-in rate limits. Network exposure, TLS, and DB grants are operator-owned — see [SECURITY.md](SECURITY.md).
|
|
467
|
+
|
|
468
|
+
## Troubleshooting
|
|
469
|
+
|
|
470
|
+
**"Forbidden: Invalid Host header" on every request.** The transport's DNS-rebinding protection only accepts loopback hosts (`127.0.0.1`, `::1`, `localhost`) by default. Any deployment served from a real domain must list it:
|
|
471
|
+
|
|
472
|
+
```ruby
|
|
473
|
+
config.allowed_hosts = TalkToYourApp.rails_hosts # your app's own config.hosts…
|
|
474
|
+
config.allowed_hosts += ["app.example.com"] # …or add it explicitly
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Entries match a bare host (any port) or a full `host:port`. Wildcard and regexp hosts from Rails' `config.hosts` are deliberately not forwarded — add those as literal strings.
|
|
478
|
+
|
|
479
|
+
**"Session not found" after the first request.** The default transport is stateful: the session created by `initialize` lives in one process's memory. With more than one Puma/Unicorn worker (or multiple replicas), the next request can land on a process that never saw it. Turn on stateless mode:
|
|
480
|
+
|
|
481
|
+
```ruby
|
|
482
|
+
config.stateless = true # required for multi-worker/multi-replica deployments
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
**Boot fails with a `ConfigurationError`.** That's the fail-closed contract working: the message names exactly what's missing (auth not configured, a plugin without `connection:`, a connection naming a missing `database.yml` key). Fix the named thing; nothing fails silently at request time.
|
|
486
|
+
|
|
487
|
+
**A write "slipped through" `db.query`.** The Rails `role: :reading` layer is best-effort and bypassable (data-modifying CTEs, stacked statements). The database account is the real boundary — see [docs/read_only_connections.md](docs/read_only_connections.md).
|
|
415
488
|
|
|
416
489
|
## Compatibility
|
|
417
490
|
|
|
418
491
|
| | Supported |
|
|
419
492
|
| --- | --- |
|
|
420
|
-
| Ruby | 3.
|
|
421
|
-
| Rails | 7.
|
|
493
|
+
| Ruby | 3.3+ |
|
|
494
|
+
| Rails | 7.2+ |
|
|
422
495
|
| MCP spec | 2025-11-25 (Streamable HTTP) |
|
|
423
|
-
| MCP SDK (`mcp` gem) | `~> 0.
|
|
496
|
+
| MCP SDK (`mcp` gem) | `~> 0.25.0` |
|
|
424
497
|
|
|
425
|
-
Tested against Rails 7.
|
|
498
|
+
Tested against Rails 7.2, 8.0, and 8.1 on Ruby 3.3 and 4.0 in CI.
|
|
426
499
|
|
|
427
500
|
## Upgrade discipline
|
|
428
501
|
|
|
429
|
-
The `mcp` SDK is pre-1.0; minor releases may break. This gem pins `~> 0.
|
|
502
|
+
The `mcp` SDK is pre-1.0; minor releases may break. This gem pins `~> 0.25.0` 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`.
|
|
503
|
+
|
|
504
|
+
This gem is also pre-1.0 — releases may include breaking changes, each documented with migration steps in the **[CHANGELOG](CHANGELOG.md)**.
|
|
430
505
|
|
|
431
506
|
## Development
|
|
432
507
|
|
|
433
508
|
Working on the gem itself? See **[LOCAL_DEVELOPMENT.md](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.
|
|
434
509
|
|
|
510
|
+
## Security
|
|
511
|
+
|
|
512
|
+
See **[SECURITY.md](SECURITY.md)** for how to report vulnerabilities and the
|
|
513
|
+
production misconfiguration checklist.
|
|
514
|
+
|
|
435
515
|
## License
|
|
436
516
|
|
|
437
517
|
MIT. See [LICENSE](LICENSE).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
This gem is pre-1.0. Security fixes land on the latest prerelease / release on
|
|
6
|
+
[RubyGems](https://rubygems.org/gems/talk_to_your_app) and on `main`. Older
|
|
7
|
+
prereleases are not patched.
|
|
8
|
+
|
|
9
|
+
## Reporting a vulnerability
|
|
10
|
+
|
|
11
|
+
Please report security issues privately via GitHub Security Advisories on
|
|
12
|
+
[igorkasyanchuk/talk_to_your_app](https://github.com/igorkasyanchuk/talk_to_your_app/security/advisories/new),
|
|
13
|
+
or email **igor@everai.ai**.
|
|
14
|
+
|
|
15
|
+
Do not open a public issue for vulnerabilities that could let an unauthenticated
|
|
16
|
+
or under-authorized caller reach tools, bypass auth, or escalate privileges.
|
|
17
|
+
|
|
18
|
+
Include:
|
|
19
|
+
|
|
20
|
+
- Affected gem version
|
|
21
|
+
- A minimal reproduction (or clear description)
|
|
22
|
+
- Impact (e.g. unauthenticated access, write via `db.query`, secret leakage)
|
|
23
|
+
|
|
24
|
+
You should hear back within a few business days. We will coordinate disclosure
|
|
25
|
+
once a fix is available.
|
|
26
|
+
|
|
27
|
+
## Operator checklist (misconfiguration is the common failure mode)
|
|
28
|
+
|
|
29
|
+
This gem mounts an authenticated MCP control surface on your Rails app. A valid
|
|
30
|
+
API key can use every enabled plugin unless you scope it. Before production:
|
|
31
|
+
|
|
32
|
+
1. **Use HTTPS** — Bearer / Basic credentials must not travel in clear text.
|
|
33
|
+
2. **High-entropy, per-principal keys** from `ENV` or credentials — never a
|
|
34
|
+
shared forever-secret in source.
|
|
35
|
+
3. **Set `config.authorize`** — without it, every authenticated principal may
|
|
36
|
+
call every enabled tool. Boot logs a warning when it is missing.
|
|
37
|
+
4. **DB plugin** — point `:reading` connections at a **SELECT-only** database
|
|
38
|
+
role or a physical replica, never your writable primary credentials. The gem
|
|
39
|
+
does not parse SQL; the database role is the write boundary. See
|
|
40
|
+
[docs/read_only_connections.md](docs/read_only_connections.md).
|
|
41
|
+
5. **Multi-worker / multi-replica** — set `config.stateless = true`.
|
|
42
|
+
6. **`config.allowed_hosts`** — list every non-loopback host that serves the
|
|
43
|
+
endpoint (the install generator defaults this to `TalkToYourApp.rails_hosts`).
|
|
44
|
+
7. **Enable the minimum plugins** — treat `:sidekiq` / `:solid_queue` job args,
|
|
45
|
+
`:flipper` writes, `:rake`, and `:cache` as sensitive; scope with `authorize`.
|
|
46
|
+
8. **Audit sinks** — full SQL (and tool params) may appear in logs; filter or
|
|
47
|
+
retain accordingly.
|
|
48
|
+
|
|
49
|
+
## Threat model (summary)
|
|
50
|
+
|
|
51
|
+
- **In scope for the gem:** fail-closed boot, request authentication, optional
|
|
52
|
+
per-tool authorization, Host/Origin DNS-rebinding controls (via the MCP SDK),
|
|
53
|
+
audit logging, and refusing to expose plugins that are not explicitly enabled.
|
|
54
|
+
- **Out of scope / operator-owned:** network exposure, TLS termination, database
|
|
55
|
+
grants, Redis/job payload sensitivity, LLM prompt injection against data the
|
|
56
|
+
agent is allowed to read, and custom tools you author.
|