talk_to_your_app 0.1.0.pre.1

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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +402 -0
  4. data/lib/generators/talk_to_your_app/custom_tool/custom_tool_generator.rb +39 -0
  5. data/lib/generators/talk_to_your_app/custom_tool/templates/tool.rb.tt +18 -0
  6. data/lib/generators/talk_to_your_app/health_check/health_check_generator.rb +31 -0
  7. data/lib/generators/talk_to_your_app/health_check/templates/check.rb.tt +12 -0
  8. data/lib/generators/talk_to_your_app/install/install_generator.rb +27 -0
  9. data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +78 -0
  10. data/lib/talk_to_your_app/audit_logger.rb +115 -0
  11. data/lib/talk_to_your_app/auth/api_key.rb +29 -0
  12. data/lib/talk_to_your_app/auth/basic.rb +24 -0
  13. data/lib/talk_to_your_app/auth/middleware.rb +74 -0
  14. data/lib/talk_to_your_app/configuration.rb +129 -0
  15. data/lib/talk_to_your_app/connection_registry.rb +131 -0
  16. data/lib/talk_to_your_app/current.rb +14 -0
  17. data/lib/talk_to_your_app/custom_tool.rb +40 -0
  18. data/lib/talk_to_your_app/plugin.rb +59 -0
  19. data/lib/talk_to_your_app/plugin_registry.rb +48 -0
  20. data/lib/talk_to_your_app/plugins/custom_tools/plugin.rb +26 -0
  21. data/lib/talk_to_your_app/plugins/db/plugin.rb +57 -0
  22. data/lib/talk_to_your_app/plugins/db/tools/query.rb +126 -0
  23. data/lib/talk_to_your_app/plugins/db/tools/schema.rb +60 -0
  24. data/lib/talk_to_your_app/plugins/db/tools/tables.rb +28 -0
  25. data/lib/talk_to_your_app/plugins/flipper/plugin.rb +132 -0
  26. data/lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb +41 -0
  27. data/lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb +42 -0
  28. data/lib/talk_to_your_app/plugins/flipper/tools/enabled_flags.rb +41 -0
  29. data/lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb +23 -0
  30. data/lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb +33 -0
  31. data/lib/talk_to_your_app/plugins/health/plugin.rb +31 -0
  32. data/lib/talk_to_your_app/plugins/health/registry.rb +68 -0
  33. data/lib/talk_to_your_app/plugins/health/tools/list_checks.rb +24 -0
  34. data/lib/talk_to_your_app/plugins/health/tools/run_check.rb +27 -0
  35. data/lib/talk_to_your_app/plugins/jobs/adapters/sidekiq.rb +122 -0
  36. data/lib/talk_to_your_app/plugins/jobs/adapters/solid_queue.rb +90 -0
  37. data/lib/talk_to_your_app/plugins/jobs/interface.rb +38 -0
  38. data/lib/talk_to_your_app/plugins/jobs/plugin.rb +87 -0
  39. data/lib/talk_to_your_app/plugins/jobs/tools/failed_jobs.rb +28 -0
  40. data/lib/talk_to_your_app/plugins/jobs/tools/health.rb +25 -0
  41. data/lib/talk_to_your_app/plugins/jobs/tools/queue_sizes.rb +23 -0
  42. data/lib/talk_to_your_app/plugins/jobs/tools/rate_metrics.rb +30 -0
  43. data/lib/talk_to_your_app/plugins/jobs/tools/recent_jobs.rb +28 -0
  44. data/lib/talk_to_your_app/plugins/rake/plugin.rb +42 -0
  45. data/lib/talk_to_your_app/plugins/rake/tools/run.rb +56 -0
  46. data/lib/talk_to_your_app/railtie.rb +56 -0
  47. data/lib/talk_to_your_app/renderers/html_table.rb +27 -0
  48. data/lib/talk_to_your_app/tool.rb +204 -0
  49. data/lib/talk_to_your_app/transport/rails_mount.rb +46 -0
  50. data/lib/talk_to_your_app/version.rb +5 -0
  51. data/lib/talk_to_your_app.rb +124 -0
  52. metadata +140 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cab6705b5c1fffe989fa665c452b433bc245e3f4f367009714ca36c97b4b55c4
4
+ data.tar.gz: 21044992a79652e184e174bf867bae2b3569d56420299c96ac3487f5098d2385
5
+ SHA512:
6
+ metadata.gz: 9c1679d7a4b0fcea64c7c3007b6925dd7a7eabb513990b4247b35092b453d0c3f0488d39e511b15593ff43cb5aa4adc6800bc19b71dcc28a92c2a8b4c447a232
7
+ data.tar.gz: 7855f1866f5fba865424544ae143176e727f569b621e7c5e950e3df592f63c1babb828d82aa6b460e3cf40b5e81966bc3ec82f99832c8997d04b3739409ba2ea
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Igor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,402 @@
1
+ # talk_to_your_app
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.
4
+
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
+
7
+ **What you get**
8
+
9
+ - 🔌 **A Streamable HTTP MCP endpoint in two lines** — `mount TalkToYourApp.rack_app`, and you're live.
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).
12
+ - 📝 **Every call audit-logged** — principal, IP, params, outcome, duration. Subscribe to persist your own trail.
13
+ - ✍️ **A Ruby DSL + generators** for writing first-class tools of your own in a few lines.
14
+
15
+ Everything is **off by default and opt-in per plugin** — an agent can only touch what you explicitly turn on.
16
+
17
+ ## Installation
18
+
19
+ ```ruby
20
+ # Gemfile
21
+ gem "talk_to_your_app"
22
+ ```
23
+
24
+ ```sh
25
+ bundle install
26
+ bin/rails generate talk_to_your_app:install
27
+ ```
28
+
29
+ The generator writes a commented `config/initializers/talk_to_your_app.rb`. Then mount the endpoint in `config/routes.rb`:
30
+
31
+ ```ruby
32
+ mount TalkToYourApp.rack_app, at: TalkToYourApp.configuration.mount_at
33
+ ```
34
+
35
+ ## Your first query
36
+
37
+ 1. **Declare a read-only connection and enable the DB plugin** in `config/initializers/talk_to_your_app.rb`:
38
+
39
+ ```ruby
40
+ TalkToYourApp.configure do |config|
41
+ config.api_keys = { "my-agent" => ENV.fetch("TTYA_KEY") }
42
+ config.connection :replica_readonly, database: "primary", role: :reading
43
+ config.plugin :db
44
+ end
45
+ ```
46
+
47
+ `"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
+
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.
50
+
51
+ 2. **Mount it** (see above) and boot the app. If the `:replica_readonly` connection were missing, boot would fail with a clear error.
52
+
53
+ 3. **Point your MCP client** (e.g. Claude Code) at `http://localhost:3000/mcp` with the bearer token `TTYA_KEY`.
54
+
55
+ 4. **Ask in plain English.** The agent translates your question into SQL, calls `db.query`, and answers:
56
+
57
+ ```
58
+ You: How many users registered this week?
59
+ Claude: 1,284 — up 12% from last week.
60
+ (db.query → SELECT count(*) FROM users WHERE created_at >= '2026-05-25')
61
+ ```
62
+
63
+ Rows come back as JSON, plain text, or an HTML table — the agent picks what it needs.
64
+
65
+ ## Configuration reference
66
+
67
+ Every option lives inside `TalkToYourApp.configure`:
68
+
69
+ | Option | Description |
70
+ | --- | --- |
71
+ | `config.mount_at` | Path the endpoint serves from. Default `"/mcp"`. |
72
+ | `config.server_name` | Server name in the `initialize` handshake. Default `"talk_to_your_app"`. |
73
+ | `config.server_title` | Human-friendly server title shown to clients (optional). |
74
+ | `config.server_version` | Reported server version. Defaults to the gem version. |
75
+ | `config.server_description` | One-line description of this MCP server, shown to clients (optional). |
76
+ | `config.instructions` | Guidance shown to the LLM on how to use this server's tools (optional). |
77
+ | `config.api_keys` | `{ "principal-name" => "secret" }`. The name is logged as the principal. Multiple keys supported for rotation. |
78
+ | `config.basic_auth { \|user, pass\| ... }` | HTTP Basic callable returning truthy to authenticate. Wire it to your own user model. |
79
+ | `config.authorize { \|principal, tool\| ... }` | Optional per-principal tool authorization. Returns truthy to allow. Without it, any authenticated principal may call any enabled tool. |
80
+ | `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). |
83
+ | `config.logger` | Audit logger. Defaults to `Rails.logger`. Swappable to any Logger-compatible object. |
84
+ | `config.log_level` | Global audit level (default `:info`); overridable per plugin. |
85
+
86
+ At least one of `api_keys` / `basic_auth` must be configured once any plugin is enabled, or the app refuses to boot.
87
+
88
+ ## Authentication & per-user tokens
89
+
90
+ > **What's a principal?** The *identity* behind a request — the name of the API key that authenticated (or the HTTP Basic username). It's what gets written to every audit line and what `config.authorize` receives, so giving each user or client its own named token gives you per-user attribution, scoping, and revocation.
91
+
92
+ `config.api_keys` is a map of **principal name → secret token**. The *name* is what gets logged as the principal and what `config.authorize` receives — so give **each user or client its own named token** rather than sharing one. That buys you per-user attribution in the audit log, per-user revocation, and per-user scoping:
93
+
94
+ ```ruby
95
+ TalkToYourApp.configure do |config|
96
+ # One named token per client/user — the key NAME is the principal.
97
+ config.api_keys = {
98
+ "claude-desktop" => ENV.fetch("TTYA_KEY_CLAUDE"),
99
+ "alice@acme.com" => ENV.fetch("TTYA_KEY_ALICE"),
100
+ "ci-readonly-bot" => ENV.fetch("TTYA_KEY_CI"),
101
+ }
102
+
103
+ # Optionally scope what each principal may call.
104
+ config.authorize { |principal, tool| principal == "ci-readonly-bot" ? tool.start_with?("db.") : true }
105
+ end
106
+ ```
107
+
108
+ Clients send `Authorization: Bearer <token>`. (HTTP Basic via `config.basic_auth` is the alternative; the username becomes the principal.)
109
+
110
+ Generating per-user tokens from your own `User` model is straightforward — give each user a high-entropy token (Rails' `has_secure_token` works well) and build the map:
111
+
112
+ ```ruby
113
+ config.api_keys = User.where.not(api_token: nil).pluck(:email, :api_token).to_h
114
+ ```
115
+
116
+ (The map is read at configure time; rebuild and redeploy — or rebuild in a `to_prepare` block — when the set of users changes. See `test/dummy` for a worked example.)
117
+
118
+ **Keeping tokens secure**
119
+
120
+ - **Never commit tokens.** Pull them from `ENV` or Rails credentials, not source.
121
+ - **Use HTTPS in production** so Bearer tokens aren't sent in clear text. Restrict `config.allowed_origins` for any browser-originated traffic.
122
+ - **Use high-entropy tokens** (e.g. `SecureRandom.hex(32)` or `has_secure_token`), one per principal — never a shared secret.
123
+ - **Rotate by adding the new named token and removing the old**; multiple keys can be valid at once, so rotation needs no downtime.
124
+ - **Revoke** a user by dropping their key (or nulling their `api_token`) and redeploying.
125
+ - Comparison is constant-time, and tokens are never written to the audit log. Mark any sensitive *tool argument* `redact: true` so it is masked too.
126
+ - Scope blast radius with `config.authorize` (per-principal tool allow-lists) and read-only DB roles, so a leaked token is bounded.
127
+
128
+ ## Plugins
129
+
130
+ All plugins are **off by default** — enable them explicitly, and an agent can only reach the ones you turn on.
131
+
132
+ | Plugin | What an agent can do | Enable with |
133
+ | --- | --- | --- |
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` |
140
+
141
+ ### DB
142
+
143
+ A single read-only SQL tool.
144
+
145
+ ```ruby
146
+ config.connection :replica_readonly, database: "primary", role: :reading
147
+ config.plugin :db
148
+ ```
149
+
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.
152
+ - **`db.schema`** — `table` (required): the table's columns, primary key, indexes, and foreign keys.
153
+
154
+ > **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
+
156
+ Setting up the read-only connection for each database engine is covered in **[docs/read_only_connections.md](docs/read_only_connections.md)**.
157
+
158
+ ### Jobs (read-only)
159
+
160
+ Common metrics across job backends. Declare which adapter you run:
161
+
162
+ ```ruby
163
+ config.plugin :jobs, adapter: :sidekiq # or :solid_queue
164
+ ```
165
+
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.
168
+
169
+ Adapters return a stable shape regardless of backend. Boot fails if the adapter is unset, unknown, or its gem is missing.
170
+
171
+ ### Flipper
172
+
173
+ Read and toggle feature flags, globally or per actor.
174
+
175
+ ```ruby
176
+ config.connection :flipper_writer, database: "primary", role: :writing
177
+ config.plugin :flipper
178
+ ```
179
+
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.)
184
+
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.
186
+
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
+ ### Rake (allow-listed task runner)
219
+
220
+ Runs operator-approved rake tasks and returns their status and output.
221
+
222
+ ```ruby
223
+ config.plugin :rake, allowed: ["stats", "report:generate"]
224
+ ```
225
+
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.
227
+
228
+ > ⚠️ **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
+
230
+ ### Custom Tools
231
+
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.
233
+
234
+ ```ruby
235
+ config.plugin :custom_tools
236
+ ```
237
+
238
+ Scaffold one with the generator (creates `app/talk_to_your_app/custom_tools/<name>.rb`, exposed as `custom.<name>`):
239
+
240
+ ```sh
241
+ bin/rails generate talk_to_your_app:custom_tool MakeAdmin
242
+ ```
243
+
244
+ ```ruby
245
+ # app/talk_to_your_app/custom_tools/make_admin.rb
246
+ class MakeAdmin < TalkToYourApp::CustomTool
247
+ name "custom.make_admin"
248
+ description "Grant admin to a user by id."
249
+ argument :user_id, :integer, required: true
250
+
251
+ def call(args, _ctx)
252
+ user = User.find(args[:user_id])
253
+ user.update!(admin: true)
254
+ json(id: user.id, admin: user.admin)
255
+ end
256
+ end
257
+ ```
258
+
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.
260
+ - 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
+
262
+ ## Writing your own plugin
263
+
264
+ See [docs/plugin_authoring.md](docs/plugin_authoring.md) for a full walkthrough. The short version:
265
+
266
+ ```ruby
267
+ class CacheStatsTool < TalkToYourApp::Tool
268
+ name "cache.stats"
269
+ description "Rails cache statistics."
270
+
271
+ def call(_args, _ctx)
272
+ json(Rails.cache.stats)
273
+ end
274
+ end
275
+
276
+ class CachePlugin < TalkToYourApp::Plugin
277
+ tools CacheStatsTool
278
+ end
279
+
280
+ TalkToYourApp.register_plugin(:cache, CachePlugin)
281
+ ```
282
+
283
+ Then `config.plugin :cache`. Third-party plugins use the exact same DSL and lifecycle as the bundled ones.
284
+
285
+ ## Custom audit logging
286
+
287
+ Every tool invocation produces one audit record. There are two ways to consume it:
288
+
289
+ **1. Swap the logger.** `config.logger` accepts any object with a `Logger` interface; the gem writes one line per call to it at `config.log_level`.
290
+
291
+ **2. Subscribe to the structured event** (recommended for a durable, queryable trail). The gem emits an `ActiveSupport::Notifications` event — `talk_to_your_app.tool_call` — for every invocation, with a structured payload: `ts`, `principal`, `ip`, `session_id`, `plugin`, `tool`, `params` (redacted), `outcome`, `duration_ms`, and `error_class` (on failure). Subscribe and persist it to your own table:
292
+
293
+ ```ruby
294
+ # An Activity model: t.string :principal, :ip, :plugin, :tool, :outcome;
295
+ # t.text :params; t.float :duration_ms; t.timestamps
296
+ ActiveSupport::Notifications.subscribe("talk_to_your_app.tool_call") do |*args|
297
+ e = ActiveSupport::Notifications::Event.new(*args).payload
298
+ Activity.create!(
299
+ principal: e[:principal], # the authenticated key name / Basic username
300
+ ip: e[:ip], # client IP (from the request)
301
+ plugin: e[:plugin].to_s,
302
+ tool: e[:tool], # e.g. "db.query"
303
+ params: e[:params].to_json,
304
+ outcome: e[:outcome], # "success" | "error"
305
+ duration_ms: e[:duration_ms],
306
+ )
307
+ rescue => err
308
+ Rails.logger.warn("activity log failed: #{err.message}") # never break the tool call
309
+ end
310
+ ```
311
+
312
+ The client IP comes from the request; the principal is the authenticated identity (so per-user tokens give you per-user attribution). Sensitive arguments marked `redact: true` are already masked in the payload. Put the subscriber in an initializer. See `test/dummy` for a working `Activity`-table example surfaced on its home page.
313
+
314
+ ## Connecting an MCP client
315
+
316
+ The endpoint is Streamable HTTP at `config.mount_at` (default `/mcp`). Point any MCP client at `https://your-app.example.com/mcp` with an `Authorization` header (a per-user Bearer token or HTTP Basic). The snippets below cover Claude, Gemini CLI, and Codex CLI; config keys for the CLIs evolve, so check your version's docs if a key differs.
317
+
318
+ **Claude Code** — add the server with a header (`--scope user` makes it available across projects):
319
+
320
+ ```sh
321
+ # Per-user API key (Bearer) — recommended
322
+ claude mcp add --transport http my-app https://your-app.example.com/mcp \
323
+ --header "Authorization: Bearer $TTYA_TOKEN"
324
+
325
+ # HTTP Basic instead
326
+ claude mcp add --transport http my-app https://your-app.example.com/mcp \
327
+ --header "Authorization: Basic $(printf 'user:pass' | base64)"
328
+ ```
329
+
330
+ List with `claude mcp list`, inspect in a session with `/mcp`, and **remove** with `claude mcp remove my-app`.
331
+
332
+ **Claude Desktop** — add to `claude_desktop_config.json` (Settings → Developer → Edit Config) and restart:
333
+
334
+ ```json
335
+ {
336
+ "mcpServers": {
337
+ "my-app": {
338
+ "url": "https://your-app.example.com/mcp",
339
+ "headers": { "Authorization": "Bearer YOUR_TOKEN" }
340
+ }
341
+ }
342
+ }
343
+ ```
344
+
345
+ (You can also add it through Settings → Connectors with the same URL and `Authorization` header.) Remove the server by deleting its entry and restarting.
346
+
347
+ **Gemini CLI** — add the server to `~/.gemini/settings.json` (or a project `.gemini/settings.json`). Use `httpUrl` for the Streamable HTTP transport, with `headers`:
348
+
349
+ ```json
350
+ {
351
+ "mcpServers": {
352
+ "my-app": {
353
+ "httpUrl": "https://your-app.example.com/mcp",
354
+ "headers": { "Authorization": "Bearer YOUR_TOKEN" }
355
+ }
356
+ }
357
+ }
358
+ ```
359
+
360
+ Recent Gemini CLI versions can also add it from the command line: `gemini mcp add --transport http my-app https://your-app.example.com/mcp --header "Authorization: Bearer YOUR_TOKEN"`. Manage with `gemini mcp list` / `gemini mcp remove my-app`, and `/mcp` inside a session.
361
+
362
+ **Codex CLI** — add the server to `~/.codex/config.toml` under `[mcp_servers.<name>]`. Recent Codex versions support Streamable HTTP servers directly:
363
+
364
+ ```toml
365
+ [mcp_servers.my-app]
366
+ url = "https://your-app.example.com/mcp"
367
+ http_headers = { Authorization = "Bearer YOUR_TOKEN" }
368
+ ```
369
+
370
+ You can also add it with `codex mcp add`. (If your Codex version only supports stdio MCP servers, point it at a stdio→HTTP bridge instead.)
371
+
372
+ For a local end-to-end walkthrough (run the bundled dummy app, connect a client, curl examples), see **[LOCAL_DEVELOPMENT.md](LOCAL_DEVELOPMENT.md)**.
373
+
374
+ ## Security model
375
+
376
+ - **Fail-closed.** Missing required config (auth, a required connection, a missing adapter gem) raises at boot, not at the first request.
377
+ - **Per-tool database roles.** Tools run on the connection their plugin declares, switched via Rails' `connected_to`. The DB plugin can only use a `:reading` connection.
378
+ - **One audit line per invocation** through `Rails.logger`: timestamp, principal, plugin, tool, params, outcome, duration. Mark sensitive arguments `redact: true`.
379
+ - **What the gem does NOT do:** no "execute arbitrary Ruby" tool; no writes through the DB plugin; no OAuth/JWT (static API keys and HTTP Basic only); no stdio transport; no web admin UI.
380
+
381
+ ## Compatibility
382
+
383
+ | | Supported |
384
+ | --- | --- |
385
+ | Ruby | 3.2+ |
386
+ | Rails | 7.1+ |
387
+ | MCP spec | 2025-11-25 (Streamable HTTP) |
388
+ | MCP SDK (`mcp` gem) | `~> 0.18` |
389
+
390
+ Tested against Rails 7.1, 7.2, and 8.0 in CI.
391
+
392
+ ## Upgrade discipline
393
+
394
+ The `mcp` SDK is pre-1.0; minor releases may break. This gem pins `~> 0.18` and isolates all SDK touch points to the transport mount and tool compilation. Watch the SDK's releases before bumping, and pin it in your own `Gemfile.lock`.
395
+
396
+ ## Development
397
+
398
+ Working on the gem itself? See **[LOCAL_DEVELOPMENT.md](LOCAL_DEVELOPMENT.md)** for setup, how to run the test suite (and what PostgreSQL/Redis it optionally uses), and how to test against the Rails version matrix.
399
+
400
+ ## License
401
+
402
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+ require "talk_to_your_app"
5
+
6
+ module TalkToYourApp
7
+ module Generators
8
+ # `rails g talk_to_your_app:custom_tool MakeAdmin` — scaffolds a custom MCP
9
+ # tool in app/talk_to_your_app/custom_tools/. Enable
10
+ # `config.plugin :custom_tools` and the tool is exposed automatically as
11
+ # `custom.<name>`.
12
+ class CustomToolGenerator < ::Rails::Generators::NamedBase
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Creates a TalkToYourApp::CustomTool subclass in app/talk_to_your_app/custom_tools/."
16
+
17
+ TOOLS_DIR = File.join(TalkToYourApp::APP_DIR, "custom_tools")
18
+
19
+ def create_tool
20
+ template "tool.rb.tt", File.join(TOOLS_DIR, class_path, "#{file_name}.rb")
21
+ end
22
+
23
+ def show_next_steps
24
+ say ""
25
+ say "Created #{File.join(TOOLS_DIR, class_path, "#{file_name}.rb")} — exposed as MCP tool #{tool_name.inspect}.", :green
26
+ say "Make sure config/initializers/talk_to_your_app.rb enables it:"
27
+ say " config.plugin :custom_tools"
28
+ say "Files here are loaded with require (not Zeitwerk-reloaded) — restart the server to pick up new or edited tools."
29
+ end
30
+
31
+ private
32
+
33
+ # MCP tool name: "custom.<underscored_name>".
34
+ def tool_name
35
+ "custom.#{file_name}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Custom MCP tool. Exposed automatically by the :custom_tools plugin as
4
+ # "custom.<%= file_name %>". See the README "Custom tools".
5
+ class <%= class_name %> < TalkToYourApp::CustomTool
6
+ name "custom.<%= file_name %>"
7
+ description "TODO: describe what <%= class_name %> does (shown to the client)."
8
+
9
+ # Declare arguments with the Tool DSL (compiled to JSON Schema):
10
+ # argument :user_id, :integer, required: true, description: "..."
11
+
12
+ def call(args, ctx)
13
+ # `ctx` exposes ctx.principal, ctx.ip, ctx.session_id, and
14
+ # ctx.connection(:name) { |conn| ... } for role-switched DB access.
15
+ # Return a String (text), a Hash/Array (JSON), or use text(...) / json(...) / error(...).
16
+ json(ok: true, tool: "custom.<%= file_name %>")
17
+ end
18
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+ require "talk_to_your_app"
5
+
6
+ module TalkToYourApp
7
+ module Generators
8
+ # `rails g talk_to_your_app:health_check Database` — scaffolds a health check
9
+ # in app/talk_to_your_app/health/. Enable `config.plugin :health` and the
10
+ # check is exposed automatically via `health.list` / `health.run`.
11
+ class HealthCheckGenerator < ::Rails::Generators::NamedBase
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ desc "Creates a TalkToYourApp::Health check in app/talk_to_your_app/health/."
15
+
16
+ CHECKS_DIR = File.join(TalkToYourApp::APP_DIR, "health")
17
+
18
+ def create_check
19
+ template "check.rb.tt", File.join(CHECKS_DIR, class_path, "#{file_name}.rb")
20
+ end
21
+
22
+ def show_next_steps
23
+ say ""
24
+ say "Created #{File.join(CHECKS_DIR, class_path, "#{file_name}.rb")} — run it with health.run name: #{file_name.inspect}.", :green
25
+ say "Make sure config/initializers/talk_to_your_app.rb enables it:"
26
+ say " config.plugin :health"
27
+ say "Files here are loaded with require (not Zeitwerk-reloaded) — restart the server to pick up new or edited checks."
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Health check, exposed by the :health plugin via health.list / health.run.
4
+ # Registered on load; the block returns JSON (a Hash) with a status: plus any
5
+ # values you want to surface (or a bare true/false). A raised exception is
6
+ # reported as status "error" rather than crashing the request.
7
+ TalkToYourApp::Health.register(:<%= file_name %>, description: "TODO: describe the <%= file_name %> check") do
8
+ {
9
+ status: :pass,
10
+ # ...add the values you want to surface, e.g. counts or timings.
11
+ }
12
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module TalkToYourApp
6
+ module Generators
7
+ # `rails g talk_to_your_app:install` — copies a commented initializer into
8
+ # the host app. Configuration is initializer-only; there are no migrations
9
+ # and no other generated files.
10
+ class InstallGenerator < ::Rails::Generators::Base
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates config/initializers/talk_to_your_app.rb in the host app."
14
+
15
+ def copy_initializer
16
+ template "initializer.rb.tt", "config/initializers/talk_to_your_app.rb"
17
+ end
18
+
19
+ def show_next_steps
20
+ say ""
21
+ say "talk_to_your_app: edit config/initializers/talk_to_your_app.rb, then"
22
+ say "mount the endpoint in config/routes.rb:", :green
23
+ say " mount TalkToYourApp.rack_app, at: TalkToYourApp.configuration.mount_at"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # talk_to_your_app configuration.
4
+ #
5
+ # This gem is fail-closed: if required configuration is missing, the app raises
6
+ # at boot rather than at the first MCP request. At minimum you must configure
7
+ # one authentication mechanism and mount the endpoint in config/routes.rb.
8
+ #
9
+ # After editing this file, add to config/routes.rb:
10
+ # mount TalkToYourApp.rack_app, at: TalkToYourApp.configuration.mount_at
11
+
12
+ TalkToYourApp.configure do |config|
13
+ # --- Endpoint -------------------------------------------------------------
14
+ # Path the MCP Streamable HTTP endpoint is served from. Default "/mcp".
15
+ # config.mount_at = "/mcp"
16
+
17
+ # --- Server identity (shown to MCP clients in the initialize handshake) ---
18
+ # config.server_name = "talk_to_your_app"
19
+ # config.server_title = "Acme — talk to your app"
20
+ # config.server_version = "1.0.0"
21
+ # config.server_description = "Read-only access to Acme's data over MCP."
22
+ # config.instructions = "Use db.query for read-only SQL; jobs.* for queue health."
23
+
24
+ # Origins allowed for browser-originated requests (DNS-rebinding protection).
25
+ # config.allowed_origins = ["https://app.example.com"]
26
+
27
+ # --- Authentication (at least one is required) ----------------------------
28
+ # Named API keys. The name becomes the logged principal, so use stable,
29
+ # human-meaningful names. Supports multiple keys for rotation.
30
+ # config.api_keys = { "claude-desktop" => ENV.fetch("TTYA_KEY") }
31
+ #
32
+ # HTTP Basic auth. The callable receives (username, password) and returns
33
+ # true/false. Wire it to whatever the host app uses.
34
+ # config.basic_auth { |username, password| User.authenticate(username, password) }
35
+
36
+ # Optional per-principal tool authorization. Receives (principal, tool_name);
37
+ # return truthy to allow. Without it, every authenticated principal may call
38
+ # every enabled tool.
39
+ # config.authorize { |principal, tool| principal == "admin" || tool.start_with?("db.") }
40
+
41
+ # --- Connections ----------------------------------------------------------
42
+ # Declare the named DB connections plugins reference. The gem-internal name
43
+ # (first arg) is what plugins ask for; `database:` maps to a database.yml key.
44
+ # Boot fails if a plugin requires a connection that is not declared here.
45
+ #
46
+ # config.connection :replica_readonly, database: "primary", role: :reading
47
+ # config.connection :flipper_writer, database: "primary", role: :writing
48
+
49
+ # --- Logging --------------------------------------------------------------
50
+ # Audit logger. Defaults to Rails.logger at INFO. Swappable to any object
51
+ # implementing the Logger interface.
52
+ # config.logger = Rails.logger
53
+ # config.log_level = :info
54
+
55
+ # --- Plugins (all off by default) -----------------------------------------
56
+ # config.plugin :db # db.query caps results at 2000 rows
57
+ # config.plugin :db, max_rows: 5000 # ...raise/lower the cap
58
+ # config.plugin :db, max_rows: nil # ...or remove it (also: false / :unlimited)
59
+ # config.plugin :jobs, adapter: :sidekiq # or :solid_queue
60
+ # config.plugin :flipper
61
+ # config.plugin :health
62
+ # config.plugin :rake, allowed: ["stats", "report:generate"]
63
+ end
64
+
65
+ # --- Custom audit logging (optional) ----------------------------------------
66
+ # Every tool call emits a structured event. Subscribe to persist a full audit
67
+ # trail (IP, principal, params, outcome, duration) to your own table.
68
+ #
69
+ # ActiveSupport::Notifications.subscribe("talk_to_your_app.tool_call") do |*args|
70
+ # e = ActiveSupport::Notifications::Event.new(*args).payload
71
+ # Activity.create!(
72
+ # principal: e[:principal], ip: e[:ip], plugin: e[:plugin].to_s,
73
+ # tool: e[:tool], params: e[:params].to_json,
74
+ # outcome: e[:outcome], duration_ms: e[:duration_ms],
75
+ # )
76
+ # rescue => err
77
+ # Rails.logger.warn("activity log failed: #{err.message}")
78
+ # end