talk_to_your_app 0.1.0.pre.1 → 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.
- checksums.yaml +4 -4
- data/LOCAL_DEVELOPMENT.md +284 -0
- data/README.md +77 -42
- data/docs/brainstorms/talk-to-your-app-gem-v1-requirements.md +158 -0
- data/docs/plans/2026-06-01-001-feat-talk-to-your-app-gem-v1-plan.md +772 -0
- data/docs/plugin_authoring.md +102 -0
- data/docs/read_only_connections.md +189 -0
- data/docs/residual-review-findings/fd33390.md +19 -0
- data/docs/solutions/architecture-patterns/mcp-ruby-sdk-rails-integration-2026-06-01.md +180 -0
- data/docs/solutions/runtime-errors/sidekiq-adapter-requires-sidekiq-api-2026-06-16.md +76 -0
- data/lib/generators/talk_to_your_app/custom_tool/custom_tool_generator.rb +4 -4
- data/lib/generators/talk_to_your_app/custom_tool/templates/tool.rb.tt +10 -4
- data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +8 -5
- data/lib/talk_to_your_app/configuration.rb +10 -0
- data/lib/talk_to_your_app/plugins/custom_tools/plugin.rb +18 -9
- data/lib/talk_to_your_app/plugins/flipper/plugin.rb +10 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb +4 -2
- data/lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb +4 -2
- data/lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb +2 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb +4 -0
- data/lib/talk_to_your_app/plugins/jobs/adapters/sidekiq.rb +12 -30
- data/lib/talk_to_your_app/plugins/jobs/adapters/solid_queue.rb +0 -25
- data/lib/talk_to_your_app/plugins/jobs/interface.rb +1 -7
- data/lib/talk_to_your_app/plugins/jobs/plugin.rb +1 -2
- data/lib/talk_to_your_app/plugins/rake/plugin.rb +13 -0
- data/lib/talk_to_your_app/plugins/rake/tools/run.rb +63 -3
- data/lib/talk_to_your_app/railtie.rb +7 -10
- data/lib/talk_to_your_app/tool.rb +35 -1
- data/lib/talk_to_your_app/transport/rails_mount.rb +4 -2
- data/lib/talk_to_your_app/version.rb +1 -1
- data/lib/talk_to_your_app.rb +11 -8
- metadata +11 -11
- data/lib/generators/talk_to_your_app/health_check/health_check_generator.rb +0 -31
- data/lib/generators/talk_to_your_app/health_check/templates/check.rb.tt +0 -12
- data/lib/talk_to_your_app/custom_tool.rb +0 -40
- data/lib/talk_to_your_app/plugins/health/plugin.rb +0 -31
- data/lib/talk_to_your_app/plugins/health/registry.rb +0 -68
- data/lib/talk_to_your_app/plugins/health/tools/list_checks.rb +0 -24
- data/lib/talk_to_your_app/plugins/health/tools/run_check.rb +0 -27
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 593eb7d10a1e55f810002c8890fe4eb9e650d5001c2f252c17c79c78c70f9b8d
|
|
4
|
+
data.tar.gz: 73bfc284f8ce0081c3af5d40a1b30e7ed14a644d0340e1c7603f98d07d76ecb7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
-
- 🧰 **
|
|
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
|
|
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
|
-
|
|
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::
|
|
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::
|
|
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
|
-
|
|
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
|