From 2585e89e9c023ac80ec61035634d2799241af734 Mon Sep 17 00:00:00 2001 From: Khaled AbuShqear Date: Sun, 9 Aug 2026 19:29:31 +0300 Subject: [PATCH] fix(mcp): forward dcr_bridge-injected credential instead of discarding it for oauth_delegate _admit_dcr_bridge_delegate opens the caller's signed DCR-bridge envelope during admission and injects the real unsealed upstream credential as mcp_auth_header. _create_mcp_client's v1-override exemption kept the v2 spec for PassthroughConfig unconditionally (which oauth_delegate maps to), discarding that injected credential and falling back to the v2 resolver's raw passthrough headers -- headers an external DCR client (e.g. claude.ai) has no way to populate, since it only ever presents the one envelope bearer that admission already strips before egress. The upstream MCP server then receives no Authorization header at all. dcr_bridge servers now always take the v1 override path here, since their mcp_auth_header is a cryptographically verified credential from admission, not an arbitrary caller-supplied bearer -- the security concern the PassthroughConfig exemption exists for doesn't apply to this origin. Fixes #36358 --- .../mcp_server/mcp_server_manager.py | 19 +++++++++-- tests/mcp_tests/test_mcp_auth_priority.py | 32 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index c8ff6e262d2..3a01d76e47c 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -3193,12 +3193,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"