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
This commit is contained in:
Koichi ITO 2026-09-20 00:03:41 +09:00 • committed by GitHub
commit 66b2efa11e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 330 additions and 41 deletions

View file

@ -39,6 +39,8 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
- 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
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),
and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed.
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`,
raises `Flow::MetadataUnreachableError` instead of triggering that fallback, and a body over the response cap is refused outright.
- 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).
`ClientCredentialsProvider` and `CrossAppAccessProvider` refresh the same way and fall back to their own grant instead;
their refresh also requires the `issuer` the SDK records on the tokens, so tokens stored without it run the grant again.

View file

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

View file

@ -16,6 +16,8 @@ module MCP
class Flow
TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128
TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512
METADATA_DIAGNOSTIC_MAX_LENGTH = 128
METADATA_URL_MAX_LENGTH = 2048
# Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
# so a provider naming one of these is refused rather than left believing its value was sent.
@ -57,6 +59,17 @@ module MCP
# or authorization server metadata failure by rescuing a class rather than by matching the message text.
class AuthorizationRefusedError < AuthorizationError; end
# Raised by metadata discovery when every candidate URL answered that nothing usable is published there
# (a `4xx` other than `429`, a redirect that was not followed, or a body that is not a JSON object).
# The only discovery failure that may select the legacy 2025-03-26 path.
class MetadataNotPublishedError < AuthorizationError; end
# Raised by metadata discovery when the answer says nothing about what is published: the request failed to
# reach the server, or a candidate answered `5xx` or `429`. Falling back on this would move
# the flow to a different authorization server because of a transient failure, so it is surfaced instead,
# as the TypeScript SDK does for network errors outside browsers and the Python SDK does for both.
class MetadataUnreachableError < AuthorizationError; end
# Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity,
# or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake,
# not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on
@ -470,7 +483,12 @@ module MCP
#
# Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
# and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
# Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior.
# Only a discovery answer saying that nothing usable is published (`MetadataNotPublishedError`: a `4xx` other than `429`,
# a redirect that was not followed, or a body that is not a JSON object) selects the legacy path.
# A request that failed to reach the server, or returned a `5xx` or `429`, says nothing about what the server publishes,
# so once no candidate has served a usable document it is surfaced instead (`MetadataUnreachableError`),
# as both SDKs do for network errors (the TypeScript SDK outside browsers) and the Python SDK does for server errors.
# A body over the response cap is refused outright and never reaches the fallback either.
# https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
def locate_authorization_server(server_url:, resource_metadata_url:)
prm = begin
@ -478,7 +496,7 @@ module MCP
server_url: server_url,
resource_metadata_url: resource_metadata_url,
)
rescue AuthorizationError
rescue MetadataNotPublishedError
nil
end
@ -599,44 +617,63 @@ module MCP
first
end
# Walks candidate metadata URLs and returns the parsed JSON body of
# the first 2xx response. Raises `AuthorizationError` for transport
# failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`)
# so callers do not have to handle raw Faraday/JSON exceptions.
# Walks candidate metadata URLs and returns the parsed body of the first 2xx response that is a JSON object;
# the caller checks its fields. Candidates are tried until one serves such a body, since a later one may still
# be usable when an earlier one is broken or down (the URL from `WWW-Authenticate` against the well-known path,
# or the OAuth document against the OpenID one). Once the candidates are exhausted, an answer that said nothing
# about what is published (a network error, a `5xx`, or a `429`) outranks the rest and raises
# `MetadataUnreachableError`; otherwise (any other status, such as a `4xx` other than `429` or a redirect that
# was not followed, a body that is not JSON, or not a JSON object) `MetadataNotPublishedError`.
# A body over the cap is refused outright by `bounded_request` with a plain `AuthorizationError`,
# before any classification. Each failure is listed with its URL stripped of userinfo, query and fragment
# and cut to `METADATA_URL_MAX_LENGTH`, but otherwise spelled as requested, so it can be matched against
# a server's access log, and with exception text bounded, since the message lands in every log destination
# the error passes through.
def fetch_metadata_json(urls, label:)
last_error = nil
failures = []
inconclusive = false
urls.each do |url|
response = begin
http_get(url)
rescue Faraday::Error => e
last_error = "GET #{url} raised #{e.class}: #{e.message}"
detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH)
failures << "GET #{reported_url(url)} raised #{[e.class, detail].compact.join(": ")}"
inconclusive = true
next
end
unless response.status >= 200 && response.status < 300
failures << "GET #{reported_url(url)} returned #{response.status}"
inconclusive = true if response.status >= 500 || response.status == 429
next
end
if response.status >= 200 && response.status < 300
parsed = begin
JSON.parse(response_body_string(response))
rescue JSON::ParserError => e
raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}."
detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH) || e.class.name
failures << "GET #{reported_url(url)} returned a body that is not JSON: #{detail}"
next
end
# Even valid JSON can be the wrong shape (a top-level array,
# a bare `null`, a string, ...). The discovery callers index by
# name (`prm["authorization_servers"]`, etc.), so anything that
# is not a Hash would raise `TypeError` / `NoMethodError`
# downstream. Surface that as `AuthorizationError` instead so
# callers see a single, documented error type.
# Even valid JSON can be the wrong shape (a top-level array, a bare `null`, a string, ...).
# The discovery callers index by name (`prm["authorization_servers"]`, etc.), so anything that
# is not a Hash would raise `TypeError` / `NoMethodError` downstream.
unless parsed.is_a?(Hash)
raise AuthorizationError,
"#{label} from #{url} is not a JSON object (got #{parsed.class})."
failures << "GET #{reported_url(url)} returned a body that is not a JSON object (got #{parsed.class})"
next
end
return parsed
end
last_error = "GET #{url} returned #{response.status}"
message = "Failed to fetch #{label}: #{failures.join("; ")}."
if inconclusive
raise MetadataUnreachableError, message
else
raise MetadataNotPublishedError, message
end
raise AuthorizationError, "Failed to fetch #{label}: #{last_error}."
end
def ensure_pkce_supported!(as_metadata)
@ -1304,8 +1341,8 @@ module MCP
parsed = {} unless parsed.is_a?(Hash)
error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError
error = token_endpoint_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
description = token_endpoint_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
error = bounded_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
description = bounded_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
message += " #{[error, description].compact.join(": ")}" if error || description
error_class.new(message, http_status: response.status, error: error, error_description: description)
@ -1314,17 +1351,23 @@ module MCP
error_class.new("Token endpoint returned status #{response.status}.", http_status: response.status)
end
def token_endpoint_diagnostic(value, limit:)
def bounded_diagnostic(value, limit:)
return unless value.is_a?(String)
# RFC 6749 permits printable ASCII except double quotes and backslashes.
# Replace other characters to keep provider text on one log line.
# RFC 6749 permits printable ASCII except double quotes and backslashes in token endpoint error fields.
# Replace other characters to keep text received off the network on one log line.
value = value.scrub(" ").gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, " ").strip
return if value.empty?
value.length > limit ? "#{value[0, limit - 3]}..." : value
end
# A candidate URL as it goes into a failure string: redacted, and cut so a URL the server chose cannot
# grow the message without limit.
def reported_url(url)
bounded_diagnostic(Discovery.redact_url(url), limit: METADATA_URL_MAX_LENGTH)
end
# Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
# `application/x-www-form-urlencoded` encoded before they are joined with
# `:` and base64-encoded for the `Authorization: Basic` header. This is

