context_server: Handle buggy server WWW-Authenticate resource_metadata URLs

Tom Houlé created

When the resource_metadata URL from the WWW-Authenticate header is same-origin but points to a broken endpoint (for example Pydantic Logfire doubles the path component, producing /mcp/mcp), fall back to the RFC 9728 well-known URIs instead of failing outright. The header URL is still tried first per the MCP spec requirement.

Change summary

crates/context_server/src/oauth.rs | 79 +++++++++++++++++++++++++++++++
1 file changed, 78 insertions(+), 1 deletion(-)

Detailed changes

crates/context_server/src/oauth.rs 🔗

@@ -694,7 +694,19 @@ pub async fn fetch_protected_resource_metadata(
     www_authenticate: &WwwAuthenticate,
 ) -> Result<ProtectedResourceMetadata> {
     let candidate_urls = match &www_authenticate.resource_metadata {
-        Some(url) if url.origin() == server_url.origin() => vec![url.clone()],
+        Some(url) if url.origin() == server_url.origin() => {
+            // Try the header-provided URL first (per MCP spec: "use the resource
+            // metadata URL from the parsed WWW-Authenticate headers when present"),
+            // then fall back to RFC 9728 well-known URIs in case the header URL is
+            // wrong (e.g. a buggy server that doubles the path component).
+            let mut urls = vec![url.clone()];
+            for fallback in protected_resource_metadata_urls(server_url) {
+                if !urls.contains(&fallback) {
+                    urls.push(fallback);
+                }
+            }
+            urls
+        }
         Some(url) => {
             log::warn!(
                 "Ignoring cross-origin resource_metadata URL {} \
@@ -2044,6 +2056,71 @@ mod tests {
         });
     }
 
+    #[test]
+    fn test_fetch_protected_resource_metadata_falls_back_when_header_url_fails() {
+        // Reproduces the Pydantic Logfire case: the server's WWW-Authenticate
+        // header contains a resource_metadata URL with a doubled path (e.g.
+        // /mcp/mcp), which returns HTML instead of JSON. The client should
+        // fall back to the RFC 9728 well-known URL, which works correctly.
+        smol::block_on(async {
+            let client = make_fake_http_client(|req| {
+                Box::pin(async move {
+                    let uri = req.uri().to_string();
+                    if uri
+                        == "https://mcp.example.com/.well-known/oauth-protected-resource/api/mcp/mcp"
+                    {
+                        // Buggy header URL returns HTML (like a SPA catch-all).
+                        Ok(Response::builder()
+                            .status(200)
+                            .header("Content-Type", "text/html")
+                            .body(AsyncBody::from(b"<!doctype html><html></html>".to_vec()))
+                            .unwrap())
+                    } else if uri
+                        == "https://mcp.example.com/.well-known/oauth-protected-resource/api/mcp"
+                    {
+                        // Correct well-known URL returns valid metadata.
+                        json_response(
+                            200,
+                            r#"{
+                                "resource": "https://mcp.example.com/api/mcp",
+                                "authorization_servers": ["https://auth.example.com"]
+                            }"#,
+                        )
+                    } else {
+                        json_response(404, "{}")
+                    }
+                })
+            });
+
+            let server_url = Url::parse("https://mcp.example.com/api/mcp").unwrap();
+            let www_auth = WwwAuthenticate {
+                resource_metadata: Some(
+                    // Buggy URL with doubled path component.
+                    Url::parse(
+                        "https://mcp.example.com/.well-known/oauth-protected-resource/api/mcp/mcp",
+                    )
+                    .unwrap(),
+                ),
+                scope: None,
+                error: None,
+                error_description: None,
+            };
+
+            let metadata = fetch_protected_resource_metadata(&client, &server_url, &www_auth)
+                .await
+                .unwrap();
+
+            assert_eq!(
+                metadata.resource.as_str(),
+                "https://mcp.example.com/api/mcp"
+            );
+            assert_eq!(
+                metadata.authorization_servers[0].as_str(),
+                "https://auth.example.com/"
+            );
+        });
+    }
+
     #[test]
     fn test_fetch_protected_resource_metadata_rejects_cross_origin_url() {
         smol::block_on(async {