talk_to_your_app 0.1.0.pre.2 → 0.1.0.pre.3

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/LOCAL_DEVELOPMENT.md +284 -0
  3. data/README.md +77 -42
  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/plugin_authoring.md +102 -0
  7. data/docs/read_only_connections.md +189 -0
  8. data/docs/residual-review-findings/fd33390.md +19 -0
  9. data/docs/solutions/architecture-patterns/mcp-ruby-sdk-rails-integration-2026-06-01.md +180 -0
  10. data/docs/solutions/runtime-errors/sidekiq-adapter-requires-sidekiq-api-2026-06-16.md +76 -0
  11. data/lib/generators/talk_to_your_app/custom_tool/custom_tool_generator.rb +4 -4
  12. data/lib/generators/talk_to_your_app/custom_tool/templates/tool.rb.tt +10 -4
  13. data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +8 -5
  14. data/lib/talk_to_your_app/plugins/custom_tools/plugin.rb +18 -9
  15. data/lib/talk_to_your_app/plugins/flipper/plugin.rb +10 -0
  16. data/lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb +4 -2
  17. data/lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb +4 -2
  18. data/lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb +2 -0
  19. data/lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb +4 -0
  20. data/lib/talk_to_your_app/plugins/jobs/adapters/sidekiq.rb +12 -30
  21. data/lib/talk_to_your_app/plugins/jobs/adapters/solid_queue.rb +0 -25
  22. data/lib/talk_to_your_app/plugins/jobs/interface.rb +1 -7
  23. data/lib/talk_to_your_app/plugins/jobs/plugin.rb +1 -2
  24. data/lib/talk_to_your_app/plugins/rake/plugin.rb +13 -0
  25. data/lib/talk_to_your_app/plugins/rake/tools/run.rb +63 -3
  26. data/lib/talk_to_your_app/railtie.rb +7 -10
  27. data/lib/talk_to_your_app/tool.rb +35 -1
  28. data/lib/talk_to_your_app/version.rb +1 -1
  29. data/lib/talk_to_your_app.rb +11 -8
  30. metadata +11 -11
  31. data/lib/generators/talk_to_your_app/health_check/health_check_generator.rb +0 -31
  32. data/lib/generators/talk_to_your_app/health_check/templates/check.rb.tt +0 -12
  33. data/lib/talk_to_your_app/custom_tool.rb +0 -40
  34. data/lib/talk_to_your_app/plugins/health/plugin.rb +0 -31
  35. data/lib/talk_to_your_app/plugins/health/registry.rb +0 -68
  36. data/lib/talk_to_your_app/plugins/health/tools/list_checks.rb +0 -24
  37. data/lib/talk_to_your_app/plugins/health/tools/run_check.rb +0 -27
  38. 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: 593eb7d10a1e55f810002c8890fe4eb9e650d5001c2f252c17c79c78c70f9b8d
4
+ data.tar.gz: 73bfc284f8ce0081c3af5d40a1b30e7ed14a644d0340e1c7603f98d07d76ecb7
5
5
  SHA512:
6
- metadata.gz: 8b1ed1bfd86c608f3fd8867b2c376ca92068f05044506291a034e2636da306656ee218a1bf0d9cd974d6eb1879256d6dc47db4c0d8e4ddd0b3ac1bd17440bddf
7
- data.tar.gz: 630827c1779170f29c971d12310cf4f77ceb51f1504e160140bf6013d90214f4d892a949a9aa04f79c00c9781b053f926ac6a523c57a56a9b2cb267d0145073e
6
+ metadata.gz: d537fc75c1a3d5e59591829de61792610089e8fb4c55c3327abe8ceb228d26fe27bd8802895785940f84fec29d33e1677c3af85ce078e4ff160e14185cfa4766
7
+ data.tar.gz: '091db0b41b51ea0dfa3862b3fdade99ae30980c7b80d8c03e323e77be8f957fb7cc34129c79e4e2766f39e056d2f6fac3cecd85287df61ebf4c2dc7861c52dad'
@@ -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,6 @@
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
4
 
5
5
  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
6
 
@@ -8,7 +8,7 @@ It's a thin, Rails-native layer over the official [MCP Ruby SDK](https://github.
8
8
 
9
9
  - 🔌 **A Streamable HTTP MCP endpoint in two lines** — `mount TalkToYourApp.rack_app`, and you're live.
10
10
  - 🔒 **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).
11
+ - 🧰 **Five batteries-included plugins** — `db` (read-only SQL + schema introspection), `jobs` (Sidekiq / Solid Queue metrics), `flipper` (feature flags), `rake` (allow-listed tasks), and `custom_tools` (your own tools, with a generator).
12
12
  - 📝 **Every call audit-logged** — principal, IP, params, outcome, duration. Subscribe to persist your own trail.
13
13
  - ✍️ **A Ruby DSL + generators** for writing first-class tools of your own in a few lines.
14
14
 
@@ -62,6 +62,32 @@ mount TalkToYourApp.rack_app, at: TalkToYourApp.configuration.mount_at
62
62
 
63
63
  Rows come back as JSON, plain text, or an HTML table — the agent picks what it needs.
64
64
 
65
+ ## Try it locally
66
+
67
+ Want to see it end to end before wiring it into your own app? The bundled
68
+ `test/dummy` app runs as a real MCP server with HTTP Basic auth, the bundled
69
+ plugins, and seeded data. From a clone of this repo (needs a local PostgreSQL —
70
+ see [LOCAL_DEVELOPMENT.md](LOCAL_DEVELOPMENT.md)):
71
+
72
+ ```sh
73
+ cd test/dummy
74
+ RAILS_ENV=development bundle exec ruby bin/rails db:prepare # creates the DB + a read-only role, seeds data
75
+ RAILS_ENV=development bundle exec ruby bin/rails server -p 3000 # MCP endpoint at http://localhost:3000/mcp
76
+ ```
77
+
78
+ Then connect Claude Code with one command (Basic auth `dev` / `secret`, where
79
+ `ZGV2OnNlY3JldA==` is `base64("dev:secret")`):
80
+
81
+ ```sh
82
+ claude mcp add --transport http talk-to-your-app http://localhost:3000/mcp \
83
+ --header "Authorization: Basic ZGV2OnNlY3JldA=="
84
+ ```
85
+
86
+ Run `/mcp` in a Claude Code session to list the tools — e.g. *"use
87
+ talk-to-your-app's db.query to count comments per user."* The full walkthrough
88
+ (per-user tokens, background jobs, curl examples) is in
89
+ **[LOCAL_DEVELOPMENT.md](LOCAL_DEVELOPMENT.md)**.
90
+
65
91
  ## Configuration reference
66
92
 
67
93
  Every option lives inside `TalkToYourApp.configure`:
@@ -132,9 +158,8 @@ All plugins are **off by default** — enable them explicitly, and an agent can
132
158
  | Plugin | What an agent can do | Enable with |
133
159
  | --- | --- | --- |