View file

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

View file

@ -1602,9 +1602,9 @@ module MCP
end
def test_run_falls_back_to_legacy_discovery_when_prm_is_not_a_json_object
# Valid JSON but the wrong shape. Any PRM discovery failure selects the legacy 2025-03-26 path
# (matching the TypeScript and Python SDKs); here the legacy path also dead-ends, surfacing
# a domain error rather than a raw `TypeError` from indexing the array.
# Valid JSON but the wrong shape counts as nothing usable being published, so it selects the legacy 2025-03-26 path
# (matching the TypeScript and Python SDKs); here the legacy path also dead-ends, surfacing a domain error rather than
# a raw `TypeError` from indexing the array.
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
@ -1636,6 +1636,211 @@ module MCP
assert_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
end
def test_run_surfaces_a_network_error_during_prm_discovery_instead_of_falling_back
# A request that never reached the server says nothing about whether it publishes PRM, so once
# the other candidates answer 404 the flow stops instead of moving to the legacy authorization base;
# the TypeScript and Python SDKs propagate the error the same way.
challenge_url = "https://srv.example.com/prm.json"
stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused"))
stub_prm_not_found
error = assert_raises(Flow::MetadataUnreachableError) do
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
end
assert_match(/Faraday::ConnectionFailed/, error.message)
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
assert_not_requested(:post, "https://srv.example.com/register")
end
def test_run_reports_a_prm_candidate_url_without_its_query_in_the_discovery_error
# The candidate URL comes from the server's `WWW-Authenticate` challenge and lands in every log line
# the error reaches, so the query is dropped, while the path keeps the spelling that was requested
# (`%2E` is not decoded) so the line can be matched against the server's access log.
challenge_url = "https://srv.example.com/prm%2Ejson?token=abc"
stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused"))
stub_prm_not_found
error = assert_raises(Flow::MetadataUnreachableError) do
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
end
assert_includes(error.message, "GET https://srv.example.com/prm%2Ejson raised Faraday::ConnectionFailed: connection refused; GET")
refute_includes(error.message, "token=abc")
end
def test_run_cuts_an_overlong_prm_candidate_url_in_the_discovery_error
# The challenge URL is the server's to choose, so its length is bounded before it lands in the message.
challenge_url = "https://srv.example.com/#{"a" * 5000}/prm.json"
stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused"))
stub_prm_not_found
error = assert_raises(Flow::MetadataUnreachableError) do
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
end
assert_includes(error.message, "GET #{challenge_url[0, Flow::METADATA_URL_MAX_LENGTH - 3]}... raised Faraday::ConnectionFailed")
refute_includes(error.message, "a" * (Flow::METADATA_URL_MAX_LENGTH - 3))
end
def test_run_bounds_the_transport_error_message_in_the_discovery_error
# A caller-supplied client can raise with server-chosen text in the message (Faraday's `raise_error` middleware repeats
# the status line and the request URL), so it is bounded like the parser's message.
stub_request(:get, @prm_url).to_raise(Faraday::ConnectionFailed.new("x" * 5000))
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
error = assert_raises(Flow::MetadataUnreachableError) do
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_includes(error.message, "raised Faraday::ConnectionFailed: #{"x" * 125}...; GET")
refute_includes(error.message, "x" * 126)
end
def test_run_bounds_the_parser_message_in_the_discovery_error
# json before 2.10 repeats the remaining source in `JSON::ParserError#message`, so a malformed body
# could otherwise put up to the response cap into the message once per candidate.
stub_request(:get, @prm_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "{")
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 503)
JSON.stubs(:parse).raises(JSON::ParserError, "unexpected token at '#{"{" * 5000}'")
error = assert_raises(Flow::MetadataUnreachableError) do
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
# `METADATA_DIAGNOSTIC_MAX_LENGTH` is 128: the first 125 characters survive, then three dots.
assert_includes(error.message, <<~MESSAGE.chomp)
returned a body that is not JSON: unexpected token at '#{"{" * 104}...; \
GET https://srv.example.com/.well-known/oauth-protected-resource returned 503.
MESSAGE
refute_includes(error.message, "{" * 105)
end
def test_run_surfaces_a_server_error_during_prm_discovery_instead_of_falling_back
# A `5xx` or `429` says nothing about what the server publishes either, even when a later candidate answers 404;
# the Python SDK refuses the legacy path the same way.
[503, 429].each do |status|
stub_request(:get, @prm_url).to_return(status: status)
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
error = assert_raises(Flow::MetadataUnreachableError, "status #{status}") do
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_match(/returned #{status}/, error.message)
end
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
assert_not_requested(:post, "https://srv.example.com/register")
end
def test_run_keeps_a_server_error_in_mind_when_a_later_candidate_is_unusable
# An unusable document from a later candidate must not turn an earlier `503` into "nothing published".
challenge_url = "https://srv.example.com/prm.json"
stub_request(:get, challenge_url).to_return(status: 503)
stub_request(:get, @prm_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "[]")
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
error = assert_raises(Flow::MetadataUnreachableError) do
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
end
assert_match(/returned 503/, error.message)
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
end
def test_run_uses_a_later_prm_candidate_when_the_challenge_url_serves_an_unusable_document
# The setup stubs a valid document at the well-known path; a broken one at the challenge URL is skipped.
challenge_url = "https://srv.example.com/prm.json"
stub_request(:get, challenge_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "[]")
result = Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
assert_equal(:authorized, result)
assert_requested(:get, @prm_url)
end
def test_run_recovers_authorization_server_metadata_from_a_later_candidate_after_a_network_error
# The OAuth document being unreachable must not stop the OpenID one from being tried.
stub_request(:get, @as_metadata_url).to_raise(Faraday::TimeoutError.new("execution expired"))
stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
result = run_authorization_flow
assert_equal(:authorized, result)
assert_requested(:get, "#{@auth_base}/.well-known/openid-configuration")
end
def test_run_recovers_authorization_server_metadata_from_a_later_candidate_after_an_unusable_document
# An OAuth document that is not JSON, or not a JSON object, must not stop the OpenID one from being tried.
stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
["{", "[]"].each do |body|
stub_request(:get, @as_metadata_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: body)
assert_equal(:authorized, run_authorization_flow, "body #{body.inspect}")
end
assert_requested(:get, "#{@auth_base}/.well-known/openid-configuration", times: 2)
end
def test_refresh_surfaces_a_network_error_during_prm_discovery_instead_of_falling_back
stub_request(:get, @prm_url).to_raise(Faraday::TimeoutError.new("execution expired"))
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
provider = build_legacy_discovery_provider({})
provider.save_client_information("client_id" => "test-client")
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
assert_raises(Flow::MetadataUnreachableError) do
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
assert_not_requested(:post, "https://srv.example.com/token")
assert_equal("saved-rt", provider.tokens["refresh_token"])
end
def test_refresh_surfaces_a_server_error_during_prm_discovery_instead_of_falling_back
stub_request(:get, @prm_url).to_return(status: 503)
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
provider = build_legacy_discovery_provider({})
provider.save_client_information("client_id" => "test-client")
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
error = assert_raises(Flow::MetadataUnreachableError) do
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_match(/returned 503/, error.message)
assert_not_requested(:post, "https://srv.example.com/token")
assert_equal("saved-rt", provider.tokens["refresh_token"])
end
# Builds a provider for the legacy-discovery tests, capturing the authorization URL so tests can assert
# which endpoint was used.
def build_legacy_discovery_provider(holder)
@ -2029,10 +2234,10 @@ module MCP
end
def test_run_refuses_a_protected_resource_metadata_body_over_the_cap
# PRM discovery failures select the legacy path by design, so the refusal shows up as
# the fallback rather than as a raise. The padded document is valid JSON naming
# an authorization server, so contacting that server is exactly what would happen
# if the body had been read: the assertion below fails if the cap stops working.
# An oversized document is refused outright rather than treated as unpublished, so the flow stops
# without the legacy fallback. The padded document is valid JSON naming an authorization server,
# so contacting that server is exactly what would happen if the body had been read:
# the assertion below fails if the cap stops working.
stub_request(:any, %r{\Ahttps://srv\.example\.com/}).to_return(status: 404)
stub_request(:get, @prm_url).to_return(
status: 200,
@ -2044,9 +2249,11 @@ module MCP
),
)
assert_raises(Flow::AuthorizationError) { run_authorization_flow }
error = assert_raises(Flow::AuthorizationError) { run_authorization_flow }
assert_match(/exceeds \d+ bytes/, error.message)
assert_not_requested(:get, @as_metadata_url)
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
end
def test_run_refuses_a_dynamic_client_registration_body_over_the_cap
@ -2150,6 +2357,7 @@ module MCP
headers: { "Content-Type" => "application/json" },
body: "null",
)
stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return(status: 404)
provider = Provider.new(
client_metadata: {
@ -3801,7 +4009,7 @@ module MCP
end
def test_token_endpoint_errors_fall_back_when_diagnostic_extraction_raises
Flow.any_instance.stubs(:token_endpoint_diagnostic).raises(ArgumentError, "sensitive provider text")
Flow.any_instance.stubs(:bounded_diagnostic).raises(ArgumentError, "sensitive provider text")
{ "invalid_grant" => Flow::InvalidGrantError, "invalid_client" => Flow::AuthorizationError }.each do |code, klass|
error = refresh_token_endpoint_error(JSON.generate(error: code, error_description: "details"))