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

1""" 

2MCP Server Utilities 

3""" 

4 

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 

15 

16from litellm._logging import verbose_logger 

17from litellm.types.mcp_server.mcp_server_manager import MCPServer 

18 

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 

21 

22 

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

32 

33 

34class McpServerPayloadLike(Protocol): 

35 alias: str | None 

36 

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

41 

42 

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}" 

56 

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" 

96 

97 

98def is_short_mcp_tool_prefix_enabled() -> bool: 

99 """Return True when the short-ID tool prefix mode is enabled. 

100 

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") 

106 

107 

108def compute_short_server_prefix(server_id: str, attempt: int = 0) -> str: 

109 """Derive the deterministic three-character prefix for a server. 

110 

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") 

123 

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") 

127 

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

139 

140 

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 

150 

151 

152def normalize_server_name(server_name: str) -> str: 

153 """ 

154 Normalize server name by replacing spaces with underscores 

155 """ 

156 return server_name.replace(" ", "_") 

157 

158 

159_MCP_ALIAS_HEADER_INVALID_RE: Final = re.compile(r"[^a-z0-9_]") 

160 

161 

162def sanitize_mcp_alias_for_header(alias: str) -> str: 

163 """ 

164 Sanitize an MCP server alias for x-mcp-{alias}-{header} HTTP headers. 

165 

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("_") 

171 

172 

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) 

177 

178 

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) 

181 

182 

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. 

192 

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. 

195 

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 

203 

204 normalized_headers: Final = {k.lower(): v for k, v in mcp_server_auth_headers.items()} 

205 

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] 

212 

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] 

225 

226 

227MCP_TOOL_ALLOWLIST_ENFORCED_KEY: Final = "tool_allowlist_enforced" 

228 

229 

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 

242 

243 

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

249 

250 

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) 

255 

256 

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

261 

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) 

267 

268 Args: 

269 payload: The payload object containing server_name and alias fields 

270 

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) 

277 

278 # Alias validation: disallow '-' 

279 if hasattr(payload, "alias") and payload.alias: 

280 validate_mcp_server_name(payload.alias, raise_http_exception=True) 

281 

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) 

285 

286 # Alias normalization and defaulting 

287 alias: str | None = getattr(payload, "alias", None) 

288 server_name: Final[str | None] = getattr(payload, "server_name", None) 

289 

290 if not alias and server_name: 

291 alias = normalize_server_name(server_name) 

292 elif alias: 

293 alias = normalize_server_name(alias) 

294 

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 

298 

299 

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) 

303 

304 return MCP_TOOL_PREFIX_FORMAT.format( 

305 server_name=formatted_server_name, 

306 separator=MCP_TOOL_PREFIX_SEPARATOR, 

307 tool_name=name, 

308 ) 

309 

310 

311def get_server_prefix(server: object) -> str: 

312 """Return the prefix for a server. 

313 

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) 

330 

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

341 

342 

343def iter_known_server_prefixes(server: _McpServerLike) -> Iterator[str]: 

344 """Yield every prefix form that may appear in tool names for ``server``. 

345 

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() 

352 

353 def _emit(value: str | None) -> Iterator[str]: 

354 if value and value not in seen: 

355 seen.add(value) 

356 yield value 

357 

358 yield from _emit(get_server_prefix(server)) 

359 yield from _emit(getattr(server, "short_prefix", None)) 

360 

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 

367 

368 yield from _emit(getattr(server, "alias", None)) 

369 yield from _emit(getattr(server, "server_name", None)) 

370 yield from _emit(server_id) 

371 

372 

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) 

382 

383 

384def openapi_tool_name(operation_id: str) -> str: 

385 """Return the tool name ``_register_openapi_tools`` registers ``operation_id`` under. 

386 

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() 

393 

394 

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

397 

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. 

402 

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. 

407 

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) 

414 

415 

416def split_server_prefix_from_name(prefixed_name: str) -> tuple[str, str]: 

417 """Return the unprefixed name plus the server name used as prefix. 

418 

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, "" 

431 

432 

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. 

435 

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 

452 

453 

454def strip_known_server_prefix(name: str, server: _McpServerLike | None) -> str: 

455 """Strip ``server``'s registered prefix from a prefixed tool/resource name. 

456 

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. 

464 

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] 

473 

474 

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. 

481 

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

489 

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. 

496 

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 

502 

503 if known_server_prefixes is not None: 

504 return match_known_server_prefix(tool_name, known_server_prefixes) is not None 

505 

506 # Legacy fallback – separator present somewhere in the name. 

507 return True 

508 

509 

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

513 

514 Args: 

515 server_name: The server name to validate 

516 raise_http_exception: If True, raises HTTPException instead of generic Exception 

517 

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 

526 

527 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail={"error": error_message}) 

528 else: 

529 raise Exception(error_message) 

530 

531 

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. 

535 

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" 

553 

554 

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. 

557 

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. 

562 

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 

570 

571 

572def mcp_content_item_text(item: object) -> str | None: 

573 """The ``text`` of a rewritable MCP content item, or ``None``. 

574 

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 

589 

590 

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. 

593 

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 

603 

604 

605TOOL_DISPLAY_NAME_PATTERN: Final = re.compile(r"^[a-zA-Z0-9_-]+$") 

606 

607 

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. 

611 

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. 

616 

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 

622 

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 

627 

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 ) 

639 

640 

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. 

644 

645 The error message is user-facing and includes a URL the user can visit 

646 to fill them in. 

647 """ 

648 

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) 

672 

673 

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_]*)\}") 

677 

678 

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. 

683 

684 Accepts the raw value of ``MCPServer.env_vars`` (list of dicts or Pydantic 

685 models). Returns: 

686 

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. 

690 

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 

716 

717 

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

723 

724 

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 

732 

733 

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 

741 

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) 

747 

748 return _ENV_VAR_PATTERN.sub(_sub, value) 

749 

750 

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()} 

754 

755 

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 

761 

762 

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. 

769 

770 This is used when calling out to external MCP servers (or OpenAPI-based MCP tools). 

771 

772 Merge rules: 

773 - Start with `extra_headers` (typically OAuth2-derived headers) 

774 - Overlay `static_headers` (user-configured per MCP server) 

775 

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]] = {} 

781 

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()}) 

784 

785 if static_headers: 

786 merged.update({str(k): str(v) for k, v in static_headers.items()}) 

787 

788 return merged or None 

789 

790 

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 

794 

795 

796JSONLeafPath = tuple[str | int, ...] 

797 

798 

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) 

807 

808 

809def json_string_leaves(value: object, path: JSONLeafPath = ()) -> tuple[tuple[JSONLeafPath, str], ...] | None: 

810 """Depth-first, deterministically ordered string leaves of a JSON value. 

811 

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 () 

828 

829 

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 

843 

844 

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. 

847 

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 () 

872 

873 

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) 

880 

881 

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. 

884 

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 

899 

900 

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) 

912 

913_SYNTHETIC_REQUEST_EXCLUDED_HEADERS: Final = _HOP_BY_HOP_HEADERS | frozenset( 

914 {"content-type", "host", "x-forwarded-for"} 

915) 

916 

917_SYNTHETIC_REQUEST_SERVER: Final = ("127.0.0.1", 4000) 

918 

919_MCP_SERVER_AUTH_HEADER_PREFIX: Final = "x-mcp-" 

920 

921 

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 

930 

931 

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 

936 

937 try: 

938 return MCPRequestHandler.get_mcp_client_side_auth_header_name() 

939 except ImportError: 

940 return MCPRequestHandler.LITELLM_MCP_AUTH_HEADER_NAME 

941 

942 

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. 

948 

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) 

964 

965 

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. 

970 

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 ) 

987 

988 

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 

997 

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 ) 

1013 

1014 

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. 

1022 

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 

1037 

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 ) 

1066 

1067 

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. 

1073 

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 

1081 

1082 from litellm.proxy.litellm_pre_call_utils import ( 

1083 UNTRUSTED_REQUEST_HEADER_CONTROL_FIELDS, 

1084 clean_headers, 

1085 redact_credential_headers, 

1086 ) 

1087 

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})