mcp 1.6.1 → 1.8.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 +1 -1
- data/lib/mcp/client/http.rb +105 -18
- data/lib/mcp/client/oauth/flow.rb +276 -23
- data/lib/mcp/client/oauth/in_memory_storage.rb +74 -1
- data/lib/mcp/client/oauth/provider.rb +77 -6
- data/lib/mcp/client/stdio.rb +23 -4
- data/lib/mcp/icon.rb +61 -0
- data/lib/mcp/prompt.rb +1 -1
- data/lib/mcp/resource.rb +2 -2
- data/lib/mcp/resource_template.rb +2 -2
- data/lib/mcp/server/oauth/access_token.rb +89 -0
- data/lib/mcp/server/oauth/authenticator.rb +150 -0
- data/lib/mcp/server/oauth/challenge.rb +114 -0
- data/lib/mcp/server/oauth/errors.rb +58 -0
- data/lib/mcp/server/oauth/introspection_verifier.rb +151 -0
- data/lib/mcp/server/oauth/jwt_verifier.rb +330 -0
- data/lib/mcp/server/oauth/middleware.rb +60 -0
- data/lib/mcp/server/oauth/protected_resource_metadata.rb +138 -0
- data/lib/mcp/server/oauth/protected_resource_metadata_middleware.rb +86 -0
- data/lib/mcp/server/oauth/token_verifier.rb +135 -0
- data/lib/mcp/server/oauth.rb +29 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +712 -160
- data/lib/mcp/server.rb +63 -31
- data/lib/mcp/server_context.rb +26 -2
- data/lib/mcp/server_session.rb +14 -4
- data/lib/mcp/tool.rb +1 -1
- data/lib/mcp/version.rb +1 -1
- metadata +13 -2
|
@@ -19,6 +19,9 @@ module MCP
|
|
|
19
19
|
METADATA_DIAGNOSTIC_MAX_LENGTH = 128
|
|
20
20
|
METADATA_URL_MAX_LENGTH = 2048
|
|
21
21
|
|
|
22
|
+
# RFC 6749 scope-token: visible ASCII except space, double quote, and backslash.
|
|
23
|
+
SCOPE_TOKEN_FORMAT = /\A[\x21\x23-\x5B\x5D-\x7E]+\z/.freeze
|
|
24
|
+
|
|
22
25
|
# Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
|
|
23
26
|
# so a provider naming one of these is refused rather than left believing its value was sent.
|
|
24
27
|
RESERVED_TOKEN_REQUEST_PARAMS = [
|
|
@@ -83,6 +86,27 @@ module MCP
|
|
|
83
86
|
# and `MCP::Client::HTTP` treats on a failed refresh as a reason to run the interactive flow.
|
|
84
87
|
class DestinationMismatchError < ArgumentError; end
|
|
85
88
|
|
|
89
|
+
# Raised by `MCP::Client::HTTP` when a provider without a `callback_handler` has sent the user to the authorization server:
|
|
90
|
+
# the request cannot be retried until the application finishes the authorization with `finish!` in the request that
|
|
91
|
+
# receives the redirect. `authorization_url` is the URL handed to `redirect_handler`; it is kept out of the message,
|
|
92
|
+
# which may reach logs, because it carries the `state` that `finish!` looks the pending authorization up by.
|
|
93
|
+
# Deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on a failed refresh as a reason to run
|
|
94
|
+
# the interactive flow: this is the interactive flow waiting on the user, not a failure.
|
|
95
|
+
class AuthorizationPendingError < StandardError
|
|
96
|
+
attr_reader :authorization_url
|
|
97
|
+
|
|
98
|
+
def initialize(message = nil, authorization_url: nil)
|
|
99
|
+
super(message)
|
|
100
|
+
@authorization_url = authorization_url
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A `state` the SDK generates is 43 characters; a callback carrying a much longer one is refused before storage is asked.
|
|
105
|
+
CALLBACK_STATE_MAX_LENGTH = 128
|
|
106
|
+
|
|
107
|
+
UNKNOWN_PENDING_AUTHORIZATION_MESSAGE =
|
|
108
|
+
"Authorization callback `state` matches no pending authorization; it is unknown, already used, or expired."
|
|
109
|
+
|
|
86
110
|
# Faraday middleware registered on the connection `build_http_client` assembles before the customizer
|
|
87
111
|
# is invoked, so with the usual `use` it sits ahead of the customizer's middleware and sees the URL exactly
|
|
88
112
|
# as the flow requested it, which it records on the request environment for `RequestedOriginGuard`.
|
|
@@ -189,13 +213,20 @@ module MCP
|
|
|
189
213
|
end
|
|
190
214
|
end
|
|
191
215
|
|
|
216
|
+
# The authorization URL the last `run!` handed to `redirect_handler` before returning `:redirect`, or `nil`.
|
|
217
|
+
attr_reader :authorization_url
|
|
218
|
+
|
|
192
219
|
def initialize(provider:, http_client_factory: nil)
|
|
193
220
|
@provider = provider
|
|
194
221
|
@http_client_factory = http_client_factory || -> { default_http_client }
|
|
222
|
+
@authorization_url = nil
|
|
195
223
|
end
|
|
196
224
|
|
|
197
225
|
# Runs the full discovery, registration, authorization, and token exchange flow.
|
|
198
226
|
# On success, persists tokens via the provider and returns `:authorized`.
|
|
227
|
+
# A provider without a `callback_handler` stops after the redirect instead: the flow saves
|
|
228
|
+
# a pending authorization in the provider's storage, keyed by `state`, and returns `:redirect`, leaving the code
|
|
229
|
+
# exchange to `finish!` in the request that receives the redirect.
|
|
199
230
|
def run!(server_url:, resource_metadata_url: nil, scope: nil)
|
|
200
231
|
# The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
|
|
201
232
|
# first thing we contact in the OAuth flow, so it has to clear the same
|
|
@@ -236,12 +267,14 @@ module MCP
|
|
|
236
267
|
|
|
237
268
|
ensure_pkce_supported!(as_metadata)
|
|
238
269
|
|
|
239
|
-
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
270
|
+
effective_scope = resolve_scope(scope: scope, prm: prm, select_prm_scope: true)
|
|
240
271
|
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
|
|
272
|
+
endpoint_uri, endpoint_params = authorization_endpoint_parameters(as_metadata: as_metadata)
|
|
273
|
+
request_scope = authorization_request_scope(scope: effective_scope, endpoint_params: endpoint_params)
|
|
241
274
|
|
|
242
275
|
# 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:
|
|
276
|
+
# the embedding application has just rejected. Use the scopes that will actually reach the browser URL.
|
|
277
|
+
authorize_request!(as_metadata: as_metadata, scope: request_scope, server_url: server_url, resource: resource)
|
|
245
278
|
|
|
246
279
|
client_info = ensure_client_registered(as_metadata: as_metadata)
|
|
247
280
|
|
|
@@ -249,7 +282,8 @@ module MCP
|
|
|
249
282
|
state = SecureRandom.urlsafe_base64(32)
|
|
250
283
|
|
|
251
284
|
authorization_url = build_authorization_url(
|
|
252
|
-
|
|
285
|
+
endpoint_uri: endpoint_uri,
|
|
286
|
+
endpoint_params: endpoint_params,
|
|
253
287
|
client_id: client_info_required_value(client_info, "client_id"),
|
|
254
288
|
scope: effective_scope,
|
|
255
289
|
state: state,
|
|
@@ -257,6 +291,20 @@ module MCP
|
|
|
257
291
|
resource: resource,
|
|
258
292
|
)
|
|
259
293
|
|
|
294
|
+
if @provider.callback_handler.nil?
|
|
295
|
+
save_pending_authorization(
|
|
296
|
+
state: state,
|
|
297
|
+
code_verifier: pkce[:code_verifier],
|
|
298
|
+
server_url: server_url,
|
|
299
|
+
resource: resource,
|
|
300
|
+
client_id: client_info_required_value(client_info, "client_id"),
|
|
301
|
+
as_metadata: as_metadata,
|
|
302
|
+
)
|
|
303
|
+
@authorization_url = authorization_url
|
|
304
|
+
@provider.redirect_handler.call(authorization_url)
|
|
305
|
+
return :redirect
|
|
306
|
+
end
|
|
307
|
+
|
|
260
308
|
@provider.redirect_handler.call(authorization_url)
|
|
261
309
|
callback_result = Array(@provider.callback_handler.call)
|
|
262
310
|
code, returned_state, returned_iss = callback_result
|
|
@@ -449,6 +497,87 @@ module MCP
|
|
|
449
497
|
:refreshed
|
|
450
498
|
end
|
|
451
499
|
|
|
500
|
+
# Finishes an authorization that `run!` left pending, in the request that receives the redirect to `redirect_uri`,
|
|
501
|
+
# which may run in another process. `callback_params` is that redirect's whole query as a Hash (`code`, `state`, and,
|
|
502
|
+
# when present, `iss`, `error`, and `error_description`); passing all of it, rather than picking values out, is
|
|
503
|
+
# the caller's part of the `iss` check: an `iss` the caller drops reads as absent, and the flow cannot tell the difference.
|
|
504
|
+
#
|
|
505
|
+
# The pending authorization is looked up by `state` before any request is made, and it binds the rest of the exchange:
|
|
506
|
+
# the code is redeemed at the token endpoint recorded when the authorization began, with the client registration,
|
|
507
|
+
# `resource`, and `redirect_uri` used then, and without discovery running again, so the code reaches the authorization
|
|
508
|
+
# server the user was sent to (SEP-2352). The RFC 9207 `iss` is validated against the recorded issuer before the pending
|
|
509
|
+
# authorization is consumed, so a callback from another authorization server, as in a mix-up, is refused without
|
|
510
|
+
# consuming the entry the legitimate callback needs, and before the callback's `error` is read, since in a mix-up
|
|
511
|
+
# those parameters are the other server's. The check establishes only that a present `iss` matches the recorded issuer,
|
|
512
|
+
# not who sent the callback, and a callback without `iss` passes it unless the metadata advertises
|
|
513
|
+
# `authorization_response_iss_parameter_supported`: whoever holds the `state`, passes that check, and reaches
|
|
514
|
+
# the consume first takes the entry, and the legitimate callback then finds nothing.
|
|
515
|
+
#
|
|
516
|
+
# Past that check the pending authorization is consumed with `delete_pending_authorization`, which returns the entry
|
|
517
|
+
# it removed; only a callback that gets the entry back redeems the code, so of callbacks racing on the same `state`
|
|
518
|
+
# (a retried redirect, say), at most one does.
|
|
519
|
+
#
|
|
520
|
+
# Binding the callback to the user who started the authorization stays with the application: scoping `storage`
|
|
521
|
+
# to that user means a callback delivered to another user's session finds no pending authorization.
|
|
522
|
+
#
|
|
523
|
+
# On success, persists tokens via the provider and returns `:authorized`.
|
|
524
|
+
def finish!(server_url:, callback_params:)
|
|
525
|
+
unless provider_authorization_flow == :authorization_code && @provider.callback_handler.nil?
|
|
526
|
+
raise ArgumentError,
|
|
527
|
+
"finish! completes an authorization started by a provider without a callback_handler; " \
|
|
528
|
+
"this provider finishes its authorizations in `run!`."
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
state = callback_param(callback_params, "state")
|
|
532
|
+
raise AuthorizationError, "Authorization callback carried no `state`." unless state
|
|
533
|
+
|
|
534
|
+
pending = @provider.pending_authorization(state) if state.bytesize <= CALLBACK_STATE_MAX_LENGTH
|
|
535
|
+
raise AuthorizationError, UNKNOWN_PENDING_AUTHORIZATION_MESSAGE unless valid_pending_authorization?(pending)
|
|
536
|
+
|
|
537
|
+
if pending_authorization_expired?(pending)
|
|
538
|
+
@provider.delete_pending_authorization(state)
|
|
539
|
+
raise AuthorizationError, "The pending authorization has expired; start a new authorization."
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
unless safe_canonicalize_url(server_url, label: "MCP server URL") == pending["server_url"]
|
|
543
|
+
raise AuthorizationError, "The pending authorization was started for a different MCP server."
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
as_metadata = pending["authorization_server_metadata"]
|
|
547
|
+
# Checked when the authorization began, and checked again because the metadata has since made a round trip
|
|
548
|
+
# through the application's storage.
|
|
549
|
+
ensure_secure_endpoints!(as_metadata, server_url: pending["server_url"])
|
|
550
|
+
|
|
551
|
+
validate_authorization_response_issuer!(
|
|
552
|
+
as_metadata: as_metadata,
|
|
553
|
+
iss: callback_param(callback_params, "iss"),
|
|
554
|
+
iss_provided: true,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# The read above and this delete are separate calls, so the storage's atomic delete is what decides
|
|
558
|
+
# which of two concurrent callbacks carrying this `state` proceeds.
|
|
559
|
+
consumed = @provider.delete_pending_authorization(state)
|
|
560
|
+
raise AuthorizationError, UNKNOWN_PENDING_AUTHORIZATION_MESSAGE unless valid_pending_authorization?(consumed)
|
|
561
|
+
|
|
562
|
+
error = callback_param(callback_params, "error")
|
|
563
|
+
raise authorization_response_error(error, callback_param(callback_params, "error_description")) if error
|
|
564
|
+
|
|
565
|
+
code = callback_param(callback_params, "code")
|
|
566
|
+
raise AuthorizationError, "Authorization callback carried no authorization code." unless code
|
|
567
|
+
|
|
568
|
+
tokens = exchange_authorization_code(
|
|
569
|
+
as_metadata: as_metadata,
|
|
570
|
+
client_info: pending_client_information(pending, as_metadata: as_metadata),
|
|
571
|
+
code: code,
|
|
572
|
+
code_verifier: pending["code_verifier"],
|
|
573
|
+
resource: pending["resource"],
|
|
574
|
+
redirect_uri: pending["redirect_uri"],
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
save_tokens_issued_by(tokens, as_metadata: as_metadata)
|
|
578
|
+
:authorized
|
|
579
|
+
end
|
|
580
|
+
|
|
452
581
|
private
|
|
453
582
|
|
|
454
583
|
def read_token(key)
|
|
@@ -727,16 +856,16 @@ module MCP
|
|
|
727
856
|
# Hands the embedding application the authorization server and the scopes that are about to be requested,
|
|
728
857
|
# and abandons the flow when it refuses them.
|
|
729
858
|
#
|
|
730
|
-
#
|
|
731
|
-
#
|
|
859
|
+
# The MCP server names its authorization server in Protected Resource Metadata and states scopes
|
|
860
|
+
# in `scopes_supported` or the `WWW-Authenticate` challenge. Authorization-code policy also sees
|
|
861
|
+
# any prefilled endpoint scope that will survive URL assembly.
|
|
732
862
|
# Neither the specification nor any MCP SDK binds that choice to the server's own identity,
|
|
733
863
|
# and validating that a token was issued for the intended audience is a responsibility the specification
|
|
734
864
|
# places on MCP servers rather than on clients.
|
|
735
865
|
# A host that knows which providers its user deals with can apply that knowledge here.
|
|
736
866
|
#
|
|
737
|
-
#
|
|
738
|
-
#
|
|
739
|
-
# to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before.
|
|
867
|
+
# Challenged scopes are authoritative for the operation and bypass the PRM scope selector.
|
|
868
|
+
# The validator can accept or refuse them, not quietly request fewer scopes.
|
|
740
869
|
#
|
|
741
870
|
# Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered
|
|
742
871
|
# this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead:
|
|
@@ -1096,17 +1225,101 @@ module MCP
|
|
|
1096
1225
|
result.zero?
|
|
1097
1226
|
end
|
|
1098
1227
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1228
|
+
def provider_pending_authorization_max_age
|
|
1229
|
+
return Provider::DEFAULT_PENDING_AUTHORIZATION_MAX_AGE unless @provider.respond_to?(:pending_authorization_max_age)
|
|
1230
|
+
|
|
1231
|
+
@provider.pending_authorization_max_age
|
|
1232
|
+
end
|
|
1233
|
+
|
|
1234
|
+
# Records what `finish!` needs to redeem the code exactly as this authorization began: the PKCE verifier, the MCP server,
|
|
1235
|
+
# the `resource` and `redirect_uri` sent, the client identity used, and the authorization server metadata already validated.
|
|
1236
|
+
# The client secret is not copied: `finish!` reads the registration from storage and requires the same `client_id`.
|
|
1237
|
+
# Every value is JSON-compatible, so storage can serialize the entry as-is.
|
|
1238
|
+
def save_pending_authorization(state:, code_verifier:, server_url:, resource:, client_id:, as_metadata:)
|
|
1239
|
+
@provider.save_pending_authorization(
|
|
1240
|
+
state,
|
|
1241
|
+
{
|
|
1242
|
+
"code_verifier" => code_verifier,
|
|
1243
|
+
"server_url" => safe_canonicalize_url(server_url, label: "MCP server URL"),
|
|
1244
|
+
"resource" => resource,
|
|
1245
|
+
"redirect_uri" => @provider.redirect_uri,
|
|
1246
|
+
"client_id" => client_id,
|
|
1247
|
+
"authorization_server_metadata" => as_metadata,
|
|
1248
|
+
"created_at" => Time.now.to_i,
|
|
1249
|
+
},
|
|
1250
|
+
)
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
def valid_pending_authorization?(pending)
|
|
1254
|
+
return false unless pending.is_a?(Hash)
|
|
1255
|
+
return false unless ["code_verifier", "server_url", "redirect_uri", "client_id"].all? { |key| non_empty_string?(pending[key]) }
|
|
1256
|
+
return false unless pending["resource"].nil? || non_empty_string?(pending["resource"])
|
|
1257
|
+
|
|
1258
|
+
pending["authorization_server_metadata"].is_a?(Hash) && pending["created_at"].is_a?(Integer)
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
def pending_authorization_expired?(pending)
|
|
1262
|
+
Time.now.to_i - pending["created_at"] > provider_pending_authorization_max_age
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
# The registration the authorization began with. A stored registration is used only while it still carries
|
|
1266
|
+
# that `client_id` and is bound to the recorded authorization server; a Client ID Metadata Document URL,
|
|
1267
|
+
# which is never stored, is used again while the recorded metadata advertises support for it.
|
|
1268
|
+
def pending_client_information(pending, as_metadata:)
|
|
1269
|
+
client_id = pending["client_id"]
|
|
1270
|
+
|
|
1271
|
+
stored = @provider.client_information
|
|
1272
|
+
if stored.is_a?(Hash) &&
|
|
1273
|
+
client_info_required_value(stored, "client_id") == client_id &&
|
|
1274
|
+
client_info_required_value(stored, "issuer") == as_metadata["issuer"]
|
|
1275
|
+
return stored
|
|
1276
|
+
end
|
|
1277
|
+
|
|
1278
|
+
if client_id == provider_client_id_metadata_document_url && as_metadata["client_id_metadata_document_supported"] == true
|
|
1279
|
+
return { "client_id" => client_id }
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1282
|
+
raise AuthorizationError, "The client registration changed after the authorization began; start a new authorization."
|
|
1283
|
+
end
|
|
1284
|
+
|
|
1285
|
+
# Reads one parameter of an authorization response. Only a non-empty String counts: a repeated parameter that
|
|
1286
|
+
# a framework delivers as an Array, or an empty value, is treated as absent.
|
|
1287
|
+
def callback_param(params, key)
|
|
1288
|
+
return unless params.is_a?(Hash)
|
|
1289
|
+
|
|
1290
|
+
value = params[key] || params[key.to_sym]
|
|
1291
|
+
non_empty_string?(value) ? value : nil
|
|
1292
|
+
end
|
|
1293
|
+
|
|
1294
|
+
def non_empty_string?(value)
|
|
1295
|
+
value.is_a?(String) && !value.empty?
|
|
1296
|
+
end
|
|
1297
|
+
|
|
1298
|
+
# An RFC 6749 Section 4.1.2.1 error response, reported with the bounds a token endpoint error gets.
|
|
1299
|
+
# Reached only after the `iss` check, so any `iss` beside the values matched the recorded issuer; that does not
|
|
1300
|
+
# prove who sent them, and they are text the sender chose, so they are cut to a bounded length and confined to
|
|
1301
|
+
# the printable ASCII the RFC permits.
|
|
1302
|
+
def authorization_response_error(error, description)
|
|
1303
|
+
error = bounded_diagnostic(error, limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
|
|
1304
|
+
description = bounded_diagnostic(description, limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
|
|
1305
|
+
message = "The authorization server returned an error to the authorization callback."
|
|
1306
|
+
message += " #{[error, description].compact.join(": ")}" if error || description
|
|
1307
|
+
|
|
1308
|
+
AuthorizationError.new(message, error: error, error_description: description)
|
|
1309
|
+
end
|
|
1310
|
+
|
|
1311
|
+
# MCP scope selection prefers the challenge, then PRM `scopes_supported`, then the provider's fallback.
|
|
1312
|
+
# Authorization-code clients may narrow only the PRM default, before `offline_access` augmentation.
|
|
1313
|
+
def resolve_scope(scope:, prm:, select_prm_scope: false)
|
|
1105
1314
|
return scope if scope && !scope.empty?
|
|
1106
1315
|
|
|
1107
1316
|
# `prm` is nil on the legacy path, where nothing advertises scopes.
|
|
1108
1317
|
supported = prm && prm["scopes_supported"]
|
|
1109
|
-
|
|
1318
|
+
if supported.is_a?(Array) && !supported.empty?
|
|
1319
|
+
return select_prm_scopes(supported) if select_prm_scope
|
|
1320
|
+
|
|
1321
|
+
return supported.join(" ")
|
|
1322
|
+
end
|
|
1110
1323
|
|
|
1111
1324
|
return @provider.scope if @provider.scope && !@provider.scope.empty?
|
|
1112
1325
|
|
|
@@ -1128,7 +1341,8 @@ module MCP
|
|
|
1128
1341
|
# the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
|
|
1129
1342
|
# own request consistent with the AS's advertisement.
|
|
1130
1343
|
#
|
|
1131
|
-
# Returns `nil` when
|
|
1344
|
+
# Returns `nil` when empty so the URL builder adds no flow-owned scope parameter; a prefilled scope
|
|
1345
|
+
# can still survive and is checked by the authorization-request validator.
|
|
1132
1346
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
|
|
1133
1347
|
def normalize_offline_access_scope(scope, as_metadata:)
|
|
1134
1348
|
scopes = scope.to_s.split
|
|
@@ -1148,6 +1362,28 @@ module MCP
|
|
|
1148
1362
|
supported.is_a?(Array) && supported.include?("offline_access")
|
|
1149
1363
|
end
|
|
1150
1364
|
|
|
1365
|
+
# Selects a subset of the PRM default without changing challenges, provider fallback, or refresh policy.
|
|
1366
|
+
def select_prm_scopes(scopes)
|
|
1367
|
+
selector = @provider.scope_selector if @provider.respond_to?(:scope_selector)
|
|
1368
|
+
return scopes.join(" ") unless selector
|
|
1369
|
+
|
|
1370
|
+
unless scopes.all? { |token| token.is_a?(String) && SCOPE_TOKEN_FORMAT.match?(token) }
|
|
1371
|
+
raise AuthorizationError, "Protected Resource Metadata `scopes_supported` contains invalid OAuth scope tokens."
|
|
1372
|
+
end
|
|
1373
|
+
|
|
1374
|
+
candidates = scopes.map { |token| token.dup.freeze }.freeze
|
|
1375
|
+
candidate_index = candidates.to_h { |token| [token, true] }
|
|
1376
|
+
selected = selector.call(candidates)
|
|
1377
|
+
valid = selected.is_a?(Array) && selected.all? do |token|
|
|
1378
|
+
token.is_a?(String) && SCOPE_TOKEN_FORMAT.match?(token) && candidate_index.key?(token)
|
|
1379
|
+
end
|
|
1380
|
+
unless valid
|
|
1381
|
+
raise ArgumentError, "scope_selector must return an Array containing only scopes from PRM scopes_supported."
|
|
1382
|
+
end
|
|
1383
|
+
|
|
1384
|
+
selected.empty? ? nil : selected.join(" ")
|
|
1385
|
+
end
|
|
1386
|
+
|
|
1151
1387
|
def wants_refresh_token?
|
|
1152
1388
|
metadata = @provider.client_metadata
|
|
1153
1389
|
grant_types = metadata[:grant_types] || metadata["grant_types"]
|
|
@@ -1192,7 +1428,7 @@ module MCP
|
|
|
1192
1428
|
@provider.client_id_metadata_document_url
|
|
1193
1429
|
end
|
|
1194
1430
|
|
|
1195
|
-
def
|
|
1431
|
+
def authorization_endpoint_parameters(as_metadata:)
|
|
1196
1432
|
authorization_endpoint = as_metadata["authorization_endpoint"]
|
|
1197
1433
|
unless authorization_endpoint
|
|
1198
1434
|
raise AuthorizationError,
|
|
@@ -1206,6 +1442,23 @@ module MCP
|
|
|
1206
1442
|
"Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}."
|
|
1207
1443
|
end
|
|
1208
1444
|
|
|
1445
|
+
[uri, URI.decode_www_form(uri.query.to_s)]
|
|
1446
|
+
end
|
|
1447
|
+
|
|
1448
|
+
# A flow scope replaces every endpoint scope; otherwise a single prefilled value survives.
|
|
1449
|
+
# Decode once before policy approval and reuse those parameters when building the URL.
|
|
1450
|
+
def authorization_request_scope(scope:, endpoint_params:)
|
|
1451
|
+
return scope if scope
|
|
1452
|
+
|
|
1453
|
+
endpoint_scopes = endpoint_params.filter_map { |name, value| value if name == "scope" }
|
|
1454
|
+
if endpoint_scopes.length > 1
|
|
1455
|
+
raise AuthorizationError, "Authorization endpoint contains repeated `scope` parameters."
|
|
1456
|
+
end
|
|
1457
|
+
|
|
1458
|
+
endpoint_scopes.first
|
|
1459
|
+
end
|
|
1460
|
+
|
|
1461
|
+
def build_authorization_url(endpoint_uri:, endpoint_params:, client_id:, scope:, state:, code_challenge:, resource:)
|
|
1209
1462
|
# A parameter the flow sets replaces any of the same name the endpoint URL already carries.
|
|
1210
1463
|
# RFC 6749 Section 3.1 forbids sending a parameter twice, and which of two values a server would honor is
|
|
1211
1464
|
# its own choice; on the legacy path the endpoint URL is served by the MCP server, whose query must not speak
|
|
@@ -1227,17 +1480,17 @@ module MCP
|
|
|
1227
1480
|
own_params << ["resource", resource] if resource
|
|
1228
1481
|
dropped_names = own_params.map(&:first) + ["request", "request_uri"]
|
|
1229
1482
|
|
|
1230
|
-
params =
|
|
1231
|
-
|
|
1483
|
+
params = endpoint_params.reject { |name, _value| dropped_names.include?(name) }
|
|
1484
|
+
endpoint_uri.query = URI.encode_www_form(params + own_params)
|
|
1232
1485
|
|
|
1233
|
-
|
|
1486
|
+
endpoint_uri
|
|
1234
1487
|
end
|
|
1235
1488
|
|
|
1236
|
-
def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:)
|
|
1489
|
+
def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:, redirect_uri: @provider.redirect_uri)
|
|
1237
1490
|
form = {
|
|
1238
1491
|
"grant_type" => "authorization_code",
|
|
1239
1492
|
"code" => code,
|
|
1240
|
-
"redirect_uri" =>
|
|
1493
|
+
"redirect_uri" => redirect_uri,
|
|
1241
1494
|
"code_verifier" => code_verifier,
|
|
1242
1495
|
}
|
|
1243
1496
|
form["resource"] = resource if resource
|
|
@@ -16,6 +16,17 @@ module MCP
|
|
|
16
16
|
# binding the credentials to the authorization server that issued them (SEP-2352);
|
|
17
17
|
# custom storages should treat the hash as opaque and persist it as-is.
|
|
18
18
|
#
|
|
19
|
+
# A provider without a `callback_handler` also keeps each pending authorization here, keyed by its `state`,
|
|
20
|
+
# between the request that sends the user to the authorization server and the request that receives
|
|
21
|
+
# the redirect (`save_pending_authorization(state, pending)`, `pending_authorization(state)`,
|
|
22
|
+
# `delete_pending_authorization(state)`). A pending authorization holds the PKCE verifier, so custom storages
|
|
23
|
+
# should treat it as a secret and persist it as-is. `Flow#finish!` touches only the entry its callback's `state`
|
|
24
|
+
# names, so an authorization the user never finished stays until the storage drops it: this class drops entries
|
|
25
|
+
# older than `pending_authorization_max_age` the next time one is saved, and a custom storage should expire them,
|
|
26
|
+
# with a TTL of that age. `delete_pending_authorization` must remove the entry and return it in one atomic step
|
|
27
|
+
# (`Hash#delete` under a mutex here; `GETDEL` in Redis, `DELETE ... RETURNING` in SQL), returning `nil` when
|
|
28
|
+
# there was none: the flow redeems the code only when it gets the entry back.
|
|
29
|
+
#
|
|
19
30
|
# This class keeps everything in process memory, so the credentials live
|
|
20
31
|
# only for the lifetime of the Ruby process. Applications that need
|
|
21
32
|
# persistence across restarts should supply a custom object responding to
|
|
@@ -24,12 +35,25 @@ module MCP
|
|
|
24
35
|
# `Provider.new(storage: ...)`. The shape mirrors Python SDK's
|
|
25
36
|
# `TokenStorage` Protocol; TypeScript's `OAuthClientProvider` rolls
|
|
26
37
|
# the same responsibilities into a single object.
|
|
38
|
+
# A web application may receive the redirect in another process, so pending authorizations
|
|
39
|
+
# need shared storage.
|
|
27
40
|
class InMemoryStorage
|
|
28
41
|
attr_accessor :tokens, :client_information
|
|
29
42
|
|
|
30
|
-
|
|
43
|
+
# @param pending_authorization_max_age [Integer] seconds after which a pending authorization that was
|
|
44
|
+
# never finished is dropped from this storage, at its next save. `Provider.new` passes its own
|
|
45
|
+
# `pending_authorization_max_age` when it builds the default storage, so the two ages agree; a storage built
|
|
46
|
+
# by hand defaults to `Provider::DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`.
|
|
47
|
+
def initialize(pending_authorization_max_age: Provider::DEFAULT_PENDING_AUTHORIZATION_MAX_AGE)
|
|
48
|
+
unless pending_authorization_max_age.is_a?(Integer) && pending_authorization_max_age.positive?
|
|
49
|
+
raise ArgumentError, "pending_authorization_max_age must be a positive Integer number of seconds (got #{pending_authorization_max_age.inspect})."
|
|
50
|
+
end
|
|
51
|
+
|
|
31
52
|
@tokens = nil
|
|
32
53
|
@client_information = nil
|
|
54
|
+
@pending_authorizations = {}
|
|
55
|
+
@pending_authorization_max_age = pending_authorization_max_age
|
|
56
|
+
@pending_authorizations_mutex = Mutex.new
|
|
33
57
|
end
|
|
34
58
|
|
|
35
59
|
def save_tokens(tokens)
|
|
@@ -39,6 +63,55 @@ module MCP
|
|
|
39
63
|
def save_client_information(info)
|
|
40
64
|
@client_information = info
|
|
41
65
|
end
|
|
66
|
+
|
|
67
|
+
# Drops every pending authorization older than `pending_authorization_max_age` before saving the new one.
|
|
68
|
+
# The flow refuses such an entry when it is looked up, but only a callback carrying its `state` looks it up,
|
|
69
|
+
# and an authorization the user abandoned never gets one; every `401` a provider without a `callback_handler` meets
|
|
70
|
+
# starts another, so without this the entries, each holding the verifier and the authorization server metadata,
|
|
71
|
+
# would accumulate for the life of the process.
|
|
72
|
+
def save_pending_authorization(state, pending)
|
|
73
|
+
@pending_authorizations_mutex.synchronize do
|
|
74
|
+
drop_expired_pending_authorizations
|
|
75
|
+
@pending_authorizations[state] = pending
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def pending_authorization(state)
|
|
80
|
+
@pending_authorizations_mutex.synchronize { @pending_authorizations[state] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The mutex is what makes the removal and the return one step on every Ruby implementation: `Hash#delete`
|
|
84
|
+
# alone is atomic only where a global interpreter lock serializes it, and `Flow#finish!` relies on exactly
|
|
85
|
+
# one of two callbacks racing on the same `state` getting the entry back.
|
|
86
|
+
def delete_pending_authorization(state)
|
|
87
|
+
@pending_authorizations_mutex.synchronize { @pending_authorizations.delete(state) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Every hash this storage holds may carry a secret (an access token, a client secret, a PKCE verifier),
|
|
91
|
+
# and `inspect` is what error reporters and consoles print an object with, so it shows only whether each is present.
|
|
92
|
+
def inspect
|
|
93
|
+
pending_count = @pending_authorizations_mutex.synchronize { @pending_authorizations.size }
|
|
94
|
+
|
|
95
|
+
"#<#{self.class.name} tokens=#{present_or_nil(@tokens)} client_information=#{present_or_nil(@client_information)} pending_authorizations=#{pending_count}>"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# An entry's age is read from the Integer `created_at` the flow records when it saves the entry; an entry without one,
|
|
101
|
+
# which the flow never writes, is left alone.
|
|
102
|
+
def drop_expired_pending_authorizations
|
|
103
|
+
now = Time.now.to_i
|
|
104
|
+
|
|
105
|
+
@pending_authorizations.delete_if do |_state, pending|
|
|
106
|
+
created_at = pending.is_a?(Hash) ? pending["created_at"] : nil
|
|
107
|
+
|
|
108
|
+
created_at.is_a?(Integer) && now - created_at > @pending_authorization_max_age
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def present_or_nil(value)
|
|
113
|
+
value.nil? ? "nil" : "[present]"
|
|
114
|
+
end
|
|
42
115
|
end
|
|
43
116
|
end
|
|
44
117
|
end
|