mcp_toolkit 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9e89469906c5d75ea46174ecf1e7368ffbe93669ec80526e559f683c778516b
4
- data.tar.gz: 6c97881a9edbd3df1185069364c99457e90fecb4ecf95a7a6c85612e9f5d0d2e
3
+ metadata.gz: 375206ea98c72a64738894427abd86bf0e272f5fad3c359860cd684707e38da7
4
+ data.tar.gz: e37f4ca9689b78c871daabc65110c7b5676760806d9eadaec45b30ea687f9b79
5
5
  SHA512:
6
- metadata.gz: 21daf10ed0e045d978a698482d87581222994cb3ba7aba15f6252f236b5d102bb71a27c29e6d0f05f2e762889fd3dbc8fb58fb3ca5c8293870b46d81145bf540
7
- data.tar.gz: 96ddb6e0fb9c72353b17d725a171b6c065537cabb55b8d9f3d208d6bad71bf76291c5394678dedba38e742474d6351234bef6d56240fff0dd9ed5649ce465196
6
+ metadata.gz: 5920ce2f6e47c077d6f89a4078b74b82935a4be18f5a44cc415168b78e65f85aec6354ba7a3d81d1246f63d0c4d621503f5445b198ad42d80db2ab1ad129d750
7
+ data.tar.gz: '091c72fd1efc0d5dbea0298af70355f21436e194d37534aa98629ea786442fa4477b00b656de0a03b8ac31485ea38d8ad783de1a59bb927f82df75922f028f79'
data/CHANGELOG.md CHANGED
@@ -1,3 +1,190 @@
1
+ ## [0.6.0] - 2026-07-16
2
+
3
+ An OAuth 2.1 authorization bridge for the authority role, so hosted MCP clients
4
+ that will only authenticate by discovering an authorization server and running a
5
+ browser flow can reach a server whose tokens are issued out-of-band. Additive and
6
+ opt-in: a host that configures nothing behaves exactly as it did on 0.5.0.
7
+
8
+ ### Added
9
+
10
+ - **OAuth authorization bridge (authority-only, opt-in).** A standards-shaped
11
+ envelope around the tokens a host ALREADY issues — not an identity provider. Its
12
+ authorization page asks an operator to paste an existing access token, and the
13
+ `access_token` it returns IS that token, verified through the same
14
+ `config.token_authenticator` the transport uses. Scopes, expiry, revocation and
15
+ tenancy stay entirely with the host; the bridge widens nobody's reach.
16
+
17
+ Deliberately not implemented, because none of it gates anything here: client
18
+ registration returns an identifier and stores nothing (no endpoint reads a
19
+ `client_id`); there is no consent step (pasting a token you already hold is the
20
+ grant); no refresh token is issued (the pasted token's own expiry is the real
21
+ lifetime, so a client re-runs the flow rather than refreshing a shadow of it).
22
+
23
+ Deliberately NOT mocked, because faking either would create a real vulnerability
24
+ rather than skip a ceremony: `redirect_uri` is checked against the host's policy
25
+ on BOTH legs (below), and the PKCE `code_verifier` is verified (constant-time)
26
+ against the stored S256 `code_challenge`.
27
+
28
+ **Which clients may receive a code.** The authorization page is served from the
29
+ host's own origin, under its own certificate, and asks an operator to paste a
30
+ live token — so an unvetted `redirect_uri` does not merely add an open redirect,
31
+ it makes the host's own domain a credential-phishing page: an attacker sends the
32
+ operator an authorize link carrying the attacker's own `code_challenge`, the
33
+ operator pastes, and the code is delivered to the attacker, who redeems it with
34
+ the verifier they chose. PKCE cannot help — they own the verifier.
35
+
36
+ So every redirect target must be named by exact string in
37
+ `config.oauth_allowed_redirect_uris`, with exactly ONE exception:
38
+ **loopback** (`http://127.0.0.1:*`, `localhost`, `[::1]`), enabled by
39
+ `config.oauth_allow_loopback_redirects`. It is the exception because it is the
40
+ one target that CANNOT be named even in principle — an MCP client on an
41
+ operator's machine listens on an ephemeral port chosen at runtime, so no list
42
+ could enumerate it (RFC 8252 §7.3 exists for this) — and because a loopback
43
+ address resolves on the operator's OWN machine, so the attack above, which needs
44
+ the code to reach a REMOTE attacker, does not work through it.
45
+
46
+ A private-use scheme (`cursor://…`, §7.1) is NOT covered: its redirect URI is a
47
+ fixed string, so it just goes in the allowlist. There is no forcing reason to
48
+ accept one unnamed, and whole schemes cannot be accepted generically anyway —
49
+ separating a private-use scheme from a registered network one (`ssh:`, `ldap:`,
50
+ `gopher:`, each naming a REMOTE host) would mean enumerating the IANA registry,
51
+ and a denylist of the ones you thought of is the shape that fails open.
52
+
53
+ Loopback is judged on the PARSED URI: `http://127.0.0.1@evil.example/` has host
54
+ `evil.example` and is remote, as is `http://127.0.0.1.evil.example/`; a fragment
55
+ is refused.
56
+
57
+ Endpoints — `GET`/`POST` `<mcp>/oauth/authorize`, `POST <mcp>/oauth/token`,
58
+ `POST <mcp>/oauth/register`, plus the two metadata documents. A `/.well-known/*`
59
+ path cannot be drawn by an engine mounted under a path, so a host adds one line at
60
+ the top level of its route set: `McpToolkit.draw_oauth_metadata_routes(self)` (a
61
+ no-op unless the bridge is configured). Every identifier is derived from the live
62
+ request origin, so each host name an app answers on works without further
63
+ configuration.
64
+
65
+ **Additive to a host's own OAuth provider, and it claims nothing origin-global.**
66
+ The flow endpoints live under the engine's mount (`<mcp>/oauth/*`), so a host
67
+ already serving OAuth at the conventional top-level `/oauth/*` — as an app with
68
+ Doorkeeper for its own API does — keeps every one of those routes. The metadata
69
+ documents are PATH-SCOPED to the mount
70
+ (`/.well-known/oauth-protected-resource/mcp`), never the bare
71
+ `/.well-known/oauth-authorization-server`: the bare paths are origin-global and
72
+ mean "the authorization server of this whole origin", which belongs to that
73
+ pre-existing provider. RFC 8414 §3.1 exists for exactly this ("Using path
74
+ components enables supporting multiple issuers per host"), and the MCP
75
+ authorization spec (2025-11-25) requires a client given a path-ful issuer to try
76
+ the path-INSERTED URLs with no root fallback — so the issuer is the MCP endpoint
77
+ URL itself. A host mounted AT its origin root has no path to insert and gets the
78
+ bare paths, which is correct there.
79
+ - `config.oauth_allowed_redirect_uris` (default `[]`; entries are validated at
80
+ assignment and the list is frozen — an unparseable, scheme-less,
81
+ fragment-bearing or *opaque* URI raises, as does cleartext `http://` to a remote
82
+ host and the `javascript:`/`data:`/`file:` schemes a browser treats as script.
83
+ Naming bad schemes is sound here and nowhere else: this list is what the HOST
84
+ wrote, so there is no unlisted scheme for an attacker to slip through — unlike
85
+ the request-time policy, which is why that one takes the opposite shape),
86
+ `config.oauth_allow_loopback_redirects` (default `false`),
87
+ `config.oauth_resource_path` (default `"/mcp"` — must match the engine's mount
88
+ point), `config.oauth_authorization_code_ttl` (default `60`),
89
+ `config.oauth_signing_secret` (defaults to the Rails app's `secret_key_base`),
90
+ and `config.oauth_parent_controller` (default `"ActionController::Base"`).
91
+ - `config.oauth_bridge?` — whether the bridge is live. Gated on the authority role;
92
+ on a `token_authenticator` being set, since the bridge verifies the pasted token
93
+ through it on both legs and drawing no route beats an authorization page that
94
+ takes an operator's token and then errors; and on at least one redirect target
95
+ being named (an allowlist entry or the loopback switch), so it cannot run
96
+ without a bound answer to who may receive a code. A satellite — whose tokens
97
+ belong to its central app — never draws it.
98
+ - The token response is served `Cache-Control: no-store` + `Pragma: no-cache`, a
99
+ MUST of RFC 6749 §5.1 for any response carrying a token. Both metadata documents
100
+ get the same headers for a subtler reason: they name the
101
+ `authorization_endpoint` an operator will be sent to and are built from the
102
+ caller-influenced request origin (`request.base_url` honours `X-Forwarded-Host`),
103
+ so a shared cache holding one could hand every client an origin an attacker
104
+ chose, with the document itself vouching for it. Hosts should also pin
105
+ `config.hosts`, which Rails leaves empty in production by default.
106
+ - `POST <mcp>/oauth/authorize` answers **303**, not Rails' default 302. That POST
107
+ carried the operator's token in its body, and only 303 unambiguously tells the
108
+ browser to fetch the callback with GET and no body (RFC 9700 §4.12).
109
+ - The authority transport's 401 now carries
110
+ `WWW-Authenticate: Bearer resource_metadata="..."` when the bridge is configured —
111
+ the header a hosted client waits for before it will start a flow at all. Absent
112
+ otherwise, so an opted-out host's 401 is unchanged.
113
+
114
+ ### Notes
115
+
116
+ - **An authorization code leaves nothing usable in the cache.** The entry is keyed
117
+ by the code's SHA256, and its payload is sealed with `MessageEncryptor`
118
+ (AES-256-GCM) under a key HMAC'd from `config.oauth_signing_secret` **and** the
119
+ code. Worth the few lines because what is parked there for the code's lifetime
120
+ is not the short-lived credential an authorization server would normally hold:
121
+ it is the operator's pre-existing, long-lived, full-scope token, in a store
122
+ hosts are told to point at their shared `Rails.cache`.
123
+
124
+ The secret is in the key for a specific reason: **the code alone must not be
125
+ it.** Rails logs an authorization code twice per flow at INFO — `Redirected to
126
+ ...?code=...` (only `config.filter_redirect` touches that line) and the token
127
+ endpoint's `Parameters:` (no stock `filter_parameters` entry matches `code`) —
128
+ so keying on the code alone would leave the key to the cache sitting in the one
129
+ artifact that is more widely read, longer retained and more replicated than the
130
+ 60-second entry it protects. With the secret mixed in, the cache, the logs and
131
+ the code together still open nothing.
132
+
133
+ The serializer is pinned to `NullSerializer` for a similar reason: every
134
+ ActiveSupport default across the supported range (`:marshal`, and 7.1+'s
135
+ `:json_allow_marshal`) reaches `Marshal.load`, so a host with cache-write access
136
+ forging one blob would have had code execution. `JSON.parse` is now the only
137
+ parser that sees the payload.
138
+
139
+ Codes are also single-use by the DELETE rather than the read, so of two
140
+ concurrent redemptions exactly one proceeds (verified on MemoryStore, Redis and
141
+ MemCache stores).
142
+ - The bridge's controller is built from its own `config.oauth_parent_controller`
143
+ rather than the `parent_controller` the transport uses. The transport is a
144
+ JSON-only endpoint whose parent is typically `ActionController::API`, which
145
+ cannot render an HTML view — and the authorization page is one. Keeping them
146
+ separate means enabling the bridge changes nothing about the transport. Point
147
+ `oauth_parent_controller` at your own `ApplicationController` to inherit app
148
+ branding; the page renders with `layout: false` either way.
149
+ - The engine adds `access_token` and `code_verifier` to `config.filter_parameters`
150
+ itself. The bridge takes a live token in a POST body and Rails logs parameters
151
+ at INFO, so filtering it is the gem's business — a `rails new` app happens to
152
+ ship a `:token` entry that covers `access_token` by substring, but that is a
153
+ host default the gem does not own and an `--api` host may not have.
154
+ - **Serve the bridge over HTTPS** (`config.force_ssl`). The authorization page
155
+ receives a live access token; on cleartext it is on the wire. A cleartext remote
156
+ `redirect_uri` is refused in the allowlist for the same reason, but the gem
157
+ cannot make a host's own origin HTTPS.
158
+ - The bad-paste page answers **422 as an integer**, not a symbol: the gemspec pins
159
+ no Rack floor and neither symbol spans the supported range
160
+ (`:unprocessable_content` raises below Rack 3.1, `:unprocessable_entity` is
161
+ deprecated above it), so a symbol would turn a mistyped paste into an
162
+ unauthenticated 500 on Rails 7.x.
163
+ - `config.oauth_allowed_redirect_uris` is **frozen** once assigned, and
164
+ `config.oauth_signing_secret=` validates its input. Both are the same lesson:
165
+ validation that can be bypassed (`<<` onto the reader) or skipped (a bare
166
+ writer) is a suggestion, and both failures surface at request time — after an
167
+ operator has pasted a live token.
168
+ - **A host MUST pin `config.hosts`.** Every identifier the bridge publishes is
169
+ derived from `request.base_url`, which honours `X-Forwarded-Host`. Rails does
170
+ not pin it for you — it populates `config.hosts` in development and leaves it
171
+ empty in production, where empty means no host checking at all.
172
+ - **A host MUST point `config.cache_store` at a shared store** before running the
173
+ bridge on more than one worker. The default is an in-process MemoryStore, which
174
+ cannot carry a code from the worker that issued it to the worker that redeems
175
+ it: the flow then fails roughly (N-1)/N of the time, intermittently, and only
176
+ after the operator has pasted a live token. Warned about once at boot rather
177
+ than gated, because a MemoryStore is correct in a single process and
178
+ `Rails.cache` IS one in a stock development environment.
179
+ - The bridge's two browser legs are guarded by a `before_action` rather than a
180
+ check inside each action, so the guard cannot be routed around: `authorize` is
181
+ a common method name, and a gem defining one on `ActionController::Base` would
182
+ drop the action from Rails' `action_methods` and have Rails serve the template
183
+ by implicit render — skipping a guard that lived in the body.
184
+ - A host restyles the page by defining its own
185
+ `app/views/mcp_toolkit/oauth/authorize.html.erb`, which takes precedence over the
186
+ engine's.
187
+
1
188
  ## [0.5.0] - 2026-07-14
2
189
 
3
190
  Authority-path discoverability + backward-compatibility work (driven by an
data/README.md CHANGED
@@ -403,6 +403,180 @@ mount McpToolkit::Engine => "/mcp" # POST /mcp/tokens/introspect now works
403
403
  Drawing it is safe even on an app that is not an authority: with no
404
404
  `token_authenticator`, it simply answers `{ "valid": false }`.
405
405
 
406
+ ### OAuth authorization bridge (authority-only, opt-in)
407
+
408
+ Some MCP clients will not accept a token you hand them. They authenticate one way
409
+ only: discover an authorization server, run an authorization-code + PKCE flow in a
410
+ browser, and use whatever `access_token` comes back. The MCP authorization spec
411
+ also forbids a token in the request URI, so `?token=<...>` is not a fallback for
412
+ them either. If your tokens are issued out-of-band — an admin UI, a CLI, a support
413
+ process — those clients cannot reach your server at all.
414
+
415
+ The bridge is a standards-shaped **envelope around the tokens you already issue**.
416
+ It is not an identity provider: its authorization page asks the operator to paste
417
+ an access token they already hold, and the `access_token` it returns **is that
418
+ token**, verified through the same `token_authenticator` your transport uses.
419
+ Scopes, expiry, revocation and tenancy stay exactly where you put them, and it
420
+ creates no new way to obtain a token.
421
+
422
+ ```ruby
423
+ # config/initializers/mcp_toolkit.rb
424
+ McpToolkit.configure do |c|
425
+ c.auth_role = :authority
426
+ c.token_authenticator = ->(plaintext) { AccessToken.authenticate(plaintext) }
427
+
428
+ # REQUIRED for the bridge on any multi-worker deployment. The default is an
429
+ # in-process MemoryStore, which cannot carry an authorization code from the
430
+ # worker that issues it to the worker that redeems it — the flow then fails
431
+ # intermittently, *after* the operator has pasted their token.
432
+ c.cache_store = Rails.cache
433
+
434
+ # Naming who may receive an authorization code is what switches the bridge on.
435
+ c.oauth_allowed_redirect_uris = ["https://client.example/callback"]
436
+ c.oauth_resource_path = "/mcp" # must match the engine's mount point
437
+
438
+ # Optional: let any MCP client running on your operators' OWN machines connect
439
+ # without an allowlist entry each (RFC 8252 — see below). This is an opt-in
440
+ # signal in its own right, so it alone can switch the bridge on.
441
+ c.oauth_allow_loopback_redirects = true
442
+ end
443
+ ```
444
+
445
+ ```ruby
446
+ # config/routes.rb — the helper call must be TOP LEVEL. A `/.well-known/*` path
447
+ # cannot be drawn by an engine mounted under a path, so the metadata routes have
448
+ # to live in your own route set. A no-op unless the bridge is configured.
449
+ Rails.application.routes.draw do
450
+ McpToolkit.draw_oauth_metadata_routes(self)
451
+ mount McpToolkit::Engine => "/mcp"
452
+ end
453
+ ```
454
+
455
+ That yields the whole flow — `GET /.well-known/oauth-protected-resource/mcp`,
456
+ `GET /.well-known/oauth-authorization-server/mcp`, `POST /mcp/oauth/register`,
457
+ `GET`/`POST /mcp/oauth/authorize`, `POST /mcp/oauth/token` — plus a
458
+ `WWW-Authenticate: Bearer resource_metadata="..."` header on the transport's 401,
459
+ which is what makes a client start the flow at all. Every identifier is derived
460
+ from the live request origin, so each host name your app answers on works with no
461
+ further configuration.
462
+
463
+ **What is deliberately absent**, because none of it gates anything here: client
464
+ registration returns an identifier and stores nothing (no endpoint reads a
465
+ `client_id`); there is no consent step (pasting a token you hold *is* the grant);
466
+ no refresh token is issued (the pasted token's own expiry is the real lifetime, so
467
+ a client re-runs the flow instead of refreshing a shadow of it).
468
+
469
+ **What is not faked**, because faking either would be a real vulnerability rather
470
+ than a skipped ceremony: `redirect_uri` is checked against your policy on both
471
+ legs (below), and the PKCE `code_verifier` is verified against the stored S256
472
+ challenge in constant time.
473
+
474
+ ### Which clients may receive a code
475
+
476
+ This is the bridge's load-bearing control, so it is worth knowing why it is shaped
477
+ the way it is. The authorization page is served from **your** origin under **your**
478
+ certificate and asks an operator to paste a live token. So an unvetted
479
+ `redirect_uri` does not merely add an open redirect — it makes your own domain a
480
+ credential-phishing page: an attacker sends the operator an authorize link
481
+ carrying the attacker's own `code_challenge`, the operator pastes, the code is
482
+ delivered to the attacker, and they redeem it with the verifier they chose. PKCE
483
+ does not help (they own the verifier), nor does the single-use code, nor
484
+ re-verifying the token. A full authorization server blocks this with a consent
485
+ screen naming the client plus an authenticated session; this bridge mocks both
486
+ away, which is exactly what the redirect policy compensates for.
487
+
488
+ So **every target must be named by exact string**, with exactly one exception:
489
+
490
+ | Target | Rule | Why |
491
+ |---|---|---|
492
+ | Anything remote (`https://client.example/cb`) | Exact string, in `oauth_allowed_redirect_uris` | The phishing vector. Never opened up. |
493
+ | Private-use scheme (`cursor://…`, `com.example.app:/cb`) | Exact string, in `oauth_allowed_redirect_uris` | Keeps the code on the device, but its URI is a fixed string — so just name it. |
494
+ | Loopback (`http://127.0.0.1:*`, `localhost`, `[::1]`) | `oauth_allow_loopback_redirects` | The only target that **cannot** be named: the client picks an ephemeral port at runtime (RFC 8252 §7.3). And it resolves on the operator's own machine, so the attack above cannot reach it. |
495
+
496
+ The loopback exception exists because an allowlist entry is *impossible* there,
497
+ not because native clients are trusted. A private-use scheme keeps the code on the
498
+ device too, but nothing forces it to be unnamed — and whole **schemes** cannot be
499
+ accepted generically anyway: telling a private-use scheme from a registered
500
+ network one (`ssh:`, `ldap:`, `gopher:` — each naming a **remote** host) would
501
+ mean enumerating the IANA registry, and a denylist of the ones you happened to
502
+ think of is the shape that fails open.
503
+
504
+ Loopback is judged on the *parsed* URI, so `http://127.0.0.1@evil.example/` (host
505
+ `evil.example`) and `http://127.0.0.1.evil.example/` are both correctly seen as
506
+ remote, and a fragment is refused.
507
+
508
+ **What the allowlist does not cover.** It binds which URL a code may be sent to —
509
+ not *whose session at that URL* receives it. A hosted MCP client is one callback
510
+ shared by every one of its users, so an attacker can start a flow in their own
511
+ account there, send an operator the resulting authorize link, and have the code
512
+ land back at that client carrying the attacker's `state`. Whether the operator's
513
+ token then ends up in the attacker's account is decided by whether **the client**
514
+ binds `state` to the browser session that began the flow (RFC 6819 §4.4.1.7). An
515
+ authorization server cannot bind a code to a session it never saw, so this is not
516
+ something the bridge — or a full authorization server, which has the identical
517
+ exposure — can close. **Only allowlist clients you believe handle `state`
518
+ correctly.**
519
+
520
+ ### Deployment note
521
+
522
+ Every identifier the bridge publishes is derived from the live request origin
523
+ (`request.base_url`), which honours `X-Forwarded-Host`. **You MUST pin
524
+ `config.hosts`** so Rails' `HostAuthorization` rejects a forged header before it
525
+ reaches the bridge — Rails does *not* do this for you: it populates `config.hosts`
526
+ in development and leaves it **empty in production**, where empty means no
527
+ checking at all. Both metadata documents are served
528
+ `Cache-Control: no-store` regardless, so no shared cache can hand one client an
529
+ origin another client chose.
530
+
531
+ **Serve it over HTTPS** (`config.force_ssl = true`). The authorization page
532
+ receives a live access token in a POST body; on cleartext that token is on the
533
+ wire. The gem refuses a cleartext remote `redirect_uri` in the allowlist for the
534
+ same reason, but it cannot make your own origin HTTPS for you.
535
+
536
+ The engine adds `access_token` and `code_verifier` to `config.filter_parameters`
537
+ itself, so the pasted token stays out of your logs even on a host that ships no
538
+ filter list of its own — nothing to configure.
539
+
540
+ **It is additive to an OAuth provider you already run, and it claims nothing
541
+ origin-global.** The flow endpoints live under the engine's mount
542
+ (`/mcp/oauth/*`), so if you already serve OAuth at the conventional top-level
543
+ `/oauth/*` — as an app with Doorkeeper for its own API does — you keep every one of
544
+ those routes.
545
+
546
+ The metadata documents are **path-scoped** to the mount
547
+ (`/.well-known/oauth-protected-resource/mcp`), never the bare
548
+ `/.well-known/oauth-authorization-server`. That matters: the bare paths are
549
+ origin-global and mean *"the authorization server of this whole origin"*, which
550
+ belongs to a provider you already run, not to an MCP server sharing the host.
551
+ RFC 8414 §3.1 exists for exactly this — *"Using path components enables supporting
552
+ multiple issuers per host"* — and the MCP authorization spec (2025-11-25) requires
553
+ a client given a path-ful issuer to try the path-**inserted** URLs, with no root
554
+ fallback. So the issuer is your MCP endpoint URL, and both documents hang off it.
555
+
556
+ If your MCP endpoint IS its origin root (a dedicated MCP domain), there is no path
557
+ to insert and you get the bare paths — correct there, since your server really is
558
+ that origin's only authorization server. Set `oauth_resource_path = "/"`.
559
+
560
+ `oauth_allowed_redirect_uris` is empty and `oauth_allow_loopback_redirects`
561
+ is off by default, which leaves `config.oauth_bridge?` false and the routes
562
+ undrawn — the bridge cannot run without bounds on where codes may go. A satellite
563
+ never draws it at all (its tokens belong to its central app, so there is nothing
564
+ for it to authorize against), and neither does an authority with no
565
+ `token_authenticator`, since the bridge verifies the pasted token through it on
566
+ both legs and could not work without one.
567
+
568
+ The bridge's controller has its **own** parent, `config.oauth_parent_controller`
569
+ (default `ActionController::Base`), deliberately separate from the
570
+ `parent_controller` your transport uses. The transport is a JSON-only endpoint you
571
+ may well have pointed at `ActionController::API`, which cannot render an HTML view
572
+ — and the authorization page is one. Keeping them apart means enabling the bridge
573
+ changes nothing about your transport. Point it at your own `ApplicationController`
574
+ to inherit branding; the page renders with `layout: false` either way, so an app
575
+ layout that needs asset-pipeline context is not pulled in.
576
+
577
+ To restyle the page, define your own `app/views/mcp_toolkit/oauth/authorize.html.erb`
578
+ — your app's view path takes precedence over the engine's.
579
+
406
580
  ## Authority + gateway server (own tools + upstreams, no SDK)
407
581
 
408
582
  Beyond the SDK-backed satellite path, the toolkit also ships a **hand-rolled
@@ -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,25 @@ 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
+ # 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.
48
+ if McpToolkit.config.oauth_bridge?
49
+ get "oauth/authorize", to: "oauth#authorize", format: false
50
+ post "oauth/authorize", to: "oauth#approve", format: false
51
+ post "oauth/token", to: "oauth#token", format: false
52
+ post "oauth/register", to: "oauth#register", format: false
53
+ end
33
54
  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,