mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
Merge pull request #41667 from BerriAI/litellm_mcp_client_allowlist
feat(mcp): allowlist MCP client applications at the gateway
This commit is contained in:
commit
6d8a960e1d
14 changed files with 1556 additions and 24 deletions
|
|
@ -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)
|
||||
|
|
|
|||
171
litellm/proxy/_experimental/mcp_server/client_allowlist.py
Normal file
171
litellm/proxy/_experimental/mcp_server/client_allowlist.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
26
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
26
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue