talk_to_your_app 0.1.0.pre.2 → 0.1.0.pre.4

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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/LOCAL_DEVELOPMENT.md +284 -0
  3. data/README.md +130 -61
  4. data/docs/brainstorms/talk-to-your-app-gem-v1-requirements.md +158 -0
  5. data/docs/plans/2026-06-01-001-feat-talk-to-your-app-gem-v1-plan.md +772 -0
  6. data/docs/plans/2026-06-18-001-feat-connection-wiring-and-enabled-flag-plan.md +363 -0
  7. data/docs/plugin_authoring.md +113 -0
  8. data/docs/read_only_connections.md +195 -0
  9. data/docs/residual-review-findings/fd33390.md +19 -0
  10. data/docs/solutions/architecture-patterns/mcp-ruby-sdk-rails-integration-2026-06-01.md +180 -0
  11. data/docs/solutions/runtime-errors/sidekiq-adapter-requires-sidekiq-api-2026-06-16.md +76 -0
  12. data/lib/generators/talk_to_your_app/custom_tool/custom_tool_generator.rb +4 -4
  13. data/lib/generators/talk_to_your_app/custom_tool/templates/tool.rb.tt +10 -4
  14. data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +43 -13
  15. data/lib/talk_to_your_app/configuration.rb +53 -9
  16. data/lib/talk_to_your_app/plugin.rb +27 -10
  17. data/lib/talk_to_your_app/plugin_registry.rb +13 -0
  18. data/lib/talk_to_your_app/plugins/custom_tools/plugin.rb +18 -9
  19. data/lib/talk_to_your_app/plugins/db/plugin.rb +41 -16
  20. data/lib/talk_to_your_app/plugins/db/tools/query.rb +17 -13
  21. data/lib/talk_to_your_app/plugins/db/tools/schema.rb +0 -3
  22. data/lib/talk_to_your_app/plugins/db/tools/tables.rb +4 -6
  23. data/lib/talk_to_your_app/plugins/flipper/plugin.rb +31 -3
  24. data/lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb +4 -3
  25. data/lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb +4 -3
  26. data/lib/talk_to_your_app/plugins/flipper/tools/enabled_flags.rb +0 -1
  27. data/lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb +2 -1
  28. data/lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb +4 -1
  29. data/lib/talk_to_your_app/plugins/jobs/adapters/sidekiq.rb +12 -30
  30. data/lib/talk_to_your_app/plugins/jobs/adapters/solid_queue.rb +0 -25
  31. data/lib/talk_to_your_app/plugins/jobs/interface.rb +1 -7
  32. data/lib/talk_to_your_app/plugins/jobs/plugin.rb +53 -29
  33. data/lib/talk_to_your_app/plugins/jobs/tools/base.rb +28 -0
  34. data/lib/talk_to_your_app/plugins/jobs/tools/failed_jobs.rb +3 -4
  35. data/lib/talk_to_your_app/plugins/jobs/tools/queue_sizes.rb +4 -4
  36. data/lib/talk_to_your_app/plugins/jobs/tools/rate_metrics.rb +3 -4
  37. data/lib/talk_to_your_app/plugins/jobs/tools/recent_jobs.rb +3 -4
  38. data/lib/talk_to_your_app/plugins/rake/plugin.rb +13 -0
  39. data/lib/talk_to_your_app/plugins/rake/tools/run.rb +63 -3
  40. data/lib/talk_to_your_app/railtie.rb +13 -12
  41. data/lib/talk_to_your_app/tool.rb +97 -14
  42. data/lib/talk_to_your_app/transport/rails_mount.rb +25 -2
  43. data/lib/talk_to_your_app/version.rb +1 -1
  44. data/lib/talk_to_your_app.rb +37 -13
  45. metadata +15 -13
  46. data/lib/generators/talk_to_your_app/health_check/health_check_generator.rb +0 -31
  47. data/lib/generators/talk_to_your_app/health_check/templates/check.rb.tt +0 -12
  48. data/lib/talk_to_your_app/custom_tool.rb +0 -40
  49. data/lib/talk_to_your_app/plugins/health/plugin.rb +0 -31
  50. data/lib/talk_to_your_app/plugins/health/registry.rb +0 -68
  51. data/lib/talk_to_your_app/plugins/health/tools/list_checks.rb +0 -24
  52. data/lib/talk_to_your_app/plugins/health/tools/run_check.rb +0 -27
  53. data/lib/talk_to_your_app/plugins/jobs/tools/health.rb +0 -25
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10204bc4a2149e7f2f78edd74990df717777df92e8312cd5d71f4d628a66e732
4
- data.tar.gz: 32bfa44be84cd987c93e0553328126dbca1f1efb0f13d15b62d8cecdbb4a5456
3
+ metadata.gz: 968cb02f102a1c4e238a3305a9b67f9b00642e3da99c30ccf9ece00799251547
4
+ data.tar.gz: 8d87ffd983498a0636b3e3d88b465262fa7781e0e534325cd96ef3c06ee15c28
5
5
  SHA512:
6
- metadata.gz: 8b1ed1bfd86c608f3fd8867b2c376ca92068f05044506291a034e2636da306656ee218a1bf0d9cd974d6eb1879256d6dc47db4c0d8e4ddd0b3ac1bd17440bddf
7
- data.tar.gz: 630827c1779170f29c971d12310cf4f77ceb51f1504e160140bf6013d90214f4d892a949a9aa04f79c00c9781b053f926ac6a523c57a56a9b2cb267d0145073e
6
+ metadata.gz: e6dae56486a9660bc76769312aeb9c7a0f42de81b15c0b409d084962f4e3ea20600c9b8eb2670d267c6621885c3aa0e3c5ab02b471747662dbc74f9bc179571a
7
+ data.tar.gz: 7c13b0d2eb1110fd16ff11e224b5be5fb73a8cb5330913d3310d561064efe8af537d0d03e5a0997bf52b27628f53c0d0a1775fbd7c24a26a7d690cd97b312910
@@ -0,0 +1,284 @@
1
+ # Local development
2
+
3
+ How to work on the `talk_to_your_app` gem and run its test suite.
4
+
5
+ ## Prerequisites
6
+
7
+ - **Ruby** >= 3.2 (CI runs 3.2 and 3.3).
8
+ - **Bundler** (`gem install bundler`).
9
+ - **SQLite** — the default test database; the `sqlite3` gem is bundled, no server needed.
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 Jobs plugin's Sidekiq adapter. A running server on `localhost:6379`.
12
+
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
+
15
+ ## Setup
16
+
17
+ ```sh
18
+ git clone https://github.com/igorkasyanchuk/talk_to_your_app.git
19
+ cd talk_to_your_app
20
+ bundle install
21
+ ```
22
+
23
+ ## Running tests
24
+
25
+ The suite is Minitest, driven by Rake.
26
+
27
+ ```sh
28
+ # Everything
29
+ bundle exec rake test
30
+
31
+ # A single file (Rake)
32
+ bundle exec rake test TEST=test/talk_to_your_app/configuration_test.rb
33
+
34
+ # A single file (plain Ruby — fastest feedback)
35
+ bundle exec ruby -Itest -Ilib test/talk_to_your_app/configuration_test.rb
36
+
37
+ # Filter by test name within a file
38
+ bundle exec rake test TEST=test/talk_to_your_app/configuration_test.rb TESTOPTS="--name=/mount_at/"
39
+ ```
40
+
41
+ ### What the suite touches
42
+
43
+ - **SQLite** — the dummy app's primary database, in-memory, set up automatically by `test/test_helper.rb`.
44
+ - **PostgreSQL** — `test/support/pg_test_db.rb` connects as the `postgres` superuser and (re)creates a `ttya_test` database plus a genuinely read-only role (`ttya_ro`, `GRANT SELECT` only) on each run. Nothing to set up by hand; if Postgres is unreachable, the DB-plugin tests skip.
45
+ - **Redis** — the Sidekiq adapter tests use database **15** at `redis://localhost:6379/15` and flush it between tests. Override with `TTYA_TEST_REDIS_URL` if your Redis lives elsewhere:
46
+
47
+ ```sh
48
+ TTYA_TEST_REDIS_URL=redis://localhost:6380/15 bundle exec rake test
49
+ ```
50
+
51
+ - **MySQL/MariaDB** — the DB plugin has a MySQL statement-timeout path, but it is not exercised locally (no `mysql2` in the bundle). Its SQL is unit-tested without a server in `test/talk_to_your_app/plugins/db/timeout_statement_test.rb`.
52
+
53
+ ## Testing against multiple Rails versions
54
+
55
+ The gem supports Rails 7.1, 7.2, and 8.0 via [Appraisal](https://github.com/thoughtbot/appraisal). The version matrix lives in `Appraisals`.
56
+
57
+ ```sh
58
+ # Generate/install the per-version gemfiles under gemfiles/ (needs network)
59
+ bundle exec appraisal install
60
+
61
+ # Run the suite against one Rails version
62
+ bundle exec appraisal rails-8.0 bundle exec rake test
63
+
64
+ # …or all of them
65
+ bundle exec appraisal rake test
66
+ ```
67
+
68
+ CI runs the full Rails × Ruby matrix (see `.github/workflows/ci.yml`).
69
+
70
+ ## Running the dummy app as a local MCP server (and connecting Claude)
71
+
72
+ The bundled `test/dummy` app can run as a real MCP server so you can point an MCP
73
+ client such as **Claude Code** or **Claude Desktop** at it and exercise the gem
74
+ end to end. In `development` it auto-configures **HTTP Basic auth** and enables
75
+ the bundled plugins (see `test/dummy/config/initializers/talk_to_your_app.rb`).
76
+
77
+ It runs on **PostgreSQL** so the DB plugin can connect through a genuine
78
+ read-only database user — the same pattern you'd use in production. You need a
79
+ local Postgres reachable as the `postgres` superuser (see Prerequisites).
80
+
81
+ It ships `User`, `Post`, and `Comment` models (with associations) so there's real
82
+ relational data to query.
83
+
84
+ ### 1. One-time database setup
85
+
86
+ ```sh
87
+ cd test/dummy
88
+ RAILS_ENV=development bundle exec ruby bin/rails db:prepare
89
+ ```
90
+
91
+ `db:prepare` creates `ttya_dummy_development`, loads the schema, seeds sample data
92
+ (users Alice/Bob, their posts and comments, plus a `widgets` table), **and
93
+ provisions a read-only Postgres role** `ttya_dummy_ro` (`GRANT SELECT` only).
94
+ The DB plugin's `:replica_readonly` connection authenticates as that role against
95
+ the **same database**, so writes are rejected by Postgres itself — not just by
96
+ Rails. Override any of the names with `TTYA_DEV_DB_*` / `TTYA_DEV_RO_*` env vars.
97
+
98
+ Re-run `RAILS_ENV=development bundle exec ruby bin/rails db:seed` any time to
99
+ re-grant or top up data; explore the models with
100
+ `RAILS_ENV=development bundle exec ruby bin/rails console`.
101
+
102
+ ### 2. Start the server
103
+
104
+ ```sh
105
+ # from test/dummy
106
+ RAILS_ENV=development bundle exec ruby bin/rails server -p 3000
107
+ ```
108
+
109
+ The MCP endpoint is now at **`http://localhost:3000/mcp`**.
110
+
111
+ - **Auth (two options):**
112
+ - **HTTP Basic** — username `dev` / password `secret` (override with
113
+ `TTYA_DEV_USER` / `TTYA_DEV_PASSWORD`); the username is the logged principal.
114
+ - **Per-user Bearer token** — each seeded user has an `api_token`. Open
115
+ <http://localhost:3000/> to see the users and their tokens, and send
116
+ `Authorization: Bearer <token>` — the audit log then attributes calls to
117
+ that user's name. (Restart the server after seeding new users so their
118
+ tokens are picked up.)
119
+ - **Root page:** `http://localhost:3000/` shows DB stats and the per-user tokens.
120
+ - **Plugins enabled:** DB, **Jobs** (Solid Queue adapter), **Flipper**, and **Rake** (allow-listed tasks `demo:stats`, `demo:echo`).
121
+ - **Tools available:** `db.query` (read-only SQL over `users` / `posts` /
122
+ `comments` / `widgets`), `db.tables`, `db.schema` (columns/indexes/FKs);
123
+ `jobs.queue_sizes` / `jobs.recent_jobs` /
124
+ `jobs.failed_jobs` / `jobs.rate_metrics`; `flipper.list_flags` /
125
+ `read_flag` / `enable_flag` / `disable_flag` / `enabled_flags`; `rake.run`
126
+ (allow-listed `demo:stats` and `demo:echo[message]`); and custom tools
127
+ `custom.make_admin` / `custom.toggle_active` (which write user state).
128
+ - **Seed data also includes** 3 feature flags (`new_dashboard` on,
129
+ `beta_search` 25% of actors, `dark_mode` 10% of the time) and a few queued
130
+ `HeartbeatJob`s, so the Flipper and Jobs tools have something to show
131
+ immediately.
132
+ - Audit log lines stream to the server's stdout, one per tool call.
133
+
134
+ ### Background jobs (Solid Queue)
135
+
136
+ `db:prepare` loads Solid Queue's tables and enqueues a few jobs. To actually
137
+ process them — and run the **`HeartbeatJob` every minute** (`config/recurring.yml`)
138
+ — start the Solid Queue worker in a second terminal:
139
+
140
+ ```sh
141
+ cd test/dummy
142
+ RAILS_ENV=development bundle exec ruby bin/jobs
143
+ ```
144
+
145
+ Watch the queue with the `jobs.*` MCP tools (or `bin/rails console`).
146
+
147
+ ### 3. Connect Claude Code
148
+
149
+ Add the server with an `Authorization` header — either the Basic credential
150
+ (`base64("dev:secret")` = `ZGV2OnNlY3JldA==`) or a per-user Bearer token copied
151
+ from <http://localhost:3000/>:
152
+
153
+ ```sh
154
+ # Basic
155
+ claude mcp add --transport http talk-to-your-app http://localhost:3000/mcp \
156
+ --header "Authorization: Basic ZGV2OnNlY3JldA=="
157
+
158
+ # or a per-user token (audit log attributes calls to that user)
159
+ claude mcp add --transport http talk-to-your-app http://localhost:3000/mcp \
160
+ --header "Authorization: Bearer <token-from-the-home-page>"
161
+ ```
162
+
163
+ Then in a Claude Code session: `/mcp` lists the server, and you can ask it to
164
+ run a tool — e.g. *"use talk-to-your-app's db.query to count comments per user"*.
165
+
166
+ ### 4. Connect Claude Desktop
167
+
168
+ Add an entry to your `claude_desktop_config.json` (Settings → Developer → Edit
169
+ Config) and restart the app:
170
+
171
+ ```json
172
+ {
173
+ "mcpServers": {
174
+ "talk-to-your-app": {
175
+ "url": "http://localhost:3000/mcp",
176
+ "headers": { "Authorization": "Basic ZGV2OnNlY3JldA==" }
177
+ }
178
+ }
179
+ }
180
+ ```
181
+
182
+ (Or add it through Settings → Connectors with the same URL and `Authorization`
183
+ header.)
184
+
185
+ ### Removing the server
186
+
187
+ - **Claude Code:** `claude mcp remove talk-to-your-app` (confirm with `claude mcp list`).
188
+ - **Claude Desktop:** delete the `talk-to-your-app` entry from
189
+ `claude_desktop_config.json` and restart the app.
190
+ - **Stop the dummy server:** Ctrl-C the `rails server` process. To wipe the demo
191
+ database, drop it (this also drops the `ttya_test` DB used by the suite, which
192
+ the tests recreate automatically):
193
+ `RAILS_ENV=development bundle exec ruby bin/rails db:drop`.
194
+
195
+ ### 5. Verify without a client (curl)
196
+
197
+ MCP is stateful: `initialize` first to get an `Mcp-Session-Id`, then send it plus
198
+ the protocol version on each call.
199
+
200
+ ```sh
201
+ AUTH="Authorization: Basic ZGV2OnNlY3JldA=="
202
+
203
+ # initialize → read the Mcp-Session-Id response header
204
+ curl -i -sS -X POST http://localhost:3000/mcp -H "$AUTH" \
205
+ -H "Content-Type: application/json" -H "Accept: application/json" \
206
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
207
+
208
+ # then call a tool (substitute the session id)
209
+ curl -sS -X POST http://localhost:3000/mcp -H "$AUTH" \
210
+ -H "Content-Type: application/json" -H "Accept: application/json" \
211
+ -H "Mcp-Session-Id: <id-from-above>" -H "MCP-Protocol-Version: 2025-11-25" \
212
+ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"db.query","arguments":{"sql":"SELECT u.name, p.title, COUNT(c.id) AS comments FROM users u JOIN posts p ON p.user_id = u.id LEFT JOIN comments c ON c.post_id = p.id GROUP BY u.name, p.title ORDER BY u.name","format":"text"}}}'
213
+ ```
214
+
215
+ An unauthenticated request returns `401`; a write (`INSERT`/`UPDATE`/`DELETE`)
216
+ returns a tool error — Postgres rejects it because `db.query` connects as the
217
+ `ttya_dummy_ro` SELECT-only role.
218
+
219
+ ### Generating the initializer in your own app
220
+
221
+ In a host application, `bundle add talk_to_your_app` then run the install
222
+ generator to drop a commented initializer at
223
+ `config/initializers/talk_to_your_app.rb`:
224
+
225
+ ```sh
226
+ bin/rails generate talk_to_your_app:install
227
+ ```
228
+
229
+ The dummy app's initializer is a worked example of the same configuration.
230
+
231
+ ## Debugging
232
+
233
+ The [`debug`](https://github.com/ruby/debug) gem is a dev/test dependency. Drop a
234
+ breakpoint anywhere and run the test:
235
+
236
+ ```ruby
237
+ require "debug"
238
+ # ...
239
+ binding.break # or: debugger
240
+ ```
241
+
242
+ ```sh
243
+ bundle exec ruby -Itest -Ilib test/talk_to_your_app/configuration_test.rb
244
+ ```
245
+
246
+ Execution stops at the breakpoint with an interactive console (`c` to continue,
247
+ `n` next, `s` step, `info` for locals).
248
+
249
+ ## Project layout
250
+
251
+ ```
252
+ lib/talk_to_your_app/ # the gem
253
+ configuration.rb # TalkToYourApp.configure surface
254
+ connection_registry.rb # fail-closed named connections + role switching
255
+ tool.rb / plugin.rb # the Tool & Plugin DSLs
256
+ plugin_registry.rb # module-level plugin registry
257
+ auth/ # Bearer/Basic middleware + validators
258
+ transport/rails_mount.rb # builds the MCP::Server + Rack app
259
+ audit_logger.rb # one log line per tool call
260
+ plugins/ # db, jobs, flipper, rake, custom_tools
261
+ lib/generators/talk_to_your_app/ # install, custom_tool generators
262
+ test/
263
+ dummy/ # minimal Rails app for integration tests
264
+ support/ # test helpers (mcp_driver, pg_test_db, …)
265
+ integration/ # full MCP HTTP round-trip tests
266
+ talk_to_your_app/ # unit tests mirroring lib/
267
+ ```
268
+
269
+ ## Working on a plugin or tool
270
+
271
+ Writing a plugin uses the same public DSL as the bundled ones — see
272
+ [docs/plugin_authoring.md](docs/plugin_authoring.md). The bundled plugins under
273
+ `lib/talk_to_your_app/plugins/` are good references; each has unit tests under
274
+ `test/talk_to_your_app/plugins/` and an end-to-end test under `test/integration/`.
275
+
276
+ ## Conventions
277
+
278
+ - Every Ruby file starts with `# frozen_string_literal: true`.
279
+ - No linter is configured; match the surrounding style.
280
+ - New behavior ships with tests. Integration tests drive the real MCP stack via
281
+ `test/support/mcp_driver.rb` (the `initialize` handshake → `tools/list` →
282
+ `tools/call`).
283
+ - Keep SDK touch points isolated to `transport/rails_mount.rb` and tool
284
+ compilation; the `mcp` gem is pinned `~> 0.18` (see the README upgrade note).
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # talk_to_your_app
2
2
 
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, 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.
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
+
5
+ [![Watch the 20-second demo](docs/demo_poster.png)](docs/demo.mp4)
6
+
7
+ > ▶️ **[Watch the 20-second demo](docs/demo.mp4)** — setup, live `db.query`, and read-only enforcement.
4
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
 
@@ -8,7 +12,7 @@ It's a thin, Rails-native layer over the official [MCP Ruby SDK](https://github.
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
- - 🧰 **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).
15
+ - 🧰 **Batteries-included plugins** — `db` (read-only SQL + schema introspection), `sidekiq` and `solid_queue` (background-job metrics), `flipper` (feature flags), `rake` (allow-listed tasks), 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 :replica_readonly, database: "primary", role: :reading
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 the `:replica_readonly` connection were missing, boot would fail with a clear error.
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
 
@@ -62,12 +66,39 @@ mount TalkToYourApp.rack_app, at: TalkToYourApp.configuration.mount_at
62
66
 
63
67
  Rows come back as JSON, plain text, or an HTML table — the agent picks what it needs.
64
68
 
69
+ ## Try it locally
70
+
71
+ Want to see it end to end before wiring it into your own app? The bundled
72
+ `test/dummy` app runs as a real MCP server with HTTP Basic auth, the bundled
73
+ plugins, and seeded data. From a clone of this repo (needs a local PostgreSQL —
74
+ see [LOCAL_DEVELOPMENT.md](LOCAL_DEVELOPMENT.md)):
75
+
76
+ ```sh
77
+ cd test/dummy
78
+ RAILS_ENV=development bundle exec ruby bin/rails db:prepare # creates the DB + a read-only role, seeds data
79
+ RAILS_ENV=development bundle exec ruby bin/rails server -p 3000 # MCP endpoint at http://localhost:3000/mcp
80
+ ```
81
+
82
+ Then connect Claude Code with one command (Basic auth `dev` / `secret`, where
83
+ `ZGV2OnNlY3JldA==` is `base64("dev:secret")`):
84
+
85
+ ```sh
86
+ claude mcp add --transport http talk-to-your-app http://localhost:3000/mcp \
87
+ --header "Authorization: Basic ZGV2OnNlY3JldA=="
88
+ ```
89
+
90
+ Run `/mcp` in a Claude Code session to list the tools — e.g. *"use
91
+ talk-to-your-app's db.query to count comments per user."* The full walkthrough
92
+ (per-user tokens, background jobs, curl examples) is in
93
+ **[LOCAL_DEVELOPMENT.md](LOCAL_DEVELOPMENT.md)**.
94
+
65
95
  ## Configuration reference
66
96
 
67
97
  Every option lives inside `TalkToYourApp.configure`:
68
98
 
69
99
  | Option | Description |
70
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`. |
71
102
  | `config.mount_at` | Path the endpoint serves from. Default `"/mcp"`. |
72
103
  | `config.server_name` | Server name in the `initialize` handshake. Default `"talk_to_your_app"`. |
73
104
  | `config.server_title` | Human-friendly server title shown to clients (optional). |
@@ -78,8 +109,9 @@ Every option lives inside `TalkToYourApp.configure`:
78
109
  | `config.basic_auth { \|user, pass\| ... }` | HTTP Basic callable returning truthy to authenticate. Wire it to your own user model. |
79
110
  | `config.authorize { \|principal, tool\| ... }` | Optional per-principal tool authorization. Returns truthy to allow. Without it, any authenticated principal may call any enabled tool. |
80
111
  | `config.allowed_origins` | Origins permitted for browser requests (DNS-rebinding protection). Empty allows non-browser clients. |
81
- | `config.connection :name, database:, role:, replica:, statement_timeout:` | Declares a named connection. `role:` is `:reading` or `:writing`. |
82
- | `config.plugin :name, **opts` | Enables a plugin (off by default). |
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.connection :name, database:, role:, replica:, statement_timeout:` | Declares a named connection. `role:` defaults to `:reading`; pass `:writing` for a writer. |
114
+ | `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). |
83
115
  | `config.logger` | Audit logger. Defaults to `Rails.logger`. Swappable to any Logger-compatible object. |
84
116
  | `config.log_level` | Global audit level (default `:info`); overridable per plugin. |
85
117
 
@@ -131,108 +163,140 @@ All plugins are **off by default** — enable them explicitly, and an agent can
131
163
 
132
164
  | Plugin | What an agent can do | Enable with |
133
165
  | --- | --- | --- |
134
- | [DB](#db) | Run read-only SQL; introspect tables, columns, indexes, FKs | `config.plugin :db` |
135
- | [Jobs](#jobs-read-only) | Read queue sizes, recent/failed jobs, rates, worker health | `config.plugin :jobs, adapter: :sidekiq` |
136
- | [Flipper](#flipper) | Read and toggle feature flags (global, actor, group, %) | `config.plugin :flipper` |
137
- | [Health Checks](#health-checks) | Run checks you register liveness, dependencies, queues | `config.plugin :health` |
138
- | [Rake](#rake-allow-listed-task-runner) | Run allow-listed rake tasks and read their output | `config.plugin :rake, allowed: [...]` |
139
- | [Custom Tools](#custom-tools) | Call tools you write yourself (writes allowed) | `config.plugin :custom_tools` |
166
+ | [DB](#db) | Run SQL (read-only by default); introspect tables, columns, indexes, FKs | `config.plugin :db, connection: :readonly` |
167
+ | [Sidekiq](#jobs-read-only) | Read Sidekiq queue sizes, recent/failed jobs, rates | `config.plugin :sidekiq, connection: false` |
168
+ | [Solid Queue](#jobs-read-only) | Read Solid Queue queue sizes, recent/failed jobs, rates | `config.plugin :solid_queue, connection: false` |
169
+ | [Flipper](#flipper) | Read and toggle feature flags (global, actor, group, %) | `config.plugin :flipper, connection: :writer` |
170
+ | [Rake](#rake-allow-listed-task-runner) | Run allow-listed rake tasks and read their output | `config.plugin :rake, connection: false, allowed: [...]` |
171
+ | [Custom Tools](#custom-tools) | Call tools you write yourself (writes allowed) | `config.plugin :custom_tools, connection: false` |
140
172
 
141
173
  ### DB
142
174
 
143
- A single read-only SQL tool.
175
+ A single SQL tool — read-only by default.
144
176
 
145
177
  ```ruby
146
- config.connection :replica_readonly, database: "primary", role: :reading
147
- config.plugin :db
178
+ # `database:` must map to a SELECT-only DB user or a replica — not your
179
+ # writable primary. That read-only role is the security boundary.
180
+ config.connection :readonly, database: "readonly" # role: defaults to :reading
181
+ config.plugin :db, connection: :readonly
148
182
  ```
149
183
 
150
- - **`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.
151
- - **`db.tables`** — lists the table names in the read-only database.
184
+ - **`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.
185
+ - **`db.tables`** — lists the table names in the database.
152
186
  - **`db.schema`** — `table` (required): the table's columns, primary key, indexes, and foreign keys.
153
187
 
154
188
  > **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.
155
189
 
156
190
  Setting up the read-only connection for each database engine is covered in **[docs/read_only_connections.md](docs/read_only_connections.md)**.
157
191
 
158
- ### Jobs (read-only)
192
+ #### Read-only is enforced by the database
159
193
 
160
- Common metrics across job backends. Declare which adapter you run:
194
+ `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:
161
195
 
162
- ```ruby
163
- config.plugin :jobs, adapter: :sidekiq # or :solid_queue
196
+ ```sql
197
+ -- starts with WITH/SELECT, so Rails classifies it as a read — but it writes:
198
+ WITH gone AS (DELETE FROM users RETURNING *) SELECT count(*) FROM gone;
199
+ SELECT 1; UPDATE accounts SET balance = 0; -- stacked statement (PostgreSQL)
164
200
  ```
165
201
 
166
- - **`jobs.queue_sizes`**, **`jobs.recent_jobs`** (`limit`, ≤500), **`jobs.failed_jobs`** (`limit`, ≤500), **`jobs.rate_metrics`** (`window` seconds, default 1800).
167
- - **`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.
202
+ 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:
168
203
 
