mcp 0.25.0 → 1.6.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +50 -2548
  3. data/lib/json_rpc_handler.rb +3 -2
  4. data/lib/mcp/client/http.rb +355 -66
  5. data/lib/mcp/client/mcp_param_headers.rb +242 -0
  6. data/lib/mcp/client/modern_envelope.rb +32 -0
  7. data/lib/mcp/client/oauth/authorization_request.rb +27 -0
  8. data/lib/mcp/client/oauth/bounded_body.rb +67 -0
  9. data/lib/mcp/client/oauth/client_credentials_provider.rb +20 -6
  10. data/lib/mcp/client/oauth/cross_app_access_provider.rb +21 -1
  11. data/lib/mcp/client/oauth/discovery.rb +131 -14
  12. data/lib/mcp/client/oauth/flow.rb +586 -108
  13. data/lib/mcp/client/oauth/id_jag_token_exchange.rb +12 -1
  14. data/lib/mcp/client/oauth/provider.rb +16 -1
  15. data/lib/mcp/client/oauth/storage_backed_provider.rb +49 -5
  16. data/lib/mcp/client/oauth.rb +2 -0
  17. data/lib/mcp/client/stdio.rb +271 -66
  18. data/lib/mcp/client/tool.rb +3 -2
  19. data/lib/mcp/client.rb +364 -41
  20. data/lib/mcp/configuration.rb +84 -7
  21. data/lib/mcp/elicitation/enum_schema.rb +121 -0
  22. data/lib/mcp/elicitation.rb +10 -0
  23. data/lib/mcp/error_codes.rb +14 -8
  24. data/lib/mcp/icon.rb +35 -2
  25. data/lib/mcp/instrumentation.rb +6 -0
  26. data/lib/mcp/methods.rb +21 -0
  27. data/lib/mcp/prompt.rb +8 -1
  28. data/lib/mcp/protocol_deprecations.rb +61 -0
  29. data/lib/mcp/request_envelope.rb +117 -0
  30. data/lib/mcp/server/input_required_result.rb +163 -0
  31. data/lib/mcp/server/pending_response.rb +62 -0
  32. data/lib/mcp/server/request_state_security.rb +131 -0
  33. data/lib/mcp/server/transports/stdio_transport.rb +39 -2
  34. data/lib/mcp/server/transports/streamable_http_transport.rb +820 -24
  35. data/lib/mcp/server.rb +623 -54
  36. data/lib/mcp/server_context.rb +92 -5
  37. data/lib/mcp/server_session.rb +104 -20
  38. data/lib/mcp/tool/response.rb +8 -2
  39. data/lib/mcp/tool.rb +1 -1
  40. data/lib/mcp/transport.rb +7 -0
  41. data/lib/mcp/version.rb +1 -1
  42. data/lib/mcp.rb +3 -0
  43. metadata +14 -3
@@ -14,7 +14,38 @@ module MCP
14
14
  # `Provider`; this class consumes a Provider plus signal data extracted from
15
15
  # the failing response (resource_metadata URL, scope challenge).
16
16
  class Flow
17
- class AuthorizationError < StandardError; end
17
+ TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128
18
+ TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512
19
+ METADATA_DIAGNOSTIC_MAX_LENGTH = 128
20
+ METADATA_URL_MAX_LENGTH = 2048
21
+
22
+ # Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
23
+ # so a provider naming one of these is refused rather than left believing its value was sent.
24
+ RESERVED_TOKEN_REQUEST_PARAMS = [
25
+ "grant_type",
26
+ "client_id",
27
+ "client_secret",
28
+ "client_assertion",
29
+ "client_assertion_type",
30
+ "scope",
31
+ "resource",
32
+ "code",
33
+ "code_verifier",
34
+ "redirect_uri",
35
+ "refresh_token",
36
+ "assertion",
37
+ ].freeze
38
+
39
+ class AuthorizationError < StandardError
40
+ attr_reader :http_status, :error, :error_description
41
+
42
+ def initialize(message = nil, http_status: nil, error: nil, error_description: nil)
43
+ super(message)
44
+ @http_status = http_status
45
+ @error = error
46
+ @error_description = error_description
47
+ end
48
+ end
18
49
 
19
50
  # Raised specifically when the token endpoint rejects a grant with
20
51
  # `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to
@@ -23,6 +54,141 @@ module MCP
23
54
  # should leave the refresh token intact.
24
55
  class InvalidGrantError < AuthorizationError; end
25
56
 
