mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
feat(mcp): resolve the allowlisted client identity from the JWT claim or an opt-in header instead of clientInfo.name
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
c5919a3c0e
commit
fe8cf02823
11 changed files with 527 additions and 659 deletions
|
|
@ -183,7 +183,6 @@ MCP_TOOL_LISTING_TIMEOUT: Final = float(os.getenv("LITELLM_MCP_TOOL_LISTING_TIME
|
|||
MCP_METADATA_TIMEOUT: Final = float(os.getenv("LITELLM_MCP_METADATA_TIMEOUT", "10.0"))
|
||||
MCP_HEALTH_CHECK_TIMEOUT: Final = float(os.getenv("LITELLM_MCP_HEALTH_CHECK_TIMEOUT", "10.0"))
|
||||
MCP_TOOL_LISTING_MAX_PAGES: Final = 1000
|
||||
MCP_ALLOWLIST_PEEK_MAX_BYTES: Final = 64 * 1024
|
||||
|
||||
# Allowlist of commands permitted for MCP stdio transport.
|
||||
# Prevents arbitrary command execution via /mcp-rest/test/* endpoints or server creation.
|
||||
|
|
|
|||
|
|
@ -1,35 +1,31 @@
|
|||
"""
|
||||
Gateway-level allowlist of MCP client applications, matched against the
|
||||
``clientInfo.name`` a client sends in its JSON-RPC ``initialize`` request. The
|
||||
name is client-supplied, so this is a policy control and not a security boundary.
|
||||
Gateway-level allowlist of MCP client applications (``general_settings.mcp_allowed_clients``).
|
||||
|
||||
A caller that authenticated with a JWT is identified by the claim named in
|
||||
``litellm_jwtauth.mcp_client_id_jwt_field``, a value asserted by the identity provider.
|
||||
Every other caller is identified by the header named in ``general_settings.mcp_client_id_header``,
|
||||
which the client picks itself, so that source is a policy control rather than a security boundary.
|
||||
While the allowlist is set, a caller with no usable identity source is rejected.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import MCP_ALLOWLIST_PEEK_MAX_BYTES
|
||||
from litellm.litellm_core_utils.dot_notation_indexing import get_nested_value
|
||||
|
||||
MCP_ALLOWED_CLIENTS_SETTING: Final = "mcp_allowed_clients"
|
||||
MCP_CLIENT_ID_HEADER_SETTING: Final = "mcp_client_id_header"
|
||||
MCP_CLIENT_ID_JWT_FIELD_SETTING: Final = "mcp_client_id_jwt_field"
|
||||
_JWT_AUTH_SETTING: Final = "litellm_jwtauth"
|
||||
|
||||
_ALLOWED_CLIENTS_ADAPTER: Final = TypeAdapter(list[str])
|
||||
_GENERAL_SETTINGS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
class _ClientInfo(BaseModel):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class _InitializeParams(BaseModel):
|
||||
clientInfo: _ClientInfo | None = None
|
||||
|
||||
|
||||
class _InitializeRequest(BaseModel):
|
||||
params: _InitializeParams | None = None
|
||||
_ALLOWED_CLIENTS_ADAPTER: Final[TypeAdapter[list[str]]] = TypeAdapter(list[str])
|
||||
_OPTIONAL_NAME_ADAPTER: Final[TypeAdapter[str | None]] = TypeAdapter(str | None)
|
||||
_OPTIONAL_MAPPING_ADAPTER: Final[TypeAdapter[dict[str, object] | None]] = TypeAdapter(dict[str, object] | None)
|
||||
|
||||
|
||||
class MCPClientForbiddenBody(TypedDict):
|
||||
|
|
@ -37,23 +33,27 @@ class MCPClientForbiddenBody(TypedDict):
|
|||
details: ReadOnly[str]
|
||||
|
||||
|
||||
class MCPSessionNotFoundBody(TypedDict):
|
||||
error: ReadOnly[Literal["Not Found"]]
|
||||
details: ReadOnly[str]
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MCPClientAllowlist:
|
||||
allowed_clients: frozenset[str]
|
||||
jwt_field: str | None
|
||||
header: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MCPClientIdentity:
|
||||
client_id: str
|
||||
source: Literal["jwt", "header"]
|
||||
source_name: str
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return f"'{self.client_id}' (from {'JWT claim' if self.source == 'jwt' else 'header'} '{self.source_name}')"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MCPClientRejection:
|
||||
client_name: str | None
|
||||
|
||||
@property
|
||||
def details(self) -> str:
|
||||
if self.client_name is None:
|
||||
return (
|
||||
"MCP initialize request did not identify the client application (clientInfo.name). "
|
||||
f"This gateway only admits clients listed in {MCP_ALLOWED_CLIENTS_SETTING}."
|
||||
)
|
||||
return f"MCP client '{self.client_name}' is not listed in this gateway's {MCP_ALLOWED_CLIENTS_SETTING}."
|
||||
details: str
|
||||
|
||||
@property
|
||||
def response_body(self) -> MCPClientForbiddenBody:
|
||||
|
|
@ -61,38 +61,10 @@ class MCPClientRejection:
|
|||
return body
|
||||
|
||||
|
||||
def oversized_unidentified_request_body() -> MCPClientForbiddenBody:
|
||||
body: Final[MCPClientForbiddenBody] = {
|
||||
"error": "Forbidden",
|
||||
"details": (
|
||||
f"While {MCP_ALLOWED_CLIENTS_SETTING} is set, this gateway reads at most "
|
||||
f"{MCP_ALLOWLIST_PEEK_MAX_BYTES} bytes of an MCP POST to find clientInfo.name before routing it; "
|
||||
"this request was larger than that and could not be identified."
|
||||
),
|
||||
}
|
||||
return body
|
||||
|
||||
|
||||
def unidentified_sessionless_request_body() -> MCPClientForbiddenBody:
|
||||
body: Final[MCPClientForbiddenBody] = {
|
||||
"error": "Forbidden",
|
||||
"details": (
|
||||
f"While {MCP_ALLOWED_CLIENTS_SETTING} is set, an MCP POST without a live mcp-session-id must be an "
|
||||
"initialize request; open a session with initialize from a listed client application first."
|
||||
),
|
||||
}
|
||||
return body
|
||||
|
||||
|
||||
def unknown_session_request_body(session_id: str) -> MCPSessionNotFoundBody:
|
||||
body: Final[MCPSessionNotFoundBody] = {
|
||||
"error": "Not Found",
|
||||
"details": (
|
||||
f"mcp-session-id '{session_id}' is not known to this gateway worker. While {MCP_ALLOWED_CLIENTS_SETTING} "
|
||||
"is set the request cannot fall back to a sessionless call; start a new session with initialize."
|
||||
),
|
||||
}
|
||||
return body
|
||||
def _unidentified_rejection(reason: str) -> MCPClientRejection:
|
||||
return MCPClientRejection(
|
||||
details=f"{reason} This gateway only admits client applications listed in {MCP_ALLOWED_CLIENTS_SETTING}."
|
||||
)
|
||||
|
||||
|
||||
def parse_allowed_mcp_clients(raw_setting: object) -> frozenset[str] | None:
|
||||
|
|
@ -110,26 +82,80 @@ def parse_allowed_mcp_clients(raw_setting: object) -> frozenset[str] | None:
|
|||
return frozenset()
|
||||
|
||||
|
||||
def allowed_mcp_clients_from_general_settings(general_settings: object) -> frozenset[str] | None:
|
||||
return parse_allowed_mcp_clients(
|
||||
_GENERAL_SETTINGS_ADAPTER.validate_python(general_settings).get(MCP_ALLOWED_CLIENTS_SETTING)
|
||||
def _parse_optional_name(setting_name: str, raw_setting: object) -> str | None:
|
||||
try:
|
||||
name: Final = _OPTIONAL_NAME_ADAPTER.validate_python(raw_setting)
|
||||
except ValidationError:
|
||||
verbose_logger.warning("%s is not a string (%r); ignoring it", setting_name, raw_setting)
|
||||
return None
|
||||
return name or None
|
||||
|
||||
|
||||
def _jwt_field_from_general_settings(general_settings: Mapping[str, object]) -> str | None:
|
||||
try:
|
||||
jwt_auth: Final = _OPTIONAL_MAPPING_ADAPTER.validate_python(general_settings.get(_JWT_AUTH_SETTING))
|
||||
except ValidationError:
|
||||
return None
|
||||
if jwt_auth is None:
|
||||
return None
|
||||
return _parse_optional_name(
|
||||
f"{_JWT_AUTH_SETTING}.{MCP_CLIENT_ID_JWT_FIELD_SETTING}", jwt_auth.get(MCP_CLIENT_ID_JWT_FIELD_SETTING)
|
||||
)
|
||||
|
||||
|
||||
def extract_mcp_client_name(body: bytes) -> str | None:
|
||||
try:
|
||||
request: Final = _InitializeRequest.model_validate_json(body)
|
||||
except ValidationError:
|
||||
return None
|
||||
client_info: Final = request.params.clientInfo if request.params is not None else None
|
||||
name: Final = client_info.name if client_info is not None else None
|
||||
return name if name else None
|
||||
|
||||
|
||||
def check_mcp_client_allowed(body: bytes, allowed_clients: frozenset[str] | None) -> MCPClientRejection | None:
|
||||
def load_mcp_client_allowlist(general_settings: Mapping[str, object]) -> MCPClientAllowlist | None:
|
||||
"""None when ``mcp_allowed_clients`` is unset, which admits every client."""
|
||||
allowed_clients: Final = parse_allowed_mcp_clients(general_settings.get(MCP_ALLOWED_CLIENTS_SETTING))
|
||||
if allowed_clients is None:
|
||||
return None
|
||||
client_name: Final = extract_mcp_client_name(body)
|
||||
if client_name is not None and client_name in allowed_clients:
|
||||
header: Final = _parse_optional_name(
|
||||
MCP_CLIENT_ID_HEADER_SETTING, general_settings.get(MCP_CLIENT_ID_HEADER_SETTING)
|
||||
)
|
||||
return MCPClientAllowlist(
|
||||
allowed_clients=allowed_clients,
|
||||
jwt_field=_jwt_field_from_general_settings(general_settings),
|
||||
header=header.lower() if header is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def resolve_mcp_client_identity(
|
||||
allowlist: MCPClientAllowlist,
|
||||
jwt_claims: Mapping[str, object] | None,
|
||||
headers: Mapping[str, str],
|
||||
) -> MCPClientIdentity | MCPClientRejection:
|
||||
"""A JWT caller is identified by its configured claim alone, so a header can never override the IdP."""
|
||||
if jwt_claims and allowlist.jwt_field is not None:
|
||||
claim: Final[object] = get_nested_value(data=jwt_claims, key_path=allowlist.jwt_field)
|
||||
if isinstance(claim, str) and claim:
|
||||
return MCPClientIdentity(client_id=claim, source="jwt", source_name=allowlist.jwt_field)
|
||||
return _unidentified_rejection(
|
||||
f"The JWT presented has no '{allowlist.jwt_field}' claim naming the client application."
|
||||
)
|
||||
if allowlist.header is None:
|
||||
configured: Final = (
|
||||
f"litellm_jwtauth.{MCP_CLIENT_ID_JWT_FIELD_SETTING} for JWT callers or {MCP_CLIENT_ID_HEADER_SETTING}"
|
||||
)
|
||||
return _unidentified_rejection(
|
||||
f"No client identity source is configured for this request; set {configured} in general_settings."
|
||||
)
|
||||
header_value: Final = headers.get(allowlist.header)
|
||||
if header_value:
|
||||
return MCPClientIdentity(client_id=header_value, source="header", source_name=allowlist.header)
|
||||
return _unidentified_rejection(f"The request has no '{allowlist.header}' header naming the client application.")
|
||||
|
||||
|
||||
def check_mcp_client_allowed(
|
||||
allowlist: MCPClientAllowlist | None,
|
||||
jwt_claims: Mapping[str, object] | None,
|
||||
headers: Mapping[str, str],
|
||||
) -> MCPClientRejection | None:
|
||||
if allowlist is None:
|
||||
return None
|
||||
return MCPClientRejection(client_name=client_name)
|
||||
identity: Final = resolve_mcp_client_identity(allowlist, jwt_claims, headers)
|
||||
if isinstance(identity, MCPClientRejection):
|
||||
return identity
|
||||
if identity.client_id in allowlist.allowed_clients:
|
||||
return None
|
||||
return MCPClientRejection(
|
||||
details=f"MCP client {identity.description} is not listed in this gateway's {MCP_ALLOWED_CLIENTS_SETTING}."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
|
|||
MCPRequestHandler,
|
||||
_is_mcp_admitted_user_subject,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.client_allowlist import (
|
||||
MCPClientAllowlist,
|
||||
check_mcp_client_allowed,
|
||||
load_mcp_client_allowlist,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
get_request_base_url,
|
||||
)
|
||||
|
|
@ -3713,6 +3718,22 @@ if MCP_AVAILABLE:
|
|||
mcp_servers_from_path = [servers_and_path]
|
||||
return mcp_servers_from_path
|
||||
|
||||
def _load_mcp_client_allowlist() -> MCPClientAllowlist | None:
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
return load_mcp_client_allowlist(general_settings)
|
||||
|
||||
def _reject_disallowed_mcp_client(scope: Scope, user_api_key_auth: UserAPIKeyAuth | None) -> None:
|
||||
rejection: Final = check_mcp_client_allowed(
|
||||
allowlist=_load_mcp_client_allowlist(),
|
||||
jwt_claims=user_api_key_auth.jwt_claims if user_api_key_auth is not None else None,
|
||||
headers=StarletteRequest(scope).headers,
|
||||
)
|
||||
if rejection is None:
|
||||
return
|
||||
verbose_logger.warning("Rejected MCP request from a disallowed client application: %s", rejection.details)
|
||||
raise HTTPException(status_code=403, detail=rejection.response_body)
|
||||
|
||||
async def extract_mcp_auth_context(scope, path):
|
||||
"""
|
||||
Extracts mcp_servers from the path and processes the MCP request for auth context.
|
||||
|
|
@ -4472,6 +4493,7 @@ if MCP_AVAILABLE:
|
|||
oauth2_headers,
|
||||
raw_headers,
|
||||
) = await extract_mcp_auth_context(scope, path)
|
||||
_reject_disallowed_mcp_client(scope, user_api_key_auth)
|
||||
scoped_server_endpoint: Final = len(_get_mcp_servers_in_path(path) or []) == 1
|
||||
|
||||
# Extract client IP for MCP access control
|
||||
|
|
@ -4800,6 +4822,7 @@ if MCP_AVAILABLE:
|
|||
oauth2_headers,
|
||||
raw_headers,
|
||||
) = await extract_mcp_auth_context(scope, path)
|
||||
_reject_disallowed_mcp_client(scope, user_api_key_auth)
|
||||
scoped_server_endpoint: Final = len(_get_mcp_servers_in_path(path) or []) == 1
|
||||
|
||||
# Extract client IP for MCP access control
|
||||
|
|
|
|||
|
|
@ -2867,7 +2867,11 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
)
|
||||
mcp_allowed_clients: list[str] | None = Field(
|
||||
None,
|
||||
description="MCP client applications admitted by the gateway, matched exactly against the clientInfo.name the client sends in its initialize request (for example 'claude-code'). When set, an initialize from any other client, or one that does not identify itself, is rejected with 403. Unset means every client is admitted. The name is client-supplied, so this is a policy control rather than a security boundary.",
|
||||
description="MCP client applications admitted by the gateway. When set, every MCP request must carry a client identity that matches one of these values exactly: a JWT caller is identified by the claim named in litellm_jwtauth.mcp_client_id_jwt_field, any other caller by the header named in mcp_client_id_header. A request with no resolvable identity, or an unlisted one, is rejected with 403. Unset means every client is admitted.",
|
||||
)
|
||||
mcp_client_id_header: str | None = Field(
|
||||
None,
|
||||
description="Request header whose value names the calling MCP client application (for example 'x-mcp-client') for callers that did not authenticate with a JWT, used only while mcp_allowed_clients is set. The client picks this value itself, so it is a policy control rather than a security boundary; prefer litellm_jwtauth.mcp_client_id_jwt_field where callers use JWTs.",
|
||||
)
|
||||
mcp_trusted_proxy_ranges: list[str] | None = Field(
|
||||
None,
|
||||
|
|
@ -5075,6 +5079,15 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
|
|||
"then agent_name, and the request is rejected when it matches neither."
|
||||
),
|
||||
)
|
||||
mcp_client_id_jwt_field: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"The field in the JWT token that identifies the MCP client application (harness) making the request, "
|
||||
"e.g. 'azp' or 'client_id'. Supports dot notation. Only consulted while general_settings.mcp_allowed_clients "
|
||||
"is set: the claim value must be listed there or the MCP request is rejected with 403. Distinct from "
|
||||
"agent_id_jwt_field, which identifies an AI agent rather than the client software."
|
||||
),
|
||||
)
|
||||
public_key_ttl: float = 600
|
||||
public_key_stale_ttl: float = Field(
|
||||
default=DEFAULT_JWKS_STALE_TTL,
|
||||
|
|
|
|||
|
|
@ -17119,6 +17119,8 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro
|
|||
"maximum_spend_logs_cleanup_run_budget": "String",
|
||||
"maximum_spend_logs_cleanup_batch_timeout": "String",
|
||||
"mcp_internal_ip_ranges": "List",
|
||||
"mcp_allowed_clients": "List",
|
||||
"mcp_client_id_header": "String",
|
||||
"mcp_trusted_proxy_ranges": "List",
|
||||
"mcp_xff_num_trusted_hops": "Integer",
|
||||
"always_include_stream_usage": "Boolean",
|
||||
|
|
|
|||
|
|
@ -1,105 +1,177 @@
|
|||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.client_allowlist import (
|
||||
MCP_ALLOWED_CLIENTS_SETTING,
|
||||
MCP_CLIENT_ID_HEADER_SETTING,
|
||||
MCP_CLIENT_ID_JWT_FIELD_SETTING,
|
||||
MCPClientAllowlist,
|
||||
MCPClientIdentity,
|
||||
MCPClientRejection,
|
||||
check_mcp_client_allowed,
|
||||
extract_mcp_client_name,
|
||||
load_mcp_client_allowlist,
|
||||
parse_allowed_mcp_clients,
|
||||
resolve_mcp_client_identity,
|
||||
)
|
||||
|
||||
JWT_ONLY: Final = MCPClientAllowlist(allowed_clients=frozenset({"antigravity-cli"}), jwt_field="azp", header=None)
|
||||
HEADER_ONLY: Final = MCPClientAllowlist(
|
||||
allowed_clients=frozenset({"antigravity-cli"}), jwt_field=None, header="x-mcp-client"
|
||||
)
|
||||
JWT_AND_HEADER: Final = MCPClientAllowlist(
|
||||
allowed_clients=frozenset({"antigravity-cli"}), jwt_field="azp", header="x-mcp-client"
|
||||
)
|
||||
NO_SOURCE: Final = MCPClientAllowlist(allowed_clients=frozenset({"antigravity-cli"}), jwt_field=None, header=None)
|
||||
NO_HEADERS: Final[Mapping[str, str]] = {}
|
||||
|
||||
|
||||
_ALLOWLIST_SETTING_CASES: Final[tuple[tuple[object, frozenset[str] | None], ...]] = (
|
||||
(None, None),
|
||||
([], frozenset()),
|
||||
(["antigravity-cli"], frozenset({"antigravity-cli"})),
|
||||
(["antigravity-cli", "codex-mcp-client"], frozenset({"antigravity-cli", "codex-mcp-client"})),
|
||||
("antigravity-cli", frozenset()),
|
||||
([1, "antigravity-cli"], frozenset()),
|
||||
({"name": "antigravity-cli"}, frozenset()),
|
||||
)
|
||||
|
||||
|
||||
def _initialize_body(client_info: object) -> bytes:
|
||||
return json.dumps(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 0,
|
||||
"method": "initialize",
|
||||
"params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": client_info},
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
CLAUDE_CODE: Final = _initialize_body({"name": "claude-code", "version": "2.1.274"})
|
||||
ANTIGRAVITY: Final = _initialize_body({"name": "antigravity-cli", "version": "1.0.0"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_setting", "expected"),
|
||||
(
|
||||
(None, None),
|
||||
([], frozenset()),
|
||||
(["antigravity-cli"], frozenset({"antigravity-cli"})),
|
||||
(["antigravity-cli", "codex-mcp-client"], frozenset({"antigravity-cli", "codex-mcp-client"})),
|
||||
("antigravity-cli", frozenset()),
|
||||
([1, "antigravity-cli"], frozenset()),
|
||||
({"name": "antigravity-cli"}, frozenset()),
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize(("raw_setting", "expected"), _ALLOWLIST_SETTING_CASES)
|
||||
def test_parse_allowed_mcp_clients(raw_setting: object, expected: frozenset[str] | None) -> None:
|
||||
assert parse_allowed_mcp_clients(raw_setting) == expected
|
||||
|
||||
|
||||
def test_load_returns_none_when_the_allowlist_setting_is_absent_even_if_identity_sources_are_set() -> None:
|
||||
settings: Final = {"litellm_jwtauth": {"mcp_client_id_jwt_field": "azp"}, "mcp_client_id_header": "x-mcp-client"}
|
||||
assert load_mcp_client_allowlist(settings) is None
|
||||
|
||||
|
||||
def test_load_reads_the_jwt_field_from_litellm_jwtauth_and_lowercases_the_header_name() -> None:
|
||||
settings: Final = {
|
||||
"mcp_allowed_clients": ["antigravity-cli", "codex-mcp-client"],
|
||||
"litellm_jwtauth": {"user_id_jwt_field": "sub", "mcp_client_id_jwt_field": "resource_access.mcp.client"},
|
||||
"mcp_client_id_header": "X-MCP-Client",
|
||||
}
|
||||
assert load_mcp_client_allowlist(settings) == MCPClientAllowlist(
|
||||
allowed_clients=frozenset({"antigravity-cli", "codex-mcp-client"}),
|
||||
jwt_field="resource_access.mcp.client",
|
||||
header="x-mcp-client",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "expected"),
|
||||
"settings",
|
||||
(
|
||||
(CLAUDE_CODE, "claude-code"),
|
||||
(_initialize_body({"name": "", "version": "1"}), None),
|
||||
(_initialize_body({"version": "1"}), None),
|
||||
(_initialize_body({"name": 7}), None),
|
||||
(_initialize_body("claude-code"), None),
|
||||
(b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}', None),
|
||||
(b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":[]}', None),
|
||||
(b'["not", "an", "object"]', None),
|
||||
(b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"clau', None),
|
||||
(b"\xff\xfe", None),
|
||||
(b"", None),
|
||||
{"mcp_allowed_clients": ["antigravity-cli"]},
|
||||
{"mcp_allowed_clients": ["antigravity-cli"], "litellm_jwtauth": {}, "mcp_client_id_header": ""},
|
||||
{"mcp_allowed_clients": ["antigravity-cli"], "litellm_jwtauth": {"mcp_client_id_jwt_field": ""}},
|
||||
{"mcp_allowed_clients": ["antigravity-cli"], "litellm_jwtauth": "azp", "mcp_client_id_header": ["x"]},
|
||||
),
|
||||
)
|
||||
def test_extract_mcp_client_name(body: bytes, expected: str | None) -> None:
|
||||
assert extract_mcp_client_name(body) == expected
|
||||
def test_load_without_a_usable_identity_source_keeps_the_allowlist_but_no_source(
|
||||
settings: Mapping[str, object],
|
||||
) -> None:
|
||||
assert load_mcp_client_allowlist(settings) == NO_SOURCE
|
||||
|
||||
|
||||
def test_unconfigured_allowlist_admits_every_client_including_unidentified_ones() -> None:
|
||||
assert check_mcp_client_allowed(CLAUDE_CODE, None) is None
|
||||
assert check_mcp_client_allowed(b'{"method":"initialize","params":{}}', None) is None
|
||||
assert check_mcp_client_allowed(b"garbage", None) is None
|
||||
def test_load_malformed_allowlist_admits_nobody() -> None:
|
||||
loaded: Final = load_mcp_client_allowlist({"mcp_allowed_clients": "antigravity-cli"})
|
||||
assert loaded is not None
|
||||
assert loaded.allowed_clients == frozenset()
|
||||
|
||||
|
||||
def test_listed_client_is_admitted_and_unlisted_client_is_rejected_by_name() -> None:
|
||||
allowed: Final = frozenset({"antigravity-cli"})
|
||||
assert check_mcp_client_allowed(ANTIGRAVITY, allowed) is None
|
||||
assert check_mcp_client_allowed(CLAUDE_CODE, allowed) == MCPClientRejection(client_name="claude-code")
|
||||
def test_unconfigured_allowlist_admits_callers_with_no_identity_at_all() -> None:
|
||||
assert check_mcp_client_allowed(None, None, NO_HEADERS) is None
|
||||
assert check_mcp_client_allowed(None, {"azp": "claude-code"}, {"x-mcp-client": "claude-code"}) is None
|
||||
|
||||
|
||||
def test_jwt_claim_identifies_the_client() -> None:
|
||||
assert resolve_mcp_client_identity(JWT_ONLY, {"azp": "antigravity-cli"}, NO_HEADERS) == MCPClientIdentity(
|
||||
client_id="antigravity-cli", source="jwt", source_name="azp"
|
||||
)
|
||||
assert check_mcp_client_allowed(JWT_ONLY, {"azp": "antigravity-cli"}, NO_HEADERS) is None
|
||||
|
||||
|
||||
def test_nested_jwt_claim_path_is_resolved_with_dot_notation() -> None:
|
||||
nested: Final = MCPClientAllowlist(
|
||||
allowed_clients=frozenset({"antigravity-cli"}), jwt_field="resource_access.mcp.client", header=None
|
||||
)
|
||||
claims: Final = {"resource_access": {"mcp": {"client": "antigravity-cli"}}}
|
||||
assert check_mcp_client_allowed(nested, claims, NO_HEADERS) is None
|
||||
|
||||
|
||||
def test_unlisted_jwt_client_is_rejected_and_the_rejection_names_it() -> None:
|
||||
rejection: Final = check_mcp_client_allowed(JWT_ONLY, {"azp": "claude-code"}, NO_HEADERS)
|
||||
assert isinstance(rejection, MCPClientRejection)
|
||||
assert "'claude-code'" in rejection.details
|
||||
assert "azp" in rejection.details
|
||||
assert MCP_ALLOWED_CLIENTS_SETTING in rejection.details
|
||||
assert rejection.response_body == {"error": "Forbidden", "details": rejection.details}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("claims", ({"sub": "user-1"}, {"azp": ""}, {"azp": 42}, {"azp": ["antigravity-cli"]}))
|
||||
def test_jwt_without_a_usable_client_claim_is_rejected(claims: Mapping[str, object]) -> None:
|
||||
rejection: Final = check_mcp_client_allowed(JWT_ONLY, claims, NO_HEADERS)
|
||||
assert isinstance(rejection, MCPClientRejection)
|
||||
assert "azp" in rejection.details
|
||||
|
||||
|
||||
def test_matching_is_exact_not_prefix_or_case_insensitive() -> None:
|
||||
allowed: Final = frozenset({"claude-code"})
|
||||
assert check_mcp_client_allowed(_initialize_body({"name": "Claude-Code"}), allowed) is not None
|
||||
assert check_mcp_client_allowed(_initialize_body({"name": "claude-code-sdk"}), allowed) is not None
|
||||
assert check_mcp_client_allowed(_initialize_body({"name": " claude-code"}), allowed) is not None
|
||||
for spoof in ("Antigravity-Cli", "antigravity-cli-sdk", " antigravity-cli"):
|
||||
assert check_mcp_client_allowed(JWT_ONLY, {"azp": spoof}, NO_HEADERS) is not None
|
||||
assert check_mcp_client_allowed(HEADER_ONLY, None, {"x-mcp-client": spoof}) is not None
|
||||
|
||||
|
||||
def test_empty_allowlist_rejects_every_client() -> None:
|
||||
assert check_mcp_client_allowed(ANTIGRAVITY, frozenset()) == MCPClientRejection(client_name="antigravity-cli")
|
||||
assert check_mcp_client_allowed(CLAUDE_CODE, frozenset()) == MCPClientRejection(client_name="claude-code")
|
||||
def test_configured_header_identifies_callers_without_a_jwt() -> None:
|
||||
headers: Final = {"x-mcp-client": "antigravity-cli"}
|
||||
assert resolve_mcp_client_identity(HEADER_ONLY, None, headers) == MCPClientIdentity(
|
||||
client_id="antigravity-cli", source="header", source_name="x-mcp-client"
|
||||
)
|
||||
assert check_mcp_client_allowed(HEADER_ONLY, None, headers) is None
|
||||
assert check_mcp_client_allowed(HEADER_ONLY, {}, headers) is None
|
||||
|
||||
|
||||
def test_missing_or_malformed_client_metadata_is_rejected_when_allowlist_is_set() -> None:
|
||||
allowed: Final = frozenset({"antigravity-cli"})
|
||||
assert check_mcp_client_allowed(_initialize_body({"version": "1"}), allowed) == MCPClientRejection(None)
|
||||
assert check_mcp_client_allowed(b'{"method":"initialize","params":{}}', allowed) == MCPClientRejection(None)
|
||||
assert check_mcp_client_allowed(b"{not json", allowed) == MCPClientRejection(None)
|
||||
def test_unlisted_or_missing_header_is_rejected() -> None:
|
||||
unlisted: Final = check_mcp_client_allowed(HEADER_ONLY, None, {"x-mcp-client": "claude-code"})
|
||||
assert isinstance(unlisted, MCPClientRejection)
|
||||
assert "'claude-code'" in unlisted.details
|
||||
for headers in (NO_HEADERS, {"x-mcp-client": ""}, {"x-other": "antigravity-cli"}):
|
||||
missing = check_mcp_client_allowed(HEADER_ONLY, None, headers)
|
||||
assert isinstance(missing, MCPClientRejection)
|
||||
assert "x-mcp-client" in missing.details
|
||||
|
||||
|
||||
def test_rejection_details_name_the_setting_and_the_offending_client() -> None:
|
||||
named: Final = MCPClientRejection(client_name="claude-code").details
|
||||
assert "claude-code" in named
|
||||
assert MCP_ALLOWED_CLIENTS_SETTING in named
|
||||
def test_header_is_not_consulted_when_it_is_not_configured() -> None:
|
||||
rejection: Final = check_mcp_client_allowed(JWT_ONLY, None, {"x-mcp-client": "antigravity-cli"})
|
||||
assert isinstance(rejection, MCPClientRejection)
|
||||
assert MCP_CLIENT_ID_HEADER_SETTING in rejection.details
|
||||
assert MCP_CLIENT_ID_JWT_FIELD_SETTING in rejection.details
|
||||
|
||||
anonymous: Final = MCPClientRejection(client_name=None).details
|
||||
assert "clientInfo.name" in anonymous
|
||||
assert MCP_ALLOWED_CLIENTS_SETTING in anonymous
|
||||
assert "None" not in anonymous
|
||||
|
||||
def test_jwt_caller_is_judged_by_its_claim_even_when_the_header_would_pass() -> None:
|
||||
spoofed_header: Final = {"x-mcp-client": "antigravity-cli"}
|
||||
assert check_mcp_client_allowed(JWT_AND_HEADER, {"azp": "claude-code"}, spoofed_header) is not None
|
||||
assert check_mcp_client_allowed(JWT_AND_HEADER, {"sub": "user-1"}, spoofed_header) is not None
|
||||
assert check_mcp_client_allowed(JWT_AND_HEADER, {"azp": "antigravity-cli"}, {"x-mcp-client": "claude-code"}) is None
|
||||
|
||||
|
||||
def test_non_jwt_caller_falls_back_to_the_header_when_both_sources_are_configured() -> None:
|
||||
assert check_mcp_client_allowed(JWT_AND_HEADER, None, {"x-mcp-client": "antigravity-cli"}) is None
|
||||
assert check_mcp_client_allowed(JWT_AND_HEADER, None, {"x-mcp-client": "claude-code"}) is not None
|
||||
|
||||
|
||||
def test_allowlist_with_no_identity_source_rejects_everyone_and_says_what_to_configure() -> None:
|
||||
rejection: Final = check_mcp_client_allowed(
|
||||
NO_SOURCE, {"azp": "antigravity-cli"}, {"x-mcp-client": "antigravity-cli"}
|
||||
)
|
||||
assert isinstance(rejection, MCPClientRejection)
|
||||
assert MCP_CLIENT_ID_JWT_FIELD_SETTING in rejection.details
|
||||
assert MCP_CLIENT_ID_HEADER_SETTING in rejection.details
|
||||
|
||||
|
||||
def test_empty_allowlist_rejects_an_identified_client() -> None:
|
||||
empty: Final = MCPClientAllowlist(allowed_clients=frozenset(), jwt_field="azp", header="x-mcp-client")
|
||||
assert check_mcp_client_allowed(empty, {"azp": "antigravity-cli"}, NO_HEADERS) is not None
|
||||
assert check_mcp_client_allowed(empty, None, {"x-mcp-client": "antigravity-cli"}) is not None
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import asyncio
|
||||
import contextlib
|
||||
import contextvars
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Final
|
||||
|
|
@ -20,9 +18,9 @@ from mcp.types import (
|
|||
TextContent,
|
||||
TextResourceContents,
|
||||
)
|
||||
from pydantic import TypeAdapter
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from litellm.constants import MCP_ALLOWLIST_PEEK_MAX_BYTES
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_MCPServerTable,
|
||||
MCPTransport,
|
||||
|
|
@ -2040,75 +2038,81 @@ async def test_mcp_routing_initialize_to_stateful_no_session_to_stateless(
|
|||
assert not any(name.startswith(b"x-mcp-debug") for name in headers)
|
||||
|
||||
|
||||
_CLAUDE_CODE_INITIALIZE: Final = (
|
||||
b'{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18",'
|
||||
b'"capabilities":{},"clientInfo":{"name":"claude-code","version":"2.1.274"}}}'
|
||||
)
|
||||
_ANTIGRAVITY_INITIALIZE: Final = (
|
||||
_FORBIDDEN_BODY_ADAPTER: Final[TypeAdapter[dict[str, str]]] = TypeAdapter(dict[str, str])
|
||||
_BODY_CHUNK_ADAPTER: Final[TypeAdapter[bytes]] = TypeAdapter(bytes)
|
||||
_INITIALIZE: Final = (
|
||||
b'{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18",'
|
||||
b'"capabilities":{},"clientInfo":{"name":"antigravity-cli","version":"1.0.0"}}}'
|
||||
)
|
||||
_ANONYMOUS_INITIALIZE: Final = b'{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{}}}'
|
||||
_TOOLS_LIST: Final = b'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
|
||||
_ALLOWLIST_SETTINGS: Final[dict[str, object]] = {
|
||||
"mcp_allowed_clients": ["antigravity-cli"],
|
||||
"litellm_jwtauth": {"mcp_client_id_jwt_field": "azp"},
|
||||
"mcp_client_id_header": "x-mcp-client",
|
||||
}
|
||||
_LISTED_JWT: Final[dict[str, object]] = {"azp": "antigravity-cli", "sub": "user-1"}
|
||||
_UNLISTED_JWT: Final[dict[str, object]] = {"azp": "claude-code", "sub": "user-1"}
|
||||
_LISTED_HEADER: Final[list[tuple[bytes, bytes]]] = [(b"x-mcp-client", b"antigravity-cli")]
|
||||
_UNLISTED_HEADER: Final[list[tuple[bytes, bytes]]] = [(b"x-mcp-client", b"claude-code")]
|
||||
|
||||
|
||||
async def _drain_body(receive: Receive) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
message = await receive()
|
||||
chunks.append(message.get("body", b""))
|
||||
if not message.get("more_body", False):
|
||||
return b"".join(chunks)
|
||||
first: Final = await receive()
|
||||
body: Final = _BODY_CHUNK_ADAPTER.validate_python(first.get("body", b""))
|
||||
if not first.get("more_body", False):
|
||||
return body
|
||||
return body + await _drain_body(receive)
|
||||
|
||||
|
||||
def _forbidden_client_response(send: AsyncMock) -> tuple[int, dict[str, str]]:
|
||||
start: Final = send.call_args_list[0].args[0]
|
||||
body: Final = b"".join(call.args[0].get("body", b"") for call in send.call_args_list[1:])
|
||||
return start["status"], json.loads(body)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _client_allowlist_patches(allowed_clients: list[str] | list[dict[str, str]] | str | None) -> Iterator[None]:
|
||||
settings: Final = {} if allowed_clients is None else {"mcp_allowed_clients": allowed_clients}
|
||||
with (
|
||||
def _client_allowlist_patches(
|
||||
settings: dict[str, object], jwt_claims: dict[str, object] | None
|
||||
) -> contextlib.ExitStack:
|
||||
stack: Final = contextlib.ExitStack()
|
||||
stack.enter_context(
|
||||
patch( # test-quality-ok: the ASGI handler resolves auth through a module-level function; no injection seam
|
||||
"litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(UserAPIKeyAuth(user_id="allowlist-user"), None, None, None, None, {}),
|
||||
),
|
||||
return_value=(UserAPIKeyAuth(user_id="allowlist-user", jwt_claims=jwt_claims), None, None, None, None, {}),
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch( # test-quality-ok: module flag guarding lazy session-manager startup; no injection seam
|
||||
"litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True
|
||||
),
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch( # test-quality-ok: the allowlist is read off this module global; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", settings
|
||||
),
|
||||
):
|
||||
yield
|
||||
)
|
||||
)
|
||||
return stack
|
||||
|
||||
|
||||
def _forbidden_body(denied: HTTPException) -> dict[str, str]:
|
||||
return _FORBIDDEN_BODY_ADAPTER.validate_python(denied.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("request_body", "expected_details"),
|
||||
("jwt_claims", "headers", "request_body", "expected_fragment"),
|
||||
(
|
||||
(
|
||||
_CLAUDE_CODE_INITIALIZE,
|
||||
"MCP client 'claude-code' is not listed in this gateway's mcp_allowed_clients.",
|
||||
),
|
||||
(
|
||||
_ANONYMOUS_INITIALIZE,
|
||||
"MCP initialize request did not identify the client application (clientInfo.name). "
|
||||
"This gateway only admits clients listed in mcp_allowed_clients.",
|
||||
),
|
||||
(_UNLISTED_JWT, [], _INITIALIZE, "MCP client 'claude-code' (from JWT claim 'azp')"),
|
||||
(_UNLISTED_JWT, _LISTED_HEADER, _INITIALIZE, "MCP client 'claude-code' (from JWT claim 'azp')"),
|
||||
({"sub": "user-1"}, _LISTED_HEADER, _INITIALIZE, "no 'azp' claim"),
|
||||
(None, _UNLISTED_HEADER, _INITIALIZE, "MCP client 'claude-code' (from header 'x-mcp-client')"),
|
||||
(None, [], _INITIALIZE, "no 'x-mcp-client' header"),
|
||||
(_UNLISTED_JWT, [(b"mcp-session-id", b"session-1")], _TOOLS_LIST, "MCP client 'claude-code'"),
|
||||
),
|
||||
)
|
||||
async def test_streamable_http_rejects_initialize_from_unlisted_client_before_session_creation(
|
||||
request_body: bytes, expected_details: str
|
||||
async def test_streamable_http_rejects_unlisted_client_before_any_session_work(
|
||||
jwt_claims: dict[str, object] | None,
|
||||
headers: list[tuple[bytes, bytes]],
|
||||
request_body: bytes,
|
||||
expected_fragment: str,
|
||||
) -> None:
|
||||
from starlette.types import Scope
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
|
||||
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp", "headers": headers}
|
||||
receive: Final = AsyncMock(return_value={"type": "http.request", "body": request_body, "more_body": False})
|
||||
send: Final = AsyncMock()
|
||||
stateful_handle: Final = AsyncMock()
|
||||
|
|
@ -2116,7 +2120,7 @@ async def test_streamable_http_rejects_initialize_from_unlisted_client_before_se
|
|||
session_cap: Final = AsyncMock(return_value=True)
|
||||
|
||||
with (
|
||||
_client_allowlist_patches(["antigravity-cli"]),
|
||||
_client_allowlist_patches(_ALLOWLIST_SETTINGS, jwt_claims),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateful",
|
||||
SimpleNamespace(handle_request=stateful_handle),
|
||||
|
|
@ -2128,10 +2132,17 @@ async def test_streamable_http_rejects_initialize_from_unlisted_client_before_se
|
|||
patch( # test-quality-ok: module-level cap check; asserting it is never reached is the point
|
||||
"litellm.proxy._experimental.mcp_server.server._enforce_stateful_session_cap_for_owner", session_cap
|
||||
),
|
||||
pytest.raises(HTTPException) as denied,
|
||||
):
|
||||
await mcp_module.handle_streamable_http_mcp(scope, receive, send)
|
||||
|
||||
assert _forbidden_client_response(send) == (403, {"error": "Forbidden", "details": expected_details})
|
||||
assert denied.value.status_code == 403
|
||||
body: Final = _forbidden_body(denied.value)
|
||||
assert body["error"] == "Forbidden"
|
||||
assert expected_fragment in body["details"]
|
||||
assert "mcp_allowed_clients" in body["details"]
|
||||
receive.assert_not_awaited()
|
||||
send.assert_not_awaited()
|
||||
stateful_handle.assert_not_awaited()
|
||||
stateless_handle.assert_not_awaited()
|
||||
session_cap.assert_not_awaited()
|
||||
|
|
@ -2139,26 +2150,25 @@ async def test_streamable_http_rejects_initialize_from_unlisted_client_before_se
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("allowed_clients", "request_body"),
|
||||
("settings", "jwt_claims", "headers"),
|
||||
(
|
||||
(["antigravity-cli"], _ANTIGRAVITY_INITIALIZE),
|
||||
(["claude-code", "antigravity-cli"], _CLAUDE_CODE_INITIALIZE),
|
||||
(None, _CLAUDE_CODE_INITIALIZE),
|
||||
(None, _ANONYMOUS_INITIALIZE),
|
||||
(_ALLOWLIST_SETTINGS, _LISTED_JWT, []),
|
||||
(_ALLOWLIST_SETTINGS, _LISTED_JWT, _UNLISTED_HEADER),
|
||||
(_ALLOWLIST_SETTINGS, None, _LISTED_HEADER),
|
||||
({}, _UNLISTED_JWT, _UNLISTED_HEADER),
|
||||
({}, None, []),
|
||||
),
|
||||
)
|
||||
async def test_streamable_http_admits_listed_or_unrestricted_initialize_and_replays_body(
|
||||
allowed_clients: list[str] | None, request_body: bytes
|
||||
async def test_streamable_http_admits_listed_or_unrestricted_clients_and_hands_the_body_downstream(
|
||||
settings: dict[str, object], jwt_claims: dict[str, object] | None, headers: list[tuple[bytes, bytes]]
|
||||
) -> None:
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
|
||||
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp", "headers": headers}
|
||||
receive: Final = AsyncMock(
|
||||
side_effect=[
|
||||
{"type": "http.request", "body": request_body[:20], "more_body": True},
|
||||
{"type": "http.request", "body": request_body[20:], "more_body": False},
|
||||
{"type": "http.request", "body": _INITIALIZE[:20], "more_body": True},
|
||||
{"type": "http.request", "body": _INITIALIZE[20:], "more_body": False},
|
||||
]
|
||||
)
|
||||
send: Final = AsyncMock()
|
||||
|
|
@ -2171,7 +2181,7 @@ async def test_streamable_http_admits_listed_or_unrestricted_initialize_and_repl
|
|||
stateless_handle: Final = AsyncMock()
|
||||
|
||||
with (
|
||||
_client_allowlist_patches(allowed_clients),
|
||||
_client_allowlist_patches(settings, jwt_claims),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateful",
|
||||
SimpleNamespace(handle_request=stateful_handle),
|
||||
|
|
@ -2183,351 +2193,29 @@ async def test_streamable_http_admits_listed_or_unrestricted_initialize_and_repl
|
|||
):
|
||||
await mcp_module.handle_streamable_http_mcp(scope, receive, send)
|
||||
|
||||
assert downstream_bodies == [request_body]
|
||||
assert downstream_bodies == [_INITIALIZE]
|
||||
stateless_handle.assert_not_awaited()
|
||||
send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("allowed_clients", ([], "claude-code", [{"name": "claude-code"}]))
|
||||
async def test_streamable_http_empty_or_malformed_allowlist_admits_nobody(
|
||||
allowed_clients: list[str] | list[dict[str, str]] | str,
|
||||
) -> None:
|
||||
from starlette.types import Scope
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
|
||||
receive: Final = AsyncMock(
|
||||
return_value={"type": "http.request", "body": _CLAUDE_CODE_INITIALIZE, "more_body": False}
|
||||
)
|
||||
send: Final = AsyncMock()
|
||||
stateful_handle: Final = AsyncMock()
|
||||
|
||||
with (
|
||||
_client_allowlist_patches(allowed_clients),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateful",
|
||||
SimpleNamespace(handle_request=stateful_handle),
|
||||
),
|
||||
):
|
||||
await mcp_module.handle_streamable_http_mcp(scope, receive, send)
|
||||
|
||||
status, body = _forbidden_client_response(send)
|
||||
assert status == 403
|
||||
assert body["details"] == "MCP client 'claude-code' is not listed in this gateway's mcp_allowed_clients."
|
||||
stateful_handle.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_allowlist_refuses_sessionless_posts_that_are_not_initialize() -> None:
|
||||
from starlette.types import Scope
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
|
||||
receive: Final = AsyncMock(side_effect=[{"type": "http.request", "body": _TOOLS_LIST, "more_body": False}])
|
||||
send: Final = AsyncMock()
|
||||
stateful_handle: Final = AsyncMock()
|
||||
stateless_handle: Final = AsyncMock()
|
||||
|
||||
with (
|
||||
_client_allowlist_patches(["antigravity-cli"]),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateful",
|
||||
SimpleNamespace(handle_request=stateful_handle),
|
||||
),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateless",
|
||||
SimpleNamespace(handle_request=stateless_handle),
|
||||
),
|
||||
):
|
||||
await mcp_module.handle_streamable_http_mcp(scope, receive, send)
|
||||
|
||||
status, body = _forbidden_client_response(send)
|
||||
assert status == 403
|
||||
assert body["error"] == "Forbidden"
|
||||
assert body["details"].startswith("While mcp_allowed_clients is set, an MCP POST without a live mcp-session-id")
|
||||
stateful_handle.assert_not_awaited()
|
||||
stateless_handle.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_allowlist_returns_404_for_a_session_unknown_to_this_worker() -> None:
|
||||
from starlette.types import Scope
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
scope: Final[Scope] = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp",
|
||||
"headers": [(b"mcp-session-id", b"stale-session-from-another-worker")],
|
||||
}
|
||||
receive: Final = AsyncMock(side_effect=[{"type": "http.request", "body": _TOOLS_LIST, "more_body": False}])
|
||||
send: Final = AsyncMock()
|
||||
stateful_handle: Final = AsyncMock()
|
||||
stateless_handle: Final = AsyncMock()
|
||||
|
||||
with (
|
||||
_client_allowlist_patches(["antigravity-cli"]),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateful",
|
||||
SimpleNamespace(handle_request=stateful_handle, _server_instances={}),
|
||||
),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateless",
|
||||
SimpleNamespace(handle_request=stateless_handle),
|
||||
),
|
||||
):
|
||||
await mcp_module.handle_streamable_http_mcp(scope, receive, send)
|
||||
|
||||
status, body = _forbidden_client_response(send)
|
||||
assert status == 404
|
||||
assert body["error"] == "Not Found"
|
||||
assert "stale-session-from-another-worker" in body["details"]
|
||||
stateful_handle.assert_not_awaited()
|
||||
stateless_handle.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_without_allowlist_still_serves_sessionless_posts_statelessly() -> None:
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
|
||||
receive: Final = AsyncMock(side_effect=[{"type": "http.request", "body": _TOOLS_LIST, "more_body": False}])
|
||||
send: Final = AsyncMock()
|
||||
downstream_bodies: Final[list[bytes]] = []
|
||||
|
||||
async def handle_request(_: Scope, downstream_receive: Receive, __: Send) -> None:
|
||||
downstream_bodies.append(await _drain_body(downstream_receive))
|
||||
|
||||
with (
|
||||
_client_allowlist_patches(None),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateless",
|
||||
SimpleNamespace(handle_request=AsyncMock(side_effect=handle_request)),
|
||||
),
|
||||
):
|
||||
await mcp_module.handle_streamable_http_mcp(scope, receive, send)
|
||||
|
||||
assert downstream_bodies == [_TOOLS_LIST]
|
||||
send.assert_not_awaited()
|
||||
|
||||
|
||||
def _oversized_initialize(client_name: str, peek_cap: int) -> bytes:
|
||||
return _padded_initialize(client_name, peek_cap * 2)
|
||||
|
||||
|
||||
def _padded_initialize(client_name: str, padding: int) -> bytes:
|
||||
return json.dumps(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 0,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {"experimental": {"padding": "x" * padding}},
|
||||
"clientInfo": {"name": client_name, "version": "1.0.0"},
|
||||
},
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
def _chunked_receive(body: bytes, chunk_size: int) -> AsyncMock:
|
||||
chunks: Final = [body[i : i + chunk_size] for i in range(0, len(body), chunk_size)]
|
||||
return AsyncMock(
|
||||
side_effect=[
|
||||
{"type": "http.request", "body": chunk, "more_body": i + 1 < len(chunks)} for i, chunk in enumerate(chunks)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("client_name", "admitted"), (("antigravity-cli", True), ("claude-code", False)))
|
||||
async def test_streamable_http_allowlist_reads_past_the_routing_peek_cap_for_initialize(
|
||||
client_name: str, admitted: bool
|
||||
) -> None:
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
request_body: Final = _oversized_initialize(client_name, mcp_module._MCP_ROUTING_PEEK_MAX_BYTES)
|
||||
assert len(request_body) > mcp_module._MCP_ROUTING_PEEK_MAX_BYTES
|
||||
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
|
||||
receive: Final = _chunked_receive(request_body, 1024)
|
||||
send: Final = AsyncMock()
|
||||
downstream_bodies: Final[list[bytes]] = []
|
||||
|
||||
async def handle_request(_: Scope, downstream_receive: Receive, __: Send) -> None:
|
||||
downstream_bodies.append(await _drain_body(downstream_receive))
|
||||
|
||||
stateful_handle: Final = AsyncMock(side_effect=handle_request)
|
||||
stateless_handle: Final = AsyncMock(side_effect=handle_request)
|
||||
|
||||
with (
|
||||
_client_allowlist_patches(["antigravity-cli"]),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateful",
|
||||
SimpleNamespace(handle_request=stateful_handle),
|
||||
),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateless",
|
||||
SimpleNamespace(handle_request=stateless_handle),
|
||||
),
|
||||
):
|
||||
await mcp_module.handle_streamable_http_mcp(scope, receive, send)
|
||||
|
||||
if admitted:
|
||||
assert downstream_bodies == [request_body]
|
||||
stateless_handle.assert_not_awaited()
|
||||
send.assert_not_awaited()
|
||||
return
|
||||
assert downstream_bodies == []
|
||||
status, body = _forbidden_client_response(send)
|
||||
assert status == 403
|
||||
assert body["details"] == "MCP client 'claude-code' is not listed in this gateway's mcp_allowed_clients."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_allowlist_bounds_the_body_it_buffers_for_unidentified_posts() -> None:
|
||||
from starlette.types import Scope
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import client_allowlist
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
chunk_size: Final = 1024
|
||||
request_body: Final = _oversized_initialize("antigravity-cli", MCP_ALLOWLIST_PEEK_MAX_BYTES)
|
||||
assert len(request_body) > 2 * MCP_ALLOWLIST_PEEK_MAX_BYTES
|
||||
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
|
||||
receive: Final = _chunked_receive(request_body, chunk_size)
|
||||
send: Final = AsyncMock()
|
||||
stateful_handle: Final = AsyncMock()
|
||||
stateless_handle: Final = AsyncMock()
|
||||
|
||||
with (
|
||||
_client_allowlist_patches(["antigravity-cli"]),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateful",
|
||||
SimpleNamespace(handle_request=stateful_handle),
|
||||
),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateless",
|
||||
SimpleNamespace(handle_request=stateless_handle),
|
||||
),
|
||||
):
|
||||
await mcp_module.handle_streamable_http_mcp(scope, receive, send)
|
||||
|
||||
status, body = _forbidden_client_response(send)
|
||||
assert status == 403
|
||||
assert body == client_allowlist.oversized_unidentified_request_body()
|
||||
assert receive.await_count <= MCP_ALLOWLIST_PEEK_MAX_BYTES // chunk_size + 1
|
||||
stateful_handle.assert_not_awaited()
|
||||
stateless_handle.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_allowlist_admits_an_initialize_of_exactly_the_peek_cap() -> None:
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
request_body: Final = _padded_initialize(
|
||||
"antigravity-cli", MCP_ALLOWLIST_PEEK_MAX_BYTES - len(_padded_initialize("antigravity-cli", 0))
|
||||
)
|
||||
assert len(request_body) == MCP_ALLOWLIST_PEEK_MAX_BYTES
|
||||
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
|
||||
receive: Final = AsyncMock(
|
||||
side_effect=[
|
||||
{"type": "http.request", "body": request_body, "more_body": True},
|
||||
{"type": "http.request", "body": b"", "more_body": False},
|
||||
]
|
||||
)
|
||||
send: Final = AsyncMock()
|
||||
downstream_bodies: Final[list[bytes]] = []
|
||||
|
||||
async def handle_request(_: Scope, downstream_receive: Receive, __: Send) -> None:
|
||||
downstream_bodies.append(await _drain_body(downstream_receive))
|
||||
|
||||
with (
|
||||
_client_allowlist_patches(["antigravity-cli"]),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateful",
|
||||
SimpleNamespace(handle_request=AsyncMock(side_effect=handle_request)),
|
||||
),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateless",
|
||||
SimpleNamespace(handle_request=AsyncMock(side_effect=handle_request)),
|
||||
),
|
||||
):
|
||||
await mcp_module.handle_streamable_http_mcp(scope, receive, send)
|
||||
|
||||
assert downstream_bodies == [request_body]
|
||||
send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamable_http_allowlist_streams_large_posts_on_an_admitted_session() -> None:
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
request_body: Final = json.dumps(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "echo",
|
||||
"arguments": {"text": "x" * (2 * MCP_ALLOWLIST_PEEK_MAX_BYTES)},
|
||||
},
|
||||
}
|
||||
).encode()
|
||||
scope: Final[Scope] = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp",
|
||||
"headers": [(b"mcp-session-id", b"admitted-session")],
|
||||
}
|
||||
receive: Final = _chunked_receive(request_body, 1024)
|
||||
send: Final = AsyncMock()
|
||||
downstream_bodies: Final[list[bytes]] = []
|
||||
|
||||
async def handle_request(_: Scope, downstream_receive: Receive, __: Send) -> None:
|
||||
downstream_bodies.append(await _drain_body(downstream_receive))
|
||||
|
||||
stateful_handle: Final = AsyncMock(side_effect=handle_request)
|
||||
|
||||
with (
|
||||
_client_allowlist_patches(["antigravity-cli"]),
|
||||
patch( # test-quality-ok: session managers are module singletons; the downstream call is the observable
|
||||
"litellm.proxy._experimental.mcp_server.server.session_manager_stateful",
|
||||
SimpleNamespace(handle_request=stateful_handle, _server_instances={"admitted-session": object()}),
|
||||
),
|
||||
):
|
||||
await mcp_module.handle_streamable_http_mcp(scope, receive, send)
|
||||
|
||||
assert downstream_bodies == [request_body]
|
||||
send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("request_body", "admitted"),
|
||||
((_ANTIGRAVITY_INITIALIZE, True), (_CLAUDE_CODE_INITIALIZE, False), (_ANONYMOUS_INITIALIZE, False)),
|
||||
("jwt_claims", "headers", "admitted"),
|
||||
(
|
||||
(_LISTED_JWT, [], True),
|
||||
(None, _LISTED_HEADER, True),
|
||||
(_UNLISTED_JWT, _LISTED_HEADER, False),
|
||||
(None, _UNLISTED_HEADER, False),
|
||||
(None, [], False),
|
||||
),
|
||||
)
|
||||
async def test_sse_endpoint_applies_the_same_client_allowlist(request_body: bytes, admitted: bool) -> None:
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
async def test_sse_endpoint_applies_the_same_client_allowlist(
|
||||
jwt_claims: dict[str, object] | None, headers: list[tuple[bytes, bytes]], admitted: bool
|
||||
) -> None:
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp/sse", "headers": []}
|
||||
receive: Final = AsyncMock(
|
||||
side_effect=[
|
||||
{"type": "http.request", "body": request_body, "more_body": False},
|
||||
{"type": "http.request", "body": b"not-the-replayed-initialize", "more_body": False},
|
||||
]
|
||||
)
|
||||
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp/sse", "headers": headers}
|
||||
receive: Final = AsyncMock(return_value={"type": "http.request", "body": _INITIALIZE, "more_body": False})
|
||||
send: Final = AsyncMock()
|
||||
downstream_bodies: Final[list[bytes]] = []
|
||||
|
||||
|
|
@ -2535,7 +2223,7 @@ async def test_sse_endpoint_applies_the_same_client_allowlist(request_body: byte
|
|||
downstream_bodies.append(await _drain_body(downstream_receive))
|
||||
|
||||
with (
|
||||
_client_allowlist_patches(["antigravity-cli"]),
|
||||
_client_allowlist_patches(_ALLOWLIST_SETTINGS, jwt_claims),
|
||||
patch( # test-quality-ok: module-level pre-auth probe unrelated to the allowlist under test; no injection seam
|
||||
"litellm.proxy._experimental.mcp_server.server._raise_preemptive_401_for_unauthenticated_servers",
|
||||
new_callable=AsyncMock,
|
||||
|
|
@ -2548,54 +2236,20 @@ async def test_sse_endpoint_applies_the_same_client_allowlist(request_body: byte
|
|||
mcp_module.sse_session_manager, "handle_request", side_effect=handle_request
|
||||
),
|
||||
):
|
||||
await mcp_module.handle_sse_mcp(scope, receive, send)
|
||||
if admitted:
|
||||
await mcp_module.handle_sse_mcp(scope, receive, send)
|
||||
assert downstream_bodies == [_INITIALIZE]
|
||||
send.assert_not_awaited()
|
||||
return
|
||||
with pytest.raises(HTTPException) as denied:
|
||||
await mcp_module.handle_sse_mcp(scope, receive, send)
|
||||
|
||||
if admitted:
|
||||
assert downstream_bodies == [request_body]
|
||||
send.assert_not_awaited()
|
||||
return
|
||||
assert downstream_bodies == []
|
||||
status, body = _forbidden_client_response(send)
|
||||
assert status == 403
|
||||
assert denied.value.status_code == 403
|
||||
body: Final = _forbidden_body(denied.value)
|
||||
assert body["error"] == "Forbidden"
|
||||
assert "mcp_allowed_clients" in body["details"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_endpoint_rejects_posts_larger_than_the_allowlist_peek_cap() -> None:
|
||||
from starlette.types import Scope
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import client_allowlist
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
chunk_size: Final = 1024
|
||||
request_body: Final = _oversized_initialize("antigravity-cli", MCP_ALLOWLIST_PEEK_MAX_BYTES)
|
||||
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp/sse", "headers": []}
|
||||
receive: Final = _chunked_receive(request_body, chunk_size)
|
||||
send: Final = AsyncMock()
|
||||
sse_handle: Final = AsyncMock()
|
||||
|
||||
with (
|
||||
_client_allowlist_patches(["antigravity-cli"]),
|
||||
patch( # test-quality-ok: module-level pre-auth probe unrelated to the allowlist under test; no injection seam
|
||||
"litellm.proxy._experimental.mcp_server.server._raise_preemptive_401_for_unauthenticated_servers",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch( # test-quality-ok: module-level upstream auth probe unrelated to the allowlist under test; no injection seam
|
||||
"litellm.proxy._experimental.mcp_server.server._check_passthrough_upstream_auth",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch.object( # test-quality-ok: SSE manager is a module singleton; the downstream call is the observable
|
||||
mcp_module.sse_session_manager, "handle_request", sse_handle
|
||||
),
|
||||
):
|
||||
await mcp_module.handle_sse_mcp(scope, receive, send)
|
||||
|
||||
status, body = _forbidden_client_response(send)
|
||||
assert status == 403
|
||||
assert body == client_allowlist.oversized_unidentified_request_body()
|
||||
assert receive.await_count <= MCP_ALLOWLIST_PEEK_MAX_BYTES // chunk_size + 1
|
||||
sse_handle.assert_not_awaited()
|
||||
assert downstream_bodies == []
|
||||
send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -5736,11 +5390,12 @@ async def test_get_tools_from_mcp_servers_logs_list_tools_to_spendlogs_when_enab
|
|||
Ensure list-tools logging path calls `async_success_handler` when enabled.
|
||||
"""
|
||||
try:
|
||||
from mcp.types import Tool as MCPTool
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_get_tools_from_mcp_servers,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from mcp.types import Tool as MCPTool
|
||||
except ImportError:
|
||||
pytest.skip("MCP server not available")
|
||||
|
||||
|
|
@ -8690,10 +8345,10 @@ async def test_fire_mcp_tool_call_logging_iserror_logs_failure():
|
|||
"""Regression test: a CallToolResult with isError=True must go
|
||||
down the failure logging path (async_failure_handler + post_call_failure_hook),
|
||||
never async_success_handler."""
|
||||
from litellm.proxy._experimental.mcp_server.exceptions import MCPToolResultError
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_fire_mcp_tool_call_logging,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.exceptions import MCPToolResultError
|
||||
|
||||
logging_obj = _mock_mcp_logging_obj()
|
||||
proxy_logging_mock = _mock_mcp_proxy_logging()
|
||||
|
|
@ -9043,11 +8698,11 @@ async def test_call_mcp_tool_skips_failure_hook_for_upstream_auth_error():
|
|||
caller-must-reauth signal, not a failed call, so call_mcp_tool must re-raise it WITHOUT firing
|
||||
post_call_failure_hook (which records a failure and can trip LLM exception alerts). The
|
||||
streamable handler downgrades it to an informational isError result afterward."""
|
||||
from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
call_mcp_tool,
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError
|
||||
from litellm.proxy._types import MCPTransport, UserAPIKeyAuth
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
|
|
|
|||
|
|
@ -14072,47 +14072,23 @@ async def test_update_general_settings_keeps_yaml_openai_websocket_passthrough()
|
|||
assert ps.general_settings["enable_openai_websocket_passthrough"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"db_general_settings, expected",
|
||||
[
|
||||
({"mcp_allowed_clients": ["antigravity-cli"]}, ["antigravity-cli"]),
|
||||
({"mcp_allowed_clients": []}, []),
|
||||
({}, None),
|
||||
],
|
||||
)
|
||||
async def test_update_general_settings_propagates_mcp_allowed_clients(
|
||||
db_general_settings: dict[str, list[str]], expected: list[str] | None
|
||||
) -> None:
|
||||
def test_settings_store_exposes_dashboard_saved_mcp_client_allowlist_to_the_mcp_gateway() -> None:
|
||||
from litellm.proxy._experimental.mcp_server.client_allowlist import MCPClientAllowlist, load_mcp_client_allowlist
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
proxy_config = ProxyConfig()
|
||||
settings: Final = ProxyConfig().settings
|
||||
settings.load_yaml({"litellm_jwtauth": {"mcp_client_id_jwt_field": "azp"}})
|
||||
assert load_mcp_client_allowlist(settings) is None
|
||||
|
||||
with patch( # test-quality-ok: the method writes this module global; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", {"mcp_allowed_clients": ["claude-code"]}
|
||||
):
|
||||
await proxy_config._update_general_settings(db_general_settings=db_general_settings)
|
||||
settings.apply_db_row(
|
||||
"general_settings", {"mcp_allowed_clients": ["antigravity-cli"], "mcp_client_id_header": "X-MCP-Client"}
|
||||
)
|
||||
assert load_mcp_client_allowlist(settings) == MCPClientAllowlist(
|
||||
allowed_clients=frozenset({"antigravity-cli"}), jwt_field="azp", header="x-mcp-client"
|
||||
)
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
|
||||
assert ps.general_settings["mcp_allowed_clients"] == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_general_settings_keeps_yaml_mcp_allowed_clients() -> None:
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
proxy_config = ProxyConfig()
|
||||
proxy_config._yaml_general_settings_keys = {"mcp_allowed_clients"}
|
||||
|
||||
with patch( # test-quality-ok: the method writes this module global; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", {"mcp_allowed_clients": ["claude-code"]}
|
||||
):
|
||||
await proxy_config._update_general_settings(db_general_settings={"mcp_allowed_clients": ["codex-mcp-client"]})
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
|
||||
assert ps.general_settings["mcp_allowed_clients"] == ["claude-code"]
|
||||
settings.apply_db_row("general_settings", {"mcp_client_id_header": "X-MCP-Client"})
|
||||
assert load_mcp_client_allowlist(settings) is None
|
||||
|
||||
|
||||
async def test_token_counter_keeps_the_event_loop_free_during_a_huggingface_count(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import MCPNetworkSettings from "./MCPNetworkSettings";
|
||||
|
|
@ -128,7 +128,7 @@ describe("MCPNetworkSettings", () => {
|
|||
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the stored allowed client names once settings load", async () => {
|
||||
it("renders the stored allowed client IDs once settings load", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{ field_name: "mcp_allowed_clients", field_value: ["antigravity-cli", "codex-mcp-client"] },
|
||||
]);
|
||||
|
|
@ -139,9 +139,9 @@ describe("MCPNetworkSettings", () => {
|
|||
expect(screen.getByText("codex-mcp-client")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds typed client names on Enter and saves them under mcp_allowed_clients", async () => {
|
||||
it("adds typed client IDs on Enter and saves them under mcp_allowed_clients", async () => {
|
||||
renderSettings();
|
||||
const input = await screen.findByRole("textbox", { name: "Allowed client names" });
|
||||
const input = await screen.findByRole("textbox", { name: "Allowed client IDs" });
|
||||
|
||||
await userEvent.type(input, "antigravity-cli, codex-mcp-client{Enter}");
|
||||
|
||||
|
|
@ -160,7 +160,7 @@ describe("MCPNetworkSettings", () => {
|
|||
expect(deleteConfigFieldSetting).not.toHaveBeenCalledWith("tok", "mcp_allowed_clients");
|
||||
});
|
||||
|
||||
it("removes a client name and clears the setting when the list becomes empty", async () => {
|
||||
it("removes a client ID and clears the setting when the list becomes empty", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{ field_name: "mcp_allowed_clients", field_value: ["claude-code"] },
|
||||
]);
|
||||
|
|
@ -199,6 +199,69 @@ describe("MCPNetworkSettings", () => {
|
|||
expect(screen.queryByText(/every client is denied/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains that JWT callers are identified by the configured claim and others by the opt-in header", async () => {
|
||||
renderSettings();
|
||||
|
||||
expect(await screen.findByText(/litellm_jwtauth\.mcp_client_id_jwt_field/)).toBeVisible();
|
||||
expect(screen.getByText(/Clients pick this value themselves, so it is a policy control/)).toBeVisible();
|
||||
expect(screen.queryByText(/clientInfo/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the stored client identity header once settings load", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{ field_name: "mcp_client_id_header", field_value: "x-mcp-client" },
|
||||
]);
|
||||
|
||||
renderSettings();
|
||||
|
||||
expect(await screen.findByRole("textbox", { name: "Client identity header" })).toHaveValue("x-mcp-client");
|
||||
});
|
||||
|
||||
it("saves a newly typed client identity header under mcp_client_id_header", async () => {
|
||||
renderSettings();
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: "Client identity header" }), {
|
||||
target: { value: " x-mcp-client " },
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_client_id_header", "x-mcp-client"),
|
||||
);
|
||||
expect(updateConfigFieldSetting).not.toHaveBeenCalledWith("tok", "mcp_allowed_clients", expect.anything());
|
||||
expect(deleteConfigFieldSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears a stored client identity header when the field is emptied, so only JWT identity is trusted", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{ field_name: "mcp_client_id_header", field_value: "x-mcp-client" },
|
||||
]);
|
||||
|
||||
renderSettings();
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: "Client identity header" }), {
|
||||
target: { value: "" },
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
|
||||
|
||||
await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_client_id_header"));
|
||||
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not rewrite an unchanged client identity header on save", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{ field_name: "mcp_client_id_header", field_value: "x-mcp-client" },
|
||||
]);
|
||||
|
||||
renderSettings();
|
||||
await screen.findByRole("textbox", { name: "Client identity header" });
|
||||
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
|
||||
expect(deleteConfigFieldSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the private ranges and the allowed clients as independent settings on save", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{ field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8"] },
|
||||
|
|
@ -206,10 +269,7 @@ describe("MCPNetworkSettings", () => {
|
|||
]);
|
||||
|
||||
renderSettings();
|
||||
await userEvent.type(
|
||||
await screen.findByRole("textbox", { name: "Allowed client names" }),
|
||||
"codex-mcp-client{Enter}",
|
||||
);
|
||||
await userEvent.type(await screen.findByRole("textbox", { name: "Allowed client IDs" }), "codex-mcp-client{Enter}");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
|
|
@ -231,7 +291,7 @@ describe("MCPNetworkSettings", () => {
|
|||
|
||||
renderSettings();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Remove 10.0.0.0/8" }));
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "Allowed client names" }), "codex-mcp-client{Enter}");
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "Allowed client IDs" }), "codex-mcp-client{Enter}");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
|
|
@ -257,7 +317,7 @@ describe("MCPNetworkSettings", () => {
|
|||
|
||||
renderSettings();
|
||||
await userEvent.click(await screen.findByText("203.0.113.0/24"));
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "Allowed client names" }), "codex-mcp-client{Enter}");
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "Allowed client IDs" }), "codex-mcp-client{Enter}");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
|
|
|
|||
|
|
@ -32,13 +32,18 @@ const sameList = (a: string[], b: string[]) => a.length === b.length && a.every(
|
|||
const unchangedSinceLoad = (value: string[], stored: string[] | null) =>
|
||||
stored === null ? value.length === 0 : value.length > 0 && sameList(value, stored);
|
||||
|
||||
const headerUnchangedSinceLoad = (value: string, stored: string | null) =>
|
||||
stored === null ? value === "" : value !== "" && value === stored;
|
||||
|
||||
const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [privateRanges, setPrivateRanges] = useState<string[]>([]);
|
||||
const [allowedClients, setAllowedClients] = useState<string[]>([]);
|
||||
const [clientIdHeader, setClientIdHeader] = useState("");
|
||||
const [storedRanges, setStoredRanges] = useState<string[] | null>(null);
|
||||
const [storedClients, setStoredClients] = useState<string[] | null>(null);
|
||||
const [storedClientIdHeader, setStoredClientIdHeader] = useState<string | null>(null);
|
||||
const [currentIp, setCurrentIp] = useState<string | null>(null);
|
||||
const [rangeDraft, setRangeDraft] = useState("");
|
||||
const [clientDraft, setClientDraft] = useState("");
|
||||
|
|
@ -62,6 +67,10 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
setAllowedClients(field.field_value);
|
||||
setStoredClients(field.field_value);
|
||||
}
|
||||
if (field.field_name === "mcp_client_id_header" && typeof field.field_value === "string") {
|
||||
setClientIdHeader(field.field_value);
|
||||
setStoredClientIdHeader(field.field_value);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load MCP network settings:", error);
|
||||
|
|
@ -97,6 +106,18 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
setStored(null);
|
||||
};
|
||||
|
||||
const persistClientIdHeader = async (token: string) => {
|
||||
const value = clientIdHeader.trim();
|
||||
if (headerUnchangedSinceLoad(value, storedClientIdHeader)) return;
|
||||
if (value !== "") {
|
||||
await updateConfigFieldSetting(token, "mcp_client_id_header", value);
|
||||
setStoredClientIdHeader(value);
|
||||
return;
|
||||
}
|
||||
await deleteConfigFieldSetting(token, "mcp_client_id_header");
|
||||
setStoredClientIdHeader(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!accessToken) return;
|
||||
setSaving(true);
|
||||
|
|
@ -114,8 +135,9 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
setStored: setStoredClients,
|
||||
}),
|
||||
]);
|
||||
const [headerResult] = await Promise.allSettled([persistClientIdHeader(accessToken)]);
|
||||
setSaving(false);
|
||||
const failures = [rangeResult, clientResult].filter(
|
||||
const failures = [rangeResult, clientResult, headerResult].filter(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
);
|
||||
if (failures.length === 0) {
|
||||
|
|
@ -239,16 +261,16 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
<div>
|
||||
<p className="text-lg font-semibold">Allowed Client Applications</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Only the MCP client applications listed here can connect to the gateway. Names are matched exactly against the
|
||||
clientInfo.name each client sends in its MCP initialize request (for example claude-code or codex-mcp-client).
|
||||
Leave empty to allow every client. Clients choose the name they send, so treat this as a policy control rather
|
||||
than a security boundary.
|
||||
Only the MCP client applications listed here can use the gateway. Leave empty to allow every client. A client
|
||||
that authenticates with a JWT is identified by the claim named in litellm_jwtauth.mcp_client_id_jwt_field in
|
||||
your proxy config (for example azp or client_id), which your identity provider asserts and the client cannot
|
||||
change. Any other client is identified by the request header configured below, if you enable one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="mb-2 flex items-center">
|
||||
<p className="text-sm font-medium">Allowed Client Names</p>
|
||||
<p className="text-sm font-medium">Allowed Client IDs</p>
|
||||
</div>
|
||||
{storedAllowlistDeniesEveryone && (
|
||||
<p className="mb-2 text-sm text-destructive">
|
||||
|
|
@ -274,9 +296,9 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
</div>
|
||||
)}
|
||||
<Input
|
||||
aria-label="Allowed client names"
|
||||
aria-label="Allowed client IDs"
|
||||
value={clientDraft}
|
||||
placeholder="Leave empty to allow every client, e.g. claude-code, codex-mcp-client"
|
||||
placeholder="Leave empty to allow every client, e.g. mcp-client-prod, claude-code"
|
||||
onChange={(e) => setClientDraft(e.target.value)}
|
||||
onBlur={commitClientDraft}
|
||||
onKeyDown={(e) => {
|
||||
|
|
@ -287,8 +309,23 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
}}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Enter the clientInfo.name values to admit. Any other client, or one that does not identify itself, gets a 403
|
||||
on its MCP initialize request.
|
||||
Enter the exact JWT claim or header values to admit. Every MCP request from any other client, or from one with
|
||||
no resolvable identity, gets a 403.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 mb-2 flex items-center">
|
||||
<p className="text-sm font-medium">Client Identity Header (less secure)</p>
|
||||
</div>
|
||||
<Input
|
||||
aria-label="Client identity header"
|
||||
value={clientIdHeader}
|
||||
placeholder="Leave empty to identify clients by JWT only, e.g. x-mcp-client"
|
||||
onChange={(e) => setClientIdHeader(e.target.value)}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Optional header whose value names the client for callers without a JWT identity. Clients pick this value
|
||||
themselves, so it is a policy control rather than a security boundary. Without it, callers that do not carry
|
||||
the JWT claim are rejected while the allowlist is set.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
7
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
7
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -26803,9 +26803,14 @@ export interface components {
|
|||
maximum_spend_logs_retention_period?: string | null;
|
||||
/**
|
||||
* Mcp Allowed Clients
|
||||
* @description MCP client applications admitted by the gateway, matched exactly against the clientInfo.name the client sends in its initialize request (for example 'claude-code'). When set, an initialize from any other client, or one that does not identify itself, is rejected with 403. Unset means every client is admitted. The name is client-supplied, so this is a policy control rather than a security boundary.
|
||||
* @description MCP client applications admitted by the gateway. When set, every MCP request must carry a client identity that matches one of these values exactly: a JWT caller is identified by the claim named in litellm_jwtauth.mcp_client_id_jwt_field, any other caller by the header named in mcp_client_id_header. A request with no resolvable identity, or an unlisted one, is rejected with 403. Unset means every client is admitted.
|
||||
*/
|
||||
mcp_allowed_clients?: string[] | null;
|
||||
/**
|
||||
* Mcp Client Id Header
|
||||
* @description Request header whose value names the calling MCP client application (for example 'x-mcp-client') for callers that did not authenticate with a JWT, used only while mcp_allowed_clients is set. The client picks this value itself, so it is a policy control rather than a security boundary; prefer litellm_jwtauth.mcp_client_id_jwt_field where callers use JWTs.
|
||||
*/
|
||||
mcp_client_id_header?: string | null;
|
||||
/**
|
||||
* Mcp Internal Ip Ranges
|
||||
* @description Custom CIDR ranges that define internal/private networks for MCP access control. When set, only these ranges are treated as internal. Defaults to RFC 1918 private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue