mcp_toolkit 0.6.0 → 0.6.2

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: 375206ea98c72a64738894427abd86bf0e272f5fad3c359860cd684707e38da7
4
- data.tar.gz: e37f4ca9689b78c871daabc65110c7b5676760806d9eadaec45b30ea687f9b79
3
+ metadata.gz: fa488bbd5b90e15c5f7e9a70f8792be1ec070b0fdd6ce6e89f1279bd24bf59cf
4
+ data.tar.gz: 2e8953c00c9b6dab828efcd9c92f71990d8e09477f15851aa9976f0b942456ae
5
5
  SHA512:
6
- metadata.gz: 5920ce2f6e47c077d6f89a4078b74b82935a4be18f5a44cc415168b78e65f85aec6354ba7a3d81d1246f63d0c4d621503f5445b198ad42d80db2ab1ad129d750
7
- data.tar.gz: '091c72fd1efc0d5dbea0298af70355f21436e194d37534aa98629ea786442fa4477b00b656de0a03b8ac31485ea38d8ad783de1a59bb927f82df75922f028f79'
6
+ metadata.gz: 1f478ffa9b25834f25e4481b964015e8a73719525109986cb59972b6acecd27f9b47c7238ad435f8c30c491c88fee8631b0cd174bd68b4b61440a7996161d05a
7
+ data.tar.gz: 887ada2ad7fd66ba42feae592e8cc2ede5e2e89a075a1d2581d78e93bc8a0cd0bdb0bb7e5ffcb487efa22944ce51d303fa236f146b291f0987a242c4f4ddc6a7
data/CHANGELOG.md CHANGED
@@ -1,3 +1,95 @@
1
+ ## [0.6.2] - 2026-08-28
2
+
3
+ RFC 9207 authorization server issuer identification. Additive hardening, and the
4
+ fix for hosted clients that choose their redirect URI based on whether the
5
+ authorization server supports it.
6
+
7
+ ### Added
8
+
9
+ - **`iss` in the authorization response, and `authorization_response_iss_parameter_supported`
10
+ in the metadata** (RFC 9207). The parameter names which authorization server
11
+ produced a response, so a client registered with several cannot be induced to
12
+ redeem a code at the wrong one — the mixed-up authorization server attack.
13
+
14
+ This also unblocks **ChatGPT connectors**, which pick their `redirect_uri` from
15
+ whether the server meets RFC 9207: when it does, ChatGPT uses the stable
16
+ `https://chatgpt.com/connector_platform_oauth_redirect`; when it does not, it
17
+ mints a per-connector `https://chatgpt.com/connector/oauth/{callback_id}` that
18
+ no exact-match allowlist can express, and the connection fails with an
19
+ unregistered `redirect_uri`. Observed in production against a real customer
20
+ before this release.
21
+
22
+ Note for hosts: a client that picks its callback at *connector-creation* time
23
+ reads the metadata then. An existing connector created against a pre-0.6.2
24
+ server keeps the URI it already chose — **it has to be re-created** to pick up
25
+ the change.
26
+
27
+ `iss` is emitted on the one response this bridge redirects (`approve`); every
28
+ error path renders rather than redirecting, so there is no error response for it
29
+ to be absent from. It is byte-identical to the advertised `issuer` — clients
30
+ compare by exact string and do not normalise trailing slashes, paths, ports or
31
+ casing — and a spec pins the redirect against the discovery document rather than
32
+ against a literal.
33
+
34
+ `iss` is now response-owned alongside `code` and `state`: a caller that seeds one
35
+ into its own `redirect_uri` has it replaced, not appended. A value the caller
36
+ chose would defeat the point of the parameter.
37
+
38
+ **No control was relaxed.** The redirect allowlist is unchanged and still exact-match
39
+ (RFC 9700 §2.1); this release makes conforming clients ask for a URI already on it.
40
+
41
+ ## [0.6.1] - 2026-07-20
42
+
43
+ Two additive fixes for hosted MCP clients whose OAuth setup could not complete
44
+ against the 0.6.0 bridge: it now answers the metadata documents at the path-APPENDED
45
+ discovery locations as well as the RFC 8414 path-inserted ones, and its Dynamic
46
+ Client Registration response now states the registration (per RFC 7591) instead of
47
+ returning a bare `client_id`. Both are bridge-gated and change nothing for a host
48
+ with the bridge off, or for a client that already worked.
49
+
50
+ ### Fixed
51
+
52
+ - **Authorization-server discovery for clients that path-APPEND the well-known
53
+ segment.** For an MCP endpoint at `https://host/mcp`, the issuer is path-ful,
54
+ and RFC 8414 §3.1 places its metadata at the path-INSERTED
55
+ `/.well-known/oauth-authorization-server/mcp` — which the bridge already served.
56
+ But some MCP clients (observed with a hosted client in the wild) instead request
57
+ the path-APPENDED `https://host/mcp/.well-known/oauth-authorization-server`, got
58
+ a 404 with no fallback, never obtained a `registration_endpoint`, and reported a
59
+ registration failure. The bridge now answers the metadata at BOTH forms.
60
+
61
+ - **Dynamic Client Registration response now states the registration.** The stub
62
+ returned only a `client_id`. A strict client validates that the `redirect_uris`
63
+ it registered come back and abandons a registration that drops them — a plausible
64
+ cause of the same "couldn't register" failure, independent of the discovery gap
65
+ above. `register` now returns the client's `redirect_uris` and `client_name`
66
+ alongside `client_id_issued_at` (RFC 7591 §3.2.1). Echoing a `redirect_uri`
67
+ AUTHORIZES nothing: the bridge still stores no client, and `authorize`/`token`
68
+ check every one against the host allowlist independently, so a value reflected
69
+ here is not thereby permitted.
70
+
71
+ The `token_endpoint_auth_method` and the grant/response types are **substituted,
72
+ not echoed** — RFC 7591 §3.2.1 states the metadata as REGISTERED and lets a server
73
+ replace what it does not support. Reflecting the client's request would contradict
74
+ the discovery document that named this endpoint (`token_endpoint_auth_methods_supported:
75
+ ["none"]`, `grant_types_supported: ["authorization_code"]`) and promise a flow the
76
+ token endpoint rejects — a reflected `refresh_token` is a refresh answered
77
+ `unsupported_grant_type`. The supported sets are now named once and shared by both
78
+ documents, so they cannot drift apart.
79
+
80
+ ### Added
81
+
82
+ - **Path-appended metadata routes under the engine mount** (drawn only when
83
+ `oauth_bridge?`): `GET <mcp>/.well-known/oauth-authorization-server`,
84
+ `GET <mcp>/.well-known/oauth-protected-resource`, and the OIDC discovery alias
85
+ `GET <mcp>/.well-known/openid-configuration` (which returns the
86
+ authorization-server document). All stay UNDER the mount, so they claim nothing
87
+ origin-global and cannot collide with an OAuth provider the host already runs;
88
+ the RFC 8414 path-inserted documents the host draws at the origin root are
89
+ unchanged. Every identifier is still derived from the live request origin, so
90
+ the document served at an appended location is byte-identical to the inserted
91
+ one, and both carry `Cache-Control: no-store`.
92
+
1
93
  ## [0.6.0] - 2026-07-16
2
94
 
3
95
  An OAuth 2.1 authorization bridge for the authority role, so hosted MCP clients
data/README.md CHANGED
@@ -244,6 +244,98 @@ discovery tool, a custom serializer may also expose `declared_attributes` /
244
244
 
245
245
  ---
246
246
 
247
+ ## Reading data: pagination, sparse fieldsets, filters
248
+
249
+ The four generic tools serve the same grammar on both the satellite and authority
250
+ paths (they share the executors). `list` accepts:
251
+
252
+ | Argument | Shape | Notes |
253
+ |---|---|---|
254
+ | `resource` | String | the registered resource name |
255
+ | `limit` / `offset` | Integer | page size (default 25, max 100) / offset (default 0) |
256
+ | `fields` | Array or comma-separated String | sparse fieldset — attribute and/or relationship names, one flat namespace. Unknown names raise `InvalidParams` rather than being silently dropped |
257
+ | `filter` | Object | per-attribute filters, applied **on top of** the account scope (they can only narrow, never widen) |
258
+ | *(resource-specific)* | — | a resource's own `filter` declarations arrive as **top-level** arguments, not inside `filter` |
259
+
260
+ `list` returns `{ "<resource>": [...], "meta": { total_count, limit, offset } }`.
261
+
262
+ Clients discover all of this at runtime: `resources` lists each resource with
263
+ `filterable` and its usage `note`, and `resource_schema` advertises every
264
+ attribute's type and accepted `operators`, the valid `fields` values, the
265
+ resource's own `resource_filters`, and any companion-key requirements.
266
+
267
+ ### Filter values
268
+
269
+ A filter value is either a **bare value** or an `{ op:, value: }` condition.
270
+
271
+ ```jsonc
272
+ { "filter": { "status": "active" } } // equality
273
+ { "filter": { "status": "active,archived" } } // IN set (comma-separated)
274
+ { "filter": { "status": ["active", "archived"] } } // IN set (array)
275
+ { "filter": { "archived_at": "null" } } // IS NULL ("null" token, or a JSON null)
276
+ { "filter": { "created_at": { "op": "gteq", "value": "2026-01-01" } } }
277
+ { "filter": { "created_at": [ // conditions AND together
278
+ { "op": "gteq", "value": "2026-01-01" },
279
+ { "op": "lt", "value": "2026-02-01" }
280
+ ] } }
281
+ ```
282
+
283
+ Under the default `:tokenized` semantics a bare `""` means "no filter", and a
284
+ comma splits an IN set. Set `bare_filter_value_semantics = :literal` to match
285
+ bare values verbatim instead; operator conditions behave identically in both.
286
+
287
+ ### Operators by column type
288
+
289
+ `resource_schema` advertises these per attribute; `filter_operator_overrides`
290
+ narrows them per type.
291
+
292
+ | Column type | Operators |
293
+ |---|---|
294
+ | `integer` / `float` / `decimal` / `datetime` | `eq` `not_eq` `gt` `gteq` `lt` `lteq` |
295
+ | `date` | `eq` `not_eq` `gt` `gteq` `lt` `lteq` `in` |
296
+ | `string` / `text` | `eq` `in` `not_eq` `matches` `does_not_match` |
297
+ | `boolean` | `eq` `not_eq` |
298
+ | anything else (`uuid`, `enum`, `jsonb`, `citext`, …) | `eq` `in` |
299
+
300
+ `matches` / `does_not_match` are SQL `LIKE`, with wildcards in the value escaped
301
+ by `config.sql_sanitizer`. Only `eq` / `in` / `not_eq` accept a null (`IS NULL` /
302
+ `IS NOT NULL`); a comparison or `LIKE` against null raises `InvalidParams`,
303
+ because it could never match a row. IN-set elements must be non-null scalars —
304
+ SQL `IN` cannot match NULL, so a null-or-nothing condition is expressed as the
305
+ filter's single scalar value.
306
+
307
+ These refusals are deliberate: each previously returned a silently wrong or empty
308
+ result, which is far harder for a client to notice than an error.
309
+
310
+ ### Resource-specific filters and companion keys
311
+
312
+ `filterable` maps public filter keys onto backing columns. When the generic
313
+ equality/operator grammar cannot express a filter, declare a `filter` block —
314
+ it takes a **top-level** request param and narrows the relation itself:
315
+
316
+ ```ruby
317
+ filterable status: :status, owner_id: :owner_id
318
+
319
+ filter :for_project, type: :integer, description: "Only widgets in this project" do |relation, id|
320
+ relation.joins(:board).where(boards: { project_id: id })
321
+ end
322
+ ```
323
+
324
+ `filter_requirements` declares that a key is meaningless alone — a polymorphic
325
+ foreign key is type-ambiguous without its `*_type`, so filtering on it alone
326
+ would silently match rows across types:
327
+
328
+ ```ruby
329
+ filter_requirements subject_id: :subject_type
330
+ ```
331
+
332
+ `list` then rejects `subject_id` unless `subject_type` comes with it, and
333
+ `resource_schema` advertises the requirement under the relationship's
334
+ `filter.requires` so a client can satisfy it without guessing. Both accept a Hash
335
+ or a lazily-resolved callable.
336
+
337
+ ---
338
+
247
339
  ## Configuration reference
248
340
 
249
341
  | Setting | Default | Purpose |
@@ -262,9 +354,11 @@ discovery tool, a custom serializer may also expose `declared_attributes` /
262
354
  | `session_ttl` | `3600` | session sliding TTL (s) |
263
355
  | `protocol_version` | `nil` (negotiate) | pin an MCP protocol version (satellite/upstream client) |
264
356
  | `supported_protocol_versions` | `Protocol::SUPPORTED_VERSIONS` | version set the authority dispatcher negotiates |
265
- | `tool_provider` | `nil` | authority: the host's api-agnostic tool catalog (see below) |
357
+ | `tool_provider` | composed (see below) | authority: the host's api-agnostic tool catalog. Left **unset it composes itself** — the generic `RegistryToolProvider` (only when resources are registered) followed by every `extra_tool_providers` entry — so the common case needs no provider plumbing. Assign explicitly to take full control |
358
+ | `extra_tool_providers` | `[]` | authority: extra providers (or bare tool **classes**, auto-wrapped in a `SingleToolProvider`) composed after the generic tools when `tool_provider` is unset |
266
359
  | `generic_tool_name_prefix` | `""` | authority: prefix namespacing the four generic Registry-backed tools (e.g. `"foo_"` → `foo_resources` …) |
267
360
  | `rate_limiter` / `usage_recorder` / `usage_flusher` | `nil` | authority transport billing hooks (config callables) |
