From 6e8282d40655d47ed1557f030e53d6819e464e79 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Wed, 24 Jun 2026 20:50:21 -0700 Subject: [PATCH] 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. --- .../proxy/_experimental/mcp_server/server.py | 14 ++++- .../mcp_server/test_mcp_stale_session.py | 54 +++++++++++++------ 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 08e42e918e9..1f86a0fcb7f 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -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) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py index d80d15e2140..67b72dbb695 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py @@ -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 + )