ruby-mcp-client 2.1.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  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 +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  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 +796 -518
  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/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -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
@@ -72,9 +129,49 @@ module MCPClient
72
129
  @challenge_error = nil
73
130
  end
74
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
162
+ end
163
+
75
164
  # @param url [String] Server URL to normalize
76
165
  def server_url=(url)
77
- @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
78
175
  end
79
176
 
80
177
  # Set the Client ID Metadata Document URL (SEP-991), validating it per the
@@ -89,15 +186,50 @@ module MCPClient
89
186
  # Get current access token (refresh if needed)
90
187
  # @return [Token, nil] Current valid access token or nil
91
188
  def access_token
92
- 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
93
194
  logger.debug("OAuth access_token: retrieved token=#{token ? 'present' : 'nil'} for #{server_url}")
94
195
  return nil unless token
95
196
 
96
197
  # Return token if still valid
97
198
  return token unless token.expired? || token.expires_soon?
98
199
 
99
- # Try to refresh if we have a refresh token
100
- 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
101
233
  end
102
234
 
103
235
  # Return the scopes supported by the authorization server
@@ -105,7 +237,9 @@ module MCPClient
105
237
  # @return [Array<String>] supported scopes, or empty array if not advertised
106
238
  # @raise [MCPClient::Errors::ConnectionError] if server discovery fails
107
239
  def supported_scopes
108
- @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)
109
243
  end
110
244
 
111
245
  # Start OAuth authorization flow
@@ -118,13 +252,37 @@ module MCPClient
118
252
  # Register client if needed
119
253
  client_info = get_or_register_client(server_metadata)
120
254
 
121
- # Generate PKCE parameters
122
- pkce = PKCE.new
123
- storage.set_pkce(server_url, pkce)
124
-
125
- # 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.
126
266
  state = SecureRandom.urlsafe_base64(32)
127
- 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
128
286
 
129
287
  # Build authorization URL
130
288
  build_authorization_url(server_metadata, client_info, pkce, state)
@@ -133,148 +291,343 @@ module MCPClient
133
291
  # Complete OAuth authorization flow with authorization code
134
292
  # @param code [String] Authorization code from callback
135
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)
136
297
  # @return [Token] Access token
137
- # @raise [MCPClient::Errors::ConnectionError] if token exchange fails
298
+ # @raise [MCPClient::Errors::ConnectionError] if the issuer check or the token exchange fails
138
299
  # @raise [ArgumentError] if state parameter doesn't match
139
- def complete_authorization_flow(code, state)
300
+ def complete_authorization_flow(code, state, iss: nil)
140
301
  # Verify state parameter
141
302
  stored_state = storage.get_state(server_url)
142
303
  raise ArgumentError, 'Invalid state parameter' unless stored_state == state
143
304
 
144
305
  # Get stored PKCE and client info
145
- pkce = storage.get_pkce(server_url)
146
- client_info = storage.get_client_info(server_url)
147
- server_metadata = discover_authorization_server
148
-
306
+ pkce = stored_pkce
307
+ client_info = stored_client_info
149
308
  raise MCPClient::Errors::ConnectionError, 'Missing PKCE or client info' unless pkce && client_info
150
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
+
151
331
  # Exchange authorization code for tokens
152
332
  token = exchange_authorization_code(server_metadata, client_info, code, pkce)
153
333
 
154
- # Store token
155
- storage.set_token(server_url, token)
334
+ accept_exchanged_token(token, pkce)
335
+ end
156
336
 
157
- # Clean up temporary data
158
- storage.delete_pkce(server_url)
159
- 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
160
374
 
161
375
  token
162
376
  end
163
377
 
164
- # Apply OAuth authorization to HTTP request
165
- # @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
166
419
  # @return [void]
167
- def apply_authorization(request)
168
- token = access_token
169
- logger.debug("OAuth apply_authorization: token=#{token ? 'present' : 'nil'}")
170
- 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
171
429
 
172
- logger.debug("OAuth applying authorization header: #{token.to_header[0..20]}...")
173
- 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(&)
174
453
  end
175
454
 
176
- # Handle 401 Unauthorized response (for server discovery)
177
- # @param response [Faraday::Response] HTTP response
178
- # @return [ResourceMetadata, nil] Resource metadata if found
179
- def handle_unauthorized_response(response)
180
- www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
181
- return nil unless www_authenticate
182
-
183
- # Challenge parameters are read from the Bearer challenge's own
184
- # segment only — never from the whole (possibly multi-challenge)
185
- # header — so parameters belonging to Basic or another scheme cannot
186
- # drive Bearer scope selection or resource metadata discovery. A
187
- # header without a Bearer challenge carries no usable Bearer params.
188
- bearer_params = bearer_challenge_segment(www_authenticate)
189
-
190
- # MCP 2025-11-25: "Clients MUST treat the scopes provided in the
191
- # challenge as authoritative for satisfying the current request" —
192
- # including resetting a previously challenged scope when the current
193
- # challenge carries none.
194
- url = extract_resource_metadata_url(www_authenticate)
195
-
196
- # The challenge header is peer-controlled input: validate the
197
- # advertised URL BEFORE storing, fetching, or recording any challenge
198
- # state, so a malicious challenge cannot pivot this host into requests
199
- # against internal services (SSRF) and cannot leave the provider
200
- # holding half of a rejected challenge.
201
- validate_peer_advertised_url!(url, 'resource metadata URL (from WWW-Authenticate challenge)') if url
202
-
203
- @challenge_scope = bearer_params && extract_challenge_param(bearer_params, 'scope')
204
- return nil unless url
205
-
206
- # Remember the advertised URL even if the fetch below fails, so a
207
- # later discovery retries it instead of probing well-known URIs the
208
- # challenge already superseded.
209
- @challenge_metadata_url = url
210
-
211
- # This URL was explicitly advertised by the 401 challenge, so a 404 is a
212
- # misconfiguration to surface (strict), not a speculative miss to skip.
213
- metadata = fetch_resource_metadata(url, strict: true)
214
- # Reuse this challenge-advertised metadata during the subsequent OAuth
215
- # flow instead of re-deriving (and possibly missing) the well-known URL.
216
- @challenge_resource_metadata = metadata
217
- 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)
218
469
  end
219
470
 
220
- # Extract the protected-resource-metadata URL from a WWW-Authenticate header.
221
- # Per RFC 9728 the parameter is `resource_metadata`; a legacy `resource`
222
- # parameter is accepted as a fallback for older servers. Only the Bearer
223
- # challenge's own segment is consulted, so a parameter belonging to
224
- # another scheme's challenge can never drive discovery.
225
- # @param header [String] the WWW-Authenticate header value
226
- # @return [String, nil] the metadata URL if present
227
- def extract_resource_metadata_url(header)
228
- params = bearer_challenge_segment(header)
229
- return nil unless params
230
-
231
- # Auth-params may include optional whitespace around '=' (RFC 7235).
232
- # Quoted form: resource_metadata = "https://..."
233
- if (m = params.match(/resource_metadata\s*=\s*"([^"]+)"/))
234
- return m[1]
235
- end
236
-
237
- # Unquoted token form: resource_metadata = https://...
238
- if (m = params.match(/resource_metadata\s*=\s*([^,\s]+)/))
239
- return m[1]
240
- end
241
-
242
- # Legacy fallback: resource="https://..."
243
- params.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
244
- end
245
-
246
- # Extract the Bearer challenge's own parameter segment from a (possibly
247
- # multi-challenge) WWW-Authenticate header, so params belonging to other
248
- # schemes (e.g. `Basic resource_metadata="...", Bearer realm="x"`) are
249
- # never attributed to the Bearer challenge. Mirrors
250
- # HttpTransportBase#bearer_challenge_segment.
251
- # @param header [String, nil] the WWW-Authenticate header value
252
- # @return [String, nil] the Bearer challenge's parameters (possibly
253
- # empty), or nil when the header has no Bearer challenge
254
- def bearer_challenge_segment(header)
255
- return nil unless header
256
-
257
- # Locate the Bearer scheme token only OUTSIDE quoted strings: a
258
- # quoted value such as realm="prefix Bearer x" must not anchor the
259
- # segment.
260
- masked = header.gsub(/"(?:\\.|[^"\\])*"/) { |q| "\"#{' ' * (q.length - 2)}\"" }
261
- match = masked.match(/(?:\A|[\s,])Bearer(?=[\s,]|\z)/i)
262
- 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
476
+
477
+ storage.set_state(server_url, removed)
478
+ end
479
+
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
263
489
 
264
- header[match.end(0)..][AUTH_PARAMS_RUN]
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
265
495
  end
266
496
 
267
- # Extract an auth-param value from a WWW-Authenticate header
268
- # (quoted or unquoted form, optional whitespace around '=').
269
- # @param header [String] the WWW-Authenticate header value
270
- # @param name [String] the auth-param name
271
- # @return [String, nil] the parameter value if present
272
- def extract_challenge_param(header, name)
273
- if (m = header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*"([^"]*)"/i))
274
- return m[1]
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'
275
527
  end
528
+ return if client_info && client_for_request?(client_info, pkce)
276
529
 
277
- header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*([^,\s]+)/i)&.captures&.first
530
+ raise MCPClient::Errors::ConnectionError,
531
+ 'Authorization response rejected: the client credentials changed during the flow; ' \
532
+ 'restart the authorization'
533
+ end
534
+
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
278
631
  end
279
632
 
280
633
  # Scope requested by the most recent WWW-Authenticate challenge.
@@ -283,27 +636,123 @@ module MCPClient
283
636
 
284
637
  private
285
638
 
