@@ -16,6 +16,8 @@ module OAuth
1616 class Flow
1717 TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128
1818 TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512
19+ METADATA_DIAGNOSTIC_MAX_LENGTH = 128
20+ METADATA_URL_MAX_LENGTH = 2048
1921
2022 # Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
2123 # so a provider naming one of these is refused rather than left believing its value was sent.
@@ -57,6 +59,17 @@ class InvalidGrantError < AuthorizationError; end
5759 # or authorization server metadata failure by rescuing a class rather than by matching the message text.
5860 class AuthorizationRefusedError < AuthorizationError ; end
5961
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+
6073 # Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity,
6174 # or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake,
6275 # not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on
@@ -470,15 +483,20 @@ def fetch_protected_resource_metadata(server_url:, resource_metadata_url:)
470483 #
471484 # Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
472485 # and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
473- # 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.
474492 # https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
475493 def locate_authorization_server ( server_url :, resource_metadata_url :)
476494 prm = begin
477495 fetch_protected_resource_metadata (
478496 server_url : server_url ,
479497 resource_metadata_url : resource_metadata_url ,
480498 )
481- rescue AuthorizationError
499+ rescue MetadataNotPublishedError
482500 nil
483501 end
484502
@@ -599,44 +617,63 @@ def first_authorization_server(prm)
599617 first
600618 end
601619
602- # Walks candidate metadata URLs and returns the parsed JSON body of
603- # the first 2xx response. Raises `AuthorizationError` for transport
604- # failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`)
605- # 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.
606632 def fetch_metadata_json ( urls , label :)
607- last_error = nil
633+ failures = [ ]
634+ inconclusive = false
608635 urls . each do |url |
609636 response = begin
610637 http_get ( url )
611638 rescue Faraday ::Error => e
612- 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
613642 next
614643 end
615644
616- if response . status >= 200 && response . status < 300
617- parsed = begin
618- JSON . parse ( response_body_string ( response ) )
619- rescue JSON ::ParserError => e
620- raise AuthorizationError , "Failed to parse #{ label } from #{ url } : #{ e . message } ."
621- 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
622650
623- # Even valid JSON can be the wrong shape (a top-level array,
624- # a bare `null`, a string, ...). The discovery callers index by
625- # name (`prm["authorization_servers"]`, etc.), so anything that
626- # is not a Hash would raise `TypeError` / `NoMethodError`
627- # downstream. Surface that as `AuthorizationError` instead so
628- # callers see a single, documented error type.
629- unless parsed . is_a? ( Hash )
630- raise AuthorizationError ,
631- "#{ label } from #{ url } is not a JSON object (got #{ parsed . class } )."
632- 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
633658
634- 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
635665 end
636666
637- 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
638676 end
639- raise AuthorizationError , "Failed to fetch #{ label } : #{ last_error } ."
640677 end
641678
642679 def ensure_pkce_supported! ( as_metadata )
@@ -1304,8 +1341,8 @@ def token_endpoint_error(response)
13041341 parsed = { } unless parsed . is_a? ( Hash )
13051342
13061343 error_class = parsed [ "error" ] == "invalid_grant" ? InvalidGrantError : AuthorizationError
1307- error = token_endpoint_diagnostic ( parsed [ "error" ] , limit : TOKEN_ENDPOINT_ERROR_MAX_LENGTH )
1308- description = token_endpoint_diagnostic ( parsed [ "error_description" ] , limit : TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH )
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 )
13091346 message += " #{ [ error , description ] . compact . join ( ": " ) } " if error || description
13101347
13111348 error_class . new ( message , http_status : response . status , error : error , error_description : description )
@@ -1314,17 +1351,23 @@ def token_endpoint_error(response)
13141351 error_class . new ( "Token endpoint returned status #{ response . status } ." , http_status : response . status )
13151352 end
13161353
1317- def token_endpoint_diagnostic ( value , limit :)
1354+ def bounded_diagnostic ( value , limit :)
13181355 return unless value . is_a? ( String )
13191356
1320- # RFC 6749 permits printable ASCII except double quotes and backslashes.
1321- # Replace other characters to keep provider text on one log line.
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.
13221359 value = value . scrub ( " " ) . gsub ( /[^\x20 -\x21 \x23 -\x5B \x5D -\x7E ]/ , " " ) . strip
13231360 return if value . empty?
13241361
13251362 value . length > limit ? "#{ value [ 0 , limit - 3 ] } ..." : value
13261363 end
13271364
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 )
1369+ end
1370+
13281371 # Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
13291372 # `application/x-www-form-urlencoded` encoded before they are joined with
13301373 # `:` and base64-encoded for the `Authorization: Basic` header. This is
0 commit comments