diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 22281803acc..93354ad2153 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -1168,18 +1168,30 @@ if MCP_AVAILABLE: str(k).lower(): v for k, v in raw_headers.items() if isinstance(k, str) } + has_explicit_litellm_admission_header = ( + normalized_raw_headers.get("x-litellm-api-key") is not None + ) + for header in server.extra_headers: if not isinstance(header, str): continue - # Never forward the inbound Authorization header that was used for - # LiteLLM API key authentication: - # - skip if server has client_credentials (fetch upstream token via M2M flow) - # - skip if server is oauth_passthrough (upstream token must come from - # a server-specific header, not the LiteLLM API key header) - if header.lower() == "authorization" and ( - server.has_client_credentials or server.is_oauth_passthrough - ): - continue + if header.lower() == "authorization": + # M2M servers fetch their own upstream token via the + # client_credentials flow — never forward the caller's + # Authorization header. + if server.has_client_credentials: + continue + # Transparent OAuth pass-through: forward the caller's + # Authorization header only when LiteLLM admission used + # a different header (`x-litellm-api-key`). Without an + # explicit admission header, `Authorization` may itself + # be the LiteLLM key — strip it to avoid leaking the + # gateway credential upstream. + if ( + server.is_oauth_passthrough + and not has_explicit_litellm_admission_header + ): + continue header_value = normalized_raw_headers.get(header.lower()) if header_value is None: continue diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 8ecdcec3400..dfe65dd7f56 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -177,6 +177,81 @@ def test_prepare_mcp_server_headers_passthrough_forwards_other_headers(): assert extra_headers == {"x-request-id": "req-123", "x-trace-id": "trace-456"} +def test_prepare_mcp_server_headers_passthrough_forwards_authorization_with_explicit_admission(): + """Transparent OAuth pass-through: when LiteLLM admission used the explicit + `x-litellm-api-key` header, the inbound `Authorization` header is + unambiguously the upstream OAuth bearer and MUST be forwarded. + + Regression for EAI-506 V5/V6 — a standards-compliant MCP client (e.g. + OpenCode) completes PKCE against the upstream IdP and sends the resulting + token as plain `Authorization: Bearer ` per the MCP spec. + """ + try: + from litellm.proxy._experimental.mcp_server.server import ( + _prepare_mcp_server_headers, + ) + except ImportError: + pytest.skip("MCP server not available") + + server = MCPServer( + server_id="server-passthrough-explicit-admission", + name="server", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + ) + + server_auth_header, extra_headers = _prepare_mcp_server_headers( + server=server, + mcp_server_auth_headers=None, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers={ + "x-litellm-api-key": "Bearer sk-litellm-key", + "authorization": "Bearer upstream-okta-token", + }, + ) + + assert server_auth_header is None + assert extra_headers == {"Authorization": "Bearer upstream-okta-token"} + + +def test_prepare_mcp_server_headers_passthrough_strips_authorization_without_admission_header(): + """Counterpart to the explicit-admission test: without `x-litellm-api-key`, + the inbound `Authorization` may itself be the LiteLLM admission key, so we + strip it to avoid leaking the gateway credential upstream. This preserves + the security guarantee introduced in commit 3753970cc9. + """ + try: + from litellm.proxy._experimental.mcp_server.server import ( + _prepare_mcp_server_headers, + ) + except ImportError: + pytest.skip("MCP server not available") + + server = MCPServer( + server_id="server-passthrough-no-admission", + name="server", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization", "x-request-id"], + ) + + server_auth_header, extra_headers = _prepare_mcp_server_headers( + server=server, + mcp_server_auth_headers=None, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers={ + "authorization": "Bearer sk-litellm-key", + "x-request-id": "req-789", + }, + ) + + assert server_auth_header is None + assert extra_headers == {"x-request-id": "req-789"} + + def test_prepare_mcp_server_headers_oauth2_m2m_omits_litellm_caller_authorization(): """M2M OAuth must not put caller Bearer (LiteLLM API key) into extra_headers (#23652).""" try: