diff --git a/litellm/constants.py b/litellm/constants.py index 25359fd547b..d85e104ba15 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -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. diff --git a/litellm/proxy/_experimental/mcp_server/client_allowlist.py b/litellm/proxy/_experimental/mcp_server/client_allowlist.py index eca1f4eb026..cb708723efd 100644 --- a/litellm/proxy/_experimental/mcp_server/client_allowlist.py +++ b/litellm/proxy/_experimental/mcp_server/client_allowlist.py @@ -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}." + ) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 524bac747ad..65f46786ccb 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -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 diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index e3c672a7111..a6c5c74c706 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 04757424896..bac90c179a2 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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", diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_client_allowlist.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_client_allowlist.py index fce8a0a6c3b..714b044d311 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_client_allowlist.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_client_allowlist.py @@ -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 diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index da8905a5dfd..6bf5c5f5b49 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -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 diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 207c29a6f7b..06617e81ff5 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -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): diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx index 404efae9e16..f78461e2253 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx @@ -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(() => diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx index a49e70eb0c3..61294e1eb8e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx @@ -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 = ({ accessToken }) => { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [privateRanges, setPrivateRanges] = useState([]); const [allowedClients, setAllowedClients] = useState([]); + const [clientIdHeader, setClientIdHeader] = useState(""); const [storedRanges, setStoredRanges] = useState(null); const [storedClients, setStoredClients] = useState(null); + const [storedClientIdHeader, setStoredClientIdHeader] = useState(null); const [currentIp, setCurrentIp] = useState(null); const [rangeDraft, setRangeDraft] = useState(""); const [clientDraft, setClientDraft] = useState(""); @@ -62,6 +67,10 @@ const MCPNetworkSettings: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ accessToken })

Allowed Client Applications

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

-

Allowed Client Names

+

Allowed Client IDs

{storedAllowlistDeniesEveryone && (

@@ -274,9 +296,9 @@ const MCPNetworkSettings: React.FC = ({ accessToken }) )} setClientDraft(e.target.value)} onBlur={commitClientDraft} onKeyDown={(e) => { @@ -287,8 +309,23 @@ const MCPNetworkSettings: React.FC = ({ accessToken }) }} />

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

+ +
+

Client Identity Header (less secure)

+
+ setClientIdHeader(e.target.value)} + /> +

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

diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 2a19e559a16..755c0451a8f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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).