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
|
@@ -112,6 +112,214 @@ class McpToolkit::Configuration
|
|
|
112
112
|
# @return [#call, nil]
|
|
113
113
|
attr_accessor :token_authenticator
|
|
114
114
|
|
|
115
|
+
# --- auth: OAuth authorization bridge (authority-only) ---------------------
|
|
116
|
+
#
|
|
117
|
+
# An OAuth 2.1 authorization-code + PKCE envelope around the tokens the host
|
|
118
|
+
# ALREADY issues, for hosted MCP clients that will only authenticate by
|
|
119
|
+
# discovering an authorization server and running a browser flow (and that
|
|
120
|
+
# cannot be handed a token in the request URI, which the MCP authorization spec
|
|
121
|
+
# forbids). It authenticates nobody: its authorization page asks the operator
|
|
122
|
+
# to paste an existing access token and hands that same token back. See
|
|
123
|
+
# McpToolkit::Oauth::ControllerMethods.
|
|
124
|
+
|
|
125
|
+
# The exact redirect URIs an authorization code may be handed to — the
|
|
126
|
+
# allowlist a REMOTE client's `redirect_uri` is matched against by exact
|
|
127
|
+
# string. This is the bridge's load-bearing control: without it the authorize
|
|
128
|
+
# endpoint would be an open redirect that emits authorization codes.
|
|
129
|
+
#
|
|
130
|
+
# Why it cannot just be opened up to "any client": the page is served from the
|
|
131
|
+
# host's OWN origin under its own certificate and asks for a live token, so an
|
|
132
|
+
# unvetted `redirect_uri` makes it a credential-phishing page hosted by the host
|
|
133
|
+
# itself — an attacker sends the operator an authorize link carrying the
|
|
134
|
+
# attacker's `code_challenge`, the operator pastes, and the code goes to the
|
|
135
|
+
# attacker, who redeems it with the verifier they chose. PKCE cannot help; they
|
|
136
|
+
# own the verifier. This list IS the redirect-URI registration a real
|
|
137
|
+
# authorization server does, and it is the only thing standing in for it.
|
|
138
|
+
#
|
|
139
|
+
# Be precise about what it does NOT cover, because the boundary is easy to
|
|
140
|
+
# overstate. It binds which URL a code may be sent to — not whose session at
|
|
141
|
+
# that URL receives it. Point it at a MULTI-TENANT client (which is the usual
|
|
142
|
+
# case: a hosted MCP client is one callback shared by every user) and an
|
|
143
|
+
# attacker can still start a flow in their OWN account there, send the operator
|
|
144
|
+
# the resulting authorize link, and have the code land back at that client
|
|
145
|
+
# carrying the attacker's `state`. Whether the operator's token then ends up in
|
|
146
|
+
# the attacker's account is decided entirely by whether the CLIENT binds
|
|
147
|
+
# `state` to the browser session that began the flow (RFC 6819 §4.4.1.7; RFC
|
|
148
|
+
# 9700 §4.7.1) — an authorization server cannot bind a code to a session it
|
|
149
|
+
# never saw, so no amount of consent or authentication here would close it. A
|
|
150
|
+
# real authorization server has exactly the same exposure. Only allowlist
|
|
151
|
+
# clients you believe handle `state` correctly.
|
|
152
|
+
#
|
|
153
|
+
# EMPTY BY DEFAULT. Empty, and with `oauth_allow_loopback_redirects` off,
|
|
154
|
+
# the bridge is DISABLED entirely (see `oauth_bridge?`) — so it cannot be
|
|
155
|
+
# switched on without naming who may receive a code, and a host that wants
|
|
156
|
+
# nothing to do with it sets nothing.
|
|
157
|
+
#
|
|
158
|
+
# c.oauth_allowed_redirect_uris = ["https://client.example/callback"]
|
|
159
|
+
#
|
|
160
|
+
# @return [Array<String>]
|
|
161
|
+
attr_reader :oauth_allowed_redirect_uris
|
|
162
|
+
|
|
163
|
+
# Assigns the allowlist, rejecting an entry the bridge must not, or could not,
|
|
164
|
+
# redirect to. Validated at CONFIG time for the same reason
|
|
165
|
+
# `filter_operator_overrides=` is: the alternative is a request-time failure,
|
|
166
|
+
# and here that failure lands at the worst possible moment — the authorize page
|
|
167
|
+
# renders, the operator pastes a live token, the token is verified, a code is
|
|
168
|
+
# written to the cache, and only THEN does the callback URL turn out to be
|
|
169
|
+
# unusable. A typo would cost the operator their paste.
|
|
170
|
+
#
|
|
171
|
+
# The result is FROZEN, so validation is a gate rather than a suggestion: an
|
|
172
|
+
# `<<` onto the reader would otherwise slip an unchecked entry straight past
|
|
173
|
+
# this method. (`config.oauth_allowed_redirect_uris << "…#frag"` used to work,
|
|
174
|
+
# and a fragment is not a harmless typo — the emitted `…/cb#frag?code=…` puts
|
|
175
|
+
# the code in the browser's HASH, so it never reaches the client's server and
|
|
176
|
+
# is readable by any script on the page.)
|
|
177
|
+
#
|
|
178
|
+
# See `oauth_redirect_uri_problem` for what is rejected and why.
|
|
179
|
+
def oauth_allowed_redirect_uris=(uris)
|
|
180
|
+
entries = Array(uris).map { |uri| uri.to_s.dup.freeze }
|
|
181
|
+
entries.each do |uri|
|
|
182
|
+
problem = oauth_redirect_uri_problem(uri)
|
|
183
|
+
next unless problem
|
|
184
|
+
|
|
185
|
+
raise ArgumentError, "oauth_allowed_redirect_uris contains #{uri.inspect}: #{problem}"
|
|
186
|
+
end
|
|
187
|
+
@oauth_allowed_redirect_uris = entries.freeze
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Permits LOOPBACK redirect targets on any port without naming each one:
|
|
191
|
+
# `http://127.0.0.1:54321/cb`, `http://localhost:*/cb`, `http://[::1]:*/cb`.
|
|
192
|
+
#
|
|
193
|
+
# This is the one target that cannot be allowlisted even in principle — an MCP
|
|
194
|
+
# client on an operator's machine listens on an ephemeral port chosen at
|
|
195
|
+
# runtime, so no list could enumerate it (RFC 8252 §7.3 exists for exactly
|
|
196
|
+
# this). And it is safe to accept unnamed for the same reason the list above
|
|
197
|
+
# cannot be opened up: a loopback address resolves on the operator's OWN
|
|
198
|
+
# machine, so the phishing described above — which needs the code to reach a
|
|
199
|
+
# REMOTE attacker — does not work through it.
|
|
200
|
+
#
|
|
201
|
+
# Note what this deliberately does NOT cover: a private-use scheme
|
|
202
|
+
# (`cursor://…`, RFC 8252 §7.1). Those keep the code on the device too, but
|
|
203
|
+
# their redirect URI is a FIXED STRING, so it simply goes in the list above —
|
|
204
|
+
# there is no forcing reason to accept one unnamed. And whole schemes cannot be
|
|
205
|
+
# accepted generically anyway: separating a private-use scheme from a
|
|
206
|
+
# registered network one (`ssh:`, `ldap:`, `gopher:` — each naming a remote
|
|
207
|
+
# host) would mean enumerating the IANA registry, and a denylist of the ones
|
|
208
|
+
# you happened to think of is the shape that fails open.
|
|
209
|
+
#
|
|
210
|
+
# A remote `https://` callback is never covered by this, whatever it is set to.
|
|
211
|
+
#
|
|
212
|
+
# OFF BY DEFAULT: switching it on says "any client on my operators' machines may
|
|
213
|
+
# receive a code", which is a decision, not a default — and so is an opt-in
|
|
214
|
+
# signal in its own right.
|
|
215
|
+
#
|
|
216
|
+
# c.oauth_allow_loopback_redirects = true
|
|
217
|
+
#
|
|
218
|
+
# @return [Boolean]
|
|
219
|
+
attr_accessor :oauth_allow_loopback_redirects
|
|
220
|
+
|
|
221
|
+
# The path McpToolkit::Engine is mounted at, used to build the `resource`
|
|
222
|
+
# identifier, the issuer, the two metadata locations, and the bridge's own
|
|
223
|
+
# endpoint URLs (their origin comes from the live request, so every host name
|
|
224
|
+
# the app answers on works). MUST match the actual mount point, and the
|
|
225
|
+
# `resource` it yields MUST equal the MCP endpoint URL as an operator types it
|
|
226
|
+
# into their client.
|
|
227
|
+
#
|
|
228
|
+
# This path is ALSO what keeps the bridge out of the origin's global namespace
|
|
229
|
+
# — see `oauth_protected_resource_path`.
|
|
230
|
+
#
|
|
231
|
+
# @return [String]
|
|
232
|
+
attr_accessor :oauth_resource_path
|
|
233
|
+
|
|
234
|
+
# Seconds an issued authorization code stays redeemable. Codes are single-use
|
|
235
|
+
# (read-and-deleted at exchange); this only bounds a code that is never
|
|
236
|
+
# redeemed. Short by design — a client exchanges immediately.
|
|
237
|
+
#
|
|
238
|
+
# @return [Integer]
|
|
239
|
+
attr_accessor :oauth_authorization_code_ttl
|
|
240
|
+
|
|
241
|
+
# The server-held secret mixed into the key that seals a cached authorization
|
|
242
|
+
# code's payload (McpToolkit::Oauth::ControllerMethods#mcp_oauth_encryptor).
|
|
243
|
+
#
|
|
244
|
+
# It exists because the code alone must NOT be the key: Rails logs an
|
|
245
|
+
# authorization code twice per flow at INFO, so the logs would otherwise carry
|
|
246
|
+
# the key to the cache entry. This secret never reaches a log or a response, so
|
|
247
|
+
# the cache and the logs together still open nothing without it.
|
|
248
|
+
#
|
|
249
|
+
# Defaults to the Rails app's `secret_key_base` (lazily, so load order and a
|
|
250
|
+
# non-Rails host are both fine), which is exactly the "server-held, in ENV,
|
|
251
|
+
# never logged" property wanted — so a Rails host configures nothing. A
|
|
252
|
+
# non-Rails host MUST set it; the bridge refuses to run without one
|
|
253
|
+
# (`oauth_bridge?`), rather than silently sealing with a weak key.
|
|
254
|
+
#
|
|
255
|
+
# c.oauth_signing_secret = ENV.fetch("MCP_OAUTH_SIGNING_SECRET")
|
|
256
|
+
#
|
|
257
|
+
# Use `fetch`, not `ENV["..."]`: a nil from a missing var falls back to
|
|
258
|
+
# `secret_key_base` silently, leaving a host believing it runs a dedicated
|
|
259
|
+
# secret when it does not.
|
|
260
|
+
#
|
|
261
|
+
# Rotating it invalidates in-flight codes (a 60s window), nothing else — the
|
|
262
|
+
# tokens themselves are the host's and are untouched.
|
|
263
|
+
#
|
|
264
|
+
# @return [String, nil]
|
|
265
|
+
#
|
|
266
|
+
# Validated at assignment, like `oauth_allowed_redirect_uris=` — a non-String
|
|
267
|
+
# here passed `oauth_bridge?`, drew the routes, and then raised a TypeError out
|
|
268
|
+
# of `OpenSSL::HMAC` at request time: after the operator pasted a live token and
|
|
269
|
+
# a code was already cached. That is the exact failure the sibling setter exists
|
|
270
|
+
# to prevent, so it gets the same treatment.
|
|
271
|
+
def oauth_signing_secret=(secret)
|
|
272
|
+
raise ArgumentError, "oauth_signing_secret must be a String, got #{secret.class}" if secret && !secret.is_a?(String)
|
|
273
|
+
|
|
274
|
+
if secret.is_a?(String) && !secret.empty? && secret.bytesize < MINIMUM_SIGNING_SECRET_BYTES
|
|
275
|
+
raise ArgumentError,
|
|
276
|
+
"oauth_signing_secret is #{secret.bytesize} bytes; use at least " \
|
|
277
|
+
"#{MINIMUM_SIGNING_SECRET_BYTES} (a real secret_key_base is 128)"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
@oauth_signing_secret = secret
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Short enough to admit any real secret, long enough to catch a placeholder.
|
|
284
|
+
#
|
|
285
|
+
# Deliberately NOT applied to the `secret_key_base` fallback, which is checked
|
|
286
|
+
# for presence only. The minimum exists to catch a placeholder a host typed into
|
|
287
|
+
# THIS setting; `secret_key_base` is Rails' own, is 128 chars in a real
|
|
288
|
+
# deployment, and is short only in environments where Rails generates a throwaway
|
|
289
|
+
# (a stock test env is ~15 bytes). A genuinely weak `secret_key_base` is an
|
|
290
|
+
# app-wide problem — signed cookies, message verifiers, Active Record encryption
|
|
291
|
+
# — and not this gem's to police from the outside.
|
|
292
|
+
MINIMUM_SIGNING_SECRET_BYTES = 32
|
|
293
|
+
|
|
294
|
+
# Reads the configured secret, else the Rails app's `secret_key_base`. Resolved
|
|
295
|
+
# lazily rather than in the initializer: the gem loads before a Rails app is
|
|
296
|
+
# fully configured, and a non-Rails host has no `Rails` constant at all.
|
|
297
|
+
def oauth_signing_secret
|
|
298
|
+
return @oauth_signing_secret if @oauth_signing_secret
|
|
299
|
+
|
|
300
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
301
|
+
|
|
302
|
+
::Rails.application.secret_key_base
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# The parent class of the bridge's controller, SEPARATE from
|
|
306
|
+
# `parent_controller` and defaulting to ActionController::Base.
|
|
307
|
+
#
|
|
308
|
+
# They are separate because the two controllers have opposite needs. The MCP
|
|
309
|
+
# transport is a JSON-only endpoint, so a host quite reasonably points
|
|
310
|
+
# `parent_controller` at `ActionController::API` — which cannot render an HTML
|
|
311
|
+
# view. The bridge's authorization page IS an HTML view. Deriving it from
|
|
312
|
+
# `parent_controller` would therefore force a host to weaken its transport's
|
|
313
|
+
# superclass just to switch the bridge on; keeping them apart means enabling
|
|
314
|
+
# the bridge changes nothing about the transport.
|
|
315
|
+
#
|
|
316
|
+
# Point this at your own `ApplicationController` to inherit app branding (the
|
|
317
|
+
# page renders with `layout: false` regardless, so an app layout that needs
|
|
318
|
+
# asset-pipeline context is not pulled in).
|
|
319
|
+
#
|
|
320
|
+
# @return [String]
|
|
321
|
+
attr_accessor :oauth_parent_controller
|
|
322
|
+
|
|
115
323
|
# --- caching ---------------------------------------------------------------
|
|
116
324
|
|
|
117
325
|
# The cache store backing sessions and introspection results. Must satisfy the
|
|
@@ -434,6 +642,8 @@ class McpToolkit::Configuration
|
|
|
434
642
|
|
|
435
643
|
@token_authenticator = nil
|
|
436
644
|
|
|
645
|
+
initialize_oauth_bridge_defaults
|
|
646
|
+
|
|
437
647
|
@cache_store = ActiveSupport::Cache::MemoryStore.new
|
|
438
648
|
initialize_data_path_defaults
|
|
439
649
|
|
|
@@ -454,6 +664,18 @@ class McpToolkit::Configuration
|
|
|
454
664
|
@upstreams = McpToolkit::Gateway::UpstreamRegistry.new
|
|
455
665
|
end
|
|
456
666
|
|
|
667
|
+
# OAuth bridge defaults. The empty redirect allowlist AND the off native-client
|
|
668
|
+
# switch are jointly what keep the bridge OFF (`oauth_bridge?`), so a host that
|
|
669
|
+
# never configures it is unaffected.
|
|
670
|
+
def initialize_oauth_bridge_defaults
|
|
671
|
+
@oauth_allowed_redirect_uris = []
|
|
672
|
+
@oauth_allow_loopback_redirects = false
|
|
673
|
+
@oauth_resource_path = "/mcp"
|
|
674
|
+
@oauth_authorization_code_ttl = 60
|
|
675
|
+
@oauth_parent_controller = "ActionController::Base"
|
|
676
|
+
@oauth_signing_secret = nil # falls back to Rails' secret_key_base — see the reader
|
|
677
|
+
end
|
|
678
|
+
|
|
457
679
|
# Session-TTL and list-executor defaults: the :tokenized / :created_at
|
|
458
680
|
# data-path semantics (a host preserving a pre-gem contract overrides these —
|
|
459
681
|
# see each accessor's docs).
|
|
@@ -556,6 +778,149 @@ class McpToolkit::Configuration
|
|
|
556
778
|
auth_role.to_sym == :authority
|
|
557
779
|
end
|
|
558
780
|
|
|
781
|
+
# The well-known prefixes the two metadata documents hang off. The resource
|
|
782
|
+
# path is INSERTED after these, never appended to the origin — see
|
|
783
|
+
# `oauth_protected_resource_path`.
|
|
784
|
+
PROTECTED_RESOURCE_WELL_KNOWN = "/.well-known/oauth-protected-resource"
|
|
785
|
+
AUTHORIZATION_SERVER_WELL_KNOWN = "/.well-known/oauth-authorization-server"
|
|
786
|
+
|
|
787
|
+
# `oauth_resource_path` normalized for URL building: no trailing slash, and
|
|
788
|
+
# empty when the MCP endpoint IS the origin root (where there is no path
|
|
789
|
+
# component to insert).
|
|
790
|
+
#
|
|
791
|
+
# @return [String] e.g. "/mcp", or "" for a root-mounted endpoint.
|
|
792
|
+
def oauth_resource_path_component
|
|
793
|
+
path = oauth_resource_path.to_s.chomp("/")
|
|
794
|
+
path == "/" ? "" : path
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# Where the protected-resource metadata (RFC 9728) answers, and where
|
|
798
|
+
# `WWW-Authenticate` points.
|
|
799
|
+
#
|
|
800
|
+
# Path-SCOPED (`/.well-known/oauth-protected-resource/mcp`), never the bare
|
|
801
|
+
# path, because the bare ones are ORIGIN-GLOBAL: they describe the authorization
|
|
802
|
+
# server of the whole origin, which on a host already running an unrelated OAuth
|
|
803
|
+
# provider is that provider's claim to make, not an MCP server's. RFC 8414 §3.1
|
|
804
|
+
# exists for this — "Using path components enables supporting multiple issuers
|
|
805
|
+
# per host" — and MCP's 2025-11-25 authorization spec gives a path-ful issuer no
|
|
806
|
+
# root fallback, so scoping is the correct reading rather than a workaround.
|
|
807
|
+
#
|
|
808
|
+
# A root-mounted endpoint has no path to insert and gets the bare paths, which is
|
|
809
|
+
# correct there: it really is that origin's only authorization server.
|
|
810
|
+
#
|
|
811
|
+
# @return [String]
|
|
812
|
+
def oauth_protected_resource_path
|
|
813
|
+
"#{PROTECTED_RESOURCE_WELL_KNOWN}#{oauth_resource_path_component}"
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# Where the authorization-server metadata (RFC 8414) answers. Path-inserted for
|
|
817
|
+
# the same reason, and it MUST agree with the issuer: a client constructs this
|
|
818
|
+
# URL from the issuer it was given.
|
|
819
|
+
#
|
|
820
|
+
# @return [String]
|
|
821
|
+
def oauth_authorization_server_path
|
|
822
|
+
"#{AUTHORIZATION_SERVER_WELL_KNOWN}#{oauth_resource_path_component}"
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
# Whether the OAuth authorization bridge is live: its routes are drawn, and the
|
|
826
|
+
# authority transport advertises it on a 401 via `WWW-Authenticate`.
|
|
827
|
+
#
|
|
828
|
+
# Gated on three conditions, each for its own reason.
|
|
829
|
+
#
|
|
830
|
+
# AUTHORITY-ONLY, because the flow hands back a token this app itself
|
|
831
|
+
# authenticates — a satellite's tokens belong to its central app, so there is
|
|
832
|
+
# nothing here for it to authorize against.
|
|
833
|
+
#
|
|
834
|
+
# A `token_authenticator` must be set, because the bridge cannot function
|
|
835
|
+
# without one: it verifies the pasted token through it on both legs. Gated
|
|
836
|
+
# rather than left to fail at request time so a misconfigured host serves no
|
|
837
|
+
# bridge at all, instead of an authorization page that accepts an operator's
|
|
838
|
+
# token and then errors — the sibling introspection endpoint fails safe the
|
|
839
|
+
# same way.
|
|
840
|
+
#
|
|
841
|
+
# An `oauth_signing_secret` must resolve, for the same reason: without one the
|
|
842
|
+
# bridge cannot seal a code's payload, and it must not fall back to sealing
|
|
843
|
+
# with something weaker. A Rails host gets `secret_key_base` for free.
|
|
844
|
+
#
|
|
845
|
+
# And at least one redirect target must be named — an allowlist entry, or the
|
|
846
|
+
# loopback switch — so the bridge cannot be running without a bound answer to
|
|
847
|
+
# "who may receive a code". Both are empty/off by default, which is what makes
|
|
848
|
+
# an unconfigured host byte-identical to one without the bridge.
|
|
849
|
+
#
|
|
850
|
+
# NOT gated on a shared `cache_store`, deliberately, even though a per-worker
|
|
851
|
+
# one breaks the flow (see `oauth_per_process_cache_store?`): a MemoryStore is
|
|
852
|
+
# correct in a single process, `Rails.cache` IS a MemoryStore in a stock
|
|
853
|
+
# development environment, and the gem cannot see the worker count. Gating
|
|
854
|
+
# would make the bridge undevelopable locally to prevent a production mistake,
|
|
855
|
+
# so that one is a loud warning at boot instead.
|
|
856
|
+
#
|
|
857
|
+
# @return [Boolean]
|
|
858
|
+
def oauth_bridge?
|
|
859
|
+
return false unless authority?
|
|
860
|
+
return false if token_authenticator.nil?
|
|
861
|
+
return false if oauth_signing_secret.to_s.empty?
|
|
862
|
+
|
|
863
|
+
Array(oauth_allowed_redirect_uris).any? || !!oauth_allow_loopback_redirects
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
# Why a given allowlist entry must not or cannot receive a code, or nil if it
|
|
867
|
+
# may. A lint over values the HOST wrote, not a boundary against an attacker —
|
|
868
|
+
# which is why naming bad schemes is sound here and would not be at request
|
|
869
|
+
# time: nothing an attacker sends reaches this list, so there is no unlisted
|
|
870
|
+
# scheme for them to slip through. (Request-time policy takes the opposite
|
|
871
|
+
# shape for exactly that reason — see
|
|
872
|
+
# McpToolkit::Oauth::ControllerMethods#mcp_oauth_loopback_redirect_uri?.)
|
|
873
|
+
def oauth_redirect_uri_problem(uri)
|
|
874
|
+
parsed = oauth_parse_redirect_uri(uri)
|
|
875
|
+
return "it is not a valid URI" if parsed.nil?
|
|
876
|
+
|
|
877
|
+
scheme = parsed.scheme&.downcase
|
|
878
|
+
return "it names no scheme" if scheme.nil?
|
|
879
|
+
return "it carries a fragment, which OAuth forbids on a redirect_uri" unless parsed.fragment.nil?
|
|
880
|
+
unless parsed.opaque.nil?
|
|
881
|
+
return "it is opaque (#{scheme}:#{parsed.opaque}) so it cannot carry the code — " \
|
|
882
|
+
"write it with a path, e.g. #{scheme}:/#{parsed.opaque}"
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
oauth_redirect_scheme_problem(scheme, parsed)
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# Schemes a browser may treat as script or local content. A code handed to one
|
|
889
|
+
# is at best lost and at worst executed; no client legitimately registers one.
|
|
890
|
+
ACTIVE_CONTENT_SCHEMES = %w[javascript data vbscript file blob about view-source].freeze
|
|
891
|
+
|
|
892
|
+
def oauth_redirect_scheme_problem(scheme, parsed)
|
|
893
|
+
if ACTIVE_CONTENT_SCHEMES.include?(scheme)
|
|
894
|
+
return "#{scheme}: is not a redirect target — a browser treats it as script or local content"
|
|
895
|
+
end
|
|
896
|
+
return nil unless scheme == "http"
|
|
897
|
+
# RFC 8252 §7.3: cleartext is fine to a loopback address, which never leaves
|
|
898
|
+
# the operator's machine. Anywhere else it puts the code on the wire.
|
|
899
|
+
return nil if McpToolkit::Oauth.loopback_host?(parsed.host)
|
|
900
|
+
|
|
901
|
+
"it is cleartext http to a remote host, which would put the authorization code on the wire — " \
|
|
902
|
+
"use https (cleartext is only accepted for loopback)"
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
def oauth_parse_redirect_uri(uri)
|
|
906
|
+
URI.parse(uri)
|
|
907
|
+
rescue URI::InvalidURIError
|
|
908
|
+
nil
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
# Whether `cache_store` is per-process, which the bridge cannot survive on a
|
|
912
|
+
# multi-worker deployment: a code written on the worker that ran leg 1 is
|
|
913
|
+
# invisible to the worker that runs leg 2, so the exchange reads nil and
|
|
914
|
+
# answers `invalid_grant` — roughly (N-1)/N of the time, AFTER the operator has
|
|
915
|
+
# pasted a live token, and intermittently enough to read as a fluke rather than
|
|
916
|
+
# a misconfiguration. (It also quietly voids the payload sealing: a per-process
|
|
917
|
+
# store has no snapshot to steal, and its key would be in the same heap.)
|
|
918
|
+
#
|
|
919
|
+
# Fine in one process, which is why this warns rather than gates.
|
|
920
|
+
def oauth_per_process_cache_store?
|
|
921
|
+
cache_store.is_a?(ActiveSupport::Cache::MemoryStore)
|
|
922
|
+
end
|
|
923
|
+
|
|
559
924
|
# Full introspection URL the satellite POSTs to. Raises a clear error if the
|
|
560
925
|
# central URL was never configured.
|
|
561
926
|
def introspect_url
|
data/lib/mcp_toolkit/engine.rb
CHANGED
|
@@ -36,4 +36,19 @@ class McpToolkit::Engine < Rails::Engine
|
|
|
36
36
|
# reload so a changed parent (or a reloaded app parent class) takes effect on the
|
|
37
37
|
# next reference. Runs before `:eager_load!`, so the fresh classes exist for it.
|
|
38
38
|
config.to_prepare { McpToolkit.reset_engine_controllers! }
|
|
39
|
+
|
|
40
|
+
# The OAuth bridge takes the operator's live access token in a POST body, and
|
|
41
|
+
# Rails logs request parameters at INFO. Filtering it is therefore the gem's
|
|
42
|
+
# business, not a deployment note: a `rails new` app happens to ship a `:token`
|
|
43
|
+
# entry that covers `access_token` by substring, but that is a host default this
|
|
44
|
+
# gem does not own and an `--api` or hand-rolled host may not have. Additive, so
|
|
45
|
+
# a host's own list is untouched, and harmless when the bridge is off.
|
|
46
|
+
#
|
|
47
|
+
# `code_verifier` is belt-and-braces (a logged verifier is worthless once the
|
|
48
|
+
# code is spent, and the code is burnt on read). `code` is deliberately NOT
|
|
49
|
+
# filtered: it is single-use, useless without the verifier, and matching it
|
|
50
|
+
# would filter every `country_code`/`state_code` a host logs.
|
|
51
|
+
initializer "mcp_toolkit.filter_oauth_parameters" do |app|
|
|
52
|
+
app.config.filter_parameters += %i[access_token code_verifier]
|
|
53
|
+
end
|
|
39
54
|
end
|
|
@@ -28,7 +28,7 @@ module McpToolkit
|
|
|
28
28
|
# The controllers built directly under McpToolkit (the engine's routes point at
|
|
29
29
|
# these). McpToolkit::Authority::ServerController is built alongside them but is
|
|
30
30
|
# fetched through McpToolkit::Authority's own const_missing.
|
|
31
|
-
ENGINE_CONTROLLER_NAMES = %i[ServerController TokensController].freeze
|
|
31
|
+
ENGINE_CONTROLLER_NAMES = %i[ServerController TokensController OauthController].freeze
|
|
32
32
|
|
|
33
33
|
# (Re)builds the engine controllers + the authority base from the current
|
|
34
34
|
# config. Idempotent: an existing constant is replaced so a rebuild reflects a
|
|
@@ -37,6 +37,11 @@ module McpToolkit
|
|
|
37
37
|
parent = config.parent_controller.constantize
|
|
38
38
|
define_controller(self, :ServerController, build_server_controller(parent))
|
|
39
39
|
define_controller(self, :TokensController, build_tokens_controller(parent))
|
|
40
|
+
# Only when the bridge is on — matching its routes, which are equally gated.
|
|
41
|
+
# Its parent (default ActionController::Base) would otherwise be constantized
|
|
42
|
+
# on every host, pulling view machinery into an API-only app that never
|
|
43
|
+
# enables the bridge, and breaking a non-Rails host outright.
|
|
44
|
+
define_controller(self, :OauthController, build_oauth_controller) if config.oauth_bridge?
|
|
40
45
|
define_controller(Authority, :ServerController, build_authority_server_controller(parent))
|
|
41
46
|
ServerController
|
|
42
47
|
end
|
|
@@ -44,7 +49,7 @@ module McpToolkit
|
|
|
44
49
|
# Undefines the built controllers so the next reference rebuilds them from the
|
|
45
50
|
# then-current config. Called from the engine's `to_prepare` on every reload.
|
|
46
51
|
def self.reset_engine_controllers!
|
|
47
|
-
|
|
52
|
+
ENGINE_CONTROLLER_NAMES.map { |name| [self, name] }.push([Authority, :ServerController]).each do |mod, name|
|
|
48
53
|
mod.send(:remove_const, name) if mod.const_defined?(name, false)
|
|
49
54
|
end
|
|
50
55
|
end
|
|
@@ -96,12 +101,74 @@ module McpToolkit
|
|
|
96
101
|
end
|
|
97
102
|
end
|
|
98
103
|
|
|
104
|
+
# The OAuth authorization bridge the engine mounts at `<mcp>/oauth/*` and the
|
|
105
|
+
# host draws at the two `.well-known` metadata paths.
|
|
106
|
+
#
|
|
107
|
+
# Built from `config.oauth_parent_controller`, NOT the `parent_controller` its
|
|
108
|
+
# siblings use: the transport is a JSON-only endpoint that a host rightly points
|
|
109
|
+
# at ActionController::API, which cannot render an HTML view — and this
|
|
110
|
+
# controller's authorization page is one. Sharing the parent would force a host
|
|
111
|
+
# to weaken its transport's superclass just to enable the bridge. Read lazily
|
|
112
|
+
# here, like the rest, so the host's initializer/to_prepare has already run.
|
|
113
|
+
def self.build_oauth_controller
|
|
114
|
+
warn_about_per_process_cache_store
|
|
115
|
+
Class.new(config.oauth_parent_controller.constantize) { include McpToolkit::Oauth::ControllerMethods }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Said once per boot (this runs from the engine's `to_prepare`), because the
|
|
119
|
+
# failure it predicts is otherwise hard to read as a misconfiguration at all:
|
|
120
|
+
# with a per-process cache the two legs of a flow land on different workers, so
|
|
121
|
+
# the exchange fails (N-1)/N of the time — intermittently, and only AFTER the
|
|
122
|
+
# operator has already pasted a live token. A warning rather than a gate,
|
|
123
|
+
# because one process is genuinely fine and `Rails.cache` is a MemoryStore in a
|
|
124
|
+
# stock development environment.
|
|
125
|
+
def self.warn_about_per_process_cache_store
|
|
126
|
+
return unless config.oauth_per_process_cache_store?
|
|
127
|
+
|
|
128
|
+
config.logger&.warn(
|
|
129
|
+
"[mcp_toolkit] The OAuth bridge is enabled but config.cache_store is an in-process MemoryStore. " \
|
|
130
|
+
"That is safe in a single process only: on a multi-worker deployment an authorization code issued " \
|
|
131
|
+
"by one worker is invisible to the worker that redeems it, so the flow fails intermittently AFTER " \
|
|
132
|
+
"the operator has pasted their token. Point config.cache_store at a shared store (Rails.cache " \
|
|
133
|
+
"backed by Redis/Memcached) before running the bridge in production."
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
99
137
|
# The AUTHORITY base controller a host subclasses (the recommended path for a
|
|
100
138
|
# host whose rate-limit/usage/account hooks touch app models).
|
|
101
139
|
def self.build_authority_server_controller(parent)
|
|
102
140
|
Class.new(parent) { include McpToolkit::Authority::ControllerMethods }
|
|
103
141
|
end
|
|
104
142
|
|
|
143
|
+
# Draws the OAuth bridge's two metadata documents. A `/.well-known/*` path
|
|
144
|
+
# cannot be drawn by an engine mounted under a path, so it has to live in the
|
|
145
|
+
# host's own route set. The host calls this at the TOP LEVEL — not inside a
|
|
146
|
+
# locale/format/constraint scope, which would prefix the paths out of view:
|
|
147
|
+
#
|
|
148
|
+
# # config/routes.rb
|
|
149
|
+
# Rails.application.routes.draw do
|
|
150
|
+
# McpToolkit.draw_oauth_metadata_routes(self)
|
|
151
|
+
# mount McpToolkit::Engine => "/mcp"
|
|
152
|
+
# # ...
|
|
153
|
+
# end
|
|
154
|
+
#
|
|
155
|
+
# The paths are PATH-SCOPED to the engine's mount
|
|
156
|
+
# (`/.well-known/oauth-protected-resource/mcp`), so this claims NOTHING
|
|
157
|
+
# origin-global and cannot collide with an OAuth provider the host already runs
|
|
158
|
+
# — see Configuration#oauth_protected_resource_path for why that matters. A host
|
|
159
|
+
# mounted at its origin root gets the bare paths instead, which is correct there.
|
|
160
|
+
#
|
|
161
|
+
# A no-op unless the bridge is configured, so the call can sit in a host's routes
|
|
162
|
+
# unconditionally across environments.
|
|
163
|
+
def self.draw_oauth_metadata_routes(mapper)
|
|
164
|
+
return unless config.oauth_bridge?
|
|
165
|
+
|
|
166
|
+
mapper.get config.oauth_protected_resource_path,
|
|
167
|
+
to: "mcp_toolkit/oauth#protected_resource", format: false
|
|
168
|
+
mapper.get config.oauth_authorization_server_path,
|
|
169
|
+
to: "mcp_toolkit/oauth#authorization_server", format: false
|
|
170
|
+
end
|
|
171
|
+
|
|
105
172
|
# Removes an existing same-named constant (avoiding a redefinition warning on a
|
|
106
173
|
# rebuild) before setting the freshly-built class.
|
|
107
174
|
def self.define_controller(mod, name, klass)
|