From 0bfb2d88bb9f86097d2629b88eaf12ab63ddbf4d Mon Sep 17 00:00:00 2001 From: easyinplay Date: Sun, 23 Aug 2026 08:11:59 +0800 Subject: [PATCH 1/2] fix(auth): ignore non-metadata JSON when probing for protected resource 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 #810, which made a non-JSON body at the base URL a soft failure for the same reason. --- crates/rmcp/src/transport/auth.rs | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 2eae2b220..ff778512d 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2701,6 +2701,23 @@ impl AuthorizationManager { return Ok(None); } }; + + // Every field of `ResourceServerMetadata` is optional, so an unrelated JSON + // object deserializes into an all-`None` value and then fails validation + // fatally. RFC 9728 requires `resource`, and MCP requires an authorization + // server reference, so a document carrying neither is not a protected + // resource metadata document. Treat it as a soft failure, the same way this + // function already treats a non-200 status and a body that is not JSON. + if metadata.resource.is_none() + && metadata.authorization_server.is_none() + && metadata.authorization_servers.is_none() + { + debug!( + "response at {resource_metadata_url} is not a protected resource metadata document" + ); + return Ok(None); + } + Ok(Some(metadata)) } @@ -4853,6 +4870,49 @@ mod tests { ); } + #[tokio::test] + async fn resolve_metadata_ignores_non_metadata_json_at_the_base_url() { + let health = || { + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ) + }; + let client = RecordingOAuthHttpClient::with_responses(vec![ + // the MCP endpoint answers GET with a health payload, not metadata. + // The same URL is hit twice: once to probe, once to fetch the document. + health(), + health(), + http_response( + 200, + serde_json::json!({ + "issuer": "https://mcp.example.com", + "authorization_endpoint": "https://mcp.example.com/oauth/authorize", + "token_endpoint": "https://mcp.example.com/oauth/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::AuthorizationServerMetadata, + "https://mcp.example.com/oauth/token", + ) + ); + } + #[rstest] #[case::protected_resource_metadata( AuthorizationMetadataSource::ProtectedResourceMetadata, From 09c5e5178f39a89f41807b2b7db43fa3f739d163 Mon Sep 17 00:00:00 2001 From: easyinplay Date: Tue, 25 Aug 2026 22:36:15 +0800 Subject: [PATCH 2/2] fix(auth): stop a 200 from the resource ending metadata discovery 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". --- crates/rmcp/src/transport/auth.rs | 102 +++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 9 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index ff778512d..eb6357643 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2606,9 +2606,7 @@ impl AuthorizationManager { } async fn discover_resource_metadata_url(&self) -> Result, AuthError> { - if let Some(resource_metadata_url) = - self.probe_resource_metadata_url(&self.base_url).await? - { + if let Some(resource_metadata_url) = self.probe_resource_endpoint_for_challenge().await? { return Ok(Some(resource_metadata_url)); } @@ -2631,10 +2629,37 @@ impl AuthorizationManager { Ok(None) } - /// Probe `url` with a GET, extracting the resource metadata url from a - /// 200 (the url itself is the metadata document) or from a 401's - /// WWW-Authenticate header value. + /// Probe the resource itself, looking only for a `WWW-Authenticate` challenge + /// that carries a `resource_metadata` pointer. + /// + /// A 200 here says nothing about metadata. RFC 9728 publishes the document at + /// the well-known URI and advertises it through the challenge parameter, so the + /// resource answering its own GET is not the document and must not end + /// discovery before the well-known candidates are tried. /// https://www.rfc-editor.org/rfc/rfc9728.html#name-use-of-www-authenticate-for + async fn probe_resource_endpoint_for_challenge(&self) -> Result, AuthError> { + let response = self + .discovery_get(&self.base_url) + .await + .map_err(|error| Self::discovery_failed(&self.base_url, error))?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Ok(self + .extract_resource_metadata_url_from_www_authenticate(&response) + .await); + } + + debug!( + "resource endpoint probe returned {}, no WWW-Authenticate pointer to follow", + response.status() + ); + Ok(None) + } + + /// Probe a `.well-known` candidate with a GET, extracting the resource metadata + /// url from a 200 (the url itself is the metadata document) or from a 401's + /// WWW-Authenticate header value. + /// https://www.rfc-editor.org/rfc/rfc9728.html#name-obtaining-protected-resourc async fn probe_resource_metadata_url(&self, url: &Url) -> Result, AuthError> { let response = self .discovery_get(url) @@ -4871,7 +4896,65 @@ mod tests { } #[tokio::test] - async fn resolve_metadata_ignores_non_metadata_json_at_the_base_url() { + async fn resolve_metadata_reaches_the_well_known_document_past_a_non_metadata_base_url() { + let document = || { + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_servers": ["https://auth.example.com"] + }), + ) + }; + let client = RecordingOAuthHttpClient::with_responses(vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // the well-known candidate carries the real document: probed, then fetched + document(), + document(), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }), + ), + ]); + let recorder = client.clone(); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::ProtectedResourceMetadata, + "https://auth.example.com/token", + ) + ); + assert!( + recorder.requests().iter().any(|request| { + request.uri == "https://mcp.example.com/.well-known/oauth-protected-resource" + }), + "the well-known candidate was never probed: {:?}", + recorder.requests() + ); + } + + #[tokio::test] + async fn resolve_metadata_ignores_a_well_known_url_that_is_not_a_metadata_document() { let health = || { http_response( 200, @@ -4879,8 +4962,9 @@ mod tests { ) }; let client = RecordingOAuthHttpClient::with_responses(vec![ - // the MCP endpoint answers GET with a health payload, not metadata. - // The same URL is hit twice: once to probe, once to fetch the document. + // the MCP endpoint answers GET with a health payload, not metadata + health(), + // so does the well-known candidate: probed, then fetched health(), health(), http_response(