fix(mcp): fabricate resource_metadata challenge when upstream 401 omits WWW-Authenticate

When an upstream pass-through MCP server returns 401 without a
WWW-Authenticate header (non-compliant per RFC 7235 §3.1),
to_http_exception() now produces a synthetic Bearer challenge pointing
at the gateway's standard-pattern oauth-protected-resource well-known
endpoint for that server. This keeps MCP clients on the RFC 9728
discovery flow instead of receiving a bare 401 with no recovery hint.
This commit is contained in:
mateo-berri 2026-05-21 19:38:19 +00:00
parent 1b0b75ccd7
commit 064f31e826
No known key found for this signature in database
2 changed files with 53 additions and 5 deletions

View file

@ -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,
)

View file

@ -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."""