diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 2330120adad..42addbee8b5 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -3638,12 +3638,25 @@ class MCPServerManager: # arbitrary bearer upstream, so we keep the v2 spec and ignore the override for these; the # REST tools preview supplies its not-yet-persisted token through the resolver # (cred_provider), never this path. + # + # dcr_bridge is a different origin, not a caller override: for a client-forwarded-token + # server admitted through the gateway-DCR bridge, `_admit_dcr_bridge_delegate` has already + # opened the caller's signed envelope and injected the real upstream credential it sealed — + # `mcp_auth_header` here IS that unsealed credential, not an arbitrary bearer the caller + # attached. Keeping `spec` for PassthroughConfig in this case discards that credential and + # falls through to the v2 resolver's own passthrough headers, which the DCR caller has no + # way to populate (it only ever presents the one envelope bearer, which admission already + # strips before egress). So a dcr_bridge server always takes the v1 path here regardless of + # spec.config, same as the non-exempted modes above. if ( spec is not None and mcp_auth_header - and not isinstance( - spec.config, - (AuthorizationCodeConfig, IdJagConfig, PassthroughConfig, TokenExchangeConfig), + and ( + server.is_dcr_bridge + or not isinstance( + spec.config, + (AuthorizationCodeConfig, IdJagConfig, PassthroughConfig, TokenExchangeConfig), + ) ) ): spec = None diff --git a/tests/mcp_tests/test_mcp_auth_priority.py b/tests/mcp_tests/test_mcp_auth_priority.py index 7ae0f59afe5..9b402f2f89b 100644 --- a/tests/mcp_tests/test_mcp_auth_priority.py +++ b/tests/mcp_tests/test_mcp_auth_priority.py @@ -76,3 +76,35 @@ async def test_mcp_server_config_auth_value_header_used(token_key): emitted = next(client._resolved_auth.auth_flow(httpx.Request("POST", server.url))) assert emitted.headers["Authorization"] == "Bearer example_token" assert client.auth_type == MCPAuth.bearer_token + + +@pytest.mark.asyncio +async def test_dcr_bridge_oauth_delegate_uses_admission_injected_credential(): + """A dcr_bridge + oauth_delegate server's mcp_auth_header must not be discarded. + + ``_admit_dcr_bridge_delegate`` opens the caller's signed envelope during admission and + injects the real upstream credential it sealed as ``mcp_auth_header`` here -- it is not an + arbitrary caller-supplied override. oauth_delegate maps to PassthroughConfig in the v2 + resolver (see outbound_credentials/adapter.py), which used to be exempted from the v1 + override path regardless of origin, silently discarding this credential and leaving the + upstream MCP server with no Authorization header at all (regression: BerriAI/litellm#36358). + """ + server = MCPServer( + server_id="test-dcr-bridge-server", + name="Test DCR Bridge Server", + server_name="test_dcr_bridge_server", + alias="test_dcr_bridge", + url="https://internal-mcp-server.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + dcr_bridge=True, + ) + + manager = MCPServerManager() + + client = await manager._create_mcp_client( + server=server, + mcp_auth_header="Bearer unsealed_upstream_token", + ) + + assert client._mcp_auth_value == "Bearer unsealed_upstream_token"