57
+ # Raised when `authorization_request_validator` returns a falsy value. Separate from its parent
58
+ # so that a caller can tell a refusal by its own policy from a network, discovery,
59
+ # or authorization server metadata failure by rescuing a class rather than by matching the message text.
60
+ class AuthorizationRefusedError < AuthorizationError; end
61
+
62
+ # Raised by metadata discovery when every candidate URL answered that nothing usable is published there
63
+ # (a `4xx` other than `429`, a redirect that was not followed, or a body that is not a JSON object).
64
+ # The only discovery failure that may select the legacy 2025-03-26 path.
65
+ class MetadataNotPublishedError < AuthorizationError; end
66
+
67
+ # Raised by metadata discovery when the answer says nothing about what is published: the request failed to
68
+ # reach the server, or a candidate answered `5xx` or `429`. Falling back on this would move
69
+ # the flow to a different authorization server because of a transient failure, so it is surfaced instead,
70
+ # as the TypeScript SDK does for network errors outside browsers and the Python SDK does for both.
71
+ class MetadataUnreachableError < AuthorizationError; end
72
+
73
+ # Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity,
74
+ # or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake,
75
+ # not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on
76
+ # a failed refresh as a reason to run the interactive flow.
77
+ class InvalidTokenRequestParamsError < ArgumentError; end
78
+
79
+ # Raised by `RequestedOriginGuard` when middleware added through the provider's `http_client_customizer`
80
+ # would send a request to an origin other than the one the flow validated, or has dropped the record of
81
+ # the URL the flow asked for. An `ArgumentError` because the middleware is a configuration mistake,
82
+ # and deliberately outside `AuthorizationError`, which discovery treats as "nothing published"
83
+ # and `MCP::Client::HTTP` treats on a failed refresh as a reason to run the interactive flow.
84
+ class DestinationMismatchError < ArgumentError; end
85
+
86
+ # Faraday middleware registered on the connection `build_http_client` assembles before the customizer
87
+ # is invoked, so with the usual `use` it sits ahead of the customizer's middleware and sees the URL exactly
88
+ # as the flow requested it, which it records on the request environment for `RequestedOriginGuard`.
89
+ # The guard covers what happens to a request after that record; middleware inserted ahead of it with
90
+ # `builder.insert(0, ...)` that rewrites the URL before it or rebuilds the environment is outside the guard.
91
+ # The record lives on the environment, not in `env.request.context`: that slot belongs to the application,
92
+ # which may fill it on the connection or replace it from a middleware of its own. Only the first URL seen
93
+ # on an environment is recorded: a middleware inserted ahead of this one that re-enters the stack after
94
+ # a `3xx` with the same environment, or with its `dup`, which shares the record, cannot replace it with
95
+ # the redirected URL.
96
+ class RequestedURLStamp
97
+ KEY = :mcp_oauth_requested_url
98
+
99
+ def initialize(app)
100
+ @app = app
101
+ end
102
+
103
+ def call(env)
104
+ env[KEY] ||= env.url.to_s
105
+ @app.call(env)
106
+ end
107
+ end
108
+
109
+ # Faraday middleware registered last on that connection, so it sees `env.url` after any customizer-added
110
+ # middleware has rewritten it or followed a redirect. A request that would leave the origin the flow asked
111
+ # for is refused before it reaches the adapter, since every destination check ran against the URL as
112
+ # written; a same-origin change stays with the server those checks admitted. The record survives
113
+ # the `env.dup` that redirect-following middleware performs, and a request that arrives without it is
114
+ # refused as well, so a middleware that rebuilds the environment fails closed rather than open.
115
+ # The origin boundary resembles the one the Python SDK keeps for its own auth requests, which follows
116
+ # a redirect itself only within the origin; this flow follows none.
117
+ class RequestedOriginGuard
118
+ def initialize(app)
119
+ @app = app
120
+ end
121
+
122
+ def call(env)
123
+ requested = env[RequestedURLStamp::KEY]
124
+ unless requested
125
+ raise DestinationMismatchError, <<~MESSAGE
126
+ Request to #{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect} carries no record of \
127
+ the URL the flow asked for; middleware that rebuilds the request environment is refused.
128
+ MESSAGE
129
+ end
130
+
131
+ unless Discovery.same_origin?(env.url.to_s, requested)
132
+ raise DestinationMismatchError, <<~MESSAGE
133
+ Request to #{Discovery.canonicalize_origin_and_path(requested).inspect} would be sent to \
134
+ #{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect}, on a different origin; \
135
+ middleware that follows redirects or rewrites URLs is refused.
136
+ MESSAGE
137
+ end
138
+
139
+ @app.call(env)
140
+ end
141
+ end
142
+ private_constant :RequestedURLStamp, :RequestedOriginGuard
143
+
144
+ class << self
145
+ # Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can.
146
+ # Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`,
147
+ # so the same problem reads the same wherever it surfaces.
148
+ def token_request_params_problem(params)
149
+ return "must be a Hash (got #{params.class})." unless params.is_a?(Hash)
150
+
151
+ # Two equal keys are two entries here, which would be sent twice from a provider method
152
+ # or silently collapse into one when the constructors copy the Hash.
153
+ return "must not compare keys by identity." if params.compare_by_identity?
154
+
155
+ params.each do |key, value|
156
+ return "keys must be Strings (got #{key.class})." unless key.is_a?(String)
157
+ return "values must be Strings (got #{value.class} for #{key.inspect})." unless value.is_a?(String)
158
+ return "must not set #{key.inspect}, which the SDK sets itself." if RESERVED_TOKEN_REQUEST_PARAMS.include?(key)
159
+ end
160
+
161
+ nil
162
+ end
163
+
164
+ # Builds the connection the flow uses for its own requests: the SDK's defaults, `RequestedURLStamp`,
165
+ # then `customizer` (a provider's `http_client_customizer`, called with the `Faraday::Connection`),
166
+ # then `RequestedOriginGuard` last so it sees what the customizer's middleware does to each request
167
+ # after the stamp recorded it. Every request on the connection passes through both, so a caller using
168
+ # it directly is held to the same origin rule.
169
+ #
170
+ # Deliberately built without redirect-following middleware. Every destination check in this class runs
171
+ # against the URL as written, before the request goes out, so a connection that transparently followed
172
+ # a `3xx` would let a server reach a host the checks just refused. The guard turns following at
173
+ # the middleware level into a refusal; following inside an adapter stays invisible, so a customizer must
174
+ # not enable it.
175
+ #
176
+ # `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
177
+ # the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
178
+ # which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
179
+ # after the check.
180
+ def build_http_client(customizer = nil)
181
+ require "faraday"
182
+
183
+ Faraday.new do |faraday|
184
+ faraday.headers["Accept"] = "application/json"
185
+ faraday.use(RequestedURLStamp)
186
+ customizer&.call(faraday)
187
+ faraday.use(RequestedOriginGuard)
188
+ end
189
+ end
190
+ end
191
+
26
192
  def initialize(provider:, http_client_factory: nil)
27
193
  @provider = provider
28
194
  @http_client_factory = http_client_factory || -> { default_http_client }
@@ -33,9 +199,15 @@ module MCP
33
199
  def run!(server_url:, resource_metadata_url: nil, scope: nil)
34
200
  # The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
35
201
  # first thing we contact in the OAuth flow, so it has to clear the same
36
- # Communication Security bar as the OAuth endpoints downstream.
202
+ # Communication Security bar as the OAuth endpoints downstream, and it has to
203
+ # point back at the server that issued the challenge.
37
204
  if resource_metadata_url
38
205
  ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
206
+ ensure_same_origin!(
207
+ resource_metadata_url,
208
+ label: "WWW-Authenticate resource_metadata URL",
209
+ server_url: server_url,
210
+ )
39
211
  end
