talk_to_your_app 0.1.0.pre.3 → 0.1.0.pre.5

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