talk_to_your_app 0.1.0.pre.4 → 0.1.0.pre.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 968cb02f102a1c4e238a3305a9b67f9b00642e3da99c30ccf9ece00799251547
4
- data.tar.gz: 8d87ffd983498a0636b3e3d88b465262fa7781e0e534325cd96ef3c06ee15c28
3
+ metadata.gz: 29d220dca433672cda7a7de7b934ee9fff8d9fd2004a2ae1f8b01066a6f0ec78
4
+ data.tar.gz: 4334526b082a29b0e81a0fc8e36778e76f2ba0363d0e36e2fdc9b875551ed094
5
5
  SHA512:
6
- metadata.gz: e6dae56486a9660bc76769312aeb9c7a0f42de81b15c0b409d084962f4e3ea20600c9b8eb2670d267c6621885c3aa0e3c5ab02b471747662dbc74f9bc179571a
7
- data.tar.gz: 7c13b0d2eb1110fd16ff11e224b5be5fb73a8cb5330913d3310d561064efe8af537d0d03e5a0997bf52b27628f53c0d0a1775fbd7c24a26a7d690cd97b312910
6
+ metadata.gz: 1ad3205b3b15658133ccc9137e5d20ac8b8c96369a304b292a5e90b85f6c0bcec058fb6d3b59e6cc1043026f4f9e37d583c9576247430fbb0e72c4b68b2d3856
7
+ data.tar.gz: bedb84dc9a63d12ef2e80ed09ee8bea2a00a91764f3753be433ace2a1135cb6b6c48eee5b8cc7bf2b239e94c479c913d9f1b710fbfb8b7ba1b135e58e654a558
data/CHANGELOG.md ADDED
@@ -0,0 +1,146 @@
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.6] - 2026-07-20
10
+
11
+ ### Fixed
12
+ - Boot-time warnings (`no config.authorize configured`, writable DB connection)
13
+ now go to `$stderr` (`Kernel#warn`) instead of the configured logger. The
14
+ configured logger may write to `$stdout`, and boot output on `$stdout` can be
15
+ captured by scripts (e.g. building `DATABASE_URL` from `rails runner`), which
16
+ corrupted the captured value. Warnings never touch `$stdout` at boot now.
17
+
18
+ ## [0.1.0.pre.5] - 2026-07-18
19
+
20
+ Requires Ruby >= 3.3 and Rails >= 7.2 (the tested CI matrix: Ruby 3.3/4.0 ×
21
+ Rails 7.2/8.0/8.1); the `mcp` SDK is pinned `~> 0.25.0`.
22
+
23
+ ### Added
24
+ - Cache plugin: `config.plugin :cache, connection: false` exposes `cache.clear`
25
+ (`Rails.cache.clear`). Destructive (drops every cached entry) — scope it to
26
+ trusted principals with `config.authorize`.
27
+ - `config.authorize` now receives the tool's arguments as a third parameter:
28
+ `{ |principal, tool_name, args| ... }` (symbol keys, before defaults are
29
+ applied). Enables per-flag/per-task/per-table authorization. Two-parameter
30
+ blocks keep working (Ruby blocks ignore extra arguments); an explicit lambda
31
+ passed via `authorize(&my_lambda)` must accept all three.
32
+ - Boot warns (does not fail) when plugins are enabled without `config.authorize`
33
+ — every authenticated principal can otherwise call every enabled tool.
34
+ - `SECURITY.md` with private reporting instructions and an operator misconfiguration
35
+ checklist. Packaged in the gem; linked from the README security model.
36
+ - `config.allowed_hosts` (default `[]`). Extra `Host` header values accepted by
37
+ the transport's DNS-rebinding protection, beyond the loopback defaults
38
+ (`127.0.0.1`, `::1`, `localhost`). Required for any non-loopback deployment
39
+ (endpoint served from a real domain) — without it the transport rejects every
40
+ request with "Forbidden: Invalid Host header". Forwarded to the mcp SDK's
41
+ `StreamableHTTPTransport(allowed_hosts:)`. The generated initializer defaults
42
+ it to `TalkToYourApp.rails_hosts` — the host app's own `config.hosts` (literal
43
+ host strings only; regexp/IPAddr/dotted-wildcard entries are skipped).
44
+ - `config.enabled` (default true). A global on/off switch: when false the
45
+ mounted endpoint serves `503` and boot validation is skipped, so an operator
46
+ can ship the initializer and disable it per-environment without the gem
47
+ refusing to boot on otherwise-incomplete configuration.
48
+ - Opt-in DB writes: wiring a `role: :writing` connection into the DB plugin
49
+ (`config.plugin :db, connection: :writer`) lets `db.query` execute writes. It
50
+ is read-only on a `:reading` connection (the default) and logs a loud warning
51
+ at boot on a writable one. `config.authorize` cannot distinguish reads from
52
+ writes, and full SQL is written to the audit log — scope the DB user and log
53
+ sinks accordingly (see the README).
54
+ - `config.stateless` (default false). When true the Streamable HTTP transport
55
+ runs stateless — every request is self-contained with no per-session state in
56
+ the transport — so any worker or replica can serve any request. Set it when
57
+ the host app runs more than one Puma/Unicorn worker, where a follow-up request
58
+ can otherwise land on a process that never saw `initialize` and fail with
59
+ "Session not found". Stateless mode does not support SSE streaming or
60
+ server-initiated notifications.
61
+ - `app/talk_to_your_app/` convention directory: custom tools live in
62
+ `custom_tools/` (one `TalkToYourApp::Tool` subclass per file, loaded by
63
+ `:custom_tools`). The gem ignores the tree in Zeitwerk and requires the files
64
+ itself. The custom-tool generator writes here.
65
+ - `rails g talk_to_your_app:custom_tool NAME` generator — scaffolds a
66
+ `TalkToYourApp::Tool` subclass in `app/talk_to_your_app/custom_tools/`,
67
+ exposed as `custom.<name>`.
68
+ - Rake plugin per-task timeout: `config.plugin :rake, allowed: [...], timeout: 60`
69
+ (seconds, default 20). A task exceeding it is hard-killed (process group) and
70
+ returned as a tool error, so a hung task can't pin the web thread.
71
+ - `Tool::Context#ip` exposes the request IP (from `Current.ip`), completing
72
+ parity with `#principal` and `#session_id` for custom tools.
73
+ - Per-principal tool authorization: `config.authorize { |principal, tool_name| ... }`.
74
+ - DB plugin statement timeout on MySQL (`max_execution_time`) in addition to
75
+ PostgreSQL. SQLite and MariaDB have no per-statement timeout (the read-only
76
+ role still applies).
77
+ - Flipper plugin: enable/disable across actor, group, and percentage
78
+ (`percentage_of_actors` / `percentage_of_time`) gates, plus a
79
+ `flipper.enabled_flags` tool that lists active flags with their gates and
80
+ last-change timestamps.
81
+
82
+ ### Changed
83
+ - **BREAKING — minimum `mcp` SDK raised to `~> 0.25.0`.** 0.23 added `Host`-header
84
+ DNS-rebinding protection (`allowed_hosts:`); 0.24/0.25 add transport fixes
85
+ (SSE write synchronization, header normalization) with no further breaking
86
+ changes to this gem's SDK touch points. Earlier SDK versions reject
87
+ `allowed_hosts:` at boot.
88
+ - `config.allowed_origins` now works: it is forwarded to the mcp SDK transport,
89
+ which owns Origin validation (same-origin allowed, case-insensitive matching,
90
+ no-Origin non-browser clients allowed). Previously the gem's own middleware
91
+ rejected every cross-origin request regardless of the setting — the gem-side
92
+ check is removed in favor of the SDK's.
93
+ - Connections use `with_connection` inside `connected_to` (with explicit
94
+ `prevent_writes:` from the connection spec) so checkouts return to the pool
95
+ when the tool block ends.
96
+ - `401` `WWW-Authenticate` lists only the configured schemes (`Bearer`, `Basic`,
97
+ or both) instead of always advertising Bearer.
98
+ - Install generator: sets `config.stateless = true` in production; stronger
99
+ production comments for `enabled`, `authorize`, and the security checklist.
100
+ - Gem package includes `CHANGELOG.md` and `SECURITY.md`; ships only operator
101
+ docs (`docs/read_only_connections.md`, `docs/plugin_authoring.md`) rather than
102
+ internal plans/brainstorms.
103
+ - README security model updated for opt-in writable DB and the authorize warning.
104
+ - **BREAKING — the `:jobs` plugin is split into `:sidekiq` and `:solid_queue`.**
105
+ Enable the backend you run (or both, e.g. mid-migration):
106
+ `config.plugin :sidekiq, connection: false`. The `adapter:` option is gone.
107
+ Tools are now adapter-namespaced — `sidekiq.queue_sizes` / `solid_queue.queue_sizes`
108
+ (etc.) instead of `jobs.queue_sizes` — so both backends can be exposed at once.
109
+ - **BREAKING — every plugin must declare `connection:`.** Pass a declared
110
+ connection name, or `connection: false` to opt out (`:sidekiq`, `:solid_queue`,
111
+ `:rake`, and connection-less `:custom_tools` use `false`). Enabling any plugin
112
+ without the option fails at boot. `:db` and `:flipper` require a real
113
+ connection (`connection: false` is rejected).
114
+ - **BREAKING — connections are wired into plugins by name.** Plugins no longer
115
+ hardcode a connection name; you declare connections and wire one into each
116
+ plugin that needs a database: `config.plugin :db, connection: :readonly` and
117
+ `config.plugin :flipper, connection: :writer`. Enabling `:db` or `:flipper`
118
+ without `connection:` now fails at boot with an actionable error. The per-tool
119
+ hardcoded connection names were removed. `config.connection`'s `role:` now
120
+ defaults to `:reading`. **Existing initializers must add `connection:` to
121
+ `config.plugin :db`/`:flipper`.** **Plugin authors:** the `requires_connection`
122
+ DSL and the `Plugin.required_connections` alias are removed entirely — the
123
+ framework now enforces `connection:` universally (see above), so plugins no
124
+ longer mark themselves. `Tool::Context#connection_name` is now a no-arg reader
125
+ (it reports the connection the call ran on); pass an explicit override to
126
+ `ctx.connection(:name)`, not to `connection_name`. Connection resolution is
127
+ most-specific-first: an explicit `ctx.connection(:name)` arg, then the tool's
128
+ own static `connection` DSL, then the plugin-wired `connection:` default. A
129
+ tool that declares its own connection is no longer overridden by the
130
+ plugin-wired one (custom tools), and a tool's declared connection is now
131
+ validated at boot, not at first call.
132
+ - **BREAKING — Flipper enforces `role: :writing` at boot.** A Flipper connection
133
+ declared `role: :reading` previously booted and failed only on the first write;
134
+ it now fails closed at boot.
135
+ - **Jobs adapter contract:** the `required_gem` hash key was renamed from
136
+ `name:` to `gem_name:` (e.g. `{ const: "Sidekiq", gem_name: "sidekiq" }`),
137
+ matching the Plugin DSL's `requires_gem` option. **Third-party jobs adapters
138
+ must update their `REQUIRED_GEM`/`required_gem` to use `gem_name:`** — with the
139
+ old `name:` key the boot-time gem check still runs but its error message shows
140
+ a blank gem name.
141
+ - **Jobs adapter response shape:** `enqueued_at` is now an ISO-8601 string
142
+ across all adapters, and every job hash carries the same keys (`jid`, `class`,
143
+ `queue`, `args`, `enqueued_at`, `error_message`), with `error_message` nil for
144
+ jobs that have not failed.
145
+ - **Flipper enable/disable response shape:** now `{ name, enabled, gate_type,
146
+ 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
@@ -12,7 +12,7 @@ It's a thin, Rails-native layer over the official [MCP Ruby SDK](https://github.
12
12
 
13
13
  - 🔌 **A Streamable HTTP MCP endpoint in two lines** — `mount TalkToYourApp.rack_app`, and you're live.
14
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.
15
- - 🧰 **Batteries-included plugins** — `db` (read-only SQL + schema introspection), `sidekiq` and `solid_queue` (background-job metrics), `flipper` (feature flags), `rake` (allow-listed tasks), and `custom_tools` (your own tools, with a generator).
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).
16
16
  - 📝 **Every call audit-logged** — principal, IP, params, outcome, duration. Subscribe to persist your own trail.
17
17
  - ✍️ **A Ruby DSL + generators** for writing first-class tools of your own in a few lines.
18
18
 
@@ -107,9 +107,10 @@ Every option lives inside `TalkToYourApp.configure`:
107
107
  | `config.instructions` | Guidance shown to the LLM on how to use this server's tools (optional). |
108
108
  | `config.api_keys` | `{ "principal-name" => "secret" }`. The name is logged as the principal. Multiple keys supported for rotation. |
109
109
  | `config.basic_auth { \|user, pass\| ... }` | HTTP Basic callable returning truthy to authenticate. Wire it to your own user model. |
110
- | `config.authorize { \|principal, tool\| ... }` | Optional per-principal tool authorization. Returns truthy to allow. Without it, any authenticated principal may call any enabled tool. |
111
- | `config.allowed_origins` | Origins permitted for browser requests (DNS-rebinding protection). Empty allows non-browser clients. |
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
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`. |
113
114
  | `config.connection :name, database:, role:, replica:, statement_timeout:` | Declares a named connection. `role:` defaults to `:reading`; pass `:writing` for a writer. |
114
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). |
115
116
  | `config.logger` | Audit logger. Defaults to `Rails.logger`. Swappable to any Logger-compatible object. |
@@ -134,6 +135,10 @@ TalkToYourApp.configure do |config|
134
135
 
135
136
  # Optionally scope what each principal may call.
136
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" }
137
142
  end
138
143
  ```
139
144
 
@@ -168,6 +173,7 @@ All plugins are **off by default** — enable them explicitly, and an agent can
168
173
  | [Solid Queue](#jobs-read-only) | Read Solid Queue queue sizes, recent/failed jobs, rates | `config.plugin :solid_queue, connection: false` |
169
174
  | [Flipper](#flipper) | Read and toggle feature flags (global, actor, group, %) | `config.plugin :flipper, connection: :writer` |
170
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` |
171
177
  | [Custom Tools](#custom-tools) | Call tools you write yourself (writes allowed) | `config.plugin :custom_tools, connection: false` |
172
178
 
173
179
  ### DB
@@ -277,6 +283,8 @@ Flipper writes flag state, so it requires a connection declared `role: :writing`
277
283
 
278
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.
279
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
+
280
288
  ### Rake (allow-listed task runner)
281
289
 
282
290
  Runs operator-approved rake tasks and returns their status and output.
@@ -290,6 +298,16 @@ config.plugin :rake, connection: false, allowed: [...], timeout: 60 # per-task
290
298
 
291
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.
292
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
+
293
311
  ### Custom Tools
294
312
 
295
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.
@@ -442,30 +460,58 @@ For a local end-to-end walkthrough (run the bundled dummy app, connect a client,
442
460
 
443
461
  ## Security model
444
462
 
445
- - **Fail-closed.** Missing required config (auth, a required connection, a missing adapter gem) raises at boot, not at the first request.
446
- - **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.
447
- - **One audit line per invocation** through `Rails.logger`: timestamp, principal, plugin, tool, params, outcome, duration. Mark sensitive arguments `redact: true`.
448
- - **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).
449
488
 
450
489
  ## Compatibility
451
490
 
452
491
  | | Supported |
453
492
  | --- | --- |
454
- | Ruby | 3.2+ |
455
- | Rails | 7.1+ |
493
+ | Ruby | 3.3+ |
494
+ | Rails | 7.2+ |
456
495
  | MCP spec | 2025-11-25 (Streamable HTTP) |
457
- | MCP SDK (`mcp` gem) | `~> 0.18` |
496
+ | MCP SDK (`mcp` gem) | `~> 0.25.0` |
458
497
 
459
- 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.
460
499
 
461
500
  ## Upgrade discipline
462
501
 
463
- 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)**.
464
505
 
465
506
  ## Development
466
507
 
467
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.
468
509
 
510
+ ## Security
511
+
512
+ See **[SECURITY.md](SECURITY.md)** for how to report vulnerabilities and the
513
+ production misconfiguration checklist.
514
+
469
515
  ## License
470
516
 
471
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.
@@ -110,4 +110,4 @@ class CachePlugin < TalkToYourApp::Plugin
110
110
  end
111
111
  ```
112
112
 
113
- That's the whole contract. Bundled plugins (`db`, `jobs`, `flipper`) are good references — they live in `lib/talk_to_your_app/plugins/`.
113
+ That's the whole contract. Bundled plugins (`db`, `solid_queue`, `flipper`) are good references — they live in `lib/talk_to_your_app/plugins/`.
@@ -1,20 +1,33 @@
1
1
  # Setting up a read-only database connection
2
2
 
3
3
  The DB plugin runs operator-submitted SQL, so it must only ever touch a
4
- **read-only** connection. `talk_to_your_app` enforces this on two layers:
5
-
6
- 1. **Rails layer (always on).** You declare the connection with `role: :reading`,
7
- and the gem refuses to boot otherwise. Rails' `connected_to(role: :reading)`
8
- sets `prevent_writes`, which raises `ActiveRecord::ReadOnlyError` on any
9
- statement it detects as a write.
10
- 2. **Database layer (the real backstop).** You point that connection at a
11
- genuinely read-only target a physical replica, or a DB role/user granted
12
- only `SELECT`. Even if the Rails layer were bypassed, the database itself
13
- rejects writes.
4
+ **read-only** connection. `talk_to_your_app` has two layers here, and only one
5
+ of them is a security boundary:
6
+
7
+ 1. **Database layer (REQUIRED this is the security boundary).** You point the
8
+ connection at a genuinely read-only target: a physical replica, or a DB
9
+ role/user granted only `SELECT`. The database server then rejects writes no
10
+ matter how the SQL is shaped.
11
+ 2. **Rails layer (best-effort convenience, bypassable).** You declare the
12
+ connection with `role: :reading`, and the gem refuses to boot otherwise.
13
+ Rails' `connected_to(role: :reading)` sets `prevent_writes`, which raises
14
+ `ActiveRecord::ReadOnlyError` on statements it detects as writes — but Rails
15
+ detects writes with a **leading-keyword check**, so statements that start
16
+ with a read keyword and still modify data slip through it:
17
+
18
+ ```sql
19
+ -- classified as reads by Rails, but they write:
20
+ WITH gone AS (DELETE FROM users RETURNING *) SELECT count(*) FROM gone;
21
+ SELECT 1; UPDATE accounts SET balance = 0; -- stacked statement (PostgreSQL)
22
+ ```
23
+
24
+ Treat this layer as a guard rail against accidents, never as the thing that
25
+ stops a hostile or confused agent.
14
26
 
15
27
  Never "fake" the read-only connection by pointing it at your primary writable
16
- user. The Rails layer would still block obvious writes, but you lose the
17
- database-level guarantee that is the whole point.
28
+ user. The Rails layer blocks only the writes it can recognize; against the
29
+ statements above your data is protected by nothing at all. A SELECT-only DB
30
+ user or a replica is **required**, not a recommendation.
18
31
 
19
32
  ## The two pieces you configure
20
33
 
@@ -141,8 +154,15 @@ read-only role still applies), like SQLite.
141
154
 
142
155
  ## SQLite
143
156
 
144
- SQLite has no separate users or replicas. Declare the connection with
145
- `role: :reading` so the gem's Rails-layer write prevention rejects writes:
157
+ SQLite has no separate users or replicas, so it has **no database-level
158
+ read-only account** the layer that is the real security boundary on
159
+ PostgreSQL/MySQL does not exist here. That makes an OS-level backstop
160
+ **required, not optional**: run the MCP-serving process with the SQLite file
161
+ read-only (a dedicated read-only bind mount, or file permissions that deny
162
+ write to that process's user). Without it, the bypassable Rails-layer check is
163
+ the only thing between an agent and your data.
164
+
165
+ Declare the connection with `role: :reading` as usual:
146
166
 
147
167
  ```yaml
148
168
  production:
@@ -158,15 +178,15 @@ production:
158
178
  config.connection :replica_readonly, database: "primary_replica", role: :reading
159
179
  ```
160
180
 
161
- `role: :reading` makes Rails raise `ActiveRecord::ReadOnlyError` on any detected
162
- write. For a database-level backstop, mark the SQLite file read-only at the OS
163
- level for the MCP process (e.g. a dedicated read-only mount or file permissions).
181
+ `role: :reading` makes Rails raise `ActiveRecord::ReadOnlyError` on writes it
182
+ detects remember this check is bypassable (see the top of this document), which
183
+ is why the OS-level file protection above is required.
164
184
 
165
185
  > **Avoid the `query_only` pragma with the Rails SQLite adapter.** It looks
166
186
  > tempting, but the adapter writes to the database at connect time (WAL/journal
167
187
  > setup), so `PRAGMA query_only = ON` makes even `SELECT`s fail with
168
- > "attempt to write a readonly database". Use `role: :reading` (plus OS-level
169
- > file permissions if you want a hard backstop) instead.
188
+ > "attempt to write a readonly database". Use `role: :reading` plus the
189
+ > required OS-level file protection instead.
170
190
 
171
191
  > **SQLite has no per-statement timeout.** The `statement_timeout` option is a
172
192
  > no-op there; a long-running query is not cancelled by the gem. Prefer
@@ -8,18 +8,29 @@
8
8
  #
9
9
  # After editing this file, add to config/routes.rb:
10
10
  # mount TalkToYourApp.rack_app, at: TalkToYourApp.configuration.mount_at
11
+ #
12
+ # Production checklist: https://github.com/igorkasyanchuk/talk_to_your_app/blob/main/SECURITY.md
11
13
 
12
14
  TalkToYourApp.configure do |config|
13
15
  # --- On/off ---------------------------------------------------------------
14
16
  # Global switch. When false the endpoint serves 503 and boot validation is
15
- # skipped, so you can ship this initializer and disable it per-environment.
16
- # Default true.
17
- # config.enabled = Rails.env.production?
17
+ # skipped. Recommended: keep production off until auth, a SELECT-only DB
18
+ # connection, authorize, and allowed_hosts are wired — then opt in via ENV.
19
+ # Default true when unset.
20
+ # config.enabled = ENV.fetch("TTYA_ENABLED", Rails.env.local?.to_s) == "true"
18
21
 
19
22
  # --- Endpoint -------------------------------------------------------------
20
23
  # Path the MCP Streamable HTTP endpoint is served from. Default "/mcp".
21
24
  # config.mount_at = "/mcp"
22
25
 
26
+ # Stateless transport: every request is self-contained, so any worker or
27
+ # replica can serve it. Required for multi-worker / multi-replica deploys
28
+ # (most production apps) — otherwise a follow-up request can land on a
29
+ # process that never saw `initialize` and fail with "Session not found".
30
+ # Stateless mode does not support SSE streaming or server-initiated
31
+ # notifications. Default false when unset.
32
+ config.stateless = true if Rails.env.production?
33
+
23
34
  # --- Server identity (shown to MCP clients in the initialize handshake) ---
24
35
  # config.server_name = "talk_to_your_app"
25
36
  # config.server_title = "Acme — talk to your app"
@@ -39,19 +50,27 @@ TalkToYourApp.configure do |config|
39
50
  config.allowed_hosts = TalkToYourApp.rails_hosts
40
51
  # config.allowed_hosts += ["app.example.com"]
41
52
 
42
- # --- Authentication (at least one is required) ----------------------------
53
+ # --- Authentication (at least one is required once a plugin is enabled) ---
43
54
  # Named API keys. The name becomes the logged principal, so use stable,
44
- # human-meaningful names. Supports multiple keys for rotation.
55
+ # human-meaningful names. One high-entropy token per client (from ENV /
56
+ # credentials) — never a shared forever-secret in source.
45
57
  # config.api_keys = { "claude-desktop" => ENV.fetch("TTYA_KEY") }
46
58
  #
47
59
  # HTTP Basic auth. The callable receives (username, password) and returns
48
60
  # true/false. Wire it to whatever the host app uses.
49
61
  # config.basic_auth { |username, password| User.authenticate(username, password) }
50
62
 
51
- # Optional per-principal tool authorization. Receives (principal, tool_name);
52
- # return truthy to allow. Without it, every authenticated principal may call
53
- # every enabled tool.
54
- # config.authorize { |principal, tool| principal == "admin" || tool.start_with?("db.") }
63
+ # Strongly recommended in production. Without it, every authenticated
64
+ # principal may call every enabled tool (boot logs a warning). Receives
65
+ # (principal, tool_name, args) — args is the tool's argument hash, so you can
66
+ # authorize per flag/task/argument, not just per tool.
67
+ # config.authorize do |principal, tool, _args|
68
+ # case principal
69
+ # when "ci-readonly-bot" then tool.start_with?("db.")
70
+ # when "admin" then true
71
+ # else false
72
+ # end
73
+ # end
55
74
 
56
75
  # --- Connections ----------------------------------------------------------
57
76
  # Declare named DB connections, then wire one into each plugin that needs a
@@ -77,10 +96,11 @@ TalkToYourApp.configure do |config|
77
96
  # `connection: false` to opt out (no wired SQL connection).
78
97
  # config.plugin :db, connection: :read # db.query caps results at 2000 rows
79
98
  # config.plugin :db, connection: :read, max_rows: 5000 # ...raise/lower the cap (nil/false/:unlimited removes it)
80
- # config.plugin :sidekiq, connection: false # Sidekiq job metrics (reads Redis)
99
+ # config.plugin :sidekiq, connection: false # Sidekiq job metrics (reads Redis; job args may contain secrets)
81
100
  # config.plugin :solid_queue, connection: false # Solid Queue job metrics (both may run at once)
82
101
  # config.plugin :flipper, connection: :write # Flipper writes flag state — requires a :writing connection
83
102
  # config.plugin :rake, connection: false, allowed: ["stats", "report:generate"] # optional: timeout: 60 (per-task seconds, default 20)
103
+ # config.plugin :cache, connection: false # cache.clear (Rails.cache.clear) — destructive, scope with config.authorize
84
104
  # config.plugin :custom_tools, connection: false # or connection: :read to give tools a default
85
105
  #
86
106
  # DANGER — running :db against a writable connection. Wiring a :writing