fix(mcp): harden DCR bridge admission

Preserve standard Authorization key validation while preventing client MCP credentials from receiving anonymous bridge admission.

Generated with AI

Co-Authored-By: Codex
This commit is contained in:
Daniel Meismer 2026-08-25 22:29:42 -04:00
parent a66e091cd7
commit da036ad0f0
2 changed files with 144 additions and 21 deletions

View file

@ -447,6 +447,8 @@ class MCPRequestHandler:
)
is not None
and not oauth2_headers
and not mcp_server_auth_headers
and not mcp_auth_header
):
validated_user_api_key_auth = UserAPIKeyAuth()
elif (
@ -456,9 +458,13 @@ class MCPRequestHandler:
client_ip=IPAddressUtils.get_mcp_client_ip(request),
)
) is not None and oauth2_headers:
validated_user_api_key_auth, mcp_server_auth_headers = await MCPRequestHandler._admit_dcr_bridge_delegate(
(
validated_user_api_key_auth,
mcp_server_auth_headers,
) = await MCPRequestHandler._admit_dcr_bridge_authorization(
server=bridge_delegate_target,
authorization_value=oauth2_headers["Authorization"],
litellm_api_key=litellm_api_key,
mcp_server_auth_headers=mcp_server_auth_headers,
request=request,
route=request_route,
@ -802,28 +808,57 @@ class MCPRequestHandler:
new_headers: Final = {**(mcp_server_auth_headers or {}), **injected}
return admitted, new_headers
case BridgeEnvelopeInvalid() | NotBridgeEnvelope():
resource_name: Final = server.alias or server.server_name
if resource_name is None:
raise HTTPException(
status_code=500,
detail="Server misconfigured: MCP server has no routable name",
)
raise HTTPException(
status_code=401,
detail="Invalid or expired credential",
headers=MappingProxyType(
{
"www-authenticate": get_passthrough_www_authenticate(
scope=request.scope,
server_name=resource_name,
invalid_token=True,
)
}
),
)
raise MCPRequestHandler._dcr_bridge_invalid_token_challenge(server=server, request=request)
case _:
assert_never(result)
@staticmethod
async def _admit_dcr_bridge_authorization(
server: MCPServer,
authorization_value: str,
litellm_api_key: str,
mcp_server_auth_headers: dict[str, dict[str, str]] | None,
request: Request,
route: str,
) -> tuple[UserAPIKeyAuth, dict[str, dict[str, str]] | None]:
if is_bridge_envelope_shaped(authorization_value):
return await MCPRequestHandler._admit_dcr_bridge_delegate(
server=server,
authorization_value=authorization_value,
mcp_server_auth_headers=mcp_server_auth_headers,
request=request,
route=route,
)
try:
admitted: Final = await user_api_key_auth(api_key=litellm_api_key, request=request)
except (HTTPException, ProxyException) as exc:
if not _is_litellm_auth_admission_error(exc):
raise
raise MCPRequestHandler._dcr_bridge_invalid_token_challenge(server=server, request=request) from exc
return admitted, mcp_server_auth_headers
@staticmethod
def _dcr_bridge_invalid_token_challenge(server: MCPServer, request: Request) -> HTTPException:
resource_name: Final = server.alias or server.server_name
if resource_name is None:
raise HTTPException(
status_code=500,
detail="Server misconfigured: MCP server has no routable name",
)
return HTTPException(
status_code=401,
detail="Invalid or expired credential",
headers=MappingProxyType(
{
"www-authenticate": get_passthrough_www_authenticate(
scope=request.scope,
server_name=resource_name,
invalid_token=True,
)
}
),
)
@staticmethod
async def _admit_gateway_session(
authorization_value: str,

View file

@ -5320,6 +5320,37 @@ class TestMCPDcrBridgeDelegateAdmission:
assert auth_result == UserAPIKeyAuth()
assert mcp_server_auth_headers == {}
@pytest.mark.parametrize(
"headers",
(
[(b"x-mcp-auth", b"Bearer upstream-token")],
[(b"x-mcp-bridge_delegate_server-authorization", b"Bearer upstream-token")],
),
ids=("deprecated-mcp-auth", "per-server-auth"),
)
async def test_client_mcp_credentials_do_not_receive_keyless_bridge_admission(self, headers):
scope = {
"type": "http",
"method": "POST",
"path": "/mcp/bridge_delegate_server",
"headers": headers,
}
with (
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth",
new_callable=AsyncMock,
side_effect=HTTPException(status_code=401, detail="Invalid key"),
) as mock_auth,
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
):
mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server()
with pytest.raises(HTTPException) as exc_info:
await MCPRequestHandler.process_mcp_request(scope)
assert exc_info.value.status_code == 401
mock_auth.assert_awaited_once()
async def test_valid_envelope_reloads_live_key_and_admits_its_authorization_context(self):
"""A valid envelope admits under the LIVE key record the sealed key_hash references, not a
blank identity: the reload is keyed by that exact hash, and the admitted auth carries the
@ -6058,6 +6089,7 @@ class TestMCPDcrBridgeDelegateAdmission:
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth",
new_callable=AsyncMock,
side_effect=HTTPException(status_code=401, detail="Invalid key"),
) as mock_auth,
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY),
@ -6067,7 +6099,7 @@ class TestMCPDcrBridgeDelegateAdmission:
await MCPRequestHandler.process_mcp_request(scope)
assert exc_info.value.status_code == 401
mock_auth.assert_not_called()
mock_auth.assert_awaited_once()
assert exc_info.value.headers == {
"www-authenticate": (
'Bearer error="invalid_token", '
@ -6075,6 +6107,62 @@ class TestMCPDcrBridgeDelegateAdmission:
)
}
async def test_valid_litellm_authorization_key_uses_standard_admission(self):
scope = {
"type": "http",
"method": "POST",
"path": "/mcp/bridge_delegate_server",
"headers": [(b"authorization", b"Bearer sk-valid-litellm-key")],
}
admitted = UserAPIKeyAuth(api_key="hashed-key", user_id="litellm-key-user")
with (
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth",
new_callable=AsyncMock,
return_value=admitted,
) as mock_auth,
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY),
):
mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server()
(
auth_result,
_mcp_auth,
_servers,
mcp_server_auth_headers,
_oauth,
_raw,
) = await MCPRequestHandler.process_mcp_request(scope)
assert auth_result is admitted
assert mcp_server_auth_headers == {}
assert mock_auth.await_args.kwargs["api_key"] == "Bearer sk-valid-litellm-key"
async def test_non_401_litellm_key_failure_is_not_converted_to_oauth_challenge(self):
scope = {
"type": "http",
"method": "POST",
"path": "/mcp/bridge_delegate_server",
"headers": [(b"authorization", b"Bearer sk-blocked-litellm-key")],
}
with (
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth",
new_callable=AsyncMock,
side_effect=HTTPException(status_code=403, detail="Key blocked"),
),
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY),
):
mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server()
with pytest.raises(HTTPException) as exc_info:
await MCPRequestHandler.process_mcp_request(scope)
assert exc_info.value.status_code == 403
assert not exc_info.value.headers
async def test_explicit_litellm_key_wins_over_envelope_arm(self):
"""An explicit x-litellm-api-key is always a LiteLLM credential and its arm precedes the
envelope arm: user_api_key_auth validates the key and NO inner token is injected, even