Merge pull request #41667 from BerriAI/litellm_mcp_client_allowlist

feat(mcp): allowlist MCP client applications at the gateway
This commit is contained in:
Yassin Kortam 2026-09-18 17:48:36 -07:00 committed by GitHub
commit 6d8a960e1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1556 additions and 24 deletions

View file

@ -21,6 +21,7 @@ litellm/proxy/_experimental/mcp_server/
user_api_key_auth_mcp.py # LiteLLM admission auth and MCP request headers
token_exchange.py # OAuth token exchange handling [unchanged; V1TokenExchangeAdapter delegates here]
litellm_auth_handler.py # authenticated-user adapter for MCP sessions
client_allowlist.py # gateway-level client application allowlist (mcp_allowed_clients); leaf module, no litellm.proxy imports
outbound_credentials/ # NEW — typed upstream-credential resolution (resolve_credentials + arms)
__init__.py # public surface: resolve_credentials, the configs, CredError
result.py # Ok | Error union (pure stdlib)

View file

@ -0,0 +1,171 @@
"""
Gateway-level allowlist of MCP client applications (``general_settings.mcp_allowed_clients``).
Each entry pairs an admin-chosen ``alias`` (shown in the dashboard and logs) with the ``value`` that
identifies the client. Only the value is compared, exactly and case-sensitively.
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 types import MappingProxyType
from typing import Final, Literal
from pydantic import TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.dot_notation_indexing import get_nested_value
from litellm.types.mcp import MCPAllowedClient
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[MCPAllowedClient]]] = TypeAdapter(list[MCPAllowedClient])
_OPTIONAL_NAME_ADAPTER: Final[TypeAdapter[str | None]] = TypeAdapter(str | None)
_OPTIONAL_MAPPING_ADAPTER: Final[TypeAdapter[dict[str, object] | None]] = TypeAdapter(dict[str, object] | None)
_NOBODY: Final[Mapping[str, str]] = MappingProxyType({})
class MCPClientForbiddenBody(TypedDict):
error: ReadOnly[Literal["Forbidden"]]
details: ReadOnly[str]
@dataclass(frozen=True, slots=True)
class MCPClientAllowlist:
"""``aliases_by_value`` maps each admitted identity value to the alias the admin gave it."""
aliases_by_value: Mapping[str, 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:
details: str
@property
def response_body(self) -> MCPClientForbiddenBody:
body: Final[MCPClientForbiddenBody] = {"error": "Forbidden", "details": self.details}
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) -> Mapping[str, str] | None:
"""Value-to-alias mapping; None when the setting is absent (not enforced). A malformed setting admits nobody."""
if raw_setting is None:
return None
try:
clients: Final = _ALLOWED_CLIENTS_ADAPTER.validate_python(raw_setting)
except ValidationError:
verbose_logger.warning(
"%s is not a list of {alias, value} entries (%r); rejecting every MCP client until it is fixed",
MCP_ALLOWED_CLIENTS_SETTING,
raw_setting,
)
return _NOBODY
return MappingProxyType({client.value: client.alias for client in clients})
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 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
header: Final = _parse_optional_name(
MCP_CLIENT_ID_HEADER_SETTING, general_settings.get(MCP_CLIENT_ID_HEADER_SETTING)
)
return MCPClientAllowlist(
aliases_by_value=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 is not None 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
identity: Final = resolve_mcp_client_identity(allowlist, jwt_claims, headers)
if isinstance(identity, MCPClientRejection):
return identity
alias: Final = allowlist.aliases_by_value.get(identity.client_id)
if alias is None:
return MCPClientRejection(
details=f"MCP client {identity.description} is not listed in this gateway's {MCP_ALLOWED_CLIENTS_SETTING}."
)
verbose_logger.debug("Admitted MCP client '%s' identified as %s", alias, identity.description)
return None

View file

@ -193,6 +193,7 @@ if MCP_AVAILABLE:
filter_tools_by_allowed_tools,
filter_tools_by_key_team_permissions,
fire_mcp_tool_call_failure_logging,
reject_disallowed_mcp_client,
)
########################################################
@ -875,6 +876,7 @@ if MCP_AVAILABLE:
MCPRequestHandler,
)
reject_disallowed_mcp_client(request.headers, user_api_key_dict)
try:
mcp_server_name = _as_query_str(mcp_server_name)
toolset_name = _as_query_str(toolset_name)
@ -1078,6 +1080,7 @@ if MCP_AVAILABLE:
proxy_logging_obj,
)
reject_disallowed_mcp_client(request.headers, user_api_key_dict)
try:
user_api_key_dict = await acting_user_auth(user_api_key_dict)
data = await request.json()

View file

@ -47,6 +47,11 @@ from litellm.proxy._experimental.mcp_server.byok_credential_cache import (
cache_byok_credential,
get_cached_byok_credential,
)
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,
)
@ -72,6 +77,7 @@ from litellm.proxy._experimental.mcp_server.oauth_utils import (
get_route_relative_request_path,
well_known_root_suffix,
)
from litellm.proxy._experimental.mcp_server.ui_session_utils import is_ui_session_credential
from litellm.proxy._experimental.mcp_server.utils import (
LITELLM_MCP_SERVER_DESCRIPTION,
LITELLM_MCP_SERVER_NAME,
@ -3701,6 +3707,25 @@ 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(headers: Mapping[str, str], user_api_key_auth: UserAPIKeyAuth | None) -> None:
"""Gate every MCP tool surface on ``mcp_allowed_clients``; the dashboard's own session is not a client app."""
if user_api_key_auth is not None and is_ui_session_credential(user_api_key_auth):
return
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=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.
@ -4537,6 +4562,7 @@ if MCP_AVAILABLE:
oauth2_headers,
raw_headers,
) = await extract_mcp_auth_context(scope, path)
reject_disallowed_mcp_client(StarletteRequest(scope).headers, user_api_key_auth)
scoped_server_endpoint: Final = len(_get_mcp_servers_in_path(path) or []) == 1
# Extract client IP for MCP access control
@ -4865,6 +4891,7 @@ if MCP_AVAILABLE:
oauth2_headers,
raw_headers,
) = await extract_mcp_auth_context(scope, path)
reject_disallowed_mcp_client(StarletteRequest(scope).headers, 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

@ -34,6 +34,7 @@ from litellm.types.llms.openai import (
ResponsesAPIResponse,
)
from litellm.types.mcp import (
MCPAllowedClient,
MCPAuth,
MCPAuthType,
MCPCredentials,
@ -2902,6 +2903,14 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
None,
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).",
)
mcp_allowed_clients: list[MCPAllowedClient] | None = Field(
None,
description="MCP client applications admitted by the gateway, each an {alias, value} pair where alias is the name shown in the dashboard and logs and value is the identity that must match exactly. When set, every MCP request must carry a client identity equal to one of the values: 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,
description="CIDR ranges of trusted reverse proxies. When set, X-Forwarded-For and X-Forwarded-* origin headers are only trusted from these IPs.",
@ -5118,6 +5127,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

@ -17249,6 +17249,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": "TypedDictionary",
"mcp_client_id_header": "String",
"mcp_trusted_proxy_ranges": "List",
"mcp_xff_num_trusted_hops": "Integer",
"always_include_stream_usage": "Boolean",

View file

@ -91,6 +91,22 @@ class MCPPublicServer(BaseModel):
mcp_info: dict[str, Any] | None = None
class MCPAllowedClient(BaseModel):
"""One entry of `general_settings.mcp_allowed_clients`."""
model_config = ConfigDict(frozen=True)
alias: str = Field(
min_length=1,
description="Human-readable name for this client application, shown in the dashboard and in gateway logs.",
)
value: str = Field(
min_length=1,
description="Exact value of the JWT claim named in litellm_jwtauth.mcp_client_id_jwt_field, or of the "
"mcp_client_id_header header, that identifies this client application. Matched case-sensitively.",
)
class MCPToolSearchSettings(BaseModel):
"""`litellm_settings.mcp_tool_search`: how the native `mcp_tool_search` virtual tool ranks the caller's tools."""

View file

@ -0,0 +1,217 @@
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,
load_mcp_client_allowlist,
parse_allowed_mcp_clients,
resolve_mcp_client_identity,
)
ANTIGRAVITY: Final = {"alias": "Antigravity CLI", "value": "antigravity-cli"}
CODEX: Final = {"alias": "Codex", "value": "codex-mcp-client"}
ANTIGRAVITY_ONLY: Final[Mapping[str, str]] = {"antigravity-cli": "Antigravity CLI"}
JWT_ONLY: Final = MCPClientAllowlist(aliases_by_value=ANTIGRAVITY_ONLY, jwt_field="azp", header=None)
HEADER_ONLY: Final = MCPClientAllowlist(aliases_by_value=ANTIGRAVITY_ONLY, jwt_field=None, header="x-mcp-client")
JWT_AND_HEADER: Final = MCPClientAllowlist(aliases_by_value=ANTIGRAVITY_ONLY, jwt_field="azp", header="x-mcp-client")
NO_SOURCE: Final = MCPClientAllowlist(aliases_by_value=ANTIGRAVITY_ONLY, jwt_field=None, header=None)
NO_HEADERS: Final[Mapping[str, str]] = {}
_ALLOWLIST_SETTING_CASES: Final[tuple[tuple[object, Mapping[str, str] | None], ...]] = (
(None, None),
([], {}),
([ANTIGRAVITY], ANTIGRAVITY_ONLY),
([ANTIGRAVITY, CODEX], {"antigravity-cli": "Antigravity CLI", "codex-mcp-client": "Codex"}),
(
[ANTIGRAVITY, {"alias": "Antigravity (prod)", "value": "antigravity-cli"}],
{"antigravity-cli": "Antigravity (prod)"},
),
(
[ANTIGRAVITY, {"alias": "Antigravity CLI", "value": "antigravity-prod"}],
{**ANTIGRAVITY_ONLY, "antigravity-prod": "Antigravity CLI"},
),
(["antigravity-cli"], {}),
("antigravity-cli", {}),
([ANTIGRAVITY, 1], {}),
([{"alias": "Antigravity CLI"}], {}),
([{"value": "antigravity-cli"}], {}),
([{"alias": "", "value": "antigravity-cli"}], {}),
([{"alias": "Antigravity CLI", "value": ""}], {}),
([{"alias": "Antigravity CLI", "value": ["antigravity-cli"]}], {}),
(ANTIGRAVITY, {}),
)
@pytest.mark.parametrize(("raw_setting", "expected"), _ALLOWLIST_SETTING_CASES)
def test_parse_allowed_mcp_clients(raw_setting: object, expected: Mapping[str, 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, CODEX],
"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(
aliases_by_value={"antigravity-cli": "Antigravity CLI", "codex-mcp-client": "Codex"},
jwt_field="resource_access.mcp.client",
header="x-mcp-client",
)
@pytest.mark.parametrize(
"settings",
(
{"mcp_allowed_clients": [ANTIGRAVITY]},
{"mcp_allowed_clients": [ANTIGRAVITY], "litellm_jwtauth": {}, "mcp_client_id_header": ""},
{"mcp_allowed_clients": [ANTIGRAVITY], "litellm_jwtauth": {"mcp_client_id_jwt_field": ""}},
{"mcp_allowed_clients": [ANTIGRAVITY], "litellm_jwtauth": "azp", "mcp_client_id_header": ["x"]},
),
)
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
@pytest.mark.parametrize("raw_setting", ("antigravity-cli", ["antigravity-cli"], [{"alias": "Antigravity CLI"}]))
def test_load_malformed_allowlist_admits_nobody(raw_setting: object) -> None:
loaded: Final = load_mcp_client_allowlist({"mcp_allowed_clients": raw_setting})
assert loaded is not None
assert loaded.aliases_by_value == {}
assert check_mcp_client_allowed(loaded, {"azp": "antigravity-cli"}, {"x-mcp-client": "antigravity-cli"}) is not None
def test_only_the_value_identifies_a_client_never_its_alias() -> None:
assert check_mcp_client_allowed(JWT_ONLY, {"azp": "Antigravity CLI"}, NO_HEADERS) is not None
assert check_mcp_client_allowed(HEADER_ONLY, None, {"x-mcp-client": "Antigravity CLI"}) is not None
def test_two_clients_may_share_an_alias_and_both_are_admitted() -> None:
settings: Final = {
"mcp_allowed_clients": [
{"alias": "Coding CLI", "value": "cli-dev"},
{"alias": "Coding CLI", "value": "cli-prod"},
],
"litellm_jwtauth": {"mcp_client_id_jwt_field": "azp"},
}
loaded: Final = load_mcp_client_allowlist(settings)
assert check_mcp_client_allowed(loaded, {"azp": "cli-dev"}, NO_HEADERS) is None
assert check_mcp_client_allowed(loaded, {"azp": "cli-prod"}, NO_HEADERS) is None
assert check_mcp_client_allowed(loaded, {"azp": "Coding CLI"}, NO_HEADERS) is not None
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(
aliases_by_value=ANTIGRAVITY_ONLY, 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:
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_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_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_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
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_jwt_caller_with_an_empty_claim_set_cannot_fall_back_to_the_header() -> None:
rejection: Final = check_mcp_client_allowed(JWT_AND_HEADER, {}, {"x-mcp-client": "antigravity-cli"})
assert isinstance(rejection, MCPClientRejection)
assert "azp" in rejection.details
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(aliases_by_value={}, 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,4 +1,5 @@
import asyncio
import contextlib
import contextvars
import os
from datetime import datetime, timedelta
@ -17,6 +18,8 @@ from mcp.types import (
TextContent,
TextResourceContents,
)
from pydantic import TypeAdapter
from starlette.types import Receive, Scope, Send
from litellm.proxy._types import (
LiteLLM_MCPServerTable,
@ -2035,6 +2038,220 @@ 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)
_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"}}}'
)
_TOOLS_LIST: Final = b'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
_ALLOWLIST_SETTINGS: Final[dict[str, object]] = {
"mcp_allowed_clients": [{"alias": "Antigravity CLI", "value": "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:
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 _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", 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
)
)
return stack
def _forbidden_body(denied: HTTPException) -> dict[str, str]:
return _FORBIDDEN_BODY_ADAPTER.validate_python(denied.detail)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("jwt_claims", "headers", "request_body", "expected_fragment"),
(
(_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_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 litellm.proxy._experimental.mcp_server import server as mcp_module
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()
stateless_handle: Final = AsyncMock()
session_cap: Final = AsyncMock(return_value=True)
with (
_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),
),
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),
),
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 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()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("settings", "jwt_claims", "headers"),
(
(_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_clients_and_hands_the_body_downstream(
settings: dict[str, object], jwt_claims: dict[str, object] | None, headers: list[tuple[bytes, bytes]]
) -> None:
from litellm.proxy._experimental.mcp_server import server as mcp_module
scope: Final[Scope] = {"type": "http", "method": "POST", "path": "/mcp", "headers": headers}
receive: Final = AsyncMock(
side_effect=[
{"type": "http.request", "body": _INITIALIZE[:20], "more_body": True},
{"type": "http.request", "body": _INITIALIZE[20:], "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))
stateful_handle: Final = AsyncMock(side_effect=handle_request)
stateless_handle: Final = AsyncMock()
with (
_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),
),
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)
assert downstream_bodies == [_INITIALIZE]
stateless_handle.assert_not_awaited()
send.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("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(
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": headers}
receive: Final = AsyncMock(return_value={"type": "http.request", "body": _INITIALIZE, "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(_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,
),
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", side_effect=handle_request
),
):
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)
assert denied.value.status_code == 403
body: Final = _forbidden_body(denied.value)
assert body["error"] == "Forbidden"
assert "mcp_allowed_clients" in body["details"]
assert downstream_bodies == []
send.assert_not_awaited()
@pytest.mark.asyncio
async def test_mcp_routing_chunked_initialize_to_stateful():
"""
@ -5422,11 +5639,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")
@ -8376,10 +8594,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()
@ -8729,11 +8947,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

@ -4313,3 +4313,131 @@ class TestV1ResolvedOauth2Gate:
assert rest_endpoints._v1_resolved_oauth2_server_ids(["oauth2-srv"]) == set()
assert rest_endpoints._v1_resolved_oauth2_server_ids(["oauth2-srv", "delegate-srv"]) == {"delegate-srv"}
_CLIENT_ALLOWLIST_SETTINGS: Final[dict[str, object]] = {
"mcp_allowed_clients": [{"alias": "Antigravity CLI", "value": "antigravity-cli"}],
"litellm_jwtauth": {"mcp_client_id_jwt_field": "azp"},
"mcp_client_id_header": "x-mcp-client",
}
class TestClientAllowlistOnRestRoutes:
"""``mcp_allowed_clients`` must gate the REST tool facade exactly like the /mcp transports,
otherwise an unlisted harness can list and call tools by switching to /mcp-rest."""
pytestmark = pytest.mark.asyncio
@staticmethod
def _stub_listing(monkeypatch: pytest.MonkeyPatch) -> list[UserAPIKeyAuth]:
listed_for: list[UserAPIKeyAuth] = []
async def fake_contexts(user_api_key_auth: UserAPIKeyAuth) -> list[UserAPIKeyAuth]:
listed_for.append(user_api_key_auth)
return [user_api_key_auth]
async def fake_get_allowed_mcp_servers(
user_api_key_auth: UserAPIKeyAuth | None = None,
*,
keyless_source: bool = False,
) -> list[str]:
return []
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", _CLIENT_ALLOWLIST_SETTINGS, raising=False)
monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", fake_contexts, raising=False)
monkeypatch.setattr(
rest_endpoints.global_mcp_server_manager,
"get_allowed_mcp_servers",
fake_get_allowed_mcp_servers,
raising=False,
)
return listed_for
@pytest.mark.parametrize(
("caller", "headers", "expected_fragment"),
(
(UserAPIKeyAuth(jwt_claims={"azp": "claude-code"}), {"x-mcp-client": "antigravity-cli"}, "'claude-code'"),
(UserAPIKeyAuth(jwt_claims={}), {"x-mcp-client": "antigravity-cli"}, "no 'azp' claim"),
(UserAPIKeyAuth(), {"x-mcp-client": "claude-code"}, "'claude-code'"),
(UserAPIKeyAuth(), {}, "no 'x-mcp-client' header"),
),
)
async def test_tools_list_rejects_unlisted_clients_before_resolving_servers(
self,
monkeypatch: pytest.MonkeyPatch,
caller: UserAPIKeyAuth,
headers: dict[str, str],
expected_fragment: str,
) -> None:
listed_for: Final = self._stub_listing(monkeypatch)
request: Final = _build_request(headers, path="/mcp-rest/tools/list", method="GET")
with pytest.raises(HTTPException) as denied:
await rest_endpoints.list_tool_rest_api(
request, server_id=None, mcp_server_name=None, toolset_name=None, user_api_key_dict=caller
)
assert denied.value.status_code == 403
assert denied.value.detail["error"] == "Forbidden"
assert expected_fragment in denied.value.detail["details"]
assert "mcp_allowed_clients" in denied.value.detail["details"]
assert listed_for == []
@pytest.mark.parametrize(
("caller", "headers"),
(
(UserAPIKeyAuth(jwt_claims={"azp": "antigravity-cli"}), {"x-mcp-client": "claude-code"}),
(UserAPIKeyAuth(), {"x-mcp-client": "antigravity-cli"}),
),
)
async def test_tools_list_admits_listed_clients(
self, monkeypatch: pytest.MonkeyPatch, caller: UserAPIKeyAuth, headers: dict[str, str]
) -> None:
listed_for: Final = self._stub_listing(monkeypatch)
request: Final = _build_request(headers, path="/mcp-rest/tools/list", method="GET")
result: Final = await rest_endpoints.list_tool_rest_api(
request, server_id=None, mcp_server_name=None, toolset_name=None, user_api_key_dict=caller
)
assert result["tools"] == []
assert listed_for == [caller]
async def test_dashboard_session_is_not_treated_as_a_client_application(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
listed_for: Final = self._stub_listing(monkeypatch)
session: Final = UserAPIKeyAuth(team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="admin-user", user_role="proxy_admin")
request: Final = _build_request(path="/mcp-rest/tools/list", method="GET")
result: Final = await rest_endpoints.list_tool_rest_api(
request, server_id=None, mcp_server_name=None, toolset_name=None, user_api_key_dict=session
)
assert result["tools"] == []
assert listed_for == [session]
async def test_tools_call_rejects_unlisted_clients_before_reading_the_body(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", _CLIENT_ALLOWLIST_SETTINGS, raising=False)
acting: Final = AsyncMock()
monkeypatch.setattr(rest_endpoints, "acting_user_auth", acting, raising=False)
request: Final = _build_request(
{"x-mcp-client": "antigravity-cli"},
path="/mcp-rest/tools/call",
method="POST",
json_body={"server_id": "server-1", "name": "demo-tool", "arguments": {}},
)
with pytest.raises(HTTPException) as denied:
await rest_endpoints.call_tool_rest_api(
request, user_api_key_dict=UserAPIKeyAuth(jwt_claims={"azp": "claude-code"})
)
assert denied.value.status_code == 403
assert denied.value.detail["error"] == "Forbidden"
assert "'claude-code'" in denied.value.detail["details"]
acting.assert_not_awaited()

View file

@ -14314,6 +14314,29 @@ async def test_update_general_settings_keeps_yaml_openai_websocket_passthrough()
assert ps.general_settings["enable_openai_websocket_passthrough"] is False
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
settings: Final = ProxyConfig().settings
settings.load_yaml({"litellm_jwtauth": {"mcp_client_id_jwt_field": "azp"}})
assert load_mcp_client_allowlist(settings) is None
settings.apply_db_row(
"general_settings",
{
"mcp_allowed_clients": [{"alias": "Antigravity CLI", "value": "antigravity-cli"}],
"mcp_client_id_header": "X-MCP-Client",
},
)
assert load_mcp_client_allowlist(settings) == MCPClientAllowlist(
aliases_by_value={"antigravity-cli": "Antigravity CLI"}, jwt_field="azp", header="x-mcp-client"
)
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):
from tests.large_text import text
from tests.test_litellm.litellm_core_utils.event_loop_lag import (

View file

@ -1,7 +1,8 @@
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import MCPNetworkSettings from "./MCPNetworkSettings";
import { toast } from "@/lib/toast";
import {
getGeneralSettingsCall,
updateConfigFieldSetting,
@ -16,8 +17,31 @@ vi.mock("@/components/networking", () => ({
fetchMCPClientIp: vi.fn(),
}));
vi.mock("@/lib/toast", () => ({
toast: { success: vi.fn(), fromError: vi.fn() },
}));
const renderSettings = () => render(<MCPNetworkSettings accessToken="tok" />);
const ANTIGRAVITY = { alias: "Antigravity CLI", value: "antigravity-cli" };
const CODEX = { alias: "Codex", value: "codex-mcp-client" };
const clientCard = (alias: string) => screen.getByRole("button", { name: new RegExp(`^${alias}`) });
const fillClientDialog = async (alias: string, value: string) => {
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByRole("textbox", { name: "Alias" }), { target: { value: alias } });
fireEvent.change(within(dialog).getByRole("textbox", { name: "Value" }), { target: { value } });
return dialog;
};
const addClient = async (alias: string, value: string) => {
await userEvent.click(screen.getByRole("button", { name: "Add client" }));
const dialog = await fillClientDialog(alias, value);
await userEvent.click(within(dialog).getByRole("button", { name: "Add" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
};
describe("MCPNetworkSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -82,25 +106,399 @@ describe("MCPNetworkSettings", () => {
expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument();
});
it("saves the configured ranges", async () => {
it("saves the configured ranges once they change", async () => {
vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45");
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8"] },
]);
renderSettings();
await userEvent.click(await screen.findByText("203.0.113.0/24"));
await userEvent.click(await screen.findByRole("button", { name: /Save/ }));
await waitFor(() =>
expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges", ["10.0.0.0/8"]),
expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges", [
"10.0.0.0/8",
"203.0.113.0/24",
]),
);
expect(deleteConfigFieldSetting).not.toHaveBeenCalled();
expect(deleteConfigFieldSetting).not.toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges");
});
it("clears the setting instead of saving an empty list", async () => {
it("clears a stored range setting instead of saving an empty list", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8"] },
]);
renderSettings();
await userEvent.click(await screen.findByRole("button", { name: /Save/ }));
await userEvent.click(await screen.findByRole("button", { name: "Remove 10.0.0.0/8" }));
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges"));
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
});
it("does not write settings that were never stored and are still empty", async () => {
renderSettings();
await userEvent.click(await screen.findByRole("button", { name: /Save/ }));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("MCP network settings saved"));
expect(deleteConfigFieldSetting).not.toHaveBeenCalled();
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
});
it("labels the section Allowed Clients and renders each stored client as a card showing alias and value", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_allowed_clients", field_value: [ANTIGRAVITY, CODEX] },
]);
renderSettings();
expect(await screen.findByText("Allowed Clients")).toBeVisible();
expect(screen.queryByText(/Allowed Client IDs/)).not.toBeInTheDocument();
expect(screen.queryByText(/Allowed Client Applications/)).not.toBeInTheDocument();
expect(clientCard("Antigravity CLI")).toHaveTextContent("antigravity-cli");
expect(clientCard("Codex")).toHaveTextContent("codex-mcp-client");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("opens an edit dialog when a client card is clicked, prefilled with that client's alias and value", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_allowed_clients", field_value: [ANTIGRAVITY, CODEX] },
]);
renderSettings();
await screen.findByText("Allowed Clients");
await userEvent.click(clientCard("Codex"));
const dialog = await screen.findByRole("dialog", { name: "Edit client" });
expect(within(dialog).getByRole("textbox", { name: "Alias" })).toHaveValue("Codex");
expect(within(dialog).getByRole("textbox", { name: "Value" })).toHaveValue("codex-mcp-client");
});
it("warns that a stored allowlist in the old plain-string shape denies every client and lets Save remove it", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_allowed_clients", field_value: ["antigravity-cli"] },
]);
renderSettings();
expect(await screen.findByText(/stored allowlist is not a list of alias and value pairs/)).toBeVisible();
expect(screen.queryByText("antigravity-cli")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_allowed_clients"));
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByText(/stored allowlist is not a list/)).not.toBeInTheDocument());
});
it("replaces a stored allowlist in the old plain-string shape with the clients the admin adds", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_allowed_clients", field_value: ["antigravity-cli"] },
]);
renderSettings();
await screen.findByText(/stored allowlist is not a list of alias and value pairs/);
await addClient(ANTIGRAVITY.alias, ANTIGRAVITY.value);
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
await waitFor(() =>
expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_allowed_clients", [ANTIGRAVITY]),
);
expect(deleteConfigFieldSetting).not.toHaveBeenCalledWith("tok", "mcp_allowed_clients");
await waitFor(() => expect(screen.queryByText(/stored allowlist is not a list/)).not.toBeInTheDocument());
});
it("treats a stored entry with an empty alias or value as denying every client, like the gateway does", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_allowed_clients", field_value: [ANTIGRAVITY, { alias: "", value: "claude-code" }] },
]);
renderSettings();
expect(await screen.findByText(/stored allowlist is not a list of alias and value pairs/)).toBeVisible();
expect(screen.queryByRole("button", { name: /^Antigravity CLI/ })).not.toBeInTheDocument();
});
it("adds clients as alias and value pairs and saves them under mcp_allowed_clients", async () => {
renderSettings();
await screen.findByText("Allowed Clients");
await addClient(" Antigravity CLI ", " antigravity-cli ");
await addClient("Codex", "codex-mcp-client");
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
await waitFor(() =>
expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_allowed_clients", [ANTIGRAVITY, CODEX]),
);
expect(deleteConfigFieldSetting).not.toHaveBeenCalledWith("tok", "mcp_allowed_clients");
});
it("edits a stored client's value through its dialog and saves the new value", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_allowed_clients", field_value: [ANTIGRAVITY] },
]);
renderSettings();
await screen.findByText("Allowed Clients");
await userEvent.click(clientCard("Antigravity CLI"));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByRole("textbox", { name: "Value" }), {
target: { value: " 0oa1b2c3d4e5f6g7h8i9 " },
});
await userEvent.click(within(dialog).getByRole("button", { name: "Done" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(clientCard("Antigravity CLI")).toHaveTextContent("0oa1b2c3d4e5f6g7h8i9");
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
await waitFor(() =>
expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_allowed_clients", [
{ alias: "Antigravity CLI", value: "0oa1b2c3d4e5f6g7h8i9" },
]),
);
});
it("keeps a stored client untouched when its dialog is cancelled", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_allowed_clients", field_value: [ANTIGRAVITY] },
]);
renderSettings();
await screen.findByText("Allowed Clients");
await userEvent.click(clientCard("Antigravity CLI"));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByRole("textbox", { name: "Value" }), { target: { value: "changed" } });
await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(clientCard("Antigravity CLI")).toHaveTextContent("antigravity-cli");
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("MCP network settings saved"));
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
});
it("will not add a client that has an alias but no value", async () => {
renderSettings();
await screen.findByText("Allowed Clients");
await userEvent.click(screen.getByRole("button", { name: "Add client" }));
const dialog = await fillClientDialog("Antigravity CLI", " ");
expect(within(dialog).getByRole("button", { name: "Add" })).toBeDisabled();
});
it("adds nothing when the add dialog is cancelled", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_allowed_clients", field_value: [ANTIGRAVITY] },
]);
renderSettings();
await screen.findByText("Allowed Clients");
await userEvent.click(screen.getByRole("button", { name: "Add client" }));
const dialog = await fillClientDialog("Codex", "codex-mcp-client");
await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.queryByText("Codex")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("MCP network settings saved"));
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
expect(deleteConfigFieldSetting).not.toHaveBeenCalled();
});
it("removes the right client from the middle of the list", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{
field_name: "mcp_allowed_clients",
field_value: [ANTIGRAVITY, { alias: "Claude Code", value: "claude-code" }, CODEX],
},
]);
renderSettings();
await screen.findByText("Allowed Clients");
await userEvent.click(clientCard("Claude Code"));
await userEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Remove client" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.queryByText("claude-code")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
await waitFor(() =>
expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_allowed_clients", [ANTIGRAVITY, CODEX]),
);
});
it("removes a client and clears the setting when the list becomes empty", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_allowed_clients", field_value: [{ alias: "Claude Code", value: "claude-code" }] },
]);
renderSettings();
await screen.findByText("Allowed Clients");
await userEvent.click(clientCard("Claude Code"));
await userEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Remove client" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.queryByText("claude-code")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_allowed_clients"));
expect(updateConfigFieldSetting).not.toHaveBeenCalledWith("tok", "mcp_allowed_clients", expect.anything());
});
it("warns that a stored empty allowlist denies every client and lets Save remove it", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([{ field_name: "mcp_allowed_clients", field_value: [] }]);
renderSettings();
expect(await screen.findByText(/An empty allowlist is currently stored, so every client is denied/)).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_allowed_clients"));
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByText(/An empty allowlist is currently stored/)).not.toBeInTheDocument());
});
it("does not show the deny-all warning when no allowlist is stored", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([{ field_name: "mcp_allowed_clients", field_value: null }]);
renderSettings();
await screen.findByText("Allowed Clients");
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"] },
{ field_name: "mcp_allowed_clients", field_value: [ANTIGRAVITY] },
]);
renderSettings();
await screen.findByText("Allowed Clients");
await addClient("Codex", "codex-mcp-client");
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
await waitFor(() =>
expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_allowed_clients", [ANTIGRAVITY, CODEX]),
);
expect(updateConfigFieldSetting).not.toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges", expect.anything());
expect(deleteConfigFieldSetting).not.toHaveBeenCalled();
});
it("still saves the allowed clients when the private range write fails, and reports the failure", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8"] },
]);
const rangeFailure = new Error("Field name=mcp_internal_ip_ranges not in config");
vi.mocked(deleteConfigFieldSetting).mockRejectedValue(rangeFailure);
renderSettings();
await userEvent.click(await screen.findByRole("button", { name: "Remove 10.0.0.0/8" }));
await addClient("Codex", "codex-mcp-client");
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
await waitFor(() => expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_allowed_clients", [CODEX]));
await waitFor(() => expect(toast.fromError).toHaveBeenCalledWith(rangeFailure));
expect(toast.success).not.toHaveBeenCalled();
});
it("writes the private ranges and the allowed clients one after the other, never concurrently", async () => {
vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45");
let finishRangeWrite: (() => void) | undefined;
vi.mocked(updateConfigFieldSetting).mockImplementation(
(_token, fieldName) =>
new Promise<void>((resolve) => {
if (fieldName === "mcp_internal_ip_ranges") {
finishRangeWrite = resolve;
} else {
resolve();
}
}),
);
renderSettings();
await userEvent.click(await screen.findByText("203.0.113.0/24"));
await addClient("Codex", "codex-mcp-client");
await userEvent.click(screen.getByRole("button", { name: /Save/ }));
await waitFor(() =>
expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges", ["203.0.113.0/24"]),
);
expect(updateConfigFieldSetting).not.toHaveBeenCalledWith("tok", "mcp_allowed_clients", expect.anything());
finishRangeWrite?.();
await waitFor(() => expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_allowed_clients", [CODEX]));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("MCP network settings saved"));
});
});

View file

@ -1,11 +1,21 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useId } from "react";
import { Save, Plus, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { DeprecationBanner } from "@/components/DeprecationBanner";
import { toast } from "@/lib/toast";
import {
getGeneralSettingsCall,
updateConfigFieldSetting,
@ -26,12 +36,142 @@ function ipToSlash24(ip: string): string {
return `${parts[0]}.${parts[1]}.${parts[2]}.0/24`;
}
export interface AllowedClient {
readonly alias: string;
readonly value: string;
}
interface AllowedClientRow extends AllowedClient {
readonly key: string;
}
interface ClientDraft extends AllowedClient {
readonly key: string | null;
}
const isAllowedClient = (entry: unknown): entry is AllowedClient => {
if (typeof entry !== "object" || entry === null) return false;
const { alias, value } = entry as Partial<Record<keyof AllowedClient, unknown>>;
return typeof alias === "string" && typeof value === "string" && !isIncomplete({ alias, value });
};
type StoredAllowlist =
| { readonly kind: "absent" }
| { readonly kind: "clients"; readonly clients: AllowedClient[] }
| { readonly kind: "malformed" };
const ABSENT: StoredAllowlist = { kind: "absent" };
const parseStoredClients = (fieldValue: unknown): StoredAllowlist => {
if (fieldValue === null || fieldValue === undefined) return ABSENT;
if (Array.isArray(fieldValue) && fieldValue.every(isAllowedClient)) {
return { kind: "clients", clients: fieldValue.map(({ alias, value }) => ({ alias, value })) };
}
return { kind: "malformed" };
};
let nextRowKey = 0;
const newRow = (client: AllowedClient): AllowedClientRow => ({ ...client, key: `client-${nextRowKey++}` });
const trimClient = ({ alias, value }: AllowedClient): AllowedClient => ({ alias: alias.trim(), value: value.trim() });
const isIncomplete = ({ alias, value }: AllowedClient) => alias === "" || value === "";
const sameList = (a: string[], b: string[]) => a.length === b.length && a.every((value, i) => value === b[i]);
const sameClients = (a: AllowedClient[], b: AllowedClient[]) =>
a.length === b.length && a.every((client, i) => client.alias === b[i].alias && client.value === b[i].value);
const unchangedSinceLoad = (value: string[], stored: string[] | null) =>
stored === null ? value.length === 0 : value.length > 0 && sameList(value, stored);
const clientsUnchangedSinceLoad = (value: AllowedClient[], stored: StoredAllowlist) => {
switch (stored.kind) {
case "absent":
return value.length === 0;
case "clients":
return value.length > 0 && sameClients(value, stored.clients);
case "malformed":
return false;
}
};
const headerUnchangedSinceLoad = (value: string, stored: string | null) =>
stored === null ? value === "" : value !== "" && value === stored;
interface AllowedClientDialogProps {
readonly draft: ClientDraft | null;
readonly onChange: (draft: ClientDraft) => void;
readonly onCommit: () => void;
readonly onRemove: () => void;
readonly onClose: () => void;
}
const AllowedClientDialog: React.FC<AllowedClientDialogProps> = ({ draft, onChange, onCommit, onRemove, onClose }) => {
const aliasId = useId();
const valueId = useId();
if (draft === null) return null;
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{draft.key === null ? "Add client" : "Edit client"}</DialogTitle>
<DialogDescription>
The alias is the name shown in the dashboard and gateway logs. The value is the exact JWT claim or header
value that identifies the client, such as the OAuth client ID your identity provider issues.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor={aliasId}>Alias</Label>
<Input
id={aliasId}
value={draft.alias}
placeholder="e.g. Coding CLI"
onChange={(e) => onChange({ ...draft, alias: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={valueId}>Value</Label>
<Input
id={valueId}
value={draft.value}
placeholder="e.g. 0oa1b2c3d4e5f6g7h8i9"
className="font-mono"
onChange={(e) => onChange({ ...draft, value: e.target.value })}
/>
</div>
</div>
<DialogFooter>
{draft.key !== null && (
<Button type="button" variant="destructive" className="sm:mr-auto" onClick={onRemove}>
Remove client
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="button" disabled={isIncomplete(trimClient(draft))} onClick={onCommit}>
{draft.key === null ? "Add" : "Done"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
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<AllowedClientRow[]>([]);
const [clientIdHeader, setClientIdHeader] = useState("");
const [storedRanges, setStoredRanges] = useState<string[] | null>(null);
const [storedClients, setStoredClients] = useState<StoredAllowlist>(ABSENT);
const [storedClientIdHeader, setStoredClientIdHeader] = useState<string | null>(null);
const [currentIp, setCurrentIp] = useState<string | null>(null);
const [rangeDraft, setRangeDraft] = useState("");
const [clientDraft, setClientDraft] = useState<ClientDraft | null>(null);
useEffect(() => {
loadSettings();
@ -44,8 +184,18 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
try {
const settings = await getGeneralSettingsCall(accessToken);
for (const field of settings) {
if (field.field_name === "mcp_internal_ip_ranges" && field.field_value) {
if (field.field_name === "mcp_internal_ip_ranges" && Array.isArray(field.field_value)) {
setPrivateRanges(field.field_value);
setStoredRanges(field.field_value);
}
if (field.field_name === "mcp_allowed_clients") {
const stored = parseStoredClients(field.field_value);
setAllowedClients(stored.kind === "clients" ? stored.clients.map(newRow) : []);
setStoredClients(stored);
}
if (field.field_name === "mcp_client_id_header" && typeof field.field_value === "string") {
setClientIdHeader(field.field_value);
setStoredClientIdHeader(field.field_value);
}
}
} catch (error) {
@ -63,20 +213,56 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
}
};
const persistRanges = async (token: string) => {
if (unchangedSinceLoad(privateRanges, storedRanges)) return;
if (privateRanges.length > 0) {
await updateConfigFieldSetting(token, "mcp_internal_ip_ranges", privateRanges);
setStoredRanges(privateRanges);
return;
}
await deleteConfigFieldSetting(token, "mcp_internal_ip_ranges");
setStoredRanges(null);
};
const persistAllowedClients = async (token: string) => {
const clients = allowedClients.map(({ alias, value }) => ({ alias, value }));
if (clientsUnchangedSinceLoad(clients, storedClients)) return;
if (clients.length > 0) {
await updateConfigFieldSetting(token, "mcp_allowed_clients", clients);
setStoredClients({ kind: "clients", clients });
return;
}
await deleteConfigFieldSetting(token, "mcp_allowed_clients");
setStoredClients(ABSENT);
};
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);
try {
if (privateRanges.length > 0) {
await updateConfigFieldSetting(accessToken, "mcp_internal_ip_ranges", privateRanges);
} else {
await deleteConfigFieldSetting(accessToken, "mcp_internal_ip_ranges");
}
} catch (error) {
console.error("Failed to save MCP network settings:", error);
} finally {
setSaving(false);
const [rangeResult] = await Promise.allSettled([persistRanges(accessToken)]);
const [clientResult] = await Promise.allSettled([persistAllowedClients(accessToken)]);
const [headerResult] = await Promise.allSettled([persistClientIdHeader(accessToken)]);
setSaving(false);
const failures = [rangeResult, clientResult, headerResult].filter(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (failures.length === 0) {
toast.success("MCP network settings saved");
return;
}
failures.forEach((failure) => toast.fromError(failure.reason));
};
const addSuggestedRange = (range: string) => {
@ -86,17 +272,37 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
};
// Commas separate entries, matching the old tokenised input.
const commitDraft = () => {
const added = rangeDraft
const splitDraft = (draft: string, existing: string[]) =>
draft
.split(",")
.map((r) => r.trim())
.filter((r) => r !== "" && !privateRanges.includes(r));
.filter((r) => r !== "" && !existing.includes(r));
const commitDraft = () => {
const added = splitDraft(rangeDraft, privateRanges);
if (added.length > 0) {
setPrivateRanges([...privateRanges, ...added]);
}
setRangeDraft("");
};
const commitClientDraft = () => {
if (clientDraft === null) return;
const client = trimClient(clientDraft);
setAllowedClients(
clientDraft.key === null
? [...allowedClients, newRow(client)]
: allowedClients.map((row) => (row.key === clientDraft.key ? { ...row, ...client } : row)),
);
setClientDraft(null);
};
const removeDraftedClient = () => {
if (clientDraft === null) return;
setAllowedClients(allowedClients.filter((row) => row.key !== clientDraft.key));
setClientDraft(null);
};
if (loading) {
return (
<div className="flex justify-center py-12">
@ -106,6 +312,8 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
}
const suggestedRange = currentIp ? ipToSlash24(currentIp) : null;
const storedAllowlistIsMalformed = storedClients.kind === "malformed";
const storedAllowlistIsEmpty = storedClients.kind === "clients" && storedClients.clients.length === 0;
return (
<div className="space-y-6 p-4">
@ -178,12 +386,88 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
</p>
</Card>
<div>
<p className="text-lg font-semibold">Allowed Clients</p>
<p className="mt-1 text-sm text-muted-foreground">
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">
{storedAllowlistIsMalformed && (
<p className="mb-2 text-sm text-destructive">
The stored allowlist is not a list of alias and value pairs, so every client is denied. Add the clients you
want and save to replace it, or save with the list empty to remove it and allow every client again.
</p>
)}
{storedAllowlistIsEmpty && (
<p className="mb-2 text-sm text-destructive">
An empty allowlist is currently stored, so every client is denied. Save with the list empty to remove it and
allow every client again.
</p>
)}
{allowedClients.length > 0 && (
<div className="mb-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{allowedClients.map((row) => (
<button
key={row.key}
type="button"
className="flex min-w-0 flex-col items-start gap-1 rounded-lg border border-border bg-background p-3 text-left hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
onClick={() => setClientDraft(row)}
>
<span className="w-full truncate text-sm font-medium">{row.alias}</span>
<span className="w-full truncate font-mono text-xs text-muted-foreground">{row.value}</span>
</button>
))}
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setClientDraft({ key: null, alias: "", value: "" })}
>
<Plus />
Add client
</Button>
<p className="mt-2 text-xs text-muted-foreground">
Click a client to edit or remove it. Leave the list empty to allow every client. Every MCP request from an
unlisted 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>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
<Save />
Save
</Button>
</div>
<AllowedClientDialog
draft={clientDraft}
onChange={setClientDraft}
onCommit={commitClientDraft}
onRemove={removeDraftedClient}
onClose={() => setClientDraft(null)}
/>
</div>
);
};

View file

@ -26927,6 +26927,16 @@ export interface components {
* @description Maximum retention period for spend logs (e.g., '7d' for 7 days). Logs older than this will be deleted.
*/
maximum_spend_logs_retention_period?: string | null;
/**
* Mcp Allowed Clients
* @description MCP client applications admitted by the gateway, each an {alias, value} pair where alias is the name shown in the dashboard and logs and value is the identity that must match exactly. When set, every MCP request must carry a client identity equal to one of the values: 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?: components["schemas"]["MCPAllowedClient"][] | 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).
@ -32563,6 +32573,22 @@ export interface components {
*/
status?: "healthy" | "unhealthy";
};
/**
* MCPAllowedClient
* @description One entry of `general_settings.mcp_allowed_clients`.
*/
MCPAllowedClient: {
/**
* Alias
* @description Human-readable name for this client application, shown in the dashboard and in gateway logs.
*/
alias: string;
/**
* Value
* @description Exact value of the JWT claim named in litellm_jwtauth.mcp_client_id_jwt_field, or of the mcp_client_id_header header, that identifies this client application. Matched case-sensitively.
*/
value: string;
};
/** MCPConnectorEntry */
MCPConnectorEntry: {
/** Args */