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 +4 -4
- data/CHANGELOG.md +187 -0
- data/README.md +174 -0
- data/app/views/mcp_toolkit/oauth/authorize.html.erb +83 -0
- data/config/routes.rb +21 -0
- data/lib/mcp_toolkit/authority/controller_methods.rb +24 -0
- data/lib/mcp_toolkit/configuration.rb +365 -0
- data/lib/mcp_toolkit/engine.rb +15 -0
- data/lib/mcp_toolkit/engine_controllers.rb +69 -2
- data/lib/mcp_toolkit/oauth/controller_methods.rb +426 -0
- data/lib/mcp_toolkit/oauth.rb +23 -0
- data/lib/mcp_toolkit/version.rb +1 -1
- data/lib/mcp_toolkit.rb +8 -2
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 375206ea98c72a64738894427abd86bf0e272f5fad3c359860cd684707e38da7
|
|
4
|
+
data.tar.gz: e37f4ca9689b78c871daabc65110c7b5676760806d9eadaec45b30ea687f9b79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|