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:
yassin 2026-09-18 19:27:43 +00:00
parent c5919a3c0e
commit fe8cf02823
11 changed files with 527 additions and 659 deletions

View file

@ -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.

View file

@ -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}."
)

View file

@ -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

View file

@ -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,

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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(() =>

View file

@ -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>

View file

@ -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).