mcp_toolkit 0.5.0 → 0.6.1

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: d9e89469906c5d75ea46174ecf1e7368ffbe93669ec80526e559f683c778516b
4
- data.tar.gz: 6c97881a9edbd3df1185069364c99457e90fecb4ecf95a7a6c85612e9f5d0d2e
3
+ metadata.gz: af5e847538e7fe188e7fb0b0b509f0f430d2c3d33c84fa21350e30367a409578
4
+ data.tar.gz: dc3233698fd64947b42330fe51bd230e249f37e9044d303eaf1915ea213e1695
5
5
  SHA512:
6
- metadata.gz: 21daf10ed0e045d978a698482d87581222994cb3ba7aba15f6252f236b5d102bb71a27c29e6d0f05f2e762889fd3dbc8fb58fb3ca5c8293870b46d81145bf540
7
- data.tar.gz: 96ddb6e0fb9c72353b17d725a171b6c065537cabb55b8d9f3d208d6bad71bf76291c5394678dedba38e742474d6351234bef6d56240fff0dd9ed5649ce465196
6
+ metadata.gz: a1fc3088f0a6532ed7a52e7cf13e13ce372edbdcddd61f255fd93cb28df6c0c5fe3a3c17ef98cedf8c5a3be5f8c72d58f8f9bdbc28eac1e7b5ae3308a83cd21f
7
+ data.tar.gz: fb9a05e81f3895dc89d65dc45a33deef371387d8ba14bf223454f666b8bdeb9c828876a66ba9f6c54b19019b416cd88dc6c66dd5ab562fdc577d1b5148872aae
data/CHANGELOG.md CHANGED
@@ -1,3 +1,242 @@
1
+ ## [0.6.1] - 2026-07-20
2
+
3
+ Two additive fixes for hosted MCP clients whose OAuth setup could not complete
4
+ against the 0.6.0 bridge: it now answers the metadata documents at the path-APPENDED
5
+ discovery locations as well as the RFC 8414 path-inserted ones, and its Dynamic
6
+ Client Registration response now states the registration (per RFC 7591) instead of
7
+ returning a bare `client_id`. Both are bridge-gated and change nothing for a host
8
+ with the bridge off, or for a client that already worked.
9
+
10
+ ### Fixed
11
+
12
+ - **Authorization-server discovery for clients that path-APPEND the well-known
13
+ segment.** For an MCP endpoint at `https://host/mcp`, the issuer is path-ful,
14
+ and RFC 8414 §3.1 places its metadata at the path-INSERTED
15
+ `/.well-known/oauth-authorization-server/mcp` — which the bridge already served.
16
+ But some MCP clients (observed with a hosted client in the wild) instead request
17
+ the path-APPENDED `https://host/mcp/.well-known/oauth-authorization-server`, got
18
+ a 404 with no fallback, never obtained a `registration_endpoint`, and reported a
19
+ registration failure. The bridge now answers the metadata at BOTH forms.
20
+
21
+ - **Dynamic Client Registration response now states the registration.** The stub
22
+ returned only a `client_id`. A strict client validates that the `redirect_uris`
23
+ it registered come back and abandons a registration that drops them — a plausible
24
+ cause of the same "couldn't register" failure, independent of the discovery gap
25
+ above. `register` now returns the client's `redirect_uris` and `client_name`
26
+ alongside `client_id_issued_at` (RFC 7591 §3.2.1). Echoing a `redirect_uri`
27
+ AUTHORIZES nothing: the bridge still stores no client, and `authorize`/`token`
28
+ check every one against the host allowlist independently, so a value reflected
29
+ here is not thereby permitted.
30
+
31
+ The `token_endpoint_auth_method` and the grant/response types are **substituted,
32
+ not echoed** — RFC 7591 §3.2.1 states the metadata as REGISTERED and lets a server
33
+ replace what it does not support. Reflecting the client's request would contradict
34
+ the discovery document that named this endpoint (`token_endpoint_auth_methods_supported:
35
+ ["none"]`, `grant_types_supported: ["authorization_code"]`) and promise a flow the
36
+ token endpoint rejects — a reflected `refresh_token` is a refresh answered
37
+ `unsupported_grant_type`. The supported sets are now named once and shared by both
38
+ documents, so they cannot drift apart.
39
+
40
+ ### Added
41
+
42
+ - **Path-appended metadata routes under the engine mount** (drawn only when
43
+ `oauth_bridge?`): `GET <mcp>/.well-known/oauth-authorization-server`,
44
+ `GET <mcp>/.well-known/oauth-protected-resource`, and the OIDC discovery alias
45
+ `GET <mcp>/.well-known/openid-configuration` (which returns the
46
+ authorization-server document). All stay UNDER the mount, so they claim nothing
47
+ origin-global and cannot collide with an OAuth provider the host already runs;
48
+ the RFC 8414 path-inserted documents the host draws at the origin root are
49
+ unchanged. Every identifier is still derived from the live request origin, so
50
+ the document served at an appended location is byte-identical to the inserted
51
+ one, and both carry `Cache-Control: no-store`.
52
+
53
+ ## [0.6.0] - 2026-07-16
54
+
55
+ An OAuth 2.1 authorization bridge for the authority role, so hosted MCP clients
56
+ that will only authenticate by discovering an authorization server and running a
57
+ browser flow can reach a server whose tokens are issued out-of-band. Additive and
58
+ opt-in: a host that configures nothing behaves exactly as it did on 0.5.0.
59
+
60
+ ### Added
61
+
62
+ - **OAuth authorization bridge (authority-only, opt-in).** A standards-shaped
63
+ envelope around the tokens a host ALREADY issues — not an identity provider. Its
64
+ authorization page asks an operator to paste an existing access token, and the
65
+ `access_token` it returns IS that token, verified through the same
66
+ `config.token_authenticator` the transport uses. Scopes, expiry, revocation and
67
+ tenancy stay entirely with the host; the bridge widens nobody's reach.
68
+
69
+ Deliberately not implemented, because none of it gates anything here: client
70
+ registration returns an identifier and stores nothing (no endpoint reads a
71
+ `client_id`); there is no consent step (pasting a token you already hold is the
72
+ grant); no refresh token is issued (the pasted token's own expiry is the real
73
+ lifetime, so a client re-runs the flow rather than refreshing a shadow of it).
74
+
75
+ Deliberately NOT mocked, because faking either would create a real vulnerability
76
+ rather than skip a ceremony: `redirect_uri` is checked against the host's policy
77
+ on BOTH legs (below), and the PKCE `code_verifier` is verified (constant-time)
78
+ against the stored S256 `code_challenge`.
79
+
80
+ **Which clients may receive a code.** The authorization page is served from the
81
+ host's own origin, under its own certificate, and asks an operator to paste a
82
+ live token — so an unvetted `redirect_uri` does not merely add an open redirect,
83
+ it makes the host's own domain a credential-phishing page: an attacker sends the
84
+ operator an authorize link carrying the attacker's own `code_challenge`, the
85
+ operator pastes, and the code is delivered to the attacker, who redeems it with
86
+ the verifier they chose. PKCE cannot help — they own the verifier.
87
+
88
+ So every redirect target must be named by exact string in
89
+ `config.oauth_allowed_redirect_uris`, with exactly ONE exception:
90
+ **loopback** (`http://127.0.0.1:*`, `localhost`, `[::1]`), enabled by
91
+ `config.oauth_allow_loopback_redirects`. It is the exception because it is the
92
+ one target that CANNOT be named even in principle — an MCP client on an
93
+ operator's machine listens on an ephemeral port chosen at runtime, so no list
94
+ could enumerate it (RFC 8252 §7.3 exists for this) — and because a loopback
95
+ address resolves on the operator's OWN machine, so the attack above, which needs
96
+ the code to reach a REMOTE attacker, does not work through it.
97
+
98
+ A private-use scheme (`cursor://…`, §7.1) is NOT covered: its redirect URI is a
99
+ fixed string, so it just goes in the allowlist. There is no forcing reason to
100
+ accept one unnamed, and whole schemes cannot be accepted generically anyway —
101
+ separating a private-use scheme from a registered network one (`ssh:`, `ldap:`,
102
+ `gopher:`, each naming a REMOTE host) would mean enumerating the IANA registry,
103
+ and a denylist of the ones you thought of is the shape that fails open.
104
+
105
+ Loopback is judged on the PARSED URI: `http://127.0.0.1@evil.example/` has host
106
+ `evil.example` and is remote, as is `http://127.0.0.1.evil.example/`; a fragment
107
+ is refused.
108
+
109
+ Endpoints — `GET`/`POST` `<mcp>/oauth/authorize`, `POST <mcp>/oauth/token`,
110
+ `POST <mcp>/oauth/register`, plus the two metadata documents. A `/.well-known/*`
111
+ path cannot be drawn by an engine mounted under a path, so a host adds one line at
112
+ the top level of its route set: `McpToolkit.draw_oauth_metadata_routes(self)` (a
113
+ no-op unless the bridge is configured). Every identifier is derived from the live
114
+ request origin, so each host name an app answers on works without further
115
+ configuration.
116
+
117
+ **Additive to a host's own OAuth provider, and it claims nothing origin-global.**
118
+ The flow endpoints live under the engine's mount (`<mcp>/oauth/*`), so a host
119
+ already serving OAuth at the conventional top-level `/oauth/*` — as an app with
120
+ Doorkeeper for its own API does — keeps every one of those routes. The metadata
121
+ documents are PATH-SCOPED to the mount
122
+ (`/.well-known/oauth-protected-resource/mcp`), never the bare
123
+ `/.well-known/oauth-authorization-server`: the bare paths are origin-global and
124
+ mean "the authorization server of this whole origin", which belongs to that
125
+ pre-existing provider. RFC 8414 §3.1 exists for exactly this ("Using path
126
+ components enables supporting multiple issuers per host"), and the MCP
127
+ authorization spec (2025-11-25) requires a client given a path-ful issuer to try
128
+ the path-INSERTED URLs with no root fallback — so the issuer is the MCP endpoint
129
+ URL itself. A host mounted AT its origin root has no path to insert and gets the
130
+ bare paths, which is correct there.
131
+ - `config.oauth_allowed_redirect_uris` (default `[]`; entries are validated at
132
+ assignment and the list is frozen — an unparseable, scheme-less,
133
+ fragment-bearing or *opaque* URI raises, as does cleartext `http://` to a remote
134
+ host and the `javascript:`/`data:`/`file:` schemes a browser treats as script.
135
+ Naming bad schemes is sound here and nowhere else: this list is what the HOST
136
+ wrote, so there is no unlisted scheme for an attacker to slip through — unlike
137
+ the request-time policy, which is why that one takes the opposite shape),
138
+ `config.oauth_allow_loopback_redirects` (default `false`),
139
+ `config.oauth_resource_path` (default `"/mcp"` — must match the engine's mount
140
+ point), `config.oauth_authorization_code_ttl` (default `60`),
141
+ `config.oauth_signing_secret` (defaults to the Rails app's `secret_key_base`),
142
+ and `config.oauth_parent_controller` (default `"ActionController::Base"`).
143
+ - `config.oauth_bridge?` — whether the bridge is live. Gated on the authority role;
144
+ on a `token_authenticator` being set, since the bridge verifies the pasted token
145
+ through it on both legs and drawing no route beats an authorization page that
146
+ takes an operator's token and then errors; and on at least one redirect target
147
+ being named (an allowlist entry or the loopback switch), so it cannot run
148
+ without a bound answer to who may receive a code. A satellite — whose tokens
149
+ belong to its central app — never draws it.
150
+ - The token response is served `Cache-Control: no-store` + `Pragma: no-cache`, a
151
+ MUST of RFC 6749 §5.1 for any response carrying a token. Both metadata documents
152
+ get the same headers for a subtler reason: they name the
153
+ `authorization_endpoint` an operator will be sent to and are built from the
154
+ caller-influenced request origin (`request.base_url` honours `X-Forwarded-Host`),
155
+ so a shared cache holding one could hand every client an origin an attacker
156
+ chose, with the document itself vouching for it. Hosts should also pin
157
+ `config.hosts`, which Rails leaves empty in production by default.
158
+ - `POST <mcp>/oauth/authorize` answers **303**, not Rails' default 302. That POST
159
+ carried the operator's token in its body, and only 303 unambiguously tells the
160
+ browser to fetch the callback with GET and no body (RFC 9700 §4.12).
161
+ - The authority transport's 401 now carries
162
+ `WWW-Authenticate: Bearer resource_metadata="..."` when the bridge is configured —
163
+ the header a hosted client waits for before it will start a flow at all. Absent
164
+ otherwise, so an opted-out host's 401 is unchanged.
165
+
166
+ ### Notes
167
+
168
+ - **An authorization code leaves nothing usable in the cache.** The entry is keyed
169
+ by the code's SHA256, and its payload is sealed with `MessageEncryptor`
170
+ (AES-256-GCM) under a key HMAC'd from `config.oauth_signing_secret` **and** the
171
+ code. Worth the few lines because what is parked there for the code's lifetime
172
+ is not the short-lived credential an authorization server would normally hold:
173
+ it is the operator's pre-existing, long-lived, full-scope token, in a store
174
+ hosts are told to point at their shared `Rails.cache`.
175
+
176
+ The secret is in the key for a specific reason: **the code alone must not be
177
+ it.** Rails logs an authorization code twice per flow at INFO — `Redirected to
178
+ ...?code=...` (only `config.filter_redirect` touches that line) and the token
179
+ endpoint's `Parameters:` (no stock `filter_parameters` entry matches `code`) —
180
+ so keying on the code alone would leave the key to the cache sitting in the one
181
+ artifact that is more widely read, longer retained and more replicated than the
182
+ 60-second entry it protects. With the secret mixed in, the cache, the logs and
183
+ the code together still open nothing.
184
+
185
+ The serializer is pinned to `NullSerializer` for a similar reason: every
186
+ ActiveSupport default across the supported range (`:marshal`, and 7.1+'s
187
+ `:json_allow_marshal`) reaches `Marshal.load`, so a host with cache-write access
188
+ forging one blob would have had code execution. `JSON.parse` is now the only
189
+ parser that sees the payload.
190
+
191
+ Codes are also single-use by the DELETE rather than the read, so of two
192
+ concurrent redemptions exactly one proceeds (verified on MemoryStore, Redis and
193
+ MemCache stores).
194
+ - The bridge's controller is built from its own `config.oauth_parent_controller`
195
+ rather than the `parent_controller` the transport uses. The transport is a
196
+ JSON-only endpoint whose parent is typically `ActionController::API`, which
197
+ cannot render an HTML view — and the authorization page is one. Keeping them
198
+ separate means enabling the bridge changes nothing about the transport. Point
199
+ `oauth_parent_controller` at your own `ApplicationController` to inherit app
200
+ branding; the page renders with `layout: false` either way.
201
+ - The engine adds `access_token` and `code_verifier` to `config.filter_parameters`
202
+ itself. The bridge takes a live token in a POST body and Rails logs parameters
203
+ at INFO, so filtering it is the gem's business — a `rails new` app happens to
204
+ ship a `:token` entry that covers `access_token` by substring, but that is a
205
+ host default the gem does not own and an `--api` host may not have.
206
+ - **Serve the bridge over HTTPS** (`config.force_ssl`). The authorization page
207
+ receives a live access token; on cleartext it is on the wire. A cleartext remote
208
+ `redirect_uri` is refused in the allowlist for the same reason, but the gem
209
+ cannot make a host's own origin HTTPS.
210
+ - The bad-paste page answers **422 as an integer**, not a symbol: the gemspec pins
211
+ no Rack floor and neither symbol spans the supported range
212
+ (`:unprocessable_content` raises below Rack 3.1, `:unprocessable_entity` is
213
+ deprecated above it), so a symbol would turn a mistyped paste into an
214
+ unauthenticated 500 on Rails 7.x.
215
+ - `config.oauth_allowed_redirect_uris` is **frozen** once assigned, and
216
+ `config.oauth_signing_secret=` validates its input. Both are the same lesson:
217
+ validation that can be bypassed (`<<` onto the reader) or skipped (a bare
218
+ writer) is a suggestion, and both failures surface at request time — after an
219
+ operator has pasted a live token.
220
+ - **A host MUST pin `config.hosts`.** Every identifier the bridge publishes is
221
+ derived from `request.base_url`, which honours `X-Forwarded-Host`. Rails does
222
+ not pin it for you — it populates `config.hosts` in development and leaves it
223
+ empty in production, where empty means no host checking at all.
224
+ - **A host MUST point `config.cache_store` at a shared store** before running the
225
+ bridge on more than one worker. The default is an in-process MemoryStore, which
226
+ cannot carry a code from the worker that issued it to the worker that redeems
227
+ it: the flow then fails roughly (N-1)/N of the time, intermittently, and only
228
+ after the operator has pasted a live token. Warned about once at boot rather
229
+ than gated, because a MemoryStore is correct in a single process and
230
+ `Rails.cache` IS one in a stock development environment.
231
+ - The bridge's two browser legs are guarded by a `before_action` rather than a
232
+ check inside each action, so the guard cannot be routed around: `authorize` is
233
+ a common method name, and a gem defining one on `ActionController::Base` would
234
+ drop the action from Rails' `action_methods` and have Rails serve the template
235
+ by implicit render — skipping a guard that lived in the body.
236
+ - A host restyles the page by defining its own
237
+ `app/views/mcp_toolkit/oauth/authorize.html.erb`, which takes precedence over the
238
+ engine's.
239
+
1
240
  ## [0.5.0] - 2026-07-14
2
241
 
3
242
  Authority-path discoverability + backward-compatibility work (driven by an
data/README.md CHANGED
@@ -403,6 +403,190 @@ 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, never the bare
547
+ origin-global `/.well-known/oauth-authorization-server`. That matters: the bare
548
+ paths are origin-global and mean *"the authorization server of this whole origin"*,
549
+ which belongs to a provider you already run, not to an MCP server sharing the host.
550
+ RFC 8414 §3.1 exists for exactly this — *"Using path components enables supporting
551
+ multiple issuers per host"* — and the MCP authorization spec (2025-11-25) requires
552
+ a client given a path-ful issuer to try the path-**inserted** URLs, with no root
553
+ fallback. So the issuer is your MCP endpoint URL, and both documents hang off it.
554
+
555
+ A path-ful issuer, though, has two readings of *where* under the origin its
556
+ metadata lives, and MCP clients disagree: some **insert** the well-known segment
557
+ before the resource path (`/.well-known/oauth-authorization-server/mcp` — the RFC
558
+ form the host draws), others **append** it after
559
+ (`/mcp/.well-known/oauth-authorization-server`). The bridge serves both: the
560
+ inserted forms at the origin root (`draw_oauth_metadata_routes`), and the appended
561
+ forms — plus the `openid-configuration` OIDC alias — under the engine mount,
562
+ automatically whenever the bridge is on. Both stay path-scoped and claim nothing
563
+ origin-global; a client discovers the authorization server whichever convention it
564
+ follows.
565
+
566
+ If your MCP endpoint IS its origin root (a dedicated MCP domain), there is no path
567
+ to insert and you get the bare paths — correct there, since your server really is
568
+ that origin's only authorization server. Set `oauth_resource_path = "/"`.
569
+
570
+ `oauth_allowed_redirect_uris` is empty and `oauth_allow_loopback_redirects`
571
+ is off by default, which leaves `config.oauth_bridge?` false and the routes
572
+ undrawn — the bridge cannot run without bounds on where codes may go. A satellite
573
+ never draws it at all (its tokens belong to its central app, so there is nothing
574
+ for it to authorize against), and neither does an authority with no
575
+ `token_authenticator`, since the bridge verifies the pasted token through it on
576
+ both legs and could not work without one.
577
+
578
+ The bridge's controller has its **own** parent, `config.oauth_parent_controller`
579
+ (default `ActionController::Base`), deliberately separate from the
580
+ `parent_controller` your transport uses. The transport is a JSON-only endpoint you
581
+ may well have pointed at `ActionController::API`, which cannot render an HTML view
582
+ — and the authorization page is one. Keeping them apart means enabling the bridge
583
+ changes nothing about your transport. Point it at your own `ApplicationController`
584
+ to inherit branding; the page renders with `layout: false` either way, so an app
585
+ layout that needs asset-pipeline context is not pulled in.
586
+
587
+ To restyle the page, define your own `app/views/mcp_toolkit/oauth/authorize.html.erb`
588
+ — your app's view path takes precedence over the engine's.
589
+
406
590
  ## Authority + gateway server (own tools + upstreams, no SDK)
407
591
 
408
592
  Beyond the SDK-backed satellite path, the toolkit also ships a **hand-rolled
@@ -0,0 +1,83 @@
1
+ <%#
2
+ The bridge's only human-facing surface: paste an access token you already hold.
3
+
4
+ Rendered with `layout: false`, so this is a whole document and carries its own
5
+ styles inline — it must not depend on the host's asset pipeline. A host that
6
+ wants its own branding defines app/views/mcp_toolkit/oauth/authorize.html.erb
7
+ in its own tree, which takes precedence over this one.
8
+
9
+ The authorization parameters ride through as hidden fields because the bridge
10
+ keeps no state between the two legs. They are re-validated on POST — the
11
+ redirect_uri against the allowlist — so a tampered field cannot widen anything.
12
+ %>
13
+ <!DOCTYPE html>
14
+ <html lang="en">
15
+ <head>
16
+ <meta charset="utf-8">
17
+ <meta name="viewport" content="width=device-width, initial-scale=1">
18
+ <meta name="robots" content="noindex, nofollow">
19
+ <title>Connect to <%= McpToolkit.config.server_name %></title>
20
+ <style>
21
+ :root { color-scheme: light dark; }
22
+ body {
23
+ margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
24
+ padding: 1.5rem; box-sizing: border-box;
25
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif; line-height: 1.5;
26
+ background: #f4f4f5; color: #18181b;
27
+ }
28
+ main { width: 100%; max-width: 26rem; background: #fff; border-radius: 0.75rem; padding: 2rem;
29
+ box-shadow: 0 1px 3px rgb(0 0 0 / 0.1), 0 8px 24px rgb(0 0 0 / 0.06); }
30
+ h1 { margin: 0 0 0.5rem; font-size: 1.25rem; }
31
+ p { margin: 0 0 1.5rem; color: #52525b; font-size: 0.9375rem; }
32
+ label { display: block; margin-bottom: 0.375rem; font-size: 0.875rem; font-weight: 600; }
33
+ input[type="password"] {
34
+ width: 100%; box-sizing: border-box; padding: 0.625rem 0.75rem; font-size: 1rem;
35
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
36
+ border: 1px solid #d4d4d8; border-radius: 0.375rem; background: #fff; color: inherit;
37
+ }
38
+ input[type="password"]:focus { outline: 2px solid #2563eb; outline-offset: 1px; border-color: #2563eb; }
39
+ button {
40
+ width: 100%; margin-top: 1.25rem; padding: 0.625rem 1rem; font-size: 0.9375rem; font-weight: 600;
41
+ color: #fff; background: #18181b; border: 0; border-radius: 0.375rem; cursor: pointer;
42
+ }
43
+ button:hover { background: #3f3f46; }
44
+ .error {
45
+ margin: 0 0 1.25rem; padding: 0.75rem; border-radius: 0.375rem; font-size: 0.875rem;
46
+ background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;
47
+ }
48
+ @media (prefers-color-scheme: dark) {
49
+ body { background: #18181b; color: #fafafa; }
50
+ main { background: #27272a; box-shadow: none; border: 1px solid #3f3f46; }
51
+ p { color: #a1a1aa; }
52
+ input[type="password"] { background: #18181b; border-color: #52525b; }
53
+ button { background: #fafafa; color: #18181b; }
54
+ button:hover { background: #d4d4d8; }
55
+ .error { background: #450a0a; border-color: #7f1d1d; color: #fecaca; }
56
+ }
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <main>
61
+ <h1>Connect to <%= McpToolkit.config.server_name %></h1>
62
+ <p>Paste your access token to finish connecting. It is not shown again after this step.</p>
63
+
64
+ <% if @mcp_oauth_error.present? %>
65
+ <div class="error" role="alert"><%= @mcp_oauth_error %></div>
66
+ <% end %>
67
+
68
+ <%= form_tag request.path, method: :post do %>
69
+ <%= hidden_field_tag :redirect_uri, params[:redirect_uri] %>
70
+ <%= hidden_field_tag :state, params[:state] %>
71
+ <%= hidden_field_tag :code_challenge, params[:code_challenge] %>
72
+ <%= hidden_field_tag :code_challenge_method, params[:code_challenge_method] %>
73
+
74
+ <label for="access_token">Access token</label>
75
+ <%= password_field_tag :access_token, nil, id: "access_token", autocomplete: "off",
76
+ autocapitalize: "off", autocorrect: "off", spellcheck: false,
77
+ autofocus: true, required: true %>
78
+
79
+ <button type="submit">Connect</button>
80
+ <% end %>
81
+ </main>
82
+ </body>
83
+ </html>
data/config/routes.rb CHANGED
@@ -30,4 +30,39 @@ McpToolkit::Engine.routes.draw do
30
30
  # is true whenever `auth_role == :authority`). The controller also fails safe
31
31
  # (no `token_authenticator` => `{ valid: false }`), so this is defence in depth.
32
32
  post "tokens/introspect", to: "tokens#introspect" if McpToolkit.config.authority?
33
+
34
+ # The OAuth authorization bridge (McpToolkit::Oauth::ControllerMethods). Drawn
35
+ # only when the bridge is configured — `oauth_bridge?` is authority-only AND
36
+ # requires a redirect-uri allowlist — so a satellite, or any host that has not
37
+ # opted in, gets no such routes at all. Same reasoning as the introspection
38
+ # route above: the routes file is evaluated through the routes_reloader, after
39
+ # the host's initializers/to_prepare, so the config is already set.
40
+ #
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.
45
+ if McpToolkit.config.oauth_bridge?
46
+ get "oauth/authorize", to: "oauth#authorize", format: false
47
+ post "oauth/authorize", to: "oauth#approve", format: false
48
+ post "oauth/token", to: "oauth#token", format: false
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
67
+ end
33
68
  end
@@ -381,6 +381,7 @@ module McpToolkit::Authority::ControllerMethods
381
381
  # ---- error renders --------------------------------------------------
382
382
 
383
383
  def mcp_render_unauthorized(message)
384
+ mcp_set_authenticate_challenge
384
385
  render json: {
385
386
  jsonrpc: McpToolkit::Protocol::JSONRPC_VERSION,
386
387
  id: nil,
@@ -391,6 +392,29 @@ module McpToolkit::Authority::ControllerMethods
391
392
  }, status: :unauthorized
392
393
  end
393
394
 
395
+ # Points an unauthenticated caller at the OAuth bridge's protected-resource
396
+ # metadata (RFC 9728). This header is what a hosted MCP client waits for before
397
+ # it will start an authorization flow at all — without it, a 401 is just a
398
+ # failure. It also makes the metadata's location OURS to state rather than the
399
+ # client's to guess: RFC 9728 has the client fetch this URL directly, so the
400
+ # path-scoped location is found without probing the origin's bare well-known
401
+ # path. Emitted only when the bridge is configured, so a host that has not opted
402
+ # in keeps its 401 byte-identical.
403
+ #
404
+ # `request.base_url` honours `X-Forwarded-Host`, so it is caller-influenced and
405
+ # cannot be interpolated into a quoted-string parameter unescaped: a host
406
+ # carrying a `"` would close the quotes and let a caller append auth-params of
407
+ # their own. A URL has no business containing one, so refuse rather than escape
408
+ # — an origin that odd is a misconfiguration to notice, not to render.
409
+ def mcp_set_authenticate_challenge
410
+ return unless mcp_config.oauth_bridge?
411
+
412
+ metadata_url = "#{request.base_url}#{mcp_config.oauth_protected_resource_path}"
413
+ return if metadata_url.include?('"')
414
+
415
+ response.headers["WWW-Authenticate"] = %(Bearer resource_metadata="#{metadata_url}")
416
+ end
417
+
394
418
  def mcp_render_session_not_found
395
419
  render json: {
396
420
  jsonrpc: McpToolkit::Protocol::JSONRPC_VERSION,