mcp 0.25.0 → 1.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/README.md +50 -2548
- data/lib/json_rpc_handler.rb +3 -2
- data/lib/mcp/client/http.rb +355 -66
- data/lib/mcp/client/mcp_param_headers.rb +242 -0
- data/lib/mcp/client/modern_envelope.rb +32 -0
- data/lib/mcp/client/oauth/authorization_request.rb +27 -0
- data/lib/mcp/client/oauth/bounded_body.rb +67 -0
- data/lib/mcp/client/oauth/client_credentials_provider.rb +20 -6
- data/lib/mcp/client/oauth/cross_app_access_provider.rb +21 -1
- data/lib/mcp/client/oauth/discovery.rb +131 -14
- data/lib/mcp/client/oauth/flow.rb +586 -108
- data/lib/mcp/client/oauth/id_jag_token_exchange.rb +12 -1
- data/lib/mcp/client/oauth/provider.rb +16 -1
- data/lib/mcp/client/oauth/storage_backed_provider.rb +49 -5
- data/lib/mcp/client/oauth.rb +2 -0
- data/lib/mcp/client/stdio.rb +271 -66
- data/lib/mcp/client/tool.rb +3 -2
- data/lib/mcp/client.rb +364 -41
- data/lib/mcp/configuration.rb +84 -7
- data/lib/mcp/elicitation/enum_schema.rb +121 -0
- data/lib/mcp/elicitation.rb +10 -0
- data/lib/mcp/error_codes.rb +14 -8
- data/lib/mcp/icon.rb +35 -2
- data/lib/mcp/instrumentation.rb +6 -0
- data/lib/mcp/methods.rb +21 -0
- data/lib/mcp/prompt.rb +8 -1
- data/lib/mcp/protocol_deprecations.rb +61 -0
- data/lib/mcp/request_envelope.rb +117 -0
- data/lib/mcp/server/input_required_result.rb +163 -0
- data/lib/mcp/server/pending_response.rb +62 -0
- data/lib/mcp/server/request_state_security.rb +131 -0
- data/lib/mcp/server/transports/stdio_transport.rb +39 -2
- data/lib/mcp/server/transports/streamable_http_transport.rb +820 -24
- data/lib/mcp/server.rb +623 -54
- data/lib/mcp/server_context.rb +92 -5
- data/lib/mcp/server_session.rb +104 -20
- data/lib/mcp/tool/response.rb +8 -2
- data/lib/mcp/tool.rb +1 -1
- data/lib/mcp/transport.rb +7 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -0
- metadata +14 -3
|
@@ -14,7 +14,38 @@ module MCP
|
|
|
14
14
|
# `Provider`; this class consumes a Provider plus signal data extracted from
|
|
15
15
|
# the failing response (resource_metadata URL, scope challenge).
|
|
16
16
|
class Flow
|
|
17
|
-
|
|
17
|
+
TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128
|
|
18
|
+
TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512
|
|
19
|
+
METADATA_DIAGNOSTIC_MAX_LENGTH = 128
|
|
20
|
+
METADATA_URL_MAX_LENGTH = 2048
|
|
21
|
+
|
|
22
|
+
# Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
|
|
23
|
+
# so a provider naming one of these is refused rather than left believing its value was sent.
|
|
24
|
+
RESERVED_TOKEN_REQUEST_PARAMS = [
|
|
25
|
+
"grant_type",
|
|
26
|
+
"client_id",
|
|
27
|
+
"client_secret",
|
|
28
|
+
"client_assertion",
|
|
29
|
+
"client_assertion_type",
|
|
30
|
+
"scope",
|
|
31
|
+
"resource",
|
|
32
|
+
"code",
|
|
33
|
+
"code_verifier",
|
|
34
|
+
"redirect_uri",
|
|
35
|
+
"refresh_token",
|
|
36
|
+
"assertion",
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
class AuthorizationError < StandardError
|
|
40
|
+
attr_reader :http_status, :error, :error_description
|
|
41
|
+
|
|
42
|
+
def initialize(message = nil, http_status: nil, error: nil, error_description: nil)
|
|
43
|
+
super(message)
|
|
44
|
+
@http_status = http_status
|
|
45
|
+
@error = error
|
|
46
|
+
@error_description = error_description
|
|
47
|
+
end
|
|
48
|
+
end
|
|
18
49
|
|
|
19
50
|
# Raised specifically when the token endpoint rejects a grant with
|
|
20
51
|
# `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to
|
|
@@ -23,6 +54,141 @@ module MCP
|
|
|
23
54
|
# should leave the refresh token intact.
|
|
24
55
|
class InvalidGrantError < AuthorizationError; end
|
|
25
56
|
|
|
57
|
+
# Raised when `authorization_request_validator` returns a falsy value. Separate from its parent
|
|
58
|
+
# so that a caller can tell a refusal by its own policy from a network, discovery,
|
|
59
|
+
# or authorization server metadata failure by rescuing a class rather than by matching the message text.
|
|
60
|
+
class AuthorizationRefusedError < AuthorizationError; end
|
|
61
|
+
|
|
62
|
+
# Raised by metadata discovery when every candidate URL answered that nothing usable is published there
|
|
63
|
+
# (a `4xx` other than `429`, a redirect that was not followed, or a body that is not a JSON object).
|
|
64
|
+
# The only discovery failure that may select the legacy 2025-03-26 path.
|
|
65
|
+
class MetadataNotPublishedError < AuthorizationError; end
|
|
66
|
+
|
|
67
|
+
# Raised by metadata discovery when the answer says nothing about what is published: the request failed to
|
|
68
|
+
# reach the server, or a candidate answered `5xx` or `429`. Falling back on this would move
|
|
69
|
+
# the flow to a different authorization server because of a transient failure, so it is surfaced instead,
|
|
70
|
+
# as the TypeScript SDK does for network errors outside browsers and the Python SDK does for both.
|
|
71
|
+
class MetadataUnreachableError < AuthorizationError; end
|
|
72
|
+
|
|
73
|
+
# Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity,
|
|
74
|
+
# or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake,
|
|
75
|
+
# not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on
|
|
76
|
+
# a failed refresh as a reason to run the interactive flow.
|
|
77
|
+
class InvalidTokenRequestParamsError < ArgumentError; end
|
|
78
|
+
|
|
79
|
+
# Raised by `RequestedOriginGuard` when middleware added through the provider's `http_client_customizer`
|
|
80
|
+
# would send a request to an origin other than the one the flow validated, or has dropped the record of
|
|
81
|
+
# the URL the flow asked for. An `ArgumentError` because the middleware is a configuration mistake,
|
|
82
|
+
# and deliberately outside `AuthorizationError`, which discovery treats as "nothing published"
|
|
83
|
+
# and `MCP::Client::HTTP` treats on a failed refresh as a reason to run the interactive flow.
|
|
84
|
+
class DestinationMismatchError < ArgumentError; end
|
|
85
|
+
|
|
86
|
+
# Faraday middleware registered on the connection `build_http_client` assembles before the customizer
|
|
87
|
+
# is invoked, so with the usual `use` it sits ahead of the customizer's middleware and sees the URL exactly
|
|
88
|
+
# as the flow requested it, which it records on the request environment for `RequestedOriginGuard`.
|
|
89
|
+
# The guard covers what happens to a request after that record; middleware inserted ahead of it with
|
|
90
|
+
# `builder.insert(0, ...)` that rewrites the URL before it or rebuilds the environment is outside the guard.
|
|
91
|
+
# The record lives on the environment, not in `env.request.context`: that slot belongs to the application,
|
|
92
|
+
# which may fill it on the connection or replace it from a middleware of its own. Only the first URL seen
|
|
93
|
+
# on an environment is recorded: a middleware inserted ahead of this one that re-enters the stack after
|
|
94
|
+
# a `3xx` with the same environment, or with its `dup`, which shares the record, cannot replace it with
|
|
95
|
+
# the redirected URL.
|
|
96
|
+
class RequestedURLStamp
|
|
97
|
+
KEY = :mcp_oauth_requested_url
|
|
98
|
+
|
|
99
|
+
def initialize(app)
|
|
100
|
+
@app = app
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def call(env)
|
|
104
|
+
env[KEY] ||= env.url.to_s
|
|
105
|
+
@app.call(env)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Faraday middleware registered last on that connection, so it sees `env.url` after any customizer-added
|
|
110
|
+
# middleware has rewritten it or followed a redirect. A request that would leave the origin the flow asked
|
|
111
|
+
# for is refused before it reaches the adapter, since every destination check ran against the URL as
|
|
112
|
+
# written; a same-origin change stays with the server those checks admitted. The record survives
|
|
113
|
+
# the `env.dup` that redirect-following middleware performs, and a request that arrives without it is
|
|
114
|
+
# refused as well, so a middleware that rebuilds the environment fails closed rather than open.
|
|
115
|
+
# The origin boundary resembles the one the Python SDK keeps for its own auth requests, which follows
|
|
116
|
+
# a redirect itself only within the origin; this flow follows none.
|
|
117
|
+
class RequestedOriginGuard
|
|
118
|
+
def initialize(app)
|
|
119
|
+
@app = app
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def call(env)
|
|
123
|
+
requested = env[RequestedURLStamp::KEY]
|
|
124
|
+
unless requested
|
|
125
|
+
raise DestinationMismatchError, <<~MESSAGE
|
|
126
|
+
Request to #{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect} carries no record of \
|
|
127
|
+
the URL the flow asked for; middleware that rebuilds the request environment is refused.
|
|
128
|
+
MESSAGE
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
unless Discovery.same_origin?(env.url.to_s, requested)
|
|
132
|
+
raise DestinationMismatchError, <<~MESSAGE
|
|
133
|
+
Request to #{Discovery.canonicalize_origin_and_path(requested).inspect} would be sent to \
|
|
134
|
+
#{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect}, on a different origin; \
|
|
135
|
+
middleware that follows redirects or rewrites URLs is refused.
|
|
136
|
+
MESSAGE
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
@app.call(env)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
private_constant :RequestedURLStamp, :RequestedOriginGuard
|
|
143
|
+
|
|
144
|
+
class << self
|
|
145
|
+
# Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can.
|
|
146
|
+
# Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`,
|
|
147
|
+
# so the same problem reads the same wherever it surfaces.
|
|
148
|
+
def token_request_params_problem(params)
|
|
149
|
+
return "must be a Hash (got #{params.class})." unless params.is_a?(Hash)
|
|
150
|
+
|
|
151
|
+
# Two equal keys are two entries here, which would be sent twice from a provider method
|
|
152
|
+
# or silently collapse into one when the constructors copy the Hash.
|
|
153
|
+
return "must not compare keys by identity." if params.compare_by_identity?
|
|
154
|
+
|
|
155
|
+
params.each do |key, value|
|
|
156
|
+
return "keys must be Strings (got #{key.class})." unless key.is_a?(String)
|
|
157
|
+
return "values must be Strings (got #{value.class} for #{key.inspect})." unless value.is_a?(String)
|
|
158
|
+
return "must not set #{key.inspect}, which the SDK sets itself." if RESERVED_TOKEN_REQUEST_PARAMS.include?(key)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Builds the connection the flow uses for its own requests: the SDK's defaults, `RequestedURLStamp`,
|
|
165
|
+
# then `customizer` (a provider's `http_client_customizer`, called with the `Faraday::Connection`),
|
|
166
|
+
# then `RequestedOriginGuard` last so it sees what the customizer's middleware does to each request
|
|
167
|
+
# after the stamp recorded it. Every request on the connection passes through both, so a caller using
|
|
168
|
+
# it directly is held to the same origin rule.
|
|
169
|
+
#
|
|
170
|
+
# Deliberately built without redirect-following middleware. Every destination check in this class runs
|
|
171
|
+
# against the URL as written, before the request goes out, so a connection that transparently followed
|
|
172
|
+
# a `3xx` would let a server reach a host the checks just refused. The guard turns following at
|
|
173
|
+
# the middleware level into a refusal; following inside an adapter stays invisible, so a customizer must
|
|
174
|
+
# not enable it.
|
|
175
|
+
#
|
|
176
|
+
# `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
|
|
177
|
+
# the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
|
|
178
|
+
# which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
|
|
179
|
+
# after the check.
|
|
180
|
+
def build_http_client(customizer = nil)
|
|
181
|
+
require "faraday"
|
|
182
|
+
|
|
183
|
+
Faraday.new do |faraday|
|
|
184
|
+
faraday.headers["Accept"] = "application/json"
|
|
185
|
+
faraday.use(RequestedURLStamp)
|
|
186
|
+
customizer&.call(faraday)
|
|
187
|
+
faraday.use(RequestedOriginGuard)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
26
192
|
def initialize(provider:, http_client_factory: nil)
|
|
27
193
|
@provider = provider
|
|
28
194
|
@http_client_factory = http_client_factory || -> { default_http_client }
|
|
@@ -33,9 +199,15 @@ module MCP
|
|
|
33
199
|
def run!(server_url:, resource_metadata_url: nil, scope: nil)
|
|
34
200
|
# The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
|
|
35
201
|
# first thing we contact in the OAuth flow, so it has to clear the same
|
|
36
|
-
# Communication Security bar as the OAuth endpoints downstream
|
|
202
|
+
# Communication Security bar as the OAuth endpoints downstream, and it has to
|
|
203
|
+
# point back at the server that issued the challenge.
|
|
37
204
|
if resource_metadata_url
|
|
38
205
|
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
|
206
|
+
ensure_same_origin!(
|
|
207
|
+
resource_metadata_url,
|
|
208
|
+
label: "WWW-Authenticate resource_metadata URL",
|
|
209
|
+
server_url: server_url,
|
|
210
|
+
)
|
|
39
211
|
end
|
|
40
212
|
|
|
41
213
|
prm, authorization_server = locate_authorization_server(
|
|
@@ -49,21 +221,30 @@ module MCP
|
|
|
49
221
|
# being redirected to credentials minted for a different audience.
|
|
50
222
|
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
|
|
51
223
|
|
|
52
|
-
as_metadata = authorization_server_metadata(
|
|
224
|
+
as_metadata = authorization_server_metadata(
|
|
225
|
+
authorization_server: authorization_server,
|
|
226
|
+
legacy: prm.nil?,
|
|
227
|
+
server_url: server_url,
|
|
228
|
+
)
|
|
53
229
|
|
|
54
230
|
case provider_authorization_flow
|
|
55
231
|
when :client_credentials
|
|
56
|
-
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
|
|
232
|
+
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url)
|
|
57
233
|
when :jwt_bearer
|
|
58
|
-
return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
|
|
234
|
+
return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url)
|
|
59
235
|
end
|
|
60
236
|
|
|
61
237
|
ensure_pkce_supported!(as_metadata)
|
|
62
238
|
|
|
239
|
+
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
240
|
+
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
|
|
241
|
+
|
|
242
|
+
# Asked before registering, not after: a refusal must not leave this client registered at an authorization server
|
|
243
|
+
# the embedding application has just rejected.
|
|
244
|
+
authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
|
|
245
|
+
|
|
63
246
|
client_info = ensure_client_registered(as_metadata: as_metadata)
|
|
64
247
|
|
|
65
|
-
effective_scope = resolve_scope(scope: scope, prm: prm || {})
|
|
66
|
-
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
|
|
67
248
|
pkce = PKCE.generate
|
|
68
249
|
state = SecureRandom.urlsafe_base64(32)
|
|
69
250
|
|
|
@@ -99,27 +280,29 @@ module MCP
|
|
|
99
280
|
resource: resource,
|
|
100
281
|
)
|
|
101
282
|
|
|
102
|
-
|
|
283
|
+
save_tokens_issued_by(tokens, as_metadata: as_metadata)
|
|
103
284
|
:authorized
|
|
104
285
|
end
|
|
105
286
|
|
|
106
287
|
# Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
|
|
107
288
|
# the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
|
|
108
289
|
# the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
|
|
109
|
-
# and no `offline_access` augmentation because the grant
|
|
290
|
+
# and no `offline_access` augmentation because the grant is not expected to issue a refresh token (OAuth 2.1 Section 4.3.3).
|
|
110
291
|
# The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
|
|
111
292
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
|
112
|
-
def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
|
|
293
|
+
def run_client_credentials!(as_metadata:, prm:, resource:, scope:, server_url:)
|
|
113
294
|
client_info = client_credentials_client_info
|
|
114
295
|
ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
|
|
115
296
|
|
|
116
297
|
form = { "grant_type" => "client_credentials" }
|
|
117
298
|
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
299
|
+
authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
|
|
118
300
|
form["scope"] = effective_scope if effective_scope
|
|
119
301
|
form["resource"] = resource if resource
|
|
120
302
|
|
|
121
303
|
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
|
122
|
-
|
|
304
|
+
save_tokens_issued_by(tokens, as_metadata: as_metadata)
|
|
305
|
+
|
|
123
306
|
:authorized
|
|
124
307
|
end
|
|
125
308
|
|
|
@@ -163,12 +346,18 @@ module MCP
|
|
|
163
346
|
# and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request.
|
|
164
347
|
# The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated.
|
|
165
348
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
|
|
166
|
-
def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:)
|
|
349
|
+
def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:, server_url:)
|
|
167
350
|
client_info = @provider.client_information
|
|
168
351
|
unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
|
|
169
352
|
raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`."
|
|
170
353
|
end
|
|
171
354
|
|
|
355
|
+
# Asked before the assertion is minted, for the same reason registration waits on the authorization-code grant:
|
|
356
|
+
# obtaining an ID-JAG sends the identity provider an audience of this authorization server, which must not happen once
|
|
357
|
+
# the host has refused it.
|
|
358
|
+
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
359
|
+
authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
|
|
360
|
+
|
|
172
361
|
assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource)
|
|
173
362
|
if assertion.nil? || assertion.to_s.empty?
|
|
174
363
|
raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion."
|
|
@@ -178,12 +367,11 @@ module MCP
|
|
|
178
367
|
"grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
179
368
|
"assertion" => assertion,
|
|
180
369
|
}
|
|
181
|
-
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
182
370
|
form["scope"] = effective_scope if effective_scope
|
|
183
371
|
form["resource"] = resource if resource
|
|
184
372
|
|
|
185
373
|
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
|
186
|
-
|
|
374
|
+
save_tokens_issued_by(tokens, as_metadata: as_metadata)
|
|
187
375
|
:authorized
|
|
188
376
|
end
|
|
189
377
|
|
|
@@ -192,19 +380,22 @@ module MCP
|
|
|
192
380
|
# checks before talking to it.
|
|
193
381
|
#
|
|
194
382
|
# Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
|
|
383
|
+
# when a `client_credentials` or `jwt-bearer` provider's tokens record no issuer,
|
|
195
384
|
# or when the token endpoint refuses the refresh request.
|
|
196
385
|
# https://www.rfc-editor.org/rfc/rfc6749#section-6
|
|
197
386
|
def refresh!(server_url:, resource_metadata_url: nil)
|
|
198
387
|
refresh_token = read_token("refresh_token")
|
|
199
388
|
raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
|
|
200
389
|
|
|
390
|
+
ensure_refresh_token_issuer_recorded!
|
|
391
|
+
|
|
201
392
|
stored_client_info = @provider.client_information
|
|
202
393
|
have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
|
|
203
394
|
|
|
204
395
|
# A CIMD-configured provider stores no `client_information` on purpose
|
|
205
396
|
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
|
|
206
397
|
# Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
|
|
207
|
-
have_cimd_url =
|
|
398
|
+
have_cimd_url = !provider_client_id_metadata_document_url.nil?
|
|
208
399
|
|
|
209
400
|
unless have_stored_client_info || have_cimd_url
|
|
210
401
|
raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
|
|
@@ -212,6 +403,11 @@ module MCP
|
|
|
212
403
|
|
|
213
404
|
if resource_metadata_url
|
|
214
405
|
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
|
406
|
+
ensure_same_origin!(
|
|
407
|
+
resource_metadata_url,
|
|
408
|
+
label: "WWW-Authenticate resource_metadata URL",
|
|
409
|
+
server_url: server_url,
|
|
410
|
+
)
|
|
215
411
|
end
|
|
216
412
|
|
|
217
413
|
prm, authorization_server = locate_authorization_server(
|
|
@@ -221,7 +417,13 @@ module MCP
|
|
|
221
417
|
|
|
222
418
|
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
|
|
223
419
|
|
|
224
|
-
as_metadata = authorization_server_metadata(
|
|
420
|
+
as_metadata = authorization_server_metadata(
|
|
421
|
+
authorization_server: authorization_server,
|
|
422
|
+
legacy: prm.nil?,
|
|
423
|
+
server_url: server_url,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
ensure_token_issuer!(as_metadata: as_metadata)
|
|
225
427
|
|
|
226
428
|
client_info = if have_stored_client_info
|
|
227
429
|
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
|
|
@@ -229,7 +431,7 @@ module MCP
|
|
|
229
431
|
ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata)
|
|
230
432
|
stored_client_info
|
|
231
433
|
elsif as_metadata["client_id_metadata_document_supported"] == true
|
|
232
|
-
{ "client_id" =>
|
|
434
|
+
{ "client_id" => provider_client_id_metadata_document_url }
|
|
233
435
|
else
|
|
234
436
|
raise AuthorizationError,
|
|
235
437
|
"Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
|
|
@@ -243,7 +445,7 @@ module MCP
|
|
|
243
445
|
resource: resource,
|
|
244
446
|
)
|
|
245
447
|
|
|
246
|
-
|
|
448
|
+
save_tokens_issued_by(preserve_refresh_token(new_tokens, refresh_token), as_metadata: as_metadata)
|
|
247
449
|
:refreshed
|
|
248
450
|
end
|
|
249
451
|
|
|
@@ -281,7 +483,12 @@ module MCP
|
|
|
281
483
|
#
|
|
282
484
|
# Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
|
|
283
485
|
# and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
|
|
284
|
-
#
|
|
486
|
+
# Only a discovery answer saying that nothing usable is published (`MetadataNotPublishedError`: a `4xx` other than `429`,
|
|
487
|
+
# a redirect that was not followed, or a body that is not a JSON object) selects the legacy path.
|
|
488
|
+
# A request that failed to reach the server, or returned a `5xx` or `429`, says nothing about what the server publishes,
|
|
489
|
+
# so once no candidate has served a usable document it is surfaced instead (`MetadataUnreachableError`),
|
|
490
|
+
# as both SDKs do for network errors (the TypeScript SDK outside browsers) and the Python SDK does for server errors.
|
|
491
|
+
# A body over the response cap is refused outright and never reaches the fallback either.
|
|
285
492
|
# https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
|
|
286
493
|
def locate_authorization_server(server_url:, resource_metadata_url:)
|
|
287
494
|
prm = begin
|
|
@@ -289,13 +496,18 @@ module MCP
|
|
|
289
496
|
server_url: server_url,
|
|
290
497
|
resource_metadata_url: resource_metadata_url,
|
|
291
498
|
)
|
|
292
|
-
rescue
|
|
499
|
+
rescue MetadataNotPublishedError
|
|
293
500
|
nil
|
|
294
501
|
end
|
|
295
502
|
|
|
296
503
|
if prm
|
|
297
504
|
authorization_server = first_authorization_server(prm)
|
|
298
505
|
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
506
|
+
ensure_routable_destination!(
|
|
507
|
+
authorization_server,
|
|
508
|
+
label: "PRM `authorization_servers` entry",
|
|
509
|
+
server_url: server_url,
|
|
510
|
+
)
|
|
299
511
|
[prm, authorization_server]
|
|
300
512
|
else
|
|
301
513
|
authorization_base = server_origin!(server_url)
|
|
@@ -306,16 +518,26 @@ module MCP
|
|
|
306
518
|
|
|
307
519
|
# Fetches and validates the authorization server's RFC 8414 metadata.
|
|
308
520
|
#
|
|
309
|
-
#
|
|
310
|
-
# On the legacy 2025-03-26 path
|
|
311
|
-
#
|
|
312
|
-
#
|
|
521
|
+
# The metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3) on both paths.
|
|
522
|
+
# On the legacy 2025-03-26 path the discovery URL is the MCP server's origin, which that spec names as
|
|
523
|
+
# the authorization base URL and which a document may render with a trailing slash; the TypeScript and Python SDKs
|
|
524
|
+
# accept the same slash-only difference. A document naming any other issuer is refused: an unverified `issuer`
|
|
525
|
+
# would otherwise become the identity tokens and client information are bound to, assertions are minted for,
|
|
526
|
+
# and the validator is shown, so a server could claim another authorization server and unlock the credentials
|
|
527
|
+
# bound to it.
|
|
313
528
|
# When even the metadata document is absent, the legacy spec's default endpoints are used.
|
|
314
|
-
def authorization_server_metadata(authorization_server:, legacy:)
|
|
529
|
+
def authorization_server_metadata(authorization_server:, legacy:, server_url:)
|
|
315
530
|
metadata = if legacy
|
|
316
|
-
begin
|
|
531
|
+
fetched = begin
|
|
317
532
|
fetch_authorization_server_metadata(issuer_url: authorization_server)
|
|
318
533
|
rescue AuthorizationError
|
|
534
|
+
nil
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
if fetched
|
|
538
|
+
ensure_legacy_issuer_matches!(expected: authorization_server, returned: fetched["issuer"])
|
|
539
|
+
fetched
|
|
540
|
+
else
|
|
319
541
|
default_legacy_metadata(authorization_server)
|
|
320
542
|
end
|
|
321
543
|
else
|
|
@@ -324,7 +546,7 @@ module MCP
|
|
|
324
546
|
end
|
|
325
547
|
end
|
|
326
548
|
|
|
327
|
-
ensure_secure_endpoints!(metadata)
|
|
549
|
+
ensure_secure_endpoints!(metadata, server_url: server_url)
|
|
328
550
|
metadata
|
|
329
551
|
end
|
|
330
552
|
|
|
@@ -360,6 +582,14 @@ module MCP
|
|
|
360
582
|
fetch_metadata_json(urls, label: "authorization server metadata")
|
|
361
583
|
end
|
|
362
584
|
|
|
585
|
+
# The legacy authorization base is an origin, which a document may render as `https://host/`;
|
|
586
|
+
# both name the same server, and nothing else does.
|
|
587
|
+
def ensure_legacy_issuer_matches!(expected:, returned:)
|
|
588
|
+
return if returned == "#{expected}/"
|
|
589
|
+
|
|
590
|
+
ensure_issuer_matches!(expected: expected, returned: returned)
|
|
591
|
+
end
|
|
592
|
+
|
|
363
593
|
# Reads `authorization_servers` from a PRM document and returns
|
|
364
594
|
# the first entry, raising `AuthorizationError` for any of the malformed
|
|
365
595
|
# shapes a non-compliant server could emit (missing field, non-Array
|
|
@@ -387,44 +617,63 @@ module MCP
|
|
|
387
617
|
first
|
|
388
618
|
end
|
|
389
619
|
|
|
390
|
-
# Walks candidate metadata URLs and returns the parsed
|
|
391
|
-
# the
|
|
392
|
-
#
|
|
393
|
-
#
|
|
620
|
+
# Walks candidate metadata URLs and returns the parsed body of the first 2xx response that is a JSON object;
|
|
621
|
+
# the caller checks its fields. Candidates are tried until one serves such a body, since a later one may still
|
|
622
|
+
# be usable when an earlier one is broken or down (the URL from `WWW-Authenticate` against the well-known path,
|
|
623
|
+
# or the OAuth document against the OpenID one). Once the candidates are exhausted, an answer that said nothing
|
|
624
|
+
# about what is published (a network error, a `5xx`, or a `429`) outranks the rest and raises
|
|
625
|
+
# `MetadataUnreachableError`; otherwise (any other status, such as a `4xx` other than `429` or a redirect that
|
|
626
|
+
# was not followed, a body that is not JSON, or not a JSON object) `MetadataNotPublishedError`.
|
|
627
|
+
# A body over the cap is refused outright by `bounded_request` with a plain `AuthorizationError`,
|
|
628
|
+
# before any classification. Each failure is listed with its URL stripped of userinfo, query and fragment
|
|
629
|
+
# and cut to `METADATA_URL_MAX_LENGTH`, but otherwise spelled as requested, so it can be matched against
|
|
630
|
+
# a server's access log, and with exception text bounded, since the message lands in every log destination
|
|
631
|
+
# the error passes through.
|
|
394
632
|
def fetch_metadata_json(urls, label:)
|
|
395
|
-
|
|
633
|
+
failures = []
|
|
634
|
+
inconclusive = false
|
|
396
635
|
urls.each do |url|
|
|
397
636
|
response = begin
|
|
398
637
|
http_get(url)
|
|
399
638
|
rescue Faraday::Error => e
|
|
400
|
-
|
|
639
|
+
detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH)
|
|
640
|
+
failures << "GET #{reported_url(url)} raised #{[e.class, detail].compact.join(": ")}"
|
|
641
|
+
inconclusive = true
|
|
401
642
|
next
|
|
402
643
|
end
|
|
403
644
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
end
|
|
645
|
+
unless response.status >= 200 && response.status < 300
|
|
646
|
+
failures << "GET #{reported_url(url)} returned #{response.status}"
|
|
647
|
+
inconclusive = true if response.status >= 500 || response.status == 429
|
|
648
|
+
next
|
|
649
|
+
end
|
|
410
650
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
#
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
raise AuthorizationError,
|
|
419
|
-
"#{label} from #{url} is not a JSON object (got #{parsed.class})."
|
|
420
|
-
end
|
|
651
|
+
parsed = begin
|
|
652
|
+
JSON.parse(response_body_string(response))
|
|
653
|
+
rescue JSON::ParserError => e
|
|
654
|
+
detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH) || e.class.name
|
|
655
|
+
failures << "GET #{reported_url(url)} returned a body that is not JSON: #{detail}"
|
|
656
|
+
next
|
|
657
|
+
end
|
|
421
658
|
|
|
422
|
-
|
|
659
|
+
# Even valid JSON can be the wrong shape (a top-level array, a bare `null`, a string, ...).
|
|
660
|
+
# The discovery callers index by name (`prm["authorization_servers"]`, etc.), so anything that
|
|
661
|
+
# is not a Hash would raise `TypeError` / `NoMethodError` downstream.
|
|
662
|
+
unless parsed.is_a?(Hash)
|
|
663
|
+
failures << "GET #{reported_url(url)} returned a body that is not a JSON object (got #{parsed.class})"
|
|
664
|
+
next
|
|
423
665
|
end
|
|
424
666
|
|
|
425
|
-
|
|
667
|
+
return parsed
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
message = "Failed to fetch #{label}: #{failures.join("; ")}."
|
|
671
|
+
|
|
672
|
+
if inconclusive
|
|
673
|
+
raise MetadataUnreachableError, message
|
|
674
|
+
else
|
|
675
|
+
raise MetadataNotPublishedError, message
|
|
426
676
|
end
|
|
427
|
-
raise AuthorizationError, "Failed to fetch #{label}: #{last_error}."
|
|
428
677
|
end
|
|
429
678
|
|
|
430
679
|
def ensure_pkce_supported!(as_metadata)
|
|
@@ -445,13 +694,109 @@ module MCP
|
|
|
445
694
|
"#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)."
|
|
446
695
|
end
|
|
447
696
|
|
|
448
|
-
|
|
697
|
+
# Requires a URL the *server* chose to sit on the origin the *caller* chose.
|
|
698
|
+
#
|
|
699
|
+
# Protected Resource Metadata describes the MCP server itself, so on a real deployment it is
|
|
700
|
+
# published on that server's own origin. Without this check a `WWW-Authenticate` challenge
|
|
701
|
+
# can aim the first request of the flow at any host the client can route to: the URL arrives
|
|
702
|
+
# from the network, it is fetched before the user approves anything, and the `resource` check that
|
|
703
|
+
# runs afterwards cannot un-send the request.
|
|
704
|
+
#
|
|
705
|
+
# RFC 9728 does not itself require the metadata URL to be same-origin, so this is stricter than
|
|
706
|
+
# the specification. It is enforced unconditionally because no known deployment publishes its PRM
|
|
707
|
+
# anywhere else, and because the alternative (`Discovery.private_network_host?`) cannot see
|
|
708
|
+
# internal hosts that are named rather than addressed.
|
|
709
|
+
# https://www.rfc-editor.org/rfc/rfc9728#section-7.7
|
|
710
|
+
def ensure_same_origin!(url, label:, server_url:)
|
|
711
|
+
return if Discovery.same_origin?(url, server_url)
|
|
712
|
+
|
|
713
|
+
raise AuthorizationError,
|
|
714
|
+
"#{label} #{sanitized_url(url).inspect} is not on the MCP server origin " \
|
|
715
|
+
"#{sanitized_url(server_url).inspect}; refusing to fetch it."
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Refuses an OAuth URL that points into a private, loopback, link-local, or unique-local address,
|
|
719
|
+
# which is the SSRF precaution RFC 9728 Section 7.7 and the MCP security best practices ask clients to take.
|
|
720
|
+
#
|
|
721
|
+
# The carve-out matters as much as the rule: when the MCP server the caller configured is itself on such an address,
|
|
722
|
+
# the whole flow is already inside that network and the authorization server legitimately lives there too.
|
|
723
|
+
# That covers `http://localhost` development, the conformance harness (which runs the MCP server and
|
|
724
|
+
# the authorization server on two loopback ports), and deployments that never leave a corporate network.
|
|
725
|
+
# Only a server reachable on the public internet is barred from steering the client inward.
|
|
726
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
|
727
|
+
# Hands the embedding application the authorization server and the scopes that are about to be requested,
|
|
728
|
+
# and abandons the flow when it refuses them.
|
|
729
|
+
#
|
|
730
|
+
# Both values are chosen by the MCP server: it names its own authorization server in Protected
|
|
731
|
+
# Resource Metadata and states the scopes in `scopes_supported` or the `WWW-Authenticate` challenge.
|
|
732
|
+
# Neither the specification nor any MCP SDK binds that choice to the server's own identity,
|
|
733
|
+
# and validating that a token was issued for the intended audience is a responsibility the specification
|
|
734
|
+
# places on MCP servers rather than on clients.
|
|
735
|
+
# A host that knows which providers its user deals with can apply that knowledge here.
|
|
736
|
+
#
|
|
737
|
+
# The scopes are passed on unchanged whatever the host decides, because the specification requires
|
|
738
|
+
# a client to treat the challenged scopes as authoritative for the operation; the choice offered is
|
|
739
|
+
# to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before.
|
|
740
|
+
#
|
|
741
|
+
# Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered
|
|
742
|
+
# this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead:
|
|
743
|
+
# an authorization server that has changed since the tokens were issued sends the flow back through here,
|
|
744
|
+
# where the host sees the new one and decides again.
|
|
745
|
+
def authorize_request!(as_metadata:, scope:, server_url:, resource:)
|
|
746
|
+
return unless @provider.respond_to?(:authorization_request_validator)
|
|
747
|
+
return unless (validator = @provider.authorization_request_validator)
|
|
748
|
+
|
|
749
|
+
request = AuthorizationRequest.new(
|
|
750
|
+
authorization_server: as_metadata["issuer"],
|
|
751
|
+
scopes: scope.to_s.split,
|
|
752
|
+
server_url: server_url,
|
|
753
|
+
resource: resource,
|
|
754
|
+
).freeze
|
|
755
|
+
|
|
756
|
+
return if validator.call(request)
|
|
757
|
+
|
|
758
|
+
raise AuthorizationRefusedError, <<~MESSAGE
|
|
759
|
+
The authorization request was refused by `authorization_request_validator` \
|
|
760
|
+
(authorization server #{request.authorization_server.inspect}, scopes #{request.scopes.inspect}).
|
|
761
|
+
MESSAGE
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def ensure_routable_destination!(url, label:, server_url:)
|
|
765
|
+
return unless private_network_url?(url)
|
|
766
|
+
return if private_network_url?(server_url)
|
|
767
|
+
|
|
768
|
+
raise AuthorizationError,
|
|
769
|
+
"#{label} #{sanitized_url(url).inspect} points into a private network range, " \
|
|
770
|
+
"which the MCP server at #{sanitized_url(server_url).inspect} is not on; refusing to contact it."
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def ensure_secure_endpoints!(as_metadata, server_url:)
|
|
449
774
|
["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key|
|
|
450
775
|
endpoint = as_metadata[key]
|
|
451
|
-
|
|
776
|
+
next unless endpoint
|
|
777
|
+
|
|
778
|
+
ensure_secure_url!(endpoint, label: "Authorization server #{key}")
|
|
779
|
+
ensure_routable_destination!(endpoint, label: "Authorization server #{key}", server_url: server_url)
|
|
452
780
|
end
|
|
453
781
|
end
|
|
454
782
|
|
|
783
|
+
# `ensure_secure_url!` runs first at every call site and already rejects a URL that fails to parse,
|
|
784
|
+
# so the rescue here is a backstop rather than a leniency.
|
|
785
|
+
def private_network_url?(url)
|
|
786
|
+
Discovery.private_network_host?(URI.parse(url.to_s).host)
|
|
787
|
+
rescue URI::InvalidURIError
|
|
788
|
+
false
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
# Strips userinfo and query before a URL reaches an exception message, the same precaution `MCP::Client::HTTP` takes
|
|
792
|
+
# when it reports a URL: these values come off the network and can carry credentials that would otherwise land in
|
|
793
|
+
# every log destination the error passes through.
|
|
794
|
+
def sanitized_url(url)
|
|
795
|
+
Discovery.canonicalize_origin_and_path(url)
|
|
796
|
+
rescue URI::Error
|
|
797
|
+
url.to_s
|
|
798
|
+
end
|
|
799
|
+
|
|
455
800
|
# Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be
|
|
456
801
|
# identical (literal byte-for-byte equality, no normalization) to
|
|
457
802
|
# the issuer URL the client used to discover that document. This guards
|
|
@@ -489,7 +834,7 @@ module MCP
|
|
|
489
834
|
# (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
|
|
490
835
|
# keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
|
|
491
836
|
# and the current `provider.client_id_metadata_document_url`.
|
|
492
|
-
cimd_url =
|
|
837
|
+
cimd_url = provider_client_id_metadata_document_url
|
|
493
838
|
if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
|
|
494
839
|
return { "client_id" => cimd_url }
|
|
495
840
|
end
|
|
@@ -586,6 +931,51 @@ module MCP
|
|
|
586
931
|
# the error and falls back to the full flow, which discards the stale registration and re-registers.
|
|
587
932
|
# Credentials without an `"issuer"` binding predate this check and are allowed through;
|
|
588
933
|
# CIMD `client_id`s are portable.
|
|
934
|
+
# Records which authorization server minted these tokens, alongside the tokens themselves.
|
|
935
|
+
# SEP-2352 already binds *client credentials* to their issuer; this binds the *tokens*, which that
|
|
936
|
+
# SEP leaves unbound.
|
|
937
|
+
# Stored in the token hash so it travels through any `storage` a caller supplied, without widening
|
|
938
|
+
# the storage interface that custom implementations have to satisfy.
|
|
939
|
+
def save_tokens_issued_by(tokens, as_metadata:)
|
|
940
|
+
issuer = as_metadata["issuer"]
|
|
941
|
+
tokens = tokens.merge("issuer" => issuer) if tokens.is_a?(Hash) && issuer
|
|
942
|
+
|
|
943
|
+
@provider.save_tokens(tokens)
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
# Refuses to present a refresh token to an authorization server other than the one that issued it.
|
|
947
|
+
# `refresh!` rediscovers the authorization server from Protected Resource Metadata every time,
|
|
948
|
+
# so the server named for a session can differ from the one the stored tokens came from.
|
|
949
|
+
# Unlike `ensure_refreshable_client_information!` this holds whatever the client identity is,
|
|
950
|
+
# including a Client ID Metadata Document URL, which is portable across authorization servers precisely
|
|
951
|
+
# so that re-registration is unnecessary.
|
|
952
|
+
#
|
|
953
|
+
# Tokens stored before this shipped carry no issuer and are left alone, matching how SEP-2352 treats
|
|
954
|
+
# client information without one; the binding takes effect from their next authorization.
|
|
955
|
+
# The caller answers a refusal by running the full authorization flow, which is where the embedding application
|
|
956
|
+
# is asked about the new authorization server.
|
|
957
|
+
def ensure_token_issuer!(as_metadata:)
|
|
958
|
+
stored_issuer = read_token("issuer")
|
|
959
|
+
return if stored_issuer.nil? || stored_issuer == as_metadata["issuer"]
|
|
960
|
+
|
|
961
|
+
raise AuthorizationError, <<~MESSAGE
|
|
962
|
+
Cannot refresh: the stored tokens were issued by a different authorization server (stored issuer #{stored_issuer.inspect}, \
|
|
963
|
+
current #{as_metadata["issuer"].inspect}); re-authorization is required.
|
|
964
|
+
MESSAGE
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# `Provider` tolerates tokens stored before the issuer was recorded (see `ensure_token_issuer!`).
|
|
968
|
+
# The `client_credentials` and `jwt-bearer` providers have no such tokens, since their refresh is new,
|
|
969
|
+
# and a refresh asks no validator, so a token without an `issuer` would be presented to whatever
|
|
970
|
+
# authorization server discovery names now. Refusing it sends the transport back through the grant,
|
|
971
|
+
# which does ask.
|
|
972
|
+
def ensure_refresh_token_issuer_recorded!
|
|
973
|
+
return if provider_authorization_flow == :authorization_code
|
|
974
|
+
return unless read_token("issuer").nil?
|
|
975
|
+
|
|
976
|
+
raise AuthorizationError, "Cannot refresh: the stored tokens record no issuer; re-authorization is required."
|
|
977
|
+
end
|
|
978
|
+
|
|
589
979
|
def ensure_refreshable_client_information!(client_info, as_metadata:)
|
|
590
980
|
stored_issuer = client_info_required_value(client_info, "issuer")
|
|
591
981
|
return if stored_issuer.nil?
|
|
@@ -714,7 +1104,8 @@ module MCP
|
|
|
714
1104
|
def resolve_scope(scope:, prm:)
|
|
715
1105
|
return scope if scope && !scope.empty?
|
|
716
1106
|
|
|
717
|
-
|
|
1107
|
+
# `prm` is nil on the legacy path, where nothing advertises scopes.
|
|
1108
|
+
supported = prm && prm["scopes_supported"]
|
|
718
1109
|
return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
|
|
719
1110
|
|
|
720
1111
|
return @provider.scope if @provider.scope && !@provider.scope.empty?
|
|
@@ -776,6 +1167,31 @@ module MCP
|
|
|
776
1167
|
@provider.authorization_flow
|
|
777
1168
|
end
|
|
778
1169
|
|
|
1170
|
+
# Parameters the provider adds to every token request it makes (RFC 6749 Section 8.2 leaves room for them;
|
|
1171
|
+
# Auth0's `audience` is the usual one). Duck-typed like `authorization_flow`, so a provider without the method,
|
|
1172
|
+
# or one returning `nil`, adds nothing.
|
|
1173
|
+
# A bad value raises `InvalidTokenRequestParamsError`, as the provider constructors do.
|
|
1174
|
+
def provider_token_request_params
|
|
1175
|
+
return {} unless @provider.respond_to?(:token_request_params)
|
|
1176
|
+
|
|
1177
|
+
params = @provider.token_request_params
|
|
1178
|
+
return {} if params.nil?
|
|
1179
|
+
|
|
1180
|
+
problem = self.class.token_request_params_problem(params)
|
|
1181
|
+
raise InvalidTokenRequestParamsError, "The provider's token_request_params #{problem}" if problem
|
|
1182
|
+
|
|
1183
|
+
params
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1186
|
+
# The Client ID Metadata Document URL, when the provider has one. Only `Provider` exposes the reader
|
|
1187
|
+
# (CIMD replaces Dynamic Client Registration on the authorization-code flow), while `refresh!` serves
|
|
1188
|
+
# every provider that holds a `refresh_token`, so the read is duck-typed like `authorization_flow`.
|
|
1189
|
+
def provider_client_id_metadata_document_url
|
|
1190
|
+
return unless @provider.respond_to?(:client_id_metadata_document_url)
|
|
1191
|
+
|
|
1192
|
+
@provider.client_id_metadata_document_url
|
|
1193
|
+
end
|
|
1194
|
+
|
|
779
1195
|
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
|
|
780
1196
|
authorization_endpoint = as_metadata["authorization_endpoint"]
|
|
781
1197
|
unless authorization_endpoint
|
|
@@ -825,9 +1241,12 @@ module MCP
|
|
|
825
1241
|
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
|
826
1242
|
end
|
|
827
1243
|
|
|
828
|
-
# Submits a form-encoded request
|
|
829
|
-
#
|
|
830
|
-
#
|
|
1244
|
+
# Submits a form-encoded token request using the authentication method
|
|
1245
|
+
# stored in `client_information`. The method determines whether client
|
|
1246
|
+
# credentials belong in the form body, a Basic header, or a JWT assertion.
|
|
1247
|
+
# A provider's `token_request_params` go underneath the flow's own parameters,
|
|
1248
|
+
# which therefore win, and are refused before the request is sent when they name
|
|
1249
|
+
# a reserved parameter or are not a Hash of Strings.
|
|
831
1250
|
def post_to_token_endpoint(as_metadata:, client_info:, form:)
|
|
832
1251
|
client_id = client_info_required_value(client_info, "client_id")
|
|
833
1252
|
unless client_id
|
|
@@ -837,13 +1256,15 @@ module MCP
|
|
|
837
1256
|
|
|
838
1257
|
client_secret = client_info_required_value(client_info, "client_secret")
|
|
839
1258
|
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
|
|
1259
|
+
form = provider_token_request_params.merge(form)
|
|
840
1260
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
#
|
|
846
|
-
#
|
|
1261
|
+
# Apply one client authentication method per request (RFC 6749 Section 2.3).
|
|
1262
|
+
headers = {}
|
|
1263
|
+
form = case token_endpoint_auth_method
|
|
1264
|
+
when "private_key_jwt"
|
|
1265
|
+
# The assertion identifies the client through its `iss` and `sub`
|
|
1266
|
+
# claims, so the body needs no separate `client_id` (RFC 7521 Section 4.2).
|
|
1267
|
+
# Use the issuer already checked by `ensure_issuer_matches!` as the audience.
|
|
847
1268
|
unless @provider.respond_to?(:client_assertion)
|
|
848
1269
|
raise AuthorizationError,
|
|
849
1270
|
"token_endpoint_auth_method is private_key_jwt but the provider does not " \
|
|
@@ -854,22 +1275,24 @@ module MCP
|
|
|
854
1275
|
"client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE,
|
|
855
1276
|
"client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]),
|
|
856
1277
|
)
|
|
1278
|
+
when "client_secret_post"
|
|
1279
|
+
# Send the client ID and available secret in the form body.
|
|
1280
|
+
body = form.merge("client_id" => client_id)
|
|
1281
|
+
body["client_secret"] = client_secret if client_secret
|
|
1282
|
+
body
|
|
857
1283
|
else
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
case token_endpoint_auth_method
|
|
864
|
-
when "client_secret_post"
|
|
865
|
-
form["client_secret"] = client_secret
|
|
866
|
-
when "none"
|
|
867
|
-
# Public client; no credential.
|
|
868
|
-
else
|
|
869
|
-
# RFC 6749 §2.3.1 recommends Basic for confidential clients and
|
|
870
|
-
# both Python and TypeScript SDKs default here when
|
|
871
|
-
# the authentication method is not explicitly stored.
|
|
1284
|
+
if client_secret && token_endpoint_auth_method != "none"
|
|
1285
|
+
# Basic is also the fallback when a secret is present but no method
|
|
1286
|
+
# is stored. A body `client_id` is optional (RFC 6749 Section 3.2.1);
|
|
1287
|
+
# omit it because some servers treat it alongside Basic as a second
|
|
1288
|
+
# authentication method and reject the request with `invalid_request`.
|
|
872
1289
|
headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret)
|
|
1290
|
+
form
|
|
1291
|
+
else
|
|
1292
|
+
# With `none` or no secret, identify the client using `client_id`.
|
|
1293
|
+
# This is required for unauthenticated authorization-code exchanges
|
|
1294
|
+
# (RFC 6749 Section 3.2.1).
|
|
1295
|
+
form.merge("client_id" => client_id)
|
|
873
1296
|
end
|
|
874
1297
|
end
|
|
875
1298
|
|
|
@@ -887,11 +1310,7 @@ module MCP
|
|
|
887
1310
|
end
|
|
888
1311
|
|
|
889
1312
|
if response.status < 200 || response.status >= 300
|
|
890
|
-
|
|
891
|
-
raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant."
|
|
892
|
-
end
|
|
893
|
-
|
|
894
|
-
raise AuthorizationError, "Token endpoint returned status #{response.status}."
|
|
1313
|
+
raise token_endpoint_error(response)
|
|
895
1314
|
end
|
|
896
1315
|
|
|
897
1316
|
parsed = begin
|
|
@@ -912,17 +1331,41 @@ module MCP
|
|
|
912
1331
|
parsed
|
|
913
1332
|
end
|
|
914
1333
|
|
|
915
|
-
#
|
|
916
|
-
#
|
|
917
|
-
#
|
|
918
|
-
def
|
|
919
|
-
|
|
920
|
-
|
|
1334
|
+
# Surface only RFC 6749 §5.2 diagnostic fields, never the raw response,
|
|
1335
|
+
# which may contain tokens or other credentials. Classify the original
|
|
1336
|
+
# code so sanitization cannot turn malformed input into invalid_grant.
|
|
1337
|
+
def token_endpoint_error(response)
|
|
1338
|
+
message = "Token endpoint returned status #{response.status}."
|
|
1339
|
+
error_class = AuthorizationError
|
|
1340
|
+
parsed = JSON.parse(response_body_string(response))
|
|
1341
|
+
parsed = {} unless parsed.is_a?(Hash)
|
|
1342
|
+
|
|
1343
|
+
error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError
|
|
1344
|
+
error = bounded_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
|
|
1345
|
+
description = bounded_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
|
|
1346
|
+
message += " #{[error, description].compact.join(": ")}" if error || description
|
|
1347
|
+
|
|
1348
|
+
error_class.new(message, http_status: response.status, error: error, error_description: description)
|
|
1349
|
+
rescue StandardError
|
|
1350
|
+
# Diagnostics must not mask the endpoint failure or change refresh recovery.
|
|
1351
|
+
error_class.new("Token endpoint returned status #{response.status}.", http_status: response.status)
|
|
1352
|
+
end
|
|
921
1353
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1354
|
+
def bounded_diagnostic(value, limit:)
|
|
1355
|
+
return unless value.is_a?(String)
|
|
1356
|
+
|
|
1357
|
+
# RFC 6749 permits printable ASCII except double quotes and backslashes in token endpoint error fields.
|
|
1358
|
+
# Replace other characters to keep text received off the network on one log line.
|
|
1359
|
+
value = value.scrub(" ").gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, " ").strip
|
|
1360
|
+
return if value.empty?
|
|
1361
|
+
|
|
1362
|
+
value.length > limit ? "#{value[0, limit - 3]}..." : value
|
|
1363
|
+
end
|
|
1364
|
+
|
|
1365
|
+
# A candidate URL as it goes into a failure string: redacted, and cut so a URL the server chose cannot
|
|
1366
|
+
# grow the message without limit.
|
|
1367
|
+
def reported_url(url)
|
|
1368
|
+
bounded_diagnostic(Discovery.redact_url(url), limit: METADATA_URL_MAX_LENGTH)
|
|
926
1369
|
end
|
|
927
1370
|
|
|
928
1371
|
# Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
|
|
@@ -938,35 +1381,70 @@ module MCP
|
|
|
938
1381
|
end
|
|
939
1382
|
|
|
940
1383
|
def http_get(url)
|
|
941
|
-
|
|
1384
|
+
bounded_request do |on_data|
|
|
1385
|
+
http_client.get(url) do |req|
|
|
1386
|
+
req.options.on_data = on_data
|
|
1387
|
+
end
|
|
1388
|
+
end
|
|
942
1389
|
end
|
|
943
1390
|
|
|
944
1391
|
def http_post_json(url, body)
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1392
|
+
bounded_request do |on_data|
|
|
1393
|
+
http_client.post(url) do |req|
|
|
1394
|
+
req.headers["Content-Type"] = "application/json"
|
|
1395
|
+
req.headers["Accept"] = "application/json"
|
|
1396
|
+
req.options.on_data = on_data
|
|
1397
|
+
req.body = JSON.generate(body)
|
|
1398
|
+
end
|
|
949
1399
|
end
|
|
950
1400
|
end
|
|
951
1401
|
|
|
952
1402
|
def http_post_form(url, form, headers: {})
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1403
|
+
bounded_request do |on_data|
|
|
1404
|
+
http_client.post(url) do |req|
|
|
1405
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
1406
|
+
req.headers["Accept"] = "application/json"
|
|
1407
|
+
|
|
1408
|
+
headers.each do |key, value|
|
|
1409
|
+
req.headers[key] = value
|
|
1410
|
+
end
|
|
1411
|
+
|
|
1412
|
+
req.options.on_data = on_data
|
|
1413
|
+
req.body = URI.encode_www_form(form)
|
|
1414
|
+
end
|
|
958
1415
|
end
|
|
959
1416
|
end
|
|
960
1417
|
|
|
1418
|
+
# Issues a request with the response body bounded as it arrives, and returns the status paired with
|
|
1419
|
+
# that body. An over-cap response is refused rather than truncated: a partial discovery or token document
|
|
1420
|
+
# cannot be validated, and `fetch_metadata_json` must not fall through to the next candidate URL either,
|
|
1421
|
+
# since the same server would serve the same body.
|
|
1422
|
+
def bounded_request
|
|
1423
|
+
bounded = BoundedBody.new
|
|
1424
|
+
|
|
1425
|
+
response = yield(bounded.on_data)
|
|
1426
|
+
|
|
1427
|
+
bounded.response_for(response)
|
|
1428
|
+
rescue BoundedBody::TooLargeError => e
|
|
1429
|
+
raise AuthorizationError, "#{e.message}."
|
|
1430
|
+
end
|
|
1431
|
+
|
|
961
1432
|
def http_client
|
|
962
1433
|
@http_client ||= @http_client_factory.call
|
|
963
1434
|
end
|
|
964
1435
|
|
|
1436
|
+
# A connection supplied through `http_client_factory:` replaces this one, the provider's customizer and
|
|
1437
|
+
# `RequestedOriginGuard` included, so that caller takes on the redirect responsibility described on
|
|
1438
|
+
# `build_http_client`; `bounded_request` caps its responses all the same.
|
|
965
1439
|
def default_http_client
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1440
|
+
self.class.build_http_client(provider_http_client_customizer)
|
|
1441
|
+
end
|
|
1442
|
+
|
|
1443
|
+
# `nil` for a provider that predates the hook or leaves it unset.
|
|
1444
|
+
def provider_http_client_customizer
|
|
1445
|
+
return unless @provider.respond_to?(:http_client_customizer)
|
|
1446
|
+
|
|
1447
|
+
@provider.http_client_customizer
|
|
970
1448
|
end
|
|
971
1449
|
|
|
972
1450
|
def response_body_string(response)
|