mcp_toolkit 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +418 -0
  3. data/README.md +174 -0
  4. data/app/views/mcp_toolkit/oauth/authorize.html.erb +83 -0
  5. data/config/routes.rb +22 -1
  6. data/lib/mcp_toolkit/authority/controller_methods.rb +39 -0
  7. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +9 -10
  8. data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
  9. data/lib/mcp_toolkit/authority/tools/base.rb +31 -3
  10. data/lib/mcp_toolkit/authority/tools/get.rb +2 -1
  11. data/lib/mcp_toolkit/authority/tools/list.rb +49 -1
  12. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +10 -2
  13. data/lib/mcp_toolkit/authority/tools/resources.rb +26 -4
  14. data/lib/mcp_toolkit/configuration.rb +526 -7
  15. data/lib/mcp_toolkit/dispatcher.rb +17 -3
  16. data/lib/mcp_toolkit/engine.rb +20 -2
  17. data/lib/mcp_toolkit/engine_controllers.rb +80 -5
  18. data/lib/mcp_toolkit/filtering.rb +168 -23
  19. data/lib/mcp_toolkit/gateway/aggregator.rb +25 -5
  20. data/lib/mcp_toolkit/list_executor.rb +48 -4
  21. data/lib/mcp_toolkit/oauth/controller_methods.rb +426 -0
  22. data/lib/mcp_toolkit/oauth.rb +23 -0
  23. data/lib/mcp_toolkit/resource.rb +55 -6
  24. data/lib/mcp_toolkit/resource_schema.rb +125 -16
  25. data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
  26. data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
  27. data/lib/mcp_toolkit/session.rb +2 -2
  28. data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
  29. data/lib/mcp_toolkit/tools/authority_base.rb +23 -2
  30. data/lib/mcp_toolkit/tools/base.rb +23 -3
  31. data/lib/mcp_toolkit/tools/get.rb +1 -1
  32. data/lib/mcp_toolkit/tools/list.rb +27 -9
  33. data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
  34. data/lib/mcp_toolkit/tools/resources.rb +30 -7
  35. data/lib/mcp_toolkit/usage_metering/recorder.rb +28 -2
  36. data/lib/mcp_toolkit/version.rb +1 -1
  37. data/lib/mcp_toolkit.rb +8 -2
  38. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e99039b1f2109b3da40c5d271338fbefc565bce7fb472b36947caf322d0ab3f8
4
- data.tar.gz: db79d656f89ce7566e5437956a8f0feac0ba9f4a906896078db3cee4879b512b
3
+ metadata.gz: 375206ea98c72a64738894427abd86bf0e272f5fad3c359860cd684707e38da7
4
+ data.tar.gz: e37f4ca9689b78c871daabc65110c7b5676760806d9eadaec45b30ea687f9b79
5
5
  SHA512:
6
- metadata.gz: '0804f99b21ac1cf54aa5d379957b1a3bec5af4a2aa3630bb2c0210d3b594cb98cf56dc3af4cc3693e1d872b2855ade1f1f2dc97a03760f56d16af85994f56547'
7
- data.tar.gz: 07626c80512de4d475cd1cd4b7fbe31a210718dbc8a5d2b81a97009900f8cc1df91b79cae903bfc1b0769a049ab224232cccadfd2b86f61c50ce83e5e08766f5
6
+ metadata.gz: 5920ce2f6e47c077d6f89a4078b74b82935a4be18f5a44cc415168b78e65f85aec6354ba7a3d81d1246f63d0c4d621503f5445b198ad42d80db2ab1ad129d750
7
+ data.tar.gz: '091c72fd1efc0d5dbea0298af70355f21436e194d37534aa98629ea786442fa4477b00b656de0a03b8ac31485ea38d8ad783de1a59bb927f82df75922f028f79'
data/CHANGELOG.md CHANGED
@@ -1,3 +1,421 @@
1
+ ## [0.6.0] - 2026-07-16
2
+
3
+ An OAuth 2.1 authorization bridge for the authority role, so hosted MCP clients
4
+ that will only authenticate by discovering an authorization server and running a
5
+ browser flow can reach a server whose tokens are issued out-of-band. Additive and
6
+ opt-in: a host that configures nothing behaves exactly as it did on 0.5.0.
7
+
8
+ ### Added
9
+
10
+ - **OAuth authorization bridge (authority-only, opt-in).** A standards-shaped
11
+ envelope around the tokens a host ALREADY issues — not an identity provider. Its
12
+ authorization page asks an operator to paste an existing access token, and the
13
+ `access_token` it returns IS that token, verified through the same
14
+ `config.token_authenticator` the transport uses. Scopes, expiry, revocation and
15
+ tenancy stay entirely with the host; the bridge widens nobody's reach.
16
+
17
+ Deliberately not implemented, because none of it gates anything here: client
18
+ registration returns an identifier and stores nothing (no endpoint reads a
19
+ `client_id`); there is no consent step (pasting a token you already hold is the
20
+ grant); no refresh token is issued (the pasted token's own expiry is the real
21
+ lifetime, so a client re-runs the flow rather than refreshing a shadow of it).
22
+
23
+ Deliberately NOT mocked, because faking either would create a real vulnerability
24
+ rather than skip a ceremony: `redirect_uri` is checked against the host's policy
25
+ on BOTH legs (below), and the PKCE `code_verifier` is verified (constant-time)
26
+ against the stored S256 `code_challenge`.
27
+
28
+ **Which clients may receive a code.** The authorization page is served from the
29
+ host's own origin, under its own certificate, and asks an operator to paste a
30
+ live token — so an unvetted `redirect_uri` does not merely add an open redirect,
31
+ it makes the host's own domain a credential-phishing page: an attacker sends the
32
+ operator an authorize link carrying the attacker's own `code_challenge`, the
33
+ operator pastes, and the code is delivered to the attacker, who redeems it with
34
+ the verifier they chose. PKCE cannot help — they own the verifier.
35
+
36
+ So every redirect target must be named by exact string in
37
+ `config.oauth_allowed_redirect_uris`, with exactly ONE exception:
38
+ **loopback** (`http://127.0.0.1:*`, `localhost`, `[::1]`), enabled by
39
+ `config.oauth_allow_loopback_redirects`. It is the exception because it is the
40
+ one target that CANNOT be named even in principle — an MCP client on an
41
+ operator's machine listens on an ephemeral port chosen at runtime, so no list
42
+ could enumerate it (RFC 8252 §7.3 exists for this) — and because a loopback
43
+ address resolves on the operator's OWN machine, so the attack above, which needs
44
+ the code to reach a REMOTE attacker, does not work through it.
45
+
46
+ A private-use scheme (`cursor://…`, §7.1) is NOT covered: its redirect URI is a
47
+ fixed string, so it just goes in the allowlist. There is no forcing reason to
48
+ accept one unnamed, and whole schemes cannot be accepted generically anyway —
49
+ separating a private-use scheme from a registered network one (`ssh:`, `ldap:`,
50
+ `gopher:`, each naming a REMOTE host) would mean enumerating the IANA registry,
51
+ and a denylist of the ones you thought of is the shape that fails open.
52
+
53
+ Loopback is judged on the PARSED URI: `http://127.0.0.1@evil.example/` has host
54
+ `evil.example` and is remote, as is `http://127.0.0.1.evil.example/`; a fragment
55
+ is refused.
56
+
57
+ Endpoints — `GET`/`POST` `<mcp>/oauth/authorize`, `POST <mcp>/oauth/token`,
58
+ `POST <mcp>/oauth/register`, plus the two metadata documents. A `/.well-known/*`
59
+ path cannot be drawn by an engine mounted under a path, so a host adds one line at
60
+ the top level of its route set: `McpToolkit.draw_oauth_metadata_routes(self)` (a
61
+ no-op unless the bridge is configured). Every identifier is derived from the live
62
+ request origin, so each host name an app answers on works without further
63
+ configuration.
64
+
65
+ **Additive to a host's own OAuth provider, and it claims nothing origin-global.**
66
+ The flow endpoints live under the engine's mount (`<mcp>/oauth/*`), so a host
67
+ already serving OAuth at the conventional top-level `/oauth/*` — as an app with
68
+ Doorkeeper for its own API does — keeps every one of those routes. The metadata
69
+ documents are PATH-SCOPED to the mount
70
+ (`/.well-known/oauth-protected-resource/mcp`), never the bare
71
+ `/.well-known/oauth-authorization-server`: the bare paths are origin-global and
72
+ mean "the authorization server of this whole origin", which belongs to that
73
+ pre-existing provider. RFC 8414 §3.1 exists for exactly this ("Using path
74
+ components enables supporting multiple issuers per host"), and the MCP
75
+ authorization spec (2025-11-25) requires a client given a path-ful issuer to try
76
+ the path-INSERTED URLs with no root fallback — so the issuer is the MCP endpoint
77
+ URL itself. A host mounted AT its origin root has no path to insert and gets the
78
+ bare paths, which is correct there.
79
+ - `config.oauth_allowed_redirect_uris` (default `[]`; entries are validated at
80
+ assignment and the list is frozen — an unparseable, scheme-less,
81
+ fragment-bearing or *opaque* URI raises, as does cleartext `http://` to a remote
82
+ host and the `javascript:`/`data:`/`file:` schemes a browser treats as script.
83
+ Naming bad schemes is sound here and nowhere else: this list is what the HOST
84
+ wrote, so there is no unlisted scheme for an attacker to slip through — unlike
85
+ the request-time policy, which is why that one takes the opposite shape),
86
+ `config.oauth_allow_loopback_redirects` (default `false`),
87
+ `config.oauth_resource_path` (default `"/mcp"` — must match the engine's mount
88
+ point), `config.oauth_authorization_code_ttl` (default `60`),
89
+ `config.oauth_signing_secret` (defaults to the Rails app's `secret_key_base`),
90
+ and `config.oauth_parent_controller` (default `"ActionController::Base"`).
91
+ - `config.oauth_bridge?` — whether the bridge is live. Gated on the authority role;
92
+ on a `token_authenticator` being set, since the bridge verifies the pasted token
93
+ through it on both legs and drawing no route beats an authorization page that
94
+ takes an operator's token and then errors; and on at least one redirect target
95
+ being named (an allowlist entry or the loopback switch), so it cannot run
96
+ without a bound answer to who may receive a code. A satellite — whose tokens
97
+ belong to its central app — never draws it.
98
+ - The token response is served `Cache-Control: no-store` + `Pragma: no-cache`, a
99
+ MUST of RFC 6749 §5.1 for any response carrying a token. Both metadata documents
100
+ get the same headers for a subtler reason: they name the
101
+ `authorization_endpoint` an operator will be sent to and are built from the
102
+ caller-influenced request origin (`request.base_url` honours `X-Forwarded-Host`),
103
+ so a shared cache holding one could hand every client an origin an attacker
104
+ chose, with the document itself vouching for it. Hosts should also pin
105
+ `config.hosts`, which Rails leaves empty in production by default.
106
+ - `POST <mcp>/oauth/authorize` answers **303**, not Rails' default 302. That POST
107
+ carried the operator's token in its body, and only 303 unambiguously tells the
108
+ browser to fetch the callback with GET and no body (RFC 9700 §4.12).
109
+ - The authority transport's 401 now carries
110
+ `WWW-Authenticate: Bearer resource_metadata="..."` when the bridge is configured —
111
+ the header a hosted client waits for before it will start a flow at all. Absent
112
+ otherwise, so an opted-out host's 401 is unchanged.
113
+
114
+ ### Notes
115
+
116
+ - **An authorization code leaves nothing usable in the cache.** The entry is keyed
117
+ by the code's SHA256, and its payload is sealed with `MessageEncryptor`
118
+ (AES-256-GCM) under a key HMAC'd from `config.oauth_signing_secret` **and** the
119
+ code. Worth the few lines because what is parked there for the code's lifetime
120
+ is not the short-lived credential an authorization server would normally hold:
121
+ it is the operator's pre-existing, long-lived, full-scope token, in a store
122
+ hosts are told to point at their shared `Rails.cache`.
123
+
124
+ The secret is in the key for a specific reason: **the code alone must not be
125
+ it.** Rails logs an authorization code twice per flow at INFO — `Redirected to
126
+ ...?code=...` (only `config.filter_redirect` touches that line) and the token
127
+ endpoint's `Parameters:` (no stock `filter_parameters` entry matches `code`) —
128
+ so keying on the code alone would leave the key to the cache sitting in the one
129
+ artifact that is more widely read, longer retained and more replicated than the
130
+ 60-second entry it protects. With the secret mixed in, the cache, the logs and
131
+ the code together still open nothing.
132
+
133
+ The serializer is pinned to `NullSerializer` for a similar reason: every
134
+ ActiveSupport default across the supported range (`:marshal`, and 7.1+'s
135
+ `:json_allow_marshal`) reaches `Marshal.load`, so a host with cache-write access
136
+ forging one blob would have had code execution. `JSON.parse` is now the only
137
+ parser that sees the payload.
138
+
139
+ Codes are also single-use by the DELETE rather than the read, so of two
140
+ concurrent redemptions exactly one proceeds (verified on MemoryStore, Redis and
141
+ MemCache stores).
142
+ - The bridge's controller is built from its own `config.oauth_parent_controller`
143
+ rather than the `parent_controller` the transport uses. The transport is a
144
+ JSON-only endpoint whose parent is typically `ActionController::API`, which
145
+ cannot render an HTML view — and the authorization page is one. Keeping them
146
+ separate means enabling the bridge changes nothing about the transport. Point
147
+ `oauth_parent_controller` at your own `ApplicationController` to inherit app
148
+ branding; the page renders with `layout: false` either way.
149
+ - The engine adds `access_token` and `code_verifier` to `config.filter_parameters`
150
+ itself. The bridge takes a live token in a POST body and Rails logs parameters
151
+ at INFO, so filtering it is the gem's business — a `rails new` app happens to
152
+ ship a `:token` entry that covers `access_token` by substring, but that is a
153
+ host default the gem does not own and an `--api` host may not have.
154
+ - **Serve the bridge over HTTPS** (`config.force_ssl`). The authorization page
155
+ receives a live access token; on cleartext it is on the wire. A cleartext remote
156
+ `redirect_uri` is refused in the allowlist for the same reason, but the gem
157
+ cannot make a host's own origin HTTPS.
158
+ - The bad-paste page answers **422 as an integer**, not a symbol: the gemspec pins
159
+ no Rack floor and neither symbol spans the supported range
160
+ (`:unprocessable_content` raises below Rack 3.1, `:unprocessable_entity` is
161
+ deprecated above it), so a symbol would turn a mistyped paste into an
162
+ unauthenticated 500 on Rails 7.x.
163
+ - `config.oauth_allowed_redirect_uris` is **frozen** once assigned, and
164
+ `config.oauth_signing_secret=` validates its input. Both are the same lesson:
165
+ validation that can be bypassed (`<<` onto the reader) or skipped (a bare
166
+ writer) is a suggestion, and both failures surface at request time — after an
167
+ operator has pasted a live token.
168
+ - **A host MUST pin `config.hosts`.** Every identifier the bridge publishes is
169
+ derived from `request.base_url`, which honours `X-Forwarded-Host`. Rails does
170
+ not pin it for you — it populates `config.hosts` in development and leaves it
171
+ empty in production, where empty means no host checking at all.
172
+ - **A host MUST point `config.cache_store` at a shared store** before running the
173
+ bridge on more than one worker. The default is an in-process MemoryStore, which
174
+ cannot carry a code from the worker that issued it to the worker that redeems
175
+ it: the flow then fails roughly (N-1)/N of the time, intermittently, and only
176
+ after the operator has pasted a live token. Warned about once at boot rather
177
+ than gated, because a MemoryStore is correct in a single process and
178
+ `Rails.cache` IS one in a stock development environment.
179
+ - The bridge's two browser legs are guarded by a `before_action` rather than a
180
+ check inside each action, so the guard cannot be routed around: `authorize` is
181
+ a common method name, and a gem defining one on `ActionController::Base` would
182
+ drop the action from Rails' `action_methods` and have Rails serve the template
183
+ by implicit render — skipping a guard that lived in the body.
184
+ - A host restyles the page by defining its own
185
+ `app/views/mcp_toolkit/oauth/authorize.html.erb`, which takes precedence over the
186
+ engine's.
187
+
188
+ ## [0.5.0] - 2026-07-14
189
+
190
+ Authority-path discoverability + backward-compatibility work (driven by an
191
+ adopting host's parity review against the API contract the gem replaced), plus a
192
+ role-aware mountable engine so an authority mounts its transport in one line, and
193
+ filter-path hardening from a security review.
194
+
195
+ ### Security
196
+
197
+ - `config.filter_operator_overrides` now rejects, at assignment time, any
198
+ operator outside `Filtering::AREL_PREDICATIONS`. Those are the only operators
199
+ the gem maps onto an Arel predication that binds/quotes its value; a host that
200
+ configured anything else (e.g. `"extract"`) would have it `public_send` to an
201
+ Arel attribute with the request value passed through verbatim — an
202
+ SQL-injection surface. A defense-in-depth guard in `Filtering.predicate_for`
203
+ also refuses to dispatch any non-predication operator, so the metaprogramming
204
+ call can never be reached with an unvetted method name. The default and
205
+ intended (`{ text: %w[eq in] }`-style) configurations were never vulnerable.
206
+ - New `config.max_filter_values` (default `500`, `nil` disables) caps how many
207
+ values an IN-set filter may resolve to and how many operator conditions may be
208
+ ANDed on one attribute, so a valid token can't emit an unbounded IN clause /
209
+ AND-chain (oversized SQL + Arel AST + expensive planning). Rate limiting
210
+ remains opt-in via `config.rate_limit_max_requests`.
211
+ - Added an injection-safety regression spec that renders real Arel SQL through a
212
+ correctly-escaping connection and asserts hostile payloads stay inside escaped
213
+ string literals (the prior fake connection did not escape quotes, so it could
214
+ not have caught an escaping regression).
215
+ - New `config.max_batch_size` (default `50`, `nil` disables) caps the number of
216
+ JSON-RPC calls a single authority POST batch may carry. Rate limiting is a
217
+ per-HTTP-request `before_action`, so an uncapped batch let one request fan out
218
+ unbounded work (N tool executions / N blocking upstream calls) under a single
219
+ rate-limit tick; an over-size batch is now rejected as a JSON-RPC error before
220
+ any element runs.
221
+ - The top-level `list` `ids` filter now honors `config.max_filter_values` — it
222
+ built `WHERE id IN (...)` on its own path, bypassing the cap that already
223
+ bounds the per-attribute filters.
224
+ - The authority dispatcher no longer relays an unexpected exception's message to
225
+ the caller: an unhandled `StandardError` returns a generic "Internal error"
226
+ (full detail still logged), so `ActiveRecord::StatementInvalid` SQL, internal
227
+ class names, or an internal hostname can't leak in the JSON-RPC error.
228
+ - The gateway `tools/list` aggregator now degrades a single malformed upstream
229
+ tool entry (a non-Hash / name-less definition) by skipping it, and wraps each
230
+ upstream's processing so any unexpected error omits only that upstream instead
231
+ of 500-ing the whole aggregated list for every upstream.
232
+ - Usage metering flush falls back to per-event writes when the batch write
233
+ fails, so one un-persistable ("poison") event can no longer drop metering for
234
+ a whole request's batch (a billing-evasion vector).
235
+ - The satellite tool path (`get` / `list` / `resource_schema` / `resources`) now
236
+ enforces `Resource#superusers_only!` — previously only the authority path did,
237
+ so a superuser-only resource served via a satellite was readable/discoverable
238
+ by any valid token (still account-scoped, so not cross-tenant). `get`/`list`/
239
+ `resource_schema` refuse it for a non-superuser; `resources` hides it.
240
+ - The authority dispatcher now strips a caller-supplied `context` from a tool's
241
+ arguments before the keyword splat. `tool.call(context:, **arguments)` let a
242
+ splatted `context` argument OVERRIDE the gem-resolved `Authority::Context`
243
+ (auth-context injection) — harmless for the gem's own tools (a JSON context
244
+ fails closed with a NoMethodError) but the gem handed attacker-controlled data
245
+ as `context` to arbitrary host tools.
246
+ - The gateway's transport-failure relay no longer leaks the internal upstream
247
+ host:port. `translate_upstream_call_error` returned `InternalError.new(error.message)`
248
+ for a transport failure, whose message is `"Failed to open TCP connection to
249
+ <host>:<port>"`; it now returns a generic error (the proxy already logs the
250
+ detail). A first-party upstream JSON-RPC error is still relayed verbatim.
251
+ - `Tools::AuthorityBase#execute` no longer relays an unexpected exception's
252
+ message to the caller — it returns a generic "Internal error" (detail logged),
253
+ matching the dispatcher's own catch-all.
254
+ - Usage metering's per-event flush fallback now cannot escape into the response:
255
+ a misbehaving `logger`/`error_reporter` in `flush_individually` is swallowed as
256
+ a last resort, preserving the "metering never affects the MCP response"
257
+ invariant.
258
+
259
+ ### Added
260
+
261
+ - The mountable `McpToolkit::Engine` is now ROLE-AWARE: the
262
+ `McpToolkit::ServerController` it mounts at POST/GET/DELETE /mcp is built from
263
+ `config.auth_role` — an authority host gets the hand-rolled dispatcher path
264
+ (local token auth, gateway proxying, usage metering, rate limiting), a
265
+ satellite gets the SDK-backed path. So an authority now mounts its whole
266
+ transport with `mount McpToolkit::Engine => "/mcp"` (identical to a satellite)
267
+ instead of hand-drawing the four routes against a subclass of
268
+ `McpToolkit::Authority::ServerController` — which is still supported for a host
269
+ that prefers to draw its own routes.
270
+ - `resource_schema` surfaces a resource's custom filters (`Resource#filter`)
271
+ under `resource_filters` — name, type and description — so a client can
272
+ discover them. The `Resource#filter` docs always promised this; nothing
273
+ delivered it, leaving custom filters functional but unadvertised.
274
+ - The `resources` tool returns `filterable` (whether the resource accepts any
275
+ filter — allowlist or custom) and the resource's usage `note` alongside
276
+ name/description, so caveats surface at browse time, before a client picks a
277
+ resource.
278
+ - The `list` tool description documents the full filter grammar — bare
279
+ equality, comma/array IN sets, the `"null"` token, `{ op:, value: }`
280
+ conditions and AND-ed condition arrays — plus resource-specific top-level
281
+ filters. Previously the operator payload shape was not documented anywhere a
282
+ client could see at runtime.
283
+ - Bare equality filters accept an Array of scalars as an IN set
284
+ (`filter: { status: ["a", "b"] }`). Previously the array was stringified and
285
+ comma-split into fragments that silently matched nothing.
286
+ - A JSON null filter value filters for `IS NULL` (like the `"null"` string
287
+ token). Previously it was silently ignored.
288
+
289
+ All of the above applies to the SATELLITE generic tools too: they share the
290
+ executors and schema builder, so their `resources` output gains
291
+ `filterable`/`note`, their `resource_schema` output gains `resource_filters`,
292
+ and their descriptions document the same filter grammar.
293
+
294
+ ### Host-compatibility seams (full parity with a pre-gem API contract)
295
+
296
+ For a host migrating an EXISTING MCP endpoint onto the gem, whose clients hold
297
+ the pre-gem contract:
298
+
299
+ - `config.bare_filter_value_semantics = :literal` — bare filter values reach
300
+ the WHERE clause verbatim (`"a,b"` is one literal string, `"null"` is the
301
+ literal string, `""` matches empty-string rows, an Array — including nil
302
+ elements — gets the adapter's native IN / OR-IS-NULL handling). The default
303
+ `:tokenized` keeps the gem's comma/IN/`"null"`-token grammar. Operator
304
+ conditions are identical in both modes.
305
+ - `config.non_numeric_pk_order = :primary_key` — lists of non-numeric-PK
306
+ resources order by the primary key alone, preserving a pre-gem
307
+ ORDER BY id contract. The default `:created_at` keeps chronological pages
308
+ with the PK tiebreaker.
309
+ - `Resource#filter_requirements` — declares companion-key requirements (e.g. a
310
+ polymorphic foreign key that is type-ambiguous without its `*_type`): the
311
+ list executor rejects the key without its companion ("filter attribute X
312
+ requires Y to also be provided") and `resource_schema` advertises the
313
+ requirement, restoring safe polymorphic-FK filtering instead of dropping the
314
+ key from the allowlist. Accepts a Hash or a lazily-resolved callable, like
315
+ `filterable`.
316
+ - `resource_schema` output restores the remaining pre-gem keys: top-level
317
+ `sparse_fieldsets: true` and `filter_examples` (ready-to-use payloads built
318
+ from the resource's own attributes/relationships), `relationships[].resource`
319
+ (nullable; `target_resource` remains as the resolved alias) and
320
+ `relationships[].filter` (`keys` / `type` / `operators` / `requires`).
321
+ Top-level nil keys are compacted (a nil `note` is omitted again).
322
+ - Operator conditions work on ANY column type: types outside the operator
323
+ table (uuid, enum, jsonb, ...) accept `eq` / `in` instead of failing with
324
+ "cannot be filtered with operators", and `date` columns accept `in` again.
325
+ - The `list` tools' input schemas declare `additionalProperties: true`
326
+ explicitly (resource-specific filters arrive as top-level arguments).
327
+ - `config.register_upstreams_from_env(mapping, env: ENV)` — declares gateway
328
+ upstreams from a `{ key => env var }` map: resets the registry first
329
+ (idempotent across code reloads) and skips blank urls, the two gotchas every
330
+ authority host re-discovers.
331
+ - `config.tool_provider` composes a sensible default when unset: the generic
332
+ Registry-backed provider (only when resources are registered — a pure
333
+ gateway still contributes nothing) plus `config.extra_tool_providers`
334
+ (providers, or bare tool classes auto-wrapped in the new
335
+ `Authority::SingleToolProvider`). Hosts with one bespoke tool no longer
336
+ hand-roll provider plumbing; assigning `tool_provider` explicitly still
337
+ takes full control.
338
+ - `McpToolkit::Serializer::AssociationDescriptor` + `TargetRef` — the exported
339
+ structs for the association duck-type the schema builder and field selection
340
+ probe, so a host adapting its own serializer framework doesn't re-derive the
341
+ field names by hand.
342
+ - The authority `list` tool's served description states the bare-value grammar
343
+ the host ACTUALLY configured: under `:literal` semantics the comma/`"null"`
344
+ tokenization bullet is replaced by the literal-matching one, so served docs
345
+ never advertise filters that would silently match nothing.
346
+ - The authority tools are advertised in alphabetical base-name order
347
+ (`get`, `list`, `resource_schema`, `resources`) and string/text operator
348
+ lists keep the pre-gem order (`eq, in, not_eq, matches, does_not_match`) —
349
+ JSON arrays are ordered, so byte-diffing clients see no reorder.
350
+ - `get` / `resource_schema` reject arguments outside their input schema with
351
+ InvalidParams instead of silently ignoring them (pre-gem parity — they were
352
+ strict Ruby kwargs; `account_id` is always tolerated, the transport consumes
353
+ it). `resources` and `list` stay tolerant of extra arguments, also matching
354
+ the pre-gem contract (`list`'s extras are the resource-specific filters).
355
+ - `config.filter_operator_overrides` — per-column-type overrides for the
356
+ operator sets advertised by `resource_schema` AND enforced by the executor
357
+ (single source, they cannot disagree), so a host can preserve a pre-gem
358
+ operator contract exactly (e.g. `{ text: %w[eq in], date: %w[eq in] }`).
359
+ Empty by default: the gem's own sets apply.
360
+ - A companion key whose value the executor would SKIP (an empty string under
361
+ `:tokenized` semantics) no longer satisfies a `filter_requirements` pairing —
362
+ the foreign key is rejected rather than applied alone (type-ambiguous).
363
+ - `resource_filters` entries keep nil `type`/`description` keys (pre-gem
364
+ shape) instead of compacting them; the relationship `filter_examples`
365
+ companion sample value is `"User"` (pre-gem sample) rather than `"..."`.
366
+
367
+ ### Known operator-path delta (documented, not reverted)
368
+
369
+ - `{ op: "in", value: "a,b" }` now splits the comma-separated string into an
370
+ IN set (previously `in` matched the literal string `'a,b'` as a single
371
+ element; only `eq` split). Comma-separated STRING ELEMENTS inside an Array
372
+ value are split the same way — under the tokenized operator grammar there is
373
+ no way to express a literal comma inside an IN element; a literal
374
+ comma-containing match is expressed as a bare equality value (which hosts on
375
+ `:literal` semantics match verbatim).
376
+
377
+ ### Fixed
378
+
379
+ - Generic tool descriptions and input schemas rewrite sibling-tool references
380
+ (e.g. "use the `resources` tool") to carry `config.generic_tool_name_prefix`,
381
+ so a host that namespaces its generic tools no longer serves prose pointing
382
+ at unprefixed tool names that do not exist on its server. The gateway
383
+ aggregator applies the same rewrite with the upstream namespace, so a proxied
384
+ `<app>__list` no longer points a client at the upstream's bare tool names
385
+ (`McpToolkit::ToolReferenceRewriter`).
386
+ - `eq` / `in` operator conditions against `"null"` / null render `IS NULL`
387
+ instead of `IN (NULL)`, which matches no rows in SQL.
388
+ - `eq` / `in` operator conditions accept an Array `value` (previously
389
+ stringified and comma-split into fragments).
390
+ - Non-numeric-PK resources order by `created_at` WITH the primary key as a
391
+ tiebreaker, restoring a total order so offset pagination cannot duplicate or
392
+ skip rows that share a timestamp (e.g. bulk inserts).
393
+ - An Array mixing `{ op:, value: }` conditions with bare values is rejected
394
+ with InvalidParams instead of being misread as bare equality values.
395
+ - One resource's failing lazy `filterable` resolution (e.g. a transient DB
396
+ error inside a host-supplied callable) no longer fails the whole `resources`
397
+ discovery index: the `filterable` key is omitted for that resource and the
398
+ unresolved source is retried on the next read instead of permanently and
399
+ silently resolving the allowlist to `{}`.
400
+
401
+ ### Changed (explicit over silent — each previously returned a wrong or empty result)
402
+
403
+ - IN-set elements must be non-null scalars: a nil, Hash or nested-Array element
404
+ inside an Array filter value raises InvalidParams (previously a Hash element
405
+ raised a TypeError at query time and a nil element rendered the
406
+ never-matching `IN (..., NULL)`). The `"null"` token is NOT resolved inside a
407
+ set — SQL `IN` cannot match NULL — so a null-or-nothing condition is
408
+ expressed as the filter's single scalar value.
409
+ - A null value with an operator other than `eq` / `in` / `not_eq` (comparisons,
410
+ `matches` / `does_not_match`) raises InvalidParams; a comparison or LIKE
411
+ against NULL can never match a row (previously `matches` with a JSON null
412
+ matched every row via `LIKE '%%'`, and comparisons silently matched nothing).
413
+ - An op-less Hash as a bare filter value raises InvalidParams (previously it
414
+ reached the database as a malformed condition).
415
+ - `{ op: "eq", value: "" }` matches rows whose value IS the empty string
416
+ (previously it matched nothing via an empty IN set). A bare `""` filter value
417
+ still means "no filter".
418
+
1
419
  ## [0.4.0] - 2026-07-06
2
420
 
3
421
  ### Added
data/README.md CHANGED
@@ -403,6 +403,180 @@ mount McpToolkit::Engine => "/mcp" # POST /mcp/tokens/introspect now works
403
403
  Drawing it is safe even on an app that is not an authority: with no
404
404
  `token_authenticator`, it simply answers `{ "valid": false }`.
405
405
 
406
+ ### OAuth authorization bridge (authority-only, opt-in)
407
+
408
+ Some MCP clients will not accept a token you hand them. They authenticate one way
409
+ only: discover an authorization server, run an authorization-code + PKCE flow in a
410
+ browser, and use whatever `access_token` comes back. The MCP authorization spec
411
+ also forbids a token in the request URI, so `?token=<...>` is not a fallback for
412
+ them either. If your tokens are issued out-of-band — an admin UI, a CLI, a support
413
+ process — those clients cannot reach your server at all.
414
+
415
+ The bridge is a standards-shaped **envelope around the tokens you already issue**.
416
+ It is not an identity provider: its authorization page asks the operator to paste
417
+ an access token they already hold, and the `access_token` it returns **is that
418
+ token**, verified through the same `token_authenticator` your transport uses.
419
+ Scopes, expiry, revocation and tenancy stay exactly where you put them, and it
420
+ creates no new way to obtain a token.
421
+
422
+ ```ruby
423
+ # config/initializers/mcp_toolkit.rb
424
+ McpToolkit.configure do |c|
425
+ c.auth_role = :authority
426
+ c.token_authenticator = ->(plaintext) { AccessToken.authenticate(plaintext) }
427
+
428
+ # REQUIRED for the bridge on any multi-worker deployment. The default is an
429
+ # in-process MemoryStore, which cannot carry an authorization code from the
430
+ # worker that issues it to the worker that redeems it — the flow then fails
431
+ # intermittently, *after* the operator has pasted their token.
432
+ c.cache_store = Rails.cache
433
+
434
+ # Naming who may receive an authorization code is what switches the bridge on.
435
+ c.oauth_allowed_redirect_uris = ["https://client.example/callback"]
436
+ c.oauth_resource_path = "/mcp" # must match the engine's mount point
437
+
438
+ # Optional: let any MCP client running on your operators' OWN machines connect
439
+ # without an allowlist entry each (RFC 8252 — see below). This is an opt-in
440
+ # signal in its own right, so it alone can switch the bridge on.
441
+ c.oauth_allow_loopback_redirects = true
442
+ end
443
+ ```
444
+
445
+ ```ruby
446
+ # config/routes.rb — the helper call must be TOP LEVEL. A `/.well-known/*` path
447
+ # cannot be drawn by an engine mounted under a path, so the metadata routes have
448
+ # to live in your own route set. A no-op unless the bridge is configured.
449
+ Rails.application.routes.draw do
450
+ McpToolkit.draw_oauth_metadata_routes(self)
451
+ mount McpToolkit::Engine => "/mcp"
452
+ end
453
+ ```
454
+
455
+ That yields the whole flow — `GET /.well-known/oauth-protected-resource/mcp`,
456
+ `GET /.well-known/oauth-authorization-server/mcp`, `POST /mcp/oauth/register`,
457
+ `GET`/`POST /mcp/oauth/authorize`, `POST /mcp/oauth/token` — plus a
458
+ `WWW-Authenticate: Bearer resource_metadata="..."` header on the transport's 401,
459
+ which is what makes a client start the flow at all. Every identifier is derived
460
+ from the live request origin, so each host name your app answers on works with no
461
+ further configuration.
462
+
463
+ **What is deliberately absent**, because none of it gates anything here: client
464
+ registration returns an identifier and stores nothing (no endpoint reads a
465
+ `client_id`); there is no consent step (pasting a token you hold *is* the grant);
466
+ no refresh token is issued (the pasted token's own expiry is the real lifetime, so
467
+ a client re-runs the flow instead of refreshing a shadow of it).
468
+
469
+ **What is not faked**, because faking either would be a real vulnerability rather
470
+ than a skipped ceremony: `redirect_uri` is checked against your policy on both
471
+ legs (below), and the PKCE `code_verifier` is verified against the stored S256
472
+ challenge in constant time.
473
+
474
+ ### Which clients may receive a code
475
+
476
+ This is the bridge's load-bearing control, so it is worth knowing why it is shaped
477
+ the way it is. The authorization page is served from **your** origin under **your**
478
+ certificate and asks an operator to paste a live token. So an unvetted
479
+ `redirect_uri` does not merely add an open redirect — it makes your own domain a
480
+ credential-phishing page: an attacker sends the operator an authorize link
481
+ carrying the attacker's own `code_challenge`, the operator pastes, the code is
482
+ delivered to the attacker, and they redeem it with the verifier they chose. PKCE
483
+ does not help (they own the verifier), nor does the single-use code, nor
484
+ re-verifying the token. A full authorization server blocks this with a consent
485
+ screen naming the client plus an authenticated session; this bridge mocks both
486
+ away, which is exactly what the redirect policy compensates for.
487
+
488
+ So **every target must be named by exact string**, with exactly one exception:
489
+
490
+ | Target | Rule | Why |
491
+ |---|---|---|
492
+ | Anything remote (`https://client.example/cb`) | Exact string, in `oauth_allowed_redirect_uris` | The phishing vector. Never opened up. |
493
+ | Private-use scheme (`cursor://…`, `com.example.app:/cb`) | Exact string, in `oauth_allowed_redirect_uris` | Keeps the code on the device, but its URI is a fixed string — so just name it. |
494
+ | Loopback (`http://127.0.0.1:*`, `localhost`, `[::1]`) | `oauth_allow_loopback_redirects` | The only target that **cannot** be named: the client picks an ephemeral port at runtime (RFC 8252 §7.3). And it resolves on the operator's own machine, so the attack above cannot reach it. |
495
+
496
+ The loopback exception exists because an allowlist entry is *impossible* there,
497
+ not because native clients are trusted. A private-use scheme keeps the code on the
498
+ device too, but nothing forces it to be unnamed — and whole **schemes** cannot be
499
+ accepted generically anyway: telling a private-use scheme from a registered
500
+ network one (`ssh:`, `ldap:`, `gopher:` — each naming a **remote** host) would
501
+ mean enumerating the IANA registry, and a denylist of the ones you happened to
502
+ think of is the shape that fails open.
503
+
504
+ Loopback is judged on the *parsed* URI, so `http://127.0.0.1@evil.example/` (host
505
+ `evil.example`) and `http://127.0.0.1.evil.example/` are both correctly seen as
506
+ remote, and a fragment is refused.
507
+
508
+ **What the allowlist does not cover.** It binds which URL a code may be sent to —
509
+ not *whose session at that URL* receives it. A hosted MCP client is one callback
510
+ shared by every one of its users, so an attacker can start a flow in their own
511
+ account there, send an operator the resulting authorize link, and have the code
512
+ land back at that client carrying the attacker's `state`. Whether the operator's
513
+ token then ends up in the attacker's account is decided by whether **the client**
514
+ binds `state` to the browser session that began the flow (RFC 6819 §4.4.1.7). An
515
+ authorization server cannot bind a code to a session it never saw, so this is not
516
+ something the bridge — or a full authorization server, which has the identical
517
+ exposure — can close. **Only allowlist clients you believe handle `state`
518
+ correctly.**
519
+
520
+ ### Deployment note
521
+
522
+ Every identifier the bridge publishes is derived from the live request origin
523
+ (`request.base_url`), which honours `X-Forwarded-Host`. **You MUST pin
524
+ `config.hosts`** so Rails' `HostAuthorization` rejects a forged header before it
525
+ reaches the bridge — Rails does *not* do this for you: it populates `config.hosts`
526
+ in development and leaves it **empty in production**, where empty means no
527
+ checking at all. Both metadata documents are served
528
+ `Cache-Control: no-store` regardless, so no shared cache can hand one client an
529
+ origin another client chose.
530
+
531
+ **Serve it over HTTPS** (`config.force_ssl = true`). The authorization page
532
+ receives a live access token in a POST body; on cleartext that token is on the
533
+ wire. The gem refuses a cleartext remote `redirect_uri` in the allowlist for the
534
+ same reason, but it cannot make your own origin HTTPS for you.
535
+
536
+ The engine adds `access_token` and `code_verifier` to `config.filter_parameters`
537
+ itself, so the pasted token stays out of your logs even on a host that ships no
538
+ filter list of its own — nothing to configure.
539
+
540
+ **It is additive to an OAuth provider you already run, and it claims nothing
541
+ origin-global.** The flow endpoints live under the engine's mount
542
+ (`/mcp/oauth/*`), so if you already serve OAuth at the conventional top-level
543
+ `/oauth/*` — as an app with Doorkeeper for its own API does — you keep every one of
544
+ those routes.
545
+
546
+ The metadata documents are **path-scoped** to the mount
547
+ (`/.well-known/oauth-protected-resource/mcp`), never the bare
548
+ `/.well-known/oauth-authorization-server`. That matters: the bare paths are
549
+ origin-global and mean *"the authorization server of this whole origin"*, which
550
+ belongs to a provider you already run, not to an MCP server sharing the host.
551
+ RFC 8414 §3.1 exists for exactly this — *"Using path components enables supporting
552
+ multiple issuers per host"* — and the MCP authorization spec (2025-11-25) requires
553
+ a client given a path-ful issuer to try the path-**inserted** URLs, with no root
554
+ fallback. So the issuer is your MCP endpoint URL, and both documents hang off it.
555
+
556
+ If your MCP endpoint IS its origin root (a dedicated MCP domain), there is no path
557
+ to insert and you get the bare paths — correct there, since your server really is
558
+ that origin's only authorization server. Set `oauth_resource_path = "/"`.
559
+
560
+ `oauth_allowed_redirect_uris` is empty and `oauth_allow_loopback_redirects`
561
+ is off by default, which leaves `config.oauth_bridge?` false and the routes
562
+ undrawn — the bridge cannot run without bounds on where codes may go. A satellite
563
+ never draws it at all (its tokens belong to its central app, so there is nothing
564
+ for it to authorize against), and neither does an authority with no
565
+ `token_authenticator`, since the bridge verifies the pasted token through it on
566
+ both legs and could not work without one.
567
+
568
+ The bridge's controller has its **own** parent, `config.oauth_parent_controller`
569
+ (default `ActionController::Base`), deliberately separate from the
570
+ `parent_controller` your transport uses. The transport is a JSON-only endpoint you
571
+ may well have pointed at `ActionController::API`, which cannot render an HTML view
572
+ — and the authorization page is one. Keeping them apart means enabling the bridge
573
+ changes nothing about your transport. Point it at your own `ApplicationController`
574
+ to inherit branding; the page renders with `layout: false` either way, so an app
575
+ layout that needs asset-pipeline context is not pulled in.
576
+
577
+ To restyle the page, define your own `app/views/mcp_toolkit/oauth/authorize.html.erb`
578
+ — your app's view path takes precedence over the engine's.
579
+
406
580
  ## Authority + gateway server (own tools + upstreams, no SDK)
407
581
 
408
582
  Beyond the SDK-backed satellite path, the toolkit also ships a **hand-rolled