40
212
 
41
213
  prm, authorization_server = locate_authorization_server(
@@ -49,21 +221,30 @@ module MCP
49
221
  # being redirected to credentials minted for a different audience.
50
222
  resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
51
223
 
52
- as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
224
+ as_metadata = authorization_server_metadata(
225
+ authorization_server: authorization_server,
226
+ legacy: prm.nil?,
227
+ server_url: server_url,
228
+ )
53
229
 
54
230
  case provider_authorization_flow
55
231
  when :client_credentials
56
- return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
232
+ return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url)
57
233
  when :jwt_bearer
58
- return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
234
+ return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url)
59
235
  end
60
236
 
61
237
  ensure_pkce_supported!(as_metadata)
62
238
 
239
+ effective_scope = resolve_scope(scope: scope, prm: prm)
240
+ effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
241
+
242
+ # Asked before registering, not after: a refusal must not leave this client registered at an authorization server
243
+ # the embedding application has just rejected.
244
+ authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
245
+
63
246
  client_info = ensure_client_registered(as_metadata: as_metadata)
64
247
 
65
- effective_scope = resolve_scope(scope: scope, prm: prm || {})
66
- effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
67
248
  pkce = PKCE.generate
68
249
  state = SecureRandom.urlsafe_base64(32)
69
250
 
@@ -99,27 +280,29 @@ module MCP
99
280
  resource: resource,
100
281
  )
101
282
 
102
- @provider.save_tokens(tokens)
283
+ save_tokens_issued_by(tokens, as_metadata: as_metadata)
103
284
  :authorized
104
285
  end
105
286
 
106
287
  # Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
107
288
  # the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
108
289
  # the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
109
- # and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
290
+ # and no `offline_access` augmentation because the grant is not expected to issue a refresh token (OAuth 2.1 Section 4.3.3).
110
291
  # The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
111
292
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
112
- def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
293
+ def run_client_credentials!(as_metadata:, prm:, resource:, scope:, server_url:)
113
294
  client_info = client_credentials_client_info
114
295
  ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
115
296
 
116
297
  form = { "grant_type" => "client_credentials" }
117
298
  effective_scope = resolve_scope(scope: scope, prm: prm)
299
+ authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
118
300
  form["scope"] = effective_scope if effective_scope
119
301
  form["resource"] = resource if resource
120
302
 
121
303
  tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
122
- @provider.save_tokens(tokens)
304
+ save_tokens_issued_by(tokens, as_metadata: as_metadata)
305
+
123
306
  :authorized
124
307
  end
125
308
 
@@ -163,12 +346,18 @@ module MCP
163
346
  # and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request.
164
347
  # The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated.
165
348
  # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
166
- def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:)
349
+ def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:, server_url:)
167
350
  client_info = @provider.client_information
168
351
  unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