286
- # Resolve the scope for authorization/registration requests using the
287
- # MCP 2025-11-25 scope selection strategy: the challenge's scope
288
- # parameter is authoritative; then an explicitly configured scope
289
- # (:all resolves to the AS-advertised scope list); then the Protected
290
- # Resource Metadata's scopes_supported; otherwise omit scope entirely.
291
- # @return [String, nil]
292
- def resolved_scope
293
- 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)
658
+
659
+ raise MCPClient::Errors::ConnectionError,
660
+ 'Authorization response rejected: the authorization server changed during the flow; ' \
661
+ 'restart the authorization'
662
+ end
294
663
 
295
- if scope == :all
296
- all_scopes = supported_scopes
297
- return all_scopes.join(' ') unless all_scopes.empty?
298
- elsif scope
299
- return scope
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'
300
680
  end
681
+ if iss.nil?
682
+ return unless supported
301
683
 
302
- prm = @challenge_resource_metadata || @resource_metadata
303
- prm_scopes = prm&.scopes_supported
304
- 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
305
689
 
306
- 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"
307
756
  end
308
757
 
309
758
  # Normalize server URL to canonical form
@@ -423,10 +872,15 @@ module MCPClient
423
872
  # @return [ServerMetadata] Authorization server metadata
424
873
  # @raise [MCPClient::Errors::ConnectionError] if discovery fails
425
874
  def discover_authorization_server
426
- # A challenge we refused is still authoritative: it says the cached
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
427
879
  # authorization server is no longer the right one. Falling back to
428
880
  # that cache (or to speculative well-known probing) would quietly
429
- # undo the rejection, so surface it instead.
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.
430
884
  raise MCPClient::Errors::ConnectionError, @challenge_error if @challenge_error
431
885
 
432
886
  # A fresh 401 challenge is authoritative and overrides any cached
@@ -434,22 +888,68 @@ module MCPClient
434
888
  # whether the challenge-advertised PRM was already fetched or only its
435
889
  # URL is pending (e.g. the initial fetch failed and must be retried).
436
890
  challenge_pending = @challenge_resource_metadata || @challenge_metadata_url
437
- cached = storage.get_server_metadata(server_url) unless challenge_pending
891
+ cached = stored_server_metadata unless challenge_pending || @rediscover_after_switch
438
892
  if cached
439
893
  # Validate the cached entry before use so a persisted/older cache with
440
894
  # an HTTP endpoint or without S256 is still rejected.
441
895
  validate_server_metadata!(cached)
442
- 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')
443
904
  end
444
905
 
445
- 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
446
946
  end
447
947
 
448
948
  # Discover authorization server metadata, validate it, and cache it.
449
949
  # @return [ServerMetadata]
450
950
  # @raise [MCPClient::Errors::ConnectionError] if discovery or validation fails
451
951
  def discover_and_cache_authorization_server
452
- previous = storage.get_server_metadata(server_url)
952
+ previous = stored_server_metadata
453
953
 
454
954
  # RFC 9728: Protected Resource Metadata is authoritative — try it first,
455
955
  # then fall back to treating the MCP server origin as its own AS.
@@ -463,15 +963,52 @@ module MCPClient
463
963
  # Validate BEFORE caching so invalid metadata is never persisted.
464
964
  validate_server_metadata!(server_metadata)
465
965
 
466
- 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
467
971
 
468
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
469
976
  @challenge_resource_metadata = nil # consumed
470
977
  @challenge_metadata_url = nil # consumed
471
978
  @challenge_error = nil
472
979
  server_metadata
473
980
  end
474
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
+
475
1012
  # When a 401 challenge changes the authorization server, per-AS state cached
476
1013
  # under this server_url becomes invalid: a client_id registered with the
477
1014
  # previous AS would fail as invalid_client, and memoized scopes belong to
@@ -481,17 +1018,63 @@ module MCPClient
481
1018
  def invalidate_client_info_on_as_change(previous, current)
482
1019
  return unless previous && previous.issuer != current.issuer
483
1020
 
484
- 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')
485
1022
 
486
- # Prefer an explicit delete; fall back to the always-available
487
- # set_client_info(nil) so custom storage backends are handled too.
488
- if storage.respond_to?(:delete_client_info)
489
- storage.delete_client_info(server_url)
490
- else
491
- 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)
492
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
493
1063
 
494
- @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
495
1078
  end
496
1079
 
497
1080
  # Apply the PKCE-support and HTTPS-endpoint checks to server metadata.
@@ -514,22 +1097,38 @@ module MCPClient
514
1097
  resource_metadata = challenge_or_well_known_resource_metadata
515
1098
  return nil unless resource_metadata
516
1099
 
517
- 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
518
1112
 
519
1113
  auth_server_url = Array(resource_metadata.authorization_servers).first
520
1114
  unless auth_server_url
521
- raise MCPClient::Errors::ConnectionError,
522
- 'Protected resource metadata does not advertise any authorization_servers'
1115
+ refuse_resource_metadata!('Protected resource metadata does not advertise any authorization_servers')
523
1116
  end
524
1117
 
525
1118
  # authorization_servers is untrusted PRM content: validate the
526
1119
  # advertised origin BEFORE constructing and fetching well-known URLs
527
1120
  # on it, so a malicious protected resource cannot drive discovery GETs
528
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.
529
1126
  validate_peer_advertised_url!(auth_server_url,
530
- 'authorization server (advertised by protected resource metadata)')
1127
+ 'authorization server (advertised by protected resource metadata)',
1128
+ latch: challenge_advertised_metadata?)
531
1129
 
532
- server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url))
1130
+ server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url),
1131
+ auth_server_url)
533
1132
  unless server_metadata
534
1133
  raise MCPClient::Errors::ConnectionError,
535
1134
  "Authorization server advertised by protected resource metadata (#{auth_server_url}) " \
@@ -563,7 +1162,7 @@ module MCPClient
563
1162
  # @return [ServerMetadata, nil]
564
1163
  def discover_via_direct_authorization_server
565
1164
  origin = origin_of(URI.parse(server_url))
566
- fetch_first_server_metadata(authorization_server_metadata_urls(origin))
1165
+ fetch_first_server_metadata(authorization_server_metadata_urls(origin), origin)
567
1166
  end
568
1167
 
569
1168
  # Fetch the first Protected Resource Metadata document that resolves.
@@ -586,21 +1185,57 @@ module MCPClient
586
1185
  # Fetch the first Authorization Server Metadata document that resolves.
587
1186
  # The oauth-authorization-server and openid-configuration forms are
588
1187
  # genuine alternatives, so any failing candidate is skipped to try the next.
589
- # @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
590
1190
  # @return [ServerMetadata, nil]
591
- def fetch_first_server_metadata(urls)
1191
+ def fetch_first_server_metadata(urls, issuer)
1192
+ rejected = nil
592
1193
  urls.each do |url|
593
1194
  md = try_fetch_server_metadata(url)
594
- 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
595
1208
  end
1209
+ # Only mismatching documents were found: say so rather than "nothing".
1210
+ raise rejected if rejected
1211
+
596
1212
  nil
597
1213
  end
598
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
+
599
1234
  # Non-raising server-metadata fetch used while iterating candidates.
600
1235
  def try_fetch_server_metadata(url)
601
1236
  fetch_server_metadata(url)
602
1237
  rescue MCPClient::Errors::ConnectionError => e
603
- 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}")
604
1239
  nil
605
1240
  end
606
1241
 
@@ -617,6 +1252,14 @@ module MCPClient
617
1252
  raise MCPClient::Errors::ConnectionError,
618
1253
  'Authorization server metadata omits code_challenge_methods_supported; ' \
619
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'
620
1263
  elsif !methods.include?('S256')
621
1264
  raise MCPClient::Errors::ConnectionError,
622
1265
  'Authorization server does not support PKCE S256 ' \
@@ -624,8 +1267,8 @@ module MCPClient
624
1267
  end
625
1268
  end
626
1269
 
627
- # Require HTTPS for all discovered authorization server endpoints, with a
628
- # localhost exception for local development.
1270
+ # Require HTTPS for all discovered authorization server endpoints, with
1271
+ # the local-development exception for a local stack.
629
1272
  # @param server_metadata [ServerMetadata]
630
1273
  def enforce_https_endpoints!(server_metadata)
631
1274
  enforce_https!(server_metadata.authorization_endpoint, 'authorization endpoint')
@@ -635,87 +1278,34 @@ module MCPClient
635
1278
  enforce_https!(server_metadata.registration_endpoint, 'registration endpoint')
636
1279
  end
637
1280
 
638
- # Validate a URL that a peer advertised to us (a 401 challenge's
639
- # resource_metadata, or PRM authorization_servers).
640
- #
641
- # Stricter than enforce_https!, which exists for URLs the OPERATOR
642
- # configured and therefore tolerates plain-HTTP loopback for local
643
- # development. Applying that exception to peer-supplied input would
644
- # leave the reported SSRF intact against the most sensitive targets of
645
- # all — services listening only on localhost. The loopback exception is
646
- # honored here only when the configured MCP server is itself loopback,
647
- # i.e. the developer is already pointed at a local stack.
648
- #
649
- # The rejection is recorded so a later discovery fails closed instead of
650
- # silently reusing cached authorization-server metadata.
651
- #
652
- # NOTE: hostnames are checked literally. This does not resolve DNS, so a
653
- # public name that resolves to a private address is not caught here;
654
- # that needs resolution-time checking in the HTTP layer.
655
- # @param url [String] the peer-advertised URL
656
- # @param label [String] human-readable name for errors
657
- # @raise [MCPClient::Errors::ConnectionError] if the URL is not acceptable
658
- def validate_peer_advertised_url!(url, label)
659
- uri = URI.parse(url)
660
- host = uri.hostname.to_s.downcase
661
-
662
- if uri.scheme != 'https' && !(uri.scheme == 'http' && local_development?)
663
- reject_challenge!("OAuth #{label} must use HTTPS: #{url}")
664
- end
665
- reject_challenge!("OAuth #{label} must not target a loopback or private address: #{url}") if
666
- local_address?(host) && !local_development?
667
- rescue URI::InvalidURIError
668
- reject_challenge!("OAuth #{label} is not a valid URL: #{url}")
669
- end
670
-
671
- # @param message [String] why the challenge was refused
672
- # @raise [MCPClient::Errors::ConnectionError] always
673
- def reject_challenge!(message)
674
- # Drop every scrap of the refused challenge so nothing half-applied
675
- # survives, and remember why for the next discovery attempt.
676
- @challenge_scope = nil
677
- @challenge_metadata_url = nil
678
- @challenge_resource_metadata = nil
679
- @challenge_error = message
680
- raise MCPClient::Errors::ConnectionError, message
681
- end
682
-
683
- # @return [Boolean] whether the configured MCP server is itself local,
684
- # in which case local discovery targets are expected
685
- def local_development?
686
- local_address?(URI.parse(server_url).hostname.to_s.downcase)
687
- rescue URI::InvalidURIError
688
- false
689
- end
690
-
691
- # @param host [String] a downcased hostname
692
- # @return [Boolean] whether it names a loopback, private or link-local address
693
- def local_address?(host)
694
- return true if %w[localhost 127.0.0.1 ::1 0.0.0.0].include?(host)
695
- return true if host.end_with?('.localhost', '.local', '.internal')
696
- return true if host.start_with?('127.', '10.', '192.168.', '169.254.')
697
- return true if host.match?(/\A172\.(1[6-9]|2\d|3[01])\./)
698
- return true if host.match?(/\A\[?(fc|fd|fe80)/)
699
-
700
- false
701
- end
702
-
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.
703
1292
  # @param url [String, nil] endpoint URL
704
1293
  # @param label [String] human-readable endpoint name for errors
705
- # @raise [MCPClient::Errors::ConnectionError] if the URL is not HTTPS (non-localhost)
1294
+ # @raise [MCPClient::Errors::ConnectionError] if the URL is not acceptable
706
1295
  def enforce_https!(url, label)
707
1296
  return if url.nil?
708
1297
 
709
- uri = URI.parse(url)
710
- return if uri.scheme == 'https'
711
- # Dev exception is only for plain HTTP on a loopback host — not any other
712
- # scheme (e.g. ftp://localhost). Use #hostname (not #host) so an IPv6
713
- # loopback like http://[::1]:9292 matches without the surrounding brackets.
714
- 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
715
1300
 
716
- raise MCPClient::Errors::ConnectionError, "OAuth #{label} must use HTTPS: #{url}"
717
- rescue URI::InvalidURIError
718
- 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
719
1309
  end
720
1310
 
721
1311
  # Validate the PRM `resource` identifies this server (RFC 9728 confused
@@ -739,7 +1329,7 @@ module MCPClient
739
1329
  return if advertised == expected
740
1330
 
741
1331
  raise MCPClient::Errors::ConnectionError,
742
- "Protected resource metadata resource (#{resource_metadata.resource}) " \
1332
+ "Protected resource metadata resource (#{safe_error_text(resource_metadata.resource.to_s)}) " \
743
1333
  "does not match the server URL (#{server_url})"
744
1334
  end
745
1335
 
@@ -755,7 +1345,8 @@ module MCPClient
755
1345
  def resource_identity(url)
756
1346
  uri = URI.parse(url)
757
1347
  unless uri.scheme && uri.host
758
- 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)}"
759
1350
  end
760
1351
 
761
1352
  scheme = uri.scheme.downcase
@@ -769,7 +1360,7 @@ module MCPClient
769
1360
 
770
1361
  "#{scheme}://#{host}#{":#{port}" if port}#{path}#{query}"
771
1362
  rescue URI::InvalidURIError
772
- raise MCPClient::Errors::ConnectionError, "Invalid resource URL: #{url}"
1363
+ raise MCPClient::Errors::ConnectionError, "Invalid resource URL: #{safe_error_text(url.to_s)}"
773
1364
  end
774
1365
 
775
1366
  # Fetch resource metadata from URL.
@@ -785,7 +1376,7 @@ module MCPClient
785
1376
  # @return [ResourceMetadata, nil] metadata, or nil if a speculative URL returns 404
786
1377
  # @raise [MCPClient::Errors::ConnectionError] on any non-404 failure, or on 404 when strict
787
1378
  def fetch_resource_metadata(url, strict: false)
788
- logger.debug("Fetching resource metadata from: #{url}")
1379
+ logger.debug("Fetching resource metadata from: #{safe_error_text(url.to_s)}")
789
1380
 
790
1381
  response = @http_client.get(url) do |req|
791
1382
  req.headers['Accept'] = 'application/json'
@@ -798,15 +1389,29 @@ module MCPClient
798
1389
  end
799
1390
 
800
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
+
801
1404
  metadata = ResourceMetadata.from_h(data)
802
1405
  # Retain the most recently fetched PRM so scope resolution can fall
803
1406
  # back to its scopes_supported (MCP scope selection priority 2).
804
1407
  @resource_metadata = metadata
805
1408
  metadata
806
1409
  rescue JSON::ParserError => e
807
- 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)}"
808
1412
  rescue Faraday::Error => e
809
- 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)}"
810
1415
  end
811
1416
 
812
1417
  # Fetch authorization server metadata from URL
@@ -814,7 +1419,7 @@ module MCPClient
814
1419
  # @return [ServerMetadata] Server metadata
815
1420
  # @raise [MCPClient::Errors::ConnectionError] if fetch fails
816
1421
  def fetch_server_metadata(url)
817
- logger.debug("Fetching server metadata from: #{url}")
1422
+ logger.debug("Fetching server metadata from: #{safe_error_text(url.to_s)}")
818
1423
 
819
1424
  response = @http_client.get(url) do |req|
820
1425
  req.headers['Accept'] = 'application/json'
@@ -825,68 +1430,72 @@ module MCPClient
825
1430
  end
826
1431
 
827
1432
  data = JSON.parse(response.body)
828
- 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)
829
1446
  rescue JSON::ParserError => e
830
- 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)}"
831
1449
  rescue Faraday::Error => e
832
- 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)}"
833
1452
  end
834
1453
 
835
- # Get or register OAuth client, following the MCP 2025-11-25 client
836
- # registration priority order: pre-registered/cached client information
837
- # first, then Client ID Metadata Documents (SEP-991) when the
838
- # authorization server advertises support and a metadata URL is
839
- # configured, then Dynamic Client Registration as a fallback.
840
- # @param server_metadata [ServerMetadata] Authorization server metadata
841
- # @return [ClientInfo] Client information
842
- # @raise [MCPClient::Errors::ConnectionError] if registration fails
843
- def get_or_register_client(server_metadata)
844
- # 1. Pre-registered or previously registered client info from storage
845
- if (client_info = storage.get_client_info(server_url)) && !client_info.client_secret_expired?
846
- logger.debug("Using cached OAuth client for #{server_url}")
847
- return client_info
848
- end
849
-
850
- # 2. Client ID Metadata Documents (SEP-991): the HTTPS metadata URL is
851
- # itself the client_id — no registration request is needed.
852
- if client_id_metadata_url && server_metadata.supports_client_id_metadata_documents?
853
- return client_info_from_metadata_url
854
- end
855
-
856
- # 3. Dynamic Client Registration (RFC 7591) fallback
857
- logger.debug('No cached client found, registering new OAuth client...')
858
- if server_metadata.supports_registration?
859
- register_client(server_metadata)
860
- else
861
- raise MCPClient::Errors::ConnectionError,
862
- 'Dynamic client registration not supported and no client credentials found'
863
- 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
864
1480
  end
865
1481
 
866
- # Build client information for a Client ID Metadata Document client
867
- # (SEP-991): the configured HTTPS metadata URL is used directly as the
868
- # client_id in authorization and token requests, without a dynamic
869
- # registration POST. Serving the metadata JSON at that URL is the
870
- # application's responsibility, not this library's.
871
- # @return [ClientInfo] Client information with the metadata URL as client_id
872
- def client_info_from_metadata_url
873
- logger.debug("Using Client ID Metadata Document URL as client_id: #{client_id_metadata_url}")
874
-
875
- metadata = ClientMetadata.new(
876
- redirect_uris: [redirect_uri],
877
- token_endpoint_auth_method: 'none', # Public client
878
- grant_types: %w[authorization_code refresh_token],
879
- response_types: ['code'],
880
- scope: resolved_scope,
881
- **@extra_client_metadata
882
- )
883
-
884
- 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
885
1488
 
886
- # Persist so complete_authorization_flow and token refresh can find it
887
- 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
888
1493
 
889
- 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
890
1499
  end
891
1500
 
892
1501
  # Validate a Client ID Metadata Document URL (SEP-991): "The client_id
@@ -925,38 +1534,43 @@ module MCPClient
925
1534
  end
926
1535
 
927
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.
928
1542
  # @param server_metadata [ServerMetadata] Authorization server metadata
929
1543
  # @return [ClientInfo] Registered client information
930
1544
  # @raise [MCPClient::Errors::ConnectionError] if registration fails
931
1545
  def register_client(server_metadata)
932
- logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")
933
-
934
- metadata = ClientMetadata.new(
935
- redirect_uris: [redirect_uri],
936
- token_endpoint_auth_method: 'none', # Public client
937
- grant_types: %w[authorization_code refresh_token],
938
- response_types: ['code'],
939
- scope: resolved_scope,
940
- **@extra_client_metadata
941
- )
942
-
943
- response = @http_client.post(server_metadata.registration_endpoint) do |req|
944
- req.headers['Content-Type'] = 'application/json'
945
- req.headers['Accept'] = 'application/json'
946
- 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)
947
1562
  end
948
1563
 
949
- unless response.success?
950
- raise MCPClient::Errors::ConnectionError, "Client registration failed: HTTP #{response.status}"
951
- end
1564
+ raise_registration_failure!(response) unless response.success?
952
1565
 
953
1566
  data = JSON.parse(response.body)
954
- 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)}")
955
1569
 
956
1570
  # Parse registered metadata from server response (may differ from our request)
957
1571
  registered_metadata = ClientMetadata.new(
958
- redirect_uris: data['redirect_uris'] || [redirect_uri],
959
- 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),
960
1574
  grant_types: data['grant_types'] || %w[authorization_code refresh_token],
961
1575
  response_types: data['response_types'] || ['code'],
962
1576
  scope: data['scope'],
@@ -965,35 +1579,154 @@ module MCPClient
965
1579
  logo_uri: data['logo_uri'],
966
1580
  tos_uri: data['tos_uri'],
967
1581
  policy_uri: data['policy_uri'],
968
- contacts: data['contacts']
1582
+ contacts: data['contacts'],
1583
+ application_type: data['application_type'] || app_type
969
1584
  )
970
1585
 
971
- # Warn if server changed redirect_uri
972
- requested_uri = redirect_uri
973
- registered_uri = registered_metadata.redirect_uris.first
974
- if registered_uri != requested_uri
975
- logger.warn('OAuth server changed redirect_uri:')
976
- logger.warn(" Requested: #{requested_uri}")
977
- logger.warn(" Registered: #{registered_uri}")
978
- logger.warn("Using server's registered redirect_uri for token exchange.")
979
- end
1586
+ warn_registered_redirect_uri(registered_metadata)
980
1587
 
981
1588
  client_info = ClientInfo.new(
982
- client_id: data['client_id'],
1589
+ client_id: client_id,
983
1590
  client_secret: data['client_secret'],
984
1591
  client_id_issued_at: data['client_id_issued_at'],
985
1592
  client_secret_expires_at: data['client_secret_expires_at'],
986
- 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'
987
1597
  )
988
1598
 
989
1599
  # Store client info
990
- storage.set_client_info(server_url, client_info)
1600
+ store_client_info(client_info)
991
1601
 
992
1602
  client_info
993
1603
  rescue JSON::ParserError => e
994
- 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)}"
995
1606
  rescue Faraday::Error => e
996
- 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
+ {}
997
1730
  end
998
1731
 
999
1732
  # Build authorization URL
@@ -1010,7 +1743,7 @@ module MCPClient
1010
1743
  response_type: 'code',
1011
1744
  client_id: client_info.client_id,
1012
1745
  redirect_uri: registered_redirect_uri,
1013
- scope: resolved_scope,
1746
+ scope: @requested_scope,
1014
1747
  state: state,
1015
1748
  code_challenge: pkce.code_challenge,
1016
1749
  code_challenge_method: pkce.code_challenge_method,
@@ -1018,10 +1751,50 @@ module MCPClient
1018
1751
  }.compact
1019
1752
 
1020
1753
  uri = URI.parse(server_metadata.authorization_endpoint)
1021
- 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)
1022
1773
  uri.to_s
1023
1774
  end
1024
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
+
1025
1798
  # Exchange authorization code for access token
1026
1799
  # @param server_metadata [ServerMetadata] Server metadata
1027
1800
  # @param client_info [ClientInfo] Client information
@@ -1032,8 +1805,10 @@ module MCPClient
1032
1805
  def exchange_authorization_code(server_metadata, client_info, code, pkce)
1033
1806
  logger.debug('Exchanging authorization code for access token')
1034
1807
 
1035
- # Use the redirect_uri that was actually registered, not our requested one
1036
- 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
1037
1812
 
1038
1813
  params = {
1039
1814
  grant_type: 'authorization_code',
@@ -1044,10 +1819,10 @@ module MCPClient
1044
1819
  resource: server_url
1045
1820
  }
1046
1821
 
1047
- # Add client_secret if required by token_endpoint_auth_method
1048
- if client_info.client_secret && client_info.metadata.token_endpoint_auth_method == 'client_secret_post'
1049
- params[:client_secret] = client_info.client_secret
1050
- 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)
1051
1826
 
1052
1827
  request_body = URI.encode_www_form(params)
1053
1828
 
@@ -1055,6 +1830,7 @@ module MCPClient
1055
1830
  @http_client.post(server_metadata.token_endpoint) do |req|
1056
1831
  req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
1057
1832
  req.headers['Accept'] = 'application/json'
1833
+ req.headers['Authorization'] = authorization if authorization
1058
1834
  req.body = body
1059
1835
  end
1060
1836
  end
@@ -1062,37 +1838,44 @@ module MCPClient
1062
1838
  response = send_token_request.call(request_body)
1063
1839
 
1064
1840
  unless response.success?
1065
- redirect_hint = extract_redirect_mismatch(response.body)
1066
-
1067
- if redirect_hint && redirect_hint[:expected] && redirect_hint[:expected] != registered_redirect_uri
1068
- expected_uri = redirect_hint[:expected]
1069
- logger.warn(
1070
- "Token exchange failed: redirect_uri mismatch. Retrying with server's expected value: #{expected_uri}"
1071
- )
1072
-
1073
- params[:redirect_uri] = redirect_hint[:expected]
1074
- retry_body = URI.encode_www_form(params)
1075
-
1076
- 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))
1077
1846
  end
1078
1847
  end
1079
1848
 
1080
1849
  unless response.success?
1081
- 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)}"
1082
1852
  end
1083
1853
 
1084
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
+
1085
1863
  Token.new(
1086
1864
  access_token: data['access_token'],
1087
- token_type: data['token_type'] || 'Bearer',
1865
+ token_type: data['token_type'],
1088
1866
  expires_in: data['expires_in'],
1089
- scope: data['scope'],
1090
- 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
1091
1872
  )
1092
1873
  rescue JSON::ParserError => e
1093
- raise MCPClient::Errors::ConnectionError, "Invalid token response: #{e.message}"
1874
+ raise MCPClient::Errors::ConnectionError,
1875
+ "Invalid token response: #{describe_parse_error(e, response&.body)}"
1094
1876
  rescue Faraday::Error => e
1095
- 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)}"
1096
1879
  end
1097
1880
 
1098
1881
  # Refresh access token
@@ -1104,10 +1887,15 @@ module MCPClient
1104
1887
  logger.debug('Refreshing access token')
1105
1888
 
1106
1889
  server_metadata = discover_authorization_server
1107
- 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)
1108
1893
 
1109
1894
  return nil unless server_metadata && client_info
1895
+ return nil unless refresh_permitted?(token, client_info, server_metadata)
1110
1896
 
1897
+ # The resource the refresh is made for, judged again over the response.
1898
+ resource = server_url
1111
1899
  params = {
1112
1900
  grant_type: 'refresh_token',
1113
1901
  refresh_token: token.refresh_token,
@@ -1115,14 +1903,12 @@ module MCPClient
1115
1903
  resource: server_url
1116
1904
  }
1117
1905
 
1118
- # Add client_secret if required by token_endpoint_auth_method
1119
- if client_info.client_secret && client_info.metadata.token_endpoint_auth_method == 'client_secret_post'
1120
- params[:client_secret] = client_info.client_secret
1121
- end
1906
+ authorization = apply_client_credentials!(params, client_info)
1122
1907
 
1123
1908
  response = @http_client.post(server_metadata.token_endpoint) do |req|
1124
1909
  req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
1125
1910
  req.headers['Accept'] = 'application/json'
1911
+ req.headers['Authorization'] = authorization if authorization
1126
1912
  req.body = URI.encode_www_form(params)
1127
1913
  end
1128
1914
 
@@ -1132,34 +1918,216 @@ module MCPClient
1132
1918
  end
1133
1919
 
1134
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
+
1135
1932
  new_token = Token.new(
1136
1933
  access_token: data['access_token'],
1137
- token_type: data['token_type'] || 'Bearer',
1934
+ token_type: data['token_type'],
1138
1935
  expires_in: data['expires_in'],
1139
- scope: data['scope'],
1140
- 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
1141
1941
  )
1142
1942
 
1143
- storage.set_token(server_url, new_token)
1144
- new_token
1943
+ accept_refreshed_token(new_token, server_metadata.issuer, resource, token)
1145
1944
  rescue JSON::ParserError => e
1146
- logger.warn("Invalid token refresh response: #{describe_parse_error(e)}")
1945
+ logger.warn("Invalid token refresh response: #{describe_parse_error(e, response&.body)}")
1147
1946
  nil
1148
1947
  rescue Faraday::Error => e
1149
- logger.warn("Network error during token refresh: #{e.message}")
1948
+ logger.warn("Network error during token refresh: #{safe_error_text(e.message)}")
1150
1949
  nil
1151
1950
  end
1152
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
+
1153
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.
1154
2120
  # @param body [String] Raw HTTP response body
1155
2121
  # @return [Hash, nil] Hash with :sent and :expected URIs if mismatch detected
1156
2122
  def extract_redirect_mismatch(body)
1157
- data = JSON.parse(body)
2123
+ data = JSON.parse(body.to_s)
2124
+ return nil unless data.is_a?(Hash)
2125
+
1158
2126
  error = data['error'] || data[:error]
1159
2127
  return nil unless error == 'unauthorized_client'
1160
2128
 
1161
- description = data['error_description'] || data[:error_description]
1162
- return nil unless description.is_a?(String)
2129
+ description = matchable_peer_text(data['error_description'] || data[:error_description])
2130
+ return nil unless description
1163
2131
 
1164
2132
  match = description.match(%r{You sent\s+(https?://\S+)[,.]?\s+and we expected\s+(https?://\S+)}i)
1165
2133
  return nil unless match