diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 322666b273d..9f20ea81879 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -3230,6 +3230,13 @@ if MCP_AVAILABLE: user_api_key_auth=user_api_key_auth, client_ip=_sse_client_ip, ) + # Pre-flight auth check for pass-through servers: surface upstream + # 401/403 as a proper challenge before the SSE session commits 200 + # headers, so clients can refresh their OAuth token instead of + # being stuck with a silently empty tool list. + await _check_passthrough_upstream_auth( + scope, user_api_key_auth, mcp_servers, _sse_client_ip + ) set_auth_context( user_api_key_auth=user_api_key_auth, mcp_auth_header=mcp_auth_header, diff --git a/tests/mcp_tests/test_mcp_server.py b/tests/mcp_tests/test_mcp_server.py index 409f4fad99a..1274cad66af 100644 --- a/tests/mcp_tests/test_mcp_server.py +++ b/tests/mcp_tests/test_mcp_server.py @@ -504,6 +504,69 @@ async def test_sse_mcp_handler_mock(): ) +@pytest.mark.asyncio +async def test_sse_mcp_handler_propagates_passthrough_401(): + """SSE handler must raise 401 + WWW-Authenticate when the upstream + pass-through probe rejects the client's bearer token, instead of letting + the SSE session start and silently return empty tool lists.""" + from fastapi import HTTPException + + from litellm.proxy._types import UserAPIKeyAuth + + mock_scope = { + "type": "http", + "method": "GET", + "path": "/mcp/sse", + "headers": [(b"accept", b"text/event-stream")], + "query_string": b"", + "server": ("localhost", 8000), + "scheme": "http", + } + mock_receive = AsyncMock() + mock_send = AsyncMock() + + mock_auth_result = (UserAPIKeyAuth(), None, None, {}, {}, []) + + challenge = HTTPException( + status_code=401, + detail="Unauthorized", + headers={"WWW-Authenticate": "Bearer authorization_uri=https://example/"}, + ) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.sse_session_manager", + AsyncMock(), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new=AsyncMock(return_value=mock_auth_result), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.set_auth_context", + ), + patch( + "litellm.proxy._experimental.mcp_server.server._raise_preemptive_401_for_unauthenticated_servers", + new=AsyncMock(), + ), + patch( + "litellm.proxy._experimental.mcp_server.server._check_passthrough_upstream_auth", + new=AsyncMock(side_effect=challenge), + ), + ): + from litellm.proxy._experimental.mcp_server.server import handle_sse_mcp + + with pytest.raises(HTTPException) as excinfo: + await handle_sse_mcp(mock_scope, mock_receive, mock_send) + + assert excinfo.value.status_code == 401 + assert excinfo.value.headers and "WWW-Authenticate" in excinfo.value.headers + + def test_generate_stable_server_id(): """ Test the _generate_stable_server_id method to ensure hash stability across releases.