169
352
  raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`."
170
353
  end
171
354
 
355
+ # Asked before the assertion is minted, for the same reason registration waits on the authorization-code grant:
356
+ # obtaining an ID-JAG sends the identity provider an audience of this authorization server, which must not happen once
357
+ # the host has refused it.
358
+ effective_scope = resolve_scope(scope: scope, prm: prm)
359
+ authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
360
+
172
361
  assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource)
173
362
  if assertion.nil? || assertion.to_s.empty?
174
363
  raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion."
@@ -178,12 +367,11 @@ module MCP
178
367
  "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
179
368
  "assertion" => assertion,
180
369
  }
181
- effective_scope = resolve_scope(scope: scope, prm: prm)
182
370
  form["scope"] = effective_scope if effective_scope
183
371
  form["resource"] = resource if resource
184
372
 
185
373
  tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
186
- @provider.save_tokens(tokens)
374
+ save_tokens_issued_by(tokens, as_metadata: as_metadata)
187
375
  :authorized
188
376
  end
189
377
 
@@ -192,19 +380,22 @@ module MCP
192
380
  # checks before talking to it.
193
381
  #
194
382
  # Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
383
+ # when a `client_credentials` or `jwt-bearer` provider's tokens record no issuer,
195
384
  # or when the token endpoint refuses the refresh request.
196
385
  # https://www.rfc-editor.org/rfc/rfc6749#section-6
197
386
  def refresh!(server_url:, resource_metadata_url: nil)
198
387
  refresh_token = read_token("refresh_token")
199
388
  raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
200
389
 
390
+ ensure_refresh_token_issuer_recorded!
391
+
201
392
  stored_client_info = @provider.client_information
202
393
  have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
203
394
 
204
395
  # A CIMD-configured provider stores no `client_information` on purpose
205
396
  # (the CIMD URL is re-resolved against the live AS metadata on every flow).
206
397
  # Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
207
- have_cimd_url = !@provider.client_id_metadata_document_url.nil?
398
+ have_cimd_url = !provider_client_id_metadata_document_url.nil?
208
399
 
209
400
  unless have_stored_client_info || have_cimd_url
210
401
  raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
@@ -212,6 +403,11 @@ module MCP
212
403
 
213
404
  if resource_metadata_url
214
405
  ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
406
+ ensure_same_origin!(
407
+ resource_metadata_url,
408
+ label: "WWW-Authenticate resource_metadata URL",
409
+ server_url: server_url,
410
+ )
215
411
  end
216
412
 
217
413
  prm, authorization_server = locate_authorization_server(
@@ -221,7 +417,13 @@ module MCP
221
417
 
222
418
  resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
223
419
 
224
- as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
420
+ as_metadata = authorization_server_metadata(
421
+ authorization_server: authorization_server,
422
+ legacy: prm.nil?,
423
+ server_url: server_url,
424
+ )
425
+
426
+ ensure_token_issuer!(as_metadata: as_metadata)
225
427
 
226
428
  client_info = if have_stored_client_info
227
429
  # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
@@ -229,7 +431,7 @@ module MCP
229
431
  ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata)
230
432
  stored_client_info
231
433
  elsif as_metadata["client_id_metadata_document_supported"] == true
232
- { "client_id" => @provider.client_id_metadata_document_url }
434
+ { "client_id" => provider_client_id_metadata_document_url }
233
435
  else
234
436
  raise AuthorizationError,
235
437
  "Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
@@ -243,7 +445,7 @@ module MCP
243
445
  resource: resource,
244
446
  )
245
447
 
246
- @provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token))
448
+ save_tokens_issued_by(preserve_refresh_token(new_tokens, refresh_token), as_metadata: as_metadata)
247
449
  :refreshed
248
450
  end
249
451
 
@@ -281,7 +483,12 @@ module MCP
281
483
  #
282
484
  # Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
283
485
  # and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
284
- # Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior.
486
+ # Only a discovery answer saying that nothing usable is published (`MetadataNotPublishedError`: a `4xx` other than `429`,
487
+ # a redirect that was not followed, or a body that is not a JSON object) selects the legacy path.
488
+ # A request that failed to reach the server, or returned a `5xx` or `429`, says nothing about what the server publishes,
489
+ # so once no candidate has served a usable document it is surfaced instead (`MetadataUnreachableError`),
490
+ # as both SDKs do for network errors (the TypeScript SDK outside browsers) and the Python SDK does for server errors.
491
+ # A body over the response cap is refused outright and never reaches the fallback either.
285
492
  # https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
286
493
  def locate_authorization_server(server_url:, resource_metadata_url:)
287
494
  prm = begin
@@ -289,13 +496,18 @@ module MCP
289
496
  server_url: server_url,
290
497
  resource_metadata_url: resource_metadata_url,
291
498
  )
292
- rescue AuthorizationError
499
+ rescue MetadataNotPublishedError
293
500
  nil
294
501
  end
295
502
 
296
503
  if prm
297
504
  authorization_server = first_authorization_server(prm)
298
505
  ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
506
+ ensure_routable_destination!(
507
+ authorization_server,
508
+ label: "PRM `authorization_servers` entry",
509
+ server_url: server_url,
510
+ )
299
511
  [prm, authorization_server]
300
512
  else
301
513
  authorization_base = server_origin!(server_url)
@@ -306,16 +518,26 @@ module MCP
306
518
 
307
519
  # Fetches and validates the authorization server's RFC 8414 metadata.
308
520
  #
309
- # On the modern path the metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3).
310
- # On the legacy 2025-03-26 path that validation is skipped: the legacy spec predates the requirement,
311
- # and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from
312
- # the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path).
521
+ # The metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3) on both paths.
522
+ # On the legacy 2025-03-26 path the discovery URL is the MCP server's origin, which that spec names as
523
+ # the authorization base URL and which a document may render with a trailing slash; the TypeScript and Python SDKs
524
+ # accept the same slash-only difference. A document naming any other issuer is refused: an unverified `issuer`
525
+ # would otherwise become the identity tokens and client information are bound to, assertions are minted for,
526
+ # and the validator is shown, so a server could claim another authorization server and unlock the credentials
527
+ # bound to it.
313
528
  # When even the metadata document is absent, the legacy spec's default endpoints are used.
314
- def authorization_server_metadata(authorization_server:, legacy:)
529
+ def authorization_server_metadata(authorization_server:, legacy:, server_url:)
315
530
  metadata = if legacy
316
- begin
531
+ fetched = begin
317
532
  fetch_authorization_server_metadata(issuer_url: authorization_server)
318
533
  rescue AuthorizationError
534
+ nil
535
+ end
536
+
537
+ if fetched
538
+ ensure_legacy_issuer_matches!(expected: authorization_server, returned: fetched["issuer"])
539
+ fetched
540
+ else
319
541
  default_legacy_metadata(authorization_server)
320
542
  end
321
543
  else
@@ -324,7 +546,7 @@ module MCP
324
546
  end
325
547
  end
326
548
 
327
- ensure_secure_endpoints!(metadata)
549
+ ensure_secure_endpoints!(metadata, server_url: server_url)
328
550
  metadata
329
551
  end
330
552
 
@@ -360,6 +582,14 @@ module MCP
360
582
  fetch_metadata_json(urls, label: "authorization server metadata")
361
583
  end
362
584
 
585
+ # The legacy authorization base is an origin, which a document may render as `https://host/`;
586
+ # both name the same server, and nothing else does.
587
+ def ensure_legacy_issuer_matches!(expected:, returned:)
588
+ return if returned == "#{expected}/"
589
+
590
+ ensure_issuer_matches!(expected: expected, returned: returned)
591
+ end
592
+
363
593
  # Reads `authorization_servers` from a PRM document and returns
364
594
  # the first entry, raising `AuthorizationError` for any of the malformed
365
595
  # shapes a non-compliant server could emit (missing field, non-Array
@@ -387,44 +617,63 @@ module MCP
387
617
  first
388
618
  end
389
619
 
390
- # Walks candidate metadata URLs and returns the parsed JSON body of
391
- # the first 2xx response. Raises `AuthorizationError` for transport
392
- # failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`)
393
- # so callers do not have to handle raw Faraday/JSON exceptions.
620
+ # Walks candidate metadata URLs and returns the parsed body of the first 2xx response that is a JSON object;
621
+ # the caller checks its fields. Candidates are tried until one serves such a body, since a later one may still
622
+ # be usable when an earlier one is broken or down (the URL from `WWW-Authenticate` against the well-known path,
623
+ # or the OAuth document against the OpenID one). Once the candidates are exhausted, an answer that said nothing
624
+ # about what is published (a network error, a `5xx`, or a `429`) outranks the rest and raises
625
+ # `MetadataUnreachableError`; otherwise (any other status, such as a `4xx` other than `429` or a redirect that
626
+ # was not followed, a body that is not JSON, or not a JSON object) `MetadataNotPublishedError`.
627
+ # A body over the cap is refused outright by `bounded_request` with a plain `AuthorizationError`,
628
+ # before any classification. Each failure is listed with its URL stripped of userinfo, query and fragment
629
+ # and cut to `METADATA_URL_MAX_LENGTH`, but otherwise spelled as requested, so it can be matched against
630
+ # a server's access log, and with exception text bounded, since the message lands in every log destination
631
+ # the error passes through.
394
632
  def fetch_metadata_json(urls, label:)
395
- last_error = nil
633
+ failures = []
634
+ inconclusive = false
396
635
  urls.each do |url|
397
636
  response = begin
398
637
  http_get(url)
399
638
  rescue Faraday::Error => e
400
- last_error = "GET #{url} raised #{e.class}: #{e.message}"
639
+ detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH)
640
+ failures << "GET #{reported_url(url)} raised #{[e.class, detail].compact.join(": ")}"
641
+ inconclusive = true
401
642
  next
402
643
  end
403
644
 
404
- if response.status >= 200 && response.status < 300
405
- parsed = begin
406
- JSON.parse(response_body_string(response))
407
- rescue JSON::ParserError => e
408
- raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}."
409
- end
645
+ unless response.status >= 200 && response.status < 300
646
+ failures << "GET #{reported_url(url)} returned #{response.status}"
647
+ inconclusive = true if response.status >= 500 || response.status == 429
648
+ next
649
+ end
410
650
 
411
- # Even valid JSON can be the wrong shape (a top-level array,
412
- # a bare `null`, a string, ...). The discovery callers index by
413
- # name (`prm["authorization_servers"]`, etc.), so anything that
414
- # is not a Hash would raise `TypeError` / `NoMethodError`
415
- # downstream. Surface that as `AuthorizationError` instead so
416
- # callers see a single, documented error type.
417
- unless parsed.is_a?(Hash)
418
- raise AuthorizationError,
419
- "#{label} from #{url} is not a JSON object (got #{parsed.class})."
420
- end
651
+ parsed = begin
652
+ JSON.parse(response_body_string(response))
653
+ rescue JSON::ParserError => e
654
+ detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH) || e.class.name
655
+ failures << "GET #{reported_url(url)} returned a body that is not JSON: #{detail}"
656
+ next
657
+ end
421
658
 
422
- return parsed
659
+ # Even valid JSON can be the wrong shape (a top-level array, a bare `null`, a string, ...).
660
+ # The discovery callers index by name (`prm["authorization_servers"]`, etc.), so anything that
661
+ # is not a Hash would raise `TypeError` / `NoMethodError` downstream.
662
+ unless parsed.is_a?(Hash)
663
+ failures << "GET #{reported_url(url)} returned a body that is not a JSON object (got #{parsed.class})"
664
+ next
423
665
  end
424
666
 
425
- last_error = "GET #{url} returned #{response.status}"
667
+ return parsed
668
+ end
669
+
670
+ message = "Failed to fetch #{label}: #{failures.join("; ")}."
671
+
672
+ if inconclusive
673
+ raise MetadataUnreachableError, message
674
+ else
675
+ raise MetadataNotPublishedError, message
426
676
  end
427
- raise AuthorizationError, "Failed to fetch #{label}: #{last_error}."
428
677
  end
429
678
 
430
679
  def ensure_pkce_supported!(as_metadata)
@@ -445,13 +694,109 @@ module MCP
445
694
  "#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)."
446
695
  end
447
696
 
448
- def ensure_secure_endpoints!(as_metadata)
697
+ # Requires a URL the *server* chose to sit on the origin the *caller* chose.
698
+ #
699
+ # Protected Resource Metadata describes the MCP server itself, so on a real deployment it is
700
+ # published on that server's own origin. Without this check a `WWW-Authenticate` challenge
701
+ # can aim the first request of the flow at any host the client can route to: the URL arrives
702
+ # from the network, it is fetched before the user approves anything, and the `resource` check that
703
+ # runs afterwards cannot un-send the request.
704
+ #
705
+ # RFC 9728 does not itself require the metadata URL to be same-origin, so this is stricter than
706
+ # the specification. It is enforced unconditionally because no known deployment publishes its PRM
707
+ # anywhere else, and because the alternative (`Discovery.private_network_host?`) cannot see
708
+ # internal hosts that are named rather than addressed.
709
+ # https://www.rfc-editor.org/rfc/rfc9728#section-7.7
710
+ def ensure_same_origin!(url, label:, server_url:)
711
+ return if Discovery.same_origin?(url, server_url)
712
+
713
+ raise AuthorizationError,
714
+ "#{label} #{sanitized_url(url).inspect} is not on the MCP server origin " \
715
+ "#{sanitized_url(server_url).inspect}; refusing to fetch it."
716
+ end
717
+
718
+ # Refuses an OAuth URL that points into a private, loopback, link-local, or unique-local address,
719
+ # which is the SSRF precaution RFC 9728 Section 7.7 and the MCP security best practices ask clients to take.
720
+ #
721
+ # The carve-out matters as much as the rule: when the MCP server the caller configured is itself on such an address,
722
+ # the whole flow is already inside that network and the authorization server legitimately lives there too.
723
+ # That covers `http://localhost` development, the conformance harness (which runs the MCP server and
724
+ # the authorization server on two loopback ports), and deployments that never leave a corporate network.
725
+ # Only a server reachable on the public internet is barred from steering the client inward.
726
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
727
+ # Hands the embedding application the authorization server and the scopes that are about to be requested,
728
+ # and abandons the flow when it refuses them.
729
+ #
730
+ # Both values are chosen by the MCP server: it names its own authorization server in Protected
731
+ # Resource Metadata and states the scopes in `scopes_supported` or the `WWW-Authenticate` challenge.
732
+ # Neither the specification nor any MCP SDK binds that choice to the server's own identity,
733
+ # and validating that a token was issued for the intended audience is a responsibility the specification
734
+ # places on MCP servers rather than on clients.
735
+ # A host that knows which providers its user deals with can apply that knowledge here.
736
+ #
737
+ # The scopes are passed on unchanged whatever the host decides, because the specification requires
738
+ # a client to treat the challenged scopes as authoritative for the operation; the choice offered is
739
+ # to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before.
740
+ #
741
+ # Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered
742
+ # this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead:
743
+ # an authorization server that has changed since the tokens were issued sends the flow back through here,
744
+ # where the host sees the new one and decides again.
745
+ def authorize_request!(as_metadata:, scope:, server_url:, resource:)
746
+ return unless @provider.respond_to?(:authorization_request_validator)
747
+ return unless (validator = @provider.authorization_request_validator)
748
+
749
+ request = AuthorizationRequest.new(
750
+ authorization_server: as_metadata["issuer"],
751
+ scopes: scope.to_s.split,
752
+ server_url: server_url,
753
+ resource: resource,
754
+ ).freeze
755
+
756
+ return if validator.call(request)
757
+
758
+ raise AuthorizationRefusedError, <<~MESSAGE
759
+ The authorization request was refused by `authorization_request_validator` \
760
+ (authorization server #{request.authorization_server.inspect}, scopes #{request.scopes.inspect}).
761
+ MESSAGE
762
+ end
763
+
764
+ def ensure_routable_destination!(url, label:, server_url:)
765
+ return unless private_network_url?(url)
766
+ return if private_network_url?(server_url)
767
+
768
+ raise AuthorizationError,
769
+ "#{label} #{sanitized_url(url).inspect} points into a private network range, " \
770
+ "which the MCP server at #{sanitized_url(server_url).inspect} is not on; refusing to contact it."
771
+ end
772
+
773
+ def ensure_secure_endpoints!(as_metadata, server_url:)
449
774
  ["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key|
450
775
  endpoint = as_metadata[key]
451
- ensure_secure_url!(endpoint, label: "Authorization server #{key}") if endpoint
776
+ next unless endpoint
777
+
778
+ ensure_secure_url!(endpoint, label: "Authorization server #{key}")
779
+ ensure_routable_destination!(endpoint, label: "Authorization server #{key}", server_url: server_url)
452
780
  end
453
781
  end
454
782
 
783
+ # `ensure_secure_url!` runs first at every call site and already rejects a URL that fails to parse,
784
+ # so the rescue here is a backstop rather than a leniency.
785
+ def private_network_url?(url)
786
+ Discovery.private_network_host?(URI.parse(url.to_s).host)
787
+ rescue URI::InvalidURIError
788
+ false
789
+ end
790
+
791
+ # Strips userinfo and query before a URL reaches an exception message, the same precaution `MCP::Client::HTTP` takes
792
+ # when it reports a URL: these values come off the network and can carry credentials that would otherwise land in
793
+ # every log destination the error passes through.
794
+ def sanitized_url(url)
795
+ Discovery.canonicalize_origin_and_path(url)
796
+ rescue URI::Error
797
+ url.to_s
798
+ end
799
+
455
800
  # Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be
456
801
  # identical (literal byte-for-byte equality, no normalization) to
457
802
  # the issuer URL the client used to discover that document. This guards
@@ -489,7 +834,7 @@ module MCP
489
834
  # (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
490
835
  # keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
491
836
  # and the current `provider.client_id_metadata_document_url`.
492
- cimd_url = @provider.client_id_metadata_document_url
837
+ cimd_url = provider_client_id_metadata_document_url
493
838
  if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
494
839
  return { "client_id" => cimd_url }
495
840
  end
@@ -586,6 +931,51 @@ module MCP
586
931
  # the error and falls back to the full flow, which discards the stale registration and re-registers.
587
932
  # Credentials without an `"issuer"` binding predate this check and are allowed through;
588
933
  # CIMD `client_id`s are portable.
934
+ # Records which authorization server minted these tokens, alongside the tokens themselves.
935
+ # SEP-2352 already binds *client credentials* to their issuer; this binds the *tokens*, which that
936
+ # SEP leaves unbound.
937
+ # Stored in the token hash so it travels through any `storage` a caller supplied, without widening
938
+ # the storage interface that custom implementations have to satisfy.
939
+ def save_tokens_issued_by(tokens, as_metadata:)
940
+ issuer = as_metadata["issuer"]
941
+ tokens = tokens.merge("issuer" => issuer) if tokens.is_a?(Hash) && issuer
942
+
943
+ @provider.save_tokens(tokens)
944
+ end
945
+
946
+ # Refuses to present a refresh token to an authorization server other than the one that issued it.
947
+ # `refresh!` rediscovers the authorization server from Protected Resource Metadata every time,
948
+ # so the server named for a session can differ from the one the stored tokens came from.
949
+ # Unlike `ensure_refreshable_client_information!` this holds whatever the client identity is,
950
+ # including a Client ID Metadata Document URL, which is portable across authorization servers precisely
951
+ # so that re-registration is unnecessary.
952
+ #
953
+ # Tokens stored before this shipped carry no issuer and are left alone, matching how SEP-2352 treats
954
+ # client information without one; the binding takes effect from their next authorization.
955
+ # The caller answers a refusal by running the full authorization flow, which is where the embedding application
956
+ # is asked about the new authorization server.
957
+ def ensure_token_issuer!(as_metadata:)
958
+ stored_issuer = read_token("issuer")
959
+ return if stored_issuer.nil? || stored_issuer == as_metadata["issuer"]
960
+
961
+ raise AuthorizationError, <<~MESSAGE
962
+ Cannot refresh: the stored tokens were issued by a different authorization server (stored issuer #{stored_issuer.inspect}, \
963
+ current #{as_metadata["issuer"].inspect}); re-authorization is required.
964
+ MESSAGE
965
+ end
966
+
967
+ # `Provider` tolerates tokens stored before the issuer was recorded (see `ensure_token_issuer!`).
968
+ # The `client_credentials` and `jwt-bearer` providers have no such tokens, since their refresh is new,
969
+ # and a refresh asks no validator, so a token without an `issuer` would be presented to whatever
970
+ # authorization server discovery names now. Refusing it sends the transport back through the grant,
971
+ # which does ask.
972
+ def ensure_refresh_token_issuer_recorded!
973
+ return if provider_authorization_flow == :authorization_code
974
+ return unless read_token("issuer").nil?
975
+
976
+ raise AuthorizationError, "Cannot refresh: the stored tokens record no issuer; re-authorization is required."
977
+ end
978
+
589
979
  def ensure_refreshable_client_information!(client_info, as_metadata:)
590
980
  stored_issuer = client_info_required_value(client_info, "issuer")
591
981
  return if stored_issuer.nil?
@@ -714,7 +1104,8 @@ module MCP
714
1104
  def resolve_scope(scope:, prm:)
715
1105
  return scope if scope && !scope.empty?
716
1106
 
717
- supported = prm["scopes_supported"]
1107
+ # `prm` is nil on the legacy path, where nothing advertises scopes.
1108
+ supported = prm && prm["scopes_supported"]
718
1109
  return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
719
1110
 
720
1111
  return @provider.scope if @provider.scope && !@provider.scope.empty?
@@ -776,6 +1167,31 @@ module MCP
776
1167
  @provider.authorization_flow
777
1168
  end
778
1169
 
1170
+ # Parameters the provider adds to every token request it makes (RFC 6749 Section 8.2 leaves room for them;
1171
+ # Auth0's `audience` is the usual one). Duck-typed like `authorization_flow`, so a provider without the method,
1172
+ # or one returning `nil`, adds nothing.
1173
+ # A bad value raises `InvalidTokenRequestParamsError`, as the provider constructors do.
1174
+ def provider_token_request_params
1175
+ return {} unless @provider.respond_to?(:token_request_params)
1176
+
1177
+ params = @provider.token_request_params
1178
+ return {} if params.nil?
1179
+
1180
+ problem = self.class.token_request_params_problem(params)
1181
+ raise InvalidTokenRequestParamsError, "The provider's token_request_params #{problem}" if problem
1182
+
1183
+ params
1184
+ end
1185
+
1186
+ # The Client ID Metadata Document URL, when the provider has one. Only `Provider` exposes the reader
1187
+ # (CIMD replaces Dynamic Client Registration on the authorization-code flow), while `refresh!` serves
1188
+ # every provider that holds a `refresh_token`, so the read is duck-typed like `authorization_flow`.
1189
+ def provider_client_id_metadata_document_url
1190
+ return unless @provider.respond_to?(:client_id_metadata_document_url)
1191
+
1192
+ @provider.client_id_metadata_document_url
1193
+ end
1194
+
779
1195
  def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
780
1196
  authorization_endpoint = as_metadata["authorization_endpoint"]
781
1197
  unless authorization_endpoint
@@ -825,9 +1241,12 @@ module MCP
825
1241
  post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
826
1242
  end
827
1243
 
828
- # Submits a form-encoded request to the token endpoint, applying
829
- # the client authentication method advertised in `client_information` and
830
- # adding `client_id` (and `client_secret` when not using HTTP Basic).
1244
+ # Submits a form-encoded token request using the authentication method
1245
+ # stored in `client_information`. The method determines whether client
1246
+ # credentials belong in the form body, a Basic header, or a JWT assertion.
1247
+ # A provider's `token_request_params` go underneath the flow's own parameters,
1248
+ # which therefore win, and are refused before the request is sent when they name
1249
+ # a reserved parameter or are not a Hash of Strings.
831
1250
  def post_to_token_endpoint(as_metadata:, client_info:, form:)
832
1251
  client_id = client_info_required_value(client_info, "client_id")
833
1252
  unless client_id
@@ -837,13 +1256,15 @@ module MCP
837
1256
 
838
1257
  client_secret = client_info_required_value(client_info, "client_secret")
839
1258
  token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
1259
+ form = provider_token_request_params.merge(form)
840
1260
 
841
- form = if token_endpoint_auth_method == "private_key_jwt"
842
- # RFC 7523 Section 2.2 JWT client assertion for the `private_key_jwt` method of
843
- # the `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046).
844
- # The client identity travels in the assertion's `iss`/`sub` claims, so `client_id` is
845
- # omitted from the body per RFC 7521 Section 4.2 (the `client_assertion` conveys the client identity).
846
- # The audience is the issuer identifier that `ensure_issuer_matches!` already byte-validated.
1261
+ # Apply one client authentication method per request (RFC 6749 Section 2.3).
1262
+ headers = {}
1263
+ form = case token_endpoint_auth_method
1264
+ when "private_key_jwt"
1265
+ # The assertion identifies the client through its `iss` and `sub`
1266
+ # claims, so the body needs no separate `client_id` (RFC 7521 Section 4.2).
1267
+ # Use the issuer already checked by `ensure_issuer_matches!` as the audience.
847
1268
  unless @provider.respond_to?(:client_assertion)
848
1269
  raise AuthorizationError,
849
1270
  "token_endpoint_auth_method is private_key_jwt but the provider does not " \
@@ -854,22 +1275,24 @@ module MCP
854
1275
  "client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE,
855
1276
  "client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]),
856
1277
  )
1278
+ when "client_secret_post"
1279
+ # Send the client ID and available secret in the form body.
1280
+ body = form.merge("client_id" => client_id)
1281
+ body["client_secret"] = client_secret if client_secret
1282
+ body
857
1283
  else
858
- form.merge("client_id" => client_id)
859
- end
860
-
861
- headers = {}
862
- if client_secret
863
- case token_endpoint_auth_method
864
- when "client_secret_post"
865
- form["client_secret"] = client_secret
866
- when "none"
867
- # Public client; no credential.
868
- else
869
- # RFC 6749 §2.3.1 recommends Basic for confidential clients and
870
- # both Python and TypeScript SDKs default here when
871
- # the authentication method is not explicitly stored.
1284
+ if client_secret && token_endpoint_auth_method != "none"
1285
+ # Basic is also the fallback when a secret is present but no method
1286
+ # is stored. A body `client_id` is optional (RFC 6749 Section 3.2.1);
1287
+ # omit it because some servers treat it alongside Basic as a second
1288
+ # authentication method and reject the request with `invalid_request`.
872
1289
  headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret)
1290
+ form
1291
+ else
1292
+ # With `none` or no secret, identify the client using `client_id`.
1293
+ # This is required for unauthenticated authorization-code exchanges
1294
+ # (RFC 6749 Section 3.2.1).
1295
+ form.merge("client_id" => client_id)
873
1296
  end
874
1297
  end
875
1298
 
@@ -887,11 +1310,7 @@ module MCP
887
1310
  end
888
1311
 
889
1312
  if response.status < 200 || response.status >= 300
890
- if token_endpoint_error_code(response) == "invalid_grant"
891
- raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant."
892
- end
893
-
894
- raise AuthorizationError, "Token endpoint returned status #{response.status}."
1313
+ raise token_endpoint_error(response)
895
1314
  end
896
1315
 
897
1316
  parsed = begin
@@ -912,17 +1331,41 @@ module MCP
912
1331
  parsed
913
1332
  end
914
1333
 
915
- # Extracts the `error` code from an RFC 6749 §5.2 error response body
916
- # when one is parseable. Returns nil on any parse failure or when
917
- # the body is not JSON.
918
- def token_endpoint_error_code(response)
919
- body = response_body_string(response).to_s
920
- return if body.empty?
1334
+ # Surface only RFC 6749 §5.2 diagnostic fields, never the raw response,
1335
+ # which may contain tokens or other credentials. Classify the original
1336
+ # code so sanitization cannot turn malformed input into invalid_grant.
1337
+ def token_endpoint_error(response)
1338
+ message = "Token endpoint returned status #{response.status}."
1339
+ error_class = AuthorizationError
1340
+ parsed = JSON.parse(response_body_string(response))
1341
+ parsed = {} unless parsed.is_a?(Hash)
1342
+
1343
+ error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError
1344
+ error = bounded_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
1345
+ description = bounded_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
1346
+ message += " #{[error, description].compact.join(": ")}" if error || description
1347
+
1348
+ error_class.new(message, http_status: response.status, error: error, error_description: description)
1349
+ rescue StandardError
1350
+ # Diagnostics must not mask the endpoint failure or change refresh recovery.
1351
+ error_class.new("Token endpoint returned status #{response.status}.", http_status: response.status)
1352
+ end
921
1353
 
922
- parsed = JSON.parse(body)
923
- parsed["error"] if parsed.is_a?(Hash)
924
- rescue JSON::ParserError
925
- nil
1354
+ def bounded_diagnostic(value, limit:)
1355
+ return unless value.is_a?(String)
1356
+
1357
+ # RFC 6749 permits printable ASCII except double quotes and backslashes in token endpoint error fields.
1358
+ # Replace other characters to keep text received off the network on one log line.
1359
+ value = value.scrub(" ").gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, " ").strip
1360
+ return if value.empty?
1361
+
1362
+ value.length > limit ? "#{value[0, limit - 3]}..." : value
1363
+ end
1364
+
1365
+ # A candidate URL as it goes into a failure string: redacted, and cut so a URL the server chose cannot
1366
+ # grow the message without limit.
1367
+ def reported_url(url)
1368
+ bounded_diagnostic(Discovery.redact_url(url), limit: METADATA_URL_MAX_LENGTH)
926
1369
  end
927
1370
 
928
1371
  # Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
@@ -938,35 +1381,70 @@ module MCP
938
1381
  end
939
1382
 
940
1383
  def http_get(url)
941
- http_client.get(url)
1384
+ bounded_request do |on_data|
1385
+ http_client.get(url) do |req|
1386
+ req.options.on_data = on_data
1387
+ end
1388
+ end
942
1389
  end
943
1390
 
944
1391
  def http_post_json(url, body)
945
- http_client.post(url) do |req|
946
- req.headers["Content-Type"] = "application/json"
947
- req.headers["Accept"] = "application/json"
948
- req.body = JSON.generate(body)
1392
+ bounded_request do |on_data|
1393
+ http_client.post(url) do |req|
1394
+ req.headers["Content-Type"] = "application/json"
1395
+ req.headers["Accept"] = "application/json"
1396
+ req.options.on_data = on_data
1397
+ req.body = JSON.generate(body)
1398
+ end
949
1399
  end
950
1400
  end
951
1401
 
952
1402
  def http_post_form(url, form, headers: {})
953
- http_client.post(url) do |req|
954
- req.headers["Content-Type"] = "application/x-www-form-urlencoded"
955
- req.headers["Accept"] = "application/json"
956
- headers.each { |key, value| req.headers[key] = value }
957
- req.body = URI.encode_www_form(form)
1403
+ bounded_request do |on_data|
1404
+ http_client.post(url) do |req|
1405
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
1406
+ req.headers["Accept"] = "application/json"
1407
+
1408
+ headers.each do |key, value|
1409
+ req.headers[key] = value
1410
+ end
1411
+
1412
+ req.options.on_data = on_data
1413
+ req.body = URI.encode_www_form(form)
1414
+ end
958
1415
  end
959
1416
  end
960
1417
 
1418
+ # Issues a request with the response body bounded as it arrives, and returns the status paired with
1419
+ # that body. An over-cap response is refused rather than truncated: a partial discovery or token document
1420
+ # cannot be validated, and `fetch_metadata_json` must not fall through to the next candidate URL either,
1421
+ # since the same server would serve the same body.
1422
+ def bounded_request
1423
+ bounded = BoundedBody.new
1424
+
1425
+ response = yield(bounded.on_data)
1426
+
1427
+ bounded.response_for(response)
1428
+ rescue BoundedBody::TooLargeError => e
1429
+ raise AuthorizationError, "#{e.message}."
1430
+ end
1431
+
961
1432
  def http_client
962
1433
  @http_client ||= @http_client_factory.call
963
1434
  end
964
1435
 
1436
+ # A connection supplied through `http_client_factory:` replaces this one, the provider's customizer and
1437
+ # `RequestedOriginGuard` included, so that caller takes on the redirect responsibility described on
1438
+ # `build_http_client`; `bounded_request` caps its responses all the same.
965
1439
  def default_http_client
966
- require "faraday"
967
- Faraday.new do |faraday|
968
- faraday.headers["Accept"] = "application/json"
969
- end
1440
+ self.class.build_http_client(provider_http_client_customizer)
1441
+ end
1442
+
1443
+ # `nil` for a provider that predates the hook or leaves it unset.
1444
+ def provider_http_client_customizer
1445
+ return unless @provider.respond_to?(:http_client_customizer)
1446
+
1447
+ @provider.http_client_customizer
970
1448
  end
971
1449
 
972
1450
  def response_body_string(response)