169
- Adapters return a stable shape regardless of backend. Boot fails if the adapter is unset, unknown, or its gem is missing.
204
+ - **PostgreSQL** create a role with no write grants and use it for the reader:
205
+ ```sql
206
+ CREATE ROLE app_readonly LOGIN PASSWORD '…';
207
+ GRANT CONNECT ON DATABASE app_production TO app_readonly;
208
+ GRANT USAGE ON SCHEMA public TO app_readonly;
209
+ GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly;
210
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO app_readonly;
211
+ -- optional belt-and-suspenders: ALTER ROLE app_readonly SET default_transaction_read_only = on;
212
+ ```
213
+ …or simply point `:reading` at a **physical read replica**, which is read-only by construction.
214
+ - **MySQL** — `GRANT SELECT ON app_production.* TO 'app_readonly'@'%';` (grant `SELECT` only), or use a replica.
170
215
 
171
- ### Flipper
216
+ Then declare that account as the reader and add the `db` plugin:
172
217
 
173
- Read and toggle feature flags, globally or per actor.
218
+ ```ruby
219
+ # database.yml has a `readonly` entry using the SELECT-only credentials above
220
+ config.connection :readonly, database: "readonly" # role: defaults to :reading
221
+ config.plugin :db, connection: :readonly
222
+ ```
223
+
224
+ > ⚠️ 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.
225
+
226
+ #### Running `:db` against a writable connection
227
+
228
+ `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:
174
229
 
175
230
  ```ruby
176
- config.connection :flipper_writer, database: "primary", role: :writing
177
- config.plugin :flipper
231
+ config.connection :writer, database: "primary", role: :writing
232
+ config.plugin :db, connection: :writer # db.query can now write — logs a loud boot warning
178
233
  ```
179
234
 
180
- - **`flipper.list_flags`** names of all configured flags.
181
- - **`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.
182
- - **`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.
183
- - **`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.)
235
+ 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:
184
236
 
185
- 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.
237
+ - **`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.
238
+ - **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.
239
+ - The gem still does **not** parse SQL. Nothing inspects or restricts the statement beyond the connection's role.
186
240
 
187
- ### Health Checks
241
+ Prefer a read-only connection unless you specifically need writes.
188
242
 
189
- 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`).
243
+ ### Jobs (read-only)
244
+
245
+ Background-job metrics, as one plugin per backend — `:sidekiq` and
246
+ `:solid_queue`. Enable whichever you run (or both, e.g. mid-migration). The
247
+ backends read Redis / their own tables, not a wired SQL connection, so enable
248
+ them with `connection: false`:
190
249
 
191
250
  ```ruby
192
- config.plugin :health
251
+ config.plugin :sidekiq, connection: false
252
+ config.plugin :solid_queue, connection: false # both may run at once
193
253
  ```
