mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge pull request #37384 from daniel-meismer-zocdoc/litellm_mcp_dcr_bridge_complete_challenges
fix(mcp): complete DCR bridge OAuth challenges
This commit is contained in:
commit
ace28fd97a
2 changed files with 346 additions and 50 deletions
|
|
@ -1,6 +1,8 @@
|
|||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -13,6 +15,7 @@ import litellm
|
|||
from litellm._logging import verbose_logger
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import (
|
||||
get_passthrough_resource_metadata_url,
|
||||
get_passthrough_www_authenticate,
|
||||
get_request_base_url,
|
||||
well_known_root_suffix,
|
||||
)
|
||||
|
|
@ -298,6 +301,16 @@ def _admission_failure_fallback(
|
|||
raise exc
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DcrBridgeTarget:
|
||||
"""The single DCR-bridge server a request targets, paired with the exact name the caller
|
||||
used to reach it (alias or server_name, whichever they typed), which is the spelling an
|
||||
``invalid_token`` challenge must echo back."""
|
||||
|
||||
requested_name: str
|
||||
server: MCPServer
|
||||
|
||||
|
||||
class MCPRequestHandler:
|
||||
"""
|
||||
Class to handle MCP request processing, including:
|
||||
|
|
@ -437,27 +450,33 @@ class MCPRequestHandler:
|
|||
path=request_route,
|
||||
mcp_servers=mcp_servers,
|
||||
client_ip=IPAddressUtils.get_mcp_client_ip(request),
|
||||
) or (
|
||||
MCPRequestHandler._single_dcr_bridge_delegate_target(
|
||||
path=request_route,
|
||||
mcp_servers=mcp_servers,
|
||||
client_ip=IPAddressUtils.get_mcp_client_ip(request),
|
||||
)
|
||||
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 (
|
||||
(
|
||||
bridge_delegate_target := MCPRequestHandler._single_dcr_bridge_delegate_target(
|
||||
path=request_route,
|
||||
mcp_servers=mcp_servers,
|
||||
client_ip=IPAddressUtils.get_mcp_client_ip(request),
|
||||
)
|
||||
bridge_delegate_target := MCPRequestHandler._single_dcr_bridge_delegate_target(
|
||||
path=request_route,
|
||||
mcp_servers=mcp_servers,
|
||||
client_ip=IPAddressUtils.get_mcp_client_ip(request),
|
||||
)
|
||||
is not None
|
||||
and oauth2_headers
|
||||
and is_bridge_envelope_shaped(oauth2_headers["Authorization"])
|
||||
):
|
||||
# A single DCR-bridge oauth_delegate target carrying an envelope-shaped
|
||||
# Authorization: open the envelope, admit under its recovered identity, and
|
||||
# inject the inner upstream token for egress. A non-envelope bearer on the same
|
||||
# server is NOT admitted here — it falls through to the oauth2 arm, which 401s.
|
||||
validated_user_api_key_auth, mcp_server_auth_headers = await MCPRequestHandler._admit_dcr_bridge_delegate(
|
||||
server=bridge_delegate_target,
|
||||
) is not None and oauth2_headers:
|
||||
(
|
||||
validated_user_api_key_auth,
|
||||
mcp_server_auth_headers,
|
||||
) = await MCPRequestHandler._admit_dcr_bridge_authorization(
|
||||
server=bridge_delegate_target.server,
|
||||
requested_name=bridge_delegate_target.requested_name,
|
||||
authorization_value=oauth2_headers["Authorization"],
|
||||
litellm_api_key=litellm_api_key,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
request=request,
|
||||
route=request_route,
|
||||
|
|
@ -723,10 +742,10 @@ class MCPRequestHandler:
|
|||
@staticmethod
|
||||
def _single_dcr_bridge_delegate_target(
|
||||
path: str, mcp_servers: list[str] | None, client_ip: str | None
|
||||
) -> MCPServer | None:
|
||||
) -> DcrBridgeTarget | None:
|
||||
"""The one DCR-bridge ``oauth_delegate`` server this request targets, or ``None``.
|
||||
|
||||
Returns the server only when EXACTLY ONE target resolves and it is both
|
||||
Returns the target only when EXACTLY ONE name resolves and its server is both
|
||||
``is_oauth_delegate`` and ``is_dcr_bridge``. Fails closed (``None``) on a
|
||||
multi-target request, an unresolved target, or a non-matching server, so the
|
||||
envelope admission arm never fires for an aggregate scope or a server that did not
|
||||
|
|
@ -740,17 +759,21 @@ class MCPRequestHandler:
|
|||
if len(target_names) != 1:
|
||||
return None
|
||||
server: Final = global_mcp_server_manager.get_mcp_server_by_name(target_names[0], client_ip=client_ip)
|
||||
if server is None or not server.is_oauth_delegate or not server.is_dcr_bridge:
|
||||
# Both flags are security-sensitive opt-ins. Require literal booleans so
|
||||
# partially populated objects and truthy proxy values cannot enable bridge
|
||||
# admission accidentally.
|
||||
if server is None or server.is_oauth_delegate is not True or server.is_dcr_bridge is not True:
|
||||
return None
|
||||
# Egress resolves the injected per-server token only by alias / server_name; a server with
|
||||
# neither cannot receive the forwarded token, so fail closed rather than admit-and-drop.
|
||||
if not (server.server_name or server.alias):
|
||||
return None
|
||||
return server
|
||||
return DcrBridgeTarget(requested_name=target_names[0], server=server)
|
||||
|
||||
@staticmethod
|
||||
async def _admit_dcr_bridge_delegate(
|
||||
server: MCPServer,
|
||||
requested_name: str,
|
||||
authorization_value: str,
|
||||
mcp_server_auth_headers: dict[str, dict[str, str]] | None,
|
||||
request: Request,
|
||||
|
|
@ -798,10 +821,62 @@ class MCPRequestHandler:
|
|||
new_headers: Final = {**(mcp_server_auth_headers or {}), **injected}
|
||||
return admitted, new_headers
|
||||
case BridgeEnvelopeInvalid() | NotBridgeEnvelope():
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired credential")
|
||||
raise MCPRequestHandler._dcr_bridge_invalid_token_challenge(
|
||||
requested_name=requested_name, request=request
|
||||
)
|
||||
case _:
|
||||
assert_never(result)
|
||||
|
||||
@staticmethod
|
||||
async def _admit_dcr_bridge_authorization(
|
||||
server: MCPServer,
|
||||
requested_name: str,
|
||||
authorization_value: str,
|
||||
litellm_api_key: str,
|
||||
mcp_server_auth_headers: dict[str, dict[str, str]] | None, # mutable-ok: existing MCP sink shape
|
||||
request: Request,
|
||||
route: str,
|
||||
) -> tuple[UserAPIKeyAuth, dict[str, dict[str, str]] | None]: # mutable-ok: existing MCP sink shape
|
||||
if is_bridge_envelope_shaped(authorization_value):
|
||||
return await MCPRequestHandler._admit_dcr_bridge_delegate(
|
||||
server=server,
|
||||
requested_name=requested_name,
|
||||
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(
|
||||
requested_name=requested_name, request=request
|
||||
) from exc
|
||||
return admitted, mcp_server_auth_headers
|
||||
|
||||
@staticmethod
|
||||
def _dcr_bridge_invalid_token_challenge(requested_name: str, request: Request) -> HTTPException:
|
||||
"""The RFC 6750 ``invalid_token`` challenge for a failed bridge admission.
|
||||
|
||||
Named by the exact spelling the caller requested, matching the per-server well-known
|
||||
document and the other challenge emitters, so ``resource_metadata`` always points at the
|
||||
resource the client actually asked for even when alias and server_name differ."""
|
||||
return HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid or expired credential",
|
||||
headers=MappingProxyType(
|
||||
{
|
||||
"www-authenticate": get_passthrough_www_authenticate(
|
||||
scope=request.scope,
|
||||
server_name=requested_name,
|
||||
invalid_token=True,
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _admit_gateway_session(
|
||||
authorization_value: str,
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
|
||||
|
|
@ -5097,13 +5095,10 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
"""Admission-side arm for a DCR-bridge ``oauth_delegate`` client that authenticates with
|
||||
a single envelope bearer (LIT-4338).
|
||||
|
||||
The arm fires only for a single ``is_dcr_bridge`` ``is_oauth_delegate`` target carrying an
|
||||
envelope-shaped Authorization. It opens the litellm-signed envelope, reloads the live key
|
||||
record the sealed ``key_hash`` references so the caller is admitted under the key's current
|
||||
authorization context (team/org/object-permission) and revocation state, and injects the inner
|
||||
upstream token under the server's per-server auth-header key so egress forwards it. A key that
|
||||
is missing, blocked, or expired fails closed with a 401. Everything else must stay on its
|
||||
existing admission path.
|
||||
A credential-free request reaches the named MCP handler so it can issue the initial OAuth
|
||||
challenge. Every bearer on that same route enters envelope resolution. A valid envelope opens
|
||||
under its live authorization context, while invalid envelopes and non-envelope bearers receive
|
||||
a named ``invalid_token`` challenge. Everything else stays on its existing admission path.
|
||||
"""
|
||||
|
||||
_MASTER_KEY = "sk-bridge-master-key-for-envelope-derivation"
|
||||
|
|
@ -5138,6 +5133,8 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
minted_at=None,
|
||||
master_key=None,
|
||||
):
|
||||
from pydantic import SecretStr
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
|
||||
envelope_keys_from_master_key,
|
||||
)
|
||||
|
|
@ -5148,7 +5145,6 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
mint_envelope,
|
||||
user_identity,
|
||||
)
|
||||
from pydantic import SecretStr
|
||||
|
||||
identity = (
|
||||
user_identity(server_id=server_id, user_id=user_id)
|
||||
|
|
@ -5272,6 +5268,92 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
request.body = mock_body
|
||||
return request
|
||||
|
||||
async def test_bridge_target_requires_literal_boolean_opt_ins(self):
|
||||
"""Truthy proxy values must not opt an unresolved server into bridge admission."""
|
||||
for delegate_value, bridge_value in ((MagicMock(), True), (True, MagicMock())):
|
||||
server = MagicMock()
|
||||
server.is_oauth_delegate = delegate_value
|
||||
server.is_dcr_bridge = bridge_value
|
||||
server.server_name = "bridge_delegate_server"
|
||||
server.alias = None
|
||||
|
||||
with patch( # test-quality-ok: isolate the MCP registry when testing target selection
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
|
||||
) as mock_mgr:
|
||||
mock_mgr.get_mcp_server_by_name.return_value = server
|
||||
assert (
|
||||
MCPRequestHandler._single_dcr_bridge_delegate_target(
|
||||
path="/mcp/bridge_delegate_server",
|
||||
mcp_servers=None,
|
||||
client_ip=None,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
async def test_credential_free_named_bridge_request_reaches_mcp_handler(self):
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/bridge_delegate_server",
|
||||
"headers": [],
|
||||
}
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: observe the auth boundary while testing admission orchestration
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_auth,
|
||||
patch( # test-quality-ok: isolate the MCP registry used by request admission
|
||||
"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()
|
||||
(
|
||||
auth_result,
|
||||
_mcp_auth_header,
|
||||
_mcp_servers,
|
||||
mcp_server_auth_headers,
|
||||
_oauth2_headers,
|
||||
_raw_headers,
|
||||
) = await MCPRequestHandler.process_mcp_request(scope)
|
||||
|
||||
mock_auth.assert_not_called()
|
||||
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( # test-quality-ok: force credential rejection through request admission
|
||||
"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( # test-quality-ok: isolate the MCP registry used by request admission
|
||||
"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
|
||||
|
|
@ -5809,6 +5891,7 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
):
|
||||
_auth, new_headers = await MCPRequestHandler._admit_dcr_bridge_delegate(
|
||||
server=self._bridge_delegate_server(server_name="bridge_name", alias="bridge_alias"),
|
||||
requested_name="bridge_name",
|
||||
authorization_value=f"Bearer {envelope}",
|
||||
mcp_server_auth_headers=attacker_forwarded,
|
||||
request=self._mcp_request(),
|
||||
|
|
@ -5845,6 +5928,7 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
):
|
||||
_auth, new_headers = await MCPRequestHandler._admit_dcr_bridge_delegate(
|
||||
server=server,
|
||||
requested_name="bridge_delegate_server",
|
||||
authorization_value=f"Bearer {envelope}",
|
||||
mcp_server_auth_headers=None,
|
||||
request=self._mcp_request(),
|
||||
|
|
@ -5887,8 +5971,7 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
mock_auth.assert_called_once()
|
||||
|
||||
async def test_expired_envelope_fails_closed_401(self):
|
||||
"""An envelope whose exp is in the past must fail closed with a 401, never fall through to
|
||||
anonymous admission."""
|
||||
"""An expired envelope fails closed and tells the client where to reauthorize."""
|
||||
expired = self._mint_bridge_envelope(
|
||||
expires_in=60,
|
||||
minted_at=datetime.now(timezone.utc) - timedelta(hours=2),
|
||||
|
|
@ -5897,7 +5980,10 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/bridge_delegate_server",
|
||||
"headers": [(b"authorization", f"Bearer {expired}".encode("latin-1"))],
|
||||
"headers": [
|
||||
(b"host", b"testserver"),
|
||||
(b"authorization", f"Bearer {expired}".encode("latin-1")),
|
||||
],
|
||||
}
|
||||
|
||||
with (
|
||||
|
|
@ -5914,6 +6000,12 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
|
||||
assert exc_info.value.status_code == 401
|
||||
mock_auth.assert_not_called()
|
||||
assert exc_info.value.headers == {
|
||||
"www-authenticate": (
|
||||
'Bearer error="invalid_token", '
|
||||
'resource_metadata="http://testserver/.well-known/oauth-protected-resource/mcp/bridge_delegate_server"'
|
||||
)
|
||||
}
|
||||
|
||||
async def test_envelope_minted_for_a_different_server_fails_closed_401(self):
|
||||
"""An envelope sealed for another server_id must be rejected when presented to this server,
|
||||
|
|
@ -5924,7 +6016,10 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/bridge_delegate_server",
|
||||
"headers": [(b"authorization", f"Bearer {wrong_server}".encode("latin-1"))],
|
||||
"headers": [
|
||||
(b"host", b"testserver"),
|
||||
(b"authorization", f"Bearer {wrong_server}".encode("latin-1")),
|
||||
],
|
||||
}
|
||||
|
||||
with (
|
||||
|
|
@ -5941,6 +6036,12 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
|
||||
assert exc_info.value.status_code == 401
|
||||
mock_auth.assert_not_called()
|
||||
assert exc_info.value.headers == {
|
||||
"www-authenticate": (
|
||||
'Bearer error="invalid_token", '
|
||||
'resource_metadata="http://testserver/.well-known/oauth-protected-resource/mcp/bridge_delegate_server"'
|
||||
)
|
||||
}
|
||||
|
||||
async def test_envelope_under_wrong_master_key_fails_closed_401(self):
|
||||
"""An envelope-shaped bearer whose signature does not verify under the proxy's derived keys
|
||||
|
|
@ -5950,7 +6051,10 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/bridge_delegate_server",
|
||||
"headers": [(b"authorization", f"Bearer {foreign}".encode("latin-1"))],
|
||||
"headers": [
|
||||
(b"host", b"testserver"),
|
||||
(b"authorization", f"Bearer {foreign}".encode("latin-1")),
|
||||
],
|
||||
}
|
||||
|
||||
with (
|
||||
|
|
@ -5967,26 +6071,71 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
|
||||
assert exc_info.value.status_code == 401
|
||||
mock_auth.assert_not_called()
|
||||
assert exc_info.value.headers == {
|
||||
"www-authenticate": (
|
||||
'Bearer error="invalid_token", '
|
||||
'resource_metadata="http://testserver/.well-known/oauth-protected-resource/mcp/bridge_delegate_server"'
|
||||
)
|
||||
}
|
||||
|
||||
async def test_non_envelope_bearer_on_bridge_server_falls_through_to_oauth2_arm(self):
|
||||
"""A plain (non-envelope) bearer on the same bridge server must NOT be admitted by the
|
||||
envelope arm: it falls through to the oauth2 arm, which validates it as a LiteLLM key and
|
||||
401s here. Proves the arm is gated on envelope shape, not merely on the target being a
|
||||
bridge server."""
|
||||
@pytest.mark.parametrize("requested_name", ["bridge_name", "bridge_alias"])
|
||||
async def test_invalid_envelope_challenge_names_the_requested_spelling(self, requested_name):
|
||||
"""A server reachable under both its server_name and a distinct alias must challenge with
|
||||
metadata for the exact spelling the caller used, matching the per-server well-known
|
||||
document, so the client rediscovers against the resource it actually asked for."""
|
||||
foreign = self._mint_bridge_envelope(master_key="a-different-master-key-entirely")
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": f"/mcp/{requested_name}",
|
||||
"headers": [
|
||||
(b"host", b"testserver"),
|
||||
(b"authorization", f"Bearer {foreign}".encode("latin-1")),
|
||||
],
|
||||
}
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: prove standard admission is never consulted for an envelope bearer
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_auth,
|
||||
patch( # test-quality-ok: isolate the MCP registry, same seam as the sibling challenge tests
|
||||
"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), # test-quality-ok: envelope keys derive from the proxy master_key module global
|
||||
):
|
||||
mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server(
|
||||
server_name="bridge_name", alias="bridge_alias"
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await MCPRequestHandler.process_mcp_request(scope)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
mock_auth.assert_not_called()
|
||||
assert exc_info.value.headers == {
|
||||
"www-authenticate": (
|
||||
'Bearer error="invalid_token", '
|
||||
f'resource_metadata="http://testserver/.well-known/oauth-protected-resource/mcp/{requested_name}"'
|
||||
)
|
||||
}
|
||||
|
||||
async def test_non_envelope_bearer_on_bridge_server_returns_named_challenge(self):
|
||||
"""A raw provider bearer cannot authorize a bridge route and triggers reauthorization."""
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/bridge_delegate_server",
|
||||
"headers": [(b"authorization", b"Bearer plain-upstream-bearer-not-an-envelope")],
|
||||
"headers": [
|
||||
(b"host", b"testserver"),
|
||||
(b"authorization", b"Bearer plain-upstream-bearer-not-an-envelope"),
|
||||
],
|
||||
}
|
||||
|
||||
async def mock_user_api_key_auth_fails(api_key, request):
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth",
|
||||
side_effect=mock_user_api_key_auth_fails,
|
||||
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),
|
||||
|
|
@ -5996,8 +6145,77 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
await MCPRequestHandler.process_mcp_request(scope)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
# The envelope arm was skipped, so the oauth2 arm ran and validated the bearer.
|
||||
mock_auth.assert_called_once()
|
||||
mock_auth.assert_awaited_once()
|
||||
assert exc_info.value.headers == {
|
||||
"www-authenticate": (
|
||||
'Bearer error="invalid_token", '
|
||||
'resource_metadata="http://testserver/.well-known/oauth-protected-resource/mcp/bridge_delegate_server"'
|
||||
)
|
||||
}
|
||||
|
||||
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( # test-quality-ok: supply standard key admission through the auth boundary
|
||||
"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( # test-quality-ok: isolate the MCP registry used by request admission
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
|
||||
) as mock_mgr,
|
||||
patch( # test-quality-ok: configure key classification for the orchestration test
|
||||
"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( # test-quality-ok: force a non-401 auth result through request admission
|
||||
"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( # test-quality-ok: isolate the MCP registry used by request admission
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
|
||||
) as mock_mgr,
|
||||
patch( # test-quality-ok: configure key classification for the orchestration test
|
||||
"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
|
||||
|
|
@ -6126,6 +6344,7 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
):
|
||||
auth_result, new_headers = await MCPRequestHandler._admit_dcr_bridge_delegate(
|
||||
server=self._bridge_delegate_server(),
|
||||
requested_name="bridge_delegate_server",
|
||||
authorization_value=f"Bearer {envelope}",
|
||||
mcp_server_auth_headers=existing,
|
||||
request=self._mcp_request(),
|
||||
|
|
@ -6150,6 +6369,7 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
with pytest.raises(HTTPException) as exc_info:
|
||||
await MCPRequestHandler._admit_dcr_bridge_delegate(
|
||||
server=self._bridge_delegate_server(),
|
||||
requested_name="bridge_delegate_server",
|
||||
authorization_value=f"Bearer {envelope}",
|
||||
mcp_server_auth_headers=None,
|
||||
request=self._mcp_request(),
|
||||
|
|
@ -6168,6 +6388,7 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
with pytest.raises(HTTPException) as exc_info:
|
||||
await MCPRequestHandler._admit_dcr_bridge_delegate(
|
||||
server=self._bridge_delegate_server(),
|
||||
requested_name="bridge_delegate_server",
|
||||
authorization_value=f"Bearer {envelope}",
|
||||
mcp_server_auth_headers=None,
|
||||
request=self._mcp_request(),
|
||||
|
|
@ -6480,8 +6701,8 @@ class TestGatewaySessionAdmission:
|
|||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_token import (
|
||||
SessionPrincipal,
|
||||
mint_session_token,
|
||||
mint_session_refresh_token,
|
||||
mint_session_token,
|
||||
)
|
||||
|
||||
keys = session_keys_from_master_key(self._MASTER_KEY)
|
||||
|
|
@ -6947,8 +7168,8 @@ class TestUserSubjectTeamUnion:
|
|||
|
||||
def _manager_with(self, server_ids, allow_all=()):
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
from litellm.types.mcp import MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
manager = MCPServerManager()
|
||||
for sid in server_ids:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue