mcp 1.6.0 → 1.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3cb0f742e469efeb15202b40310bfb2eab52b94bb208bab1f33cfd2204bcf8c9
4
- data.tar.gz: 07df380a1dd652b6fd5deb0ddbafe3c3379bc6fa77c8a391f169f6a7fed11742
3
+ metadata.gz: 87f6233cbd083a14b47183b21558ee995c06953ef5bb8d9ea568eca8e69103de
4
+ data.tar.gz: 11697a1b6e71297211fac5ecf411ac381a064e66274ac9c9a9640087284859ad
5
5
  SHA512:
6
- metadata.gz: c756d7552fbf3accc8eedc32876e715c1d34939ed8c44d9d540a97b46ccf4cc2d3a163f5ba5989277be6e8933bce374ccab80f27ddf62ed52f6ea27c0367a3c5
7
- data.tar.gz: '0873ea9933d9b30e1c0e2bcbd6f56eb0c658f16736ae309007cd752b097cb9f75ebcead7277c89ea9f255f92f3f62d2d4f1ac5d4ae87cf36359df4e42e030d38'
6
+ metadata.gz: ec9899cc307eb88706aded076b0e7be9bb2bdbf2fbf52c3d8f8f7a7ebe05dbb1da802ebfc8ea9bc1d5ecc18c361a2249488815c60175650203815a084aad9833
7
+ data.tar.gz: c43d95b541108f64ac594567e44f3de9e103f4f52c2c122ef0f0d90dcaa52b391b419c536cf4653c784299324a71c23c9bb7da9a7c4f9f69db87c2ab1d730871
data/README.md CHANGED
@@ -125,7 +125,7 @@ see [Client Transports](https://ruby.sdk.modelcontextprotocol.io/client/transpor
125
125
 
126
126
  ## Examples
127
127
 
128
- Runnable examples are available in [`examples/`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples),
128
+ Runnable examples are available in [`examples`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples),
129
129
  including a complete Rails application in [`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails).
130
130
 
131
131
  ## Documentation
@@ -902,15 +902,25 @@ module MCP
902
902
  MCP::Client::OAuth::Discovery.parse_www_authenticate(header)
903
903
  end
904
904
 
905
+ # A provider without a `callback_handler` finishes the authorization in the request that receives the redirect,
906
+ # not here, so there is nothing to retry with yet: the pending authorization surfaces as
907
+ # `Flow::AuthorizationPendingError`, and requests made after `Flow#finish!` pick up the stored tokens.
905
908
  def run_full_authorization_flow!(flow:, params:)
906
909
  # Use the URL snapshotted at `initialize` time so a post-construction
907
910
  # mutation of `@url` cannot redirect PRM/AS discovery and the authorize
908
911
  # URL to an attacker-controlled host.
909
- flow.run!(
912
+ result = flow.run!(
910
913
  server_url: @oauth_server_url,
911
914
  resource_metadata_url: params["resource_metadata"],
912
915
  scope: params["scope"],
913
916
  )
917
+ return unless result == :redirect
918
+
919
+ raise MCP::Client::OAuth::Flow::AuthorizationPendingError.new(
920
+ "Authorization is pending: the user was sent to the authorization server, and the request can be retried " \
921
+ "once `MCP::Client::OAuth::Flow#finish!` completes the authorization with the redirect's query.",
922
+ authorization_url: flow.authorization_url,
923
+ )
914
924
  end
915
925
 
916
926
  # Tries to swap a saved `refresh_token` for a fresh access token. Returns truthy
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ipaddr"
4
+ require "strscan"
4
5
  require "uri"
5
6
 
6
7
  module MCP
@@ -43,6 +44,12 @@ module MCP
43
44
  # or a bare token, per RFC 7235.
44
45
  WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/.freeze
45
46
 
47
+ # The whitespace and optional comma between two `key=value` pairs, or before the first one.
48
+ WWW_AUTH_PARAM_SEPARATOR_PATTERN = /\s*,?\s*/.freeze
49
+
50
+ # The `Bearer` challenge: at the start of the header or after a comma.
51
+ WWW_AUTH_BEARER_PATTERN = /(?:\A|,)\s*Bearer(?:\s+|\z)/i.freeze
52
+
46
53
  class << self
47
54
  # Parses a `WWW-Authenticate` header and returns the parameters of
48
55
  # the `Bearer` challenge as a hash with lower-cased keys (e.g. `resource_metadata`,
@@ -55,25 +62,21 @@ module MCP
55
62
  def parse_www_authenticate(header)
56
63
  return {} unless header
57
64
 
58
- # Locate the Bearer challenge: at the start of the header or after a comma.
59
- bearer = header.match(/(?:\A|,)\s*Bearer(?:\s+|\z)/i)
60
- return {} unless bearer
61
-
62
65
  # Walk key=value pairs starting where Bearer's parameters begin.
63
- # The loop stops at the first token that is not a key=value pair,
64
- # which marks the next challenge (e.g. `, DPoP algs="..."`).
65
- cursor = bearer.end(0)
66
- params = {}
67
- while cursor < header.length
68
- prefix = header[cursor..]
69
- prefix = prefix.sub(/\A\s*,?\s*/, "")
70
- break if prefix.empty?
66
+ # The loop stops at the first token that is not a key=value pair, which marks the next challenge (e.g. `, DPoP algs="..."`).
67
+ # The scanner keeps the walk linear in the header's length: slicing off the consumed prefix instead copies the remainder
68
+ # for every pair, and the server chooses how many pairs it sends. The header is also the server's to fill: a byte sequence
69
+ # that is not valid in the string's encoding would make the patterns raise `ArgumentError`, so such bytes are replaced first.
70
+ scanner = StringScanner.new(header.scrub)
71
+ return {} unless scanner.skip_until(WWW_AUTH_BEARER_PATTERN)
71
72
 
72
- match = prefix.match(WWW_AUTH_PARAM_PATTERN)
73
- break unless match
73
+ params = {}
74
+ until scanner.eos?
75
+ scanner.skip(WWW_AUTH_PARAM_SEPARATOR_PATTERN)
76
+ break if scanner.eos?
77
+ break unless scanner.scan(WWW_AUTH_PARAM_PATTERN)
74
78
 
75
- params[match[1].downcase] = match[2] ? unescape_quoted_pair(match[2]) : match[3]
76
- cursor = header.length - prefix.length + match.end(0)
79
+ params[scanner[1].downcase] = scanner[2] ? unescape_quoted_pair(scanner[2]) : scanner[3]
77
80
  end
78
81
  params
79
82
  end
@@ -576,47 +579,46 @@ module MCP
576
579
  end.join("&")
577
580
  end
578
581
 
579
- # Implements RFC 3986 Section 5.2.4 `remove_dot_segments`. Walks the input
580
- # buffer one segment at a time, popping the previous output segment
581
- # whenever a `..` is encountered, so that `/api/../mcp` collapses to
582
- # `/mcp` and `/foo/./bar` collapses to `/foo/bar`.
582
+ # Implements RFC 3986 Section 5.2.4 `remove_dot_segments` over the path's segments, so that `/api/../mcp` collapses to
583
+ # `/mcp` and `/foo/./bar` collapses to `/foo/bar`. Each segment is visited once: the RFC's buffer rewriting,
584
+ # applied literally, copies the remaining input for every dot segment, and the path is the server's to choose.
585
+ #
586
+ # The output matches the RFC's algorithm for a relative path as well, including its quirk that a `..` popping
587
+ # the first segment leaves the result absolute (`a/../b` becomes `/b`), although `URI#path` never hands over a relative path.
588
+ # The rule letters below are the RFC's own: steps A through E of its loop.
583
589
  # https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4
584
590
  def remove_dot_segments(path)
585
591
  return path if path.nil? || path.empty?
586
592
 
587
- input = path.dup
588
- output = +""
589
- until input.empty?
590
- if input.start_with?("../")
591
- input = input[3..]
592
- elsif input.start_with?("./")
593
- input = input[2..]
594
- elsif input.start_with?("/./")
595
- input = "/#{input[3..]}"
596
- elsif input == "/."
597
- input = "/"
598
- elsif input.start_with?("/../")
599
- input = "/#{input[4..]}"
600
- output = remove_last_segment(output)
601
- elsif input == "/.."
602
- input = "/"
603
- output = remove_last_segment(output)
604
- elsif input == "." || input == ".."
605
- input = ""
593
+ absolute = path.start_with?("/")
594
+ segments = path.split("/", -1)
595
+ segments.shift if absolute
596
+
597
+ output = []
598
+
599
+ # True until a segment other than `.` or `..` is kept: a relative path's leading `./` and `../` are dropped together with
600
+ # the slash after them (Rule A), so the segment that follows is still the slash-less first one.
601
+ leading = !absolute
602
+ segments.each_with_index do |segment, index|
603
+ last = index == segments.length - 1
604
+
605
+ # Rule A, and Rule D for a path that is nothing but `.` or `..`.
606
+ next if leading && (segment == "." || segment == "..")
607
+
608
+ if segment == "."
609
+ # Rule B: `/./` disappears; a final `/.` leaves its slash behind.
610
+ output << "/" if last
611
+ elsif segment == ".."
612
+ # Rule C: `/../` removes the previous segment; a final `/..` leaves its slash behind.
613
+ output.pop
614
+ output << "/" if last
606
615
  else
607
- segment = input.match(%r{\A/?[^/]*})[0]
608
- output << segment
609
- input = input[segment.length..]
616
+ # Rule E: the first segment of a relative path carries no slash.
617
+ output << (leading ? segment : "/#{segment}")
610
618
  end
619
+ leading = false
611
620
  end
612
- output
613
- end
614
-
615
- def remove_last_segment(output)
616
- idx = output.rindex("/")
617
- return +"" if idx.nil?
618
-
619
- output[0...idx]
621
+ output.join
620
622
  end
621
623
  end
622
624
  end
@@ -83,6 +83,27 @@ module MCP
83
83
  # and `MCP::Client::HTTP` treats on a failed refresh as a reason to run the interactive flow.
84
84
  class DestinationMismatchError < ArgumentError; end
85
85
 
86
+ # Raised by `MCP::Client::HTTP` when a provider without a `callback_handler` has sent the user to the authorization server:
87
+ # the request cannot be retried until the application finishes the authorization with `finish!` in the request that
88
+ # receives the redirect. `authorization_url` is the URL handed to `redirect_handler`; it is kept out of the message,
89
+ # which may reach logs, because it carries the `state` that `finish!` looks the pending authorization up by.
90
+ # Deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on a failed refresh as a reason to run
91
+ # the interactive flow: this is the interactive flow waiting on the user, not a failure.
92
+ class AuthorizationPendingError < StandardError
93
+ attr_reader :authorization_url
94
+
95
+ def initialize(message = nil, authorization_url: nil)
96
+ super(message)
97
+ @authorization_url = authorization_url
98
+ end
99
+ end
100
+
101
+ # A `state` the SDK generates is 43 characters; a callback carrying a much longer one is refused before storage is asked.
102
+ CALLBACK_STATE_MAX_LENGTH = 128
103
+
104
+ UNKNOWN_PENDING_AUTHORIZATION_MESSAGE =
105
+ "Authorization callback `state` matches no pending authorization; it is unknown, already used, or expired."
106
+
86
107
  # Faraday middleware registered on the connection `build_http_client` assembles before the customizer
87
108
  # is invoked, so with the usual `use` it sits ahead of the customizer's middleware and sees the URL exactly
88
109
  # as the flow requested it, which it records on the request environment for `RequestedOriginGuard`.
@@ -189,13 +210,20 @@ module MCP
189
210
  end
190
211
  end
191
212
 
213
+ # The authorization URL the last `run!` handed to `redirect_handler` before returning `:redirect`, or `nil`.
214
+ attr_reader :authorization_url
215
+
192
216
  def initialize(provider:, http_client_factory: nil)
193
217
  @provider = provider
194
218
  @http_client_factory = http_client_factory || -> { default_http_client }
219
+ @authorization_url = nil
195
220
  end
196
221
 
197
222
  # Runs the full discovery, registration, authorization, and token exchange flow.
198
223
  # On success, persists tokens via the provider and returns `:authorized`.
224
+ # A provider without a `callback_handler` stops after the redirect instead: the flow saves
225
+ # a pending authorization in the provider's storage, keyed by `state`, and returns `:redirect`, leaving the code
226
+ # exchange to `finish!` in the request that receives the redirect.
199
227
  def run!(server_url:, resource_metadata_url: nil, scope: nil)
200
228
  # The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
201
229
  # first thing we contact in the OAuth flow, so it has to clear the same
@@ -257,6 +285,20 @@ module MCP
257
285
  resource: resource,
258
286
  )
259
287
 
288
+ if @provider.callback_handler.nil?
289
+ save_pending_authorization(
290
+ state: state,
291
+ code_verifier: pkce[:code_verifier],
292
+ server_url: server_url,
293
+ resource: resource,
294
+ client_id: client_info_required_value(client_info, "client_id"),
295
+ as_metadata: as_metadata,
296
+ )
297
+ @authorization_url = authorization_url
298
+ @provider.redirect_handler.call(authorization_url)
299
+ return :redirect
300
+ end
301
+
260
302
  @provider.redirect_handler.call(authorization_url)
261
303
  callback_result = Array(@provider.callback_handler.call)
262
304
  code, returned_state, returned_iss = callback_result
@@ -449,6 +491,87 @@ module MCP
449
491
  :refreshed
450
492
  end
451
493
 
494
+ # Finishes an authorization that `run!` left pending, in the request that receives the redirect to `redirect_uri`,
495
+ # which may run in another process. `callback_params` is that redirect's whole query as a Hash (`code`, `state`, and,
496
+ # when present, `iss`, `error`, and `error_description`); passing all of it, rather than picking values out, is
497
+ # the caller's part of the `iss` check: an `iss` the caller drops reads as absent, and the flow cannot tell the difference.
498
+ #
499
+ # The pending authorization is looked up by `state` before any request is made, and it binds the rest of the exchange:
500
+ # the code is redeemed at the token endpoint recorded when the authorization began, with the client registration,
501
+ # `resource`, and `redirect_uri` used then, and without discovery running again, so the code reaches the authorization
502
+ # server the user was sent to (SEP-2352). The RFC 9207 `iss` is validated against the recorded issuer before the pending
503
+ # authorization is consumed, so a callback from another authorization server, as in a mix-up, is refused without
504
+ # consuming the entry the legitimate callback needs, and before the callback's `error` is read, since in a mix-up
505
+ # those parameters are the other server's. The check establishes only that a present `iss` matches the recorded issuer,
506
+ # not who sent the callback, and a callback without `iss` passes it unless the metadata advertises
507
+ # `authorization_response_iss_parameter_supported`: whoever holds the `state`, passes that check, and reaches
508
+ # the consume first takes the entry, and the legitimate callback then finds nothing.
509
+ #
510
+ # Past that check the pending authorization is consumed with `delete_pending_authorization`, which returns the entry
511
+ # it removed; only a callback that gets the entry back redeems the code, so of callbacks racing on the same `state`
512
+ # (a retried redirect, say), at most one does.
513
+ #
514
+ # Binding the callback to the user who started the authorization stays with the application: scoping `storage`
515
+ # to that user means a callback delivered to another user's session finds no pending authorization.
516
+ #
517
+ # On success, persists tokens via the provider and returns `:authorized`.
518
+ def finish!(server_url:, callback_params:)
519
+ unless provider_authorization_flow == :authorization_code && @provider.callback_handler.nil?
520
+ raise ArgumentError,
521
+ "finish! completes an authorization started by a provider without a callback_handler; " \
522
+ "this provider finishes its authorizations in `run!`."
523
+ end
524
+
525
+ state = callback_param(callback_params, "state")
526
+ raise AuthorizationError, "Authorization callback carried no `state`." unless state
527
+
528
+ pending = @provider.pending_authorization(state) if state.bytesize <= CALLBACK_STATE_MAX_LENGTH
529
+ raise AuthorizationError, UNKNOWN_PENDING_AUTHORIZATION_MESSAGE unless valid_pending_authorization?(pending)
530
+
531
+ if pending_authorization_expired?(pending)
532
+ @provider.delete_pending_authorization(state)
533
+ raise AuthorizationError, "The pending authorization has expired; start a new authorization."
534
+ end
535
+
536
+ unless safe_canonicalize_url(server_url, label: "MCP server URL") == pending["server_url"]
537
+ raise AuthorizationError, "The pending authorization was started for a different MCP server."
538
+ end
539
+
540
+ as_metadata = pending["authorization_server_metadata"]
541
+ # Checked when the authorization began, and checked again because the metadata has since made a round trip
542
+ # through the application's storage.
543
+ ensure_secure_endpoints!(as_metadata, server_url: pending["server_url"])
544
+
545
+ validate_authorization_response_issuer!(
546
+ as_metadata: as_metadata,
547
+ iss: callback_param(callback_params, "iss"),
548
+ iss_provided: true,
549
+ )
550
+
551
+ # The read above and this delete are separate calls, so the storage's atomic delete is what decides
552
+ # which of two concurrent callbacks carrying this `state` proceeds.
553
+ consumed = @provider.delete_pending_authorization(state)
554
+ raise AuthorizationError, UNKNOWN_PENDING_AUTHORIZATION_MESSAGE unless valid_pending_authorization?(consumed)
555
+
556
+ error = callback_param(callback_params, "error")
557
+ raise authorization_response_error(error, callback_param(callback_params, "error_description")) if error
558
+
559
+ code = callback_param(callback_params, "code")
560
+ raise AuthorizationError, "Authorization callback carried no authorization code." unless code
561
+
562
+ tokens = exchange_authorization_code(
563
+ as_metadata: as_metadata,
564
+ client_info: pending_client_information(pending, as_metadata: as_metadata),
565
+ code: code,
566
+ code_verifier: pending["code_verifier"],
567
+ resource: pending["resource"],
568
+ redirect_uri: pending["redirect_uri"],
569
+ )
570
+
571
+ save_tokens_issued_by(tokens, as_metadata: as_metadata)
572
+ :authorized
573
+ end
574
+
452
575
  private
453
576
 
454
577
  def read_token(key)
@@ -1096,6 +1219,89 @@ module MCP
1096
1219
  result.zero?
1097
1220
  end
1098
1221
 
1222
+ def provider_pending_authorization_max_age
1223
+ return Provider::DEFAULT_PENDING_AUTHORIZATION_MAX_AGE unless @provider.respond_to?(:pending_authorization_max_age)
1224
+
1225
+ @provider.pending_authorization_max_age
1226
+ end
1227
+
1228
+ # Records what `finish!` needs to redeem the code exactly as this authorization began: the PKCE verifier, the MCP server,
1229
+ # the `resource` and `redirect_uri` sent, the client identity used, and the authorization server metadata already validated.
1230
+ # The client secret is not copied: `finish!` reads the registration from storage and requires the same `client_id`.
1231
+ # Every value is JSON-compatible, so storage can serialize the entry as-is.
1232
+ def save_pending_authorization(state:, code_verifier:, server_url:, resource:, client_id:, as_metadata:)
1233
+ @provider.save_pending_authorization(
1234
+ state,
1235
+ {
1236
+ "code_verifier" => code_verifier,
1237
+ "server_url" => safe_canonicalize_url(server_url, label: "MCP server URL"),
1238
+ "resource" => resource,
1239
+ "redirect_uri" => @provider.redirect_uri,
1240
+ "client_id" => client_id,
1241
+ "authorization_server_metadata" => as_metadata,
1242
+ "created_at" => Time.now.to_i,
1243
+ },
1244
+ )
1245
+ end
1246
+
1247
+ def valid_pending_authorization?(pending)
1248
+ return false unless pending.is_a?(Hash)
1249
+ return false unless ["code_verifier", "server_url", "redirect_uri", "client_id"].all? { |key| non_empty_string?(pending[key]) }
1250
+ return false unless pending["resource"].nil? || non_empty_string?(pending["resource"])
1251
+
1252
+ pending["authorization_server_metadata"].is_a?(Hash) && pending["created_at"].is_a?(Integer)
1253
+ end
1254
+
1255
+ def pending_authorization_expired?(pending)
1256
+ Time.now.to_i - pending["created_at"] > provider_pending_authorization_max_age
1257
+ end
1258
+
1259
+ # The registration the authorization began with. A stored registration is used only while it still carries
1260
+ # that `client_id` and is bound to the recorded authorization server; a Client ID Metadata Document URL,
1261
+ # which is never stored, is used again while the recorded metadata advertises support for it.
1262
+ def pending_client_information(pending, as_metadata:)
1263
+ client_id = pending["client_id"]
1264
+
1265
+ stored = @provider.client_information
1266
+ if stored.is_a?(Hash) &&
1267
+ client_info_required_value(stored, "client_id") == client_id &&
1268
+ client_info_required_value(stored, "issuer") == as_metadata["issuer"]
1269
+ return stored
1270
+ end
1271
+
1272
+ if client_id == provider_client_id_metadata_document_url && as_metadata["client_id_metadata_document_supported"] == true
1273
+ return { "client_id" => client_id }
1274
+ end
1275
+
1276
+ raise AuthorizationError, "The client registration changed after the authorization began; start a new authorization."
1277
+ end
1278
+
1279
+ # Reads one parameter of an authorization response. Only a non-empty String counts: a repeated parameter that
1280
+ # a framework delivers as an Array, or an empty value, is treated as absent.
1281
+ def callback_param(params, key)
1282
+ return unless params.is_a?(Hash)
1283
+
1284
+ value = params[key] || params[key.to_sym]
1285
+ non_empty_string?(value) ? value : nil
1286
+ end
1287
+
1288
+ def non_empty_string?(value)
1289
+ value.is_a?(String) && !value.empty?
1290
+ end
1291
+
1292
+ # An RFC 6749 Section 4.1.2.1 error response, reported with the bounds a token endpoint error gets.
1293
+ # Reached only after the `iss` check, so any `iss` beside the values matched the recorded issuer; that does not
1294
+ # prove who sent them, and they are text the sender chose, so they are cut to a bounded length and confined to
1295
+ # the printable ASCII the RFC permits.
1296
+ def authorization_response_error(error, description)
1297
+ error = bounded_diagnostic(error, limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
1298
+ description = bounded_diagnostic(description, limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
1299
+ message = "The authorization server returned an error to the authorization callback."
1300
+ message += " #{[error, description].compact.join(": ")}" if error || description
1301
+
1302
+ AuthorizationError.new(message, error: error, error_description: description)
1303
+ end
1304
+
1099
1305
  # Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution
1100
1306
  # prefers the `WWW-Authenticate` challenge first, then `scopes_supported`
1101
1307
  # from the Protected Resource Metadata, and falls back to a provider-supplied
@@ -1206,24 +1412,38 @@ module MCP
1206
1412
  "Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}."
1207
1413
  end
1208
1414
 
1209
- params = URI.decode_www_form(uri.query.to_s)
1210
- params << ["response_type", "code"]
1211
- params << ["client_id", client_id]
1212
- params << ["redirect_uri", @provider.redirect_uri]
1213
- params << ["code_challenge", code_challenge]
1214
- params << ["code_challenge_method", "S256"]
1215
- params << ["state", state]
1216
- params << ["scope", scope] if scope
1217
- params << ["resource", resource] if resource
1218
- uri.query = URI.encode_www_form(params)
1415
+ # A parameter the flow sets replaces any of the same name the endpoint URL already carries.
1416
+ # RFC 6749 Section 3.1 forbids sending a parameter twice, and which of two values a server would honor is
1417
+ # its own choice; on the legacy path the endpoint URL is served by the MCP server, whose query must not speak
1418
+ # for the client's `client_id`, `redirect_uri`, `code_challenge`, or `resource`.
1419
+ # Other parameters in the URL are kept, as the TypeScript SDK's `searchParams.set` keeps them; that includes
1420
+ # a `scope` when the flow has none, since an authorization server may set a default scope there.
1421
+ # RFC 9101 `request` and `request_uri` are dropped as well, though the flow sets neither: a server takes
1422
+ # the whole authorization request from the object they carry, over every parameter in the query, and both are
1423
+ # the client's to send, never an endpoint URL's to supply.
1424
+ own_params = [
1425
+ ["response_type", "code"],
1426
+ ["client_id", client_id],
1427
+ ["redirect_uri", @provider.redirect_uri],
1428
+ ["code_challenge", code_challenge],
1429
+ ["code_challenge_method", "S256"],
1430
+ ["state", state],
1431
+ ]
1432
+ own_params << ["scope", scope] if scope
1433
+ own_params << ["resource", resource] if resource
1434
+ dropped_names = own_params.map(&:first) + ["request", "request_uri"]
1435
+
1436
+ params = URI.decode_www_form(uri.query.to_s).reject { |name, _value| dropped_names.include?(name) }
1437
+ uri.query = URI.encode_www_form(params + own_params)
1438
+
1219
1439
  uri
1220
1440
  end
1221
1441
 
1222
- def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:)
1442
+ def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:, redirect_uri: @provider.redirect_uri)
1223
1443
  form = {
1224
1444
  "grant_type" => "authorization_code",
1225
1445
  "code" => code,
1226
- "redirect_uri" => @provider.redirect_uri,
1446
+ "redirect_uri" => redirect_uri,
1227
1447
  "code_verifier" => code_verifier,
1228
1448
  }
1229
1449
  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