diff --git a/litellm/proxy/_experimental/mcp_server/exceptions.py b/litellm/proxy/_experimental/mcp_server/exceptions.py index cb1df1e92d6..9d0f21d37d2 100644 --- a/litellm/proxy/_experimental/mcp_server/exceptions.py +++ b/litellm/proxy/_experimental/mcp_server/exceptions.py @@ -33,13 +33,21 @@ class MCPUpstreamAuthError(Exception): preserves the upstream status code and any ``WWW-Authenticate`` challenge, so standards-compliant MCP clients can trigger the upstream OAuth flow. + + When the upstream 401 omits ``WWW-Authenticate`` (non-compliant per + RFC 7235 ยง3.1) we fabricate a ``Bearer resource_metadata=`` challenge + that points at the gateway's standard-pattern well-known endpoint for + this server, so MCP clients can still initiate RFC 9728 discovery + against the upstream IdP via the gateway's proxied metadata. """ + challenge: Optional[str] = self.www_authenticate + if challenge is None and self.status_code == 401: + challenge = ( + "Bearer resource_metadata=" + f'"/.well-known/oauth-protected-resource/mcp/{self.server_name}"' + ) return HTTPException( status_code=self.status_code, detail="Unauthorized", - headers=( - {"www-authenticate": self.www_authenticate} - if self.www_authenticate - else None - ), + headers={"www-authenticate": challenge} if challenge else None, ) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py index 0e00443d15d..2d7ba622288 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py @@ -112,6 +112,46 @@ async def test_fetch_tools_from_passthrough_returns_tools_on_success(): assert tools == [tool] +def test_to_http_exception_preserves_upstream_www_authenticate(): + err = MCPUpstreamAuthError( + status_code=401, + www_authenticate='Bearer resource_metadata="https://upstream/.well-known/oauth-protected-resource"', + server_name="sample_docs", + ) + + http_exc = err.to_http_exception() + assert http_exc.status_code == 401 + assert http_exc.headers == { + "www-authenticate": 'Bearer resource_metadata="https://upstream/.well-known/oauth-protected-resource"' + } + + +def test_to_http_exception_fabricates_resource_metadata_when_upstream_omits_header(): + err = MCPUpstreamAuthError( + status_code=401, + www_authenticate=None, + server_name="sample_docs", + ) + + http_exc = err.to_http_exception() + assert http_exc.status_code == 401 + assert http_exc.headers == { + "www-authenticate": 'Bearer resource_metadata="/.well-known/oauth-protected-resource/mcp/sample_docs"' + } + + +def test_to_http_exception_skips_challenge_for_non_401_status(): + err = MCPUpstreamAuthError( + status_code=403, + www_authenticate=None, + server_name="sample_docs", + ) + + http_exc = err.to_http_exception() + assert http_exc.status_code == 403 + assert http_exc.headers is None + + @pytest.mark.asyncio async def test_fetch_tools_from_gateway_managed_swallows_errors(): """Regression guard: non-pass-through servers keep returning [] on errors."""