361
+ | `session_data_builder` | `nil` | authority: builds the opaque `Session#data` payload (e.g. bind a session to a token id so revoking the token kills it) |
268
362
  | `rate_limit_max_requests` | `nil` (off) | authority: per-principal request cap for the built-in `RateLimiter`; `nil` disables rate limiting |
269
363
  | `rate_limit_window` | `3600` | authority: fixed rate-limit window (s); ignored while `rate_limit_max_requests` is `nil` |
270
364
  | `superuser_resolver` | `nil` | optional `->(principal) -> Boolean` for `Context#superuser?`; `nil` = duck-type `principal.superuser?` |
@@ -276,13 +370,48 @@ discovery tool, a custom serializer may also expose `declared_attributes` /
276
370
  | `upstream_list_ttl` | `900` | gateway: TTL (s) for an upstream's cached tool list |
277
371
  | `logger` | `nil` | optional logger for gateway/session diagnostics (`Rails.logger`) |
278
372
 
373
+ ### Data path, filtering, and safety caps
374
+
375
+ These govern how `list` reads a filter and how much work one request may ask for.
376
+ The defaults are the gem's own grammar; the first three exist so a host migrating
377
+ an **existing** MCP endpoint onto the gem can preserve its pre-gem contract
378
+ byte-for-byte (see [Migrating an existing endpoint](#migrating-an-existing-mcp-endpoint)).
379
+
380
+ | Setting | Default | Purpose |
381
+ |---|---|---|
382
+ | `bare_filter_value_semantics` | `:tokenized` | how a **bare** filter value is read. `:tokenized` applies the comma/IN/`"null"` grammar below; `:literal` sends the value to the WHERE clause verbatim (`"a,b"` is one string, `"null"` is the literal string). Operator conditions are identical either way |
383
+ | `non_numeric_pk_order` | `:created_at` | ordering for non-numeric-PK resources. `:created_at` (with the PK as tiebreaker, so offset pagination is a total order) or `:primary_key` to preserve an `ORDER BY id` contract |
384
+ | `filter_operator_overrides` | `{}` | per-column-type overrides of the advertised **and** enforced operator sets, e.g. `{ text: %w[eq in], date: %w[eq in] }`. Single source, so `resource_schema` and the executor cannot disagree. Rejects, at assignment, any operator outside `Filtering::AREL_PREDICATIONS` |
385
+ | `max_filter_values` | `500` | caps how many values one IN-set may resolve to, and how many operator conditions may be ANDed on one attribute, so a valid token cannot emit an unbounded IN clause / AND-chain. `nil` disables |
386
+ | `max_batch_size` | `50` | authority: caps the JSON-RPC calls one POST batch may carry. Rate limiting is per-HTTP-request, so an uncapped batch would fan out unbounded work under a single tick. `nil` disables |
387
+ | `sql_sanitizer` | `McpToolkit::SqlSanitizer` | escapes LIKE wildcards in `matches` / `does_not_match`; injectable so a non-Rails host can supply its own |
388
+
389
+ ### OAuth bridge (authority-only, opt-in)
390
+
391
+ All inert unless the bridge is switched on — see
392
+ [OAuth authorization bridge](#oauth-authorization-bridge-authority-only-opt-in)
393
+ for what it is and why the redirect policy is shaped the way it is.
394
+
395
+ | Setting | Default | Purpose |
396
+ |---|---|---|
397
+ | `oauth_allowed_redirect_uris` | `[]` | exact-string allowlist of redirect targets. Validated at assignment (an unparseable, scheme-less, fragment-bearing or opaque URI raises, as does cleartext `http://` to a remote host and the `javascript:`/`data:`/`file:` schemes) and **frozen** once assigned |
398
+ | `oauth_allow_loopback_redirects` | `false` | accept `http://127.0.0.1:*` / `localhost` / `[::1]` without an allowlist entry (RFC 8252 §7.3 — the client picks an ephemeral port, so no list could name it) |
399
+ | `oauth_resource_path` | `"/mcp"` | must match the engine's mount point; `"/"` when the MCP endpoint IS the origin root |
400
+ | `oauth_authorization_code_ttl` | `60` | authorization-code lifetime (s) |
401
+ | `oauth_signing_secret` | Rails' `secret_key_base` | mixed into the key that seals a code's cache entry, so the cache, the logs and the code together still open nothing. Validated at assignment |
402
+ | `oauth_parent_controller` | `"ActionController::Base"` | superclass of the bridge's controller, deliberately **separate** from `parent_controller` — the authorization page is HTML and `ActionController::API` cannot render it |
403
+
404
+ Either naming a redirect target or enabling loopback is what flips
405
+ `config.oauth_bridge?` on; with neither, no route is drawn.
406
+
279
407
  ## Public API surface
280
408
 
281
409
  - `McpToolkit.configure { |c| ... }`, `McpToolkit.config`, `McpToolkit.registry`,
282
410
  `McpToolkit.reset_config!`
283
411
  - `McpToolkit::Registry#register(name) { ... }` (DSL: `model`, `serializer`,
284
412
  `scope`, `description`, `note`, `filterable`, `filter(name, type:, description:,
285
- &applier)`, `superusers_only!`, `required_permissions_scope`) +
413
+ &applier)`, `filter_requirements`, `superusers_only!`,
414
+ `required_permissions_scope`, `extra(key, value)` for host-defined metadata) +
286
415
  `#default_required_permissions_scope`
287
416
  - `McpToolkit::Serializer::Base` (DSL: `attributes`, `has_one`, `has_many`,
288
417
  `translates`)
@@ -342,6 +471,18 @@ McpToolkit.configure do |c|
342
471
  end
343
472
  ```
344
473
 
474
+ Declaring the whole set from ENV has two gotchas every gateway host rediscovers —
475
+ re-registering on a code reload duplicates entries, and a blank ENV var must not
476
+ become an upstream. `register_upstreams_from_env` handles both (it resets the
477
+ registry first, so it is idempotent, and skips blank urls):
478
+
479
+ ```ruby
480
+ c.register_upstreams_from_env(
481
+ "notifications" => "NOTIFICATIONS_SERVER_URL",
482
+ "billing" => "BILLING_SERVER_URL"
483
+ )
484
+ ```
485
+
345
486
  ### Aggregate upstream tool lists
346
487
 
347
488
  `Aggregator#tool_definitions` returns every upstream's tools, namespaced, pulled
@@ -493,6 +634,25 @@ So **every target must be named by exact string**, with exactly one exception:
493
634
  | 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
635
  | 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
636
 
637
+ ### If a client's callback looks impossible to name, check RFC 9207 first
638
+
639
+ Some hosted clients mint a **per-connector** callback (`https://vendor.example/connector/oauth/{id}`),
640
+ which no exact-match list can express — and the obvious response, matching the
641
+ host and path by pattern, is the wrong one. A prefix turns an attacker into
642
+ someone who can **name their own destination inside it**: they create their own
643
+ connector at that vendor, put their callback in the authorize URL, and the code
644
+ goes somewhere they control. Exact matching leaves a weaker residual (a code sent
645
+ to the vendor's *legitimate* shared callback, separated by the client binding
646
+ `state` to the initiating session — RFC 6819 §4.4.1.7); a prefix removes the need
647
+ for any of that to go wrong.
648
+
649
+ At least one such client picks the per-connector form **only when the
650
+ authorization server does not implement RFC 9207**, and uses a single stable
651
+ callback when it does. Since 0.6.2 this gem implements it, so the fix for that
652
+ class of failure is to be conforming, not to be permissive. Before relaxing the
653
+ policy for a client that "cannot be named", check whether it is asking you for a
654
+ capability instead.
655
+
496
656
  The loopback exception exists because an allowlist entry is *impossible* there,
497
657
  not because native clients are trusted. A private-use scheme keeps the code on the
498
658
  device too, but nothing forces it to be unnamed — and whole **schemes** cannot be
@@ -543,16 +703,26 @@ origin-global.** The flow endpoints live under the engine's mount
543
703
  `/oauth/*` — as an app with Doorkeeper for its own API does — you keep every one of
544
704
  those routes.
545
705
 
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.
706
+ The metadata documents are **path-scoped** to the mount, never the bare
707
+ origin-global `/.well-known/oauth-authorization-server`. That matters: the bare
708
+ paths are origin-global and mean *"the authorization server of this whole origin"*,
709
+ which belongs to a provider you already run, not to an MCP server sharing the host.
551
710
  RFC 8414 §3.1 exists for exactly this — *"Using path components enables supporting
552
711
  multiple issuers per host"* — and the MCP authorization spec (2025-11-25) requires
553
712
  a client given a path-ful issuer to try the path-**inserted** URLs, with no root
554
713
  fallback. So the issuer is your MCP endpoint URL, and both documents hang off it.
555
714
 
715
+ A path-ful issuer, though, has two readings of *where* under the origin its
716
+ metadata lives, and MCP clients disagree: some **insert** the well-known segment
717
+ before the resource path (`/.well-known/oauth-authorization-server/mcp` — the RFC
718
+ form the host draws), others **append** it after
719
+ (`/mcp/.well-known/oauth-authorization-server`). The bridge serves both: the
720
+ inserted forms at the origin root (`draw_oauth_metadata_routes`), and the appended
721
+ forms — plus the `openid-configuration` OIDC alias — under the engine mount,
722
+ automatically whenever the bridge is on. Both stay path-scoped and claim nothing
723
+ origin-global; a client discovers the authorization server whichever convention it
724
+ follows.
725
+
556
726
  If your MCP endpoint IS its origin root (a dedicated MCP domain), there is no path
557
727
  to insert and you get the bare paths — correct there, since your server really is
558
728
  that origin's only authorization server. Set `oauth_resource_path = "/"`.
@@ -650,12 +820,18 @@ McpToolkit.configure do |c|
650
820
  note "Read-only projection; do not interpret status codes without domain context."
651
821
  scope { |account| Widget.where(account_id: account.id) }
652
822
  end
653
-
654
- # The generic tools, served over config.registry:
655
- c.tool_provider = McpToolkit::Authority::RegistryToolProvider.new(config: c)
656
823
  end
657
824
  ```
658
825
 
826
+ That is the whole setup — **no `tool_provider` assignment is needed.** Left unset,
827
+ it composes itself from the registry (plus any `extra_tool_providers`), so
828
+ registering resources is enough to serve the generic tools. Assign one explicitly
829
+ only to take full control of the catalog:
830
+
831
+ ```ruby
832
+ c.tool_provider = McpToolkit::Authority::RegistryToolProvider.new(config: c)
833
+ ```
834
+
659
835
  Each generic tool resolves the `resource` argument against the registry, refuses a
660
836
  `superusers_only!` resource for a non-superuser (and hides it from `resources`),
661
837
  enforces the resource's `required_permissions_scope`, and requires a resolved
@@ -675,8 +851,16 @@ c.generic_tool_name_prefix = "foo_" # advertised + resolved as foo_resources,
675
851
  The prefix applies only to these four generic tools; a composed bespoke provider's
676
852
  own tool names are unaffected.
677
853
 
678
- To serve the generic tools **and** your own bespoke tools behind one provider,
679
- compose them:
854
+ To serve the generic tools **and** your own bespoke tools, just name the extras —
855
+ they are composed after the generic ones, and a bare tool **class** is wrapped for
856
+ you:
857
+
858
+ ```ruby
859
+ c.extra_tool_providers = [MyApp::Tools::AuditLog] # a class, or a provider object
860
+ ```
861
+
862
+ Compose by hand only when you want to control the order or drop the generic tools
863
+ entirely:
680
864
 
681
865
  ```ruby
682
866
  c.tool_provider = McpToolkit::Authority::CompositeToolProvider.new(
@@ -734,6 +918,27 @@ it to gate `superusers_only!` resources; with no resolver it duck-types
734
918
  Point your `POST /mcp` route at the subclass (or mount the engine for a pure host);
735
919
  keep `POST /mcp/tokens/introspect` on the gem's `TokensController`.
736
920
 
921
+ ### Migrating an existing MCP endpoint
922
+
923
+ If you are moving an MCP endpoint you already ship onto the gem, your clients
924
+ hold the *old* contract. Several seams exist purely so that contract survives the
925
+ move — adopt them at first, then retire them deliberately rather than breaking
926
+ clients on cutover:
927
+
928
+ | If your endpoint… | Set |
929
+ |---|---|
930
+ | matched bare filter values verbatim (no comma/`"null"` grammar) | `bare_filter_value_semantics = :literal` |
931
+ | ordered non-numeric-PK lists by `id` | `non_numeric_pk_order = :primary_key` |
932
+ | advertised a narrower operator set | `filter_operator_overrides`, e.g. `{ text: %w[eq in], date: %w[eq in] }` |
933
+ | namespaced its generic tool names | `generic_tool_name_prefix` |
934
+ | filtered a polymorphic FK safely | `filter_requirements` on the resource |
935
+
936
+ One deliberate delta is **not** revertible: `{ op: "in", value: "a,b" }` now
937
+ splits into an IN set (previously only `eq` split, and `in` matched the literal
938
+ string `'a,b'`). Under the tokenized operator grammar there is no way to express
939
+ a literal comma inside an IN element — express such a match as a bare equality
940
+ value, which `:literal` semantics match verbatim.
941
+
737
942
  ### Lazy `parent_controller`
738
943
 
739
944
  The gem's controllers subclass `config.parent_controller`. That parent is read
data/config/routes.rb CHANGED
@@ -38,17 +38,31 @@ McpToolkit::Engine.routes.draw do
38
38
  # route above: the routes file is evaluated through the routes_reloader, after
39
39
  # the host's initializers/to_prepare, so the config is already set.
40
40
  #
41
- # The two metadata documents are NOT here: a client looks for them at the origin
42
- # root, which an engine mounted under a path cannot draw. The host draws them
43
- # with `McpToolkit.draw_oauth_metadata_routes(self)`.
44
- # `format: false` on each, as on the metadata routes the host draws: without it
45
- # Rails' optional `(.:format)` segment matches, so `/mcp/oauth/authorize.json`
46
- # reaches the action, finds no JSON template, and 500s — an unauthenticated
47
- # error on a public endpoint, for a format the bridge never speaks.
41
+ # `format: false` on each: without it Rails' optional `(.:format)` segment
42
+ # matches, so `/mcp/oauth/authorize.json` reaches the action, finds no JSON
43
+ # template, and 500s — an unauthenticated error on a public endpoint, for a
44
+ # format the bridge never speaks.
48
45
  if McpToolkit.config.oauth_bridge?
49
46
  get "oauth/authorize", to: "oauth#authorize", format: false
50
47
  post "oauth/authorize", to: "oauth#approve", format: false
51
48
  post "oauth/token", to: "oauth#token", format: false
52
49
  post "oauth/register", to: "oauth#register", format: false
50
+
51
+ # The metadata documents, ALSO served path-APPENDED to the mount
52
+ # (`/mcp/.well-known/oauth-authorization-server`), in addition to the RFC 8414
53
+ # §3.1 / RFC 9728 §3.1 path-INSERTED locations the host draws at the origin
54
+ # root via `McpToolkit.draw_oauth_metadata_routes`. A path-ful issuer has two
55
+ # readings of where its metadata lives, and MCP clients disagree: some INSERT
56
+ # the well-known segment before the resource path (the RFC form), others APPEND
57
+ # it after. Observed in the wild — a hosted client requesting
58
+ # `<mcp>/.well-known/oauth-authorization-server` and getting a 404 it could not
59
+ # recover from, so it never reached registration. Serving both meets either
60
+ # convention. Unlike the origin-root bare paths, these stay UNDER the mount, so
61
+ # they claim nothing origin-global and cannot collide with an OAuth provider
62
+ # the host already runs. `openid-configuration` is the OIDC discovery alias a
63
+ # client may probe instead; it answers the same authorization-server document.
64
+ get ".well-known/oauth-authorization-server", to: "oauth#authorization_server", format: false
65
+ get ".well-known/oauth-protected-resource", to: "oauth#protected_resource", format: false
66
+ get ".well-known/openid-configuration", to: "oauth#authorization_server", format: false
53
67
  end
54
68
  end
@@ -31,12 +31,18 @@ module McpToolkit::Oauth::ControllerMethods
31
31
 
32
32
  # Query parameters the callback response owns: whatever a client put in its own
33
33
  # redirect_uri, these are set by the redirect and not carried over from it.
34
- RESPONSE_OWNED_QUERY_KEYS = %w[code state].freeze
34
+ RESPONSE_OWNED_QUERY_KEYS = %w[code state iss].freeze
35
35
 
36
36
  # RFC 7636 §4.1: 43–128 unreserved characters. The challenge is §4.2's
37
37
  # base64url of a SHA-256, which is always exactly 43 of the same alphabet.
38
38
  PKCE_VALUE = /\A[A-Za-z0-9\-._~]{43,128}\z/
39
39
 
40
+ # What the token endpoint actually honours. Named once because the discovery
41
+ # document and the registration response MUST agree — a client reads the first
42
+ # to find the second, so a disagreement is this server contradicting itself.
43
+ SUPPORTED_GRANT_TYPES = %w[authorization_code].freeze
44
+ SUPPORTED_RESPONSE_TYPES = %w[code].freeze
45
+
40
46
  included do
41
47
  # Safe to disable: the token endpoint is called server-to-server without a CSRF
42
48
  # token, and `approve` never acts on ambient authority — it reads no session and
@@ -86,22 +92,39 @@ module McpToolkit::Oauth::ControllerMethods
86
92
  authorization_endpoint: mcp_oauth_endpoint_url("authorize"),
87
93
  token_endpoint: mcp_oauth_endpoint_url("token"),
88
94
  registration_endpoint: mcp_oauth_endpoint_url("register"),
89
- response_types_supported: ["code"],
90
- grant_types_supported: ["authorization_code"],
95
+ response_types_supported: SUPPORTED_RESPONSE_TYPES,
96
+ grant_types_supported: SUPPORTED_GRANT_TYPES,
91
97
  code_challenge_methods_supported: ["S256"],
92
- token_endpoint_auth_methods_supported: ["none"]
98
+ token_endpoint_auth_methods_supported: ["none"],
99
+ authorization_response_iss_parameter_supported: true
93
100
  }
94
101
  end
95
102
 
96
- # Stateless: persisting a `client_id` nothing reads would only grow a table of
97
- # strings the bridge never consults.
103
+ # Stateless: nothing here is persisted (no endpoint reads a `client_id`). The
104
+ # response still names the `redirect_uris` the client sent, because a strict
105
+ # client validates that they come back and abandons a registration that drops
106
+ # them — the failure a hosted client hit against the pre-0.6.1 stub, which
107
+ # returned only a `client_id`. That echo AUTHORIZES nothing: `authorize` and
108
+ # `token` check every `redirect_uri` against the host's allowlist independently.
109
+ #
110
+ # The auth method and the grant/response types are SUBSTITUTED rather than
111
+ # echoed — RFC 7591 §3.2.1 states the metadata as REGISTERED, and lets a server
112
+ # replace what it does not support. Reflecting the client's request instead
113
+ # would contradict `authorization_server`, which is where that client got this
114
+ # endpoint, and would promise a flow `token` rejects: a `refresh_token` echoed
115
+ # back is a refresh answered `unsupported_grant_type`. No `client_secret` is
116
+ # issued, so `none` is the only auth method a client here could perform.
98
117
  def register
99
- render json: {
118
+ body = {
100
119
  client_id: SecureRandom.uuid,
120
+ client_id_issued_at: Time.now.to_i,
121
+ redirect_uris: mcp_oauth_param_list(:redirect_uris),
101
122
  token_endpoint_auth_method: "none",
102
- grant_types: ["authorization_code"],
103
- response_types: ["code"]
104
- }, status: :created
123
+ grant_types: mcp_oauth_registered_subset(:grant_types, SUPPORTED_GRANT_TYPES),
124
+ response_types: mcp_oauth_registered_subset(:response_types, SUPPORTED_RESPONSE_TYPES)
125
+ }
126
+ body[:client_name] = params[:client_name].to_s if params[:client_name].present?
127
+ render json: body, status: :created
105
128
  end
106
129
 
107
130
  # `formats: [:html]` because there is only an HTML template and `Accept` picks
@@ -151,6 +174,19 @@ module McpToolkit::Oauth::ControllerMethods
151
174
  McpToolkit.config
152
175
  end
153
176
 
177
+ # A registration parameter as a plain array of strings — RFC 7591 lists arrive
178
+ # as JSON arrays; empty when the client sent none.
179
+ def mcp_oauth_param_list(key)
180
+ Array(params[key]).map(&:to_s)
181
+ end
182
+
183
+ # What the client asked for, narrowed to what this server honours. A client that
184
+ # asked for NOTHING supported gets the default rather than an empty array, which
185
+ # would state a registration able to do nothing at all.
186
+ def mcp_oauth_registered_subset(key, supported)
187
+ (mcp_oauth_param_list(key) & supported).presence || supported
188
+ end
189
+
154
190
  # ---- request validation ---------------------------------------------------
155
191
 
156
192
  # Halts both legs before their action runs. Both problems RENDER rather than
@@ -373,6 +409,7 @@ module McpToolkit::Oauth::ControllerMethods
373
409
  pairs = mcp_oauth_preserved_query_pairs(existing)
374
410
  pairs << ["code", code]
375
411
  pairs << ["state", params[:state].to_s] if params[:state].present?
412
+ pairs << ["iss", mcp_oauth_issuer]
376
413
  "#{base}?#{URI.encode_www_form(pairs)}"
377
414
  end
378
415
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module McpToolkit
4
- VERSION = "0.6.0"
4
+ VERSION = "0.6.2"
5
5
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp_toolkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Karol Galanciak
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-08-28 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: zeitwerk
@@ -170,6 +171,7 @@ metadata:
170
171
  source_code_uri: https://github.com/BookingSync/mcp_toolkit
171
172
  changelog_uri: https://github.com/BookingSync/mcp_toolkit/blob/master/CHANGELOG.md
172
173
  rubygems_mfa_required: 'true'
174
+ post_install_message:
173
175
  rdoc_options: []
174
176
  require_paths:
175
177
  - lib
@@ -184,7 +186,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
184
186
  - !ruby/object:Gem::Version
185
187
  version: '0'
186
188
  requirements: []
187
- rubygems_version: 3.6.9
189
+ rubygems_version: 3.5.22
190
+ signing_key:
188
191
  specification_version: 4
189
192
  summary: Opinionated toolkit for building account-scoped, read-only MCP servers.
190
193
  test_files: []