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.
@@ -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: effective_scope, server_url: server_url, resource: resource)
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
- as_metadata: as_metadata,
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
- # 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.
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
- # 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.
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
- # Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution
1100
- # prefers the `WWW-Authenticate` challenge first, then `scopes_supported`
1101
- # from the Protected Resource Metadata, and falls back to a provider-supplied
1102
- # scope only if both are absent. The provider-supplied scope must not pre-empt
1103
- # a server-advertised one.
1104
- def resolve_scope(scope:, prm:)
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
- return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
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 the result is empty so `build_authorization_url` omits the `scope` parameter entirely.
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 build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
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 = URI.decode_www_form(uri.query.to_s).reject { |name, _value| dropped_names.include?(name) }
1231
- uri.query = URI.encode_www_form(params + own_params)
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
- uri
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" => @provider.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
- def initialize
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