Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/client_allowlist.py: 47%
96 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""
2Gateway-level allowlist of MCP client applications (``general_settings.mcp_allowed_clients``).
4Each entry pairs an admin-chosen ``alias`` (shown in the dashboard and logs) with the ``value`` that
5identifies the client. Only the value is compared, exactly and case-sensitively.
6A caller that authenticated with a JWT is identified by the claim named in
7``litellm_jwtauth.mcp_client_id_jwt_field``, a value asserted by the identity provider.
8Every other caller is identified by the header named in ``general_settings.mcp_client_id_header``,
9which the client picks itself, so that source is a policy control rather than a security boundary.
10While the allowlist is set, a caller with no usable identity source is rejected.
11"""
13from collections.abc import Mapping
14from dataclasses import dataclass
15from types import MappingProxyType
16from typing import Final, Literal
18from pydantic import TypeAdapter, ValidationError
19from typing_extensions import ReadOnly, TypedDict
21from litellm._logging import verbose_logger
22from litellm.litellm_core_utils.dot_notation_indexing import get_nested_value
23from litellm.types.mcp import MCPAllowedClient
25MCP_ALLOWED_CLIENTS_SETTING: Final = "mcp_allowed_clients"
26MCP_CLIENT_ID_HEADER_SETTING: Final = "mcp_client_id_header"
27MCP_CLIENT_ID_JWT_FIELD_SETTING: Final = "mcp_client_id_jwt_field"
28_JWT_AUTH_SETTING: Final = "litellm_jwtauth"
30_ALLOWED_CLIENTS_ADAPTER: Final[TypeAdapter[list[MCPAllowedClient]]] = TypeAdapter(list[MCPAllowedClient])
31_OPTIONAL_NAME_ADAPTER: Final[TypeAdapter[str | None]] = TypeAdapter(str | None)
32_OPTIONAL_MAPPING_ADAPTER: Final[TypeAdapter[dict[str, object] | None]] = TypeAdapter(dict[str, object] | None)
33_NOBODY: Final[Mapping[str, str]] = MappingProxyType({})
36class MCPClientForbiddenBody(TypedDict):
37 error: ReadOnly[Literal["Forbidden"]]
38 details: ReadOnly[str]
41@dataclass(frozen=True, slots=True)
42class MCPClientAllowlist:
43 """``aliases_by_value`` maps each admitted identity value to the alias the admin gave it."""
45 aliases_by_value: Mapping[str, str]
46 jwt_field: str | None
47 header: str | None
50@dataclass(frozen=True, slots=True)
51class MCPClientIdentity:
52 client_id: str
53 source: Literal["jwt", "header"]
54 source_name: str
56 @property
57 def description(self) -> str:
58 return f"'{self.client_id}' (from {'JWT claim' if self.source == 'jwt' else 'header'} '{self.source_name}')"
61@dataclass(frozen=True, slots=True)
62class MCPClientRejection:
63 details: str
65 @property
66 def response_body(self) -> MCPClientForbiddenBody:
67 body: Final[MCPClientForbiddenBody] = {"error": "Forbidden", "details": self.details}
68 return body
71def _unidentified_rejection(reason: str) -> MCPClientRejection:
72 return MCPClientRejection(
73 details=f"{reason} This gateway only admits client applications listed in {MCP_ALLOWED_CLIENTS_SETTING}."
74 )
77def parse_allowed_mcp_clients(raw_setting: object) -> Mapping[str, str] | None:
78 """Value-to-alias mapping; None when the setting is absent (not enforced). A malformed setting admits nobody."""
79 if raw_setting is None: 79 ↛ 81line 79 didn't jump to line 81 because the condition on line 79 was always true
80 return None
81 try:
82 clients: Final = _ALLOWED_CLIENTS_ADAPTER.validate_python(raw_setting)
83 except ValidationError:
84 verbose_logger.warning(
85 "%s is not a list of {alias, value} entries (%r); rejecting every MCP client until it is fixed",
86 MCP_ALLOWED_CLIENTS_SETTING,
87 raw_setting,
88 )
89 return _NOBODY
90 return MappingProxyType({client.value: client.alias for client in clients})
93def _parse_optional_name(setting_name: str, raw_setting: object) -> str | None:
94 try:
95 name: Final = _OPTIONAL_NAME_ADAPTER.validate_python(raw_setting)
96 except ValidationError:
97 verbose_logger.warning("%s is not a string (%r); ignoring it", setting_name, raw_setting)
98 return None
99 return name or None
102def _jwt_field_from_general_settings(general_settings: Mapping[str, object]) -> str | None:
103 try:
104 jwt_auth: Final = _OPTIONAL_MAPPING_ADAPTER.validate_python(general_settings.get(_JWT_AUTH_SETTING))
105 except ValidationError:
106 return None
107 if jwt_auth is None:
108 return None
109 return _parse_optional_name(
110 f"{_JWT_AUTH_SETTING}.{MCP_CLIENT_ID_JWT_FIELD_SETTING}", jwt_auth.get(MCP_CLIENT_ID_JWT_FIELD_SETTING)
111 )
114def load_mcp_client_allowlist(general_settings: Mapping[str, object]) -> MCPClientAllowlist | None:
115 """None when ``mcp_allowed_clients`` is unset, which admits every client."""
116 allowed_clients: Final = parse_allowed_mcp_clients(general_settings.get(MCP_ALLOWED_CLIENTS_SETTING))
117 if allowed_clients is None: 117 ↛ 119line 117 didn't jump to line 119 because the condition on line 117 was always true
118 return None
119 header: Final = _parse_optional_name(
120 MCP_CLIENT_ID_HEADER_SETTING, general_settings.get(MCP_CLIENT_ID_HEADER_SETTING)
121 )
122 return MCPClientAllowlist(
123 aliases_by_value=allowed_clients,
124 jwt_field=_jwt_field_from_general_settings(general_settings),
125 header=header.lower() if header is not None else None,
126 )
129def resolve_mcp_client_identity(
130 allowlist: MCPClientAllowlist,
131 jwt_claims: Mapping[str, object] | None,
132 headers: Mapping[str, str],
133) -> MCPClientIdentity | MCPClientRejection:
134 """A JWT caller is identified by its configured claim alone, so a header can never override the IdP."""
135 if jwt_claims is not None and allowlist.jwt_field is not None:
136 claim: Final[object] = get_nested_value(data=jwt_claims, key_path=allowlist.jwt_field)
137 if isinstance(claim, str) and claim:
138 return MCPClientIdentity(client_id=claim, source="jwt", source_name=allowlist.jwt_field)
139 return _unidentified_rejection(
140 f"The JWT presented has no '{allowlist.jwt_field}' claim naming the client application."
141 )
142 if allowlist.header is None:
143 configured: Final = (
144 f"litellm_jwtauth.{MCP_CLIENT_ID_JWT_FIELD_SETTING} for JWT callers or {MCP_CLIENT_ID_HEADER_SETTING}"
145 )
146 return _unidentified_rejection(
147 f"No client identity source is configured for this request; set {configured} in general_settings."
148 )
149 header_value: Final = headers.get(allowlist.header)
150 if header_value:
151 return MCPClientIdentity(client_id=header_value, source="header", source_name=allowlist.header)
152 return _unidentified_rejection(f"The request has no '{allowlist.header}' header naming the client application.")
155def check_mcp_client_allowed(
156 allowlist: MCPClientAllowlist | None,
157 jwt_claims: Mapping[str, object] | None,
158 headers: Mapping[str, str],
159) -> MCPClientRejection | None:
160 if allowlist is None: 160 ↛ 162line 160 didn't jump to line 162 because the condition on line 160 was always true
161 return None
162 identity: Final = resolve_mcp_client_identity(allowlist, jwt_claims, headers)
163 if isinstance(identity, MCPClientRejection):
164 return identity
165 alias: Final = allowlist.aliases_by_value.get(identity.client_id)
166 if alias is None:
167 return MCPClientRejection(
168 details=f"MCP client {identity.description} is not listed in this gateway's {MCP_ALLOWED_CLIENTS_SETTING}."
169 )
170 verbose_logger.debug("Admitted MCP client '%s' identified as %s", alias, identity.description)
171 return None