194
254
 
195
- Scaffold a check with the generator (creates `app/talk_to_your_app/health/<name>.rb`):
255
+ Each plugin exposes four adapter-namespaced tools:
196
256
 
197
- ```sh
198
- bin/rails generate talk_to_your_app:health_check Database
199
- ```
257
+ - **Sidekiq** — `sidekiq.queue_sizes`, `sidekiq.recent_jobs` (`limit`, ≤500), `sidekiq.failed_jobs` (`limit`, ≤500), `sidekiq.rate_metrics` (`window` seconds, default 1800).
258
+ - **Solid Queue** — `solid_queue.queue_sizes`, `solid_queue.recent_jobs`, `solid_queue.failed_jobs`, `solid_queue.rate_metrics` (same arguments).
259
+
260
+ Both backends return the same response shape. Boot fails if the backend's gem is missing.
261
+
262
+ ### Flipper
263
+
264
+ Read and toggle feature flags, globally or per actor.
200
265
 
201
266
  ```ruby
202
- # app/talk_to_your_app/health/database.rb
203
- TalkToYourApp::Health.register(:database, description: "Primary DB connectivity and row counts") do
204
- {
205
- status: :pass,
206
- adapter: ActiveRecord::Base.connection.adapter_name,
207
- users: User.count,
208
- posts: Post.count,
209
- }
210
- end
267
+ config.connection :flipper_writer, database: "primary", role: :writing
268
+ config.plugin :flipper, connection: :flipper_writer
211
269
  ```
212
270
 
213
- - **`health.list`**the registered checks with their descriptions.
214
- - **`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.
215
- - 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.
216
- - 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.
271
+ Flipper writes flag state, so it requires a connection declared `role: :writing` boot fails on a `:reading` connection.
272
+
273
+ - **`flipper.list_flags`** names of all configured flags.
274
+ - **`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.
275
+ - **`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.
276
+ - **`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.)
277
+
278
+ 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.
217
279
 
218
280
  ### Rake (allow-listed task runner)
219
281
 
220
282
  Runs operator-approved rake tasks and returns their status and output.
221
283
 
222
284
  ```ruby
223
- config.plugin :rake, allowed: ["stats", "report:generate"]
285
+ config.plugin :rake, connection: false, allowed: ["stats", "report:generate"]
286
+ config.plugin :rake, connection: false, allowed: [...], timeout: 60 # per-task seconds, default 20
224
287
  ```
225
288
 
226
- - **`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.
289
+ - **`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.
227
290
 
228
291
  > ⚠️ **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.
229
292
 
230
293
  ### Custom Tools
231
294
 
232
- 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.
295
+ 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.
233
296
 
234
297
  ```ruby
235
- config.plugin :custom_tools
298
+ config.plugin :custom_tools, connection: false # tools call ctx.connection(:name) explicitly
299
+ config.plugin :custom_tools, connection: :read # ...or wire a default; ctx.connection (no arg) uses it
236
300
  ```
237
301
 
238
302
  Scaffold one with the generator (creates `app/talk_to_your_app/custom_tools/<name>.rb`, exposed as `custom.<name>`):
@@ -243,7 +307,7 @@ bin/rails generate talk_to_your_app:custom_tool MakeAdmin
243
307
 
244
308
  ```ruby
245
309
  # app/talk_to_your_app/custom_tools/make_admin.rb
246
- class MakeAdmin < TalkToYourApp::CustomTool
310
+ class MakeAdmin < TalkToYourApp::Tool
247
311
  name "custom.make_admin"
248
312
  description "Grant admin to a user by id."
249
313
  argument :user_id, :integer, required: true
@@ -256,7 +320,12 @@ class MakeAdmin < TalkToYourApp::CustomTool
256
320
  end
257
321
  ```
258
322
 
259
- - 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.
323
+ Namespaced generator paths are reflected in the tool name. For example,
324
+ `bin/rails generate talk_to_your_app:custom_tool Admin/MakeAdmin` creates
325
+ `app/talk_to_your_app/custom_tools/admin/make_admin.rb` and exposes
326
+ `custom.admin.make_admin`.
327
+
328
+ - The tool list is dynamic — one `Tool` subclass per file in `app/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; `Tool` subclasses defined elsewhere are not.
260
329
  - 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.
261
330
 
262
331
  ## Writing your own plugin