ruby-mcp-client 2.0.0 → 3.0.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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +914 -49
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1367 -311
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -36
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +851 -478
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/elicitation_validator.rb +56 -13
  35. data/lib/mcp_client/errors.rb +481 -6
  36. data/lib/mcp_client/header_params.rb +320 -0
  37. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  38. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  39. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  40. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  41. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  42. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  43. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  44. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  45. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  46. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  47. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  48. data/lib/mcp_client/http_transport_base.rb +669 -100
  49. data/lib/mcp_client/input_round_trips.rb +128 -0
  50. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  51. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  52. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  53. data/lib/mcp_client/json_rpc_common.rb +967 -11
  54. data/lib/mcp_client/oauth_client.rb +14 -5
  55. data/lib/mcp_client/prompt.rb +4 -0
  56. data/lib/mcp_client/request_authorization.rb +128 -0
  57. data/lib/mcp_client/request_meta_scope.rb +77 -0
  58. data/lib/mcp_client/request_metadata.rb +287 -0
  59. data/lib/mcp_client/resource.rb +4 -2
  60. data/lib/mcp_client/resource_content.rb +20 -0
  61. data/lib/mcp_client/resource_link.rb +0 -2
  62. data/lib/mcp_client/resource_template.rb +4 -2
  63. data/lib/mcp_client/result_caching.rb +999 -0
  64. data/lib/mcp_client/result_completeness.rb +34 -0
  65. data/lib/mcp_client/root.rb +6 -0
  66. data/lib/mcp_client/round_trip_marker.rb +28 -0
  67. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  68. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  69. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  70. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  71. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  72. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  73. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  74. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  75. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  76. data/lib/mcp_client/schema_validator/references.rb +610 -0
  77. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  78. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  79. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  80. data/lib/mcp_client/schema_validator.rb +893 -175
  81. data/lib/mcp_client/server_base.rb +233 -5
  82. data/lib/mcp_client/server_factory.rb +12 -3
  83. data/lib/mcp_client/server_http/json_rpc_transport.rb +220 -5
  84. data/lib/mcp_client/server_http.rb +307 -90
  85. data/lib/mcp_client/server_sse/json_rpc_transport.rb +146 -30
  86. data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
  87. data/lib/mcp_client/server_sse/reconnect_monitor.rb +7 -1
  88. data/lib/mcp_client/server_sse/sse_parser.rb +77 -13
  89. data/lib/mcp_client/server_sse.rb +310 -76
  90. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  91. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1004 -29
  92. data/lib/mcp_client/server_stdio.rb +778 -186
  93. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +262 -28
  94. data/lib/mcp_client/server_streamable_http.rb +547 -157
  95. data/lib/mcp_client/session_pin.rb +119 -0
  96. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  97. data/lib/mcp_client/subscription.rb +852 -0
  98. data/lib/mcp_client/subscription_support.rb +715 -0
  99. data/lib/mcp_client/task.rb +286 -14
  100. data/lib/mcp_client/tool.rb +31 -5
  101. data/lib/mcp_client/version.rb +21 -6
  102. data/lib/mcp_client.rb +115 -19
  103. metadata +71 -7
@@ -3,7 +3,18 @@
3
3
  require 'faraday'
4
4
  require 'json'
5
5
  require 'uri'
6
+ require 'ipaddr'
7
+ require 'monitor'
6
8
  require_relative '../auth'
9
+ require_relative 'peer_text'
10
+ require_relative 'oauth_provider/challenge_handling'
11
+ require_relative 'oauth_provider/client_authentication'
12
+ require_relative 'oauth_provider/pending_requests'
13
+ require_relative 'oauth_provider/registration_store'
14
+ require_relative 'oauth_provider/response_validation'
15
+ require_relative 'oauth_provider/scope_selection'
16
+ require_relative 'oauth_provider/token_store'
17
+ require_relative '../deprecations'
7
18
 
8
19
  module MCPClient
9
20
  module Auth
@@ -11,6 +22,21 @@ module MCPClient
11
22
  # Handles the complete OAuth flow including server discovery, client registration,
12
23
  # authorization, token exchange, and refresh
13
24
  class OAuthProvider
25
+ # One lock per storage backend and resource for this resource's
26
+ # authorization state — the pending-flow records and the token slot they
27
+ # end in (see {#with_authorization_state_lock}); storage backends are
28
+ # weak keys, so a storage the host drops takes its locks with it.
29
+ #
30
+ # WeakKeyMap, not WeakMap: WeakMap holds its VALUES weakly too, and the
31
+ # value here is the table of per-resource monitors, which nothing else
32
+ # references. A collection between two acquisitions dropped that table,
33
+ # the next caller built a fresh monitor, and two providers sharing one
34
+ # storage were inside the critical section at the same time — which is
35
+ # exactly what the section exists to prevent.
36
+ AUTHORIZATION_STATE_LOCKS = ObjectSpace::WeakKeyMap.new
37
+ AUTHORIZATION_STATE_LOCKS_GUARD = Mutex.new
38
+ private_constant :AUTHORIZATION_STATE_LOCKS, :AUTHORIZATION_STATE_LOCKS_GUARD
39
+
14
40
  # One auth-param (name = token / quoted-string) as it appears in a
15
41
  # WWW-Authenticate challenge (RFC 7235 §2.1, optional whitespace around
16
42
  # '='). Mirrors HttpTransportBase::AUTH_PARAM so provider-side challenge
@@ -23,6 +49,15 @@ module MCPClient
23
49
  # boundaries. Mirrors HttpTransportBase::AUTH_PARAMS_RUN.
24
50
  AUTH_PARAMS_RUN = /\A(?:[\s,]*#{AUTH_PARAM})*/
25
51
 
52
+ include ChallengeHandling
53
+ include ClientAuthentication
54
+ include PeerText
55
+ include PendingRequests
56
+ include RegistrationStore
57
+ include ResponseValidation
58
+ include ScopeSelection
59
+ include TokenStore
60
+
26
61
  # @!attribute [rw] redirect_uri
27
62
  # @return [String] OAuth redirect URI
28
63
  # @!attribute [rw] scope
@@ -35,8 +70,8 @@ module MCPClient
35
70
  # @return [String] The MCP server URL (normalized)
36
71
  # @!attribute [r] client_id_metadata_url
37
72
  # @return [String, nil] HTTPS URL of this client's Client ID Metadata Document (SEP-991)
38
- attr_accessor :redirect_uri, :scope, :logger, :storage
39
- attr_reader :server_url, :client_id_metadata_url
73
+ attr_accessor :scope, :logger, :storage
74
+ attr_reader :server_url, :client_id_metadata_url, :redirect_uri
40
75
 
41
76
  # Initialize OAuth provider
42
77
  # @param server_url [String] The MCP server URL (used as OAuth resource parameter)
@@ -52,15 +87,37 @@ module MCPClient
52
87
  # skipping dynamic registration. Hosting the metadata JSON at that URL is the
53
88
  # application's responsibility.
54
89
  # @raise [ArgumentError] if client_id_metadata_url is not an HTTPS URL with a path component
90
+ # OIDC application types accepted for Dynamic Client Registration.
91
+ APPLICATION_TYPES = %w[native web].freeze
92
+
93
+ # The authorization request parameters this client puts in the
94
+ # authorization URL. RFC 6749 Section 3.1: "Request and response
95
+ # parameters MUST NOT be included more than once", so an authorization
96
+ # endpoint whose own query already names one of these loses it to the
97
+ # value of this request (see {#merged_authorization_query}).
98
+ AUTHORIZATION_REQUEST_PARAMS = %w[
99
+ response_type client_id redirect_uri scope state code_challenge code_challenge_method resource
100
+ ].freeze
101
+
102
+ # Literal names of the loopback interface (see #loopback_address?).
103
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
104
+
105
+ # @return [String, nil] the explicit application_type for Dynamic Client Registration
106
+ attr_reader :application_type
107
+
55
108
  def initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil,
56
- client_metadata: {}, client_id_metadata_url: nil)
109
+ client_metadata: {}, client_id_metadata_url: nil, application_type: nil)
57
110
  self.server_url = server_url
58
111
  self.redirect_uri = redirect_uri
59
112
  self.scope = scope
60
113
  self.logger = logger || Logger.new($stdout, level: Logger::WARN)
61
114
  self.storage = storage || MemoryStorage.new
62
115
  self.client_id_metadata_url = client_id_metadata_url
63
- @extra_client_metadata = client_metadata
116
+ # An application_type given through client_metadata is the host's
117
+ # explicit choice too; it never silently overrides the derived type.
118
+ extra = (client_metadata || {}).transform_keys(&:to_sym)
119
+ self.application_type = application_type || extra[:application_type]
120
+ @extra_client_metadata = extra.except(:application_type)
64
121
  @http_client = create_http_client
65
122
  # Protected resource metadata learned from a 401 WWW-Authenticate
66
123
  # challenge, reused by discovery so a challenge-advertised metadata URL
@@ -68,11 +125,53 @@ module MCPClient
68
125
  # separately so a failed fetch is retried authoritatively by discovery.
69
126
  @challenge_resource_metadata = nil
70
127
  @challenge_metadata_url = nil
128
+ # Why a peer-advertised challenge URL was refused, if one was
129
+ @challenge_error = nil
130
+ end
131
+
132
+ # MCP 2026-07-28 "Communication Security" requires implementations to
133
+ # follow OAuth 2.1 Section 1.5, and spells out what that means here:
134
+ # "All redirect URIs MUST be either `localhost` or use HTTPS." A
135
+ # callback on any other plain-HTTP host carries the authorization code
136
+ # — and, on an error response, the authorization server's own error
137
+ # text — across the network in the clear, where anyone on the path can
138
+ # redeem it. RFC 8252 Section 7.1 private-use schemes (a native app's
139
+ # `com.example.app:/oauth2redirect`) never leave the device and stay
140
+ # available.
141
+ # @param uri [String] the redirect URI to use
142
+ # @raise [ArgumentError] when it is not a callback this client may register
143
+ def redirect_uri=(uri)
144
+ unless redirect_uri_bytes?(uri)
145
+ raise ArgumentError,
146
+ 'redirect_uri must be a localhost HTTP callback, an HTTPS URL or an RFC 8252 private-use ' \
147
+ "scheme URI (MCP 2026-07-28 requires every redirect URI to be localhost or HTTPS): #{uri.inspect}"
148
+ end
149
+
150
+ @redirect_uri = uri
151
+ end
152
+
153
+ # @param type [String, nil] 'native', 'web' or nil (derived from the redirect URI)
154
+ # @raise [ArgumentError] for any other value
155
+ def application_type=(type)
156
+ type = type&.to_s
157
+ unless type.nil? || APPLICATION_TYPES.include?(type)
158
+ raise ArgumentError, "application_type must be one of #{APPLICATION_TYPES.join(', ')}: #{type.inspect}"
159
+ end
160
+
161
+ @application_type = type
71
162
  end
72
163
 
73
164
  # @param url [String] Server URL to normalize
74
165
  def server_url=(url)
75
- @server_url = normalize_server_url(url)
166
+ normalized = normalize_server_url(url)
167
+ # Everything discovery leaves on the instance describes the resource
168
+ # it was discovered FOR. Retargeting the provider at another server
169
+ # must not let the previous server's metadata answer for the new one:
170
+ # the fallback that survives a backend which does not persist metadata
171
+ # would otherwise send the new server's authorization, registration
172
+ # and token requests to the previous server's endpoints.
173
+ forget_per_server_state if defined?(@server_url) && @server_url && normalized != @server_url
174
+ @server_url = normalized
76
175
  end
77
176
 
78
177
  # Set the Client ID Metadata Document URL (SEP-991), validating it per the
@@ -87,15 +186,50 @@ module MCPClient
87
186
  # Get current access token (refresh if needed)
88
187
  # @return [Token, nil] Current valid access token or nil
89
188
  def access_token
90
- token = storage.get_token(server_url)
189
+ # A challenge still to be fetched decides which authorization server
190
+ # is current, and may retire the stored token: it is resolved before
191
+ # the token is read, so a record it deleted is never written back.
192
+ resolve_pending_challenge
193
+ token = token_in_use
91
194
  logger.debug("OAuth access_token: retrieved token=#{token ? 'present' : 'nil'} for #{server_url}")
92
195
  return nil unless token
93
196
 
94
197
  # Return token if still valid
95
198
  return token unless token.expired? || token.expires_soon?
96
199
 
97
- # Try to refresh if we have a refresh token
98
- refresh_token(token) if token.refresh_token
200
+ # Refresh early when possible; a still-valid token is presented when
201
+ # the refresh (or the discovery it needs) cannot run right now. What
202
+ # comes back is judged against the authorization server that is
203
+ # current NOW, not the one the refresh was started with.
204
+ resource = server_url
205
+ refreshed = refresh_if_possible(token)
206
+ # A provider retargeted at another resource while the refresh was in
207
+ # flight has nothing of the previous resource to present.
208
+ return nil unless server_url == resource
209
+ return refreshed if token_bytes?(refreshed) && token_for_current_issuer?(refreshed)
210
+
211
+ # The token in use may have changed hands while the refresh was in
212
+ # flight — retired by a challenge another provider sharing the storage
213
+ # handled, replaced by a flow it completed: what is in use NOW is what
214
+ # is presented, and the token this refresh started from is not.
215
+ current = token_in_use
216
+ return presentable_token(current) unless current && same_token?(current, token)
217
+
218
+ # The discovery a refresh ran may have retired this very token.
219
+ return nil if token.expired? || retired_token?(token) || !token_for_current_issuer?(token)
220
+
221
+ token
222
+ end
223
+
224
+ # @param token [Token]
225
+ # @return [Token, nil] the refreshed token, or nil when no refresh could be obtained
226
+ def refresh_if_possible(token)
227
+ return nil unless token.refresh_token
228
+
229
+ refresh_token(token)
230
+ rescue MCPClient::Errors::ConnectionError => e
231
+ logger.warn("Token refresh could not run: #{e.message}")
232
+ nil
99
233
  end
100
234
 
101
235
  # Return the scopes supported by the authorization server
@@ -103,7 +237,9 @@ module MCPClient
103
237
  # @return [Array<String>] supported scopes, or empty array if not advertised
104
238
  # @raise [MCPClient::Errors::ConnectionError] if server discovery fails
105
239
  def supported_scopes
106
- @supported_scopes ||= discover_authorization_server.scopes_supported || []
240
+ # A record a hash-persisting backend read back may carry anything
241
+ # here; only an array of scopes is a scope list (RFC 8414 Section 2).
242
+ @supported_scopes ||= advertised_scopes(discover_authorization_server.scopes_supported)
107
243
  end
108
244
 
109
245
  # Start OAuth authorization flow
@@ -116,13 +252,37 @@ module MCPClient
116
252
  # Register client if needed
117
253
  client_info = get_or_register_client(server_metadata)
118
254
 
119
- # Generate PKCE parameters
120
- pkce = PKCE.new
121
- storage.set_pkce(server_url, pkce)
122
-
123
- # Generate state parameter
255
+ # Generate the state and the PKCE parameters as ONE per-request
256
+ # record. MCP 2026-07-28 "Authorization Response Validation": the
257
+ # selected authorization server's issuer is recorded "in the same
258
+ # per-request record used to store the PKCE code verifier (and the
259
+ # `state` value, if used)", so the `iss` of the response can be
260
+ # checked against an authenticated value — and so the state the
261
+ # response carries names THIS request rather than whichever record
262
+ # happens to be in the PKCE slot. Two flows sharing one storage
263
+ # backend interleave their writes: with the state in a slot of its
264
+ # own, A's state could end up alongside B's verifier, issuer and
265
+ # client, and A's code would then be redeemed at B.
124
266
  state = SecureRandom.urlsafe_base64(32)
125
- storage.set_state(server_url, state)
267
+ # What this request asks for is what the next step-up challenge has
268
+ # to be unioned with — and, recorded with the request, what a token
269
+ # response that omits `scope` granted.
270
+ @requested_scope = resolved_scope
271
+ pkce = PKCE.new(issuer: server_metadata.issuer,
272
+ iss_parameter_supported: server_metadata.iss_parameter_supported?,
273
+ client_id: client_info.client_id,
274
+ redirect_uri: client_info.metadata.redirect_uris.first,
275
+ state: state,
276
+ resource: server_url,
277
+ scope: @requested_scope)
278
+ with_authorization_state_lock do
279
+ storage.set_pkce(server_url, pkce)
280
+
281
+ # The separate slot is still written: it is the documented storage
282
+ # interface, and a callback handler (or an older version of this
283
+ # library) reads the state from it.
284
+ storage.set_state(server_url, state)
285
+ end
126
286
 
127
287
  # Build authorization URL
128
288
  build_authorization_url(server_metadata, client_info, pkce, state)
@@ -131,141 +291,343 @@ module MCPClient
131
291
  # Complete OAuth authorization flow with authorization code
132
292
  # @param code [String] Authorization code from callback
133
293
  # @param state [String] State parameter from callback
294
+ # @param iss [String, nil] the `iss` parameter of the authorization response (RFC 9207);
295
+ # validated against the issuer recorded when the flow started, before the code is sent
296
+ # to any token endpoint (MCP 2026-07-28)
134
297
  # @return [Token] Access token
135
- # @raise [MCPClient::Errors::ConnectionError] if token exchange fails
298
+ # @raise [MCPClient::Errors::ConnectionError] if the issuer check or the token exchange fails
136
299
  # @raise [ArgumentError] if state parameter doesn't match
137
- def complete_authorization_flow(code, state)
300
+ def complete_authorization_flow(code, state, iss: nil)
138
301
  # Verify state parameter
139
302
  stored_state = storage.get_state(server_url)
140
303
  raise ArgumentError, 'Invalid state parameter' unless stored_state == state
141
304
 
142
305
  # Get stored PKCE and client info
143
- pkce = storage.get_pkce(server_url)
144
- client_info = storage.get_client_info(server_url)
145
- server_metadata = discover_authorization_server
146
-
306
+ pkce = stored_pkce
307
+ client_info = stored_client_info
147
308
  raise MCPClient::Errors::ConnectionError, 'Missing PKCE or client info' unless pkce && client_info
148
309
 
310
+ # The per-request record must be the record of THIS request.
311
+ ensure_state_for_request!(pkce, state)
312
+
313
+ # The code is redeemed only at the authorization server the request
314
+ # was sent to: the issuer recorded with the PKCE record (RFC 9207
315
+ # mix-up protection). A different server discovered since — a 401
316
+ # challenge pointing elsewhere — ends this flow instead.
317
+ unless pkce.issuer.is_a?(String)
318
+ raise MCPClient::Errors::ConnectionError,
319
+ 'Authorization response rejected: no issuer was recorded for this authorization request, ' \
320
+ 'so it cannot be bound to an authorization server; restart the authorization'
321
+ end
322
+ server_metadata = discover_authorization_server
323
+ raise_authorization_server_changed!(pkce.issuer) unless server_metadata.issuer == pkce.issuer
324
+ validate_authorization_response_issuer!(iss, pkce.issuer, iss_parameter_supported_for?(pkce, server_metadata))
325
+ # The credentials that redeem the code are the ones the request was
326
+ # made with: a record swapped in shared storage meanwhile (another
327
+ # client id, or credentials of another authorization server) is
328
+ # never sent to this token endpoint.
329
+ ensure_client_for_request!(client_info, pkce)
330
+
149
331
  # Exchange authorization code for tokens
150
332
  token = exchange_authorization_code(server_metadata, client_info, code, pkce)
151
333
 
152
- # Store token
153
- storage.set_token(server_url, token)
334
+ accept_exchanged_token(token, pkce)
335
+ end
154
336
 
155
- # Clean up temporary data
156
- storage.delete_pkce(server_url)
157
- storage.delete_state(server_url)
337
+ # The response of a code exchange arrives at a client whose
338
+ # authorization server may have changed since the request went out —
339
+ # updated protected resource metadata, a 401 challenge, another provider
340
+ # sharing the storage — exactly as a refresh response does. So the
341
+ # checks made before the request are made again over the response,
342
+ # before anything is written: bytes issued by a server that is no longer
343
+ # this resource's would otherwise be stored over the token of the server
344
+ # that IS in use, and the cleanup would delete the pending authorization
345
+ # request that server had already started.
346
+ # @param token [Token] the token the exchange response carried
347
+ # @param pkce [PKCE] the per-request record the exchange was made with
348
+ # @return [Token]
349
+ # @raise [MCPClient::Errors::ConnectionError] when the response is no longer this resource's to keep
350
+ def accept_exchanged_token(token, pkce)
351
+ # Under the resource's authorization-state lock, so the answers these
352
+ # checks give are still the answers when the token is written: a
353
+ # switch of authorization server validated between the two would
354
+ # otherwise store its token first and have this one written over it.
355
+ with_authorization_state_lock do
356
+ raise_authorization_server_changed!(pkce.issuer) unless exchange_target_current?(pkce.issuer)
357
+ # Two resources may share an authorization server and still be two
358
+ # audiences: a token bought for the resource the request named is
359
+ # never stored as another resource's, however alike their issuers.
360
+ raise_resource_changed!(pkce.resource) unless request_resource_current?(pkce)
361
+ # A validated change of authorization server ends the requests still
362
+ # pending with the previous one (see {#end_pending_requests_of}),
363
+ # whichever provider sharing the storage made them: a request whose
364
+ # record is gone was answered by a server that is no longer this
365
+ # resource's, however current that server still looks from here.
366
+ raise_authorization_server_changed!(pkce.issuer) unless request_still_pending?(pkce)
367
+
368
+ store_token(token)
369
+
370
+ # Clean up this request's temporary data — and only this request's: a
371
+ # flow started meanwhile keeps the records it is waiting on.
372
+ discard_pending_request(pkce)
373
+ end
158
374
 
159
375
  token
160
376
  end
161
377
 
162
- # Apply OAuth authorization to HTTP request
163
- # @param request [Faraday::Request] HTTP request to authorize
378
+ # Whether the code exchange that just answered is still this resource's:
379
+ # the authorization server it went to is the one in use now (an
380
+ # unresolved or refused challenge means it is unknown, which is not
381
+ # "still A"), and the token slot the response would be written to does
382
+ # not already hold another server's token. A record this client retired
383
+ # is not another server's token to protect — it is what a backend that
384
+ # could not delete left behind — so it does not stand in the way.
385
+ # @param issuer [String] the authorization server the flow started at
386
+ # @return [Boolean]
387
+ def exchange_target_current?(issuer)
388
+ return false unless current_issuer_for_tokens == issuer
389
+
390
+ stored = stored_token_or_nil
391
+ return true unless stored.respond_to?(:issuer) && stored.issuer
392
+ return true if retired_token?(stored)
393
+
394
+ stored.issuer == issuer
395
+ end
396
+
397
+ # Whether the resource a request was made for is still the one this
398
+ # provider serves. A record made before the resource was recorded says
399
+ # nothing, and is judged by its issuer alone.
400
+ # @param pkce [PKCE] the per-request record
401
+ # @return [Boolean]
402
+ def request_resource_current?(pkce)
403
+ return true unless pkce.respond_to?(:resource) && pkce.resource.is_a?(String)
404
+
405
+ pkce.resource == server_url
406
+ end
407
+
408
+ # @param recorded [String] the resource the request was made for
409
+ # @raise [MCPClient::Errors::ConnectionError]
410
+ def raise_resource_changed!(recorded)
411
+ raise MCPClient::Errors::ConnectionError,
412
+ 'Authorization response rejected: the resource changed during the flow ' \
413
+ "(the token was issued for #{safe_error_text(recorded)}); restart the authorization"
414
+ end
415
+
416
+ # Delete the pending-flow records of one authorization request, leaving
417
+ # a newer request's records alone.
418
+ # @param pkce [PKCE] the per-request record whose flow just ended
164
419
  # @return [void]
165
- def apply_authorization(request)
166
- token = access_token
167
- logger.debug("OAuth apply_authorization: token=#{token ? 'present' : 'nil'}")
168
- return unless token
420
+ def discard_pending_request(pkce)
421
+ with_authorization_state_lock do
422
+ pending = stored_pkce
423
+ discard_pending_pkce(pkce) if pending.nil? || same_request?(pending, pkce)
424
+ recorded = pkce.state if pkce.respond_to?(:state)
425
+ stored_state = storage.get_state(server_url)
426
+ discard_pending_state(recorded) if recorded.nil? || stored_state.nil? || stored_state == recorded
427
+ end
428
+ end
169
429
 
170
- logger.debug("OAuth applying authorization header: #{token.to_header[0..20]}...")
171
- request.headers['Authorization'] = token.to_header
430
+ # The authorization state of one resource in one storage backend — the
431
+ # pending-flow records, and the token slot an accepted response is
432
+ # written to — is read, compared and written under one in-process lock.
433
+ # Two windows depend on it. A flow another provider (or thread) starts
434
+ # while a completed flow discards its records waits for the delete
435
+ # instead of losing its records to it; and the checks that accept a
436
+ # token (issuer, resource, pending request, token in use) stay true
437
+ # until that token is stored, so a change of authorization server
438
+ # validated meanwhile cannot have its token written over by a response
439
+ # that passed its checks just before it.
440
+ #
441
+ # The storage interface has no conditional write or delete, and a
442
+ # backend need not answer a delete with the record it removed, so both
443
+ # windows have to be closed on this side. The lock is re-entrant: a flow
444
+ # the SAME thread starts from inside a storage callback is not
445
+ # deadlocked, and falls back to the put-back a record-answering delete
446
+ # allows.
447
+ # @return [Object] the block's value
448
+ def with_authorization_state_lock(&)
449
+ lock = AUTHORIZATION_STATE_LOCKS_GUARD.synchronize do
450
+ (AUTHORIZATION_STATE_LOCKS[storage] ||= {})[server_url] ||= Monitor.new
451
+ end
452
+ lock.synchronize(&)
172
453
  end
173
454
 
174
- # Handle 401 Unauthorized response (for server discovery)
175
- # @param response [Faraday::Response] HTTP response
176
- # @return [ResourceMetadata, nil] Resource metadata if found
177
- def handle_unauthorized_response(response)
178
- www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
179
- return nil unless www_authenticate
180
-
181
- # Challenge parameters are read from the Bearer challenge's own
182
- # segment only — never from the whole (possibly multi-challenge)
183
- # header — so parameters belonging to Basic or another scheme cannot
184
- # drive Bearer scope selection or resource metadata discovery. A
185
- # header without a Bearer challenge carries no usable Bearer params.
186
- bearer_params = bearer_challenge_segment(www_authenticate)
187
-
188
- # MCP 2025-11-25: "Clients MUST treat the scopes provided in the
189
- # challenge as authoritative for satisfying the current request" —
190
- # including resetting a previously challenged scope when the current
191
- # challenge carries none.
192
- @challenge_scope = bearer_params && extract_challenge_param(bearer_params, 'scope')
193
-
194
- url = extract_resource_metadata_url(www_authenticate)
195
- return nil unless url
196
-
197
- # Remember the advertised URL even if the fetch below fails, so a
198
- # later discovery retries it instead of probing well-known URIs the
199
- # challenge already superseded.
200
- @challenge_metadata_url = url
201
-
202
- # This URL was explicitly advertised by the 401 challenge, so a 404 is a
203
- # misconfiguration to surface (strict), not a speculative miss to skip.
204
- metadata = fetch_resource_metadata(url, strict: true)
205
- # Reuse this challenge-advertised metadata during the subsequent OAuth
206
- # flow instead of re-deriving (and possibly missing) the well-known URL.
207
- @challenge_resource_metadata = metadata
208
- metadata
455
+ # Read, compare, delete: a flow started in between loses its record to
456
+ # the delete. The storage interface has no conditional delete, but a
457
+ # backend that answers the delete with the record it removed (the
458
+ # in-memory one does, as does anything Hash-backed) says whose record
459
+ # went, and a newer flow's is put back — and, held under
460
+ # {#with_authorization_state_lock}, the newer flow cannot start in between at
461
+ # all within one process.
462
+ # @param pkce [PKCE] the record whose flow just ended
463
+ # @return [void]
464
+ def discard_pending_pkce(pkce)
465
+ removed = removed_record(storage.delete_pkce(server_url), PKCE)
466
+ return unless removed.respond_to?(:code_verifier) && !same_request?(removed, pkce)
467
+
468
+ storage.set_pkce(server_url, removed)
209
469
  end
210
470
 
211
- # Extract the protected-resource-metadata URL from a WWW-Authenticate header.
212
- # Per RFC 9728 the parameter is `resource_metadata`; a legacy `resource`
213
- # parameter is accepted as a fallback for older servers. Only the Bearer
214
- # challenge's own segment is consulted, so a parameter belonging to
215
- # another scheme's challenge can never drive discovery.
216
- # @param header [String] the WWW-Authenticate header value
217
- # @return [String, nil] the metadata URL if present
218
- def extract_resource_metadata_url(header)
219
- params = bearer_challenge_segment(header)
220
- return nil unless params
221
-
222
- # Auth-params may include optional whitespace around '=' (RFC 7235).
223
- # Quoted form: resource_metadata = "https://..."
224
- if (m = params.match(/resource_metadata\s*=\s*"([^"]+)"/))
225
- return m[1]
226
- end
227
-
228
- # Unquoted token form: resource_metadata = https://...
229
- if (m = params.match(/resource_metadata\s*=\s*([^,\s]+)/))
230
- return m[1]
231
- end
232
-
233
- # Legacy fallback: resource="https://..."
234
- params.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
235
- end
236
-
237
- # Extract the Bearer challenge's own parameter segment from a (possibly
238
- # multi-challenge) WWW-Authenticate header, so params belonging to other
239
- # schemes (e.g. `Basic resource_metadata="...", Bearer realm="x"`) are
240
- # never attributed to the Bearer challenge. Mirrors
241
- # HttpTransportBase#bearer_challenge_segment.
242
- # @param header [String, nil] the WWW-Authenticate header value
243
- # @return [String, nil] the Bearer challenge's parameters (possibly
244
- # empty), or nil when the header has no Bearer challenge
245
- def bearer_challenge_segment(header)
246
- return nil unless header
247
-
248
- # Locate the Bearer scheme token only OUTSIDE quoted strings: a
249
- # quoted value such as realm="prefix Bearer x" must not anchor the
250
- # segment.
251
- masked = header.gsub(/"(?:\\.|[^"\\])*"/) { |q| "\"#{' ' * (q.length - 2)}\"" }
252
- match = masked.match(/(?:\A|[\s,])Bearer(?=[\s,]|\z)/i)
253
- return nil unless match
471
+ # @param recorded [String, nil] the state of the request whose flow just ended
472
+ # @return [void]
473
+ def discard_pending_state(recorded)
474
+ removed = storage.delete_state(server_url)
475
+ return unless recorded && removed.is_a?(String) && removed != recorded
254
476
 
255
- header[match.end(0)..][AUTH_PARAMS_RUN]
477
+ storage.set_state(server_url, removed)
256
478
  end
257
479
 
258
- # Extract an auth-param value from a WWW-Authenticate header
259
- # (quoted or unquoted form, optional whitespace around '=').
260
- # @param header [String] the WWW-Authenticate header value
261
- # @param name [String] the auth-param name
262
- # @return [String, nil] the parameter value if present
263
- def extract_challenge_param(header, name)
264
- if (m = header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*"([^"]*)"/i))
265
- return m[1]
480
+ # The record a delete answered with, if it answered with one.
481
+ # @param removed [Object, nil] what the backend returned
482
+ # @param klass [Class] the record class
483
+ # @return [Object, nil]
484
+ def removed_record(removed, klass)
485
+ normalize_record(removed, klass)
486
+ rescue ArgumentError
487
+ nil
488
+ end
489
+
490
+ # @param one [PKCE, Object] the record currently in the pending-flow slot
491
+ # @param other [PKCE] the record the flow that just ended was made with
492
+ # @return [Boolean] whether both records describe the same authorization request
493
+ def same_request?(one, other)
494
+ one.respond_to?(:code_verifier) && one.code_verifier == other.code_verifier
495
+ end
496
+
497
+ # The state of a callback must be the state of the per-request record
498
+ # the rest of the checks read. A record written by this client always
499
+ # carries one; a record persisted before the state was recorded there
500
+ # carries none, and the separate slot (already compared by the caller)
501
+ # is all there is to go on.
502
+ # @param pkce [PKCE] the per-request record
503
+ # @param state [String, nil] the callback's state parameter
504
+ # @return [void]
505
+ # @raise [MCPClient::Errors::ConnectionError] when the record is another request's
506
+ def ensure_state_for_request!(pkce, state)
507
+ recorded = pkce.state if pkce.respond_to?(:state)
508
+ return if recorded.nil? || recorded == state
509
+
510
+ raise MCPClient::Errors::ConnectionError,
511
+ 'Authorization response rejected: the pending authorization request is not the one this ' \
512
+ 'response answers; restart the authorization'
513
+ end
514
+
515
+ # The stored credentials must be the ones the authorization request
516
+ # was made with; a request that recorded no client cannot be bound to
517
+ # any and fails closed, like one that recorded no issuer.
518
+ # @param client_info [ClientInfo, nil]
519
+ # @param pkce [PKCE]
520
+ # @return [void]
521
+ # @raise [MCPClient::Errors::ConnectionError]
522
+ def ensure_client_for_request!(client_info, pkce)
523
+ unless pkce.respond_to?(:client_id) && pkce.client_id.is_a?(String)
524
+ raise MCPClient::Errors::ConnectionError,
525
+ 'Authorization response rejected: no client was recorded for this authorization request; ' \
526
+ 'restart the authorization'
266
527
  end
528
+ return if client_info && client_for_request?(client_info, pkce)
529
+
530
+ raise MCPClient::Errors::ConnectionError,
531
+ 'Authorization response rejected: the client credentials changed during the flow; ' \
532
+ 'restart the authorization'
533
+ end
267
534
 
268
- header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*([^,\s]+)/i)&.captures&.first
535
+ # Whether stored credentials are the ones an authorization request was
536
+ # made with: the recorded client id and, unless portable, bound to the
537
+ # request's authorization server.
538
+ # @param client_info [ClientInfo]
539
+ # @param pkce [PKCE]
540
+ # @return [Boolean]
541
+ def client_for_request?(client_info, pkce)
542
+ return false if pkce.client_id != client_info.client_id
543
+ return true if portable_client?(client_info)
544
+
545
+ # A started flow binds its non-portable client, so an unbound record
546
+ # here was put in storage by someone else meanwhile.
547
+ !client_info.respond_to?(:issuer) || client_info.issuer == pkce.issuer
548
+ end
549
+
550
+ # Check a success response before anything is shown or exchanged: the
551
+ # state must be the one of the pending flow and the response's `iss`
552
+ # must identify the authorization server the request went to (RFC
553
+ # 9207). {#complete_authorization_flow} repeats the check before the
554
+ # token exchange; a browser callback uses this to answer correctly.
555
+ # @param state [String, nil] the callback's state parameter
556
+ # @param iss [String, nil] the callback's iss parameter
557
+ # @return [void]
558
+ # @raise [MCPClient::Errors::ConnectionError] when the response is not this flow's or the issuer fails
559
+ def validate_authorization_response!(state, iss: nil)
560
+ stored_state = storage.get_state(server_url)
561
+ unless stored_state && stored_state == state
562
+ raise MCPClient::Errors::ConnectionError, 'Authorization response rejected: state mismatch'
563
+ end
564
+
565
+ pkce = stored_pkce
566
+ unless pkce.respond_to?(:issuer) && pkce.issuer.is_a?(String)
567
+ raise MCPClient::Errors::ConnectionError,
568
+ 'Authorization response rejected: no issuer was recorded for this authorization request'
569
+ end
570
+
571
+ ensure_state_for_request!(pkce, state)
572
+ cached = stored_server_metadata
573
+ ensure_authorization_server_unchanged!(pkce, cached)
574
+ ensure_client_for_request!(stored_client_info, pkce)
575
+ validate_authorization_response_issuer!(iss, pkce.issuer, iss_advertised_for_response?(pkce, cached))
576
+ end
577
+
578
+ # The message to surface for an authorization *error* response, after
579
+ # the same RFC 9207 issuer check as a success response: "on mismatch
580
+ # the client MUST NOT act on or display error, error_description, or
581
+ # error_uri" (MCP 2026-07-28 "Authorization Response Validation").
582
+ # @param params [Hash] the callback parameters (error, error_description, iss, state, ...)
583
+ # @return [String] the error text to show
584
+ # @raise [MCPClient::Errors::ConnectionError] when the response's issuer does not check out
585
+ def authorization_error_message(params)
586
+ params = params.to_h.transform_keys(&:to_s)
587
+ # Every started flow records a state, so a response that cannot be
588
+ # matched to one is not this client's to act on.
589
+ stored_state = storage.get_state(server_url)
590
+ if stored_state.nil? || params['state'] != stored_state
591
+ raise MCPClient::Errors::ConnectionError, 'Authorization error response rejected: state mismatch'
592
+ end
593
+
594
+ pkce = stored_pkce
595
+ # The per-request record must be the record of THIS response, as the
596
+ # success path requires: the separate state slot and the record can
597
+ # be torn apart by two flows sharing a storage backend, and a state
598
+ # that names another request's record makes every check below — the
599
+ # authorization server, the `iss` — a check about that other request.
600
+ # An error response would then be displayed after passing an issuer
601
+ # comparison it never had to satisfy.
602
+ ensure_state_for_request!(pkce, params['state']) if pkce
603
+ cached = stored_server_metadata
604
+ # The same checks the success path makes: an error response of
605
+ # authorization server A is not displayed once a challenge received
606
+ # during the flow — or shared storage — moved the flow to B. Without a
607
+ # recorded issuer there is nothing to compare, and the issuer check
608
+ # below rejects the response outright.
609
+ ensure_authorization_server_unchanged!(pkce, cached) if pkce.respond_to?(:issuer) && pkce.issuer.is_a?(String)
610
+ # Only the request's own authorization server can say whether iss is
611
+ # expected: a cache that names another server is no guide.
612
+ cached = nil unless pkce && cached && cached.issuer == pkce.issuer
613
+ validate_authorization_response_issuer!(params['iss'], pkce&.issuer, iss_advertised_for_response?(pkce, cached))
614
+ safe_error_text((params['error_description'] || params['error'] || 'unknown error').to_s).strip
615
+ end
616
+
617
+ # Apply OAuth authorization to HTTP request
618
+ # @param request [Faraday::Request] HTTP request to authorize
619
+ # @return [void]
620
+ def apply_authorization(request)
621
+ token = access_token
622
+ logger.debug("OAuth apply_authorization: token=#{token ? 'present' : 'nil'}")
623
+ # A record without access token bytes is never presented: a bare
624
+ # "Bearer " is not a credential (RFC 6749 Section 5.1).
625
+ return unless token_bytes?(token)
626
+
627
+ # The header's value is the credential: not a prefix of it, not a
628
+ # truncation of it. It is never written to a log at any level.
629
+ logger.debug('OAuth applying authorization header')
630
+ request.headers['Authorization'] = token.to_header
269
631
  end
270
632
 
271
633
  # Scope requested by the most recent WWW-Authenticate challenge.
@@ -274,27 +636,123 @@ module MCPClient
274
636
 
275
637
  private
276
638
 
277
- # Resolve the scope for authorization/registration requests using the
278
- # MCP 2025-11-25 scope selection strategy: the challenge's scope
279
- # parameter is authoritative; then an explicitly configured scope
280
- # (:all resolves to the AS-advertised scope list); then the Protected
281
- # Resource Metadata's scopes_supported; otherwise omit scope entirely.
282
- # @return [String, nil]
283
- def resolved_scope
284
- return @challenge_scope if @challenge_scope && !@challenge_scope.empty?
639
+ # A challenge received during the flow — refused, still to be fetched,
640
+ # or naming another authorization server — or cached metadata for
641
+ # another server ends the flow here. Applied to a success response and,
642
+ # equally, to an error response: "the client MUST NOT act on or display
643
+ # error, error_description, or error_uri" is worth nothing if the text
644
+ # of authorization server A is shown after the flow moved to B.
645
+ # @param pkce [PKCE] the record of the authorization request
646
+ # @param cached [ServerMetadata, nil] the metadata currently in storage, if any
647
+ # @return [void]
648
+ # @raise [MCPClient::Errors::ConnectionError]
649
+ def ensure_authorization_server_unchanged!(pkce, cached)
650
+ if @challenge_error || (@challenge_metadata_url && @challenge_resource_metadata.nil?)
651
+ raise MCPClient::Errors::ConnectionError,
652
+ 'Authorization response rejected: a challenge received during the flow must be resolved first; ' \
653
+ 'restart the authorization'
654
+ end
655
+
656
+ advertised = Array(@challenge_resource_metadata&.authorization_servers).first
657
+ return unless (advertised && advertised != pkce.issuer) || (cached && cached.issuer != pkce.issuer)
285
658
 
286
- if scope == :all
287
- all_scopes = supported_scopes
288
- return all_scopes.join(' ') unless all_scopes.empty?
289
- elsif scope
290
- return scope
659
+ raise MCPClient::Errors::ConnectionError,
660
+ 'Authorization response rejected: the authorization server changed during the flow; ' \
661
+ 'restart the authorization'
662
+ end
663
+
664
+ # RFC 9207 Section 2.4 as applied by MCP 2026-07-28: a present `iss`
665
+ # must equal the recorded issuer byte for byte (no scheme/host case
666
+ # folding, default-port elision, trailing-slash or percent-encoding
667
+ # normalization); an absent `iss` is rejected when the authorization
668
+ # server advertises the parameter. Without a recorded issuer nothing
669
+ # can be validated, so a present `iss` is rejected (fail closed).
670
+ # @param iss [String, nil] the response's iss parameter
671
+ # @param expected [String, nil] the issuer recorded when the flow started
672
+ # @param supported [Boolean] whether the request's authorization server advertises iss
673
+ # @return [void]
674
+ # @raise [MCPClient::Errors::ConnectionError]
675
+ def validate_authorization_response_issuer!(iss, expected, supported)
676
+ unless expected.is_a?(String)
677
+ raise MCPClient::Errors::ConnectionError,
678
+ 'Authorization response rejected: no issuer was recorded for this authorization request, ' \
679
+ 'so it cannot be bound to an authorization server; restart the authorization'
291
680
  end
681
+ if iss.nil?
682
+ return unless supported
292
683
 
293
- prm = @challenge_resource_metadata || @resource_metadata
294
- prm_scopes = prm&.scopes_supported
295
- return prm_scopes.join(' ') if prm_scopes && !prm_scopes.empty?
684
+ raise MCPClient::Errors::ConnectionError,
685
+ 'Authorization response rejected: the authorization server advertises the iss parameter ' \
686
+ '(authorization_response_iss_parameter_supported) but the response carries none'
687
+ end
688
+ return if iss.to_s == expected
296
689
 
297
- nil
690
+ raise MCPClient::Errors::ConnectionError,
691
+ "Authorization response rejected: issuer mismatch (expected #{safe_error_text(expected)})"
692
+ end
693
+
694
+ # Whether the authorization server of a request advertised the iss
695
+ # response parameter: recorded with the PKCE record; for a record
696
+ # persisted before that field existed, the metadata of the same
697
+ # issuer decides.
698
+ # @return [Boolean]
699
+ def iss_parameter_supported_for?(pkce, server_metadata)
700
+ recorded = pkce&.iss_parameter_supported
701
+ return recorded == true unless recorded.nil?
702
+
703
+ server_metadata&.iss_parameter_supported? == true
704
+ end
705
+
706
+ # The same question for a check made BEFORE the token exchange (the
707
+ # browser callback precheck and the error-response path), which reads
708
+ # cached metadata rather than freshly discovered metadata. Metadata
709
+ # persisted before this client read RFC 9207 records no answer, and
710
+ # reading that silence as "not supported" would show a success page (or
711
+ # the peer's error text) for a response {#complete_authorization_flow}
712
+ # then rejects. So when neither the PKCE record nor the cache carries an
713
+ # answer, the answer is rediscovered exactly as the completion does; an
714
+ # answer that cannot be obtained is unknown, not "no", and the
715
+ # advertisement is assumed (fail closed) so a missing `iss` is refused.
716
+ # @param pkce [PKCE, nil] the record of the authorization request
717
+ # @param cached [ServerMetadata, nil] cached metadata of the same issuer, if any
718
+ # @return [Boolean] whether the request's authorization server advertises iss
719
+ # @raise [MCPClient::Errors::ConnectionError] when rediscovery names another authorization server
720
+ def iss_advertised_for_response?(pkce, cached)
721
+ recorded = pkce&.iss_parameter_supported
722
+ return recorded == true unless recorded.nil?
723
+ return cached.iss_parameter_supported? if cached&.iss_parameter_recorded?
724
+ # Without a recorded issuer nothing can be validated at all; the
725
+ # caller rejects the response, so no discovery is worth making.
726
+ return false unless pkce.respond_to?(:issuer) && pkce.issuer.is_a?(String)
727
+
728
+ rediscovered = begin
729
+ discover_authorization_server
730
+ rescue MCPClient::Errors::ConnectionError
731
+ nil
732
+ end
733
+ # An answer that could not be obtained is unknown, not "no".
734
+ return true if rediscovered.nil?
735
+
736
+ # A rediscovery naming ANOTHER authorization server says nothing about
737
+ # this request's `iss`: it is the very "the authorization server
738
+ # changed during the flow" that {#complete_authorization_flow} rejects.
739
+ # Reducing it to "iss is advertised" would let a callback carrying the
740
+ # recorded issuer pass this check, and a browser flow would show a
741
+ # success page for a response the completion then refuses.
742
+ raise_authorization_server_changed!(pkce.issuer) unless rediscovered.issuer == pkce.issuer
743
+
744
+ rediscovered.iss_parameter_supported?
745
+ end
746
+
747
+ # The authorization server of a pending flow is no longer the one the
748
+ # request went to, so the code can never be redeemed: end the flow.
749
+ # @param recorded [String] the issuer recorded when the flow started
750
+ # @return [void]
751
+ # @raise [MCPClient::Errors::ConnectionError]
752
+ def raise_authorization_server_changed!(recorded)
753
+ raise MCPClient::Errors::ConnectionError,
754
+ 'Authorization response rejected: the authorization server changed during the flow ' \
755
+ "(recorded #{safe_error_text(recorded)}); restart the authorization"
298
756
  end
299
757
 
300
758
  # Normalize server URL to canonical form
@@ -414,27 +872,84 @@ module MCPClient
414
872
  # @return [ServerMetadata] Authorization server metadata
415
873
  # @raise [MCPClient::Errors::ConnectionError] if discovery fails
416
874
  def discover_authorization_server
875
+ # Another provider sharing the storage may have moved this resource to
876
+ # another authorization server since this one last resolved anything.
877
+ forget_foreign_server_switch
878
+ # A CHALLENGE we refused is still authoritative: it says the cached
879
+ # authorization server is no longer the right one. Falling back to
880
+ # that cache (or to speculative well-known probing) would quietly
881
+ # undo the rejection, so surface it instead. Only a challenge sets
882
+ # this: a refused well-known document leaves nothing latched and is
883
+ # fetched again below.
884
+ raise MCPClient::Errors::ConnectionError, @challenge_error if @challenge_error
885
+
417
886
  # A fresh 401 challenge is authoritative and overrides any cached
418
887
  # (possibly stale or direct-discovered) authorization server metadata —
419
888
  # whether the challenge-advertised PRM was already fetched or only its
420
889
  # URL is pending (e.g. the initial fetch failed and must be retried).
421
890
  challenge_pending = @challenge_resource_metadata || @challenge_metadata_url
422
- cached = storage.get_server_metadata(server_url) unless challenge_pending
891
+ cached = stored_server_metadata unless challenge_pending || @rediscover_after_switch
423
892
  if cached
424
893
  # Validate the cached entry before use so a persisted/older cache with
425
894
  # an HTTP endpoint or without S256 is still rejected.
426
895
  validate_server_metadata!(cached)
427
- return cached
896
+ # A record persisted before this client read RFC 9207's
897
+ # authorization_response_iss_parameter_supported says nothing about
898
+ # the parameter. Treating that silence as "not supported" would
899
+ # accept an authorization response without `iss` from a server that
900
+ # advertises it, so the answer is rediscovered rather than assumed.
901
+ return note_state_issuer(cached) if cached.iss_parameter_recorded?
902
+
903
+ logger.debug('Cached authorization server metadata predates the iss parameter record; rediscovering')
428
904
  end
429
905
 
430
- discover_and_cache_authorization_server
906
+ note_state_issuer(discover_and_cache_authorization_server)
907
+ end
908
+
909
+ # Drop the issuer-dependent state this provider resolved against an
910
+ # authorization server that another provider sharing the storage has
911
+ # since replaced. The switch itself was made there — the token retired,
912
+ # the pending requests ended — but the scope state cached HERE
913
+ # (the resource metadata that advertised the previous server's scopes,
914
+ # the scopes it supported, the scope this provider last asked it for)
915
+ # would otherwise be carried into a request to the new server: asking B
916
+ # for A's scopes, which B may refuse outright or answer with permissions
917
+ # that do not cover the operation.
918
+ # @return [void]
919
+ def forget_foreign_server_switch
920
+ return unless @state_issuer
921
+
922
+ current = stored_server_metadata&.issuer
923
+ return if current.nil? || current == @state_issuer
924
+
925
+ logger.debug('The authorization server changed in shared storage; discarding the scopes of the previous one')
926
+ @state_issuer = nil
927
+ @supported_scopes = nil
928
+ @resource_metadata = nil
929
+ @requested_scope = nil
930
+ # What the new server advertises for this resource has never been read
931
+ # here: the cached entry another provider wrote says which server it
932
+ # is, not which scopes it offers. One rediscovery pass fetches the
933
+ # protected resource metadata again, so the next request asks for
934
+ # scopes this server actually advertises rather than for none.
935
+ @rediscover_after_switch = true
936
+ end
937
+
938
+ # Note the authorization server the state resolved here belongs to, so a
939
+ # change another provider makes in shared storage is recognised.
940
+ # @param metadata [ServerMetadata, nil] the metadata this resolution settled on
941
+ # @return [ServerMetadata, nil] the metadata, unchanged
942
+ def note_state_issuer(metadata)
943
+ @state_issuer = metadata.issuer if metadata.respond_to?(:issuer) && metadata.issuer
944
+ @rediscover_after_switch = nil
945
+ metadata
431
946
  end
432
947
 
433
948
  # Discover authorization server metadata, validate it, and cache it.
434
949
  # @return [ServerMetadata]
435
950
  # @raise [MCPClient::Errors::ConnectionError] if discovery or validation fails
436
951
  def discover_and_cache_authorization_server
437
- previous = storage.get_server_metadata(server_url)
952
+ previous = stored_server_metadata
438
953
 
439
954
  # RFC 9728: Protected Resource Metadata is authoritative — try it first,
440
955
  # then fall back to treating the MCP server origin as its own AS.
@@ -448,14 +963,52 @@ module MCPClient
448
963
  # Validate BEFORE caching so invalid metadata is never persisted.
449
964
  validate_server_metadata!(server_metadata)
450
965
 
451
- invalidate_client_info_on_as_change(previous, server_metadata)
966
+ if previous
967
+ invalidate_client_info_on_as_change(previous, server_metadata)
968
+ else
969
+ retire_records_without_issuer
970
+ end
452
971
 
453
972
  storage.set_server_metadata(server_url, server_metadata)
973
+ # Remembered in-process as well: a backend that does not persist
974
+ # metadata would otherwise never know the current issuer.
975
+ @discovered_server_metadata = server_metadata
454
976
  @challenge_resource_metadata = nil # consumed
455
977
  @challenge_metadata_url = nil # consumed
978
+ @challenge_error = nil
456
979
  server_metadata
457
980
  end
458
981
 
982
+ # Records persisted before issuers were recorded, with no cached
983
+ # authorization server to prove where they came from, cannot be bound
984
+ # to whatever discovery finds now: a dynamic registration (with its
985
+ # secret) and a token are retired so the flow re-registers and
986
+ # re-authorizes. A portable Client ID Metadata Document id is valid at
987
+ # every authorization server, so it needs no binding; credentials the
988
+ # host pre-registered are kept — this client cannot re-create them —
989
+ # but stay unbound until the host says which authorization server
990
+ # issued them (see RegistrationStore#unbound_static?).
991
+ # @return [void]
992
+ def retire_records_without_issuer
993
+ token = stored_token
994
+ delete_token(bind_to: Token::RETIRED_ISSUER) if token.respond_to?(:issuer) && token.issuer.nil?
995
+
996
+ client_info = stored_client_info
997
+ return unless client_info.respond_to?(:issuer) && client_info.issuer.nil?
998
+ return if portable_client?(client_info) || resolved_registration_type(client_info) != 'dynamic'
999
+
1000
+ logger.debug('Discarding a dynamic OAuth client registration whose authorization server is unknown')
1001
+ # Stamped as retired first, so a backend that cannot delete still
1002
+ # leaves a record no authorization server matches.
1003
+ begin
1004
+ storage.set_client_info(server_url, client_info.with_issuer(Token::RETIRED_ISSUER,
1005
+ registration_type: 'dynamic'))
1006
+ rescue StandardError => e
1007
+ logger.debug("The stale OAuth client registration could not be re-stored as retired (#{e.class})")
1008
+ end
1009
+ delete_client_info
1010
+ end
1011
+
459
1012
  # When a 401 challenge changes the authorization server, per-AS state cached
460
1013
  # under this server_url becomes invalid: a client_id registered with the
461
1014
  # previous AS would fail as invalid_client, and memoized scopes belong to
@@ -465,17 +1018,63 @@ module MCPClient
465
1018
  def invalidate_client_info_on_as_change(previous, current)
466
1019
  return unless previous && previous.issuer != current.issuer
467
1020
 
468
- logger.debug('Authorization server changed; discarding client and scopes from the previous AS')
1021
+ logger.debug('Authorization server changed; discarding the token and scopes from the previous AS')
469
1022
 
470
- # Prefer an explicit delete; fall back to the always-available
471
- # set_client_info(nil) so custom storage backends are handled too.
472
- if storage.respond_to?(:delete_client_info)
473
- storage.delete_client_info(server_url)
474
- else
475
- storage.set_client_info(server_url, nil)
1023
+ # Registration state is per authorization server: a token from the
1024
+ # previous one is not valid for the new one. Credentials stored
1025
+ # without a binding belonged to the previous server, so they are
1026
+ # bound to it first; then they are kept only when bound
1027
+ # (pre-registered, so the mismatch can be reported) or portable
1028
+ # (Client ID Metadata Document), and a dynamic registration is
1029
+ # discarded so the next flow re-registers.
1030
+ @authorization_server_switched = true
1031
+ @supported_scopes = nil
1032
+ # What was requested of the previous authorization server is not a
1033
+ # permission the new one ever granted: the step-up union starts again
1034
+ # rather than asking B for A's scopes.
1035
+ @requested_scope = nil
1036
+ # Records another provider sharing the storage already bound to the
1037
+ # new server are its: only unbound ones and those of the previous
1038
+ # server are affected.
1039
+ withdraw_token(previous.issuer) unless record_bound_to?(stored_token_or_nil, current.issuer)
1040
+ client_info = stored_client_info
1041
+ return if record_bound_to?(client_info, current.issuer)
1042
+
1043
+ # An unbound record this client made is one it made with the previous
1044
+ # server; credentials the HOST pre-registered are not, and are left
1045
+ # unbound rather than attributed to a server that may never have
1046
+ # issued them — attributing them would file them under that server's
1047
+ # own key, and a later flow there would send their secret to it.
1048
+ if client_info.respond_to?(:issuer) && client_info.issuer.nil? &&
1049
+ !portable_client?(client_info) && !unbound_static?(client_info)
1050
+ client_info = client_info.with_issuer(previous.issuer,
1051
+ registration_type: resolved_registration_type(client_info))
1052
+ store_client_info(client_info)
476
1053
  end
1054
+ keep = client_info.respond_to?(:portable?) && (portable_client?(client_info) || client_info.pre_registered?)
1055
+ return if keep
1056
+
1057
+ # The registration itself is still valid at the server that made it,
1058
+ # so it is kept under that server's own key; only the answer to
1059
+ # "which credentials does this resource use now" is discarded.
1060
+ preserve_client_registration(client_info)
1061
+ delete_client_info
1062
+ end
477
1063
 
478
- @supported_scopes = nil
1064
+ # @param record [Token, ClientInfo, Object, nil]
1065
+ # @param issuer [String]
1066
+ # @return [Boolean] whether the record says it belongs to that issuer
1067
+ def record_bound_to?(record, issuer)
1068
+ record.respond_to?(:issuer) && record.issuer == issuer
1069
+ end
1070
+
1071
+ # The stored token, or nil when the backend cannot even be asked
1072
+ # (a minimal backend without get_token, tolerated as before).
1073
+ # @return [Token, nil]
1074
+ def stored_token_or_nil
1075
+ stored_token
1076
+ rescue StandardError
1077
+ nil
479
1078
  end
480
1079
 
481
1080
  # Apply the PKCE-support and HTTPS-endpoint checks to server metadata.
@@ -498,15 +1097,38 @@ module MCPClient
498
1097
  resource_metadata = challenge_or_well_known_resource_metadata
499
1098
  return nil unless resource_metadata
500
1099
 
501
- validate_resource_matches!(resource_metadata)
1100
+ # A document that is not this resource's — or that names no
1101
+ # authorization server — is rejected whole, and the copy
1102
+ # {#fetch_resource_metadata} kept for scope resolution goes with it:
1103
+ # otherwise a later flow (the path 404s, the origin is its own
1104
+ # authorization server) would request the scopes of a document this
1105
+ # discovery refused, exactly the leftover already closed for a refused
1106
+ # authorization server URL.
1107
+ begin
1108
+ validate_resource_matches!(resource_metadata)
1109
+ rescue MCPClient::Errors::ConnectionError => e
1110
+ refuse_resource_metadata!(e.message)
1111
+ end
502
1112
 
503
1113
  auth_server_url = Array(resource_metadata.authorization_servers).first
504
1114
  unless auth_server_url
505
- raise MCPClient::Errors::ConnectionError,
506
- 'Protected resource metadata does not advertise any authorization_servers'
1115
+ refuse_resource_metadata!('Protected resource metadata does not advertise any authorization_servers')
507
1116
  end
508
1117
 
509
- server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url))
1118
+ # authorization_servers is untrusted PRM content: validate the
1119
+ # advertised origin BEFORE constructing and fetching well-known URLs
1120
+ # on it, so a malicious protected resource cannot drive discovery GETs
1121
+ # against internal services (SSRF).
1122
+ # Only a challenge-advertised document is authoritative enough for a
1123
+ # refusal to latch: a speculative well-known document that is refused
1124
+ # now must be fetched again by the next discovery, so a server the
1125
+ # operator fixes is not unreachable for the life of this provider.
1126
+ validate_peer_advertised_url!(auth_server_url,
1127
+ 'authorization server (advertised by protected resource metadata)',
1128
+ latch: challenge_advertised_metadata?)
1129
+
1130
+ server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url),
1131
+ auth_server_url)
510
1132
  unless server_metadata
511
1133
  raise MCPClient::Errors::ConnectionError,
512
1134
  "Authorization server advertised by protected resource metadata (#{auth_server_url}) " \
@@ -540,7 +1162,7 @@ module MCPClient
540
1162
  # @return [ServerMetadata, nil]
541
1163
  def discover_via_direct_authorization_server
542
1164
  origin = origin_of(URI.parse(server_url))
543
- fetch_first_server_metadata(authorization_server_metadata_urls(origin))
1165
+ fetch_first_server_metadata(authorization_server_metadata_urls(origin), origin)
544
1166
  end
545
1167
 
546
1168
  # Fetch the first Protected Resource Metadata document that resolves.
@@ -563,21 +1185,57 @@ module MCPClient
563
1185
  # Fetch the first Authorization Server Metadata document that resolves.
564
1186
  # The oauth-authorization-server and openid-configuration forms are
565
1187
  # genuine alternatives, so any failing candidate is skipped to try the next.
566
- # @param urls [Array<String>] candidate URLs
1188
+ # @param urls [Array<String>] well-known candidates
1189
+ # @param issuer [String] the issuer identifier the candidates were built from
567
1190
  # @return [ServerMetadata, nil]
568
- def fetch_first_server_metadata(urls)
1191
+ def fetch_first_server_metadata(urls, issuer)
1192
+ rejected = nil
569
1193
  urls.each do |url|
570
1194
  md = try_fetch_server_metadata(url)
571
- return md if md
1195
+ next unless md
1196
+
1197
+ begin
1198
+ validate_metadata_issuer!(md, issuer)
1199
+ rescue MCPClient::Errors::ConnectionError => e
1200
+ # Not this candidate: the next well-known location may hold the
1201
+ # document for the issuer actually asked for.
1202
+ logger.debug("Authorization server metadata candidate rejected (#{safe_error_text(url.to_s)}): " \
1203
+ "#{e.message}")
1204
+ rejected = e
1205
+ next
1206
+ end
1207
+ return md
572
1208
  end
1209
+ # Only mismatching documents were found: say so rather than "nothing".
1210
+ raise rejected if rejected
1211
+
573
1212
  nil
574
1213
  end
575
1214
 
1215
+ # RFC 8414 Section 3.3 / OpenID Connect Discovery 4.3 (MCP 2026-07-28
1216
+ # "Authorization Server Metadata Discovery"): "the issuer value in the
1217
+ # document MUST be identical to the issuer identifier used to construct
1218
+ # the well-known URL. If they differ, the client MUST NOT use the
1219
+ # metadata." The issuer is the trust anchor of the RFC 9207 check, so a
1220
+ # document naming another issuer is rejected outright.
1221
+ # @param metadata [ServerMetadata]
1222
+ # @param issuer [String]
1223
+ # @raise [MCPClient::Errors::ConnectionError]
1224
+ def validate_metadata_issuer!(metadata, issuer)
1225
+ # Byte-for-byte (RFC 8414 Section 4): no case folding, no slash or
1226
+ # port normalization.
1227
+ return if metadata.issuer.is_a?(String) && metadata.issuer == issuer.to_s
1228
+
1229
+ raise MCPClient::Errors::ConnectionError,
1230
+ "Authorization server metadata rejected: its issuer #{safe_error_text(metadata.issuer.to_s).inspect} " \
1231
+ "is not the identifier it was fetched for (#{safe_error_text(issuer.to_s)})"
1232
+ end
1233
+
576
1234
  # Non-raising server-metadata fetch used while iterating candidates.
577
1235
  def try_fetch_server_metadata(url)
578
1236
  fetch_server_metadata(url)
579
1237
  rescue MCPClient::Errors::ConnectionError => e
580
- logger.debug("Authorization server metadata candidate failed (#{url}): #{e.message}")
1238
+ logger.debug("Authorization server metadata candidate failed (#{safe_error_text(url.to_s)}): #{e.message}")
581
1239
  nil
582
1240
  end
583
1241
 
@@ -594,6 +1252,14 @@ module MCPClient
594
1252
  raise MCPClient::Errors::ConnectionError,
595
1253
  'Authorization server metadata omits code_challenge_methods_supported; ' \
596
1254
  'the server does not support PKCE and the client must refuse to proceed'
1255
+ elsif !methods.is_a?(Array)
1256
+ # A String answers `include?('S256')` for "S256 not supported": read
1257
+ # that way, a server with no PKCE at all would pass this check. The
1258
+ # wire path refuses such a document outright; a record read back
1259
+ # from a hash-persisting storage backend arrives here instead.
1260
+ raise MCPClient::Errors::ConnectionError,
1261
+ 'Authorization server metadata code_challenge_methods_supported is not an array of strings ' \
1262
+ '(RFC 8414); PKCE support cannot be established and the client must refuse to proceed'
597
1263
  elsif !methods.include?('S256')
598
1264
  raise MCPClient::Errors::ConnectionError,
599
1265
  'Authorization server does not support PKCE S256 ' \
@@ -601,8 +1267,8 @@ module MCPClient
601
1267
  end
602
1268
  end
603
1269
 
604
- # Require HTTPS for all discovered authorization server endpoints, with a
605
- # localhost exception for local development.
1270
+ # Require HTTPS for all discovered authorization server endpoints, with
1271
+ # the local-development exception for a local stack.
606
1272
  # @param server_metadata [ServerMetadata]
607
1273
  def enforce_https_endpoints!(server_metadata)
608
1274
  enforce_https!(server_metadata.authorization_endpoint, 'authorization endpoint')
@@ -612,22 +1278,34 @@ module MCPClient
612
1278
  enforce_https!(server_metadata.registration_endpoint, 'registration endpoint')
613
1279
  end
614
1280
 
1281
+ # An endpoint discovered in authorization server metadata is as
1282
+ # peer-controlled as a URL a challenge or a protected resource document
1283
+ # advertises — the code, the verifier and the client id are POSTed to
1284
+ # the token endpoint — so it is classified by exactly the same rules:
1285
+ # HTTPS unless this is a local stack (a loopback target advertised to a
1286
+ # client whose configured server is loopback too), never a loopback,
1287
+ # private or link-local address otherwise, and always a host a resolver
1288
+ # could look up. Without that, metadata from any public authorization
1289
+ # server could name `http://app.localhost:3000/steal` or an internal
1290
+ # address and collect the authorization code there. The refusal is not
1291
+ # latched: only a 401 challenge is authoritative enough for that.
615
1292
  # @param url [String, nil] endpoint URL
616
1293
  # @param label [String] human-readable endpoint name for errors
617
- # @raise [MCPClient::Errors::ConnectionError] if the URL is not HTTPS (non-localhost)
1294
+ # @raise [MCPClient::Errors::ConnectionError] if the URL is not acceptable
618
1295
  def enforce_https!(url, label)
619
1296
  return if url.nil?
620
1297
 
621
- uri = URI.parse(url)
622
- return if uri.scheme == 'https'
623
- # Dev exception is only for plain HTTP on a loopback host — not any other
624
- # scheme (e.g. ftp://localhost). Use #hostname (not #host) so an IPv6
625
- # loopback like http://[::1]:9292 matches without the surrounding brackets.
626
- return if uri.scheme == 'http' && %w[localhost 127.0.0.1 ::1].include?(uri.hostname)
1298
+ validate_peer_advertised_url!(url, label, latch: false)
1299
+ end
627
1300
 
628
- raise MCPClient::Errors::ConnectionError, "OAuth #{label} must use HTTPS: #{url}"
629
- rescue URI::InvalidURIError
630
- raise MCPClient::Errors::ConnectionError, "OAuth #{label} is not a valid URL: #{url}"
1301
+ # Forget the protected resource document and refuse: a document rejected
1302
+ # as not this resource's must not go on supplying scopes.
1303
+ # @param message [String] why the document was refused
1304
+ # @return [void]
1305
+ # @raise [MCPClient::Errors::ConnectionError] always
1306
+ def refuse_resource_metadata!(message)
1307
+ @resource_metadata = nil
1308
+ raise MCPClient::Errors::ConnectionError, message
631
1309
  end
632
1310
 
633
1311
  # Validate the PRM `resource` identifies this server (RFC 9728 confused
@@ -651,7 +1329,7 @@ module MCPClient
651
1329
  return if advertised == expected
652
1330
 
653
1331
  raise MCPClient::Errors::ConnectionError,
654
- "Protected resource metadata resource (#{resource_metadata.resource}) " \
1332
+ "Protected resource metadata resource (#{safe_error_text(resource_metadata.resource.to_s)}) " \
655
1333
  "does not match the server URL (#{server_url})"
656
1334
  end
657
1335
 
@@ -667,7 +1345,8 @@ module MCPClient
667
1345
  def resource_identity(url)
668
1346
  uri = URI.parse(url)
669
1347
  unless uri.scheme && uri.host
670
- raise MCPClient::Errors::ConnectionError, "Invalid resource URL (must be absolute): #{url}"
1348
+ raise MCPClient::Errors::ConnectionError,
1349
+ "Invalid resource URL (must be absolute): #{safe_error_text(url.to_s)}"
671
1350
  end
672
1351
 
673
1352
  scheme = uri.scheme.downcase
@@ -681,7 +1360,7 @@ module MCPClient
681
1360
 
682
1361
  "#{scheme}://#{host}#{":#{port}" if port}#{path}#{query}"
683
1362
  rescue URI::InvalidURIError
684
- raise MCPClient::Errors::ConnectionError, "Invalid resource URL: #{url}"
1363
+ raise MCPClient::Errors::ConnectionError, "Invalid resource URL: #{safe_error_text(url.to_s)}"
685
1364
  end
686
1365
 
687
1366
  # Fetch resource metadata from URL.
@@ -697,7 +1376,7 @@ module MCPClient
697
1376
  # @return [ResourceMetadata, nil] metadata, or nil if a speculative URL returns 404
698
1377
  # @raise [MCPClient::Errors::ConnectionError] on any non-404 failure, or on 404 when strict
699
1378
  def fetch_resource_metadata(url, strict: false)
700
- logger.debug("Fetching resource metadata from: #{url}")
1379
+ logger.debug("Fetching resource metadata from: #{safe_error_text(url.to_s)}")
701
1380
 
702
1381
  response = @http_client.get(url) do |req|
703
1382
  req.headers['Accept'] = 'application/json'
@@ -710,15 +1389,29 @@ module MCPClient
710
1389
  end
711
1390
 
712
1391
  data = JSON.parse(response.body)
1392
+ raise MCPClient::Errors::ConnectionError, 'Invalid resource metadata: not a JSON object' unless data.is_a?(Hash)
1393
+
1394
+ # RFC 9728 Section 2 gives every field a type, and this client acts on
1395
+ # them: a `scopes_supported` that is not an array of strings would be
1396
+ # joined into a scope parameter (or crash trying), an
1397
+ # `authorization_servers` that is not one would drive discovery at
1398
+ # whatever `Array()` made of it. A document that breaks a type is a
1399
+ # protocol error, not metadata.
1400
+ if (error = resource_metadata_error(data))
1401
+ raise MCPClient::Errors::ConnectionError, "Invalid resource metadata: #{error}"
1402
+ end
1403
+
713
1404
  metadata = ResourceMetadata.from_h(data)
714
1405
  # Retain the most recently fetched PRM so scope resolution can fall
715
1406
  # back to its scopes_supported (MCP scope selection priority 2).
716
1407
  @resource_metadata = metadata
717
1408
  metadata
718
1409
  rescue JSON::ParserError => e
719
- raise MCPClient::Errors::ConnectionError, "Invalid resource metadata JSON: #{e.message}"
1410
+ raise MCPClient::Errors::ConnectionError,
1411
+ "Invalid resource metadata JSON: #{describe_parse_error(e, response&.body)}"
720
1412
  rescue Faraday::Error => e
721
- raise MCPClient::Errors::ConnectionError, "Network error fetching resource metadata: #{e.message}"
1413
+ raise MCPClient::Errors::ConnectionError,
1414
+ "Network error fetching resource metadata: #{safe_error_text(e.message)}"
722
1415
  end
723
1416
 
724
1417
  # Fetch authorization server metadata from URL
@@ -726,7 +1419,7 @@ module MCPClient
726
1419
  # @return [ServerMetadata] Server metadata
727
1420
  # @raise [MCPClient::Errors::ConnectionError] if fetch fails
728
1421
  def fetch_server_metadata(url)
729
- logger.debug("Fetching server metadata from: #{url}")
1422
+ logger.debug("Fetching server metadata from: #{safe_error_text(url.to_s)}")
730
1423
 
731
1424
  response = @http_client.get(url) do |req|
732
1425
  req.headers['Accept'] = 'application/json'
@@ -737,68 +1430,72 @@ module MCPClient
737
1430
  end
738
1431
 
739
1432
  data = JSON.parse(response.body)
740
- ServerMetadata.from_h(data)
1433
+ raise MCPClient::Errors::ConnectionError, 'Invalid server metadata: not a JSON object' unless data.is_a?(Hash)
1434
+
1435
+ # RFC 8414 Section 2 gives every field a type, and a document that
1436
+ # breaks one is never usable metadata: an endpoint that is not a
1437
+ # string is not an endpoint, and a `code_challenge_methods_supported`
1438
+ # that is not an array of strings cannot establish PKCE support — a
1439
+ # String would answer `include?("S256")` for "S256 is not supported
1440
+ # here", which is precisely the downgrade the check exists to stop.
1441
+ if (error = server_metadata_error(data))
1442
+ raise MCPClient::Errors::ConnectionError, "Invalid server metadata: #{error}"
1443
+ end
1444
+
1445
+ ServerMetadata.from_discovery_document(data)
741
1446
  rescue JSON::ParserError => e
742
- raise MCPClient::Errors::ConnectionError, "Invalid server metadata JSON: #{e.message}"
1447
+ raise MCPClient::Errors::ConnectionError,
1448
+ "Invalid server metadata JSON: #{describe_parse_error(e, response&.body)}"
743
1449
  rescue Faraday::Error => e
744
- raise MCPClient::Errors::ConnectionError, "Network error fetching server metadata: #{e.message}"
1450
+ raise MCPClient::Errors::ConnectionError,
1451
+ "Network error fetching server metadata: #{safe_error_text(e.message)}"
745
1452
  end
746
1453
 
747
- # Get or register OAuth client, following the MCP 2025-11-25 client
748
- # registration priority order: pre-registered/cached client information
749
- # first, then Client ID Metadata Documents (SEP-991) when the
750
- # authorization server advertises support and a metadata URL is
751
- # configured, then Dynamic Client Registration as a fallback.
752
- # @param server_metadata [ServerMetadata] Authorization server metadata
753
- # @return [ClientInfo] Client information
754
- # @raise [MCPClient::Errors::ConnectionError] if registration fails
755
- def get_or_register_client(server_metadata)
756
- # 1. Pre-registered or previously registered client info from storage
757
- if (client_info = storage.get_client_info(server_url)) && !client_info.client_secret_expired?
758
- logger.debug("Using cached OAuth client for #{server_url}")
759
- return client_info
760
- end
761
-
762
- # 2. Client ID Metadata Documents (SEP-991): the HTTPS metadata URL is
763
- # itself the client_id — no registration request is needed.
764
- if client_id_metadata_url && server_metadata.supports_client_id_metadata_documents?
765
- return client_info_from_metadata_url
766
- end
767
-
768
- # 3. Dynamic Client Registration (RFC 7591) fallback
769
- logger.debug('No cached client found, registering new OAuth client...')
770
- if server_metadata.supports_registration?
771
- register_client(server_metadata)
772
- else
773
- raise MCPClient::Errors::ConnectionError,
774
- 'Dynamic client registration not supported and no client credentials found'
775
- end
1454
+ # Drop every piece of in-process state that belongs to one MCP server,
1455
+ # so a retargeted provider discovers the new one from scratch:
1456
+ #
1457
+ # * the discovered-metadata fallback and the memoized scopes, which name
1458
+ # the previous server's authorization server and what it advertises;
1459
+ # * the challenge state — an adopted document, a URL whose fetch is
1460
+ # still pending, a latched refusal and the scope the challenge asked
1461
+ # for — all of it said by the previous server's 401;
1462
+ # * the resource metadata kept for scope resolution;
1463
+ # * the "the authorization server changed" flag, which is a fact about
1464
+ # the previous server's history.
1465
+ #
1466
+ # Retirement markers are deliberately kept: they are keyed by the issuer
1467
+ # the bytes were retired for, not by the resource URL, so they stay true
1468
+ # when two MCP servers sit behind one authorization server.
1469
+ # @return [void]
1470
+ def forget_per_server_state
1471
+ @discovered_server_metadata = nil
1472
+ @supported_scopes = nil
1473
+ @resource_metadata = nil
1474
+ @challenge_resource_metadata = nil
1475
+ @challenge_metadata_url = nil
1476
+ @challenge_error = nil
1477
+ @challenge_scope = nil
1478
+ @requested_scope = nil
1479
+ @authorization_server_switched = nil
776
1480
  end
777
1481
 
778
- # Build client information for a Client ID Metadata Document client
779
- # (SEP-991): the configured HTTPS metadata URL is used directly as the
780
- # client_id in authorization and token requests, without a dynamic
781
- # registration POST. Serving the metadata JSON at that URL is the
782
- # application's responsibility, not this library's.
783
- # @return [ClientInfo] Client information with the metadata URL as client_id
784
- def client_info_from_metadata_url
785
- logger.debug("Using Client ID Metadata Document URL as client_id: #{client_id_metadata_url}")
786
-
787
- metadata = ClientMetadata.new(
788
- redirect_uris: [redirect_uri],
789
- token_endpoint_auth_method: 'none', # Public client
790
- grant_types: %w[authorization_code refresh_token],
791
- response_types: ['code'],
792
- scope: resolved_scope,
793
- **@extra_client_metadata
794
- )
795
-
796
- client_info = ClientInfo.new(client_id: client_id_metadata_url, metadata: metadata)
1482
+ # Storage backends may persist plain hashes (the FileTokenStorage
1483
+ # example does); records are normalized before any field is read.
1484
+ # @return [ServerMetadata, nil]
1485
+ def stored_server_metadata
1486
+ normalize_record(storage.get_server_metadata(server_url), ServerMetadata) || @discovered_server_metadata
1487
+ end
797
1488
 
798
- # Persist so complete_authorization_flow and token refresh can find it
799
- storage.set_client_info(server_url, client_info)
1489
+ # @return [PKCE, nil]
1490
+ def stored_pkce
1491
+ normalize_record(storage.get_pkce(server_url), PKCE)
1492
+ end
800
1493
 
801
- client_info
1494
+ # @param record [Object, Hash, nil]
1495
+ # @param klass [Class] a record class responding to from_h
1496
+ # @return [Object, nil]
1497
+ def normalize_record(record, klass)
1498
+ record.is_a?(Hash) ? klass.from_h(record) : record
802
1499
  end
803
1500
 
804
1501
  # Validate a Client ID Metadata Document URL (SEP-991): "The client_id
@@ -837,38 +1534,43 @@ module MCPClient
837
1534
  end
838
1535
 
839
1536
  # Register OAuth client dynamically
1537
+ # @deprecated Dynamic Client Registration is deprecated since MCP 2026-07-28
1538
+ # (PR #2858); earliest removal is the first revision released on or after
1539
+ # 2027-07-28. Prefer a Client ID Metadata Document (client_id_metadata_url)
1540
+ # or pre-registered credentials. It remains the fallback for authorization
1541
+ # servers without Client ID Metadata Document support.
840
1542
  # @param server_metadata [ServerMetadata] Authorization server metadata
841
1543
  # @return [ClientInfo] Registered client information
842
1544
  # @raise [MCPClient::Errors::ConnectionError] if registration fails
843
1545
  def register_client(server_metadata)
844
- logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")
845
-
846
- metadata = ClientMetadata.new(
847
- redirect_uris: [redirect_uri],
848
- token_endpoint_auth_method: 'none', # Public client
849
- grant_types: %w[authorization_code refresh_token],
850
- response_types: ['code'],
851
- scope: resolved_scope,
852
- **@extra_client_metadata
853
- )
854
-
855
- response = @http_client.post(server_metadata.registration_endpoint) do |req|
856
- req.headers['Content-Type'] = 'application/json'
857
- req.headers['Accept'] = 'application/json'
858
- req.body = metadata.to_h.to_json
1546
+ MCPClient::Deprecations.warn(:dynamic_client_registration, logger)
1547
+ logger.debug("Registering OAuth client at: #{safe_error_text(server_metadata.registration_endpoint.to_s)}")
1548
+
1549
+ app_type = resolved_application_type
1550
+ response = post_registration(server_metadata, app_type)
1551
+
1552
+ # "Clients MAY retry registration with an adjusted application_type"
1553
+ # when an OIDC server rejects the redirect URI for the type derived
1554
+ # here (never for one the host chose explicitly).
1555
+ if !response.success? && application_type.nil? &&
1556
+ registration_error(response)[:error] == 'invalid_redirect_uri'
1557
+ alternate = app_type == 'native' ? 'web' : 'native'
1558
+ logger.warn("Client registration rejected the redirect URI for application_type #{app_type}; " \
1559
+ "retrying as #{alternate}")
1560
+ app_type = alternate
1561
+ response = post_registration(server_metadata, app_type)
859
1562
  end
860
1563
 
861
- unless response.success?
862
- raise MCPClient::Errors::ConnectionError, "Client registration failed: HTTP #{response.status}"
863
- end
1564
+ raise_registration_failure!(response) unless response.success?
864
1565
 
865
1566
  data = JSON.parse(response.body)
866
- logger.debug("OAuth client registered successfully: #{data['client_id']}")
1567
+ client_id = registered_client_id!(data)
1568
+ logger.debug("OAuth client registered successfully: #{safe_error_text(client_id)}")
867
1569
 
868
1570
  # Parse registered metadata from server response (may differ from our request)
869
1571
  registered_metadata = ClientMetadata.new(
870
- redirect_uris: data['redirect_uris'] || [redirect_uri],
871
- token_endpoint_auth_method: data['token_endpoint_auth_method'] || 'none',
1572
+ redirect_uris: registered_redirect_uris(data),
1573
+ token_endpoint_auth_method: registered_auth_method(data),
872
1574
  grant_types: data['grant_types'] || %w[authorization_code refresh_token],
873
1575
  response_types: data['response_types'] || ['code'],
874
1576
  scope: data['scope'],
@@ -877,35 +1579,154 @@ module MCPClient
877
1579
  logo_uri: data['logo_uri'],
878
1580
  tos_uri: data['tos_uri'],
879
1581
  policy_uri: data['policy_uri'],
880
- contacts: data['contacts']
1582
+ contacts: data['contacts'],
1583
+ application_type: data['application_type'] || app_type
881
1584
  )
882
1585
 
883
- # Warn if server changed redirect_uri
884
- requested_uri = redirect_uri
885
- registered_uri = registered_metadata.redirect_uris.first
886
- if registered_uri != requested_uri
887
- logger.warn('OAuth server changed redirect_uri:')
888
- logger.warn(" Requested: #{requested_uri}")
889
- logger.warn(" Registered: #{registered_uri}")
890
- logger.warn("Using server's registered redirect_uri for token exchange.")
891
- end
1586
+ warn_registered_redirect_uri(registered_metadata)
892
1587
 
893
1588
  client_info = ClientInfo.new(
894
- client_id: data['client_id'],
1589
+ client_id: client_id,
895
1590
  client_secret: data['client_secret'],
896
1591
  client_id_issued_at: data['client_id_issued_at'],
897
1592
  client_secret_expires_at: data['client_secret_expires_at'],
898
- metadata: registered_metadata
1593
+ metadata: registered_metadata,
1594
+ # Bound to the authorization server that issued the credentials
1595
+ issuer: server_metadata.issuer,
1596
+ registration_type: 'dynamic'
899
1597
  )
900
1598
 
901
1599
  # Store client info
902
- storage.set_client_info(server_url, client_info)
1600
+ store_client_info(client_info)
903
1601
 
904
1602
  client_info
905
1603
  rescue JSON::ParserError => e
906
- raise MCPClient::Errors::ConnectionError, "Invalid client registration response: #{e.message}"
1604
+ raise MCPClient::Errors::ConnectionError,
1605
+ "Invalid client registration response: #{describe_parse_error(e, response&.body)}"
907
1606
  rescue Faraday::Error => e
908
- raise MCPClient::Errors::ConnectionError, "Network error during client registration: #{e.message}"
1607
+ raise MCPClient::Errors::ConnectionError,
1608
+ "Network error during client registration: #{safe_error_text(e.message)}"
1609
+ end
1610
+
1611
+ # The authorization server may register a redirect URI other than the
1612
+ # one asked for; the registered value is what the token exchange must
1613
+ # then present (RFC 6749 Section 4.1.3), so say so.
1614
+ # @param registered_metadata [ClientMetadata] the metadata as registered
1615
+ # @return [void]
1616
+ def warn_registered_redirect_uri(registered_metadata)
1617
+ registered_uri = registered_metadata.redirect_uris.first
1618
+ return if registered_uri == redirect_uri
1619
+
1620
+ logger.warn('OAuth server changed redirect_uri:')
1621
+ logger.warn(" Requested: #{redirect_uri}")
1622
+ logger.warn(" Registered: #{safe_error_text(registered_uri.to_s)}")
1623
+ logger.warn("Using server's registered redirect_uri for token exchange.")
1624
+ end
1625
+
1626
+ # The client id of a registration response, once the response has been
1627
+ # checked field by field against the types RFC 7591 gives them: a
1628
+ # response that names no client has registered nothing, and one whose
1629
+ # fields are of other JSON types would be stored and only crash later —
1630
+ # a `redirect_uris` string asked for its `first`, a
1631
+ # `client_secret_expires_at` string compared with a Unix timestamp. A
1632
+ # registration response is refused here exactly as a token response
1633
+ # without an access token is, before the browser is ever opened.
1634
+ # @param data [Object, nil] the parsed JSON registration response
1635
+ # @return [String] the registered client id
1636
+ # @raise [MCPClient::Errors::ConnectionError] when the response registers no usable client
1637
+ def registered_client_id!(data)
1638
+ if (error = registration_response_error(data))
1639
+ raise MCPClient::Errors::ConnectionError, "Client registration failed: #{error}"
1640
+ end
1641
+
1642
+ data['client_id']
1643
+ end
1644
+
1645
+ # The redirect URIs a registration response registered, defaulting to
1646
+ # the one the registration request asked for when the server echoes
1647
+ # none back (RFC 7591 Section 2 makes redirect_uris an array of
1648
+ # strings; its type is checked before this runs).
1649
+ # @param data [Hash] the parsed JSON registration response
1650
+ # @return [Array<String>]
1651
+ def registered_redirect_uris(data)
1652
+ uris = data['redirect_uris']
1653
+ uris.is_a?(Array) && !uris.empty? ? uris : [redirect_uri]
1654
+ end
1655
+
1656
+ # One Dynamic Client Registration request.
1657
+ # @param server_metadata [ServerMetadata]
1658
+ # @param app_type [String] the application_type to declare
1659
+ # @return [Faraday::Response]
1660
+ def post_registration(server_metadata, app_type)
1661
+ metadata = ClientMetadata.new(
1662
+ redirect_uris: [redirect_uri],
1663
+ token_endpoint_auth_method: 'none', # Public client
1664
+ grant_types: %w[authorization_code refresh_token],
1665
+ response_types: ['code'],
1666
+ scope: resolved_scope,
1667
+ application_type: app_type,
1668
+ **@extra_client_metadata
1669
+ )
1670
+
1671
+ @http_client.post(server_metadata.registration_endpoint) do |req|
1672
+ req.headers['Content-Type'] = 'application/json'
1673
+ req.headers['Accept'] = 'application/json'
1674
+ req.body = metadata.to_h.to_json
1675
+ end
1676
+ end
1677
+
1678
+ # "When a registration request is rejected, clients SHOULD surface a
1679
+ # meaningful error": the RFC 7591 error and description, when given.
1680
+ # @param response [Faraday::Response] the failed registration response
1681
+ # @raise [MCPClient::Errors::ConnectionError]
1682
+ def raise_registration_failure!(response)
1683
+ error = registration_error(response)
1684
+ detail = [error[:error], error[:error_description]].compact.join(': ')
1685
+ raise MCPClient::Errors::ConnectionError,
1686
+ "Client registration failed: HTTP #{response.status}#{" (#{detail})" unless detail.empty?}"
1687
+ end
1688
+
1689
+ # The application_type to register: the host's explicit choice, else
1690
+ # 'native' for loopback and custom-scheme redirect URIs (desktop, CLI,
1691
+ # mobile, locally hosted apps) and 'web' for a remote redirect URI
1692
+ # (MCP 2026-07-28 "Application Type and Redirect URI Constraints").
1693
+ # @return [String]
1694
+ def resolved_application_type
1695
+ return application_type if application_type
1696
+
1697
+ uri = URI.parse(redirect_uri.to_s)
1698
+ return 'native' unless %w[http https].include?(uri.scheme.to_s.downcase)
1699
+
1700
+ loopback_host?(uri.host) ? 'native' : 'web'
1701
+ rescue URI::InvalidURIError
1702
+ 'native'
1703
+ end
1704
+
1705
+ # Whether a redirect URI host is a loopback interface: 'localhost', an
1706
+ # RFC 6761 '*.localhost' name (puma-dev, Caddy), or any loopback address
1707
+ # (127.0.0.0/8, ::1, in any spelling). The host is read the way a
1708
+ # resolver reads it — decoded, unbracketed, undotted, and through the
1709
+ # shorthand IPv4 parser — so '127.1', '0x7f.0.0.1', '127.0.0.1.' and
1710
+ # 'app.localhost' register as native like the plain spelling, instead of
1711
+ # being sent for registration as a web client whose HTTP redirect URI the
1712
+ # authorization server may then reject.
1713
+ # @param host [String, nil]
1714
+ # @return [Boolean]
1715
+ def loopback_host?(host)
1716
+ loopback_address?(host.to_s)
1717
+ end
1718
+
1719
+ # The RFC 7591 error of a failed registration response, sanitized for
1720
+ # a log line or exception message.
1721
+ # @param response [Faraday::Response]
1722
+ # @return [Hash] :error and :error_description (nil when absent)
1723
+ def registration_error(response)
1724
+ body = JSON.parse(response.body.to_s)
1725
+ return {} unless body.is_a?(Hash)
1726
+
1727
+ { error: safe_error_text(body['error']), error_description: safe_error_text(body['error_description']) }
1728
+ rescue JSON::ParserError
1729
+ {}
909
1730
  end
910
1731
 
911
1732
  # Build authorization URL
@@ -922,7 +1743,7 @@ module MCPClient
922
1743
  response_type: 'code',
923
1744
  client_id: client_info.client_id,
924
1745
  redirect_uri: registered_redirect_uri,
925
- scope: resolved_scope,
1746
+ scope: @requested_scope,
926
1747
  state: state,
927
1748
  code_challenge: pkce.code_challenge,
928
1749
  code_challenge_method: pkce.code_challenge_method,
@@ -930,10 +1751,50 @@ module MCPClient
930
1751
  }.compact
931
1752
 
932
1753
  uri = URI.parse(server_metadata.authorization_endpoint)
933
- uri.query = URI.encode_www_form(params)
1754
+ # "The client directs the resource owner to the constructed URI using
1755
+ # an HTTP redirection response... The endpoint URI MAY include an
1756
+ # application/x-www-form-urlencoded formatted query component, which
1757
+ # MUST be retained when adding additional query parameters" (RFC 6749
1758
+ # Section 3.1). An authorization server that identifies a tenant, a
1759
+ # brand or a locale in its endpoint URL loses that identification if
1760
+ # the query is replaced, and sends the user somewhere else entirely.
1761
+ #
1762
+ # The same section: "Request and response parameters MUST NOT be
1763
+ # included more than once." An endpoint query that already names an
1764
+ # authorization request parameter this client sends — `scope`,
1765
+ # `state`, `client_id`, a PKCE challenge — would otherwise appear
1766
+ # twice, and which of the two the server reads is its own business:
1767
+ # a `scope` of the endpoint URL could widen the consent this request
1768
+ # asks for, and a second `state` or `code_challenge` decides the
1769
+ # checks the callback is held to. This request's own parameters are
1770
+ # therefore the only ones with those names; everything else the
1771
+ # endpoint carries is retained.
1772
+ uri.query = merged_authorization_query(uri.query, params)
934
1773
  uri.to_s
935
1774
  end
936
1775
 
1776
+ # The authorization endpoint's own query with this request's parameters
1777
+ # appended, and with any endpoint parameter this request names dropped
1778
+ # (RFC 6749 Section 3.1 forbids a repeated request parameter).
1779
+ # @param endpoint_query [String, nil] the query of the authorization endpoint URL
1780
+ # @param params [Hash{Symbol => String}] this request's parameters
1781
+ # @return [String] the query string to send
1782
+ def merged_authorization_query(endpoint_query, params)
1783
+ appended = URI.encode_www_form(params)
1784
+ return appended if endpoint_query.to_s.empty?
1785
+
1786
+ kept = URI.decode_www_form(endpoint_query).reject do |name, _value|
1787
+ next false unless AUTHORIZATION_REQUEST_PARAMS.include?(name)
1788
+
1789
+ logger.debug("Dropping #{name.inspect} from the authorization endpoint query: this authorization " \
1790
+ 'request sets it')
1791
+ true
1792
+ end
1793
+ return appended if kept.empty?
1794
+
1795
+ "#{URI.encode_www_form(kept)}&#{appended}"
1796
+ end
1797
+
937
1798
  # Exchange authorization code for access token
938
1799
  # @param server_metadata [ServerMetadata] Server metadata
939
1800
  # @param client_info [ClientInfo] Client information
@@ -944,8 +1805,10 @@ module MCPClient
944
1805
  def exchange_authorization_code(server_metadata, client_info, code, pkce)
945
1806
  logger.debug('Exchanging authorization code for access token')
946
1807
 
947
- # Use the redirect_uri that was actually registered, not our requested one
948
- registered_redirect_uri = client_info.metadata.redirect_uris.first
1808
+ # The redirect_uri the authorization request was made with (recorded
1809
+ # with the PKCE record), else the registered one
1810
+ recorded_redirect_uri = pkce.redirect_uri if pkce.respond_to?(:redirect_uri)
1811
+ registered_redirect_uri = recorded_redirect_uri || client_info.metadata.redirect_uris.first
949
1812
 
950
1813
  params = {
951
1814
  grant_type: 'authorization_code',
@@ -956,10 +1819,10 @@ module MCPClient
956
1819
  resource: server_url
957
1820
  }
958
1821
 
959
- # Add client_secret if required by token_endpoint_auth_method
960
- if client_info.client_secret && client_info.metadata.token_endpoint_auth_method == 'client_secret_post'
961
- params[:client_secret] = client_info.client_secret
962
- end
1822
+ # The credentials go out the way the authorization server registered
1823
+ # them: in the body for client_secret_post, in an Authorization header
1824
+ # for client_secret_basic (RFC 7591's default).
1825
+ authorization = apply_client_credentials!(params, client_info)
963
1826
 
964
1827
  request_body = URI.encode_www_form(params)
965
1828
 
@@ -967,6 +1830,7 @@ module MCPClient
967
1830
  @http_client.post(server_metadata.token_endpoint) do |req|
968
1831
  req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
969
1832
  req.headers['Accept'] = 'application/json'
1833
+ req.headers['Authorization'] = authorization if authorization
970
1834
  req.body = body
971
1835
  end
972
1836
  end
@@ -974,37 +1838,44 @@ module MCPClient
974
1838
  response = send_token_request.call(request_body)
975
1839
 
976
1840
  unless response.success?
977
- redirect_hint = extract_redirect_mismatch(response.body)
978
-
979
- if redirect_hint && redirect_hint[:expected] && redirect_hint[:expected] != registered_redirect_uri
980
- expected_uri = redirect_hint[:expected]
981
- logger.warn(
982
- "Token exchange failed: redirect_uri mismatch. Retrying with server's expected value: #{expected_uri}"
983
- )
984
-
985
- params[:redirect_uri] = redirect_hint[:expected]
986
- retry_body = URI.encode_www_form(params)
987
-
988
- response = send_token_request.call(retry_body)
1841
+ retry_uri = redirect_uri_retry_target(response.body, sent: registered_redirect_uri,
1842
+ recorded: recorded_redirect_uri)
1843
+ if retry_uri
1844
+ params[:redirect_uri] = retry_uri
1845
+ response = send_token_request.call(URI.encode_www_form(params))
989
1846
  end
990
1847
  end
991
1848
 
992
1849
  unless response.success?
993
- raise MCPClient::Errors::ConnectionError, "Token exchange failed: HTTP #{response.status} - #{response.body}"
1850
+ raise MCPClient::Errors::ConnectionError,
1851
+ "Token exchange failed: HTTP #{response.status} - #{safe_body_text(response.body)}"
994
1852
  end
995
1853
 
996
1854
  data = JSON.parse(response.body)
1855
+ # RFC 6749 Section 5.1 gives every field of a successful response a
1856
+ # type; a 200 that breaks one is a protocol error, not a credential.
1857
+ # Storing it would report success and then present a bare "Bearer ",
1858
+ # or crash later capitalizing a token_type that is not a string.
1859
+ if (error = token_response_error(data))
1860
+ raise MCPClient::Errors::ConnectionError, "Token exchange failed: #{error}"
1861
+ end
1862
+
997
1863
  Token.new(
998
1864
  access_token: data['access_token'],
999
- token_type: data['token_type'] || 'Bearer',
1865
+ token_type: data['token_type'],
1000
1866
  expires_in: data['expires_in'],
1001
- scope: data['scope'],
1002
- refresh_token: data['refresh_token']
1867
+ # "scope: OPTIONAL, if identical to the scope requested by the
1868
+ # client" (RFC 6749 Section 5.1): omitted means granted as asked.
1869
+ scope: data['scope'].nil? ? requested_scope_of(pkce) : data['scope'],
1870
+ refresh_token: data['refresh_token'],
1871
+ issuer: server_metadata.issuer
1003
1872
  )
1004
1873
  rescue JSON::ParserError => e
1005
- raise MCPClient::Errors::ConnectionError, "Invalid token response: #{e.message}"
1874
+ raise MCPClient::Errors::ConnectionError,
1875
+ "Invalid token response: #{describe_parse_error(e, response&.body)}"
1006
1876
  rescue Faraday::Error => e
1007
- raise MCPClient::Errors::ConnectionError, "Network error during token exchange: #{e.message}"
1877
+ raise MCPClient::Errors::ConnectionError,
1878
+ "Network error during token exchange: #{safe_error_text(e.message)}"
1008
1879
  end
1009
1880
 
1010
1881
  # Refresh access token
@@ -1016,10 +1887,15 @@ module MCPClient
1016
1887
  logger.debug('Refreshing access token')
1017
1888
 
1018
1889
  server_metadata = discover_authorization_server
1019
- client_info = storage.get_client_info(server_url)
1890
+ # Registration state is per authorization server (SEP-2352): the
1891
+ # credentials of the server being refreshed at, wherever they are kept.
1892
+ client_info = refresh_client_info(server_metadata&.issuer)
1020
1893
 
1021
1894
  return nil unless server_metadata && client_info
1895
+ return nil unless refresh_permitted?(token, client_info, server_metadata)
1022
1896
 
1897
+ # The resource the refresh is made for, judged again over the response.
1898
+ resource = server_url
1023
1899
  params = {
1024
1900
  grant_type: 'refresh_token',
1025
1901
  refresh_token: token.refresh_token,
@@ -1027,14 +1903,12 @@ module MCPClient
1027
1903
  resource: server_url
1028
1904
  }
1029
1905
 
1030
- # Add client_secret if required by token_endpoint_auth_method
1031
- if client_info.client_secret && client_info.metadata.token_endpoint_auth_method == 'client_secret_post'
1032
- params[:client_secret] = client_info.client_secret
1033
- end
1906
+ authorization = apply_client_credentials!(params, client_info)
1034
1907
 
1035
1908
  response = @http_client.post(server_metadata.token_endpoint) do |req|
1036
1909
  req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
1037
1910
  req.headers['Accept'] = 'application/json'
1911
+ req.headers['Authorization'] = authorization if authorization
1038
1912
  req.body = URI.encode_www_form(params)
1039
1913
  end
1040
1914
 
@@ -1044,34 +1918,216 @@ module MCPClient
1044
1918
  end
1045
1919
 
1046
1920
  data = JSON.parse(response.body)
1921
+ # A refresh response that breaks RFC 6749 Section 5.1 — no
1922
+ # access_token bytes, or a field of the wrong JSON type — is a failed
1923
+ # refresh: the still-valid token stays in storage rather than being
1924
+ # overwritten with something that would go out as "Bearer ", and
1925
+ # nothing is raised out of the request path that a still-valid token
1926
+ # could have served.
1927
+ if (error = token_response_error(data))
1928
+ logger.warn("Token refresh failed: #{error}; keeping the stored token")
1929
+ return nil
1930
+ end
1931
+
1047
1932
  new_token = Token.new(
1048
1933
  access_token: data['access_token'],
1049
- token_type: data['token_type'] || 'Bearer',
1934
+ token_type: data['token_type'],
1050
1935
  expires_in: data['expires_in'],
1051
- scope: data['scope'],
1052
- refresh_token: data['refresh_token'] || token.refresh_token
1936
+ # A refresh that asks for no scope is granted the original one (RFC
1937
+ # 6749 Section 6), so a response that omits it keeps the known set.
1938
+ scope: data['scope'].nil? ? token.scope : data['scope'],
1939
+ refresh_token: data['refresh_token'] || token.refresh_token,
1940
+ issuer: server_metadata.issuer
1053
1941
  )
1054
1942
 
1055
- storage.set_token(server_url, new_token)
1056
- new_token
1943
+ accept_refreshed_token(new_token, server_metadata.issuer, resource, token)
1057
1944
  rescue JSON::ParserError => e
1058
- logger.warn("Invalid token refresh response: #{e.message}")
1945
+ logger.warn("Invalid token refresh response: #{describe_parse_error(e, response&.body)}")
1059
1946
  nil
1060
1947
  rescue Faraday::Error => e
1061
- logger.warn("Network error during token refresh: #{e.message}")
1948
+ logger.warn("Network error during token refresh: #{safe_error_text(e.message)}")
1062
1949
  nil
1063
1950
  end
1064
1951
 
1952
+ # A refresh is two events with an unbounded gap between them: the
1953
+ # request goes to the authorization server the token came from, and the
1954
+ # response arrives at a client whose authorization server may have
1955
+ # changed meanwhile — updated protected resource metadata, a 401
1956
+ # challenge, another provider sharing the storage. So the check
1957
+ # {#refresh_permitted?} made before the request is made again over the
1958
+ # response, and it covers the write as much as the presentation: bytes
1959
+ # issued by a server that is no longer this resource's would otherwise
1960
+ # be stored over the token of the server that IS in use (resurrecting,
1961
+ # in the challenge case, a token that was just retired) and be handed
1962
+ # straight back to the caller without ever passing the issuer check the
1963
+ # stored-token path makes.
1964
+ # @param new_token [Token] the token the refresh response carried
1965
+ # @param issuer [String] the authorization server the refresh was made with
1966
+ # @param resource [String] the resource the refresh was made for
1967
+ # @param refreshed [Token, nil] the token the refresh was made with
1968
+ # @return [Token, nil] the token, or nil when it is no longer this resource's to keep
1969
+ def accept_refreshed_token(new_token, issuer, resource, refreshed = nil)
1970
+ # A provider retargeted at another resource meanwhile — one that may
1971
+ # share the authorization server — is not handed the previous
1972
+ # resource's token as its own. Judged before the lock: the lock of the
1973
+ # resource this provider now serves says nothing about the one the
1974
+ # refresh was made for.
1975
+ unless resource == server_url
1976
+ logger.warn('Discarding the refreshed token: the resource changed while the refresh was in flight')
1977
+ return nil
1978
+ end
1979
+
1980
+ # The remaining checks and the write are one step, for the reason
1981
+ # {#with_authorization_state_lock} gives: a switch validated between
1982
+ # them would otherwise have its token written over by this response.
1983
+ with_authorization_state_lock do
1984
+ unless refresh_target_current?(issuer)
1985
+ logger.warn('Discarding the refreshed token: the authorization server changed while the refresh ' \
1986
+ 'was in flight')
1987
+ next nil
1988
+ end
1989
+ # What shared storage shows is what another provider did meanwhile: a
1990
+ # challenge it validated retired the token being refreshed, or a flow
1991
+ # it completed replaced it. This provider's own view of the
1992
+ # authorization server says nothing about either, so the response is
1993
+ # kept only while the token it refreshed is still the token in use.
1994
+ unless refreshed_token_in_use?(refreshed)
1995
+ logger.warn('Discarding the refreshed token: the token it refreshed is no longer the token in use')
1996
+ next nil
1997
+ end
1998
+
1999
+ store_token(new_token)
2000
+ new_token
2001
+ end
2002
+ end
2003
+
2004
+ # The scope an authorization request asked for, as recorded with it.
2005
+ # @param pkce [PKCE] the per-request record
2006
+ # @return [String, nil]
2007
+ def requested_scope_of(pkce)
2008
+ pkce.scope if pkce.respond_to?(:scope) && pkce.scope.is_a?(String)
2009
+ end
2010
+
2011
+ # Whether a refresh made with an authorization server is still this
2012
+ # resource's: that server is the one in use now (an unresolved or
2013
+ # refused challenge means it is unknown, which is not "still A"), and
2014
+ # the token slot the response would be written to does not already hold
2015
+ # a token of another server.
2016
+ # @param issuer [String] the authorization server the refresh was made with
2017
+ # @return [Boolean]
2018
+ def refresh_target_current?(issuer)
2019
+ return false unless current_issuer_for_tokens == issuer
2020
+
2021
+ stored = stored_token_or_nil
2022
+ return true unless stored.respond_to?(:issuer) && stored.issuer
2023
+
2024
+ stored.issuer == issuer
2025
+ end
2026
+
2027
+ # The credentials a refresh presents are the ones an authorization
2028
+ # request would be made with: the two paths must not disagree about
2029
+ # which record answers for an authorization server. The resource slot
2030
+ # is the slot a host writes to, so credentials rotated there — a new
2031
+ # secret under the same client id — are never overruled by the older
2032
+ # copy kept under that authorization server's own key; the copy answers
2033
+ # only when the slot holds credentials of some other server.
2034
+ # @param issuer [String, nil] the authorization server the refresh is made at
2035
+ # @return [ClientInfo, nil]
2036
+ def refresh_client_info(issuer)
2037
+ in_use = stored_client_info
2038
+ # Credentials the host pre-registered without saying which
2039
+ # authorization server issued them are not presented to the one the
2040
+ # refresh goes to, exactly as an authorization request does not
2041
+ # present them: nothing says that server issued them. Expiry changes
2042
+ # nothing about whom they belong to, so it is judged on the record
2043
+ # itself, before the secret's lifetime is.
2044
+ return registration_for_issuer(issuer) if unbound_static?(in_use)
2045
+
2046
+ usable = in_use.nil? || in_use.client_secret_expired? ? nil : in_use
2047
+ return usable if usable && answers_for_issuer?(usable, issuer)
2048
+
2049
+ # Never the record whose secret was just filtered out: an
2050
+ # authorization request discards an expired registration and registers
2051
+ # again (#usable_client_info), so presenting that secret here is the
2052
+ # disagreement between the two paths this method exists to prevent.
2053
+ # With nothing left to present the refresh is skipped, and the host
2054
+ # authorizes — which is what re-registers.
2055
+ registration_for_issuer(issuer) || usable
2056
+ end
2057
+
2058
+ # A refresh token (and a client secret) is only ever presented to the
2059
+ # authorization server that issued it (MCP 2026-07-28: registration
2060
+ # state and tokens are per authorization server).
2061
+ # @return [Boolean]
2062
+ def refresh_permitted?(token, client_info, server_metadata)
2063
+ issuer = token.respond_to?(:issuer) ? token.issuer : nil
2064
+ if issuer && issuer != server_metadata.issuer
2065
+ logger.warn('Not refreshing the token: the authorization server changed since it was issued')
2066
+ return false
2067
+ end
2068
+ # A token that does not say where it came from is not refreshed once
2069
+ # the authorization server is known to have changed.
2070
+ if issuer.nil? && @authorization_server_switched
2071
+ logger.warn('Not refreshing the token: it records no issuer and the authorization server changed')
2072
+ return false
2073
+ end
2074
+ if client_info.respond_to?(:issuer) && client_info.issuer && !client_info.portable? &&
2075
+ client_info.issuer != server_metadata.issuer
2076
+ logger.warn('Not refreshing the token: the client credentials belong to another authorization server')
2077
+ return false
2078
+ end
2079
+ true
2080
+ end
2081
+
2082
+ # The redirect_uri to retry a failed code exchange with, if any.
2083
+ #
2084
+ # RFC 6749 Section 4.1.3 redeems the code with the redirect_uri of the
2085
+ # authorization request, which is recorded on the PKCE record. Where it
2086
+ # was recorded, a value parsed out of the peer's error text is never
2087
+ # substituted for it: redeeming with a URI this client never sent is not
2088
+ # a recovery, so the contradiction is surfaced. Only legacy records made
2089
+ # before the URI was recorded keep the older retry-with-the-server's-value
2090
+ # behaviour, where there is nothing authoritative to contradict.
2091
+ # @param body [String] the token endpoint's error body
2092
+ # @param sent [String, nil] the redirect_uri the request was made with
2093
+ # @param recorded [String, nil] the redirect_uri recorded for this authorization request
2094
+ # @return [String, nil] the URI to retry with, or nil to keep the failure
2095
+ # @raise [MCPClient::Errors::ConnectionError] when the error contradicts the recorded URI
2096
+ def redirect_uri_retry_target(body, sent:, recorded:)
2097
+ expected = extract_redirect_mismatch(body)&.fetch(:expected, nil)
2098
+ return nil unless expected && expected != sent
2099
+
2100
+ if recorded
2101
+ raise MCPClient::Errors::ConnectionError,
2102
+ 'Token exchange failed: the authorization server expected a redirect_uri other than the one the ' \
2103
+ "authorization request was made with (#{recorded}); restart the authorization"
2104
+ end
2105
+
2106
+ logger.warn("Token exchange failed: redirect_uri mismatch. Retrying with server's expected value: " \
2107
+ "#{safe_error_text(expected)}")
2108
+ expected
2109
+ end
2110
+
1065
2111
  # Extract redirect_uri mismatch details from an OAuth error response
2112
+ #
2113
+ # The description is a peer's bytes, and nothing about them guarantees
2114
+ # valid UTF-8: `String#match` raises `ArgumentError` on a lone `\xFF`,
2115
+ # out of the very rescue path that exists to turn a 400 into a
2116
+ # ConnectionError. It is made decodable before it is matched — the
2117
+ # bounded, printable form of {#safe_error_text} would do for a message
2118
+ # but not for a pattern, which has to see the URIs the peer actually
2119
+ # wrote.
1066
2120
  # @param body [String] Raw HTTP response body
1067
2121
  # @return [Hash, nil] Hash with :sent and :expected URIs if mismatch detected
1068
2122
  def extract_redirect_mismatch(body)
1069
- data = JSON.parse(body)
2123
+ data = JSON.parse(body.to_s)
2124
+ return nil unless data.is_a?(Hash)
2125
+
1070
2126
  error = data['error'] || data[:error]
1071
2127
  return nil unless error == 'unauthorized_client'
1072
2128
 
1073
- description = data['error_description'] || data[:error_description]
1074
- return nil unless description.is_a?(String)
2129
+ description = matchable_peer_text(data['error_description'] || data[:error_description])
2130
+ return nil unless description
1075
2131
 
1076
2132
  match = description.match(%r{You sent\s+(https?://\S+)[,.]?\s+and we expected\s+(https?://\S+)}i)
1077
2133
  return nil unless match