134
160
  | [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` |
161
+ | [Jobs](#jobs-read-only) | Read queue sizes, recent/failed jobs, rates | `config.plugin :jobs, adapter: :sidekiq` |
136
162
  | [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
163
  | [Rake](#rake-allow-listed-task-runner) | Run allow-listed rake tasks and read their output | `config.plugin :rake, allowed: [...]` |
139
164
  | [Custom Tools](#custom-tools) | Call tools you write yourself (writes allowed) | `config.plugin :custom_tools` |
140
165
 
@@ -143,11 +168,13 @@ All plugins are **off by default** — enable them explicitly, and an agent can
143
168
  A single read-only SQL tool.
144
169
 
145
170
  ```ruby
146
- config.connection :replica_readonly, database: "primary", role: :reading
171
+ # `database:` must map to a SELECT-only DB user or a replica — not your
172
+ # writable primary. That read-only role is the security boundary.
173
+ config.connection :replica_readonly, database: "readonly", role: :reading
147
174
  config.plugin :db
148
175
  ```
149
176
 
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.
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. **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, 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
178
  - **`db.tables`** — lists the table names in the read-only database.
152
179
  - **`db.schema`** — `table` (required): the table's columns, primary key, indexes, and foreign keys.
153
180
 
@@ -155,6 +182,40 @@ config.plugin :db
155
182
 
156
183
  Setting up the read-only connection for each database engine is covered in **[docs/read_only_connections.md](docs/read_only_connections.md)**.
157
184
 
185
+ #### Read-only is enforced by the database
186
+
187
+ `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:
188
+
189
+ ```sql
190
+ -- starts with WITH/SELECT, so Rails classifies it as a read — but it writes:
191
+ WITH gone AS (DELETE FROM users RETURNING *) SELECT count(*) FROM gone;
192
+ SELECT 1; UPDATE accounts SET balance = 0; -- stacked statement (PostgreSQL)
193
+ ```
194
+
195
+ 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:
196
+
197
+ - **PostgreSQL** — create a role with no write grants and use it for the reader:
198
+ ```sql
199
+ CREATE ROLE app_readonly LOGIN PASSWORD '…';
200
+ GRANT CONNECT ON DATABASE app_production TO app_readonly;
201
+ GRANT USAGE ON SCHEMA public TO app_readonly;
202
+ GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly;
203
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO app_readonly;
204
+ -- optional belt-and-suspenders: ALTER ROLE app_readonly SET default_transaction_read_only = on;
205
+ ```
206
+ …or simply point `:reading` at a **physical read replica**, which is read-only by construction.
207
+ - **MySQL** — `GRANT SELECT ON app_production.* TO 'app_readonly'@'%';` (grant `SELECT` only), or use a replica.
208
+
209
+ Then declare that account as the reader and add the `db` plugin:
210
+
211
+ ```ruby
212
+ # database.yml has a `readonly` entry using the SELECT-only credentials above
213
+ config.connection :replica_readonly, database: "readonly", role: :reading
214
+ config.plugin :db
215
+ ```
216
+
217
+ > ⚠️ Do **not** point `:reading` 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.
218
+
158
219
  ### Jobs (read-only)
159
220
 
160
221
  Common metrics across job backends. Declare which adapter you run:
@@ -164,7 +225,6 @@ config.plugin :jobs, adapter: :sidekiq # or :solid_queue
164
225
  ```
165
226
 
166
227
  - **`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.
168
228
 
169
229
  Adapters return a stable shape regardless of backend. Boot fails if the adapter is unset, unknown, or its gem is missing.
170
230
 
@@ -184,52 +244,22 @@ config.plugin :flipper
184
244
 
185
245
  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.
186
246
 
187
- ### Health Checks
188
-
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`).
190
-
191
- ```ruby
192
- config.plugin :health
193
- ```
194
-
195
- Scaffold a check with the generator (creates `app/talk_to_your_app/health/<name>.rb`):
196
-
197
- ```sh
198
- bin/rails generate talk_to_your_app:health_check Database
199
- ```
200
-
201
- ```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
211
- ```
212
-
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.
217
-
218
247
  ### Rake (allow-listed task runner)
219
248
 
220
249
  Runs operator-approved rake tasks and returns their status and output.
221
250
 
222
251
  ```ruby
223
252
  config.plugin :rake, allowed: ["stats", "report:generate"]
253
+ config.plugin :rake, allowed: [...], timeout: 60 # per-task seconds, default 20
224
254
  ```
225
255
 
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.
256
+ - **`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
257
 
228
258
  > ⚠️ **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
259
 
230
260
  ### Custom Tools
231
261
 
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.
262
+ 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
263
 
234
264
  ```ruby
235
265
  config.plugin :custom_tools
@@ -243,7 +273,7 @@ bin/rails generate talk_to_your_app:custom_tool MakeAdmin
243
273
 
244
274
  ```ruby
245
275
  # app/talk_to_your_app/custom_tools/make_admin.rb
246
- class MakeAdmin < TalkToYourApp::CustomTool
276
+ class MakeAdmin < TalkToYourApp::Tool
247
277
  name "custom.make_admin"
248
278
  description "Grant admin to a user by id."
249
279
  argument :user_id, :integer, required: true
@@ -256,7 +286,12 @@ class MakeAdmin < TalkToYourApp::CustomTool
256
286
  end
257
287
  ```
258
288
 
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.
289
+ Namespaced generator paths are reflected in the tool name. For example,
290
+ `bin/rails generate talk_to_your_app:custom_tool Admin/MakeAdmin` creates
291
+ `app/talk_to_your_app/custom_tools/admin/make_admin.rb` and exposes
292
+ `custom.admin.make_admin`.
293
+
294
+ - 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
295
  - 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
296
 
262
297
  ## Writing your own plugin