From f6359cbf045d1151793b1ec4198402888caa72fe Mon Sep 17 00:00:00 2001 From: Artem Dudarev Date: Tue, 19 May 2026 16:42:56 +0300 Subject: [PATCH] fix(mcp): forward Authorization in pass-through when x-litellm-api-key is admission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 3753970cc9 widened the Authorization strip to cover all is_oauth_passthrough servers — protecting against the LiteLLM admission key leaking upstream when the caller used Authorization for admission, but also silently stripping legitimate upstream OAuth bearers when the caller used x-litellm-api-key for admission. That broke transparent OAuth pass-through (EAI-506 V5/V6): standards- compliant MCP clients (OpenCode, Claude Code, mcp-inspector) complete PKCE against the upstream IdP and send the resulting token as plain Authorization: Bearer per the MCP spec — with the wider strip in place, that token never reaches the upstream and tools/list returns empty. Narrow the strip: skip Authorization for pass-through servers only when the caller did NOT supply x-litellm-api-key. When x-litellm-api-key is present, admission is unambiguous and Authorization is free to carry the upstream OAuth bearer. The original security guarantee is preserved — a client that sends only Authorization (no x-litellm-api-key) still has it stripped, so the LiteLLM key cannot leak upstream via that path. Tests: - new: forwards Authorization when x-litellm-api-key is present - new: still strips Authorization when only Authorization is present - existing pass-through + M2M tests unchanged Co-Authored-By: Claude Sonnet 4.6 --- .../proxy/_experimental/mcp_server/server.py | 30 +++++--- .../mcp_server/test_mcp_server.py | 75 +++++++++++++++++++ 2 files changed, 96 insertions(+), 9 deletions(-) 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: