Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/utils.py: 44%
422 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"""
2MCP Server Utilities
3"""
5import hashlib
6import importlib
7import json
8import os
9import re
10import typing
11from collections.abc import Iterable, Iterator, Mapping, MutableMapping, MutableSequence, Sequence
12from collections.abc import Set as AbstractSet
13from typing import Any, Final, Protocol
14from urllib.parse import quote
16from litellm._logging import verbose_logger
17from litellm.types.mcp_server.mcp_server_manager import MCPServer
19if typing.TYPE_CHECKING: 19 ↛ 20line 19 didn't jump to line 20 because the condition on line 19 was never true
20 from fastapi import Request
23class _McpServerLike(Protocol):
24 @property
25 def server_id(self) -> str: ... 25 ↛ exitline 25 didn't return from function 'server_id' because
26 @property
27 def server_name(self) -> str | None: ... 27 ↛ exitline 27 didn't return from function 'server_name' because
28 @property
29 def alias(self) -> str | None: ... 29 ↛ exitline 29 didn't return from function 'alias' because
30 @property
31 def short_prefix(self) -> str | None: ... 31 ↛ exitline 31 didn't return from function 'short_prefix' because
34class McpServerPayloadLike(Protocol):
35 alias: str | None
37 @property
38 def server_name(self) -> str | None: ... 38 ↛ exitline 38 didn't return from function 'server_name' because
39 @property
40 def tool_name_to_display_name(self) -> Mapping[str, str] | None: ... 40 ↛ exitline 40 didn't return from function 'tool_name_to_display_name' because
43# Constants
44#
45# NOTE: The environment-backed values below are read once, when this module is
46# first imported, and cached for the lifetime of the process. Changing the
47# corresponding environment variables after import has no effect unless the
48# module is reloaded (e.g. ``importlib.reload``). Tests that override these
49# variables must reload this module — see
50# ``tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_identity_env.py``.
51LITELLM_MCP_SERVER_NAME: Final = os.environ.get("LITELLM_MCP_SERVER_NAME", "litellm-mcp-server")
52LITELLM_MCP_SERVER_VERSION: Final = "1.0.0"
53LITELLM_MCP_SERVER_DESCRIPTION: Final = os.environ.get("LITELLM_MCP_SERVER_DESCRIPTION", "MCP Server for LiteLLM")
54MCP_TOOL_PREFIX_SEPARATOR: Final = os.environ.get("MCP_TOOL_PREFIX_SEPARATOR", "-")
55MCP_TOOL_PREFIX_FORMAT: Final = "{server_name}{separator}{tool_name}"
57# ---------------------------------------------------------------------------
58# Short-ID tool prefix (opt-in)
59# ---------------------------------------------------------------------------
60# When LITELLM_USE_SHORT_MCP_TOOL_PREFIX is truthy the prefix attached to MCP
61# tool / prompt / resource / resource-template names switches from the
62# (potentially long) human-readable server name to a deterministic three
63# character ID derived from the server's ``server_id``.
64#
65# Why three characters?
66# * The first character is restricted to 52 alphabetic characters
67# ([A-Za-z]) and the remaining two characters use the full base62
68# alphabet ([0-9A-Za-z]). That guarantees the prefix never starts
69# with a digit so it remains a valid identifier for every model API
70# (some providers historically required a leading alphabetic char).
71# * 52 * 62 * 62 = 199_888 distinct IDs. The chance of a real local
72# tool name happening to begin with the exact prefix LiteLLM assigned
73# to a given MCP server is negligible in practice.
74# * The IDs are short enough that prefixed tool names stay well under
75# the 60-character upper bound enforced by some model APIs (Anthropic
76# etc.) even for long upstream tool names.
77# * The mapping is deterministic (SHA-256 of ``server_id`` → three
78# characters drawn from the alphabets above), so the prefix is stable
79# across processes, workers and restarts without any persistence
80# layer. Two servers with different ``server_id`` values can in
81# principle hash to the same three chars; that natural-hash collision
82# IS a routing-correctness issue (the second registrant would otherwise
83# have its tools misrouted to the first), so registration goes through
84# ``MCPServerManager._assign_unique_short_prefix`` which rehashes with
85# a deterministic attempt counter until it finds an unused prefix and
86# caches the result on ``MCPServer.short_prefix``. A collision is
87# logged at INFO when it happens.
88#
89# This flag is intentionally opt-in for the first release so customers can
90# migrate. It will become the default in a future release.
91SHORT_MCP_TOOL_PREFIX_LENGTH: Final = 3
92_BASE62_ALPHABET: Final = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
93# Subset of _BASE62_ALPHABET used for the *first* character only, to
94# guarantee the prefix never starts with a digit.
95_BASE52_ALPHA_ALPHABET: Final = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
98def is_short_mcp_tool_prefix_enabled() -> bool:
99 """Return True when the short-ID tool prefix mode is enabled.
101 Read at call time (not import time) so tests and runtime config changes
102 take effect without reimporting the module.
103 """
104 raw: Final = os.environ.get("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "")
105 return raw.strip().lower() in ("1", "true", "yes", "on")
108def compute_short_server_prefix(server_id: str, attempt: int = 0) -> str:
109 """Derive the deterministic three-character prefix for a server.
111 Uses SHA-256 of ``f"{server_id}#{attempt}"`` and folds the first eight
112 bytes into a fixed-length string whose first character is drawn from
113 ``_BASE52_ALPHA_ALPHABET`` (so the prefix never starts with a digit)
114 and whose remaining characters are drawn from the full base62
115 alphabet. Pass ``attempt > 0`` to rehash to a different prefix when
116 the natural hash collides with a prefix already assigned to another
117 server (see ``MCPServerManager._assign_unique_short_prefix``). An
118 empty ``server_id`` raises ``ValueError`` — short prefixes require a
119 stable identifier to be deterministic.
120 """
121 if not server_id: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true
122 raise ValueError("compute_short_server_prefix requires a non-empty server_id")
124 seed: Final = server_id if attempt == 0 else f"{server_id}#{attempt}"
125 digest: Final = hashlib.sha256(seed.encode("utf-8")).digest()
126 value = int.from_bytes(digest[:8], "big")
128 # Build chars from least-significant to most-significant; we reverse
129 # at the end so the first emitted char comes from the high-order
130 # bits of the digest (which is the position we constrain to be
131 # alphabetic).
132 chars: Final[list[str]] = []
133 for position in range(SHORT_MCP_TOOL_PREFIX_LENGTH):
134 is_first_char = position == SHORT_MCP_TOOL_PREFIX_LENGTH - 1
135 alphabet = _BASE52_ALPHA_ALPHABET if is_first_char else _BASE62_ALPHABET
136 value, idx = divmod(value, len(alphabet))
137 chars.append(alphabet[idx])
138 return "".join(reversed(chars))
141def is_mcp_available() -> bool:
142 """
143 Returns True if the MCP module is available, False otherwise
144 """
145 try:
146 importlib.import_module("mcp")
147 return True
148 except ImportError:
149 return False
152def normalize_server_name(server_name: str) -> str:
153 """
154 Normalize server name by replacing spaces with underscores
155 """
156 return server_name.replace(" ", "_")
159_MCP_ALIAS_HEADER_INVALID_RE: Final = re.compile(r"[^a-z0-9_]")
162def sanitize_mcp_alias_for_header(alias: str) -> str:
163 """
164 Sanitize an MCP server alias for x-mcp-{alias}-{header} HTTP headers.
166 Must stay in sync with ui/litellm-dashboard/src/utils/mcpHeaderUtils.ts.
167 """
168 sanitized = _MCP_ALIAS_HEADER_INVALID_RE.sub("_", alias.lower().strip())
169 sanitized = re.sub(r"_+", "_", sanitized)
170 return sanitized.strip("_")
173def _header_keys_for_identifier(identifier: str) -> tuple[str, ...]:
174 lowered: Final = identifier.lower()
175 sanitized: Final = sanitize_mcp_alias_for_header(identifier)
176 return (lowered,) if not sanitized or sanitized == lowered else (lowered, sanitized)
179def _matching_header_key(normalized_headers: Mapping[str, object], identifier: str) -> str | None:
180 return next((key for key in _header_keys_for_identifier(identifier) if key in normalized_headers), None)
183def lookup_mcp_server_auth_in_headers(
184 mcp_server_auth_headers: Mapping[str, str | dict[str, str]],
185 *,
186 alias: str | None = None,
187 server_name: str | None = None,
188 access_groups: Sequence[str] | None = None,
189) -> str | dict[str, str] | None:
190 """
191 Resolve server-specific auth headers with case-insensitive matching.
193 Tries the raw alias/server_name (lowercased) and the header-safe sanitized
194 alias so dashboard clients using sanitize_mcp_alias_for_header() still match.
196 When no server-level header matches, an ``x-mcp-{access_group}-*`` header is
197 used as the default for every server in that group. If the server belongs to
198 several groups that each carry a different credential, nothing is returned so
199 a token is never forwarded to a server it may not have been meant for.
200 """
201 if not mcp_server_auth_headers:
202 return None
204 normalized_headers: Final = {k.lower(): v for k, v in mcp_server_auth_headers.items()}
206 server_keys: Final = (
207 _matching_header_key(normalized_headers, identifier) for identifier in (alias, server_name) if identifier
208 )
209 server_key: Final = next((key for key in server_keys if key is not None), None)
210 if server_key is not None:
211 return normalized_headers[server_key]
213 group_keys: Final = (_matching_header_key(normalized_headers, group) for group in access_groups or ())
214 group_matches: Final = tuple(normalized_headers[key] for key in group_keys if key is not None)
215 if not group_matches:
216 return None
217 if any(match != group_matches[0] for match in group_matches[1:]):
218 verbose_logger.debug(
219 "Ambiguous MCP group auth headers for server alias=%s (groups=%s); not forwarding any group credential",
220 alias,
221 access_groups,
222 )
223 return None
224 return group_matches[0]
227MCP_TOOL_ALLOWLIST_ENFORCED_KEY: Final = "tool_allowlist_enforced"
230def _parse_mcp_info_dict(mcp_info: object) -> Mapping[str, object] | None:
231 if mcp_info is None: 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true
232 return None
233 if isinstance(mcp_info, dict): 233 ↛ 235line 233 didn't jump to line 235 because the condition on line 233 was always true
234 return mcp_info
235 if isinstance(mcp_info, str):
236 try:
237 parsed: Final[object] = json.loads(mcp_info)
238 except (ValueError, TypeError):
239 return None
240 return parsed if isinstance(parsed, dict) else None
241 return None
244def is_server_tool_allowlist_enforced(mcp_server: object) -> bool:
245 mcp_info: Final = _parse_mcp_info_dict(getattr(mcp_server, "mcp_info", None))
246 if not mcp_info: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true
247 return False
248 return bool(mcp_info.get(MCP_TOOL_ALLOWLIST_ENFORCED_KEY))
251def server_applies_tool_allowlist(mcp_server: object) -> bool:
252 """Whether server-level allowed_tools whitelist filtering is active."""
253 allowed_tools: Final[object] = getattr(mcp_server, "allowed_tools", None) or []
254 return is_server_tool_allowlist_enforced(mcp_server) or bool(allowed_tools)
257def validate_and_normalize_mcp_server_payload(payload: McpServerPayloadLike) -> None:
258 """
259 Validate and normalize MCP server payload fields (server_name, alias, and
260 tool_name_to_display_name).
262 This function:
263 1. Validates that server_name and alias don't contain the MCP_TOOL_PREFIX_SEPARATOR
264 2. Validates that tool_name_to_display_name values satisfy Bedrock's tool-name pattern
265 3. Normalizes alias by replacing spaces with underscores
266 4. Sets default alias if not provided (using server_name as base)
268 Args:
269 payload: The payload object containing server_name and alias fields
271 Raises:
272 HTTPException: If validation fails
273 """
274 # Server name validation: disallow '-'
275 if hasattr(payload, "server_name") and payload.server_name:
276 validate_mcp_server_name(payload.server_name, raise_http_exception=True)
278 # Alias validation: disallow '-'
279 if hasattr(payload, "alias") and payload.alias:
280 validate_mcp_server_name(payload.alias, raise_http_exception=True)
282 # Tool display name validation: must satisfy Bedrock's tool-name pattern
283 if hasattr(payload, "tool_name_to_display_name") and payload.tool_name_to_display_name:
284 validate_tool_display_names(payload.tool_name_to_display_name)
286 # Alias normalization and defaulting
287 alias: str | None = getattr(payload, "alias", None)
288 server_name: Final[str | None] = getattr(payload, "server_name", None)
290 if not alias and server_name:
291 alias = normalize_server_name(server_name)
292 elif alias:
293 alias = normalize_server_name(alias)
295 # Update the payload with normalized alias
296 if hasattr(payload, "alias"): 296 ↛ exitline 296 didn't return from function 'validate_and_normalize_mcp_server_payload' because the condition on line 296 was always true
297 payload.alias = alias
300def add_server_prefix_to_name(name: str, server_name: str) -> str:
301 """Add server name prefix to any MCP resource name."""
302 formatted_server_name: Final = normalize_server_name(server_name)
304 return MCP_TOOL_PREFIX_FORMAT.format(
305 server_name=formatted_server_name,
306 separator=MCP_TOOL_PREFIX_SEPARATOR,
307 tool_name=name,
308 )
311def get_server_prefix(server: object) -> str:
312 """Return the prefix for a server.
314 When the short-prefix mode is enabled (``LITELLM_USE_SHORT_MCP_TOOL_PREFIX``)
315 a three-character base62 ID is returned. We prefer the cached
316 ``server.short_prefix`` value when set — that field is populated at
317 registration time by ``MCPServerManager._assign_unique_short_prefix``
318 and resolves natural-hash collisions deterministically — and only fall
319 back to the natural hash for ad-hoc / temp-server objects without a
320 cached value. In default mode the historical behaviour is preserved:
321 alias if present, else server_name, else server_id.
322 """
323 if is_short_mcp_tool_prefix_enabled(): 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true
324 cached: Final[str | None] = getattr(server, "short_prefix", None)
325 if cached:
326 return cached
327 server_id: Final[str | None] = getattr(server, "server_id", None)
328 if server_id:
329 return compute_short_server_prefix(server_id)
331 alias: Final[str | None] = getattr(server, "alias", None)
332 if alias: 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true
333 return alias
334 server_name: Final[str | None] = getattr(server, "server_name", None)
335 if server_name: 335 ↛ 336line 335 didn't jump to line 336 because the condition on line 335 was never true
336 return server_name
337 if hasattr(server, "server_id"): 337 ↛ 340line 337 didn't jump to line 340 because the condition on line 337 was always true
338 fallback_server_id: Final[str] = getattr(server, "server_id", "")
339 return fallback_server_id
340 return ""
343def iter_known_server_prefixes(server: _McpServerLike) -> Iterator[str]:
344 """Yield every prefix form that may appear in tool names for ``server``.
346 Always includes the *current* prefix returned by ``get_server_prefix``.
347 Additionally yields the historical (alias / server_name / server_id) and
348 short-ID forms so the routing layer can resolve tool names regardless of
349 which prefix mode was active when the client first observed them.
350 """
351 seen: Final = set()
353 def _emit(value: str | None) -> Iterator[str]:
354 if value and value not in seen:
355 seen.add(value)
356 yield value
358 yield from _emit(get_server_prefix(server))
359 yield from _emit(getattr(server, "short_prefix", None))
361 server_id: Final[str | None] = getattr(server, "server_id", None)
362 if server_id: 362 ↛ 368line 362 didn't jump to line 368 because the condition on line 362 was always true
363 try:
364 yield from _emit(compute_short_server_prefix(server_id))
365 except ValueError:
366 pass
368 yield from _emit(getattr(server, "alias", None))
369 yield from _emit(getattr(server, "server_name", None))
370 yield from _emit(server_id)
373def iter_known_tool_name_spellings(tool_name: str, server: MCPServer) -> Iterator[str]:
374 """Yield every name that denotes the bare ``tool_name`` on ``server``: the bare name,
375 then its wire spelling under each prefix ``iter_known_server_prefixes`` accepts.
376 ``get_server_prefix`` covers only the currently published one, and that moves with the
377 alias and with ``LITELLM_USE_SHORT_MCP_TOOL_PREFIX``.
378 """
379 yield tool_name
380 for prefix in iter_known_server_prefixes(server):
381 yield add_server_prefix_to_name(tool_name, prefix)
384def openapi_tool_name(operation_id: str) -> str:
385 """Return the tool name ``_register_openapi_tools`` registers ``operation_id`` under.
387 The single transform between a spec's operationId and the name the gateway serves.
388 Policy recovers the link by replaying this exact function, which is what keeps it from
389 deciding for a tool it does not name: two operationIds that register as two tools
390 necessarily normalize to two names here, because this is the map that registered them.
391 """
392 return operation_id.replace(" ", "_").lower()
395def match_known_tool_name(tool_name: str, server: MCPServer, names: Iterable[str]) -> str | None:
396 """Return the entry of ``names`` that denotes ``tool_name`` on ``server``, else ``None``.
398 The single question every tool-name-keyed site asks: the allow list, the deny list,
399 ``allowed_params`` and the discovery filter, so discovery hides exactly what dispatch
400 refuses. It spans every spelling routing accepts and no more, because a tool's identity
401 is the exact name routing dispatches; anything looser lets one policy decide two tools.
403 On an OpenAPI server the configured entry holds the spec's operationId while routing
404 holds :func:`openapi_tool_name` of it, so both sides go through that map first. Doing it
405 with the registering function rather than a lookalike is the whole safety argument: a
406 coarser one collapses operationIds that registration keeps apart.
408 Callers read the returned entry rather than testing a container's values, which is what
409 stops an explicitly empty ``allowed_params`` list from reading as "nothing configured".
410 """
411 normalize: Final = openapi_tool_name if getattr(server, "spec_path", None) else str
412 spellings: Final = {normalize(spelling) for spelling in iter_known_tool_name_spellings(tool_name, server)}
413 return next((name for name in names if normalize(name) in spellings), None)
416def split_server_prefix_from_name(prefixed_name: str) -> tuple[str, str]:
417 """Return the unprefixed name plus the server name used as prefix.
419 Cuts at the FIRST separator, so the two halves are only trustworthy as a
420 pair: they reassemble into ``prefixed_name`` exactly, which is what makes
421 this safe for routing. Reading one half on its own is a guess about where the
422 boundary fell, and that guess is wrong whenever the prefix itself contains
423 the separator. Callers that compare a half against configuration must use
424 :func:`match_known_server_prefix` or :func:`strip_known_server_prefix`.
425 """
426 if MCP_TOOL_PREFIX_SEPARATOR in prefixed_name:
427 parts: Final = prefixed_name.split(MCP_TOOL_PREFIX_SEPARATOR, 1)
428 if len(parts) == 2:
429 return parts[1], parts[0]
430 return prefixed_name, ""
433def match_known_server_prefix(name: str, known_prefixes: Iterable[str]) -> tuple[str, str] | None:
434 """Return ``(matched_prefix, bare_name)`` when ``name`` carries a known prefix.
436 Candidates are normalized and tried LONGEST first, so a prefix that itself
437 contains :data:`MCP_TOOL_PREFIX_SEPARATOR` (the UUID ``server_id`` used when
438 a server has no alias, or a legacy hyphenated alias) wins over a shorter
439 prefix that is merely its leading segment. Returns ``None`` when no candidate
440 matches, i.e. ``name`` carries none of these prefixes.
441 """
442 candidates: Final = sorted(
443 {normalize_server_name(prefix) for prefix in known_prefixes if prefix},
444 key=len,
445 reverse=True,
446 )
447 for prefix in candidates:
448 separator_suffixed = prefix + MCP_TOOL_PREFIX_SEPARATOR
449 if name.startswith(separator_suffixed):
450 return prefix, name[len(separator_suffixed) :]
451 return None
454def strip_known_server_prefix(name: str, server: _McpServerLike | None) -> str:
455 """Strip ``server``'s registered prefix from a prefixed tool/resource name.
457 Unlike :func:`split_server_prefix_from_name`, which guesses the boundary at
458 the first separator, this removes exactly ``{known_prefix}{separator}`` for
459 one of the server's actual registered prefixes. It therefore stays correct
460 when a prefix itself contains the separator (e.g. the UUID ``server_id``
461 used as the fallback prefix when a server has no alias, or a legacy
462 hyphenated alias), where the first-separator split would cut inside the
463 prefix and never match the stored bare tool name.
465 Returns ``name`` unchanged when ``server`` is known but none of its prefixes
466 match (the name is already unprefixed). Falls back to the legacy split only
467 when ``server`` is ``None``.
468 """
469 if server is None:
470 return split_server_prefix_from_name(name)[0]
471 matched: Final = match_known_server_prefix(name, iter_known_server_prefixes(server))
472 return name if matched is None else matched[1]
475def is_tool_name_prefixed(
476 tool_name: str,
477 known_server_prefixes: AbstractSet[str] | None = None,
478) -> bool:
479 """
480 Check if tool name has a known MCP server prefix.
482 When ``known_server_prefixes`` is provided the function verifies that the
483 name actually starts with one of those prefixes followed by the separator,
484 matching the longest candidate first so a prefix containing the separator
485 still resolves. Without it the check falls back to the legacy heuristic
486 (separator present anywhere in the name), which can produce false
487 positives for non-MCP tools whose names contain hyphens
488 (e.g. ``text-to-speech``, ``code-review``).
490 Args:
491 tool_name: Tool name to check.
492 known_server_prefixes: Optional set of normalized server prefixes
493 currently registered in the MCP manager. Pass this whenever
494 the caller has access to the server registry so that the check
495 is accurate.
497 Returns:
498 True if tool name is prefixed, False otherwise.
499 """
500 if MCP_TOOL_PREFIX_SEPARATOR not in tool_name:
501 return False
503 if known_server_prefixes is not None:
504 return match_known_server_prefix(tool_name, known_server_prefixes) is not None
506 # Legacy fallback – separator present somewhere in the name.
507 return True
510def validate_mcp_server_name(server_name: str, raise_http_exception: bool = False) -> None:
511 """
512 Validate that MCP server name does not contain 'MCP_TOOL_PREFIX_SEPARATOR'.
514 Args:
515 server_name: The server name to validate
516 raise_http_exception: If True, raises HTTPException instead of generic Exception
518 Raises:
519 Exception or HTTPException: If server name contains 'MCP_TOOL_PREFIX_SEPARATOR'
520 """
521 if server_name and MCP_TOOL_PREFIX_SEPARATOR in server_name: 521 ↛ 522line 521 didn't jump to line 522 because the condition on line 521 was never true
522 error_message = f"Server name cannot contain '{MCP_TOOL_PREFIX_SEPARATOR}'. Use an alternative character instead Found: {server_name}"
523 if raise_http_exception:
524 from fastapi import HTTPException
525 from starlette import status
527 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail={"error": error_message})
528 else:
529 raise Exception(error_message)
532def extract_mcp_tool_result_error_message(result: object) -> str | None:
533 """The first text content of an ``isError=True`` tool result, or ``None``
534 when the result is not an error.
536 Accepts both ``mcp.types.CallToolResult`` objects and their dict
537 equivalents, duck-typed so the ``mcp`` package is not required.
538 """
539 is_error: Final[object] = (
540 (result.get("isError") if result.get("isError") is not None else result.get("is_error"))
541 if isinstance(result, Mapping)
542 else getattr(result, "is_error", None)
543 )
544 if is_error is not True:
545 return None
546 content: Final[object] = result.get("content") if isinstance(result, Mapping) else getattr(result, "content", None)
547 if isinstance(content, (list, tuple)):
548 for item in content:
549 text: object = item.get("text") if isinstance(item, Mapping) else getattr(item, "text", None)
550 if isinstance(text, str) and text:
551 return text
552 return "MCP tool call returned isError=true"
555def mcp_tool_result_content_list(result: object) -> MutableSequence[object] | None: # mutable-ok: see below
556 """The mutable content list of an MCP tool result, or ``None`` when it has none.
558 Deliberately mutable: a guardrail masking the result rewrites entries in place,
559 because the logging payload captured before the guardrail runs references this
560 same list, so handing back a copy would leave the unmasked text in the spend log
561 and the OTel span.
563 Accepts both ``mcp.types.CallToolResult`` objects and their dict
564 equivalents, duck-typed so the ``mcp`` package is not required.
565 """
566 content: Final[object] = result.get("content") if isinstance(result, Mapping) else getattr(result, "content", None)
567 if isinstance(content, MutableSequence):
568 return content
569 return None
572def mcp_content_item_text(item: object) -> str | None:
573 """The ``text`` of a rewritable MCP content item, or ``None``.
575 Only mappings and Pydantic-style models report a text, because those are the
576 only shapes ``with_mcp_content_item_text`` can rewrite; a caller therefore
577 never reads text it would be unable to write back (e.g. masked by a
578 guardrail). Non-text content (images, embedded resources) has no ``text``
579 and is reported as ``None``.
580 """
581 text: object
582 if isinstance(item, Mapping):
583 text = item.get("text")
584 elif callable(getattr(item, "model_copy", None)):
585 text = getattr(item, "text", None)
586 else:
587 return None
588 return text if isinstance(text, str) else None
591def with_mcp_content_item_text(item: object, text: str) -> object:
592 """A copy of an MCP content item carrying ``text`` instead of its own.
594 Only meaningful for items ``mcp_content_item_text`` returned a text for; any
595 other item is returned unchanged.
596 """
597 if isinstance(item, Mapping):
598 return {**item, "text": text}
599 model_copy: Final = getattr(item, "model_copy", None)
600 if callable(model_copy):
601 return model_copy(update={"text": text})
602 return item
605TOOL_DISPLAY_NAME_PATTERN: Final = re.compile(r"^[a-zA-Z0-9_-]+$")
608def validate_tool_display_names(tool_name_to_display_name: Mapping[str, str] | None) -> None:
609 """
610 Validate tool display name overrides against Bedrock's tool-name constraint.
612 A display name replaces the tool name sent to the LLM provider, so it must
613 satisfy the strictest provider requirement in use (Bedrock's
614 ``[a-zA-Z0-9_-]+``); a name with spaces or other characters saves
615 successfully but fails every subsequent Bedrock tool call.
617 Raises:
618 HTTPException: If any display name fails the pattern.
619 """
620 if not tool_name_to_display_name: 620 ↛ 621line 620 didn't jump to line 621 because the condition on line 620 was never true
621 return
623 for original_name, display_name in tool_name_to_display_name.items(): 623 ↛ exitline 623 didn't return from function 'validate_tool_display_names' because the loop on line 623 didn't complete
624 if display_name and not TOOL_DISPLAY_NAME_PATTERN.match(display_name): 624 ↛ 623line 624 didn't jump to line 623 because the condition on line 624 was always true
625 from fastapi import HTTPException
626 from starlette import status
628 raise HTTPException(
629 status_code=status.HTTP_400_BAD_REQUEST,
630 detail={
631 "error": (
632 f"Invalid display name '{display_name}' for tool '{original_name}'. "
633 "Display names may only contain letters, digits, underscores, and "
634 "hyphens (no spaces or other special characters), since they replace "
635 "the tool name sent to the LLM provider."
636 )
637 },
638 )
641class MCPMissingUserEnvVarsError(Exception):
642 """Raised when an MCP request can't be built because the calling user has
643 not supplied one or more required per-user environment variables.
645 The error message is user-facing and includes a URL the user can visit
646 to fill them in.
647 """
649 def __init__(
650 self,
651 *,
652 server_id: str,
653 server_name: str | None,
654 missing: list[str],
655 setup_url: str,
656 ) -> None:
657 self.server_id = server_id
658 self.server_name = server_name
659 self.missing = missing
660 self.setup_url = setup_url
661 label: Final = server_name or server_id
662 bullet_list: Final = "\n".join(f"- {name}" for name in missing)
663 message: Final = (
664 f'Cannot connect to MCP server "{label}".\n\n'
665 f"Your administrator configured this server to require per-user "
666 f"variables, but you haven't set the following yet:\n"
667 f"{bullet_list}\n\n"
668 f"Set your credentials here:\n"
669 f"{setup_url}"
670 )
671 super().__init__(message)
674# Pattern for ``${NAME}`` substitution. Matches the standard env-var
675# identifier rules — letters, digits, underscores, can't start with a digit.
676_ENV_VAR_PATTERN: Final = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
679def parse_admin_env_vars(
680 env_vars: Iterable[Any] | None,
681) -> tuple[dict[str, str], list[dict[str, Any]]]:
682 """Split admin-configured env var entries into globals and per-user specs.
684 Accepts the raw value of ``MCPServer.env_vars`` (list of dicts or Pydantic
685 models). Returns:
687 - ``global_values``: ``{name: value}`` for entries with ``scope=="global"``.
688 - ``user_specs``: list of ``{name, description}`` for entries with
689 ``scope=="user"`` — these are the names the user must fill in.
691 Unknown / malformed entries are skipped silently.
692 """
693 global_values: Final[dict[str, str]] = {}
694 user_specs: Final[list[dict[str, Any]]] = []
695 if not env_vars:
696 return global_values, user_specs
697 for raw in env_vars:
698 if raw is None: 698 ↛ 699line 698 didn't jump to line 699 because the condition on line 698 was never true
699 continue
700 if hasattr(raw, "model_dump"): 700 ↛ 701line 700 didn't jump to line 701 because the condition on line 700 was never true
701 entry: Mapping[str, object] = raw.model_dump()
702 elif isinstance(raw, dict): 702 ↛ 705line 702 didn't jump to line 705 because the condition on line 702 was always true
703 entry = raw
704 else:
705 continue
706 name = entry.get("name")
707 if not isinstance(name, str) or not name:
708 continue
709 scope = entry.get("scope") or "global"
710 if scope == "user":
711 user_specs.append({"name": name, "description": entry.get("description")})
712 else:
713 value = entry.get("value")
714 global_values[name] = "" if value is None else str(value)
715 return global_values, user_specs
718def find_env_var_references(value: str) -> set[str]:
719 """Return the set of ``${NAME}`` identifiers referenced inside ``value``."""
720 if not value:
721 return set()
722 return set(_ENV_VAR_PATTERN.findall(value))
725def collect_env_var_references(*, strings: Iterable[str]) -> set[str]:
726 """Union of every ``${NAME}`` reference across a collection of strings."""
727 refs: set[str] = set()
728 for s in strings: 728 ↛ 729line 728 didn't jump to line 729 because the loop on line 728 never started
729 if isinstance(s, str):
730 refs |= find_env_var_references(s)
731 return refs
734def interpolate_env_vars(value: str, variables: Mapping[str, str]) -> str:
735 """Replace ``${NAME}`` references in ``value`` with the matching mapping
736 entry. Unknown names are left untouched so callers can detect them via
737 ``find_env_var_references`` on the result if needed.
738 """
739 if not value:
740 return value
742 def _sub(match: "re.Match[str]") -> str:
743 name: Final = match.group(1)
744 if name in variables:
745 return variables[name]
746 return match.group(0)
748 return _ENV_VAR_PATTERN.sub(_sub, value)
751def interpolate_headers(headers: Mapping[str, str], variables: Mapping[str, str]) -> dict[str, str]:
752 """Return a copy of ``headers`` with every value passed through ``interpolate_env_vars``."""
753 return {k: interpolate_env_vars(v, variables) for k, v in headers.items()}
756def build_env_var_setup_url(server_id: str) -> str:
757 """The frontend URL where a user can fill in their per-user env vars."""
758 base: Final = os.environ.get("PROXY_BASE_URL", "").rstrip("/")
759 path: Final = f"/ui/mcp-servers?fill_env_vars={quote(server_id, safe='')}"
760 return f"{base}{path}" if base else path
763def merge_mcp_headers(
764 *,
765 extra_headers: Mapping[str, str] | None = None,
766 static_headers: Mapping[str, str] | None = None,
767) -> dict[str, str] | None:
768 """Merge outbound HTTP headers for MCP calls.
770 This is used when calling out to external MCP servers (or OpenAPI-based MCP tools).
772 Merge rules:
773 - Start with `extra_headers` (typically OAuth2-derived headers)
774 - Overlay `static_headers` (user-configured per MCP server)
776 If both contain the same key, `static_headers` wins. This matches the existing
777 behavior in `MCPServerManager` where `server.static_headers` is applied after
778 any caller-provided headers.
779 """
780 merged: Final[dict[str, str]] = {}
782 if extra_headers: 782 ↛ 783line 782 didn't jump to line 783 because the condition on line 782 was never true
783 merged.update({str(k): str(v) for k, v in extra_headers.items()})
785 if static_headers:
786 merged.update({str(k): str(v) for k, v in static_headers.items()})
788 return merged or None
791# Local rather than litellm.constants: this module deliberately imports no litellm
792# package, so pulling one in for a single integer would drag in litellm/__init__.
793MAX_STRUCTURED_CONTENT_SCAN_DEPTH: Final = 100
796JSONLeafPath = tuple[str | int, ...]
799def _flatten_leaf_groups(
800 groups: Iterable[tuple[tuple[JSONLeafPath, str], ...] | None],
801) -> tuple[tuple[JSONLeafPath, str], ...] | None:
802 """Concatenate child leaf groups, propagating the too-deep sentinel."""
803 materialized: Final = tuple(groups)
804 if any(group is None for group in materialized):
805 return None
806 return tuple(leaf for group in materialized if group is not None for leaf in group)
809def json_string_leaves(value: object, path: JSONLeafPath = ()) -> tuple[tuple[JSONLeafPath, str], ...] | None:
810 """Depth-first, deterministically ordered string leaves of a JSON value.
812 Returns ``None`` when the value is nested past ``MAX_STRUCTURED_CONTENT_SCAN_DEPTH``,
813 so the caller blocks rather than letting deeper values through unscanned; an
814 empty tuple means there was simply nothing to scan. A sentinel rather than an
815 exception because this module is reloaded by tests (see the note above the
816 environment-backed constants), which would give a custom exception class a new
817 identity and let it escape a caller's ``except``.
818 """
819 if len(path) > MAX_STRUCTURED_CONTENT_SCAN_DEPTH:
820 return None
821 if isinstance(value, str):
822 return ((path, value),)
823 if isinstance(value, dict):
824 return _flatten_leaf_groups(json_string_leaves(item, (*path, key)) for key, item in value.items())
825 if isinstance(value, list):
826 return _flatten_leaf_groups(json_string_leaves(item, (*path, index)) for index, item in enumerate(value))
827 return ()
830def with_json_string_leaves(
831 value: object,
832 replacements: Mapping[JSONLeafPath, str],
833 path: JSONLeafPath = (),
834) -> object:
835 """Rebuild a JSON value with the guardrail's rewritten string leaves."""
836 if isinstance(value, str):
837 return replacements.get(path, value)
838 if isinstance(value, dict):
839 return {key: with_json_string_leaves(item, replacements, (*path, key)) for key, item in value.items()}
840 if isinstance(value, list):
841 return [with_json_string_leaves(item, replacements, (*path, index)) for index, item in enumerate(value)]
842 return value
845def json_unrewritable_labels(value: object, path_depth: int = 0) -> tuple[str, ...] | None:
846 """Strings in a JSON value that carry meaning but cannot be rewritten.
848 Dictionary keys and non-string scalars: masking either would change the
849 payload's contract rather than redact a value, so a caller scans these and
850 blocks on a match instead of rewriting, matching what the content filter
851 already does for MCP tool call arguments. ``None`` means the value is nested
852 past the scan depth, same contract as ``json_string_leaves``.
853 """
854 if path_depth > MAX_STRUCTURED_CONTENT_SCAN_DEPTH:
855 return None
856 if isinstance(value, bool) or value is None or isinstance(value, str):
857 return ()
858 if isinstance(value, (int, float)):
859 return (str(value),)
860 if isinstance(value, dict):
861 own: Final = tuple(key for key in value if isinstance(key, str))
862 nested = tuple(json_unrewritable_labels(item, path_depth + 1) for item in value.values())
863 if any(group is None for group in nested):
864 return None
865 return own + tuple(label for group in nested if group is not None for label in group)
866 if isinstance(value, list):
867 nested = tuple(json_unrewritable_labels(item, path_depth + 1) for item in value)
868 if any(group is None for group in nested):
869 return None
870 return tuple(label for group in nested if group is not None for label in group)
871 return ()
874def mcp_tool_result_structured_content(result: object) -> object:
875 """The ``structuredContent`` of an MCP tool result, or ``None`` when it has none."""
876 if isinstance(result, Mapping):
877 structured: Final = result.get("structuredContent")
878 return structured if structured is not None else result.get("structured_content")
879 return getattr(result, "structured_content", None)
882def set_mcp_tool_result_structured_content(result: object, value: object) -> bool:
883 """Replace ``structuredContent`` in place; ``False`` when the shape does not carry it.
885 In place for the same reason the content list is: the logging payload captured
886 before the guardrail ran references this object, so a copy would leave the
887 unmasked value in the spend log and the OTel span.
888 """
889 if isinstance(result, MutableMapping):
890 result["structured_content" if "structured_content" in result else "structuredContent"] = value
891 return True
892 if not hasattr(result, "structured_content"):
893 return False
894 try:
895 setattr(result, "structured_content", value) # attribute name is fixed by the MCP result shape
896 return True
897 except (AttributeError, TypeError, ValueError):
898 return False
901_HOP_BY_HOP_HEADERS: Final = frozenset(
902 {
903 "content-length",
904 "transfer-encoding",
905 "connection",
906 "keep-alive",
907 "upgrade",
908 "te",
909 "trailer",
910 }
911)
913_SYNTHETIC_REQUEST_EXCLUDED_HEADERS: Final = _HOP_BY_HOP_HEADERS | frozenset(
914 {"content-type", "host", "x-forwarded-for"}
915)
917_SYNTHETIC_REQUEST_SERVER: Final = ("127.0.0.1", 4000)
919_MCP_SERVER_AUTH_HEADER_PREFIX: Final = "x-mcp-"
922def _custom_litellm_key_header_name() -> str | None:
923 """``general_settings.litellm_key_header_name``, the deployment's custom header name for
924 the proxy virtual key, so it is stripped from observability copies like the standard ones."""
925 try:
926 from litellm.proxy.proxy_server import general_settings
927 except ImportError:
928 return None
929 return general_settings.get("litellm_key_header_name") if general_settings else None
932def _mcp_client_side_auth_header_name() -> str:
933 """The header name the client passes the upstream MCP credential in, falling back to the
934 default when ``general_settings`` is unavailable (the SDK, outside a running proxy)."""
935 from .auth.user_api_key_auth_mcp import MCPRequestHandler
937 try:
938 return MCPRequestHandler.get_mcp_client_side_auth_header_name()
939 except ImportError:
940 return MCPRequestHandler.LITELLM_MCP_AUTH_HEADER_NAME
943def _identity_header_names() -> frozenset[str]:
944 """Lowercased header names the deployment reads the caller's identity out of. A name here
945 is a claim about who the caller is rather than a secret, and ``get_user_from_headers``
946 resolves it off the request this module reconstructs, so dropping one would lose end user
947 attribution on the MCP paths that leave ``end_user_id`` unset at connect time.
949 ``user_header_mappings`` is accepted as a bare mapping as well as a list of them, matching
950 ``get_internal_user_header_from_mapping`` and ``get_customer_user_header_from_mapping``.
951 Iterating the bare form without normalizing yields its keys, which would silently exempt
952 nothing."""
953 try:
954 from litellm.proxy.proxy_server import general_settings
955 except ImportError:
956 return frozenset()
957 if not general_settings: 957 ↛ 958line 957 didn't jump to line 958 because the condition on line 957 was never true
958 return frozenset()
959 user_header: Final = general_settings.get("user_header_name")
960 configured: Final = general_settings.get("user_header_mappings")
961 mappings: Final = configured if isinstance(configured, list) else (configured,) if configured else ()
962 mapped: Final = (mapping.get("header_name") for mapping in mappings if isinstance(mapping, Mapping))
963 return frozenset(name.lower() for name in (user_header, *mapped) if isinstance(name, str) and name)
966def _forwarded_upstream_header_names() -> frozenset[str]:
967 """Lowercased header names that a configured MCP server forwards upstream through its
968 ``extra_headers`` allowlist. The names are chosen by the admin, so no prefix rule can
969 recognize them, and a caller supplied value under one of them is an upstream credential.
971 ``authorization`` is left out because ``clean_headers`` already strips it, and claiming it
972 here would change which header ``authenticated_with_header`` resolves to on the oauth
973 passthrough config, which lists it in ``extra_headers`` by design. Identity headers are
974 left out for the same reason: naming one in ``extra_headers`` forwards the caller's
975 identity upstream, it does not turn that identity into a secret."""
976 try:
977 from .mcp_server_manager import global_mcp_server_manager
978 except ImportError:
979 return frozenset()
980 exempt: Final = _identity_header_names() | frozenset({"authorization"})
981 return frozenset(
982 name.lower()
983 for server in global_mcp_server_manager.get_registry().values()
984 for name in (server.extra_headers or ())
985 if name.lower() not in exempt
986 )
989def upstream_credential_headers(header_names: Iterable[str]) -> frozenset[str]:
990 """Lowercased names of the headers in ``header_names`` that carry an upstream MCP
991 credential rather than request context: the configured client side auth header, any
992 header name a configured server forwards upstream via ``extra_headers``, and the
993 per-server ``x-mcp-{alias}-{header}`` family. ``clean_headers`` only knows the
994 credential headers of the chat completions path, so these are dropped on top of it.
995 """
996 from .auth.user_api_key_auth_mcp import MCPRequestHandler
998 non_credential: Final = frozenset(
999 {
1000 MCPRequestHandler.LITELLM_MCP_SERVERS_HEADER_NAME.lower(),
1001 MCPRequestHandler.LITELLM_MCP_ACCESS_GROUPS_HEADER_NAME.lower(),
1002 }
1003 )
1004 client_side_auth: Final = _mcp_client_side_auth_header_name().lower()
1005 forwarded_upstream: Final = _forwarded_upstream_header_names()
1006 return frozenset(
1007 name
1008 for name in (raw_name.lower() for raw_name in header_names)
1009 if name == client_side_auth
1010 or name in forwarded_upstream
1011 or (name.startswith(_MCP_SERVER_AUTH_HEADER_PREFIX) and name not in non_credential)
1012 )
1015def build_synthetic_mcp_request(
1016 *,
1017 path: str,
1018 raw_headers: Mapping[str, str] | None = None,
1019 client_ip: str | None = None,
1020) -> "Request":
1021 """A synthetic FastAPI ``Request`` carrying the MCP connection's HTTP headers.
1023 The MCP protocol transports do not hand a per-call ``Request`` to the tool
1024 handlers, so one is reconstructed from the connection's ``raw_headers``. That
1025 lets ``add_litellm_data_to_request`` derive ``metadata.headers``,
1026 ``proxy_server_request``, header-based tags, guardrails and trace correlation
1027 exactly as on the chat completions path. Hop-by-hop headers describe the
1028 original HTTP framing rather than the logical request, so they are dropped, and
1029 ``x-forwarded-for`` comes from the resolved ``client_ip`` to avoid spoofing. ``host`` is
1030 dropped for the same reason: it is what ``Request.url`` is built from, so forwarding it
1031 would let a caller choose the URL every logging callback records. Upstream
1032 MCP credentials and the deployment's proxy key header, including a custom
1033 ``litellm_key_header_name``, are dropped so they cannot reach a callback or a guardrail
1034 through the derived metadata even when a caller omits ``general_settings``.
1035 """
1036 from fastapi import Request
1038 custom_key_header: Final = _custom_litellm_key_header_name()
1039 excluded: Final = (
1040 _SYNTHETIC_REQUEST_EXCLUDED_HEADERS
1041 | upstream_credential_headers(raw_headers.keys() if raw_headers else ())
1042 | (frozenset({custom_key_header.lower()}) if custom_key_header else frozenset())
1043 )
1044 forwarded: Final = tuple(
1045 (
1046 name.lower().encode("latin-1", errors="replace"),
1047 value.encode("utf-8", errors="replace"),
1048 )
1049 for name, value in (raw_headers.items() if raw_headers else ())
1050 if name.lower() not in excluded
1051 )
1052 xff: Final = ((b"x-forwarded-for", client_ip.encode("utf-8")),) if client_ip else ()
1053 return Request(
1054 scope={
1055 "type": "http",
1056 "method": "POST",
1057 "path": path,
1058 "scheme": "http",
1059 "server": _SYNTHETIC_REQUEST_SERVER,
1060 "query_string": b"",
1061 "root_path": "",
1062 "headers": ((b"content-type", b"application/json"), *forwarded, *xff),
1063 **({"client": (client_ip, 0)} if client_ip else {}),
1064 }
1065 )
1068def logging_safe_mcp_headers(raw_headers: Mapping[str, str] | None) -> Mapping[str, str]:
1069 """The MCP request's client headers, sanitized the way the chat completions path
1070 sanitizes them before they reach a logging callback or a guardrail: proxy key
1071 headers stripped, including the custom key header name the deployment configured,
1072 upstream MCP credentials dropped, and credential-bearing values masked.
1074 Client-controlled behaviour flags (``litellm-disable-message-redaction``) are dropped
1075 too: these headers are read back out of the metadata to change proxy behaviour, so
1076 leaving one in place would let any MCP client turn off the redaction an admin
1077 configured. This path carries no key or team object to authorize an opt-out with, so
1078 it always strips them. ``host`` goes too, so that a caller cannot name the deployment in
1079 the guardrail payload and the spend row the way it could once name the request URL."""
1080 from starlette.datastructures import Headers
1082 from litellm.proxy.litellm_pre_call_utils import (
1083 UNTRUSTED_REQUEST_HEADER_CONTROL_FIELDS,
1084 clean_headers,
1085 redact_credential_headers,
1086 )
1088 excluded: Final = (
1089 upstream_credential_headers(raw_headers.keys() if raw_headers else ())
1090 | UNTRUSTED_REQUEST_HEADER_CONTROL_FIELDS
1091 | frozenset({"host"})
1092 )
1093 cleaned: Final = clean_headers(
1094 Headers(raw_headers),
1095 litellm_key_header_name=_custom_litellm_key_header_name(),
1096 )
1097 return redact_credential_headers({name: value for name, value in cleaned.items() if name.lower() not in excluded})