mcp_toolkit 0.4.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 +418 -0
- data/README.md +174 -0
- data/app/views/mcp_toolkit/oauth/authorize.html.erb +83 -0
- data/config/routes.rb +22 -1
- data/lib/mcp_toolkit/authority/controller_methods.rb +39 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +9 -10
- data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +31 -3
- data/lib/mcp_toolkit/authority/tools/get.rb +2 -1
- data/lib/mcp_toolkit/authority/tools/list.rb +49 -1
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +10 -2
- data/lib/mcp_toolkit/authority/tools/resources.rb +26 -4
- data/lib/mcp_toolkit/configuration.rb +526 -7
- data/lib/mcp_toolkit/dispatcher.rb +17 -3
- data/lib/mcp_toolkit/engine.rb +20 -2
- data/lib/mcp_toolkit/engine_controllers.rb +80 -5
- data/lib/mcp_toolkit/filtering.rb +168 -23
- data/lib/mcp_toolkit/gateway/aggregator.rb +25 -5
- data/lib/mcp_toolkit/list_executor.rb +48 -4
- data/lib/mcp_toolkit/oauth/controller_methods.rb +426 -0
- data/lib/mcp_toolkit/oauth.rb +23 -0
- data/lib/mcp_toolkit/resource.rb +55 -6
- data/lib/mcp_toolkit/resource_schema.rb +125 -16
- data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
- data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
- data/lib/mcp_toolkit/session.rb +2 -2
- data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
- data/lib/mcp_toolkit/tools/authority_base.rb +23 -2
- data/lib/mcp_toolkit/tools/base.rb +23 -3
- data/lib/mcp_toolkit/tools/get.rb +1 -1
- data/lib/mcp_toolkit/tools/list.rb +27 -9
- data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
- data/lib/mcp_toolkit/tools/resources.rb +30 -7
- data/lib/mcp_toolkit/usage_metering/recorder.rb +28 -2
- data/lib/mcp_toolkit/version.rb +1 -1
- data/lib/mcp_toolkit.rb +8 -2
- metadata +8 -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,421 @@
|
|
|
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
|
+
|
|
188
|
+
## [0.5.0] - 2026-07-14
|
|
189
|
+
|
|
190
|
+
Authority-path discoverability + backward-compatibility work (driven by an
|
|
191
|
+
adopting host's parity review against the API contract the gem replaced), plus a
|
|
192
|
+
role-aware mountable engine so an authority mounts its transport in one line, and
|
|
193
|
+
filter-path hardening from a security review.
|
|
194
|
+
|
|
195
|
+
### Security
|
|
196
|
+
|
|
197
|
+
- `config.filter_operator_overrides` now rejects, at assignment time, any
|
|
198
|
+
operator outside `Filtering::AREL_PREDICATIONS`. Those are the only operators
|
|
199
|
+
the gem maps onto an Arel predication that binds/quotes its value; a host that
|
|
200
|
+
configured anything else (e.g. `"extract"`) would have it `public_send` to an
|
|
201
|
+
Arel attribute with the request value passed through verbatim — an
|
|
202
|
+
SQL-injection surface. A defense-in-depth guard in `Filtering.predicate_for`
|
|
203
|
+
also refuses to dispatch any non-predication operator, so the metaprogramming
|
|
204
|
+
call can never be reached with an unvetted method name. The default and
|
|
205
|
+
intended (`{ text: %w[eq in] }`-style) configurations were never vulnerable.
|
|
206
|
+
- New `config.max_filter_values` (default `500`, `nil` disables) caps how many
|
|
207
|
+
values an IN-set filter may resolve to and how many operator conditions may be
|
|
208
|
+
ANDed on one attribute, so a valid token can't emit an unbounded IN clause /
|
|
209
|
+
AND-chain (oversized SQL + Arel AST + expensive planning). Rate limiting
|
|
210
|
+
remains opt-in via `config.rate_limit_max_requests`.
|
|
211
|
+
- Added an injection-safety regression spec that renders real Arel SQL through a
|
|
212
|
+
correctly-escaping connection and asserts hostile payloads stay inside escaped
|
|
213
|
+
string literals (the prior fake connection did not escape quotes, so it could
|
|
214
|
+
not have caught an escaping regression).
|
|
215
|
+
- New `config.max_batch_size` (default `50`, `nil` disables) caps the number of
|
|
216
|
+
JSON-RPC calls a single authority POST batch may carry. Rate limiting is a
|
|
217
|
+
per-HTTP-request `before_action`, so an uncapped batch let one request fan out
|
|
218
|
+
unbounded work (N tool executions / N blocking upstream calls) under a single
|
|
219
|
+
rate-limit tick; an over-size batch is now rejected as a JSON-RPC error before
|
|
220
|
+
any element runs.
|
|
221
|
+
- The top-level `list` `ids` filter now honors `config.max_filter_values` — it
|
|
222
|
+
built `WHERE id IN (...)` on its own path, bypassing the cap that already
|
|
223
|
+
bounds the per-attribute filters.
|
|
224
|
+
- The authority dispatcher no longer relays an unexpected exception's message to
|
|
225
|
+
the caller: an unhandled `StandardError` returns a generic "Internal error"
|
|
226
|
+
(full detail still logged), so `ActiveRecord::StatementInvalid` SQL, internal
|
|
227
|
+
class names, or an internal hostname can't leak in the JSON-RPC error.
|
|
228
|
+
- The gateway `tools/list` aggregator now degrades a single malformed upstream
|
|
229
|
+
tool entry (a non-Hash / name-less definition) by skipping it, and wraps each
|
|
230
|
+
upstream's processing so any unexpected error omits only that upstream instead
|
|
231
|
+
of 500-ing the whole aggregated list for every upstream.
|
|
232
|
+
- Usage metering flush falls back to per-event writes when the batch write
|
|
233
|
+
fails, so one un-persistable ("poison") event can no longer drop metering for
|
|
234
|
+
a whole request's batch (a billing-evasion vector).
|
|
235
|
+
- The satellite tool path (`get` / `list` / `resource_schema` / `resources`) now
|
|
236
|
+
enforces `Resource#superusers_only!` — previously only the authority path did,
|
|
237
|
+
so a superuser-only resource served via a satellite was readable/discoverable
|
|
238
|
+
by any valid token (still account-scoped, so not cross-tenant). `get`/`list`/
|
|
239
|
+
`resource_schema` refuse it for a non-superuser; `resources` hides it.
|
|
240
|
+
- The authority dispatcher now strips a caller-supplied `context` from a tool's
|
|
241
|
+
arguments before the keyword splat. `tool.call(context:, **arguments)` let a
|
|
242
|
+
splatted `context` argument OVERRIDE the gem-resolved `Authority::Context`
|
|
243
|
+
(auth-context injection) — harmless for the gem's own tools (a JSON context
|
|
244
|
+
fails closed with a NoMethodError) but the gem handed attacker-controlled data
|
|
245
|
+
as `context` to arbitrary host tools.
|
|
246
|
+
- The gateway's transport-failure relay no longer leaks the internal upstream
|
|
247
|
+
host:port. `translate_upstream_call_error` returned `InternalError.new(error.message)`
|
|
248
|
+
for a transport failure, whose message is `"Failed to open TCP connection to
|
|
249
|
+
<host>:<port>"`; it now returns a generic error (the proxy already logs the
|
|
250
|
+
detail). A first-party upstream JSON-RPC error is still relayed verbatim.
|
|
251
|
+
- `Tools::AuthorityBase#execute` no longer relays an unexpected exception's
|
|
252
|
+
message to the caller — it returns a generic "Internal error" (detail logged),
|
|
253
|
+
matching the dispatcher's own catch-all.
|
|
254
|
+
- Usage metering's per-event flush fallback now cannot escape into the response:
|
|
255
|
+
a misbehaving `logger`/`error_reporter` in `flush_individually` is swallowed as
|
|
256
|
+
a last resort, preserving the "metering never affects the MCP response"
|
|
257
|
+
invariant.
|
|
258
|
+
|
|
259
|
+
### Added
|
|
260
|
+
|
|
261
|
+
- The mountable `McpToolkit::Engine` is now ROLE-AWARE: the
|
|
262
|
+
`McpToolkit::ServerController` it mounts at POST/GET/DELETE /mcp is built from
|
|
263
|
+
`config.auth_role` — an authority host gets the hand-rolled dispatcher path
|
|
264
|
+
(local token auth, gateway proxying, usage metering, rate limiting), a
|
|
265
|
+
satellite gets the SDK-backed path. So an authority now mounts its whole
|
|
266
|
+
transport with `mount McpToolkit::Engine => "/mcp"` (identical to a satellite)
|
|
267
|
+
instead of hand-drawing the four routes against a subclass of
|
|
268
|
+
`McpToolkit::Authority::ServerController` — which is still supported for a host
|
|
269
|
+
that prefers to draw its own routes.
|
|
270
|
+
- `resource_schema` surfaces a resource's custom filters (`Resource#filter`)
|
|
271
|
+
under `resource_filters` — name, type and description — so a client can
|
|
272
|
+
discover them. The `Resource#filter` docs always promised this; nothing
|
|
273
|
+
delivered it, leaving custom filters functional but unadvertised.
|
|
274
|
+
- The `resources` tool returns `filterable` (whether the resource accepts any
|
|
275
|
+
filter — allowlist or custom) and the resource's usage `note` alongside
|
|
276
|
+
name/description, so caveats surface at browse time, before a client picks a
|
|
277
|
+
resource.
|
|
278
|
+
- The `list` tool description documents the full filter grammar — bare
|
|
279
|
+
equality, comma/array IN sets, the `"null"` token, `{ op:, value: }`
|
|
280
|
+
conditions and AND-ed condition arrays — plus resource-specific top-level
|
|
281
|
+
filters. Previously the operator payload shape was not documented anywhere a
|
|
282
|
+
client could see at runtime.
|
|
283
|
+
- Bare equality filters accept an Array of scalars as an IN set
|
|
284
|
+
(`filter: { status: ["a", "b"] }`). Previously the array was stringified and
|
|
285
|
+
comma-split into fragments that silently matched nothing.
|
|
286
|
+
- A JSON null filter value filters for `IS NULL` (like the `"null"` string
|
|
287
|
+
token). Previously it was silently ignored.
|
|
288
|
+
|
|
289
|
+
All of the above applies to the SATELLITE generic tools too: they share the
|
|
290
|
+
executors and schema builder, so their `resources` output gains
|
|
291
|
+
`filterable`/`note`, their `resource_schema` output gains `resource_filters`,
|
|
292
|
+
and their descriptions document the same filter grammar.
|
|
293
|
+
|
|
294
|
+
### Host-compatibility seams (full parity with a pre-gem API contract)
|
|
295
|
+
|
|
296
|
+
For a host migrating an EXISTING MCP endpoint onto the gem, whose clients hold
|
|
297
|
+
the pre-gem contract:
|
|
298
|
+
|
|
299
|
+
- `config.bare_filter_value_semantics = :literal` — bare filter values reach
|
|
300
|
+
the WHERE clause verbatim (`"a,b"` is one literal string, `"null"` is the
|
|
301
|
+
literal string, `""` matches empty-string rows, an Array — including nil
|
|
302
|
+
elements — gets the adapter's native IN / OR-IS-NULL handling). The default
|
|
303
|
+
`:tokenized` keeps the gem's comma/IN/`"null"`-token grammar. Operator
|
|
304
|
+
conditions are identical in both modes.
|
|
305
|
+
- `config.non_numeric_pk_order = :primary_key` — lists of non-numeric-PK
|
|
306
|
+
resources order by the primary key alone, preserving a pre-gem
|
|
307
|
+
ORDER BY id contract. The default `:created_at` keeps chronological pages
|
|
308
|
+
with the PK tiebreaker.
|
|
309
|
+
- `Resource#filter_requirements` — declares companion-key requirements (e.g. a
|
|
310
|
+
polymorphic foreign key that is type-ambiguous without its `*_type`): the
|
|
311
|
+
list executor rejects the key without its companion ("filter attribute X
|
|
312
|
+
requires Y to also be provided") and `resource_schema` advertises the
|
|
313
|
+
requirement, restoring safe polymorphic-FK filtering instead of dropping the
|
|
314
|
+
key from the allowlist. Accepts a Hash or a lazily-resolved callable, like
|
|
315
|
+
`filterable`.
|
|
316
|
+
- `resource_schema` output restores the remaining pre-gem keys: top-level
|
|
317
|
+
`sparse_fieldsets: true` and `filter_examples` (ready-to-use payloads built
|
|
318
|
+
from the resource's own attributes/relationships), `relationships[].resource`
|
|
319
|
+
(nullable; `target_resource` remains as the resolved alias) and
|
|
320
|
+
`relationships[].filter` (`keys` / `type` / `operators` / `requires`).
|
|
321
|
+
Top-level nil keys are compacted (a nil `note` is omitted again).
|
|
322
|
+
- Operator conditions work on ANY column type: types outside the operator
|
|
323
|
+
table (uuid, enum, jsonb, ...) accept `eq` / `in` instead of failing with
|
|
324
|
+
"cannot be filtered with operators", and `date` columns accept `in` again.
|
|
325
|
+
- The `list` tools' input schemas declare `additionalProperties: true`
|
|
326
|
+
explicitly (resource-specific filters arrive as top-level arguments).
|
|
327
|
+
- `config.register_upstreams_from_env(mapping, env: ENV)` — declares gateway
|
|
328
|
+
upstreams from a `{ key => env var }` map: resets the registry first
|
|
329
|
+
(idempotent across code reloads) and skips blank urls, the two gotchas every
|
|
330
|
+
authority host re-discovers.
|
|
331
|
+
- `config.tool_provider` composes a sensible default when unset: the generic
|
|
332
|
+
Registry-backed provider (only when resources are registered — a pure
|
|
333
|
+
gateway still contributes nothing) plus `config.extra_tool_providers`
|
|
334
|
+
(providers, or bare tool classes auto-wrapped in the new
|
|
335
|
+
`Authority::SingleToolProvider`). Hosts with one bespoke tool no longer
|
|
336
|
+
hand-roll provider plumbing; assigning `tool_provider` explicitly still
|
|
337
|
+
takes full control.
|
|
338
|
+
- `McpToolkit::Serializer::AssociationDescriptor` + `TargetRef` — the exported
|
|
339
|
+
structs for the association duck-type the schema builder and field selection
|
|
340
|
+
probe, so a host adapting its own serializer framework doesn't re-derive the
|
|
341
|
+
field names by hand.
|
|
342
|
+
- The authority `list` tool's served description states the bare-value grammar
|
|
343
|
+
the host ACTUALLY configured: under `:literal` semantics the comma/`"null"`
|
|
344
|
+
tokenization bullet is replaced by the literal-matching one, so served docs
|
|
345
|
+
never advertise filters that would silently match nothing.
|
|
346
|
+
- The authority tools are advertised in alphabetical base-name order
|
|
347
|
+
(`get`, `list`, `resource_schema`, `resources`) and string/text operator
|
|
348
|
+
lists keep the pre-gem order (`eq, in, not_eq, matches, does_not_match`) —
|
|
349
|
+
JSON arrays are ordered, so byte-diffing clients see no reorder.
|
|
350
|
+
- `get` / `resource_schema` reject arguments outside their input schema with
|
|
351
|
+
InvalidParams instead of silently ignoring them (pre-gem parity — they were
|
|
352
|
+
strict Ruby kwargs; `account_id` is always tolerated, the transport consumes
|
|
353
|
+
it). `resources` and `list` stay tolerant of extra arguments, also matching
|
|
354
|
+
the pre-gem contract (`list`'s extras are the resource-specific filters).
|
|
355
|
+
- `config.filter_operator_overrides` — per-column-type overrides for the
|
|
356
|
+
operator sets advertised by `resource_schema` AND enforced by the executor
|
|
357
|
+
(single source, they cannot disagree), so a host can preserve a pre-gem
|
|
358
|
+
operator contract exactly (e.g. `{ text: %w[eq in], date: %w[eq in] }`).
|
|
359
|
+
Empty by default: the gem's own sets apply.
|
|
360
|
+
- A companion key whose value the executor would SKIP (an empty string under
|
|
361
|
+
`:tokenized` semantics) no longer satisfies a `filter_requirements` pairing —
|
|
362
|
+
the foreign key is rejected rather than applied alone (type-ambiguous).
|
|
363
|
+
- `resource_filters` entries keep nil `type`/`description` keys (pre-gem
|
|
364
|
+
shape) instead of compacting them; the relationship `filter_examples`
|
|
365
|
+
companion sample value is `"User"` (pre-gem sample) rather than `"..."`.
|
|
366
|
+
|
|
367
|
+
### Known operator-path delta (documented, not reverted)
|
|
368
|
+
|
|
369
|
+
- `{ op: "in", value: "a,b" }` now splits the comma-separated string into an
|
|
370
|
+
IN set (previously `in` matched the literal string `'a,b'` as a single
|
|
371
|
+
element; only `eq` split). Comma-separated STRING ELEMENTS inside an Array
|
|
372
|
+
value are split the same way — under the tokenized operator grammar there is
|
|
373
|
+
no way to express a literal comma inside an IN element; a literal
|
|
374
|
+
comma-containing match is expressed as a bare equality value (which hosts on
|
|
375
|
+
`:literal` semantics match verbatim).
|
|
376
|
+
|
|
377
|
+
### Fixed
|
|
378
|
+
|
|
379
|
+
- Generic tool descriptions and input schemas rewrite sibling-tool references
|
|
380
|
+
(e.g. "use the `resources` tool") to carry `config.generic_tool_name_prefix`,
|
|
381
|
+
so a host that namespaces its generic tools no longer serves prose pointing
|
|
382
|
+
at unprefixed tool names that do not exist on its server. The gateway
|
|
383
|
+
aggregator applies the same rewrite with the upstream namespace, so a proxied
|
|
384
|
+
`<app>__list` no longer points a client at the upstream's bare tool names
|
|
385
|
+
(`McpToolkit::ToolReferenceRewriter`).
|
|
386
|
+
- `eq` / `in` operator conditions against `"null"` / null render `IS NULL`
|
|
387
|
+
instead of `IN (NULL)`, which matches no rows in SQL.
|
|
388
|
+
- `eq` / `in` operator conditions accept an Array `value` (previously
|
|
389
|
+
stringified and comma-split into fragments).
|
|
390
|
+
- Non-numeric-PK resources order by `created_at` WITH the primary key as a
|
|
391
|
+
tiebreaker, restoring a total order so offset pagination cannot duplicate or
|
|
392
|
+
skip rows that share a timestamp (e.g. bulk inserts).
|
|
393
|
+
- An Array mixing `{ op:, value: }` conditions with bare values is rejected
|
|
394
|
+
with InvalidParams instead of being misread as bare equality values.
|
|
395
|
+
- One resource's failing lazy `filterable` resolution (e.g. a transient DB
|
|
396
|
+
error inside a host-supplied callable) no longer fails the whole `resources`
|
|
397
|
+
discovery index: the `filterable` key is omitted for that resource and the
|
|
398
|
+
unresolved source is retried on the next read instead of permanently and
|
|
399
|
+
silently resolving the allowlist to `{}`.
|
|
400
|
+
|
|
401
|
+
### Changed (explicit over silent — each previously returned a wrong or empty result)
|
|
402
|
+
|
|
403
|
+
- IN-set elements must be non-null scalars: a nil, Hash or nested-Array element
|
|
404
|
+
inside an Array filter value raises InvalidParams (previously a Hash element
|
|
405
|
+
raised a TypeError at query time and a nil element rendered the
|
|
406
|
+
never-matching `IN (..., NULL)`). The `"null"` token is NOT resolved inside a
|
|
407
|
+
set — SQL `IN` cannot match NULL — so a null-or-nothing condition is
|
|
408
|
+
expressed as the filter's single scalar value.
|
|
409
|
+
- A null value with an operator other than `eq` / `in` / `not_eq` (comparisons,
|
|
410
|
+
`matches` / `does_not_match`) raises InvalidParams; a comparison or LIKE
|
|
411
|
+
against NULL can never match a row (previously `matches` with a JSON null
|
|
412
|
+
matched every row via `LIKE '%%'`, and comparisons silently matched nothing).
|
|
413
|
+
- An op-less Hash as a bare filter value raises InvalidParams (previously it
|
|
414
|
+
reached the database as a malformed condition).
|
|
415
|
+
- `{ op: "eq", value: "" }` matches rows whose value IS the empty string
|
|
416
|
+
(previously it matched nothing via an empty IN set). A bare `""` filter value
|
|
417
|
+
still means "no filter".
|
|
418
|
+
|
|
1
419
|
## [0.4.0] - 2026-07-06
|
|
2
420
|
|
|
3
421
|
### Added
|
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
|