fix(mcp): challenge delegate-auth OAuth servers with upstream resource_metadata (#31255)

An oauth2 MCP server with delegate_auth_to_upstream=true never prompted the
user to sign in. On an unauthenticated initialize the gateway answered locally
(200, no tools) and emitted no WWW-Authenticate, so clients like Claude Desktop
either connected empty or hit "OAuth probe timeout after 10000ms".

#30124 added a bare `continue` in _raise_preemptive_401_for_unauthenticated_servers
to stop sending LiteLLM's gateway authorization_uri challenge for delegate-auth
servers, expecting the upstream to emit its own challenge. On initialize the
gateway never probes upstream, so no challenge ever reached the client.

Replace the `continue` with a preemptive 401 carrying the proxied
resource_metadata (RFC 9728) challenge, the same form passthrough servers and
MCPUpstreamAuthError already use. This keeps #29770 fixed (still no
authorization_uri) while restoring the upstream PKCE sign-in prompt.
This commit is contained in:
tin-berri 2026-06-24 20:50:21 -07:00 committed by Yuneng Jiang
parent 0b9dce00e3
commit 6e8282d406
No known key found for this signature in database
2 changed files with 51 additions and 17 deletions

View file

@ -3462,7 +3462,19 @@ if MCP_AVAILABLE:
if stored_oauth_headers:
continue
if getattr(server, "delegate_auth_to_upstream", False) is True:
continue
# Delegate-auth servers run upstream PKCE: challenge with
# the proxied resource_metadata (RFC 9728), not the
# gateway authorization_uri below which would authorize
# against the gateway instead of the upstream IdP.
www_authenticate = _get_passthrough_www_authenticate(
scope=scope,
server_name=server_name,
)
raise HTTPException(
status_code=401,
detail="Unauthorized",
headers={"www-authenticate": www_authenticate},
)
request = StarletteRequest(scope)
base_url = get_request_base_url(request)

View file

@ -676,9 +676,11 @@ async def test_per_user_oauth_missing_stored_token_returns_preemptive_401():
@pytest.mark.asyncio
async def test_handle_streamable_http_mcp_delegated_server_surfaces_upstream_challenge():
"""
OAuth2 server with ``delegate_auth_to_upstream=True`` should let the
upstream MCP server's RFC 9728 challenge reach the client instead of
pre-emptively returning LiteLLM's gateway authorization_uri challenge.
OAuth2 server with ``delegate_auth_to_upstream=True`` where the client
already presents a bearer token the upstream rejects: the request bypasses
the preemptive challenge (a token is present) and reaches the session
manager, whose upstream ``MCPUpstreamAuthError`` is surfaced verbatim so the
client sees the upstream's RFC 9728 challenge rather than the gateway's.
"""
from fastapi import HTTPException
@ -722,9 +724,7 @@ async def test_handle_streamable_http_mcp_delegated_server_surfaces_upstream_cha
delegated_server.needs_user_oauth_token = True
delegated_server.server_id = "delegated-oauth-server"
upstream_challenge = (
'Bearer resource_metadata="https://upstream.example.com/.well-known/oauth-protected-resource"'
)
upstream_challenge = 'Bearer resource_metadata="https://upstream.example.com/.well-known/oauth-protected-resource"'
with (
patch(
@ -735,7 +735,7 @@ async def test_handle_streamable_http_mcp_delegated_server_surfaces_upstream_cha
None,
["delegated_oauth_server"],
None,
None,
{"Authorization": "Bearer upstream-token-the-upstream-rejects"},
None,
),
),
@ -863,16 +863,24 @@ async def test_per_user_oauth_with_stored_token_skips_preemptive_401():
@pytest.mark.asyncio
async def test_handle_streamable_http_mcp_delegated_server_without_token_reaches_session_manager():
async def test_handle_streamable_http_mcp_delegated_server_without_token_returns_preemptive_resource_metadata_401():
"""
OAuth2 server with ``delegate_auth_to_upstream=True`` and no stored token
should not receive LiteLLM's gateway authorization_uri challenge. The
request continues so the upstream MCP server can emit its RFC 9728 challenge.
must fail fast with a 401 carrying a ``resource_metadata=`` challenge that
points at the gateway's proxied oauth-protected-resource well-known, so the
MCP client starts PKCE against the upstream IdP. On ``initialize`` the
gateway answers locally and never probes upstream, so this preemptive
challenge is the only thing that can drive the client into the OAuth flow;
falling through to the session manager (or emitting the gateway
``authorization_uri=`` challenge) leaves the client with no tools and no
sign-in prompt.
"""
from fastapi import HTTPException
try:
from litellm.proxy._experimental.mcp_server.server import (
handle_streamable_http_mcp,
session_manager_stateless,
session_manager_stateful,
)
except ImportError:
pytest.skip("MCP server not available")
@ -880,7 +888,12 @@ async def test_handle_streamable_http_mcp_delegated_server_without_token_reaches
scope = {
"type": "http",
"method": "POST",
"path": "/mcp",
"path": "/mcp/delegated_oauth_server",
"_original_path": "/delegated_oauth_server/mcp",
"scheme": "https",
"query_string": b"",
"root_path": "",
"server": ("litellm.example.com", 443),
"headers": [
(b"content-type", b"application/json"),
(b"host", b"litellm.example.com"),
@ -889,7 +902,7 @@ async def test_handle_streamable_http_mcp_delegated_server_without_token_reaches
receive = AsyncMock(
return_value={
"type": "http.request",
"body": b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}',
"body": b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}',
"more_body": False,
}
)
@ -900,6 +913,7 @@ async def test_handle_streamable_http_mcp_delegated_server_without_token_reaches
delegated_server.auth_type = MCPAuth.oauth2
delegated_server.delegate_auth_to_upstream = True
delegated_server.needs_user_oauth_token = True
delegated_server.server_id = "delegated-oauth-server"
with (
patch(
@ -936,12 +950,20 @@ async def test_handle_streamable_http_mcp_delegated_server_without_token_reaches
return_value=delegated_server,
),
patch.object(
session_manager_stateless,
session_manager_stateful,
"handle_request",
new_callable=AsyncMock,
) as mock_handle_request,
):
await handle_streamable_http_mcp(scope, receive, send)
with pytest.raises(HTTPException) as exc_info:
await handle_streamable_http_mcp(scope, receive, send)
assert mock_get_stored_token.await_count == 1
assert mock_handle_request.await_count == 1
assert mock_handle_request.await_count == 0
assert exc_info.value.status_code == 401
challenge = exc_info.value.headers["www-authenticate"]
assert "resource_metadata=" in challenge
assert "authorization_uri=" not in challenge
assert (
"/.well-known/oauth-protected-resource/delegated_oauth_server/mcp" in challenge
)