Skip to content

fix(auth): ignore non-metadata JSON when probing for protected resource metadata - #1204

Merged
DaleSeo merged 7 commits into
modelcontextprotocol:mainfrom
easyinplay:fix-prm-probe-non-metadata-json
Sep 12, 2026
Merged

fix(auth): ignore non-metadata JSON when probing for protected resource metadata#1204
DaleSeo merged 7 commits into
modelcontextprotocol:mainfrom
easyinplay:fix-prm-probe-non-metadata-json

Conversation

@easyinplay

Copy link
Copy Markdown
Contributor

Why

AuthorizationManager probes its base URL first when looking for RFC 9728 protected resource metadata, and probe_resource_metadata_url treats any 200 there as "this URL is the metadata document". ResourceServerMetadata has only optional fields, so an unrelated JSON object deserializes into an all-None value, and validate_resource_metadata_resource then fails hard with Protected resource metadata missing required resource field. The error propagates out of resolve_metadata(), so the .well-known fallbacks on the following lines never run.

Servers that answer GET / with a JSON health payload hit this. Against https://mcp.tavily.com today:

GET https://mcp.tavily.com/
200 application/json  {"status":"healthy","message":"MCP server is running"}

GET https://mcp.tavily.com/.well-known/oauth-protected-resource
404

GET https://mcp.tavily.com/.well-known/oauth-protected-resource/mcp
200 {"resource":"https://mcp.tavily.com/mcp",
     "authorization_servers":["https://mcp.tavily.com/"],
     "scopes_supported":["openid","offline_access"],
     "bearer_methods_supported":["header"]}

GET https://mcp.tavily.com/.well-known/oauth-authorization-server
200 {"issuer":"https://mcp.tavily.com/","authorization_endpoint":"https://mcp.tavily.com/authorize",
     "token_endpoint":"https://mcp.tavily.com/token","registration_endpoint":"https://mcp.tavily.com/register"}

The server publishes valid metadata at both well-known locations, using the RFC 9728 path-insertion form. resolve_metadata() still returns Metadata error: Protected resource metadata missing required resource field, because it stops at the health payload and never reaches either. The workaround is to configure the endpoint path rather than the host, which is not always what the caller has.

This is the JSON-object half of #810. That PR made a non-JSON body at the base URL a soft failure so discovery could continue. A JSON body that is not a metadata document still deserializes, so it takes the hard path instead.

Sampling the origin root of 36 reachable public remote MCP servers, 18 answer 200. All 18 are currently read as "this URL is the protected resource metadata document"; 17 of them escape only because their body is HTML or markdown rather than a JSON object.

Standards

RFC 9728 Section 3 requires the metadata document to live at a URL formed by inserting a well-known URI string into the resource identifier, and Section 5.1 lets a 401 point at it through WWW-Authenticate. The 2026-07-28 authorization server discovery requirements list the same two mechanisms and no others. The resource URL itself is not a metadata location under either, so a 200 from it carries no metadata claim.

RFC 9728 Section 3.2 allows additional members in the document, so tightening deserialization is not an option here: real documents carry fields this struct does not model, including the bearer_methods_supported in the Tavily document above.

What this changes

fetch_resource_metadata_from_url returns Ok(None) when the parsed document has none of resource, authorization_server, or authorization_servers, with a debug! line, matching how the same function already handles a non-200 status and a body that is not JSON. Discovery then continues to the well-known paths and to authorization server metadata.

A document carrying any of those fields still goes through validate_resource_metadata_resource unchanged, so a server that advertises a metadata URL through WWW-Authenticate and serves a malformed document there still gets a hard error. protected_resource_discovery_rejects_missing_resource and protected_resource_discovery_rejects_mismatched_resource both reach the document through an explicit challenge pointer, and both stay green.

Alternative

The narrower reading is that the base URL should never be treated as a metadata location at all, only as a source of a WWW-Authenticate challenge. That is what the TypeScript SDK does: discoverOAuthProtectedResourceMetadata only ever fetches /.well-known/oauth-protected-resource{path} or a URL taken from the challenge. Scoping the StatusCode::OK arm of probe_resource_metadata_url to the well-known probe would have the same effect here, and no existing test covers the base-URL-200 path, so that shape stays green too. I went with the document-shape check because it keeps working for servers that do serve metadata at the endpoint itself. Happy to send the other shape instead if you prefer it.

Test plan

resolve_metadata_ignores_non_metadata_json_at_the_base_url mirrors resolve_metadata_reports_authorization_server_metadata, with the base URL answering a health payload twice instead of 404: once for the probe, once for the fetch, which is what a real server does. Before the change it fails with

called `Result::unwrap()` on an `Err` value: MetadataError("Protected resource metadata missing required resource field")

and after it passes.

cargo +nightly fmt --all
cargo clippy --all-targets --all-features -- -D warnings
cargo test -p rmcp --lib --features auth

cargo test -p rmcp --lib --features auth reports 373 passed; 1 failed. The one failure is default_http_client_preserves_connection_failure_cause, which asserts on an OS connection-refused string and fails the same way on an unmodified checkout of this branch point on a non-English Windows host.

…ce metadata

The base URL is probed first when looking for RFC 9728 protected resource
metadata, and any 200 there is taken to mean "this URL is the metadata
document". Every field of ResourceServerMetadata is optional, so an unrelated
JSON object deserializes into an all-None value and validation then fails hard
with "Protected resource metadata missing required resource field". The error
propagates out of resolve_metadata, so the .well-known fallbacks never run.

Servers that answer GET / with a JSON health payload hit this even when they
publish valid metadata at both well-known locations.

Treat a parsed document that carries none of resource, authorization_server or
authorization_servers as a soft failure, the same way this function already
treats a non-200 status and a body that is not JSON. A document carrying any of
those fields still goes through validate_resource_metadata_resource unchanged.

This is the JSON-object half of modelcontextprotocol#810, which made a non-JSON body at the base URL
a soft failure for the same reason.
@easyinplay
easyinplay requested a review from a team as a code owner August 23, 2026 00:14
@github-actions github-actions Bot added T-core Core library changes T-transport Transport layer changes labels Aug 23, 2026
Comment thread crates/rmcp/src/transport/auth.rs Outdated
probe_resource_metadata_url treats any 200 as "this url is the metadata
document". That holds for the .well-known candidates it is called with in the
loop, and not for the first call, which is passed the resource itself. RFC 9728
publishes the document at the well-known URI and advertises it through the
resource_metadata parameter of a WWW-Authenticate challenge, so a 200 from the
resource is the resource answering and nothing more.

Because that first probe returned Some(base_url), discovery ended before the
.well-known candidates were tried, and a valid document published there was
never reached. Rejecting the body later could not recover it: by then the
candidates had already been skipped.

Split the first probe into probe_resource_endpoint_for_challenge, which reads
only the 401 branch. The .well-known probe keeps its behaviour.

The check added in the previous commit stays. A .well-known url can also answer
200 with something that is not a metadata document, and every field of
ResourceServerMetadata being optional makes that deserialize into an all-None
value that then fails validation fatally.

resolve_metadata_reaches_the_well_known_document_past_a_non_metadata_base_url
asserts the well-known url is actually requested; without this change it fails
with the base url requested twice and the protected-resource candidate never
probed. resolve_metadata_ignores_a_well_known_url_that_is_not_a_metadata_document
covers the remaining guard; without it the run ends in the original
"Protected resource metadata missing required resource field".
Comment thread crates/rmcp/src/transport/auth.rs
Comment thread crates/rmcp/src/transport/auth.rs Outdated
…document

discover_resource_metadata_url returned the first .well-known candidate that
answered 200 and left the loop; the document itself was fetched afterwards,
outside the loop. A candidate answering 200 with something that is not metadata
is only recognised at that point, by which time the remaining candidates have
been skipped and discovery gives up with nothing.

The probe already had the body in hand and threw it away, so the winning
candidate was requested twice. Read the body where the candidate is probed
instead: a candidate that is not the document costs one request and the loop
moves on to the next one, and the candidate that is the document is requested
once.

The second half of the old function, which walks the authorization servers a
document names, is unchanged; it now takes the document as an argument so the
challenge path keeps sharing it.

resolve_metadata_tries_the_next_well_known_candidate_past_a_non_metadata_document
covers the loop; without this change the second candidate is never requested and
the run ends on the issuer of an authorization server it was never meant to
reach.
A WWW-Authenticate challenge naming a resource_metadata url is the server
saying the document is there. The previous commits made a document carrying
neither resource nor an authorization server reference a soft failure
everywhere, which on that path drops out of resolve_metadata_from_challenge,
continues with authorization server discovery and settles on the legacy
endpoints, silently losing the RFC 8707 resource binding the document was
supposed to carry. Before those commits it surfaced as
"Protected resource metadata missing required resource field".

Where the url came from decides what that document means. A .well-known
candidate is a guess, so its answer only rules out that candidate and the loop
goes on. An advertised url has no better alternative to move on to, so say what
the server got wrong instead of degrading quietly.

Reading a non-200 or a body that is not JSON stays a soft failure on both
paths.

resolve_metadata_from_challenge_reports_an_advertised_url_without_metadata
covers the challenge path; without this change it resolves to
LegacyEndpointFallback.
parse_resource_metadata decides what a body that is not the metadata document
means from where the url came from, and it decides it on the presence of
resource, authorization_server and authorization_servers alone. What comes
after is fatal whatever the origin: validate_resource_metadata_resource
rejects a missing resource, a resource that is not a URL, one carrying a
fragment, and one that does not match the base url, and each of those
propagates out of resolve_metadata.

A .well-known candidate handled by a catch-all route reaches it. The
{"error":"not_found","resource":"/.well-known/oauth-protected-resource"} such
a route answers with carries resource, so the presence check lets it through,
and the relative path then fails to parse as a URL. That is the shape this
branch opened on, one field further along: the remaining candidates are
skipped, authorization server discovery is skipped, and the run ends on an
error instead of the legacy endpoints.

Read the body and validate it in the same place, now read_resource_metadata,
so the origin governing the first decision governs the second one as well. A
guess that fails validation rules out that candidate and the loop goes on; an
advertised url still reports what the server got wrong.
authorization_metadata_from_resource_metadata takes a document that has
already been accepted.

resolve_metadata_tries_the_next_well_known_candidate_past_an_unusable_document
covers it; without this change it fails with "Protected resource metadata
resource field is not a valid URL".
… nothing

An advertised url reading a non-200 or a body that is not JSON is a soft
failure: the server said the document is at that url and nothing is being
served there, which rules out the url and not the document. The .well-known
candidates are derived from the base url rather than from that pointer, so
they are still worth probing, but the pointer was read ahead of the loop and
returned out of discover_resource_metadata either way. A challenge naming
https://host/.well-known/oauth-protected-resource that 404s takes the run
straight to authorization server discovery, while the document sits unread on
https://host/mcp/.well-known/oauth-protected-resource.

Fall through to the loop instead. Carrying on past a candidate's own 401
already let one url be requested twice, because the pointer that challenge
names can be a later candidate of the same run, and it is requested again when
the loop reaches it; not returning on the first pointer adds the same overlap.
Keep the urls this run has requested and skip the ones already read.

resolve_metadata_probes_the_candidates_past_an_advertised_url_that_is_not_served
covers the fall-through; without it the run reaches the authorization server
metadata of a server it was never pointed at.
resolve_metadata_requests_an_advertised_url_that_is_also_a_candidate_once
covers the repeat.
A protected resource metadata document can name its authorization server in
authorization_servers, and servers written against the earlier draft also fill
the singular authorization_server. Filling both with the same value is common,
and the two are concatenated into the candidate list unfiltered, so every
well-known form of that one server's discovery url is requested twice before
the walk gives up on it.

well_known_paths already keeps its candidates distinct. Do the same here,
comparing the trimmed value the loop goes on to use.

resolve_metadata_requests_an_authorization_server_named_twice_once covers it;
without this change the discovery url is requested twice.
@easyinplay

Copy link
Copy Markdown
Contributor Author

Both are right, and they are the same seam from two sides: the guard sat in the shared fetch, while the two callers need opposite answers to "this document is not metadata".

Your two comments also sort into two axes, which is what I used to look for the rest of them:

  • what the document is — every field of ResourceServerMetadata is optional, so the type cannot tell "not this document" from "this document is broken"
  • where that decision is made — deciding once, outside the loop, is what let a single answer end discovery

Four commits. The first two are your comments; the last two are the same two axes applied to the rest of the path, so this does not come back a fifth time.

The loop (83743b0). discover_resource_metadata_url returned the first candidate that answered 200 and left; the body was read afterwards, outside the loop, so a candidate answering 200 with something else was only recognised once the remaining candidates were gone. The probe already held that body and discarded it, which also cost the winning candidate a second GET. The loop body now reads the body it already has, so a candidate that is not the document costs one request and the loop moves on.

Where the url came from (b165775). A .well-known candidate is a guess, so its answer only rules out that candidate. An advertised url has no better alternative to move on to, so it reports what the server got wrong instead of degrading quietly.

Origin has to survive the next hop (f2cbb68). The field-presence guard was only half the decision. validate_resource_metadata_resource ran afterwards, outside the loop, with four fatal returns that did not look at origin — so a .well-known candidate answering {"error":"not_found","resource":"/.well-known/oauth-protected-resource"} (a catch-all handler, which is what makes this common) failed on resource field is not a valid URL and took the whole run down: remaining candidates untried, authorization server discovery untried, legacy fallback unreachable. That is the bug this PR started from, reached through a different field. JSON parse, field presence and validation now happen in one place and all three answer by origin.

Nothing is requested twice (aaf529e + e7cebf1). Two leftovers on that axis:

  • An advertised url that answers 404, or HTML, was soft — but the block returned instead of falling through, so a challenge pointing at a .well-known url that a reverse proxy does not route skipped the candidate loop entirely, including the path where the document actually was. It now falls through. The split is whether the server answered a document there: 200 with a body that parses is answered-and-wrong (hard), 404 or non-JSON is not-here (soft, keep looking).
  • With the loop no longer stopping at the first 401, a pointer from one candidate can be another candidate, so that url could be fetched twice. A HashSet of what has been requested covers both that and the advertised pointer.

e7cebf1 is the same idea one level down: a server publishing both authorization_server and authorization_servers with the same value — the usual way to straddle the draft and RFC 9728 — had that server walked twice, all four well-known shapes each time.

Six new tests, each verified to fail with its own half reverted. Two examples:

  • reverting the validation split fails with MetadataError("Protected resource metadata resource field is not a valid URL") — the catch-all case above
  • reverting the fall-through leaves the challenge test resolving past the document to an authorization server it was never meant to reach

Two existing tests fed the document twice to satisfy the duplicated GET; both lost the duplicate.

Three things I noticed on the same thread and left out of this PR, since they are wider than what it started as. Happy to open issues, or fold any of them in if you would rather:

  1. fetch_authorization_metadata reads a url from generate_discovery_urls and a url named in a protected resource document's authorization_servers the same way — both soft. If none of the named servers can be reached, resolution continues on to the legacy endpoints, so the document's choice of authorization server does not reach the caller. source does carry LegacyEndpointFallback, so the information is available to callers that read it. Every named server being rejected by the SSRF guard ends up on the same path. Whether those two are worth telling apart from an ordinary fallback looks more like a design call than a fix, so I left it alone.
  2. When an advertised url is soft-failed on the challenge path, resolve_metadata re-runs the whole discovery, so a url that is both the challenge pointer and a well-known candidate is fetched twice across the two passes.
  3. An issuer mismatch aborts the authorization server loop rather than trying the next candidate. That reads deliberate to me (fail closed on a poisoned server), and it matches how transport errors are handled — but no test covers a document naming more than one server, so nothing pins the intent.

One behaviour note on this PR as a whole: 09c5e51 means a server publishing the document directly at the resource url is no longer discovered that way. That is the point of your second comment, but it is a break for anyone relying on it and no test records it as intentional.

cargo clippy --all-targets --all-features -- -D warnings is clean and cargo test -p rmcp --all-features is 761 passed, with the same platform-worded connection-error assertion failing as before.

@DaleSeo
DaleSeo merged commit a5d1169 into modelcontextprotocol:main Sep 12, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-core Core library changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants