From 0bfb2d88bb9f86097d2629b88eaf12ab63ddbf4d Mon Sep 17 00:00:00 2001 From: easyinplay Date: Sun, 23 Aug 2026 08:11:59 +0800 Subject: [PATCH 1/7] 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/7] 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( From 83743b0a24143a19083f9834bcb434b21bc2360e Mon Sep 17 00:00:00 2001 From: easyinplay Date: Mon, 31 Aug 2026 00:19:04 +0800 Subject: [PATCH 3/7] fix(auth): keep trying the well-known candidates past a non-metadata 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. --- crates/rmcp/src/transport/auth.rs | 221 ++++++++++++++++++++---------- 1 file changed, 152 insertions(+), 69 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index eb6357643..4dc5ed3f3 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2462,13 +2462,20 @@ impl AuthorizationManager { async fn discover_oauth_server_via_resource_metadata( &self, ) -> Result, AuthError> { - let Some(resource_metadata_url) = self.discover_resource_metadata_url().await? else { + let Some((resource_metadata_url, resource_metadata)) = + self.discover_resource_metadata().await? + else { return Ok(None); }; - self.discover_oauth_server_from_resource_metadata_url(&resource_metadata_url) - .await + self.authorization_metadata_from_resource_metadata( + &resource_metadata_url, + resource_metadata, + ) + .await } + /// Read protected resource metadata from the url a `WWW-Authenticate` + /// challenge advertised. async fn discover_oauth_server_from_resource_metadata_url( &self, resource_metadata_url: &Url, @@ -2480,6 +2487,17 @@ impl AuthorizationManager { return Ok(None); }; + self.authorization_metadata_from_resource_metadata(resource_metadata_url, resource_metadata) + .await + } + + /// Walk the authorization servers a protected resource metadata document + /// names, keeping the first one that answers with usable metadata. + async fn authorization_metadata_from_resource_metadata( + &self, + resource_metadata_url: &Url, + resource_metadata: ResourceServerMetadata, + ) -> Result, AuthError> { self.validate_resource_metadata_resource(&resource_metadata)?; self.discovered_resource @@ -2605,9 +2623,17 @@ impl AuthorizationManager { || expected_path.as_bytes().get(actual_path.len()) == Some(&b'/')) } - async fn discover_resource_metadata_url(&self) -> Result, AuthError> { + /// Look for the protected resource metadata document, reading it where it is + /// found so that a candidate answering with something else only costs that + /// candidate. + async fn discover_resource_metadata( + &self, + ) -> Result, AuthError> { if let Some(resource_metadata_url) = self.probe_resource_endpoint_for_challenge().await? { - return Ok(Some(resource_metadata_url)); + return Ok(self + .fetch_resource_metadata_from_url(&resource_metadata_url) + .await? + .map(|metadata| (resource_metadata_url, metadata))); } // If the primary URL doesn't use WWW-Authenticate, try oauth-protected-resource discovery. @@ -2615,14 +2641,38 @@ impl AuthorizationManager { for candidate_path in Self::well_known_paths(self.base_url.path(), "oauth-protected-resource") { - let mut discovery_url = self.base_url.clone(); - discovery_url.set_query(None); - discovery_url.set_fragment(None); - discovery_url.set_path(&candidate_path); - if let Some(resource_metadata_url) = - self.probe_resource_metadata_url(&discovery_url).await? - { - return Ok(Some(resource_metadata_url)); + let mut candidate_url = self.base_url.clone(); + candidate_url.set_query(None); + candidate_url.set_fragment(None); + candidate_url.set_path(&candidate_path); + + let response = self + .discovery_get(&candidate_url) + .await + .map_err(|error| Self::discovery_failed(&candidate_url, error))?; + + match response.status() { + // The candidate url is the document itself, so read the body here + // instead of requesting the same url again. + StatusCode::OK => { + if let Some(metadata) = + Self::parse_resource_metadata(&candidate_url, response.body()) + { + return Ok(Some((candidate_url, metadata))); + } + } + StatusCode::UNAUTHORIZED => { + if let Some(advertised_url) = self + .extract_resource_metadata_url_from_www_authenticate(&response) + .await + && let Some(metadata) = self + .fetch_resource_metadata_from_url(&advertised_url) + .await? + { + return Ok(Some((advertised_url, metadata))); + } + } + status => debug!("resource metadata probe returned unexpected status: {status}"), } } @@ -2656,28 +2706,6 @@ impl AuthorizationManager { 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) - .await - .map_err(|error| Self::discovery_failed(url, error))?; - - match response.status() { - StatusCode::OK => Ok(Some(url.clone())), - StatusCode::UNAUTHORIZED => Ok(self - .extract_resource_metadata_url_from_www_authenticate(&response) - .await), - status => { - debug!("resource metadata probe returned unexpected status: {status}"); - Ok(None) - } - } - } - async fn extract_resource_metadata_url_from_www_authenticate( &self, response: &HttpResponse, @@ -2719,11 +2747,21 @@ impl AuthorizationManager { return Ok(None); } - let metadata = match serde_json::from_slice::(response.body()) { + Ok(Self::parse_resource_metadata( + resource_metadata_url, + response.body(), + )) + } + + fn parse_resource_metadata( + resource_metadata_url: &Url, + body: &[u8], + ) -> Option { + let metadata = match serde_json::from_slice::(body) { Ok(metadata) => metadata, Err(e) => { debug!("failed to parse resource metadata as JSON: {}", e); - return Ok(None); + return None; } }; @@ -2731,8 +2769,8 @@ impl AuthorizationManager { // 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. + // resource metadata document. Treat it as a soft failure, the same way a + // non-200 status and a body that is not JSON are treated. if metadata.resource.is_none() && metadata.authorization_server.is_none() && metadata.authorization_servers.is_none() @@ -2740,10 +2778,10 @@ impl AuthorizationManager { debug!( "response at {resource_metadata_url} is not a protected resource metadata document" ); - return Ok(None); + return None; } - Ok(Some(metadata)) + Some(metadata) } fn discovery_failed(url: &Url, error: OAuthHttpClientError) -> AuthError { @@ -4366,13 +4404,6 @@ mod tests { "authorization_servers": ["https://auth.example.com/tenant1"] }), ), - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/", - "authorization_servers": ["https://auth.example.com/tenant1"] - }), - ), http_response( 200, serde_json::json!({ @@ -4407,7 +4438,6 @@ mod tests { vec![ "https://mcp.example.com/", "https://mcp.example.com/.well-known/oauth-protected-resource", - "https://mcp.example.com/.well-known/oauth-protected-resource", "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", ], ) @@ -4425,13 +4455,6 @@ mod tests { "authorization_servers": ["https://auth.example.com/tenant1/"] }), ), - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/", - "authorization_servers": ["https://auth.example.com/tenant1/"] - }), - ), http_response( 200, serde_json::json!({ @@ -4897,24 +4920,20 @@ mod tests { #[tokio::test] async fn resolve_metadata_reaches_the_well_known_document_past_a_non_metadata_base_url() { - let document = || { + 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 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!({ @@ -4964,8 +4983,7 @@ mod tests { let client = RecordingOAuthHttpClient::with_responses(vec![ // the MCP endpoint answers GET with a health payload, not metadata health(), - // so does the well-known candidate: probed, then fetched - health(), + // so does the well-known candidate health(), http_response( 200, @@ -4997,6 +5015,71 @@ mod tests { ); } + #[tokio::test] + async fn resolve_metadata_tries_the_next_well_known_candidate_past_a_non_metadata_document() { + 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"}), + ), + // so does the first well-known candidate + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // the second candidate carries the real document + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + 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/mcp", + 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", + ) + ); + let document_requests = recorder + .requests() + .iter() + .filter(|request| { + request.uri == "https://mcp.example.com/mcp/.well-known/oauth-protected-resource" + }) + .count(); + assert_eq!( + document_requests, + 1, + "the candidate holding the document should be requested exactly once: {:?}", + recorder.requests() + ); + } + #[rstest] #[case::protected_resource_metadata( AuthorizationMetadataSource::ProtectedResourceMetadata, From b165775f28b6df528d78456189329e58e808d7c8 Mon Sep 17 00:00:00 2001 From: easyinplay Date: Mon, 31 Aug 2026 00:20:14 +0800 Subject: [PATCH 4/7] fix(auth): report an advertised url that is not the metadata document 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. --- crates/rmcp/src/transport/auth.rs | 98 +++++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 19 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 4dc5ed3f3..a3e942f37 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -635,6 +635,18 @@ struct ResourceServerMetadata { scopes_supported: Option>, } +/// How a url that may hold protected resource metadata was arrived at, which +/// decides what a document that is not metadata means there. +#[derive(Debug, Clone, Copy)] +enum ResourceMetadataUrlOrigin { + /// The server named this url in the `resource_metadata` parameter of a + /// `WWW-Authenticate` challenge. + Advertised, + /// The url was derived from the base url, on the chance that the document is + /// published there. + WellKnownGuess, +} + /// Parameters extracted from WWW-Authenticate header #[derive(Debug, Clone, Default)] #[non_exhaustive] @@ -2481,7 +2493,10 @@ impl AuthorizationManager { resource_metadata_url: &Url, ) -> Result, AuthError> { let Some(resource_metadata) = self - .fetch_resource_metadata_from_url(resource_metadata_url) + .fetch_resource_metadata_from_url( + resource_metadata_url, + ResourceMetadataUrlOrigin::Advertised, + ) .await? else { return Ok(None); @@ -2631,7 +2646,10 @@ impl AuthorizationManager { ) -> Result, AuthError> { if let Some(resource_metadata_url) = self.probe_resource_endpoint_for_challenge().await? { return Ok(self - .fetch_resource_metadata_from_url(&resource_metadata_url) + .fetch_resource_metadata_from_url( + &resource_metadata_url, + ResourceMetadataUrlOrigin::Advertised, + ) .await? .map(|metadata| (resource_metadata_url, metadata))); } @@ -2655,9 +2673,11 @@ impl AuthorizationManager { // The candidate url is the document itself, so read the body here // instead of requesting the same url again. StatusCode::OK => { - if let Some(metadata) = - Self::parse_resource_metadata(&candidate_url, response.body()) - { + if let Some(metadata) = Self::parse_resource_metadata( + &candidate_url, + response.body(), + ResourceMetadataUrlOrigin::WellKnownGuess, + )? { return Ok(Some((candidate_url, metadata))); } } @@ -2666,7 +2686,10 @@ impl AuthorizationManager { .extract_resource_metadata_url_from_www_authenticate(&response) .await && let Some(metadata) = self - .fetch_resource_metadata_from_url(&advertised_url) + .fetch_resource_metadata_from_url( + &advertised_url, + ResourceMetadataUrlOrigin::Advertised, + ) .await? { return Ok(Some((advertised_url, metadata))); @@ -2729,6 +2752,7 @@ impl AuthorizationManager { async fn fetch_resource_metadata_from_url( &self, resource_metadata_url: &Url, + origin: ResourceMetadataUrlOrigin, ) -> Result, AuthError> { debug!( "resource metadata discovery url: {:?}", @@ -2747,21 +2771,19 @@ impl AuthorizationManager { return Ok(None); } - Ok(Self::parse_resource_metadata( - resource_metadata_url, - response.body(), - )) + Self::parse_resource_metadata(resource_metadata_url, response.body(), origin) } fn parse_resource_metadata( resource_metadata_url: &Url, body: &[u8], - ) -> Option { + origin: ResourceMetadataUrlOrigin, + ) -> Result, AuthError> { let metadata = match serde_json::from_slice::(body) { Ok(metadata) => metadata, Err(e) => { debug!("failed to parse resource metadata as JSON: {}", e); - return None; + return Ok(None); } }; @@ -2769,19 +2791,30 @@ impl AuthorizationManager { // 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 a - // non-200 status and a body that is not JSON are treated. + // resource metadata document. 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 None; + return match origin { + // The server named this url, so there is nothing better to move on + // to: the alternatives all drop the resource binding the document + // was supposed to carry. Report it instead. + ResourceMetadataUrlOrigin::Advertised => Err(AuthError::MetadataError(format!( + "the server advertised {resource_metadata_url} as protected resource metadata, but the document carries neither `resource` nor an authorization server reference" + ))), + // Nothing advertised this url, so its answer only rules out this + // candidate. + ResourceMetadataUrlOrigin::WellKnownGuess => { + debug!( + "response at {resource_metadata_url} is not a protected resource metadata document" + ); + Ok(None) + } + }; } - Some(metadata) + Ok(Some(metadata)) } fn discovery_failed(url: &Url, error: OAuthHttpClientError) -> AuthError { @@ -5080,6 +5113,33 @@ mod tests { ); } + #[tokio::test] + async fn resolve_metadata_from_challenge_reports_an_advertised_url_without_metadata() { + let mut responses = vec![http_response(200, serde_json::json!({}))]; + // enough responses for the fallback to reach the legacy endpoints, so that + // treating the document as a soft failure would resolve rather than error + responses.extend(std::iter::repeat_with(|| empty_response(404)).take(8)); + let client = RecordingOAuthHttpClient::with_responses(responses); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client), + ) + .await + .unwrap(); + + let error = manager + .resolve_metadata_from_challenge(Some( + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + )) + .await + .unwrap_err(); + + assert_eq!( + error.to_string(), + "Metadata error: the server advertised https://mcp.example.com/.well-known/oauth-protected-resource as protected resource metadata, but the document carries neither `resource` nor an authorization server reference" + ); + } + #[rstest] #[case::protected_resource_metadata( AuthorizationMetadataSource::ProtectedResourceMetadata, From f2cbb68bbd7c5d9524fb6c0b8e457305896d1fe9 Mon Sep 17 00:00:00 2001 From: easyinplay Date: Mon, 31 Aug 2026 21:29:08 +0800 Subject: [PATCH 5/7] fix(auth): let a candidate fail validation without ending discovery 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". --- crates/rmcp/src/transport/auth.rs | 94 +++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index a3e942f37..47befb8b4 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2508,13 +2508,14 @@ impl AuthorizationManager { /// Walk the authorization servers a protected resource metadata document /// names, keeping the first one that answers with usable metadata. + /// + /// The document arrives here through `read_resource_metadata`, which is where + /// it is decided to be this resource's metadata at all. async fn authorization_metadata_from_resource_metadata( &self, resource_metadata_url: &Url, resource_metadata: ResourceServerMetadata, ) -> Result, AuthError> { - self.validate_resource_metadata_resource(&resource_metadata)?; - self.discovered_resource .write() .await @@ -2673,7 +2674,7 @@ impl AuthorizationManager { // The candidate url is the document itself, so read the body here // instead of requesting the same url again. StatusCode::OK => { - if let Some(metadata) = Self::parse_resource_metadata( + if let Some(metadata) = self.read_resource_metadata( &candidate_url, response.body(), ResourceMetadataUrlOrigin::WellKnownGuess, @@ -2771,10 +2772,15 @@ impl AuthorizationManager { return Ok(None); } - Self::parse_resource_metadata(resource_metadata_url, response.body(), origin) + self.read_resource_metadata(resource_metadata_url, response.body(), origin) } - fn parse_resource_metadata( + /// Read a response body as this resource's protected resource metadata. + /// + /// A body that is not that document rules out the url it came from, and where + /// that url came from decides whether ruling it out leaves anything to try. + fn read_resource_metadata( + &self, resource_metadata_url: &Url, body: &[u8], origin: ResourceMetadataUrlOrigin, @@ -2814,6 +2820,21 @@ impl AuthorizationManager { }; } + // Carrying those fields only makes the body a metadata document; validation + // is what makes it this resource's. Both answers rule out the url the same + // way, so both are read the same way. + if let Err(error) = self.validate_resource_metadata_resource(&metadata) { + return match origin { + ResourceMetadataUrlOrigin::Advertised => Err(error), + ResourceMetadataUrlOrigin::WellKnownGuess => { + debug!( + "document at {resource_metadata_url} is not this resource's metadata: {error}" + ); + Ok(None) + } + }; + } + Ok(Some(metadata)) } @@ -5140,6 +5161,69 @@ mod tests { ); } + #[tokio::test] + async fn resolve_metadata_tries_the_next_well_known_candidate_past_an_unusable_document() { + 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"}), + ), + // a catch-all handler answers the first candidate with its own error + // shape, which carries a `resource` that only validation rejects + http_response( + 200, + serde_json::json!({ + "error": "not_found", + "resource": "/.well-known/oauth-protected-resource" + }), + ), + // the second candidate carries the real document + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + 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/mcp", + 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/mcp/.well-known/oauth-protected-resource" + }), + "the candidate after the rejected one was never probed: {:?}", + recorder.requests() + ); + } + #[rstest] #[case::protected_resource_metadata( AuthorizationMetadataSource::ProtectedResourceMetadata, From aaf529e31495c96e2987d798d5af89f5230078bf Mon Sep 17 00:00:00 2001 From: easyinplay Date: Mon, 31 Aug 2026 21:31:37 +0800 Subject: [PATCH 6/7] fix(auth): keep probing the candidates when an advertised url answers 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. --- crates/rmcp/src/transport/auth.rs | 158 +++++++++++++++++++++++++++--- 1 file changed, 145 insertions(+), 13 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 47befb8b4..499d17742 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, future::Future, net::{IpAddr, Ipv4Addr, Ipv6Addr}, pin::Pin, @@ -2645,17 +2645,25 @@ impl AuthorizationManager { async fn discover_resource_metadata( &self, ) -> Result, AuthError> { - if let Some(resource_metadata_url) = self.probe_resource_endpoint_for_challenge().await? { - return Ok(self + // A url the resource points at can also be one of the candidates below. + let mut requested = HashSet::new(); + + if let Some(advertised_url) = self.probe_resource_endpoint_for_challenge().await? { + requested.insert(advertised_url.clone()); + if let Some(metadata) = self .fetch_resource_metadata_from_url( - &resource_metadata_url, + &advertised_url, ResourceMetadataUrlOrigin::Advertised, ) .await? - .map(|metadata| (resource_metadata_url, metadata))); + { + return Ok(Some((advertised_url, metadata))); + } + // Nothing was published there. The candidates below are reached from the + // base url rather than from that pointer, so they are still worth trying. } - // If the primary URL doesn't use WWW-Authenticate, try oauth-protected-resource discovery. + // The other place the document can be is the well-known location. // https://www.rfc-editor.org/rfc/rfc9728.html#name-obtaining-protected-resourc for candidate_path in Self::well_known_paths(self.base_url.path(), "oauth-protected-resource") @@ -2665,6 +2673,10 @@ impl AuthorizationManager { candidate_url.set_fragment(None); candidate_url.set_path(&candidate_path); + if !requested.insert(candidate_url.clone()) { + continue; + } + let response = self .discovery_get(&candidate_url) .await @@ -2683,15 +2695,21 @@ impl AuthorizationManager { } } StatusCode::UNAUTHORIZED => { - if let Some(advertised_url) = self + let Some(advertised_url) = self .extract_resource_metadata_url_from_www_authenticate(&response) .await - && let Some(metadata) = self - .fetch_resource_metadata_from_url( - &advertised_url, - ResourceMetadataUrlOrigin::Advertised, - ) - .await? + else { + continue; + }; + if !requested.insert(advertised_url.clone()) { + continue; + } + if let Some(metadata) = self + .fetch_resource_metadata_from_url( + &advertised_url, + ResourceMetadataUrlOrigin::Advertised, + ) + .await? { return Ok(Some((advertised_url, metadata))); } @@ -5224,6 +5242,120 @@ mod tests { ); } + #[tokio::test] + async fn resolve_metadata_probes_the_candidates_past_an_advertised_url_that_is_not_served() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/prm""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + // the advertised url is not where the document is served + empty_response(404), + // neither is the first candidate + empty_response(404), + // the second candidate carries the document + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + 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/mcp", + 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/mcp/.well-known/oauth-protected-resource" + }), + "the candidates were skipped after the advertised url answered 404: {:?}", + recorder.requests() + ); + } + + #[tokio::test] + async fn resolve_metadata_requests_an_advertised_url_that_is_also_a_candidate_once() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let mut 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 first candidate points at the last candidate of the same run + challenge, + ]; + // the document is served nowhere, so the run walks every candidate and + // settles on the legacy endpoints + responses.extend(std::iter::repeat_with(|| empty_response(404)).take(10)); + let client = RecordingOAuthHttpClient::with_responses(responses); + let recorder = client.clone(); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + resolution.source, + AuthorizationMetadataSource::LegacyEndpointFallback + ); + let advertised_requests = recorder + .requests() + .iter() + .filter(|request| { + request.uri == "https://mcp.example.com/.well-known/oauth-protected-resource" + }) + .count(); + assert_eq!( + advertised_requests, + 1, + "the url the challenge named was requested again as a candidate: {:?}", + recorder.requests() + ); + } + #[rstest] #[case::protected_resource_metadata( AuthorizationMetadataSource::ProtectedResourceMetadata, From e7cebf13bb14421b392aeb69255d9d6bd65140d3 Mon Sep 17 00:00:00 2001 From: easyinplay Date: Mon, 31 Aug 2026 21:33:42 +0800 Subject: [PATCH 7/7] fix(auth): walk an authorization server named by both fields once 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. --- crates/rmcp/src/transport/auth.rs | 78 ++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 499d17742..dacc4d942 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2528,24 +2528,28 @@ impl AuthorizationManager { *self.resource_scopes.write().await = scopes; } - let mut candidates = Vec::new(); + // A server naming the same authorization server in both the singular draft + // field and the list would otherwise have each of that server's well-known + // forms requested twice. + let mut candidates: Vec = Vec::new(); + let mut push_candidate = |candidate: String| { + let candidate = candidate.trim(); + if !candidate.is_empty() && !candidates.iter().any(|kept| kept == candidate) { + candidates.push(candidate.to_string()); + } + }; if let Some(single) = resource_metadata.authorization_server { - candidates.push(single); + push_candidate(single); } - if let Some(list) = resource_metadata.authorization_servers { - candidates.extend(list); + for candidate in resource_metadata.authorization_servers.unwrap_or_default() { + push_candidate(candidate); } for candidate in candidates { - let candidate = candidate.trim(); - if candidate.is_empty() { - continue; - } - - let candidate_url = match Url::parse(candidate) { + let candidate_url = match Url::parse(&candidate) { Ok(url) => url, - Err(_) => match resource_metadata_url.join(candidate) { + Err(_) => match resource_metadata_url.join(&candidate) { Ok(url) => url, Err(e) => { debug!("Failed to resolve authorization server URL `{candidate}`: {e}"); @@ -5356,6 +5360,58 @@ mod tests { ); } + #[tokio::test] + async fn resolve_metadata_requests_an_authorization_server_named_twice_once() { + let mut 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 document names the same authorization server in both the singular + // draft field and the list + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_server": "https://auth.example.com", + "authorization_servers": ["https://auth.example.com"] + }), + ), + ]; + // the authorization server publishes no metadata, so every form of its + // discovery url is tried before the run settles on the legacy endpoints + responses.extend(std::iter::repeat_with(|| empty_response(404)).take(10)); + let client = RecordingOAuthHttpClient::with_responses(responses); + 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, + AuthorizationMetadataSource::LegacyEndpointFallback + ); + let discovery_requests = recorder + .requests() + .iter() + .filter(|request| { + request.uri == "https://auth.example.com/.well-known/oauth-authorization-server" + }) + .count(); + assert_eq!( + discovery_requests, + 1, + "the authorization server was walked once per field naming it: {:?}", + recorder.requests() + ); + } + #[rstest] #[case::protected_resource_metadata( AuthorizationMetadataSource::ProtectedResourceMetadata,