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 +4 -4
- data/CHANGELOG.md +92 -0
- data/README.md +217 -12
- data/config/routes.rb +21 -7
- data/lib/mcp_toolkit/oauth/controller_methods.rb +47 -10
- data/lib/mcp_toolkit/version.rb +1 -1
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa488bbd5b90e15c5f7e9a70f8792be1ec070b0fdd6ce6e89f1279bd24bf59cf
|
|
4
|
+
data.tar.gz: 2e8953c00c9b6dab828efcd9c92f71990d8e09477f15851aa9976f0b942456ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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` |
|
|
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!`,
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
|
679
|
-
|
|
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
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
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:
|
|
90
|
-
grant_types_supported:
|
|
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:
|
|
97
|
-
#
|
|
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
|
-
|
|
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:
|
|
103
|
-
response_types:
|
|
104
|
-
}
|
|
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
|
|
data/lib/mcp_toolkit/version.rb
CHANGED
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.
|
|
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:
|
|
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.
|
|
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: []
|