Skip to content

Commit 66b2efa

Browse files
authored
Merge pull request #561 from koic/propagate_network_errors_from_protected_resource_metadata_discovery
Surface unreachable Protected Resource Metadata instead of falling back to legacy discovery
2 parents 5f429f3 + 5081cc5 commit 66b2efa

5 files changed

Lines changed: 330 additions & 41 deletions

File tree

‎docs/_client/authorization.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
3939
- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts
4040
as the authorization base URL, its metadata is fetched from `<origin>/.well-known/oauth-authorization-server` and must name that origin as its `issuer` (RFC 8414 Section 3.3),
4141
and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed.
42+
When no Protected Resource Metadata candidate serves a JSON object, a request that could not reach the server, or that returned a `5xx` or `429`,
43+
raises `Flow::MetadataUnreachableError` instead of triggering that fallback, and a body over the response cap is refused outright.
4244
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
4345
`ClientCredentialsProvider` and `CrossAppAccessProvider` refresh the same way and fall back to their own grant instead;
4446
their refresh also requires the `issuer` the SDK records on the tokens, so tokens stored without it run the grant again.

‎lib/mcp/client/oauth/discovery.rb‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,29 @@ def canonicalize_origin_and_path(url)
336336
uri.to_s
337337
end
338338

339+
# Drops userinfo, query and fragment from `url` and leaves the host and path spelled as given, for reporting
340+
# a URL next to the request that failed, where it must still match the server's access log; `URI#to_s`
341+
# still lowercases the scheme and drops an explicit default port. Unlike `canonicalize_origin_and_path`
342+
# it neither normalizes nor resolves dot segments, so its cost stays linear in the URL length, which matters
343+
# for a URL the server chose. A URL that does not parse is not echoed, since the raw value could carry
344+
# the very credentials being dropped.
345+
def redact_url(url)
346+
uri = URI.parse(url.to_s)
347+
348+
uri.fragment = nil
349+
uri.query = nil
350+
# `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version),
351+
# so clear the components individually.
352+
if uri.respond_to?(:user) && (uri.user || uri.password)
353+
uri.user = nil
354+
uri.password = nil
355+
end
356+
357+
uri.to_s
358+
rescue URI::Error
359+
"[unparseable URL]"
360+
end
361+
339362
# Returns true when `prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL):
340363
# same scheme/host/port, with PRM's path being a prefix of the server's path. When PRM
341364
# also advertises a query string, the server's query MUST be identical to it (otherwise

‎lib/mcp/client/oauth/flow.rb‎

Lines changed: 75 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -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

‎test/mcp/client/oauth/discovery_test.rb‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,19 @@ def test_canonicalize_url_drops_userinfo
259259
)
260260
end
261261

262+
def test_redact_url_drops_credentials_query_and_fragment_but_keeps_the_spelling
263+
# Reported next to a failed request, the URL must still match the server's access log, so the host
264+
# and path are left alone: no lowercasing of the host, no dot-segment resolution, no decoding.
265+
assert_equal(
266+
"https://Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson",
267+
Discovery.redact_url("https://user:pass@Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson?token=abc#frag"),
268+
)
269+
end
270+
271+
def test_redact_url_does_not_echo_a_url_it_cannot_parse
272+
assert_equal("[unparseable URL]", Discovery.redact_url("https://user:pass@srv.example.com/pr m.json?token=abc"))
273+
end
274+
262275
def test_canonicalize_url_normalizes_query_to_match_faraday
263276
# Faraday rewrites `env.url` before sending a request: it sorts
264277
# parameters by name, uppercases percent-encoded hex, and drops

0 commit comments

Comments
 (0)