Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py: 40%
2650 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 Client Manager
4This class is responsible for managing MCP clients with support for both SSE and HTTP streamable transports.
6This is a Proxy
7"""
9import asyncio
10import datetime
11import hashlib
12import json
13import math
14import os
15import re
16import time
17from collections.abc import (
18 AsyncIterator,
19 Awaitable,
20 Callable,
21 Container,
22 Iterable,
23 Mapping,
24 MutableMapping,
25 Sequence,
26)
27from contextlib import asynccontextmanager
28from dataclasses import dataclass, replace
29from functools import lru_cache
30from itertools import chain, groupby
31from operator import itemgetter
32from types import MappingProxyType
33from typing import TYPE_CHECKING, Any, Final, Generic, Literal, TypeAlias, TypedDict, TypeVar, cast
34from urllib.parse import ParseResult, urlparse
36import anyio
37import httpx
38import httpx2
39from fastapi import HTTPException
40from httpx import HTTPStatusError
41from mcp import ReadResourceResult, Resource
42from mcp.types import CallToolRequestParams as MCPCallToolRequestParams
43from mcp.types import (
44 CallToolResult,
45 GetPromptRequestParams,
46 GetPromptResult,
47 Prompt,
48 ResourceTemplate,
49)
50from mcp.types import Tool as MCPTool
51from pydantic import AnyUrl, BaseModel, TypeAdapter
52from typing_extensions import ReadOnly
54import litellm
55from litellm._logging import verbose_logger
56from litellm.caching.in_memory_cache import InMemoryCache
57from litellm.constants import (
58 MCP_CLIENT_TIMEOUT,
59 MCP_HEALTH_CHECK_TIMEOUT,
60 MCP_METADATA_TIMEOUT,
61 MCP_NPM_CACHE_DIR,
62 MCP_STDIO_ALLOWED_COMMANDS,
63 MCP_TOOL_LISTING_TIMEOUT,
64)
65from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException
66from litellm.experimental_mcp_client.client import MCPClient, MCPSigV4Auth, strip_auth_scheme, to_basic_credentials
67from litellm.integrations.custom_guardrail import (
68 _sync_guardrail_info_to_logging_obj, # pyright: ignore[reportPrivateUsage] - the same bridge @log_guardrail_information uses; reimplementing it here would fork the metadata-key logic
69)
70from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get
71from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
72from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
73 MCPRequestHandler,
74 MCPServerAccess,
75 _is_mcp_admitted_user_subject,
76)
77from litellm.proxy._experimental.mcp_server.contracts import OperationContext
78from litellm.proxy._experimental.mcp_server.elicitation_handler import (
79 MCP_ELICITATION_AVAILABLE,
80)
81from litellm.proxy._experimental.mcp_server.exceptions import (
82 MCPServerListError,
83 MCPUpstreamAuthError,
84)
85from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
86 ServerListFault,
87 raise_classified_list_failure,
88 upstream_auth_challenge,
89)
90from litellm.proxy._experimental.mcp_server.mcp_debug import describe_upstream_http_failure, record_auth_resolution
91from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
92 MCPPerUserTokenCache,
93 mcp_per_user_token_cache,
94 resolve_mcp_auth,
95 resolved_token_header,
96)
97from litellm.proxy._experimental.mcp_server.oauth_utils import (
98 _redact_mcp_resource_url,
99 canonicalize_url_identity,
100 get_byok_www_authenticate,
101)
102from litellm.proxy._experimental.mcp_server.outbound_credentials import (
103 Error,
104 Ok,
105 UpstreamCredentialProvider,
106)
107from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import (
108 prepare_mcp_client,
109 raise_public,
110 raise_token_exchange_challenge,
111 raise_user_oauth_challenge,
112 to_server_spec,
113 to_subject,
114)
115from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import (
116 InvalidatableOAuthTokenStore,
117)
118from litellm.proxy._experimental.mcp_server.outbound_credentials.per_user_oauth_store import (
119 LazyPerUserOAuthTokenStore,
120)
121from litellm.proxy._experimental.mcp_server.outbound_credentials.resolver import resolve_credentials_with_source
122from litellm.proxy._experimental.mcp_server.outbound_credentials.token_exchange_provider import (
123 build_token_exchanger,
124)
125from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
126 DEFAULT_CREDENTIAL_HEADER,
127 AuthorizationCodeConfig,
128 AuthResolution,
129 ClientCredentialsConfig,
130 CredError,
131 IdJagConfig,
132 PassthroughConfig,
133 ServerSpec,
134 TokenExchangeConfig,
135)
136from litellm.proxy._experimental.mcp_server.sampling_handler import (
137 MCP_SAMPLING_AVAILABLE,
138)
139from litellm.proxy._experimental.mcp_server.stdio_gate import (
140 MCP_STDIO_DISABLED_MESSAGE,
141 is_mcp_stdio_blocked,
142 is_mcp_stdio_enabled,
143 warn_if_mcp_stdio_blocked,
144)
145from litellm.proxy._experimental.mcp_server.utils import (
146 MCP_TOOL_PREFIX_SEPARATOR,
147 MCPMissingUserEnvVarsError,
148 add_server_prefix_to_name,
149 build_env_var_setup_url,
150 collect_env_var_references,
151 compute_short_server_prefix,
152 get_server_prefix,
153 interpolate_headers,
154 is_short_mcp_tool_prefix_enabled,
155 iter_known_server_prefixes,
156 iter_known_tool_name_spellings,
157 logging_safe_mcp_headers,
158 lookup_mcp_server_auth_in_headers,
159 match_known_server_prefix,
160 match_known_tool_name,
161 merge_mcp_headers,
162 normalize_server_name,
163 openapi_tool_name,
164 parse_admin_env_vars,
165 strip_known_server_prefix,
166 validate_mcp_server_name,
167)
168from litellm.proxy._types import (
169 LiteLLM_MCPServerTable,
170 MCPAuthType,
171 MCPEnvVar,
172 MCPTransport,
173 MCPTransportType,
174 SpecialMCPServerNames,
175 UserAPIKeyAuth,
176 is_per_server_oauth_discovery_eligible,
177)
178from litellm.proxy.auth.ip_address_utils import IPAddressUtils
179from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
180from litellm.proxy.common_utils.user_api_key_cache import get_management_object_ttl
181from litellm.proxy.management_endpoints.sso.id_jag_assertion_capture import (
182 id_jag_assertion_capture_gap_at_startup,
183)
184from litellm.proxy.middleware.per_request_root_path_middleware import (
185 get_request_root_path,
186)
187from litellm.proxy.utils import PrismaClient, ProxyLogging
188from litellm.repositories.table_repositories import MCPServerRepository
189from litellm.types.llms.custom_http import httpxSpecialProvider
190from litellm.types.mcp import (
191 DEFAULT_SUBJECT_TOKEN_TYPE,
192 MCPAuth,
193 MCPStdioConfig,
194 MCPTokenEndpointAuthMethod,
195 has_header,
196 without_header,
197)
198from litellm.types.mcp_server.mcp_server_manager import (
199 MCPInfo,
200 MCPOAuthMetadata,
201 MCPServer,
202)
203from litellm.types.utils import CallTypes
205if TYPE_CHECKING: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true
206 from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
207 from litellm.types.mcp_server.mcp_toolset import MCPToolset
209try:
210 from mcp.shared.tool_name_validation import (
211 SEP_986_URL,
212 validate_tool_name, # pyright: ignore[reportAssignmentType]
213 )
214except ImportError:
215 from pydantic import BaseModel
217 SEP_986_URL = "https://github.com/modelcontextprotocol/protocol/blob/main/proposals/0001-tool-name-validation.md"
219 class _ToolNameValidationResult(BaseModel):
220 is_valid: bool = True
221 warnings: list = []
223 def validate_tool_name(name: str) -> _ToolNameValidationResult:
224 return _ToolNameValidationResult()
227# Probe includes characters on both sides of the separator to mimic real prefixed tool names.
228_separator_probe_tool_name: Final = f"litellm{MCP_TOOL_PREFIX_SEPARATOR}probe"
229_separator_probe: Final = validate_tool_name(_separator_probe_tool_name)
230if not _separator_probe.is_valid: 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true
231 verbose_logger.warning(
232 "MCP tool prefix separator '%s' violates SEP-986. See %s",
233 MCP_TOOL_PREFIX_SEPARATOR,
234 SEP_986_URL,
235 )
237_AZURE_ENTRA_HOSTS: Final = {
238 "login.microsoftonline.com", # Global
239 "login.microsoftonline.us", # US Government
240 "login.chinacloudapi.cn", # China
241}
243# Short-lived in-memory cache for per-user MCP env var values, mirroring the
244# BYOK credential cache. Keyed by (user_id, server_id); value is
245# (values_dict, monotonic_timestamp). Keeps the tool-call and tool-listing
246# paths off the DB on every request within the TTL window.
247_user_env_vars_cache: Final[dict[tuple[str, str], tuple[dict[str, str], float]]] = {}
248_USER_ENV_VARS_CACHE_TTL: Final = 60 # seconds
249_USER_ENV_VARS_CACHE_MAX_SIZE: Final = 4096 # cap to prevent unbounded growth
251# Auth types whose upstream OAuth endpoints (protected-resource + authorization-server metadata) the
252# gateway discovers from the upstream itself: interactive oauth2 and the two client-forwarded modes.
253# OBO/M2M endpoint discovery is decided separately via _obo_needs_endpoint_discovery. Shared by the
254# config-YAML and DB server loaders so the two paths cannot drift on which modes trigger discovery.
255_UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES: Final[tuple[MCPAuth, ...]] = (
256 MCPAuth.oauth2,
257 MCPAuth.true_passthrough,
258 MCPAuth.oauth_delegate,
259)
262_MCP_OAUTH_DISCOVERY_ON_STARTUP_ENV: Final = "LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP"
263_TRUE_ENV_VALUES: Final = frozenset(("1", "true", "yes", "on"))
264_OAUTH_DISCOVERY_RETRY_DELAYS_SECONDS: Final = (0.05, 0.15)
265_OAUTH_DISCOVERY_RETRY_BASE_SECONDS: Final = 30.0
266_OAUTH_DISCOVERY_RETRY_MAX_SECONDS: Final = 900.0
267_OAUTH_TEMPORARY_DISCOVERY_TTL_SECONDS: Final = 300.0
270def _oauth_discovery_now() -> float:
271 return time.monotonic()
274def _oauth_discovery_retry_delay(consecutive_failures: int) -> float:
275 backoff_multiplier: Final[int] = 1 << max(consecutive_failures - 1, 0)
276 return min(
277 _OAUTH_DISCOVERY_RETRY_BASE_SECONDS * backoff_multiplier,
278 _OAUTH_DISCOVERY_RETRY_MAX_SECONDS,
279 )
282def _mcp_oauth_discovery_on_startup_enabled() -> bool:
283 """Return whether remote MCP OAuth metadata is discovered during registration.
285 Discovery is deferred until the first admitted request unless explicitly
286 enabled with ``1``, ``true``, ``yes``, or ``on``.
287 """
288 value: Final = os.getenv(_MCP_OAUTH_DISCOVERY_ON_STARTUP_ENV)
289 return value is not None and value.strip().lower() in _TRUE_ENV_VALUES
292def _requires_oauth_discovery(
293 server_url: str | None,
294 use_issuer_anchor: bool,
295 server: MCPServer,
296) -> bool:
297 return _has_oauth_discovery_source(server_url, use_issuer_anchor) and _oauth_endpoints_unresolved(server)
300_StringList: TypeAlias = list[str]
301_StringMap: TypeAlias = dict[str, str]
302_ToolParamMap: TypeAlias = dict[str, list[str]]
303_EnvVarList: TypeAlias = list[dict[str, object]]
304_InMemoryCacheDict: TypeAlias = dict[str, object]
305_ToolArguments: TypeAlias = dict[str, object]
308@dataclass(frozen=True, slots=True)
309class _OAuthDiscoveryResolved:
310 server: MCPServer
313@dataclass(frozen=True, slots=True)
314class _OAuthDiscoveryFailed:
315 server_id: str
316 timed_out: bool
319@dataclass(frozen=True, slots=True)
320class _OAuthDiscoveryStale:
321 server_id: str
324_OAuthDiscoveryOutcome: TypeAlias = _OAuthDiscoveryResolved | _OAuthDiscoveryFailed | _OAuthDiscoveryStale
327@dataclass(frozen=True, slots=True)
328class _OAuthDiscoverySlot:
329 server_id: str
330 generation: int
331 task: asyncio.Task[_OAuthDiscoveryOutcome] | None = None
332 consecutive_failures: int = 0
333 retry_not_before: float = 0.0
336class MCPServerConfig(TypedDict, total=False):
337 """Shape of a single ``mcp_servers`` entry in config.yaml, as consumed by
338 :meth:`MCPServerManager.load_servers_from_config`. Every key is optional: YAML supplies
339 whatever the admin wrote, and each read applies its own default."""
341 server_id: ReadOnly[str]
342 alias: str
343 description: str
344 mcp_info: MCPInfo
345 url: str
346 spec_path: str
347 transport: MCPTransportType
348 auth_type: MCPAuthType
349 authentication_token: str
350 auth_value: str
351 instructions: str
352 command: str
353 args: _StringList
354 env: _StringMap
355 client_id: str
356 client_secret: str
357 oauth2_flow: str
358 issuer: str
359 authorization_url: str
360 token_url: str
361 registration_url: str
362 token_endpoint_auth_method: MCPTokenEndpointAuthMethod
363 scopes: str | Sequence[str]
364 dcr_bridge: object
365 per_server_oauth_discovery: ReadOnly[object]
366 extra_headers: _StringList
367 allowed_tools: _StringList
368 disallowed_tools: _StringList
369 allowed_params: _ToolParamMap
370 access_groups: _StringList
371 static_headers: _StringMap
372 env_vars: _EnvVarList
373 allow_all_keys: bool
374 available_on_public_internet: bool
375 delegate_auth_to_upstream: bool
376 oauth_passthrough: bool
377 allow_sampling: bool
378 allow_elicitation: bool
379 aws_access_key_id: str
380 aws_secret_access_key: str
381 aws_session_token: str
382 aws_region_name: str
383 aws_service_name: str
384 aws_role_name: str
385 aws_session_name: str
386 token_exchange_endpoint: str
387 token_exchange_profile: str
388 audience: str
389 subject_token_type: str
390 upstream_resource: str
391 upstream_token_header: ReadOnly[str]
392 id_jag_resource_token_endpoint: str
393 id_jag_resource: str
394 client_private_key: str
395 client_private_key_id: str
396 client_assertion_signing_alg: str
397 timeout: float
398 max_concurrent_requests: int
401class _ProtectedResourceMetadataPayload(TypedDict, total=False):
402 """The RFC 9728 protected-resource metadata document fields this gateway reads."""
404 authorization_servers: Sequence[object]
405 scopes_supported: Sequence[str]
406 scopes: Sequence[str]
409class _AuthorizationServerMetadataPayload(TypedDict, total=False):
410 """The RFC 8414 / OpenID Discovery authorization-server metadata fields this gateway reads."""
412 issuer: str
413 authorization_endpoint: str
414 token_endpoint: str
415 registration_endpoint: str
416 scopes_supported: Sequence[str]
417 grant_types_supported: Sequence[str]
418 token_endpoint_auth_methods_supported: Sequence[str]
421def _blank_to_none(value: str | None) -> str | None:
422 """Collapse an absent, empty, or whitespace-only string to ``None``.
424 OAuth endpoint fields are consumed by truthiness-based merges (``row or discovered``) and by the
425 corroboration gate. A whitespace-only value is truthy to ``or`` but is not a usable endpoint, so
426 without this the merge would keep the blank value for redirects while the gate treats it as
427 unpinned and backfills the other fields, yielding a broken half-discovered config. Normalizing
428 the pinned fields once, at each build entry point, gives every downstream consumer a single
429 notion of "blank" so those code paths cannot disagree.
430 """
431 if not isinstance(value, str):
432 return None
433 return value.strip() or None
436def _config_per_server_oauth_discovery(
437 server_config: MCPServerConfig,
438 server_ref: str,
439 auth_type: MCPAuthType | None,
440 oauth2_flow: object,
441) -> bool:
442 match server_config.get("per_server_oauth_discovery", False):
443 case bool() as enabled:
444 pass
445 case other:
446 raise ValueError(
447 f"Invalid config for MCP server '{server_ref}': per_server_oauth_discovery must be a boolean "
448 f"(got {other!r})."
449 )
450 relay_eligible: Final = is_per_server_oauth_discovery_eligible(
451 auth_type, oauth2_flow, server_config.get("delegate_auth_to_upstream", False)
452 )
453 if enabled and not relay_eligible:
454 raise ValueError(
455 f"Invalid config for MCP server '{server_ref}': per_server_oauth_discovery is only supported for "
456 "auth_type oauth2 with oauth2_flow authorization_code and without delegate_auth_to_upstream."
457 )
458 return enabled
461def _pinned_config_server_id(raw_server_id: object, server_name: str) -> str | None:
462 """Return the ``server_id`` an admin pinned for this config.yaml server, or ``None`` when absent.
464 Without a pin the id is derived by hashing ``server_name|url|transport|auth_type|alias``, so
465 editing any of those fields mints a new id and every ``object_permission.mcp_servers`` grant
466 holding the old one silently stops matching. A pinned id is used verbatim and survives those
467 edits. Blank and non-string values are rejected rather than silently falling back to the hash,
468 because a config that pins an id and still churns is the failure this field exists to prevent.
470 Under ``LITELLM_USE_SHORT_MCP_TOOL_PREFIX`` the tool prefix is derived from the server_id, so
471 pinning an id other than the one already in use renames every tool that server exposes.
472 """
473 if raw_server_id is None:
474 return None
475 if not isinstance(raw_server_id, str) or not raw_server_id.strip():
476 raise ValueError(
477 f"Invalid config for MCP server '{server_name}': server_id must be a non-empty string "
478 f"(got {raw_server_id!r})."
479 )
480 return raw_server_id.strip()
483def _first_mapped_alias(server_name: str, mcp_aliases: Mapping[str, str] | None) -> str | None:
484 """The ``mcp_aliases`` name ``load_servers_from_config`` will assign to this server, if any.
486 Mirrors that loop, which takes the first mapping pointing at the server and stops. A later
487 mapping for the same server is never applied, so it stays free for another entry to pin.
488 """
489 if mcp_aliases is None:
490 return None
491 return next(
492 (alias_name for alias_name, target_server_name in mcp_aliases.items() if target_server_name == server_name),
493 None,
494 )
497def _assigned_alias(
498 server_name: str, server_config: MCPServerConfig, mcp_aliases: Mapping[str, str] | None
499) -> str | None:
500 """The alias ``load_servers_from_config`` will give this entry: its own, else the first mapping.
502 ``is None``, not falsiness: the loader only consults the mapping when the key is absent, so an
503 entry that sets ``alias: ""`` gets no mapped alias and reserves nothing.
504 """
505 alias: Final = server_config.get("alias")
506 return _first_mapped_alias(server_name, mcp_aliases) if alias is None else alias
509def _validate_config_server_names(mcp_servers_config: Mapping[str, MCPServerConfig]) -> None:
510 """Reject bad server names before ``_config_identifier_owners`` reads any entry's body.
512 The identifier index walks every entry up front, so without this pass a malformed entry under
513 a bad name would surface as an ``AttributeError`` from the index instead of the name error.
514 """
515 for server_name in mcp_servers_config:
516 validate_mcp_server_name(server_name)
519def _config_identifier_owners(
520 mcp_servers_config: Mapping[str, MCPServerConfig],
521 mcp_aliases: Mapping[str, str] | None,
522) -> Mapping[str, frozenset[str]]:
523 """Map every server_name and alias in the config to the entries that own it.
525 ``expand_permission_list`` resolves a grant against the registry keys before it falls back to
526 matching alias and server_name, so an id equal to another entry's name or alias captures that
527 entry's grants. Derived ids are hashes and never collide with a name, so this only matters once
528 an id is pinned.
530 An alias is either set on the entry or mapped to it from ``litellm_settings.mcp_aliases``. Only
531 a name the loader below will really assign is reserved: the mapping is ignored for an entry that
532 sets its own ``alias``, and only the first mapping wins for one that does not, so reserving every
533 mapping would fail startup on a pin that was never going to collide.
535 One identifier can have several owners when an entry's alias equals another entry's name. All of
536 them are kept: a grant naming that identifier resolves to every match while no id is pinned, and
537 a pin equal to it would narrow the grant to the pinning entry alone, even when that entry is one
538 of the owners.
539 """
540 claims: Final = tuple(
541 (identifier, server_name)
542 for server_name, server_config in mcp_servers_config.items()
543 for identifier in (server_name, _assigned_alias(server_name, server_config, mcp_aliases))
544 if identifier
545 )
546 return MappingProxyType(
547 {identifier: frozenset(owner for claimed, owner in claims if claimed == identifier) for identifier, _ in claims}
548 )
551def _config_ids_capturing_db_identifiers(
552 config_server_ids: Container[str],
553 db_servers: Iterable[MCPServer],
554) -> frozenset[str]:
555 """Config server ids that are a database-backed server's name, server_name or alias.
557 ``expand_permission_list`` matches a grant against the registry keys before it matches names, so
558 such an id answers every grant written for the database server, and the database server itself
559 stops being reachable by name. The config load cannot catch this because the database registry
560 is not loaded yet, so it is reported from the reload that does have both halves.
562 An identifier equal to the database server's own id is skipped: ``get_registry`` is
563 ``config_mcp_servers | registry``, so there the database server wins the id outright and the
564 shadow warning above is the accurate one. Reporting both would contradict. The skip is per
565 identifier rather than per server, so a row that shadows one config id and captures another
566 still reports the capture.
567 """
568 return frozenset(
569 identifier
570 for server in db_servers
571 for identifier in (server.name, server.server_name, server.alias)
572 if identifier and identifier != server.server_id and identifier in config_server_ids
573 )
576def _reject_config_server_id_collision(
577 assigned_server_ids: Mapping[str, str],
578 server_id: str,
579 server_name: str,
580 pinned: bool,
581 db_backed_server_ids: Mapping[str, object],
582 identifier_owners: Mapping[str, frozenset[str]],
583) -> None:
584 """Raise when ``server_id`` is already taken, either by an earlier config entry or by the database.
586 Two config entries sharing an id would silently overwrite each other in ``config_mcp_servers``,
587 and an id already held by a database-backed server is hidden by it, because ``get_registry`` is
588 ``config_mcp_servers | registry`` and the right operand wins. A pinned id that is another
589 entry's server_name or alias captures that entry's permission grants the same way. Derived ids
590 cannot collide (the unique config key is part of the hash input), so all three only happen once
591 an id is pinned.
593 Pinning an identifier this entry itself owns is allowed, because a grant naming it already
594 resolved here, but only when no other entry owns it too. An entry whose alias is this entry's
595 server_name shares the identifier, and pinning it would take that entry's grants.
596 """
597 claimed_by = assigned_server_ids.get(server_id)
598 if claimed_by is not None:
599 raise ValueError(
600 f"Invalid config for MCP server '{server_name}': server_id '{server_id}' is already "
601 f"used by MCP server '{claimed_by}'. Each mcp_servers entry needs its own id."
602 )
603 if pinned and server_id in db_backed_server_ids:
604 raise ValueError(
605 f"Invalid config for MCP server '{server_name}': server_id '{server_id}' belongs to a "
606 "database-backed MCP server. The database entry takes precedence over config.yaml, so "
607 "this server would never be reachable."
608 )
609 other_owners: Final = identifier_owners.get(server_id, frozenset()) - frozenset((server_name,))
610 if pinned and other_owners:
611 owner_names: Final = "', '".join(sorted(other_owners))
612 raise ValueError(
613 f"Invalid config for MCP server '{server_name}': server_id '{server_id}' is the "
614 f"server_name or alias of MCP server '{owner_names}'. Permission entries naming "
615 f"'{server_id}' would resolve to '{server_name}' alone and no longer reach '{owner_names}'."
616 )
619def _uses_issuer_anchor(manual_issuer: str | None, is_discovery_auth_type: bool) -> bool:
620 """Whether the endpoints are authoritatively anchored to an admin-pinned issuer (RFC 8414 §3.3).
622 This is the trust/provenance property, distinct from whether the ``issuer`` field is merely
623 populated: a trust-on-first-use discovered issuer sets ``issuer`` for token identity but is NOT
624 anchored, so its endpoints stay resource-rooted. Anchoring holds only when the issuer was pinned
625 (present on the row/config) on a discovery auth type. Every consumer of "is this anchored" reads
626 this one definition, so the answer cannot diverge across build paths.
627 """
628 return _blank_to_none(manual_issuer) is not None and is_discovery_auth_type
631def _has_oauth_discovery_source(server_url: str | None, use_issuer_anchor: bool) -> bool:
632 """Whether the server has any source OAuth discovery can fetch metadata from.
634 Resource-rooted discovery (RFC 9728) is fetched from the server ``url``, so spec-only
635 (OpenAPI) and stdio servers, which have none, could never discover: their OAuth endpoints
636 stayed unset unless entered manually and ``/authorize`` served its 400 with no hint of why.
637 An admin-pinned issuer is a trust anchor in its own right (RFC 8414 section 3.3) whose
638 metadata fetch does not touch the resource at all, so an anchored server can discover with
639 no ``url``. Called by both build paths (config and DB) so the two cannot disagree on when
640 discovery is reachable.
641 """
642 return bool(server_url) or use_issuer_anchor
645def _endpoints_yield_to_issuer(
646 issuer: str | None,
647 is_discovery_auth_type: bool,
648 authorization_url: str | None,
649 token_url: str | None,
650 registration_url: str | None,
651 server_ref: str,
652) -> tuple[str | None, str | None, str | None]:
653 """The single rule that makes an admin-configured ``issuer`` the sole authoritative endpoint
654 source (RFC 8414 §3.3): when it is set for a discovery auth type, the stored/manual
655 ``authorization_url``/``token_url``/``registration_url`` do not apply. They neither anchor nor
656 short-circuit discovery, never override the issuer document in the merge, and never substitute for
657 it when the issuer fetch fails (fail-closed). Returns the endpoint values that remain in force,
658 i.e. all ``None`` when issuer-anchored, else the inputs unchanged. Called at every resolution site
659 so the invariant holds in one place instead of being re-derived per merge.
660 """
661 if issuer is None or not is_discovery_auth_type:
662 return authorization_url, token_url, registration_url
663 discarded: Final = sorted(
664 label
665 for label, value in (
666 ("authorization_url", authorization_url),
667 ("token_url", token_url),
668 ("registration_url", registration_url),
669 )
670 if value
671 )
672 if discarded: 672 ↛ 673line 672 didn't jump to line 673 because the condition on line 672 was never true
673 verbose_logger.warning(
674 "MCP server %s has a pinned Issuer, so its stored %s %s not used: an anchored issuer is the "
675 "sole endpoint source (RFC 8414 section 3.3) and a failed issuer fetch fails closed rather "
676 "than falling back to them. To use manually configured endpoints instead, clear the Issuer "
677 "field and re-enter the endpoint urls (clearing the Issuer also clears endpoints that may "
678 "have been resolved under it), or clear the Issuer alone to re-discover from the server url.",
679 server_ref,
680 ", ".join(discarded),
681 "is" if len(discarded) == 1 else "are",
682 )
683 return None, None, None
686def _normalized_authorize_endpoint(url: str) -> str:
687 """Compare authorize endpoints / issuers on scheme, host, and path only, through the shared URL
688 canonicalizer: the default port is elided and the host is lowercased so
689 ``https://IDP.example.com:443/authorize/`` and ``https://idp.example.com/authorize`` are the same
690 identity, while query, fragment and a trailing slash are dropped."""
691 return canonicalize_url_identity(url)
694def _issuer_matches(claimed_issuer: object, configured_issuer: str) -> bool:
695 """RFC 8414 §3.3 issuer equality between the metadata document's self-attested ``issuer`` and the
696 admin-configured issuer, tolerant only of URL-insignificant differences (scheme/host case, the
697 default port, a trailing slash). A non-string or empty claimed issuer never matches, so a
698 document that omits ``issuer`` fails closed under issuer-anchored discovery.
699 """
700 if not isinstance(claimed_issuer, str) or not claimed_issuer:
701 return False
702 return _normalized_authorize_endpoint(claimed_issuer) == _normalized_authorize_endpoint(configured_issuer)
705def _flow_endpoints_missing(
706 auth_type: MCPAuthType | None,
707 oauth2_flow: str | None,
708 authorization_url: str | None,
709 token_url: str | None,
710 token_exchange_endpoint: str | None = None,
711) -> bool:
712 """Whether a built server is missing an endpoint its flow needs to run at all.
714 Used by the reload fast-path exemption: discovery runs at build time only, and the fast path
715 reuses an unchanged row's registry entry verbatim, so a server whose discovery came back empty
716 (transient upstream failure, rate limiting) would stay broken until some unrelated config write
717 bumps ``updated_at``, serving its 400 the whole time. Rebuilding just these entries retries
718 discovery on the normal reload cadence. It costs no extra fetch for servers that resolved, and
719 none for those with no discovery source, since the build skips discovery for both.
720 """
721 if auth_type == MCPAuth.oauth2_token_exchange:
722 # A configured exchange endpoint replaces discovery entirely; only a server that must
723 # discover its token endpoint and still has none is unresolved.
724 return token_exchange_endpoint is None and token_url is None
725 if auth_type not in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES:
726 return False
727 if oauth2_flow == "client_credentials": 727 ↛ 728line 727 didn't jump to line 728 because the condition on line 727 was never true
728 return token_url is None
729 return authorization_url is None or token_url is None
732def _oauth_endpoints_unresolved(server: MCPServer) -> bool:
733 """``_flow_endpoints_missing`` over a built registry entry, for the reload fast-path check.
735 The flow comes from ``effective_oauth2_flow``, the one column-first, shape-fallback judge every
736 flow decision uses, not from the raw column: a legacy row the startup backfill deliberately left
737 unstamped (the ambiguous M2M shape) serves M2M at request time, and reading the bare column here
738 would classify it as interactive-missing-endpoints and re-run discovery on every reload.
739 """
740 if ( 740 ↛ 748line 740 didn't jump to line 748 because the condition on line 740 was never true
741 server.auth_type == MCPAuth.oauth2_token_exchange
742 and server.token_exchange_profile == "entra_obo"
743 and not server.scopes
744 ):
745 # entra_obo fails closed at exchange time without a scope (token_exchanger.py), and scopes
746 # can come from resource discovery, so a server that resolved its endpoints but no scopes is
747 # still unresolved for its flow.
748 return True
749 if server.is_dcr_bridge and not server.client_id and server.effective_registration_url is None:
750 # A DCR bridge with no admin-configured client can only register callers through the
751 # upstream's registration endpoint, so a build that resolved the authorize and token
752 # endpoints but not registration_endpoint (partial metadata) is still unresolved for its
753 # flow and must keep retrying; without this it silently degrades to the short-circuit arm
754 # until an unrelated config write. Scopes are deliberately NOT part of completeness: they
755 # are a request hint the authorization server bounds at consent (RFC 6749 section 3.3),
756 # and a server without them is fully functional.
757 return True
758 return _flow_endpoints_missing(
759 server.auth_type,
760 MCPServerManager.effective_oauth2_flow(server),
761 server.effective_authorization_url,
762 server.effective_token_url,
763 server.token_exchange_endpoint,
764 )
767def _endpoints_corroborate_authorization_url(
768 source_authorization_url: str | None,
769 trusted_authorization_url: str | None,
770) -> bool:
771 """Whether a source's ``token_url``/``registration_url`` may be paired with a trusted authorize
772 endpoint. This is the single trust rule for adopting OAuth endpoints from any non-manual source.
774 Discovery is rooted at the MCP resource (RFC 9728), so a compromised upstream can advertise an
775 attacker-run authorization server. When ``authorization_url`` is admin-pinned, pairing it with a
776 ``token_url`` from a different source is the RFC 9700 authorization-server mix-up: the user signs
777 in at the trusted authorize endpoint while the gateway redeems the code, with the stored client
778 secret and PKCE verifier, at the attacker's token endpoint. Endpoints are trustworthy together
779 only when they share an authorization server, so a source's endpoints are adopted only when the
780 same source advertised an ``authorization_endpoint`` matching the pinned value. With no pinned
781 value (``trusted_authorization_url is None``) there is nothing to protect: the authorize endpoint
782 comes from the same source as the token endpoint, so they corroborate each other by construction.
783 """
784 if not (trusted_authorization_url and trusted_authorization_url.strip()): 784 ↛ 786line 784 didn't jump to line 786 because the condition on line 784 was always true
785 return True
786 return bool(source_authorization_url) and _normalized_authorize_endpoint(
787 source_authorization_url
788 ) == _normalized_authorize_endpoint(trusted_authorization_url)
791def _carry_forward_resolved_oauth_endpoints(new_server: MCPServer, previous_server: MCPServer | None) -> None:
792 """Keep the last known good OAuth endpoints when a rebuild's re-discovery comes back empty.
794 A rebuild wholesale-replaces the registry entry, so without this a transient upstream outage
795 during re-discovery downgrades a working server (``authorization_url`` set) to a broken one
796 (``None``, /authorize 400s) with no configuration change. Mirrors the ``short_prefix``
797 carry-forward. Skipped when the server's ``url`` or ``auth_type`` changed, since the previous
798 endpoints may then belong to a different upstream. Discovery results live only on the in-memory
799 registry entry; the gateway never writes them to the row, whose OAuth columns carry admin intent
800 alone, so this carry is the sole last-known-good mechanism and restores exactly the values the
801 previous build already ran with.
803 Carry-forward is a non-manual endpoint source, so the same trust rule as discovery applies: the
804 previous ``token_url``/``registration_url``/``scopes`` are carried only when the previous
805 ``authorization_url`` corroborates the authorize endpoint this build will use, i.e. when the
806 incoming build has no pinned authorize endpoint (``None`` -> we adopt the previous one too, a
807 consistent group) or pins the same one. An admin re-pointing ``authorization_url`` to a different
808 server must not keep serving the old server's token endpoint or granted scopes.
810 When the server is issuer-anchored (``issuer_is_anchored`` -- a pinned issuer on a discovery auth
811 type), the endpoints come solely from the §3.3-validated issuer document, so carry-forward is
812 skipped entirely for its endpoints: a failed issuer fetch leaves them ``None`` and must stay
813 ``None`` (fail-closed), never resurrected from the previous registry entry. A merely discovered
814 (trust-on-first-use) issuer is NOT anchored -- ``issuer`` is set for token identity but the
815 endpoints are resource-rooted, so they still carry forward as last-known-good, gated by the
816 corroboration check below like any other resource-rooted server. Scopes stay resource-driven and
817 can carry either way.
818 """
819 if previous_server is None:
820 return
821 if previous_server.url != new_server.url or previous_server.auth_type != new_server.auth_type:
822 return
823 if new_server.issuer_is_anchored: 823 ↛ 827line 823 didn't jump to line 827 because the condition on line 823 was never true
824 # Endpoints come solely from the §3.3-validated issuer document; a failed fetch stays
825 # fail-closed and must not be resurrected from the previous entry. Only the resource-driven
826 # scopes carry as last-known-good.
827 if not new_server.scopes and previous_server.scopes:
828 new_server.scopes = previous_server.scopes
829 return
830 may_carry: Final = _endpoints_corroborate_authorization_url(
831 previous_server.authorization_url, new_server.authorization_url
832 )
833 if new_server.authorization_url is None and previous_server.authorization_url: 833 ↛ 834line 833 didn't jump to line 834 because the condition on line 833 was never true
834 new_server.authorization_url = previous_server.authorization_url
835 if may_carry and new_server.token_url is None and previous_server.token_url: 835 ↛ 836line 835 didn't jump to line 836 because the condition on line 835 was never true
836 new_server.token_url = previous_server.token_url
837 if may_carry and new_server.registration_url is None and previous_server.registration_url: 837 ↛ 838line 837 didn't jump to line 838 because the condition on line 837 was never true
838 new_server.registration_url = previous_server.registration_url
839 if may_carry and not new_server.scopes and previous_server.scopes: 839 ↛ 840line 839 didn't jump to line 840 because the condition on line 839 was never true
840 new_server.scopes = previous_server.scopes
843def _restrict_discovery_to_corroborated_authorization_server(
844 metadata: MCPOAuthMetadata | None,
845 manual_authorization_url: str | None,
846 server_identifier: str,
847 is_dcr_bridge: bool,
848) -> MCPOAuthMetadata | None:
849 """Reject discovered token/registration endpoints a manually pinned authorize endpoint cannot
850 vouch for (the RFC 9700 authorization-server mix-up).
852 Discovery is rooted at the MCP resource, so a compromised upstream can advertise an attacker
853 ``token_endpoint``: with ``authorization_url`` admin-pinned but ``token_url`` blank, the merge
854 would pair the trusted authorize endpoint with that attacker token endpoint, and the gateway would
855 post the authorization code and client secret there. So the discovered ``token_url`` and
856 ``registration_url`` are kept only if the document corroborates the pin (its
857 ``authorization_endpoint`` matches). ``scopes`` are deliberately NOT gated here: per the MCP
858 authorization spec Scope Selection Strategy and RFC 9700 §2.3, the scopes a client requests are
859 resource-driven (the WWW-Authenticate challenge or the RFC 9728 protected-resource
860 ``scopes_supported``), and scope inflation by a compromised resource is bounded by the
861 authorization server and user consent (RFC 6749 §3.3), not by the client second-guessing the
862 request. With no pin there is no trust anchor to protect, so discovery is returned as-is.
863 """
864 if metadata is None or not (manual_authorization_url and manual_authorization_url.strip()): 864 ↛ 866line 864 didn't jump to line 866 because the condition on line 864 was always true
865 return metadata
866 if _endpoints_corroborate_authorization_url(metadata.authorization_url, manual_authorization_url):
867 return metadata
868 if not metadata.token_url and not metadata.registration_url:
869 return metadata
870 bridge_note: Final = (
871 " The discovered registration_url is rejected with it, so this dcr_bridge server stays on the"
872 " short-circuit registration arm."
873 if is_dcr_bridge and metadata.registration_url
874 else ""
875 )
876 verbose_logger.warning(
877 "MCP OAuth discovery for server %s advertised authorization_endpoint %s, which does not match the "
878 "manually configured authorization_url %s; rejecting the discovered token_url/registration_url so "
879 "authorization codes and client credentials only follow the configured authorization server. "
880 "Configure Token URL manually if the mismatch is intentional.%s",
881 server_identifier,
882 _normalized_authorize_endpoint(metadata.authorization_url) if metadata.authorization_url else "<absent>",
883 _normalized_authorize_endpoint(manual_authorization_url),
884 bridge_note,
885 )
886 return metadata.model_copy(update={"token_url": None, "registration_url": None})
889def _redacted_origin_list(urls: Sequence[str]) -> str:
890 return ", ".join(_redact_mcp_resource_url(url) or "<unparseable url>" for url in urls)
893def _sanitized_error_text(exc: Exception) -> str:
894 return re.sub(r"https?://\S+", "<url>", str(exc))[:200]
897async def _openapi_spec_health(
898 spec_path: str, *, timeout: float
899) -> tuple[Literal["healthy", "unhealthy", "unknown"], str | None]:
900 """Check specification availability, not upstream operations or user credentials."""
901 from litellm.llms.custom_httpx.http_handler import HTTPResponseLimitError
902 from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import load_openapi_spec_async
904 if not spec_path.startswith(("http://", "https://")):
905 return "unknown", "OpenAPI servers have no protocol-level health probe"
906 try:
907 await asyncio.wait_for(load_openapi_spec_async(spec_path, max_bytes=10 * 1024 * 1024), timeout=timeout)
908 except asyncio.TimeoutError:
909 return "unhealthy", f"OpenAPI specification check timed out after {timeout} seconds"
910 except HTTPStatusError as exc:
911 return "unhealthy", f"OpenAPI specification request failed (HTTP {exc.response.status_code})"
912 except HTTPResponseLimitError as exc:
913 return "unknown", f"OpenAPI specification probe refused: {exc}"
914 except (httpx.RequestError, ValueError, OSError) as exc:
915 return "unhealthy", f"OpenAPI specification could not be loaded ({type(exc).__name__})"
916 return "healthy", None
919class _OpenAPIHealthProbe:
920 def __init__(self, spec_path: str, clock: Callable[[], float] = time.monotonic) -> None:
921 self.spec_path = spec_path
922 self.clock = clock
923 self.lock = asyncio.Lock()
924 self.checked_at = float("-inf")
925 self.result: tuple[Literal["healthy", "unhealthy", "unknown"], str | None, datetime.datetime] | None = None
927 async def check(self) -> tuple[Literal["healthy", "unhealthy", "unknown"], str | None, datetime.datetime]:
928 async with self.lock:
929 if self.result is not None and self.clock() - self.checked_at < 30.0:
930 return self.result
931 try:
932 status, error = await _openapi_spec_health(self.spec_path, timeout=MCP_HEALTH_CHECK_TIMEOUT)
933 except asyncio.CancelledError:
934 return (
935 "unknown",
936 "OpenAPI specification check was cancelled",
937 datetime.datetime.now(datetime.timezone.utc),
938 )
939 self.result = (status, error, datetime.datetime.now(datetime.timezone.utc))
940 self.checked_at = self.clock()
941 return self.result
944def _discovery_failure_leaves_needs_unresolved(
945 *,
946 needs_authorization_url: bool,
947 needs_token_url: bool,
948 manual_authorization_url: str | None,
949 manual_token_url: str | None,
950) -> bool:
951 return (needs_authorization_url and not manual_authorization_url) or (needs_token_url and not manual_token_url)
954def _warn_oauth_endpoints_unresolved(
955 *,
956 server_ref: str,
957 server_url: str | None,
958 discovery_attempted: bool,
959 discovery_deferred: bool = False,
960 issuer_anchored: bool,
961 metadata: MCPOAuthMetadata | None,
962 needs_authorization_url: bool,
963 needs_token_url: bool,
964 manual_authorization_url: str | None,
965 manual_token_url: str | None,
966) -> None:
967 """Log one actionable warning when a server that depends on OAuth endpoint discovery finishes a
968 build without the endpoints that its flows need (LIT-4658).
970 This is the operator-facing signal for a misconfigured server url: discovery failures themselves
971 are logged where they happen (``_descovery_metadata``), and this names WHICH server is affected,
972 which endpoints stayed unresolved after manual configuration was considered, and the remedies.
973 Scopes never trigger the warning on their own: scope-less metadata is normal for many servers and
974 warning on it every rebuild would be noise. Callers own the per-flow policy of which endpoints
975 are needed (client_credentials never needs authorization_url; OBO needs only token_url); the
976 issuer-anchored arm is excluded here because it has its own RFC 8414 §3.3 warning.
977 """
978 if discovery_deferred or issuer_anchored: 978 ↛ 979line 978 didn't jump to line 979 because the condition on line 978 was never true
979 return
980 unresolved: Final = tuple(
981 field
982 for field, needed, value in (
983 (
984 "authorization_url",
985 needs_authorization_url,
986 manual_authorization_url or (metadata.authorization_url if metadata else None),
987 ),
988 (
989 "token_url",
990 needs_token_url,
991 manual_token_url or (metadata.token_url if metadata else None),
992 ),
993 )
994 if needed and not value
995 )
996 if not unresolved:
997 return
998 if discovery_attempted: 998 ↛ 999line 998 didn't jump to line 999 because the condition on line 998 was never true
999 verbose_logger.warning(
1000 "MCP server %s: OAuth endpoint discovery left %s unresolved (server url origin: %s). OAuth flows "
1001 "that need them will fail with 'not configured' errors until they resolve. Check the preceding "
1002 "'MCP OAuth' log lines for why discovery failed, verify the configured server url, or set the "
1003 "unresolved endpoint urls manually, or set issuer to discover them from the identity provider "
1004 "(RFC 8414)",
1005 server_ref,
1006 ", ".join(unresolved),
1007 _redact_mcp_resource_url(server_url) or "<no url>",
1008 )
1009 return
1010 verbose_logger.warning(
1011 "MCP server %s uses OAuth but has no discovery source (no server url or pinned issuer), and %s not "
1012 "set manually. Set the missing endpoint urls on the server, or set issuer to discover them from the "
1013 "identity provider (RFC 8414)",
1014 server_ref,
1015 " and ".join(unresolved) + (" is" if len(unresolved) == 1 else " are"),
1016 )
1019def invalidate_user_env_vars_cache(user_id: str, server_id: str) -> None:
1020 """Drop a cached entry after the user stores or clears their env var values
1021 so the next request reads the fresh value instead of a stale one."""
1022 _user_env_vars_cache.pop((user_id, server_id), None)
1025def _write_user_env_vars_cache(user_id: str, server_id: str, values: dict[str, str]) -> None:
1026 cache_key: Final = (user_id, server_id)
1027 # Re-insert at the tail so eviction drops the oldest-written entry, not a
1028 # freshly refreshed one, and only sheds a single entry instead of wiping the
1029 # whole cache (which would stampede the DB).
1030 _user_env_vars_cache.pop(cache_key, None)
1031 if len(_user_env_vars_cache) >= _USER_ENV_VARS_CACHE_MAX_SIZE:
1032 _user_env_vars_cache.pop(next(iter(_user_env_vars_cache)), None)
1033 _user_env_vars_cache[cache_key] = (values, time.monotonic())
1036def _should_strip_caller_authorization(
1037 mcp_server: MCPServer,
1038 raw_headers: dict[str, str] | None,
1039 user_api_key_auth: UserAPIKeyAuth | None,
1040) -> bool:
1041 """Decide whether the caller's ``Authorization`` header must NOT be
1042 forwarded upstream when populating ``extra_headers`` for an MCP server.
1044 Centralized so ``_call_regular_mcp_tool`` (this module) and
1045 ``_prepare_mcp_server_headers`` (``server.py``) cannot drift apart on
1046 this security-sensitive decision.
1048 Strip rules:
1049 - **M2M (client_credentials) servers**: never forward the caller's
1050 ``Authorization`` — the proxy fetches its own upstream token.
1051 - **Migrated per-user OAuth (authorization_code) servers**: never forward
1052 the caller's ``Authorization`` — the v2 resolver injects the stored
1053 per-user token, so a caller-supplied bearer cannot override another
1054 user's stored credential. Delegate / pass-through keep forwarding it.
1055 - **OAuth pass-through servers**: strip when the ``Authorization``
1056 header is actually the LiteLLM API key — either because admission
1057 validated it (``user_api_key_auth.api_key`` is set) and the caller
1058 did NOT also supply ``x-litellm-api-key`` to disambiguate, or
1059 because the legacy ``user_api_key_auth is None`` call sites did
1060 not supply an explicit admission header. In the anonymous /
1061 pass-through cold-start case (RFC 9728) the bearer in
1062 ``Authorization`` is the upstream OAuth token and must be
1063 forwarded, so we keep it.
1064 - **Delegated OAuth servers**: admission always runs and there is no
1065 anonymous path, so the caller's separate ``Authorization`` is
1066 forwarded only when a distinct ``x-litellm-api-key`` carried
1067 admission. Without that header the ``Authorization`` *was* the
1068 admission credential — a virtual key, an IdP JWT, or an SSO / OIDC /
1069 session token whose ``api_key`` is ``None`` — and must never reach
1070 the upstream, so it is stripped regardless of the ``api_key`` value.
1071 """
1072 if mcp_server.auth_type == MCPAuth.oauth2_token_exchange:
1073 # OBO: the inbound Authorization is the subject token. It is exchanged at the IdP and only the
1074 # exchanged token is sent upstream, so the raw caller bearer must never be forwarded.
1075 return True
1076 if mcp_server.has_client_credentials:
1077 return True
1078 if mcp_server.auth_type == MCPAuth.oauth2 and to_server_spec(mcp_server) is not None:
1079 # Migrated per-user OAuth (authorization_code): the v2 resolver injects the
1080 # stored token, so a caller-forwarded Authorization must not be forwarded
1081 # upstream — it would override another user's stored credential. Delegate and
1082 # pass-through return None from to_server_spec and keep forwarding the bearer.
1083 return True
1084 is_delegated_oauth: Final = mcp_server.is_oauth_delegate or (
1085 mcp_server.auth_type == MCPAuth.oauth2 and mcp_server.delegate_auth_to_upstream
1086 )
1087 if not (mcp_server.is_oauth_passthrough or is_delegated_oauth):
1088 return False
1090 has_explicit_litellm_admission_header: Final = _has_explicit_litellm_admission_header(raw_headers)
1091 if is_delegated_oauth:
1092 return not has_explicit_litellm_admission_header or _authorization_is_litellm_admission_credential(
1093 raw_headers, user_api_key_auth
1094 )
1095 return _authorization_is_litellm_admission_credential(raw_headers, user_api_key_auth) or (
1096 user_api_key_auth is None and not has_explicit_litellm_admission_header
1097 )
1100LITELLM_VIRTUAL_KEY_PREFIX: Final = "sk-"
1103def _raw_header_value(raw_headers: Mapping[str, str] | None, name: str) -> str | None:
1104 return next((v for k, v in (raw_headers or {}).items() if isinstance(k, str) and k.lower() == name), None)
1107def _has_explicit_litellm_admission_header(raw_headers: Mapping[str, str] | None) -> bool:
1108 """Admission only consumes a non-empty ``x-litellm-api-key``; an empty one falls back to ``Authorization``."""
1109 return bool(_raw_header_value(raw_headers, "x-litellm-api-key"))
1112def _authorization_is_litellm_admission_credential(
1113 raw_headers: Mapping[str, str] | None,
1114 user_api_key_auth: UserAPIKeyAuth | None,
1115) -> bool:
1116 """True when ``Authorization`` carries the LiteLLM key admission validated.
1118 That is the case when no usable ``x-litellm-api-key`` was sent, or when the client repeated the
1119 same key in both headers.
1120 """
1121 admission_header: Final = _raw_header_value(raw_headers, "x-litellm-api-key")
1122 authorization: Final = _raw_header_value(raw_headers, "authorization")
1123 if admission_header and authorization:
1124 return strip_auth_scheme(authorization, "Bearer") == strip_auth_scheme(admission_header, "Bearer")
1125 return bool(user_api_key_auth and user_api_key_auth.api_key and not admission_header)
1128def _format_byok_openapi_auth_header(mcp_server: MCPServer, mcp_auth_header: str) -> str:
1129 """Format a raw BYOK credential for OpenAPI tool ``Authorization`` injection.
1131 A non-BYOK server short-circuits ``_resolve_byok_mcp_auth_header``, so the value here can also
1132 be the deprecated global ``x-mcp-auth``, which is a complete header value and would otherwise
1133 be given a second scheme.
1134 """
1135 if mcp_server.auth_type == MCPAuth.api_key:
1136 return f"ApiKey {strip_auth_scheme(mcp_auth_header, 'ApiKey')}"
1137 if mcp_server.auth_type == MCPAuth.basic:
1138 return f"Basic {strip_auth_scheme(mcp_auth_header, 'Basic')}"
1139 return f"Bearer {strip_auth_scheme(mcp_auth_header, 'Bearer')}"
1142def _openapi_forwarded_extra_headers(
1143 mcp_server: MCPServer,
1144 raw_headers: dict[str, str] | None,
1145 user_api_key_auth: UserAPIKeyAuth | None,
1146) -> dict[str, str] | None:
1147 if not mcp_server.extra_headers or not raw_headers:
1148 return None
1149 normalized_raw: Final = {str(k).lower(): v for k, v in raw_headers.items() if isinstance(k, str)}
1150 skip_caller_authorization: Final = _should_strip_caller_authorization(
1151 mcp_server=mcp_server,
1152 raw_headers=raw_headers,
1153 user_api_key_auth=user_api_key_auth,
1154 )
1155 forwarded: Final[dict[str, str]] = {}
1156 for header_name in mcp_server.extra_headers:
1157 if not isinstance(header_name, str):
1158 continue
1159 if skip_caller_authorization and header_name.lower() == "authorization":
1160 continue
1161 value = normalized_raw.get(header_name.lower())
1162 if value is not None:
1163 forwarded[header_name] = value
1164 return forwarded or None
1167def _resolve_openapi_tool_auth(
1168 mcp_server: MCPServer,
1169 mcp_auth_header: str | None,
1170 mcp_server_auth_headers: Mapping[str, str | dict[str, str]] | None, # mutable-ok: sink shape
1171 raw_headers: dict[str, str] | None, # mutable-ok: sink takes a concrete dict
1172 user_api_key_auth: UserAPIKeyAuth | None,
1173) -> tuple[str | None, dict[str, str] | None, str | dict[str, str] | None]: # mutable-ok: sink shapes
1174 """The caller's upstream credential for one ``spec_path`` server, for both OpenAPI dispatch arms.
1176 A per-server ``x-mcp-{alias}-authorization`` wins over the deprecated global / BYOK
1177 ``mcp_auth_header``, the same precedence ``_call_regular_mcp_tool`` applies, so the OpenAPI and
1178 managed paths cannot disagree about which credential is authoritative. The two kinds are not
1179 interchangeable: a per-server value is already a complete header value and is forwarded verbatim,
1180 while a BYOK credential is a raw secret that takes the server's auth-type prefix. Formatting the
1181 former would ship ``Bearer Bearer <token>``.
1183 Returns the ``Authorization`` value to inject, the extra headers to forward, and the credential to
1184 hand ``resolve_openapi_upstream_auth``, whose passthrough arm reads it via
1185 ``_passthrough_token_from_mcp_auth_header``. The per-server Authorization travels only in the
1186 credential, never also in the forwarded headers, because the resolver pops Authorization out of
1187 those and would otherwise have two sources to reconcile.
1188 """
1189 forwarded: Final = _openapi_forwarded_extra_headers(mcp_server, raw_headers, user_api_key_auth)
1190 per_server: Final = (
1191 lookup_mcp_server_auth_in_headers(
1192 mcp_server_auth_headers,
1193 alias=mcp_server.alias,
1194 server_name=mcp_server.server_name,
1195 access_groups=mcp_server.access_groups,
1196 )
1197 if mcp_server_auth_headers
1198 else None
1199 )
1201 if isinstance(per_server, dict):
1202 authorization: Final = next((v for k, v in per_server.items() if k.lower() == "authorization"), None)
1203 merged: Final = merge_mcp_headers(
1204 extra_headers=forwarded, static_headers=without_header(per_server, DEFAULT_CREDENTIAL_HEADER)
1205 )
1206 if authorization is None:
1207 byok: Final = _format_byok_openapi_auth_header(mcp_server, mcp_auth_header) if mcp_auth_header else None
1208 return byok, merged, mcp_auth_header
1209 return authorization, merged, per_server
1210 if isinstance(per_server, str) and per_server:
1211 return per_server, forwarded, per_server
1212 if mcp_auth_header:
1213 return _format_byok_openapi_auth_header(mcp_server, mcp_auth_header), forwarded, mcp_auth_header
1214 return None, forwarded, None
1217async def _resolve_byok_mcp_auth_header(
1218 mcp_server: MCPServer,
1219 user_api_key_auth: UserAPIKeyAuth | None,
1220 mcp_auth_header: str | None,
1221) -> str | None:
1222 """Resolve BYOK credential for tool calls that bypass ``execute_mcp_tool``."""
1223 if not mcp_server.is_byok:
1224 return mcp_auth_header
1226 from litellm.proxy._experimental.mcp_server.operations import (
1227 _check_byok_credential,
1228 _get_byok_credential,
1229 )
1231 if not mcp_auth_header:
1232 byok_cred: Final = await _get_byok_credential(mcp_server, user_api_key_auth)
1233 if byok_cred is None:
1234 raise HTTPException(
1235 status_code=401,
1236 detail={
1237 "error": "byok_auth_required",
1238 "server_id": mcp_server.server_id,
1239 "server_name": mcp_server.server_name or mcp_server.name,
1240 "message": (
1241 "No stored credential found for this BYOK server. "
1242 "Complete the OAuth authorization flow to provide your API key."
1243 ),
1244 },
1245 headers={"WWW-Authenticate": get_byok_www_authenticate()},
1246 )
1247 return byok_cred
1249 await _check_byok_credential(mcp_server, user_api_key_auth)
1250 return mcp_auth_header
1253def _client_forwarded_authorization_headers(
1254 mcp_server: MCPServer,
1255 oauth2_headers: dict[str, str] | None,
1256 raw_headers: dict[str, str] | None,
1257 user_api_key_auth: UserAPIKeyAuth | None,
1258) -> dict[str, str] | None:
1259 """Egress headers for the client-forwarded-token modes (``true_passthrough`` / ``oauth_delegate``).
1261 Forwards the caller's ``Authorization`` to the upstream, stripped when
1262 ``_should_strip_caller_authorization`` says it was consumed as the LiteLLM admission key. Shared by
1263 ``_call_regular_mcp_tool`` and ``server.py``'s ``_prepare_mcp_server_headers`` so the two egress
1264 paths cannot drift, mirroring the ``_should_strip_caller_authorization`` split.
1265 """
1266 extra_headers: Final = oauth2_headers.copy() if oauth2_headers else None
1267 if extra_headers and _should_strip_caller_authorization(
1268 mcp_server=mcp_server,
1269 raw_headers=raw_headers,
1270 user_api_key_auth=user_api_key_auth,
1271 ):
1272 return without_header(extra_headers, DEFAULT_CREDENTIAL_HEADER)
1273 return extra_headers
1276def _take_forwarded_authorization(
1277 headers: dict[str, str] | None,
1278) -> tuple[str | None, dict[str, str] | None]:
1279 """Pop the ``Authorization`` value out of ``headers`` (case-insensitive), returning it with the
1280 remaining headers, so the passthrough resolver arm is the single Authorization source rather than
1281 the header also riding in ``extra_headers`` (which the resolved auth would then defer to)."""
1282 if not headers: 1282 ↛ 1284line 1282 didn't jump to line 1284 because the condition on line 1282 was always true
1283 return None, headers
1284 value: Final = next((v for k, v in headers.items() if k.lower() == "authorization"), None)
1285 return value, without_header(headers, DEFAULT_CREDENTIAL_HEADER)
1288def _passthrough_token_from_mcp_auth_header(
1289 mcp_auth_header: str | dict[str, str] | None,
1290) -> str | None:
1291 """The caller's per-server upstream credential for a passthrough-mode server, or None.
1293 Sourced from ``x-mcp-{alias}-authorization`` (string or per-header dict form) or the deprecated
1294 global ``x-mcp-auth`` fallback. Per-server headers are the multi-server shape: they bind one
1295 token to one server, so an aggregate scope with several passthrough-mode servers never replays
1296 a single credential across upstreams. The value is forwarded verbatim, so it must be the full
1297 header value (e.g. ``Bearer <upstream-token>``)."""
1298 if isinstance(mcp_auth_header, str): 1298 ↛ 1299line 1298 didn't jump to line 1299 because the condition on line 1298 was never true
1299 return mcp_auth_header or None
1300 if isinstance(mcp_auth_header, dict): 1300 ↛ 1301line 1300 didn't jump to line 1301 because the condition on line 1300 was never true
1301 return next((v for k, v in mcp_auth_header.items() if k.lower() == "authorization"), None)
1302 return None
1305async def _materialize_auth_headers(auth: httpx2.Auth | None) -> dict[str, str] | None:
1306 """Extract the header a resolved ``httpx2.Auth`` would set, as a plain dict, or None.
1308 OpenAPI tool closures egress through ``AsyncHTTPHandler`` methods that accept headers but no
1309 ``auth``, so a resolved credential must be materialized into a header value. Driving one step
1310 of the auth's own flow (against a throwaway request that is never sent) keeps this generic
1311 across every auth shape without per-class branching; ``header_name`` is the resolver-arm
1312 convention for "this auth sets a header" (``NoOpAuth`` has none and yields nothing to apply).
1313 The materialized value is point-in-time: flow behaviors past the first request, like the M2M
1314 one-shot 401 refetch, do not apply on this arm.
1315 """
1316 if auth is None:
1317 return None
1318 header_name: Final = getattr(auth, "header_name", None)
1319 if not isinstance(header_name, str) or not header_name:
1320 return None
1321 probe: Final = httpx2.Request("GET", "http://localhost/")
1322 flow: Final = auth.async_auth_flow(probe)
1323 try:
1324 first_request: Final = await flow.__anext__()
1325 except StopAsyncIteration:
1326 return None
1327 finally:
1328 await flow.aclose()
1329 header_value: Final = first_request.headers.get(header_name)
1330 return {header_name: header_value} if header_value else None
1333def _consumes_caller_authorization(server: MCPServer) -> bool:
1334 """True when this server's egress forwards the caller's request-wide ``Authorization`` upstream:
1335 the client-forwarded token modes, legacy OAuth pass-through, and legacy upstream-delegated
1336 interactive oauth2. An unstamped oauth2 row (flow column not yet backfilled) reads as a consumer,
1337 which errs toward suppression — the fail-safe direction."""
1338 if server.is_true_passthrough or server.is_oauth_delegate or server.is_oauth_passthrough:
1339 return True
1340 return (
1341 server.auth_type == MCPAuth.oauth2
1342 and getattr(server, "delegate_auth_to_upstream", False) is True
1343 and not server.has_client_credentials
1344 )
1347_REGISTRY_DUMP_SECRET_FIELDS: Final = frozenset(
1348 {"authentication_token", "client_secret", "client_private_key", "aws_secret_access_key", "aws_session_token"}
1349)
1352def _redacted_registry_dump(servers: dict[str, MCPServer]) -> dict[str, dict[str, str]]:
1353 """A JSON-safe view of the server registry with credential fields masked, for debug logging.
1355 The registry holds long-lived secrets as plain strings (the static token, OAuth client secret,
1356 the ID-JAG signing key, AWS keys); dumping them verbatim hands the gateway's client identity to
1357 anyone who can read debug logs.
1358 """
1359 dumps: dict[str, dict[str, object]] = {server_id: server.model_dump() for server_id, server in servers.items()}
1360 return {
1361 server_id: {
1362 field: ("**REDACTED**" if field in _REGISTRY_DUMP_SECRET_FIELDS and value is not None else str(value))
1363 for field, value in dump.items()
1364 }
1365 for server_id, dump in dumps.items()
1366 }
1369def _to_server_spec_fail_closed(server: MCPServer) -> ServerSpec | None:
1370 """`to_server_spec`, except a half-configured `oauth2_id_jag` server refuses instead of deferring.
1372 ID-JAG has no v1 arm, so deferring to v1 would let `resolve_mcp_auth` honor a caller x-mcp-*
1373 override or fall through to the static `authentication_token`, both of which bypass the per-user
1374 identity assertion the mode promises. That is an operator misconfiguration, not a fallback.
1375 """
1376 spec: Final = to_server_spec(server)
1377 if spec is None and server.auth_type == MCPAuth.oauth2_id_jag: 1377 ↛ 1378line 1377 didn't jump to line 1378 because the condition on line 1377 was never true
1378 raise_public(
1379 CredError.of_misconfigured(
1380 "oauth2_id_jag requires token_exchange_endpoint, id_jag_resource_token_endpoint, "
1381 "client_id, and a client_secret or client_private_key; refusing to fall back to "
1382 "a static credential."
1383 )
1384 )
1385 return spec
1388def _caller_authorization_fans_out(
1389 server: MCPServer,
1390 scope_servers: list[MCPServer] | None,
1391) -> bool:
1392 """True when forwarding the caller's request-wide ``Authorization`` to ``server`` inside a
1393 listing fan-out would replay one credential against multiple upstreams: another server in the
1394 scope also consumes it (RFC 9700 cross-resource replay). ``scope_servers`` is None for
1395 explicitly-addressed operations (tool call, get_prompt, read_resource, single-server routes),
1396 where the client named the one target and the gateway is not choosing recipients."""
1397 if scope_servers is None: 1397 ↛ 1398line 1397 didn't jump to line 1398 because the condition on line 1397 was never true
1398 return False
1399 return any(
1400 other is not None and other.server_id != server.server_id and _consumes_caller_authorization(other)
1401 for other in scope_servers
1402 )
1405def _extract_upstream_auth_failure(
1406 exc: BaseException,
1407) -> tuple[int, str | None] | None:
1408 """The upstream 401/403 and its ``WWW-Authenticate`` header from the exception tree, or ``None``.
1410 Delegates to the shared traversal in ``faults`` so every consumer (tool listing,
1411 tool calls, the connect-time probe) selects the same response with the same deliberate order:
1412 explicit ``raise ... from`` causes first, ExceptionGroup members in raise order, the incidental
1413 ``__context__`` chain last. A response raised while handling the real failure can therefore never
1414 shadow the causal one."""
1415 return upstream_auth_challenge(exc)
1418def _upstream_failure_suffix(exc: BaseException) -> str:
1419 detail: Final = describe_upstream_http_failure(exc)
1420 return f"\n upstream exchange: {detail}" if detail else ""
1423def _obo_retry_applies(server: MCPServer, subject_token: str | None) -> bool:
1424 """Whether an upstream 401/403 should invalidate the minted credential and retry once.
1426 ``oauth2_token_exchange`` can only mint from an inbound subject token, so with no token there is
1427 nothing to re-mint and the plain single call is correct. ``oauth2_id_jag`` also sources its
1428 subject from the identity assertion stored for the user at SSO login, so it qualifies whether or
1429 not the caller presented a token of its own; gating it on the inbound token would leave a
1430 store-sourced bearer un-invalidated and replayed until its TTL.
1431 """
1432 if server.auth_type == MCPAuth.oauth2_id_jag:
1433 return True
1434 return server.auth_type == MCPAuth.oauth2_token_exchange and bool(subject_token)
1437def _warn_on_server_name_fields(
1438 *,
1439 server_id: str,
1440 alias: str | None,
1441 server_name: str | None,
1442):
1443 def _warn(field_name: str, value: str | None) -> None:
1444 if not value: 1444 ↛ 1446line 1444 didn't jump to line 1446 because the condition on line 1444 was always true
1445 return
1446 result: Final = validate_tool_name(value)
1447 if result.is_valid:
1448 return
1450 warning_text: Final = "; ".join(result.warnings) if result.warnings else "Validation failed"
1451 verbose_logger.warning(
1452 "MCP server '%s' has invalid %s '%s': %s",
1453 server_id,
1454 field_name,
1455 value,
1456 warning_text,
1457 )
1459 _warn("alias", alias)
1460 _warn("server_name", server_name)
1463def _warn_on_shared_identifier_prefixes(servers: Iterable[MCPServer]) -> None:
1464 """Warn once per identifier that several servers share.
1466 ``get_server_prefix`` resolves alias first, so two servers sharing a
1467 lowercased ``alias or server_name`` publish the same tool prefix and calls
1468 routed by that prefix are ambiguous. A write-time uniqueness check keeps
1469 new collisions out; this surfaces the ones already stored.
1470 """
1471 pairs: Final = tuple(
1472 ((server.alias or server.server_name or "").lower(), server.server_id)
1473 for server in servers
1474 if server.alias or server.server_name
1475 )
1476 groups: Final = MappingProxyType(
1477 {
1478 identifier: tuple(sorted(server_id for key, server_id in pairs if key == identifier))
1479 for identifier in frozenset(key for key, _server_id in pairs)
1480 }
1481 )
1482 for identifier, server_ids in groups.items(): 1482 ↛ 1483line 1482 didn't jump to line 1483 because the loop on line 1482 never started
1483 if len(server_ids) > 1:
1484 verbose_logger.warning(
1485 "MCP servers %s share the identifier '%s'; tool routing for that prefix is ambiguous. "
1486 "Rename or delete all but one.",
1487 sorted(server_ids),
1488 identifier,
1489 )
1492def _warn_legacy_delegate_auth_if_applicable(server: MCPServer, *, source: str) -> None:
1493 """Direct legacy delegated OAuth configurations to the admitted replacement."""
1494 if server.auth_type != MCPAuth.oauth2: 1494 ↛ 1496line 1494 didn't jump to line 1496 because the condition on line 1494 was always true
1495 return
1496 if getattr(server, "delegate_auth_to_upstream", False) is not True:
1497 return
1498 if server.has_client_credentials:
1499 return
1500 label: Final = get_server_prefix(server)
1501 verbose_logger.warning(
1502 "MCP server %r (id=%s, source=%s) uses deprecated auth_type=oauth2 with "
1503 "delegate_auth_to_upstream=true. LiteLLM admission is now required; migrate to "
1504 "auth_type=oauth_delegate for client-forwarded OAuth.",
1505 label,
1506 server.server_id,
1507 source,
1508 )
1511def _warn_config_id_jag_server_outruns_sso(server: MCPServer) -> None:
1512 if server.auth_type != MCPAuth.oauth2_id_jag:
1513 return
1514 gap: Final = id_jag_assertion_capture_gap_at_startup()
1515 if gap is None:
1516 return
1517 verbose_logger.warning(
1518 "MCP server %r (id=%s, source=config) is declared with auth_type=oauth2_id_jag, but %s.",
1519 get_server_prefix(server),
1520 server.server_id,
1521 gap,
1522 )
1525def _deserialize_json_dict(data: str | _StringMap | None) -> dict[str, str] | None:
1526 """
1527 Deserialize optional JSON mappings stored in the database.
1529 Accepts values kept as JSON strings or materialized dictionaries and
1530 returns None when the input is empty or cannot be decoded.
1531 """
1532 if not data:
1533 return None
1535 if isinstance(data, str): 1535 ↛ 1536line 1535 didn't jump to line 1536 because the condition on line 1535 was never true
1536 try:
1537 return json.loads(data)
1538 except (json.JSONDecodeError, TypeError):
1539 # If it's not valid JSON, return as-is (shouldn't happen but safety)
1540 return None
1541 else:
1542 # Already a dictionary
1543 return data
1546def _deserialize_json_list(data: object) -> list[dict[str, Any]] | None:
1547 """Deserialize a JSON array stored in the DB (``env_vars`` and friends).
1549 Returns ``None`` for empty / null / unparseable input. Accepts strings
1550 (raw JSON), already-materialized lists of dicts, and lists of Pydantic
1551 models (Prisma may hydrate a JSON column such as ``env_vars`` into
1552 ``MCPEnvVar`` objects); model entries are normalized to plain dicts so
1553 downstream consumers expecting ``List[Dict[str, Any]]`` validate.
1554 """
1555 if data is None or data == "" or data == []:
1556 return None
1557 if isinstance(data, str): 1557 ↛ 1558line 1557 didn't jump to line 1558 because the condition on line 1557 was never true
1558 try:
1559 parsed: Final[object] = json.loads(data)
1560 except (json.JSONDecodeError, TypeError):
1561 return None
1562 data = parsed
1563 if not isinstance(data, list): 1563 ↛ 1564line 1563 didn't jump to line 1564 because the condition on line 1563 was never true
1564 return None
1565 return [item.model_dump(mode="json") if hasattr(item, "model_dump") else item for item in data]
1568def _normalize_mcp_server_cost_info(mcp_info: MCPInfo) -> None:
1569 """Coerce ``mcp_server_cost_info`` numeric fields to ``float`` at ingest.
1571 YAML 1.1 parses scientific notation without a decimal point (e.g.
1572 ``7e-05``) as a string, and ``MCPServerCostInfo`` is a TypedDict with no
1573 runtime validation, so string-typed costs flow through to the UI and
1574 crash its ``.toFixed`` formatting. Values that cannot be coerced are
1575 dropped with a warning instead of failing the server load.
1576 """
1577 cost_info: Final = mcp_info.get("mcp_server_cost_info")
1578 if not isinstance(cost_info, dict): 1578 ↛ 1581line 1578 didn't jump to line 1581 because the condition on line 1578 was always true
1579 return
1581 server_name: Final = mcp_info.get("server_name")
1582 normalized: Final = dict(cost_info)
1584 default_cost: Final = normalized.get("default_cost_per_query")
1585 if default_cost is not None:
1586 try:
1587 normalized["default_cost_per_query"] = float(default_cost)
1588 except (TypeError, ValueError):
1589 verbose_logger.warning(
1590 "MCP server '%s' has non-numeric default_cost_per_query %r; ignoring it",
1591 server_name,
1592 default_cost,
1593 )
1594 del normalized["default_cost_per_query"]
1596 tool_costs: Final = normalized.get("tool_name_to_cost_per_query")
1597 if isinstance(tool_costs, dict):
1598 normalized_tool_costs: Final = {}
1599 for tool_name, cost in tool_costs.items():
1600 try:
1601 normalized_tool_costs[tool_name] = float(cost)
1602 except (TypeError, ValueError):
1603 verbose_logger.warning(
1604 "MCP server '%s' has non-numeric cost %r for tool '%s'; ignoring it",
1605 server_name,
1606 cost,
1607 tool_name,
1608 )
1609 normalized["tool_name_to_cost_per_query"] = normalized_tool_costs
1611 mcp_info["mcp_server_cost_info"] = normalized
1614def _create_sampling_callback(
1615 user_api_key_auth: UserAPIKeyAuth | None = None,
1616 raw_headers: Mapping[str, str] | None = None,
1617 client_ip: str | None = None,
1618 operation_context: OperationContext | None = None,
1619):
1620 if not MCP_SAMPLING_AVAILABLE:
1621 return None
1622 from litellm.proxy._experimental.mcp_server.legacy_callbacks import create_sampling_callback
1624 return create_sampling_callback(user_api_key_auth, raw_headers, client_ip, operation_context)
1627def _create_elicitation_callback():
1628 if not MCP_ELICITATION_AVAILABLE:
1629 return None
1630 from litellm.proxy._experimental.mcp_server.legacy_callbacks import create_elicitation_callback
1632 return create_elicitation_callback()
1635def _record_mcp_guardrail_evaluations(
1636 synthetic_llm_data: dict[str, Any], # mutable-ok: `_sync_guardrail_info_to_logging_obj` takes a concrete dict
1637 litellm_logging_obj: "LiteLLMLoggingObj | None",
1638) -> None:
1639 """Bridge guardrail decision records off an MCP synthetic request onto the request's logger.
1641 MCP guardrails run against a throwaway LLM-shaped dict from
1642 ``ProxyLogging._convert_mcp_to_llm_format``, so ``@log_guardrail_information``
1643 files ``standard_logging_guardrail_information`` in that dict's metadata bucket,
1644 which ``get_standard_logging_object_payload`` never reads. Native (non-unified)
1645 guardrails receive no ``logging_obj`` kwarg, so the decorator cannot bridge on
1646 their behalf; this calls the same helper it would have.
1648 Only the decision records move. The synthetic request's messages and tool
1649 arguments stay behind: they can carry end-user data, and the monitor needs none
1650 of it.
1651 """
1652 if litellm_logging_obj is None:
1653 return
1655 try:
1656 _sync_guardrail_info_to_logging_obj(synthetic_llm_data, litellm_logging_obj)
1657 except Exception as e: # noqa: BLE001 # callers run this from a `finally` on the block path
1658 # The breadth is the point. Narrowing to the knowable AttributeError/TypeError
1659 # would let an unexpected type escape that ``finally`` and replace the guardrail's
1660 # block with a bookkeeping error.
1661 verbose_logger.warning("Failed to record MCP guardrail evaluation for logging: %s", e)
1664_DiscoveryItem = TypeVar("_DiscoveryItem", bound=BaseModel)
1665_DiscoveryKey: TypeAlias = tuple[str, str | None]
1666_DISCOVERY_CACHE_LIMIT: Final = 1024
1669class _DiscoveryCache(Generic[_DiscoveryItem]):
1670 def __init__(
1671 self, ttl: float, clock: Callable[[], float], adapter: TypeAdapter[tuple[_DiscoveryItem, ...]]
1672 ) -> None:
1673 self._ttl = ttl
1674 self._adapter = adapter
1675 self._entries = InMemoryCache(max_size_in_memory=_DISCOVERY_CACHE_LIMIT, max_size_per_item=64, clock=clock)
1676 self._pending: dict[
1677 _DiscoveryKey, asyncio.Task[list[_DiscoveryItem]]
1678 ] = {} # mutable-ok: constant-time fetch registration
1679 self._waiters: dict[asyncio.Task[list[_DiscoveryItem]], int] = {} # mutable-ok: constant-time waiter accounting
1681 def invalidate(self, server_id: str) -> None:
1682 prefix: Final = f"[{json.dumps(server_id)},"
1683 keys: Final = cast( # cast-ok: private cache contains only JSON string keys
1684 "tuple[str, ...]", tuple(self._entries.cache_dict)
1685 )
1686 for entry_key in keys: 1686 ↛ 1687line 1686 didn't jump to line 1687 because the loop on line 1686 never started
1687 if entry_key.startswith(prefix):
1688 self._entries.delete_cache(entry_key)
1689 for key in tuple(self._pending): 1689 ↛ 1690line 1689 didn't jump to line 1690 because the loop on line 1689 never started
1690 if key[0] == server_id:
1691 self._pending.pop(key)
1693 @staticmethod
1694 def _observe_completion(task: asyncio.Task[list[_DiscoveryItem]]) -> None:
1695 if not task.cancelled():
1696 task.exception()
1698 async def get(
1699 self, key: _DiscoveryKey, fetch: Callable[[], Awaitable[list[_DiscoveryItem]]]
1700 ) -> tuple[_DiscoveryItem, ...]:
1701 if self._ttl <= 0:
1702 return tuple(await fetch())
1703 entry: Final[object] = self._entries.get_cache(json.dumps(key))
1704 if entry is not None:
1705 return self._adapter.validate_python(entry)
1706 pending: Final = self._pending.get(key)
1707 if pending is not None:
1708 return await self._await_fetch(key, pending)
1709 if len(self._pending) >= _DISCOVERY_CACHE_LIMIT:
1710 return tuple(await fetch())
1711 task: Final = asyncio.create_task(self._fetch(key, fetch))
1712 self._pending[key] = task
1713 task.add_done_callback(self._observe_completion)
1714 return await self._await_fetch(key, task)
1716 async def _await_fetch(
1717 self, key: _DiscoveryKey, task: asyncio.Task[list[_DiscoveryItem]]
1718 ) -> tuple[_DiscoveryItem, ...]:
1719 self._waiters[task] = self._waiters.get(task, 0) + 1
1720 try:
1721 return tuple(item.model_copy(deep=True) for item in await asyncio.shield(task))
1722 finally:
1723 remaining: Final = self._waiters[task] - 1
1724 if remaining:
1725 self._waiters[task] = remaining
1726 else:
1727 self._waiters.pop(task)
1728 if self._pending.get(key) is task:
1729 self._pending.pop(key)
1730 if not task.done():
1731 task.cancel()
1733 async def _fetch(
1734 self, key: _DiscoveryKey, fetch: Callable[[], Awaitable[list[_DiscoveryItem]]]
1735 ) -> list[_DiscoveryItem]:
1736 try:
1737 items: Final = await fetch()
1738 if self._pending.get(key) is asyncio.current_task():
1739 self._entries.set_cache(
1740 json.dumps(key),
1741 self._adapter.dump_json(tuple(items)),
1742 ttl=self._ttl,
1743 )
1744 return items
1745 finally:
1746 if self._pending.get(key) is asyncio.current_task():
1747 self._pending.pop(key)
1750def _mcp_discovery_cache_ttl() -> float:
1751 raw: Final = os.environ.get("LITELLM_MCP_DISCOVERY_CACHE_TTL", "60")
1752 try:
1753 ttl: Final = float(raw)
1754 except ValueError:
1755 verbose_logger.warning("Invalid LITELLM_MCP_DISCOVERY_CACHE_TTL; using 60 seconds")
1756 return 60.0
1757 if not math.isfinite(ttl) or ttl < 0: 1757 ↛ 1758line 1757 didn't jump to line 1758 because the condition on line 1757 was never true
1758 verbose_logger.warning("Invalid LITELLM_MCP_DISCOVERY_CACHE_TTL; using 60 seconds")
1759 return 60.0
1760 return ttl
1763class MCPServerManager:
1764 _STDIO_ENV_TEMPLATE_PATTERN = re.compile(r"^\$\{(X-[^}]+)\}$")
1766 @staticmethod
1767 def _explicit_oauth2_flow(
1768 oauth2_flow: str | None,
1769 ) -> Literal["client_credentials", "authorization_code"] | None:
1770 """DB rows persist their flow (write-time stamps plus the startup backfill) and
1771 config servers must declare it (validated at load), so both builds read the
1772 value verbatim: unknown or null resolves to None, which
1773 ``needs_user_oauth_token`` already treats as interactive. Field-shape inference
1774 survives only in the request-time security helpers (``effective_oauth2_flow`` /
1775 ``resolve_oauth2_flow_for_request``).
1776 """
1777 if oauth2_flow in ("client_credentials", "authorization_code"):
1778 return cast(Literal["client_credentials", "authorization_code"], oauth2_flow)
1779 return None
1781 @staticmethod
1782 def _resolve_oauth2_flow(
1783 *,
1784 auth_type: MCPAuthType | None,
1785 oauth2_flow: str | None,
1786 token_url: str | None,
1787 authorization_url: str | None,
1788 client_id: str | None,
1789 client_secret: str | None,
1790 ) -> Literal["client_credentials", "authorization_code"] | None:
1791 """Infer oauth2_flow from field shape when the value is omitted.
1793 SECURITY-SENSITIVE: this is the shape-inference engine both request-time security
1794 helpers delegate to, so it is what decides M2M-vs-interactive for an unstamped row.
1795 Always access it through ``effective_oauth2_flow`` (boolean/enum decisions) or
1796 ``resolve_oauth2_flow_for_request`` (the egress object backstop), which are the single
1797 choke points for request-time resolution; do not call it directly from security sites
1798 and do not weaken its M2M-shape branch without accounting for those callers. DB rows
1799 are stamped at write time and by the startup backfill, config servers must declare
1800 oauth2_flow (validated at load), and both builds read the value verbatim via
1801 ``_explicit_oauth2_flow``. Delete this whole request-time layer only once the backstop
1802 warning stays silent in production.
1803 """
1804 if oauth2_flow in ("client_credentials", "authorization_code"):
1805 return cast(Literal["client_credentials", "authorization_code"], oauth2_flow)
1806 if oauth2_flow: 1806 ↛ 1808line 1806 didn't jump to line 1808 because the condition on line 1806 was never true
1807 # Ignore unknown/untyped values and continue legacy inference.
1808 return None
1809 if auth_type != MCPAuth.oauth2: 1809 ↛ 1811line 1809 didn't jump to line 1811 because the condition on line 1809 was always true
1810 return None
1811 if authorization_url:
1812 return None
1813 if token_url and client_id and client_secret:
1814 return "client_credentials"
1815 return None
1817 @staticmethod
1818 def effective_oauth2_flow(server: "MCPServer") -> Literal["client_credentials", "authorization_code"] | None:
1819 """The oauth2_flow a security decision must use for ``server`` this request.
1821 Column-first, shape-fallback: a stamped row returns its explicit value; an
1822 unstamped (null) row whose fields carry the M2M shape resolves to
1823 ``client_credentials`` so it is treated as M2M and fails closed. Every
1824 security-sensitive reader (anonymous-delegate allowlist and gate, egress flow
1825 resolution) goes through this one helper rather than reading the bare
1826 ``has_client_credentials`` column, which is unreliable for null rows.
1827 """
1828 return MCPServerManager._resolve_oauth2_flow(
1829 auth_type=server.auth_type,
1830 oauth2_flow=server.oauth2_flow,
1831 token_url=server.token_url,
1832 authorization_url=server.authorization_url,
1833 client_id=server.client_id,
1834 client_secret=server.client_secret,
1835 )
1837 @staticmethod
1838 def resolve_oauth2_flow_for_request(server: "MCPServer") -> "MCPServer":
1839 """Return ``server`` with its effective oauth2_flow applied, for egress paths.
1841 A stamped row is returned unchanged (its effective flow equals the stored value).
1842 An unstamped M2M-shape row is returned as a per-request copy carrying
1843 ``oauth2_flow=client_credentials`` so downstream ``has_client_credentials`` /
1844 ``needs_user_oauth_token`` compute correctly and the stored client credentials are
1845 used instead of forwarding the caller's Authorization. Use this at every point that
1846 resolves an allowed server id into an ``MCPServer`` for a tool call or listing.
1847 """
1848 effective: Final = MCPServerManager.effective_oauth2_flow(server)
1849 if effective is None or effective == server.oauth2_flow: 1849 ↛ 1851line 1849 didn't jump to line 1851 because the condition on line 1849 was always true
1850 return server
1851 verbose_logger.warning(
1852 "MCP server %s has no persisted oauth2_flow but matches the %s shape; using the "
1853 "inferred flow for this request. The startup backfill leaves this ambiguous M2M "
1854 "shape unstamped on purpose, so it will NOT self-heal: set oauth2_flow explicitly "
1855 "in the dashboard or via PUT /v1/mcp/server (client_credentials for M2M, or "
1856 "authorization_code after an interactive sign-in).",
1857 server.server_id,
1858 effective,
1859 )
1860 return server.model_copy(update={"oauth2_flow": effective})
1862 @staticmethod
1863 def _obo_needs_endpoint_discovery(
1864 auth_type: MCPAuthType | None,
1865 token_exchange_endpoint: str | None,
1866 token_url: str | None,
1867 ) -> bool:
1868 """An ``oauth2_token_exchange`` server with no configured token endpoint can have it
1869 discovered (RFC 9728 -> RFC 8414) the same way the ``oauth2`` flow already does; an explicitly
1870 configured ``token_exchange_endpoint``/``token_url`` wins and skips the discovery round-trip.
1871 """
1872 return auth_type == MCPAuth.oauth2_token_exchange and not (token_exchange_endpoint or token_url)
1874 def __init__(
1875 self,
1876 cred_provider: UpstreamCredentialProvider | None = None,
1877 per_user_oauth_token_store: InvalidatableOAuthTokenStore | None = None,
1878 per_user_token_cache: MCPPerUserTokenCache | None = None,
1879 discovery_clock: Callable[[], float] = time.monotonic,
1880 ):
1881 self._per_user_oauth_token_store = per_user_oauth_token_store or LazyPerUserOAuthTokenStore(
1882 self.get_mcp_server_by_id
1883 )
1884 self._per_user_token_cache = per_user_token_cache or mcp_per_user_token_cache
1885 self._cred_provider = cred_provider or UpstreamCredentialProvider(
1886 oauth_token_store=self._per_user_oauth_token_store,
1887 token_exchanger=build_token_exchanger(),
1888 )
1889 discovery_ttl: Final = _mcp_discovery_cache_ttl()
1890 self._prompt_discovery_cache = _DiscoveryCache[Prompt](
1891 discovery_ttl, discovery_clock, TypeAdapter(tuple[Prompt, ...])
1892 )
1893 self._resource_discovery_cache = _DiscoveryCache[Resource](
1894 discovery_ttl, discovery_clock, TypeAdapter(tuple[Resource, ...])
1895 )
1896 self._template_discovery_cache = _DiscoveryCache[ResourceTemplate](
1897 discovery_ttl, discovery_clock, TypeAdapter(tuple[ResourceTemplate, ...])
1898 )
1899 self.registry: dict[str, MCPServer] = {}
1900 self._openapi_health_probes: Callable[[str], _OpenAPIHealthProbe] = lru_cache(maxsize=128)(_OpenAPIHealthProbe)
1901 self.config_mcp_servers: dict[str, MCPServer] = {}
1902 """
1903 eg.
1904 [
1905 "server-1": {
1906 "name": "zapier_mcp_server",
1907 "url": "https://actions.zapier.com/mcp/<your-api-key>/sse"
1908 "transport": "sse",
1909 "auth_type": "api_key"
1910 },
1911 "uuid-2": {
1912 "name": "google_drive_mcp_server",
1913 "url": "https://actions.zapier.com/mcp/<your-api-key>/sse"
1914 }
1915 ]
1916 """
1918 # Per-server outbound tool-call concurrency limiters, lazily created from
1919 # each server's max_concurrent_requests. Keyed by server_id so the cap
1920 # survives the registry atomic-swap on config reload; a missing key means
1921 # the server has no configured limit. The limit is cached alongside the
1922 # semaphore so an edited limit rebuilds it instead of keeping the old cap
1923 # until restart.
1924 self._server_call_semaphores: dict[str, tuple[int, asyncio.Semaphore]] = {}
1925 self.tool_name_to_mcp_server_name_mapping: dict[str, str] = {}
1926 """
1927 {
1928 "gmail_send_email": "zapier_mcp_server",
1929 }
1930 """
1931 self._upstream_initialize_instructions_by_server_id: dict[str, str] = {}
1932 # Per-server monotonic timestamp of last upstream prefetch attempt (success,
1933 # empty result, or failure). Used to throttle re-probes for servers that do
1934 # not return instructions, and to apply a short cooldown after failures.
1935 self._upstream_initialize_instructions_probed_at: dict[str, float] = {}
1936 # Last set of config server ids found shadowed by database rows. reload_servers_from_database
1937 # runs on the config-reload timer, so this keeps a standing misconfiguration from re-logging
1938 # the same warning every interval; a change in the set logs again.
1939 self._warned_shadowed_config_server_ids: frozenset[str] = frozenset()
1940 self._warned_capturing_config_server_ids: frozenset[str] = frozenset()
1941 self._oauth_discovery_on_startup = _mcp_oauth_discovery_on_startup_enabled()
1942 self._oauth_discovery_generation_counter = 0
1943 self._oauth_discovery_slots: tuple[_OAuthDiscoverySlot, ...] = ()
1945 def _oauth_discovery_slot(self, server_id: str) -> _OAuthDiscoverySlot | None:
1946 return next((slot for slot in self._oauth_discovery_slots if slot.server_id == server_id), None)
1948 def _remove_oauth_discovery_slot(self, server_id: str) -> None:
1949 self._oauth_discovery_slots = tuple(slot for slot in self._oauth_discovery_slots if slot.server_id != server_id)
1951 def _store_oauth_discovery_slot(self, slot: _OAuthDiscoverySlot) -> None:
1952 self._oauth_discovery_slots = (
1953 *(existing for existing in self._oauth_discovery_slots if existing.server_id != slot.server_id),
1954 slot,
1955 )
1957 def _set_oauth_discovery_deferred(self, server_id: str, discovery_deferred: bool) -> None:
1958 previous: Final = self._oauth_discovery_slot(server_id)
1959 self._remove_oauth_discovery_slot(server_id)
1960 if previous is not None and previous.task is not None and not previous.task.done(): 1960 ↛ 1961line 1960 didn't jump to line 1961 because the condition on line 1960 was never true
1961 previous.task.cancel()
1962 if discovery_deferred:
1963 self._oauth_discovery_generation_counter += 1
1964 self._store_oauth_discovery_slot(
1965 _OAuthDiscoverySlot(
1966 server_id=server_id,
1967 generation=self._oauth_discovery_generation_counter,
1968 )
1969 )
1971 def _invalidate_oauth_discovery_state(self, server_id: str) -> None:
1972 previous: Final = self._oauth_discovery_slot(server_id)
1973 self._remove_oauth_discovery_slot(server_id)
1974 if previous is not None and previous.task is not None and not previous.task.done(): 1974 ↛ 1975line 1974 didn't jump to line 1975 because the condition on line 1974 was never true
1975 previous.task.cancel()
1977 def _registered_server(self, server: MCPServer) -> MCPServer:
1978 return self.registry.get(server.server_id) or self.config_mcp_servers.get(server.server_id) or server
1980 async def _discover_oauth_metadata_for_server(self, server: MCPServer) -> MCPOAuthMetadata | None:
1981 manual_issuer: Final = _blank_to_none(server.issuer)
1982 manual_authorization_url: Final = _blank_to_none(server.authorization_url)
1983 manual_token_url: Final = _blank_to_none(server.token_url)
1984 is_discovery_auth_type: Final = server.auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
1985 use_issuer_anchor: Final = server.issuer_is_anchored
1986 obo_needs_discovery: Final = self._obo_needs_endpoint_discovery(
1987 server.auth_type,
1988 server.token_exchange_endpoint,
1989 manual_token_url,
1990 )
1991 needs_authorization_url: Final = is_discovery_auth_type and server.oauth2_flow != "client_credentials"
1992 needs_token_url: Final = is_discovery_auth_type or obo_needs_discovery
1993 warn_on_empty_discovery: Final = _discovery_failure_leaves_needs_unresolved(
1994 needs_authorization_url=needs_authorization_url,
1995 needs_token_url=needs_token_url,
1996 manual_authorization_url=manual_authorization_url,
1997 manual_token_url=manual_token_url,
1998 )
1999 metadata: Final = await (
2000 self._fetch_issuer_anchored_oauth_metadata(manual_issuer, server.url)
2001 if use_issuer_anchor and manual_issuer is not None
2002 else self._descovery_metadata(
2003 server_url=server.url or "",
2004 allow_origin_fallback=is_discovery_auth_type,
2005 warn_when_no_metadata=warn_on_empty_discovery,
2006 )
2007 )
2008 if use_issuer_anchor: 2008 ↛ 2010line 2008 didn't jump to line 2010 because the condition on line 2008 was always true
2009 return metadata
2010 gated_metadata: Final = (
2011 _restrict_discovery_to_corroborated_authorization_server(
2012 metadata,
2013 manual_authorization_url,
2014 server.server_id,
2015 server.is_dcr_bridge,
2016 )
2017 if is_discovery_auth_type
2018 else metadata
2019 )
2020 _warn_oauth_endpoints_unresolved(
2021 server_ref=server.alias or server.server_name or server.server_id,
2022 server_url=server.url,
2023 discovery_attempted=True,
2024 issuer_anchored=False,
2025 metadata=gated_metadata,
2026 needs_authorization_url=needs_authorization_url,
2027 needs_token_url=needs_token_url,
2028 manual_authorization_url=manual_authorization_url,
2029 manual_token_url=manual_token_url,
2030 )
2031 return gated_metadata
2033 @staticmethod
2034 def _merge_discovered_oauth_metadata(server: MCPServer, metadata: MCPOAuthMetadata | None) -> MCPServer:
2035 if metadata is None: 2035 ↛ 2037line 2035 didn't jump to line 2037 because the condition on line 2035 was always true
2036 return server
2037 discovered_issuer: Final = metadata.discovered_issuer if not metadata.from_origin_fallback else None
2038 resolved: Final = server.model_copy()
2039 resolved.scopes = server.scopes or metadata.scopes
2040 resolved.issuer = server.issuer or discovered_issuer
2041 resolved.authorization_url = server.authorization_url or metadata.authorization_url
2042 resolved.token_url = server.token_url or metadata.token_url
2043 resolved.registration_url = server.registration_url or metadata.registration_url
2044 return resolved
2046 def _oauth_discovery_slot_is_current(self, server_id: str, generation: int) -> bool:
2047 slot: Final = self._oauth_discovery_slot(server_id)
2048 return slot is not None and slot.generation == generation
2050 def _expire_temporary_oauth_discovery(self, server_id: str, generation: int) -> None:
2051 if self._oauth_discovery_slot_is_current(server_id, generation):
2052 self._remove_oauth_discovery_slot(server_id)
2054 def _publish_resolved_oauth_server(
2055 self,
2056 server: MCPServer,
2057 generation: int,
2058 ) -> MCPServer | None:
2059 if not self._oauth_discovery_slot_is_current(server.server_id, generation):
2060 return None
2061 if server.server_id in self.registry:
2062 self.registry[server.server_id] = server
2063 elif server.server_id in self.config_mcp_servers:
2064 self.config_mcp_servers[server.server_id] = server
2065 else:
2066 asyncio.get_running_loop().call_later(
2067 _OAUTH_TEMPORARY_DISCOVERY_TTL_SECONDS,
2068 self._expire_temporary_oauth_discovery,
2069 server.server_id,
2070 generation,
2071 )
2072 return server
2073 self._remove_oauth_discovery_slot(server.server_id)
2074 return server
2076 async def _attempt_oauth_metadata_once(
2077 self,
2078 server: MCPServer,
2079 generation: int,
2080 ) -> _OAuthDiscoveryOutcome | None:
2081 if not self._oauth_discovery_slot_is_current(server.server_id, generation): 2081 ↛ 2082line 2081 didn't jump to line 2082 because the condition on line 2081 was never true
2082 return _OAuthDiscoveryStale(server_id=server.server_id)
2083 current: Final = self._registered_server(server)
2084 if not _oauth_endpoints_unresolved(current): 2084 ↛ 2085line 2084 didn't jump to line 2085 because the condition on line 2084 was never true
2085 published: Final = self._publish_resolved_oauth_server(current, generation)
2086 return (
2087 _OAuthDiscoveryResolved(server=published)
2088 if published is not None
2089 else _OAuthDiscoveryStale(server_id=server.server_id)
2090 )
2091 metadata: Final = await self._discover_oauth_metadata_for_server(current)
2092 if not self._oauth_discovery_slot_is_current(server.server_id, generation): 2092 ↛ 2093line 2092 didn't jump to line 2093 because the condition on line 2092 was never true
2093 return _OAuthDiscoveryStale(server_id=server.server_id)
2094 candidate: Final = self._merge_discovered_oauth_metadata(self._registered_server(server), metadata)
2095 if _oauth_endpoints_unresolved(candidate): 2095 ↛ 2097line 2095 didn't jump to line 2097 because the condition on line 2095 was always true
2096 return None
2097 published_candidate: Final = self._publish_resolved_oauth_server(candidate, generation)
2098 return (
2099 _OAuthDiscoveryResolved(server=published_candidate)
2100 if published_candidate is not None
2101 else _OAuthDiscoveryStale(server_id=server.server_id)
2102 )
2104 async def _attempt_oauth_metadata_resolution(
2105 self,
2106 server: MCPServer,
2107 generation: int,
2108 retry_delays: tuple[float, ...] = _OAUTH_DISCOVERY_RETRY_DELAYS_SECONDS,
2109 ) -> _OAuthDiscoveryOutcome:
2110 outcome: Final = await self._attempt_oauth_metadata_once(server, generation)
2111 if outcome is not None: 2111 ↛ 2112line 2111 didn't jump to line 2112 because the condition on line 2111 was never true
2112 return outcome
2113 if not retry_delays:
2114 return _OAuthDiscoveryFailed(server_id=server.server_id, timed_out=False)
2115 await asyncio.sleep(retry_delays[0])
2116 return await self._attempt_oauth_metadata_resolution(server, generation, retry_delays[1:])
2118 async def _run_oauth_metadata_resolution(
2119 self,
2120 server: MCPServer,
2121 generation: int,
2122 ) -> _OAuthDiscoveryOutcome:
2123 try:
2124 outcome: Final = await asyncio.wait_for(
2125 self._attempt_oauth_metadata_resolution(server, generation),
2126 timeout=MCP_METADATA_TIMEOUT,
2127 )
2128 except asyncio.TimeoutError:
2129 verbose_logger.warning(
2130 "Deferred MCP OAuth discovery timed out after %ss for server %s",
2131 MCP_METADATA_TIMEOUT,
2132 server.server_id,
2133 )
2134 failure: Final = _OAuthDiscoveryFailed(server_id=server.server_id, timed_out=True)
2135 self._record_oauth_discovery_failure(server.server_id, generation)
2136 return failure
2137 if isinstance(outcome, _OAuthDiscoveryFailed): 2137 ↛ 2139line 2137 didn't jump to line 2139 because the condition on line 2137 was always true
2138 self._record_oauth_discovery_failure(server.server_id, generation)
2139 return outcome
2141 def _record_oauth_discovery_failure(self, server_id: str, generation: int) -> None:
2142 slot: Final = self._oauth_discovery_slot(server_id)
2143 if slot is None or slot.generation != generation: 2143 ↛ 2144line 2143 didn't jump to line 2144 because the condition on line 2143 was never true
2144 return
2145 consecutive_failures: Final = slot.consecutive_failures + 1
2146 self._store_oauth_discovery_slot(
2147 replace(
2148 slot,
2149 consecutive_failures=consecutive_failures,
2150 retry_not_before=_oauth_discovery_now() + _oauth_discovery_retry_delay(consecutive_failures),
2151 )
2152 )
2154 def _get_or_start_oauth_discovery_task(
2155 self,
2156 server: MCPServer,
2157 ) -> tuple[asyncio.Task[_OAuthDiscoveryOutcome], int] | None:
2158 slot: Final = self._oauth_discovery_slot(server.server_id)
2159 if slot is None:
2160 return None
2161 if slot.task is not None:
2162 if not slot.task.done() or _oauth_discovery_now() < slot.retry_not_before:
2163 return slot.task, slot.generation
2164 if ( 2164 ↛ 2169line 2164 didn't jump to line 2169 because the condition on line 2164 was never true
2165 not slot.task.cancelled()
2166 and slot.task.exception() is None
2167 and isinstance(slot.task.result(), _OAuthDiscoveryResolved)
2168 ):
2169 return slot.task, slot.generation
2170 task: Final = asyncio.create_task(
2171 self._run_oauth_metadata_resolution(self._registered_server(server), slot.generation)
2172 )
2173 self._store_oauth_discovery_slot(replace(slot, task=task))
2174 return task, slot.generation
2176 def prime_oauth_metadata_discovery(self, server: MCPServer) -> None:
2177 """Start best-effort OAuth metadata discovery for ``server``.
2179 The call returns immediately and never delays registration. It is a no-op
2180 when the server has no deferred discovery slot.
2182 Args:
2183 server: The registered MCP server to warm metadata for.
2184 """
2185 self._get_or_start_oauth_discovery_task(server)
2187 def _prime_oauth_metadata_discovery_for_servers(self, servers: Sequence[MCPServer]) -> None:
2188 for server in servers:
2189 self.prime_oauth_metadata_discovery(server)
2191 def _reconcile_oauth_discovery_slots_for_servers(self, servers: Sequence[MCPServer]) -> None:
2192 """Align retry slots after an atomic registry replacement."""
2193 for server in servers:
2194 should_defer = _requires_oauth_discovery(server.url, server.issuer_is_anchored, server)
2195 has_slot = self._oauth_discovery_slot(server.server_id) is not None
2196 if should_defer != has_slot: 2196 ↛ 2197line 2196 didn't jump to line 2197 because the condition on line 2196 was never true
2197 self._set_oauth_discovery_deferred(server.server_id, should_defer)
2199 async def ensure_oauth_metadata_discovered(self, server: MCPServer, *, _retry_stale: bool = True) -> MCPServer:
2200 """Join the bounded discovery task and return the resolved server.
2202 Concurrent callers share one task per server. A failed attempt remains
2203 retryable after a per-server cooldown.
2205 Args:
2206 server: The MCP server whose OAuth metadata must be resolved.
2208 Returns:
2209 The resolved server; the registered server when no discovery is
2210 pending, or when discovery failed for a client-forwarded-token
2211 server, whose session consumes no discovered endpoint.
2213 Raises:
2214 HTTPException: Status 503 when discovery times out or returns
2215 incomplete metadata for a server whose OAuth flow the gateway
2216 runs itself.
2217 """
2218 acquisition: Final = self._get_or_start_oauth_discovery_task(server)
2219 if acquisition is None:
2220 return self._registered_server(server)
2221 task, generation = acquisition
2222 try:
2223 outcome: Final = await asyncio.shield(task)
2224 except asyncio.CancelledError:
2225 if task.cancelled() and not self._oauth_discovery_slot_is_current(server.server_id, generation):
2226 return await self._rejoin_oauth_metadata_discovery(server, retry_stale=_retry_stale)
2227 raise
2228 match outcome:
2229 case _OAuthDiscoveryResolved(resolved_server): 2229 ↛ 2230line 2229 didn't jump to line 2230 because the pattern on line 2229 never matched
2230 return resolved_server
2231 case _OAuthDiscoveryStale(): 2231 ↛ 2232line 2231 didn't jump to line 2232 because the pattern on line 2231 never matched
2232 return await self._rejoin_oauth_metadata_discovery(server, retry_stale=_retry_stale)
2233 case _OAuthDiscoveryFailed(timed_out=timed_out): 2233 ↛ exitline 2233 didn't return from function 'ensure_oauth_metadata_discovered' because the pattern on line 2233 always matched
2234 current: Final = self._registered_server(server)
2235 if current.is_client_forwarded_token: 2235 ↛ 2237line 2235 didn't jump to line 2237 because the condition on line 2235 was always true
2236 return current
2237 server_ref: Final = current.alias or current.server_name or current.name or current.server_id
2238 reason: Final = "timed out" if timed_out else "returned incomplete metadata"
2239 raise HTTPException(
2240 status_code=503,
2241 detail=f"OAuth metadata discovery {reason} for MCP server {server_ref!r}",
2242 )
2244 async def _rejoin_oauth_metadata_discovery(self, server: MCPServer, *, retry_stale: bool) -> MCPServer:
2245 if retry_stale:
2246 return await self.ensure_oauth_metadata_discovered(server, _retry_stale=False)
2247 current: Final = self._registered_server(server)
2248 if not _oauth_endpoints_unresolved(current) or current.is_client_forwarded_token:
2249 return current
2250 raise HTTPException(status_code=503, detail="OAuth metadata discovery changed repeatedly; retry shortly")
2252 def _remember_upstream_initialize_instructions(self, server: MCPServer, client: MCPClient) -> None:
2253 raw: Final[str | None] = getattr(client, "_last_initialize_instructions", None)
2254 if raw and str(raw).strip():
2255 self._upstream_initialize_instructions_by_server_id[server.server_id] = str(raw).strip()
2257 async def _ensure_upstream_initialize_instructions_cached(self, server: MCPServer) -> None:
2258 """
2259 Open one upstream session and cache InitializeResult.instructions if missing.
2261 No-op when:
2262 - YAML/DB instructions are set on the server record,
2263 - server is OpenAPI (spec_path),
2264 - non-empty upstream instructions are already cached,
2265 - auth preconditions match health_check_server's skip rules
2266 (per-user auth / missing static auth token / static headers that
2267 reference a per-user env var),
2268 - a prior probe attempt for this server is within
2269 MCP_HEALTH_CHECK_TIMEOUT seconds (the probe is a health-check-shaped
2270 op and already uses this knob for its inner call timeout; reusing it
2271 as the cooldown avoids reconnecting on every gateway initialize when
2272 upstream returns empty or fails).
2273 """
2274 if server.spec_path: 2274 ↛ 2275line 2274 didn't jump to line 2275 because the condition on line 2274 was never true
2275 return
2276 if server.instructions and server.instructions.strip(): 2276 ↛ 2277line 2276 didn't jump to line 2277 because the condition on line 2276 was never true
2277 return
2278 if self._upstream_initialize_instructions_by_server_id.get(server.server_id): 2278 ↛ 2279line 2278 didn't jump to line 2279 because the condition on line 2278 was never true
2279 return
2280 if server.requires_per_user_auth:
2281 return
2282 if self._references_per_user_env_var(server): 2282 ↛ 2283line 2282 didn't jump to line 2283 because the condition on line 2282 was never true
2283 return
2284 if ( 2284 ↛ 2290line 2284 didn't jump to line 2290 because the condition on line 2284 was never true
2285 server.auth_type
2286 and server.auth_type != MCPAuth.none
2287 and server.auth_type != MCPAuth.aws_sigv4
2288 and not server.authentication_token
2289 ):
2290 return
2292 last_probed_at: Final = self._upstream_initialize_instructions_probed_at.get(server.server_id)
2293 if last_probed_at is not None and (time.monotonic() - last_probed_at) < MCP_HEALTH_CHECK_TIMEOUT:
2294 return
2296 # Record the attempt up-front so that a failure / empty response does not
2297 # cause every subsequent initialize request to re-open the upstream session.
2298 self._upstream_initialize_instructions_probed_at[server.server_id] = time.monotonic()
2300 try:
2301 resolved_static_headers: Final = await self._resolve_static_headers_with_env_vars(
2302 server=server,
2303 user_api_key_auth=None,
2304 raise_on_missing=False,
2305 )
2306 extra_headers: dict[str, str] | None = dict(resolved_static_headers) if resolved_static_headers else None
2307 client: Final = await self._create_mcp_client(
2308 server=server,
2309 mcp_auth_header=None,
2310 extra_headers=extra_headers,
2311 stdio_env=None,
2312 )
2314 async def _noop(_session):
2315 return "ok"
2317 await asyncio.wait_for(client.run_with_session(_noop), timeout=MCP_HEALTH_CHECK_TIMEOUT)
2318 self._remember_upstream_initialize_instructions(server, client)
2319 except Exception as e:
2320 verbose_logger.debug(
2321 "Upstream initialize instructions prefetch failed for %s: %s",
2322 server.name,
2323 e,
2324 )
2326 def get_registry(self) -> dict[str, MCPServer]:
2327 """
2328 Get the registered MCP Servers from the registry and union with the config MCP Servers
2329 """
2330 return self.config_mcp_servers | self.registry
2332 def is_config_declared_server(self, server_id: str) -> bool:
2333 """True when server_id was declared in config.yaml (present in the in-memory config map).
2334 Config servers are rowless and persistent, so their DCR client belongs in the server-scoped
2335 store; a rowless server that is NOT config-declared is a throwaway temp/session server whose
2336 client must not be persisted. This never overrides the row-existence check: a server that has
2337 a LiteLLM_MCPServerTable row is always resolved to that row first."""
2338 return server_id in self.config_mcp_servers
2340 async def load_servers_from_config(
2341 self,
2342 mcp_servers_config: dict[str, MCPServerConfig],
2343 mcp_aliases: dict[str, str] | None = None,
2344 ):
2345 """
2346 Load the MCP Servers from the config
2348 Args:
2349 mcp_servers_config: Dictionary of MCP server configurations
2350 mcp_aliases: Optional dictionary mapping aliases to server names from litellm_settings
2351 """
2352 verbose_logger.debug("Loading MCP Servers from config-----")
2353 self._upstream_initialize_instructions_by_server_id.clear()
2354 self._upstream_initialize_instructions_probed_at.clear()
2356 # Track which aliases have been used to ensure only first occurrence is used
2357 used_aliases: Final = set()
2358 # server_id -> the config server_name that claimed it, so a pinned id cannot silently
2359 # overwrite another server's entry in self.config_mcp_servers.
2360 assigned_server_ids: MutableMapping[str, str] = {} # mutable-ok: per-load collision index
2361 _validate_config_server_names(mcp_servers_config)
2362 identifier_owners: Final = _config_identifier_owners(mcp_servers_config, mcp_aliases)
2364 for server_name, raw_server_config in mcp_servers_config.items():
2365 server_config: MCPServerConfig = raw_server_config
2366 _mcp_info: MCPInfo = server_config.get("mcp_info", None) or {}
2367 # Preserve all custom fields from config while setting defaults for core fields
2368 mcp_info: MCPInfo = _mcp_info.copy()
2369 # Set default values for core fields if not present
2370 if "server_name" not in mcp_info:
2371 mcp_info["server_name"] = server_name
2372 if "description" not in mcp_info and server_config.get("description"):
2373 mcp_info["description"] = server_config.get("description")
2374 _normalize_mcp_server_cost_info(mcp_info)
2376 # Use alias for name if present, else server_name
2377 alias = server_config.get("alias", None)
2379 # Apply mcp_aliases mapping if provided
2380 if mcp_aliases and alias is None:
2381 # Check if this server_name has an alias in mcp_aliases
2382 for alias_name, target_server_name in mcp_aliases.items():
2383 if target_server_name == server_name and alias_name not in used_aliases:
2384 alias = alias_name
2385 used_aliases.add(alias_name)
2386 verbose_logger.debug("Mapped alias '%s' to server '%s'", alias_name, server_name)
2387 break
2389 # Create a temporary server object to use with get_server_prefix utility
2390 temp_server = type(
2391 "TempServer",
2392 (),
2393 {"alias": alias, "server_name": server_name, "server_id": None},
2394 )()
2395 name_for_prefix = get_server_prefix(temp_server)
2397 server_url = server_config.get("url", None) or ""
2398 # An explicitly pinned server_id wins; otherwise derive one from the parameters.
2399 pinned_server_id = _pinned_config_server_id(server_config.get("server_id"), server_name)
2400 server_id = pinned_server_id or self._generate_stable_server_id(
2401 server_name=server_name,
2402 url=server_url,
2403 transport=server_config.get("transport", MCPTransport.http),
2404 auth_type=server_config.get("auth_type", None),
2405 alias=alias,
2406 )
2407 _reject_config_server_id_collision(
2408 assigned_server_ids,
2409 server_id,
2410 server_name,
2411 pinned=pinned_server_id is not None,
2412 db_backed_server_ids=self.registry,
2413 identifier_owners=identifier_owners,
2414 )
2415 assigned_server_ids[server_id] = server_name
2417 _warn_on_server_name_fields(
2418 server_id=server_id,
2419 alias=alias,
2420 server_name=server_name,
2421 )
2422 warn_if_mcp_stdio_blocked(server_name, server_config.get("transport"))
2424 auth_type = server_config.get("auth_type", None)
2425 manual_issuer = _blank_to_none(server_config.get("issuer"))
2426 manual_authorization_url = _blank_to_none(server_config.get("authorization_url"))
2427 manual_token_url = _blank_to_none(server_config.get("token_url"))
2428 manual_registration_url = _blank_to_none(server_config.get("registration_url"))
2429 is_discovery_auth_type = auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
2430 obo_needs_discovery = self._obo_needs_endpoint_discovery(
2431 auth_type,
2432 server_config.get("token_exchange_endpoint"),
2433 manual_token_url,
2434 )
2435 use_issuer_anchor = _uses_issuer_anchor(manual_issuer, is_discovery_auth_type or obo_needs_discovery)
2436 configured_authorization_url = manual_authorization_url
2437 configured_token_url = manual_token_url
2438 configured_registration_url = manual_registration_url
2439 manual_authorization_url, manual_token_url, manual_registration_url = _endpoints_yield_to_issuer(
2440 manual_issuer,
2441 is_discovery_auth_type,
2442 manual_authorization_url,
2443 manual_token_url,
2444 manual_registration_url,
2445 server_name or server_id,
2446 )
2447 should_discover = _has_oauth_discovery_source(server_url, use_issuer_anchor) and (
2448 is_discovery_auth_type or obo_needs_discovery
2449 )
2450 config_oauth2_flow = server_config.get("oauth2_flow", None)
2451 needs_authorization_url = is_discovery_auth_type and config_oauth2_flow != "client_credentials"
2452 needs_token_url = is_discovery_auth_type or obo_needs_discovery
2453 warn_on_empty_discovery = _discovery_failure_leaves_needs_unresolved(
2454 needs_authorization_url=needs_authorization_url,
2455 needs_token_url=needs_token_url,
2456 manual_authorization_url=manual_authorization_url,
2457 manual_token_url=manual_token_url,
2458 )
2459 discovery_deferred = should_discover and not self._oauth_discovery_on_startup
2460 if not should_discover or discovery_deferred:
2461 mcp_oauth_metadata = None
2462 elif use_issuer_anchor and manual_issuer is not None:
2463 mcp_oauth_metadata = await self._fetch_issuer_anchored_oauth_metadata(manual_issuer, server_url)
2464 else:
2465 mcp_oauth_metadata = await self._descovery_metadata(
2466 server_url=server_url,
2467 allow_origin_fallback=is_discovery_auth_type,
2468 warn_when_no_metadata=warn_on_empty_discovery,
2469 )
2471 if use_issuer_anchor:
2472 gated_oauth_metadata = mcp_oauth_metadata
2473 elif is_discovery_auth_type:
2474 gated_oauth_metadata = _restrict_discovery_to_corroborated_authorization_server(
2475 mcp_oauth_metadata,
2476 manual_authorization_url,
2477 server_name or server_id,
2478 bool(server_config.get("dcr_bridge")),
2479 )
2480 else:
2481 gated_oauth_metadata = mcp_oauth_metadata
2483 # Filter blank scopes (e.g. YAML ``scopes: [""]``) the same way the DB-build path does, so
2484 # an all-blank list normalizes to None rather than a ``("",)`` tuple that skips the
2485 # entra_obo fail-closed scope precondition and POSTs an empty scope to the IdP.
2486 configured_scopes = self._extract_scopes(server_config.get("scopes"))
2487 resolved_scopes = configured_scopes or (gated_oauth_metadata.scopes if gated_oauth_metadata else None)
2488 resolved_authorization_url = manual_authorization_url or (
2489 gated_oauth_metadata.authorization_url if gated_oauth_metadata else None
2490 )
2491 resolved_token_url = manual_token_url or (gated_oauth_metadata.token_url if gated_oauth_metadata else None)
2492 resolved_registration_url = manual_registration_url or (
2493 gated_oauth_metadata.registration_url if gated_oauth_metadata else None
2494 )
2495 discovered_issuer = (
2496 gated_oauth_metadata.discovered_issuer
2497 if gated_oauth_metadata and not gated_oauth_metadata.from_origin_fallback
2498 else None
2499 )
2500 effective_issuer = manual_issuer or discovered_issuer
2502 if auth_type == MCPAuth.oauth2 and config_oauth2_flow not in (
2503 "client_credentials",
2504 "authorization_code",
2505 ):
2506 raise ValueError(
2507 f"Invalid config for MCP server '{server_name or server_id}': auth_type oauth2 "
2508 f"requires an explicit oauth2_flow (got {config_oauth2_flow!r}). Set "
2509 "oauth2_flow: client_credentials for machine-to-machine servers (the proxy mints "
2510 "a shared token at token_url using client_id/client_secret, no user interaction) "
2511 "or oauth2_flow: authorization_code for interactive servers (per-user tokens via "
2512 "browser sign-in, including delegate_auth_to_upstream)."
2513 )
2515 config_dcr_bridge = server_config.get("dcr_bridge", None)
2516 config_per_server_oauth_discovery = _config_per_server_oauth_discovery(
2517 server_config, server_name or server_id, auth_type, config_oauth2_flow
2518 )
2519 if config_dcr_bridge is not None and not isinstance(config_dcr_bridge, bool):
2520 raise ValueError(
2521 f"Invalid config for MCP server '{server_name or server_id}': dcr_bridge "
2522 f"must be a boolean (got {config_dcr_bridge!r})."
2523 )
2524 if config_dcr_bridge and auth_type not in (
2525 MCPAuth.true_passthrough,
2526 MCPAuth.oauth_delegate,
2527 ):
2528 raise ValueError(
2529 f"Invalid config for MCP server '{server_name or server_id}': dcr_bridge is only "
2530 f"supported for auth_type true_passthrough or oauth_delegate (got {auth_type!r}). "
2531 "The DCR bridge serves gateway-hosted OAuth discovery for the client-forwarded "
2532 "token modes; interactive oauth2 servers already run the gateway "
2533 "authorization-code flow."
2534 )
2536 _warn_oauth_endpoints_unresolved(
2537 server_ref=server_name or server_id,
2538 server_url=server_url,
2539 discovery_attempted=should_discover,
2540 discovery_deferred=discovery_deferred,
2541 issuer_anchored=use_issuer_anchor,
2542 metadata=gated_oauth_metadata,
2543 needs_authorization_url=needs_authorization_url,
2544 needs_token_url=needs_token_url,
2545 manual_authorization_url=manual_authorization_url,
2546 manual_token_url=manual_token_url,
2547 )
2549 new_server = MCPServer(
2550 server_id=server_id,
2551 name=name_for_prefix,
2552 alias=alias,
2553 server_name=server_name,
2554 spec_path=server_config.get("spec_path", None),
2555 url=server_url,
2556 command=server_config.get("command", None) or "",
2557 args=server_config.get("args", None) or [],
2558 env=server_config.get("env", None) or {},
2559 # oauth specific fields
2560 client_id=server_config.get("client_id", None),
2561 client_secret=server_config.get("client_secret", None),
2562 oauth2_flow=self._explicit_oauth2_flow(config_oauth2_flow),
2563 scopes=resolved_scopes,
2564 configured_scopes=tuple(configured_scopes) if configured_scopes else None,
2565 issuer=effective_issuer,
2566 issuer_is_anchored=use_issuer_anchor,
2567 authorization_url=resolved_authorization_url,
2568 token_url=resolved_token_url,
2569 registration_url=resolved_registration_url,
2570 configured_authorization_url=configured_authorization_url,
2571 configured_token_url=configured_token_url,
2572 configured_registration_url=configured_registration_url,
2573 token_endpoint_auth_method=server_config.get("token_endpoint_auth_method", None),
2574 # TODO: utility fn the default values
2575 transport=server_config.get("transport", MCPTransport.http),
2576 auth_type=auth_type,
2577 authentication_token=server_config.get("authentication_token", server_config.get("auth_value", None)),
2578 mcp_info=mcp_info,
2579 extra_headers=server_config.get("extra_headers", None),
2580 allowed_tools=server_config.get("allowed_tools", None),
2581 disallowed_tools=server_config.get("disallowed_tools", None),
2582 allowed_params=server_config.get("allowed_params", None),
2583 access_groups=server_config.get("access_groups", None),
2584 static_headers=server_config.get("static_headers", None),
2585 env_vars=server_config.get("env_vars", None),
2586 allow_all_keys=bool(server_config.get("allow_all_keys", False)),
2587 available_on_public_internet=bool(server_config.get("available_on_public_internet", True)),
2588 delegate_auth_to_upstream=bool(server_config.get("delegate_auth_to_upstream", False)),
2589 oauth_passthrough=bool(server_config.get("oauth_passthrough", False)),
2590 dcr_bridge=config_dcr_bridge,
2591 per_server_oauth_discovery=config_per_server_oauth_discovery,
2592 # AWS SigV4 fields
2593 aws_access_key_id=server_config.get("aws_access_key_id", None),
2594 aws_secret_access_key=server_config.get("aws_secret_access_key", None),
2595 aws_session_token=server_config.get("aws_session_token", None),
2596 aws_region_name=server_config.get("aws_region_name", None),
2597 aws_service_name=server_config.get("aws_service_name", None),
2598 aws_role_name=server_config.get("aws_role_name", None),
2599 aws_session_name=server_config.get("aws_session_name", None),
2600 instructions=server_config.get("instructions", None),
2601 # Token Exchange (OBO) fields
2602 token_exchange_endpoint=server_config.get("token_exchange_endpoint", None),
2603 audience=server_config.get("audience", None),
2604 subject_token_type=server_config.get(
2605 "subject_token_type",
2606 DEFAULT_SUBJECT_TOKEN_TYPE,
2607 ),
2608 upstream_resource=server_config.get("upstream_resource", None),
2609 upstream_token_header=server_config.get("upstream_token_header", None),
2610 # ID-JAG fields
2611 id_jag_resource_token_endpoint=server_config.get("id_jag_resource_token_endpoint", None),
2612 id_jag_resource=server_config.get("id_jag_resource", None),
2613 client_private_key=server_config.get("client_private_key", None),
2614 client_private_key_id=server_config.get("client_private_key_id", None),
2615 client_assertion_signing_alg=server_config.get("client_assertion_signing_alg", "RS256"),
2616 token_exchange_profile=server_config.get("token_exchange_profile", "rfc8693"),
2617 allow_sampling=bool(server_config.get("allow_sampling", False)),
2618 allow_elicitation=bool(server_config.get("allow_elicitation", False)),
2619 timeout=server_config.get("timeout", None),
2620 max_concurrent_requests=server_config.get("max_concurrent_requests", None),
2621 token_validation=server_config.get("token_validation", None),
2622 oauth_identity_binding=server_config.get("oauth_identity_binding", None),
2623 )
2624 self._assign_unique_short_prefix(new_server)
2625 _warn_legacy_delegate_auth_if_applicable(new_server, source="config")
2626 _warn_config_id_jag_server_outruns_sso(new_server)
2627 self._invalidate_discovery_lists(server_id)
2628 self.config_mcp_servers[server_id] = new_server
2629 self._set_oauth_discovery_deferred(
2630 server_id,
2631 _requires_oauth_discovery(server_url, use_issuer_anchor, new_server),
2632 )
2634 # Check if this is an OpenAPI-based server
2635 spec_path = server_config.get("spec_path", None)
2636 if spec_path:
2637 verbose_logger.info("Loading OpenAPI spec from %s for server %s", spec_path, server_name)
2638 await self._register_openapi_tools(
2639 spec_path=spec_path,
2640 server=new_server,
2641 base_url=server_config.get("url", ""),
2642 )
2644 verbose_logger.debug(
2645 "Loaded MCP Servers: %s", json.dumps(_redacted_registry_dump(self.config_mcp_servers), indent=4)
2646 )
2648 await self._hydrate_config_servers_dcr_clients()
2650 self._prime_oauth_metadata_discovery_for_servers(tuple(self.config_mcp_servers.values()))
2652 self.initialize_tool_name_to_mcp_server_name_mapping()
2654 async def _hydrate_config_servers_dcr_clients(self) -> None:
2655 """Overlay each config-declared server's persisted DCR client (from the server-scoped
2656 store) onto its in-memory object so token refresh authenticates after a restart. A
2657 best-effort no-op when the DB is unreachable at config-load time."""
2658 from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( # noqa: PLC0415 # circular import
2659 hydrate_config_server_dcr_client,
2660 )
2662 for server in self.config_mcp_servers.values(): 2662 ↛ 2663line 2662 didn't jump to line 2663 because the loop on line 2662 never started
2663 try:
2664 if await hydrate_config_server_dcr_client(server):
2665 verbose_logger.debug(
2666 "hydrated persisted DCR client onto config MCP server server_id=%s",
2667 server.server_id,
2668 )
2669 except Exception as exc: # noqa: BLE001 # best-effort hydration; never fail config load
2670 verbose_logger.debug(
2671 "load_servers_from_config: failed to hydrate DCR client for server_id=%s: %s",
2672 server.server_id,
2673 exc,
2674 )
2676 async def _register_openapi_tools(self, spec_path: str, server: MCPServer, base_url: str):
2677 """
2678 Register tools from an OpenAPI specification for a given server.
2680 This creates "virtual" MCP tools from OpenAPI endpoints that are:
2681 1. Registered in the global tool registry with server prefix
2682 2. Mapped to the server for routing
2683 3. Executed via the local tool handler
2685 Args:
2686 spec_path: Path to the OpenAPI specification file
2687 server: The MCPServer instance to register tools for
2688 base_url: Base URL for API calls
2689 """
2690 from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
2691 build_input_schema,
2692 create_tool_function,
2693 load_openapi_spec_async,
2694 resolve_operation_params,
2695 )
2696 from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
2697 get_base_url as get_openapi_base_url,
2698 )
2699 from litellm.proxy._experimental.mcp_server.tool_registry import (
2700 global_mcp_tool_registry,
2701 )
2703 try:
2704 # Load OpenAPI spec (async to avoid "called from within a running event loop")
2705 spec: Final = await load_openapi_spec_async(spec_path)
2707 # Use base_url from config if provided, otherwise extract from spec
2708 if not base_url:
2709 base_url = get_openapi_base_url(spec, spec_path)
2710 verbose_logger.info("Registering OpenAPI tools for server %s with base URL: %s", server.name, base_url)
2712 # Get server prefix for tool naming
2713 server_prefix: Final = get_server_prefix(server)
2715 # Build headers from server configuration
2716 headers: dict[str, str] = {}
2718 # Add authentication headers if configured
2719 if server.authentication_token:
2720 from litellm.types.mcp import MCPAuth
2722 if server.auth_type == MCPAuth.bearer_token:
2723 headers["Authorization"] = f"Bearer {strip_auth_scheme(server.authentication_token, 'Bearer')}"
2724 elif server.auth_type == MCPAuth.api_key:
2725 headers["Authorization"] = f"ApiKey {strip_auth_scheme(server.authentication_token, 'ApiKey')}"
2726 elif server.auth_type == MCPAuth.basic:
2727 headers["Authorization"] = f"Basic {to_basic_credentials(server.authentication_token)}"
2728 elif server.auth_type == MCPAuth.token:
2729 headers["Authorization"] = f"token {strip_auth_scheme(server.authentication_token, 'token')}"
2731 # Add any static headers from server config.
2732 #
2733 # Note: `extra_headers` on MCPServer is a List[str] of header names to forward
2734 # from each client MCP request; values are applied at call time via
2735 # `_request_extra_headers` in server.py (not baked in here).
2736 # `static_headers` is a dict of concrete headers to always send.
2737 headers = (
2738 merge_mcp_headers(
2739 extra_headers=headers,
2740 static_headers=server.static_headers,
2741 )
2742 or {}
2743 )
2745 verbose_logger.debug(
2746 "Using headers for OpenAPI tools (excluding sensitive values): %s", list(headers.keys())
2747 )
2749 # Extract and register tools from OpenAPI paths
2750 paths: Final = spec.get("paths", {})
2751 components: Final = spec.get("components", {})
2752 registered_count = 0
2754 verbose_logger.debug("Processing %s paths from OpenAPI spec", len(paths))
2756 for path, path_item in paths.items():
2757 for method in ["get", "post", "put", "delete", "patch"]:
2758 if method not in path_item:
2759 continue
2761 operation = path_item[method]
2763 # Resolve $ref params and merge path-level params into the operation.
2764 resolved_operation = resolve_operation_params(operation, path_item, components)
2766 # Generate tool name (without prefix initially)
2767 operation_id = operation.get("operationId", f"{method}_{path.replace('/', '_')}")
2768 base_tool_name = openapi_tool_name(operation_id)
2770 # Add server prefix to tool name
2771 prefixed_tool_name = add_server_prefix_to_name(base_tool_name, server_prefix)
2773 # Get description
2774 description = operation.get(
2775 "summary",
2776 operation.get("description", f"{method.upper()} {path}"),
2777 )
2779 # Build input schema using imported function
2780 input_schema = build_input_schema(resolved_operation)
2782 # Create tool function with headers using imported function
2783 tool_func = create_tool_function(
2784 path,
2785 method,
2786 resolved_operation,
2787 base_url,
2788 headers=headers,
2789 server_label=server.name or server.server_name or server.alias or server.server_id,
2790 relays_upstream_auth=server.is_client_forwarded_token,
2791 auth_type=server.auth_type,
2792 upstream_token_header=server.upstream_token_header,
2793 )
2794 tool_func.__name__ = prefixed_tool_name
2795 tool_func.__doc__ = description
2797 # Register tool with prefixed name in global registry
2798 global_mcp_tool_registry.register_tool(
2799 name=prefixed_tool_name,
2800 description=description,
2801 input_schema=input_schema,
2802 handler=tool_func,
2803 )
2805 # Update tool name to server name mapping (for both prefixed and base names)
2806 self.tool_name_to_mcp_server_name_mapping[base_tool_name] = server_prefix
2807 self.tool_name_to_mcp_server_name_mapping[prefixed_tool_name] = server_prefix
2809 registered_count += 1
2810 verbose_logger.debug("Registered OpenAPI tool: %s for server %s", prefixed_tool_name, server.name)
2812 verbose_logger.info("Successfully registered %s OpenAPI tools for server %s", registered_count, server.name)
2814 except Exception as e:
2815 verbose_logger.error("Failed to register OpenAPI tools for server %s: %s", server.name, e)
2816 raise e
2818 def _cleanup_server_tool_routing_artifacts(self, server: MCPServer) -> None:
2819 """Drop OpenAPI global tools and name-mapping rows owned by ``server``.
2821 When a server leaves ``self.registry`` (eviction, ``remove_server``, etc.),
2822 OpenAPI tools remain in ``global_mcp_tool_registry`` and
2823 ``tool_name_to_mcp_server_name_mapping`` unless removed here. Stale
2824 mappings make ``_get_mcp_server_from_tool_name`` resolve to a prefix that
2825 no longer exists in the live registry.
2826 """
2827 from litellm.proxy._experimental.mcp_server.tool_registry import (
2828 global_mcp_tool_registry,
2829 )
2831 self._invalidate_discovery_lists(server.server_id)
2832 prefix_root: Final = normalize_server_name(get_server_prefix(server))
2833 if server.spec_path and prefix_root: 2833 ↛ 2834line 2833 didn't jump to line 2834 because the condition on line 2833 was never true
2834 openapi_key_prefix: Final = prefix_root + MCP_TOOL_PREFIX_SEPARATOR
2835 global_mcp_tool_registry.unregister_tools_with_prefix(openapi_key_prefix)
2837 owned_normalized: Final = self._owned_mapping_values(server)
2839 stale_mapping_keys: Final = tuple(
2840 tool_name
2841 for tool_name, mapped_server in self.tool_name_to_mcp_server_name_mapping.items()
2842 if normalize_server_name(str(mapped_server)) in owned_normalized
2843 )
2845 for key in stale_mapping_keys: 2845 ↛ 2846line 2845 didn't jump to line 2846 because the loop on line 2845 never started
2846 del self.tool_name_to_mcp_server_name_mapping[key]
2848 def _owned_mapping_values(self, server: MCPServer) -> frozenset[str]:
2849 return frozenset(
2850 normalize_server_name(value) for value in (*iter_known_server_prefixes(server), server.name) if value
2851 )
2853 def server_exposes_tool(self, server: MCPServer, tool_name: str) -> bool:
2854 owned: Final = self._owned_mapping_values(server)
2855 mapped_owners: Final = (
2856 self.tool_name_to_mcp_server_name_mapping.get(spelling)
2857 for spelling in iter_known_tool_name_spellings(tool_name, server)
2858 )
2859 return any(owner is not None and normalize_server_name(owner) in owned for owner in mapped_owners)
2861 def _known_prefix_to_server(self) -> Mapping[str, MCPServer]:
2862 """Every prefix form a tool name may carry, keyed to its server; a form two servers share
2863 stays with the one registered first."""
2864 return {
2865 normalize_server_name(known_prefix): server
2866 for server in reversed(tuple(self.get_registry().values()))
2867 for known_prefix in iter_known_server_prefixes(server)
2868 }
2870 def server_owning_tool_name_prefix(self, tool_name: str) -> MCPServer | None:
2871 prefix_to_server: Final = self._known_prefix_to_server()
2872 matched: Final = match_known_server_prefix(tool_name, prefix_to_server.keys())
2873 return None if matched is None else prefix_to_server.get(matched[0])
2875 def remove_server(self, mcp_server: LiteLLM_MCPServerTable):
2876 """
2877 Remove a server from the registry
2878 """
2879 evicted: MCPServer | None = self.registry.pop(mcp_server.server_id, None)
2880 if evicted is None and mcp_server.server_name: 2880 ↛ 2881line 2880 didn't jump to line 2881 because the condition on line 2880 was never true
2881 evicted = self.registry.pop(mcp_server.server_name, None)
2882 if evicted is not None:
2883 verbose_logger.debug("Removed MCP Server: %s", mcp_server.server_id or mcp_server.server_name)
2884 self._cleanup_server_tool_routing_artifacts(evicted)
2885 self._invalidate_oauth_discovery_state(evicted.server_id)
2886 else:
2887 verbose_logger.warning("Server ID %s not found in registry", mcp_server.server_id)
2889 def _resolve_env_vars_list(
2890 self,
2891 mcp_server: LiteLLM_MCPServerTable,
2892 *,
2893 env_vars_are_encrypted: bool,
2894 ) -> _EnvVarList | None:
2895 env_vars_list: Final = _deserialize_json_list(getattr(mcp_server, "env_vars", None))
2896 if env_vars_are_encrypted:
2897 from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415
2898 decrypt_global_env_var_values,
2899 )
2901 decrypt_global_env_var_values(env_vars_list)
2902 return env_vars_list
2904 async def _resolve_table_oauth_metadata(
2905 self,
2906 *,
2907 mcp_server: LiteLLM_MCPServerTable,
2908 auth_type: MCPAuthType,
2909 server_url: str | None,
2910 manual_issuer: str | None,
2911 manual_authorization_url: str | None,
2912 manual_token_url: str | None,
2913 is_discovery_auth_type: bool,
2914 use_issuer_anchor: bool,
2915 scopes: list[str] | None,
2916 token_exchange_endpoint: str | None,
2917 ) -> tuple[MCPOAuthMetadata | None, bool]:
2918 obo_needs_discovery = self._obo_needs_endpoint_discovery(auth_type, token_exchange_endpoint, manual_token_url)
2919 needs_authorization_url: Final = (
2920 is_discovery_auth_type and getattr(mcp_server, "oauth2_flow", None) != "client_credentials"
2921 )
2922 needs_token_url: Final = is_discovery_auth_type or obo_needs_discovery
2923 warn_on_empty_discovery: Final = _discovery_failure_leaves_needs_unresolved(
2924 needs_authorization_url=needs_authorization_url,
2925 needs_token_url=needs_token_url,
2926 manual_authorization_url=manual_authorization_url,
2927 manual_token_url=manual_token_url,
2928 )
2929 has_all_upstream_oauth_fields: Final = bool(manual_authorization_url and manual_token_url and scopes)
2930 needs_discovery: Final = _has_oauth_discovery_source(server_url, use_issuer_anchor) and (
2931 (is_discovery_auth_type and not has_all_upstream_oauth_fields) or obo_needs_discovery
2932 )
2933 discovery_deferred: Final = needs_discovery and not self._oauth_discovery_on_startup
2934 if not needs_discovery or discovery_deferred: 2934 ↛ 2936line 2934 didn't jump to line 2936 because the condition on line 2934 was always true
2935 mcp_oauth_metadata: MCPOAuthMetadata | None = None
2936 elif use_issuer_anchor and manual_issuer is not None:
2937 mcp_oauth_metadata = await self._fetch_issuer_anchored_oauth_metadata(manual_issuer, server_url)
2938 else:
2939 mcp_oauth_metadata = await self._descovery_metadata(
2940 server_url=server_url,
2941 allow_origin_fallback=is_discovery_auth_type,
2942 warn_when_no_metadata=warn_on_empty_discovery,
2943 )
2944 if use_issuer_anchor:
2945 return mcp_oauth_metadata, discovery_deferred
2946 gated_metadata: Final = (
2947 _restrict_discovery_to_corroborated_authorization_server(
2948 mcp_oauth_metadata,
2949 manual_authorization_url,
2950 mcp_server.server_id,
2951 bool(getattr(mcp_server, "dcr_bridge", None)),
2952 )
2953 if is_discovery_auth_type
2954 else mcp_oauth_metadata
2955 )
2956 _warn_oauth_endpoints_unresolved(
2957 server_ref=mcp_server.alias or mcp_server.server_name or mcp_server.server_id,
2958 server_url=server_url,
2959 discovery_attempted=needs_discovery,
2960 discovery_deferred=discovery_deferred,
2961 issuer_anchored=False,
2962 metadata=gated_metadata,
2963 needs_authorization_url=needs_authorization_url,
2964 needs_token_url=needs_token_url,
2965 manual_authorization_url=manual_authorization_url,
2966 manual_token_url=manual_token_url,
2967 )
2968 return gated_metadata, discovery_deferred
2970 async def build_mcp_server_from_table(
2971 self,
2972 mcp_server: LiteLLM_MCPServerTable,
2973 *,
2974 credentials_are_encrypted: bool = True,
2975 env_vars_are_encrypted: bool | None = None,
2976 ) -> MCPServer:
2977 _mcp_info: Final[MCPInfo] = mcp_server.mcp_info or {}
2978 env_dict: Final = _deserialize_json_dict(getattr(mcp_server, "env", None))
2979 static_headers_dict: Final = _deserialize_json_dict(getattr(mcp_server, "static_headers", None))
2980 env_vars_list: Final = self._resolve_env_vars_list(
2981 mcp_server,
2982 env_vars_are_encrypted=(
2983 credentials_are_encrypted if env_vars_are_encrypted is None else env_vars_are_encrypted
2984 ),
2985 )
2986 credentials_dict: Final = _deserialize_json_dict(getattr(mcp_server, "credentials", None))
2988 encrypted_auth_value: str | None = None
2989 encrypted_client_id: str | None = None
2990 encrypted_client_secret: str | None = None
2991 if credentials_dict: 2991 ↛ 2992line 2991 didn't jump to line 2992 because the condition on line 2991 was never true
2992 encrypted_auth_value = credentials_dict.get("auth_value")
2993 encrypted_client_id = credentials_dict.get("client_id")
2994 encrypted_client_secret = credentials_dict.get("client_secret")
2996 auth_value: str | None = None
2997 if encrypted_auth_value: 2997 ↛ 2998line 2997 didn't jump to line 2998 because the condition on line 2997 was never true
2998 if credentials_are_encrypted:
2999 auth_value = decrypt_value_helper(
3000 value=encrypted_auth_value,
3001 key="auth_value",
3002 exception_type="debug",
3003 return_original_value=True,
3004 )
3005 else:
3006 auth_value = encrypted_auth_value
3008 client_id_value: str | None = None
3009 if encrypted_client_id: 3009 ↛ 3010line 3009 didn't jump to line 3010 because the condition on line 3009 was never true
3010 if credentials_are_encrypted:
3011 client_id_value = decrypt_value_helper(
3012 value=encrypted_client_id,
3013 key="client_id",
3014 exception_type="debug",
3015 return_original_value=True,
3016 )
3017 else:
3018 client_id_value = encrypted_client_id
3020 client_secret_value: str | None = None
3021 if encrypted_client_secret: 3021 ↛ 3022line 3021 didn't jump to line 3022 because the condition on line 3021 was never true
3022 if credentials_are_encrypted:
3023 client_secret_value = decrypt_value_helper(
3024 value=encrypted_client_secret,
3025 key="client_secret",
3026 exception_type="debug",
3027 return_original_value=True,
3028 )
3029 else:
3030 client_secret_value = encrypted_client_secret
3032 # AWS SigV4 credential fields
3033 aws_creds: Final = self._extract_aws_credentials(credentials_dict, credentials_are_encrypted)
3035 scopes: list[str] | None = None
3036 if credentials_dict: 3036 ↛ 3037line 3036 didn't jump to line 3037 because the condition on line 3036 was never true
3037 scopes_value: Final = credentials_dict.get("scopes")
3038 if scopes_value is not None:
3039 scopes = self._extract_scopes(scopes_value)
3041 stored_scopes: Final[object] = credentials_dict.get("scopes") if credentials_dict else None
3042 scopes_as_objects: Final = (
3043 cast(Sequence[object], stored_scopes) # cast-ok: list shape validated below
3044 if isinstance(stored_scopes, list)
3045 else ()
3046 )
3047 configured_scopes: Final = (
3048 tuple(scope for scope in scopes_as_objects if isinstance(scope, str))
3049 if scopes_as_objects and all(isinstance(scope, str) and scope for scope in scopes_as_objects)
3050 else None
3051 )
3053 name_for_prefix: Final = mcp_server.alias or mcp_server.server_name or mcp_server.server_id
3055 mcp_info: Final[MCPInfo] = _mcp_info.copy()
3056 if "server_name" not in mcp_info: 3056 ↛ 3058line 3056 didn't jump to line 3058 because the condition on line 3056 was always true
3057 mcp_info["server_name"] = mcp_server.server_name or mcp_server.server_id
3058 if "description" not in mcp_info and mcp_server.description:
3059 mcp_info["description"] = mcp_server.description
3060 _normalize_mcp_server_cost_info(mcp_info)
3062 auth_type: Final = cast(MCPAuthType, mcp_server.auth_type)
3063 server_url: Final = mcp_server.url
3064 manual_issuer: Final = _blank_to_none(mcp_server.issuer)
3065 manual_authorization_url = _blank_to_none(mcp_server.authorization_url)
3066 manual_token_url = _blank_to_none(mcp_server.token_url)
3067 manual_registration_url = _blank_to_none(mcp_server.registration_url)
3068 is_discovery_auth_type: Final = auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
3069 token_exchange_endpoint: Final = mcp_server.token_exchange_endpoint or (
3070 credentials_dict.get("token_exchange_endpoint") if credentials_dict else None
3071 )
3072 use_issuer_anchor: Final = _uses_issuer_anchor(
3073 manual_issuer,
3074 is_discovery_auth_type
3075 or self._obo_needs_endpoint_discovery(auth_type, token_exchange_endpoint, manual_token_url),
3076 )
3077 configured_authorization_url: Final = manual_authorization_url
3078 configured_token_url: Final = manual_token_url
3079 configured_registration_url: Final = manual_registration_url
3080 manual_authorization_url, manual_token_url, manual_registration_url = _endpoints_yield_to_issuer(
3081 manual_issuer,
3082 is_discovery_auth_type,
3083 manual_authorization_url,
3084 manual_token_url,
3085 manual_registration_url,
3086 mcp_server.alias or mcp_server.server_name or mcp_server.server_id,
3087 )
3088 gated_oauth_metadata, _ = await self._resolve_table_oauth_metadata(
3089 mcp_server=mcp_server,
3090 auth_type=auth_type,
3091 server_url=server_url,
3092 manual_issuer=manual_issuer,
3093 manual_authorization_url=manual_authorization_url,
3094 manual_token_url=manual_token_url,
3095 is_discovery_auth_type=is_discovery_auth_type,
3096 use_issuer_anchor=use_issuer_anchor,
3097 scopes=scopes,
3098 token_exchange_endpoint=token_exchange_endpoint,
3099 )
3101 resolved_scopes: Final = scopes or (gated_oauth_metadata.scopes if gated_oauth_metadata else None)
3102 discovered_issuer: Final = (
3103 gated_oauth_metadata.discovered_issuer
3104 if gated_oauth_metadata and not gated_oauth_metadata.from_origin_fallback
3105 else None
3106 )
3107 effective_issuer: Final = manual_issuer or discovered_issuer
3109 new_server: Final = MCPServer(
3110 server_id=mcp_server.server_id,
3111 name=name_for_prefix,
3112 alias=getattr(mcp_server, "alias", None),
3113 server_name=getattr(mcp_server, "server_name", None),
3114 url=mcp_server.url,
3115 spec_path=getattr(mcp_server, "spec_path", None),
3116 transport=cast(MCPTransportType, mcp_server.transport),
3117 auth_type=auth_type,
3118 authentication_token=auth_value,
3119 mcp_info=mcp_info,
3120 extra_headers=getattr(mcp_server, "extra_headers", None),
3121 static_headers=static_headers_dict,
3122 env_vars=env_vars_list,
3123 client_id=client_id_value or getattr(mcp_server, "client_id", None),
3124 client_secret=client_secret_value or getattr(mcp_server, "client_secret", None),
3125 oauth2_flow=self._explicit_oauth2_flow(getattr(mcp_server, "oauth2_flow", None)),
3126 scopes=resolved_scopes,
3127 configured_scopes=configured_scopes,
3128 issuer=effective_issuer,
3129 issuer_is_anchored=use_issuer_anchor,
3130 authorization_url=manual_authorization_url or getattr(gated_oauth_metadata, "authorization_url", None),
3131 token_url=manual_token_url or getattr(gated_oauth_metadata, "token_url", None),
3132 registration_url=manual_registration_url or getattr(gated_oauth_metadata, "registration_url", None),
3133 configured_authorization_url=configured_authorization_url,
3134 configured_token_url=configured_token_url,
3135 configured_registration_url=configured_registration_url,
3136 token_endpoint_auth_method=(
3137 credentials_dict.get("token_endpoint_auth_method") if credentials_dict else None
3138 ),
3139 command=getattr(mcp_server, "command", None),
3140 args=getattr(mcp_server, "args", None) or [],
3141 env=env_dict,
3142 access_groups=getattr(mcp_server, "mcp_access_groups", None),
3143 allowed_tools=getattr(mcp_server, "allowed_tools", None),
3144 disallowed_tools=getattr(mcp_server, "disallowed_tools", None),
3145 allow_all_keys=mcp_server.allow_all_keys,
3146 available_on_public_internet=bool(getattr(mcp_server, "available_on_public_internet", True)),
3147 delegate_auth_to_upstream=bool(getattr(mcp_server, "delegate_auth_to_upstream", False)),
3148 oauth_passthrough=bool(getattr(mcp_server, "oauth_passthrough", False)),
3149 dcr_bridge=getattr(mcp_server, "dcr_bridge", None),
3150 per_server_oauth_discovery=bool(getattr(mcp_server, "per_server_oauth_discovery", False)),
3151 created_at=getattr(mcp_server, "created_at", None),
3152 updated_at=getattr(mcp_server, "updated_at", None),
3153 tool_name_to_display_name=_deserialize_json_dict(getattr(mcp_server, "tool_name_to_display_name", None)),
3154 tool_name_to_description=_deserialize_json_dict(getattr(mcp_server, "tool_name_to_description", None)),
3155 is_byok=bool(getattr(mcp_server, "is_byok", False)),
3156 byok_description=getattr(mcp_server, "byok_description", None) or [],
3157 byok_api_key_help_url=getattr(mcp_server, "byok_api_key_help_url", None),
3158 source_url=getattr(mcp_server, "source_url", None),
3159 # AWS SigV4 fields
3160 aws_access_key_id=aws_creds.get("aws_access_key_id"),
3161 aws_secret_access_key=aws_creds.get("aws_secret_access_key"),
3162 aws_session_token=aws_creds.get("aws_session_token"),
3163 aws_region_name=aws_creds.get("aws_region_name"),
3164 aws_service_name=aws_creds.get("aws_service_name"),
3165 aws_role_name=aws_creds.get("aws_role_name"),
3166 aws_session_name=aws_creds.get("aws_session_name"),
3167 instructions=mcp_server.instructions,
3168 # Token exchange (OBO) fields: dedicated columns, with the credentials blob as a
3169 # back-compat fallback for servers persisted before the columns existed.
3170 token_exchange_endpoint=mcp_server.token_exchange_endpoint
3171 or (credentials_dict.get("token_exchange_endpoint") if credentials_dict else None),
3172 audience=mcp_server.audience or (credentials_dict.get("audience") if credentials_dict else None),
3173 subject_token_type=mcp_server.subject_token_type
3174 or (credentials_dict.get("subject_token_type") if credentials_dict else None)
3175 or DEFAULT_SUBJECT_TOKEN_TYPE,
3176 upstream_resource=(credentials_dict.get("upstream_resource") if credentials_dict else None),
3177 upstream_token_header=(credentials_dict.get("upstream_token_header") if credentials_dict else None),
3178 # ID-JAG fields — read from credentials JSON blob
3179 id_jag_resource_token_endpoint=(
3180 credentials_dict.get("id_jag_resource_token_endpoint") if credentials_dict else None
3181 ),
3182 id_jag_resource=(credentials_dict.get("id_jag_resource") if credentials_dict else None),
3183 client_private_key=self._decrypt_credential_field(
3184 credentials_dict.get("client_private_key") if credentials_dict else None,
3185 "client_private_key",
3186 credentials_are_encrypted,
3187 ),
3188 client_private_key_id=(credentials_dict.get("client_private_key_id") if credentials_dict else None),
3189 client_assertion_signing_alg=(
3190 credentials_dict.get("client_assertion_signing_alg") if credentials_dict else None
3191 )
3192 or "RS256",
3193 token_exchange_profile=mcp_server.token_exchange_profile
3194 or (credentials_dict.get("token_exchange_profile") if credentials_dict else None)
3195 or "rfc8693",
3196 timeout=getattr(mcp_server, "timeout", None),
3197 max_concurrent_requests=getattr(mcp_server, "max_concurrent_requests", None),
3198 )
3199 _warn_legacy_delegate_auth_if_applicable(new_server, source="database")
3200 self._set_oauth_discovery_deferred(
3201 new_server.server_id,
3202 _requires_oauth_discovery(server_url, use_issuer_anchor, new_server),
3203 )
3204 return new_server
3206 async def _maybe_register_openapi_tools(self, server: MCPServer, *, initialize_mapping: bool = True):
3207 """Register OpenAPI tools if the server has a spec_path configured."""
3208 if server.spec_path:
3209 verbose_logger.info("Loading OpenAPI spec from %s for server %s", server.spec_path, server.name)
3210 await self._register_openapi_tools(
3211 spec_path=server.spec_path,
3212 server=server,
3213 base_url=server.url or "",
3214 )
3215 if initialize_mapping:
3216 self.initialize_tool_name_to_mcp_server_name_mapping()
3218 async def add_server(self, mcp_server: LiteLLM_MCPServerTable):
3219 # The runtime registry is the allowlist for tool calls and health
3220 # probes (which spawn the underlying transport, including stdio
3221 # subprocesses). Match the eligibility set used by the bulk DB
3222 # filter in reload_servers_from_database() — NULL is legacy and
3223 # "approved" is a legacy alias for "active".
3224 if mcp_server.approval_status not in (None, "active", "approved"):
3225 return
3226 try:
3227 if mcp_server.server_id not in self.registry:
3228 # Callers hand us a record returned by the db.py read/write
3229 # helpers, which already decrypt global env var values (the
3230 # `credentials` field is the only one still encrypted here).
3231 # Re-decrypting plaintext would zero the values, so build with
3232 # env_vars_are_encrypted=False.
3233 self._warn_if_newly_blocked_stdio(mcp_server, None)
3234 new_server: Final = await self.build_mcp_server_from_table(mcp_server, env_vars_are_encrypted=False)
3235 self._assign_unique_short_prefix(new_server)
3236 self._invalidate_discovery_lists(mcp_server.server_id)
3237 self.registry[mcp_server.server_id] = new_server
3238 await self._maybe_register_openapi_tools(new_server)
3239 self.prime_oauth_metadata_discovery(new_server)
3240 verbose_logger.debug("Added MCP Server: %s", new_server.name)
3242 except Exception as e:
3243 verbose_logger.debug("Failed to add MCP server: %s", e)
3244 raise e
3246 async def update_server(self, mcp_server: LiteLLM_MCPServerTable):
3247 # If a previously-active server has been moved out of the active
3248 # state, evict any stale registry entry so subsequent tool calls and
3249 # health probes can't reach it.
3250 if mcp_server.approval_status not in (None, "active", "approved"):
3251 evicted = self.registry.pop(mcp_server.server_id, None)
3252 if evicted is None and mcp_server.server_name: 3252 ↛ 3253line 3252 didn't jump to line 3253 because the condition on line 3252 was never true
3253 evicted = self.registry.pop(mcp_server.server_name, None)
3254 if evicted is not None: 3254 ↛ 3255line 3254 didn't jump to line 3255 because the condition on line 3254 was never true
3255 self._cleanup_server_tool_routing_artifacts(evicted)
3256 self._invalidate_oauth_discovery_state(evicted.server_id)
3257 return
3258 try:
3259 if mcp_server.server_id in self.registry: 3259 ↛ exitline 3259 didn't return from function 'update_server' because the condition on line 3259 was always true
3260 # See add_server: db.py helpers already decrypted env var
3261 # values, so don't decrypt them a second time here.
3262 new_server: Final = await self.build_mcp_server_from_table(mcp_server, env_vars_are_encrypted=False)
3263 # Carry the previously-resolved short prefix across so the
3264 # tool names stay stable for clients holding cached lists.
3265 existing_prefix: Final = self.registry[mcp_server.server_id].short_prefix
3266 if existing_prefix and not new_server.short_prefix: 3266 ↛ 3267line 3266 didn't jump to line 3267 because the condition on line 3266 was never true
3267 new_server.short_prefix = existing_prefix
3268 _carry_forward_resolved_oauth_endpoints(
3269 new_server=new_server,
3270 previous_server=self.registry[mcp_server.server_id],
3271 )
3272 self._assign_unique_short_prefix(new_server)
3273 self._invalidate_discovery_lists(mcp_server.server_id)
3274 self.registry[mcp_server.server_id] = new_server
3275 await self._maybe_register_openapi_tools(new_server)
3276 self.prime_oauth_metadata_discovery(new_server)
3277 verbose_logger.debug("Updated MCP Server: %s", new_server.name)
3279 except Exception as e:
3280 verbose_logger.debug("Failed to udpate MCP server: %s", e)
3281 raise e
3283 def get_all_mcp_server_ids(self) -> set[str]:
3284 """
3285 Get all MCP server IDs
3286 """
3287 all_servers: Final = list(self.get_registry().values())
3288 return {server.server_id for server in all_servers}
3290 def get_allow_all_keys_server_ids(self) -> list[str]:
3291 """Return server IDs that bypass per-key restrictions."""
3292 return [server.server_id for server in self.get_registry().values() if server.allow_all_keys is True]
3294 @staticmethod
3295 def get_byom_submitted_servers_cache_key(user_id: str) -> str:
3296 return f"byom_submitted_servers:{user_id}"
3298 async def invalidate_byom_submitted_servers_cache(self, user_id: str | None) -> None:
3299 if not user_id: 3299 ↛ 3301line 3299 didn't jump to line 3301 because the condition on line 3299 was always true
3300 return
3301 try:
3302 from litellm.proxy.proxy_server import user_api_key_cache
3304 await user_api_key_cache.async_delete_cache(key=self.get_byom_submitted_servers_cache_key(user_id))
3305 except Exception as e: # noqa: BLE001
3306 verbose_logger.warning("Failed to invalidate BYOM submitted MCP server cache: %s", e)
3308 async def _get_active_submitted_mcp_server_ids_for_user(
3309 self, user_api_key_auth: UserAPIKeyAuth | None
3310 ) -> list[str]:
3311 submitter_user_id: str | None = getattr(user_api_key_auth, "user_id", None) if user_api_key_auth else None
3312 if not submitter_user_id: 3312 ↛ 3313line 3312 didn't jump to line 3313 because the condition on line 3312 was never true
3313 return []
3315 try:
3316 from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415
3317 get_active_submitted_mcp_server_ids_for_user,
3318 )
3319 from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
3320 except Exception as e: # noqa: BLE001
3321 verbose_logger.warning("Failed to load BYOM submitted MCP server cache dependencies: %s", e)
3322 return []
3324 byom_cache_key: Final = self.get_byom_submitted_servers_cache_key(submitter_user_id)
3325 submitted_server_ids: list[str] | None = None
3326 try:
3327 cached_submitted_server_ids: Final = await user_api_key_cache.async_get_cache(key=byom_cache_key)
3328 if cached_submitted_server_ids is not None:
3329 submitted_server_ids = cast(list[str], cached_submitted_server_ids)
3330 except Exception as e: # noqa: BLE001
3331 verbose_logger.warning("Failed to read BYOM submitted MCP server cache: %s", e)
3333 if submitted_server_ids is None:
3334 if prisma_client is None: 3334 ↛ 3335line 3334 didn't jump to line 3335 because the condition on line 3334 was never true
3335 submitted_server_ids = []
3336 else:
3337 try:
3338 submitted_server_ids = await get_active_submitted_mcp_server_ids_for_user(
3339 prisma_client, submitter_user_id
3340 )
3341 except Exception as e: # noqa: BLE001
3342 verbose_logger.warning("Failed to read BYOM submitted MCP servers from database: %s", e)
3343 submitted_server_ids = []
3344 try:
3345 await user_api_key_cache.async_set_cache(
3346 key=byom_cache_key,
3347 value=submitted_server_ids,
3348 ttl=60,
3349 )
3350 except Exception as e: # noqa: BLE001
3351 verbose_logger.warning("Failed to write BYOM submitted MCP server cache: %s", e)
3353 return [server_id for server_id in submitted_server_ids if self.get_mcp_server_by_id(server_id) is not None]
3355 async def operator_open_server_ids(
3356 self,
3357 user_api_key_auth: UserAPIKeyAuth | None = None,
3358 *,
3359 allow_all_server_ids: list[str] | None = None,
3360 submitted_server_ids: list[str] | None = None,
3361 ) -> set:
3362 """Servers reachable through OPEN channels rather than a grant: operator-opened
3363 ``allow_all_keys`` servers, plus the caller's own active BYOM submissions when the caller
3364 carries no explicit ``mcp_servers`` scope.
3366 The single owner of that question for BOTH axes. The server union in
3367 ``get_allowed_mcp_servers`` adds these ids, and the admitted subject's tool resolution asks
3368 the same question to treat an open-channel server as default-open for tools — exactly how a
3369 virtual key experiences it. Encoding the channel membership twice is how a server ends up
3370 listable but uninvokable.
3372 Empty inside a toolset scope: toolset_mcp_route / dynamic_mcp_route set
3373 the caller's server-only ``mcp_toolset_id`` before calling the handler, pinning the request to the toolset's
3374 own servers (checking op.mcp_toolsets==[] instead would false-positive on DB-default rows
3375 where Postgres initialises the column to ARRAY[]::TEXT[]).
3377 ``allow_all_server_ids`` / ``submitted_server_ids`` are injectable so the server union,
3378 which precomputes both for its fallback path, does not compute them twice."""
3379 if user_api_key_auth is not None and user_api_key_auth.mcp_toolset_id is not None:
3380 return set()
3381 if allow_all_server_ids is None: 3381 ↛ 3382line 3381 didn't jump to line 3382 because the condition on line 3381 was never true
3382 allow_all_server_ids = self.get_allow_all_keys_server_ids()
3383 open_ids: Final = set(allow_all_server_ids)
3384 key_object_permission: Final = user_api_key_auth.object_permission if user_api_key_auth else None
3385 # "Explicitly scoped, so do not widen with BYOM" is a rule about a CREDENTIAL that carries
3386 # its own mcp_servers list. It does not describe a keyless admitted subject: its
3387 # object_permission is the user's own row, whose mcp_servers column is [] by DB default, so
3388 # applying this rule would hide almost every admitted user's OWN submitted servers. Their
3389 # submissions are theirs by authorship, and their scope comes from the per-source union.
3390 has_explicit_object_permission: Final = (
3391 not _is_mcp_admitted_user_subject(user_api_key_auth)
3392 and key_object_permission is not None
3393 and (key_object_permission.mcp_servers is not None)
3394 )
3395 if not has_explicit_object_permission: 3395 ↛ 3399line 3395 didn't jump to line 3399 because the condition on line 3395 was always true
3396 if submitted_server_ids is None: 3396 ↛ 3397line 3396 didn't jump to line 3397 because the condition on line 3396 was never true
3397 submitted_server_ids = await self._get_active_submitted_mcp_server_ids_for_user(user_api_key_auth)
3398 open_ids.update(submitted_server_ids)
3399 return open_ids
3401 @staticmethod
3402 def _admitted_session_resource_scope(user_api_key_auth: UserAPIKeyAuth | None) -> str | None:
3403 """The single server an admitted session subject's bearer was scoped to at authorize
3404 time (RFC 8707 resource), or None for every other principal shape and for unscoped
3405 sessions. Read at every return path of :meth:`get_allowed_mcp_servers`, including
3406 the exception fallback, and applied AFTER every union (grants, operator-open,
3407 submitted) because the scope is a ceiling over the whole reachable set; a resolver
3408 fault therefore never widens a scoped bearer to the allow-all set."""
3409 if user_api_key_auth is None or not _is_mcp_admitted_user_subject(user_api_key_auth): 3409 ↛ 3411line 3409 didn't jump to line 3411 because the condition on line 3409 was always true
3410 return None
3411 return user_api_key_auth.mcp_session_resource_server_id
3413 async def get_allowed_mcp_servers(
3414 self,
3415 user_api_key_auth: UserAPIKeyAuth | None = None,
3416 *,
3417 access: MCPServerAccess | None = None,
3418 general_settings: Mapping[str, object] | None = None,
3419 ) -> list[str]:
3420 """
3421 Get the allowed MCP Servers for the user.
3423 Priority:
3424 1. If object_permission.mcp_servers is explicitly set, use it (even for admins)
3425 2. If admin and no object_permission, return all servers
3426 3. Otherwise, use standard permission checks
3427 """
3428 from litellm.proxy.proxy_server import general_settings as proxy_general_settings
3430 resolved_general_settings: Final = proxy_general_settings if general_settings is None else general_settings
3431 allow_all_server_ids: Final = self.get_allow_all_keys_server_ids()
3433 # A keyless admitted subject is resolved per grant source, and channel decisions that are
3434 # absolute for a scoped KEY credential are not absolute for it: its own opt-out silences its
3435 # own source (handled per source in the resolver), never its teams' grants. Its admin role
3436 # rides the HUMAN, not the credential: an admin's session resolves the same registry their
3437 # dashboard shows (connect-page parity), bounded like an admin key by explicit
3438 # object_permission scope, the entitlement ceiling, and the session resource scope below.
3439 is_admitted_subject: Final = _is_mcp_admitted_user_subject(user_api_key_auth)
3441 # The key explicitly opted out of every MCP server. Return zero before
3442 # layering on allow_all_keys or submitted servers so the opt-out is absolute.
3443 key_object_permission: Final = user_api_key_auth.object_permission if user_api_key_auth else None
3444 if ( 3444 ↛ 3449line 3444 didn't jump to line 3449 because the condition on line 3444 was never true
3445 not is_admitted_subject
3446 and key_object_permission is not None
3447 and (SpecialMCPServerNames.no_mcp_servers.value in (key_object_permission.mcp_servers or []))
3448 ):
3449 return []
3451 # Check if object_permission.mcp_servers is explicitly set (not None, empty list is valid)
3452 has_explicit_object_permission: Final = key_object_permission is not None and (
3453 key_object_permission.mcp_servers is not None
3454 )
3455 if has_explicit_object_permission:
3456 verbose_logger.debug("Object permission mcp_servers explicitly set: %s", key_object_permission.mcp_servers)
3458 # BYOM creator visibility never widens a key that was explicitly scoped:
3459 # only keys without their own mcp_servers list get submitted servers unioned in.
3460 submitted_server_ids: Final = (
3461 []
3462 if has_explicit_object_permission
3463 else await self._get_active_submitted_mcp_server_ids_for_user(user_api_key_auth)
3464 )
3466 try:
3467 # Admin view with no explicit object permission and no entitlement ceiling resolves the
3468 # whole registry, for keys AND admitted session subjects alike (one predicate owns the
3469 # question). Seeded into the union rather than returned early so the session resource
3470 # scope below still bounds a per-server envelope held by an admin.
3471 admin_unscoped: Final = await MCPRequestHandler.admin_view_unscoped(user_api_key_auth)
3472 resolved_access: Final = (
3473 MCPServerAccess(server_ids=())
3474 if admin_unscoped
3475 else access or await MCPRequestHandler.get_mcp_server_access(user_api_key_auth)
3476 )
3477 resolved_server_ids: Final = (
3478 set(self.get_registry().keys()) if admin_unscoped else set(resolved_access.server_ids)
3479 )
3480 combined_servers: Final = set(resolved_server_ids)
3481 verbose_logger.debug("Allowed MCP Servers for user api key auth: %s", combined_servers)
3482 combined_servers.update(
3483 await self.operator_open_server_ids(
3484 user_api_key_auth,
3485 allow_all_server_ids=allow_all_server_ids,
3486 submitted_server_ids=submitted_server_ids,
3487 )
3488 )
3490 is_anonymous: Final = not (
3491 user_api_key_auth
3492 and (
3493 getattr(user_api_key_auth, "user_id", None)
3494 or getattr(user_api_key_auth, "user_role", None)
3495 or getattr(user_api_key_auth, "api_key", None)
3496 )
3497 )
3498 if is_anonymous: 3498 ↛ 3499line 3498 didn't jump to line 3499 because the condition on line 3498 was never true
3499 passthrough_server_ids: Final = [
3500 server.server_id
3501 for server in self.get_registry().values()
3502 if server.auth_type == MCPAuth.true_passthrough
3503 ]
3504 combined_servers.update(passthrough_server_ids)
3506 restrict_allow_all: Final = (
3507 resolved_general_settings.get("mcp_allow_all_keys_respects_mcp_scope", False)
3508 and user_api_key_auth is not None
3509 and user_api_key_auth.via_virtual_key
3510 and resolved_access.scope != "unscoped"
3511 )
3512 if restrict_allow_all: 3512 ↛ 3513line 3512 didn't jump to line 3513 because the condition on line 3512 was never true
3513 combined_servers.difference_update(
3514 set(allow_all_server_ids)
3515 - resolved_server_ids
3516 - (set(submitted_server_ids) if resolved_access.scope != "unresolved" else set())
3517 )
3518 if len(combined_servers) == 0:
3519 verbose_logger.debug("No allowed MCP Servers found for user api key auth.")
3520 scope = MCPServerManager._admitted_session_resource_scope(user_api_key_auth)
3521 return [server_id for server_id in combined_servers if scope is None or server_id == scope]
3522 except Exception: # noqa: BLE001
3523 verbose_logger.exception(
3524 "Failed to get allowed MCP servers; team-level object_permission "
3525 "grants may be dropped. Falling back to global and submitted servers."
3526 )
3527 scope = MCPServerManager._admitted_session_resource_scope(user_api_key_auth)
3528 return [
3529 server_id
3530 for server_id in dict.fromkeys(allow_all_server_ids + submitted_server_ids)
3531 if scope is None or server_id == scope
3532 ]
3534 async def resolve_toolset_tool_permissions(
3535 self,
3536 toolset_ids: list[str],
3537 ) -> dict[str, list[str]]:
3538 """
3539 Resolve a list of toolset IDs into a mcp_tool_permissions dict.
3541 Returns: {server_id: [tool_name, ...]} — the union of all tools across
3542 the given toolsets. Results are cached via ``user_api_key_cache`` (a
3543 Redis-backed ``DualCache`` in production) so that cache entries are
3544 shared across workers and cold-cache DB hits are minimised.
3546 A row names a tool on the server identified by ``server_id``, so the
3547 stored name is the tool's own name and is used as written. It is never
3548 reduced by the server's wire prefix: that prefix is added on the way out
3549 and is not part of any tool's identity, so treating a leading segment as
3550 one silently renames the tool when a native name happens to begin with
3551 it (``greyhound_internal_events`` on a server prefixed ``greyhound``).
3552 """
3553 from litellm.proxy._experimental.mcp_server.toolset_db import list_mcp_toolsets
3554 from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
3556 if not toolset_ids or prisma_client is None: 3556 ↛ 3557line 3556 didn't jump to line 3557 because the condition on line 3556 was never true
3557 return {}
3559 cache_key: Final = "toolset_perms:" + ",".join(sorted(toolset_ids))
3560 cached: Final[dict[str, list[str]] | None] = await user_api_key_cache.async_get_cache(key=cache_key)
3561 if cached is not None:
3562 return cached
3564 try:
3565 toolsets: Final = await list_mcp_toolsets(prisma_client, toolset_ids=toolset_ids)
3566 tool_permissions: Final[dict[str, list[str]]] = {}
3567 for toolset in toolsets:
3568 for tool in toolset.tools:
3569 allowed_names = tool_permissions.setdefault(tool["server_id"], [])
3570 if tool["tool_name"] not in allowed_names: 3570 ↛ 3568line 3570 didn't jump to line 3568 because the condition on line 3570 was always true
3571 allowed_names.append(tool["tool_name"])
3572 await user_api_key_cache.async_set_cache(
3573 key=cache_key,
3574 value=tool_permissions,
3575 ttl=get_management_object_ttl(user_api_key_cache),
3576 )
3577 return tool_permissions
3578 except Exception as e:
3579 verbose_logger.warning("Failed to resolve toolset permissions: %s", e)
3580 return {}
3582 def invalidate_toolset_cache(self, toolset_id: str | None = None) -> None:
3583 """Evict cached toolset permission entries.
3585 Called after create/update/delete of a toolset so stale data is not served.
3586 The in-memory layer of ``user_api_key_cache`` is cleared immediately;
3587 Redis entries expire naturally after the configured TTL.
3588 Pass toolset_id to evict only entries containing that ID, or None to clear all.
3589 """
3590 # Clear the in-memory layer of the shared DualCache for affected keys.
3591 # We can't enumerate Redis keys by pattern, so Redis entries expire via TTL.
3592 try:
3593 from litellm.proxy.proxy_server import user_api_key_cache
3595 in_mem: Final[InMemoryCache | None] = getattr(user_api_key_cache, "in_memory_cache", None)
3596 if in_mem is None: 3596 ↛ 3597line 3596 didn't jump to line 3597 because the condition on line 3596 was never true
3597 return
3598 cache_dict: Final[_InMemoryCacheDict] = getattr(in_mem, "cache_dict", {})
3599 if toolset_id is None:
3600 keys_to_remove = [k for k in cache_dict if k.startswith("toolset_")]
3601 else:
3602 # Evict permission-cache entries that reference this toolset ID.
3603 # Also evict ALL name-cache entries (toolset_name:*): we can't map
3604 # toolset_id → toolset_name without a DB call, and the name may have
3605 # changed in an update anyway.
3606 keys_to_remove = [
3607 k
3608 for k in cache_dict
3609 if (k.startswith("toolset_perms:") and toolset_id in k) or k.startswith("toolset_name:")
3610 ]
3611 for k in keys_to_remove:
3612 cache_dict.pop(k, None)
3613 except Exception as e:
3614 verbose_logger.warning("invalidate_toolset_cache: failed to evict in-memory entries: %s", e)
3616 async def get_toolset_by_name_cached(
3617 self,
3618 prisma_client: PrismaClient,
3619 toolset_name: str,
3620 ) -> "MCPToolset | None":
3621 """Return a toolset by name, cached in ``user_api_key_cache`` (Redis-backed
3622 ``DualCache`` in production) to avoid a DB hit on every routed request.
3624 Serialisation note: the cache value is stored as a plain JSON-safe dict via
3625 ``model_dump(mode="json")`` so that Redis round-trips correctly in multi-worker
3626 deployments. On a cache hit we reconstruct the ``MCPToolset`` Pydantic object
3627 so callers can always use attribute access (e.g. ``toolset.toolset_id``).
3628 """
3629 from litellm.proxy.proxy_server import user_api_key_cache
3630 from litellm.types.mcp_server.mcp_toolset import MCPToolset
3632 cache_key: Final = f"toolset_name:{toolset_name}"
3633 cached: Final = await user_api_key_cache.async_get_cache(key=cache_key)
3634 if cached is not None:
3635 # Sentinel value used to cache "not found" so we don't re-query for
3636 # names that don't exist.
3637 if cached == "__not_found__":
3638 return None
3639 # Redis deserialises JSON back as a plain dict — reconstruct the model.
3640 if isinstance(cached, dict): 3640 ↛ 3642line 3640 didn't jump to line 3642 because the condition on line 3640 was always true
3641 return MCPToolset(**cached)
3642 return cached
3644 from litellm.proxy._experimental.mcp_server.toolset_db import (
3645 get_mcp_toolset_by_name,
3646 )
3648 toolset: Final = await get_mcp_toolset_by_name(prisma_client, toolset_name)
3649 await user_api_key_cache.async_set_cache(
3650 key=cache_key,
3651 value=(toolset.model_dump(mode="json") if toolset is not None else "__not_found__"),
3652 ttl=get_management_object_ttl(user_api_key_cache),
3653 )
3654 return toolset
3656 def filter_server_ids_by_ip(self, server_ids: list[str], client_ip: str | None) -> list[str]:
3657 """
3658 Filter server IDs by client IP — external callers only see public servers.
3660 Returns server_ids unchanged when client_ip is None (no filtering).
3661 """
3662 filtered, _ = self.filter_server_ids_by_ip_with_info(server_ids, client_ip)
3663 return filtered
3665 def filter_server_ids_by_ip_with_info(self, server_ids: list[str], client_ip: str | None) -> tuple[list[str], int]:
3666 """
3667 Filter server IDs by client IP — external callers only see public servers.
3669 Returns (filtered_ids, ip_blocked_count) where ip_blocked_count is the number
3670 of servers that were blocked because the client IP is not allowed to access them.
3671 Returns server_ids unchanged (with 0 blocked) when client_ip is None.
3672 """
3673 if client_ip is None:
3674 return server_ids, 0
3675 allowed: Final = []
3676 blocked = 0
3677 for sid in server_ids:
3678 s = self.get_mcp_server_by_id(sid)
3679 if s is not None and self._is_server_accessible_from_ip(s, client_ip):
3680 allowed.append(sid)
3681 elif s is not None: 3681 ↛ 3682line 3681 didn't jump to line 3682 because the condition on line 3681 was never true
3682 blocked += 1
3683 return allowed, blocked
3685 async def get_tools_for_server(self, server_id: str) -> list[MCPTool]:
3686 """
3687 Get the tools for a given server
3688 """
3689 try:
3690 server: Final = self.get_mcp_server_by_id(server_id)
3691 if server is None: 3691 ↛ 3692line 3691 didn't jump to line 3692 because the condition on line 3691 was never true
3692 verbose_logger.warning("MCP Server %s not found", server_id)
3693 return []
3694 return await self._get_tools_from_server(server)
3695 except Exception as e:
3696 verbose_logger.warning("Failed to get tools from server %s: %s", server_id, e)
3697 return []
3699 async def list_tools(
3700 self,
3701 user_api_key_auth: UserAPIKeyAuth | None = None,
3702 mcp_auth_header: str | None = None,
3703 mcp_server_auth_headers: dict[str, str | dict[str, str]] | None = None,
3704 ) -> list[MCPTool]:
3705 """
3706 List all tools available across all MCP Servers.
3708 Args:
3709 user_api_key_auth: User authentication
3710 mcp_auth_header: MCP auth header (deprecated)
3711 mcp_server_auth_headers: Optional dict of server-specific auth headers {server_alias: auth_value}
3712 mcp_protocol_version: Optional MCP protocol version from request header
3714 Returns:
3715 List[MCPTool]: Combined list of tools from all servers
3716 """
3717 allowed_mcp_servers: Final = await self.get_allowed_mcp_servers(user_api_key_auth)
3719 verbose_logger.debug("SERVER MANAGER LISTING TOOLS")
3721 async def _fetch_server_tools(server_id: str) -> list[MCPTool]:
3722 """Fetch tools from a single server with error handling."""
3723 server: Final = self.get_mcp_server_by_id(server_id)
3724 if server is None:
3725 verbose_logger.warning("MCP Server %s not found", server_id)
3726 return []
3728 # Get server-specific auth header if available
3729 server_auth_header: str | dict[str, str] | None = None
3730 if mcp_server_auth_headers:
3731 server_auth_header = lookup_mcp_server_auth_in_headers(
3732 mcp_server_auth_headers,
3733 alias=server.alias,
3734 server_name=server.server_name,
3735 access_groups=server.access_groups,
3736 )
3738 # Fall back to deprecated mcp_auth_header if no server-specific header found
3739 if server_auth_header is None:
3740 server_auth_header = mcp_auth_header
3742 try:
3743 tools: Final = await self._get_tools_from_server(
3744 server=server,
3745 mcp_auth_header=server_auth_header,
3746 user_api_key_auth=user_api_key_auth,
3747 )
3748 return tools
3749 except Exception as e:
3750 verbose_logger.warning(
3751 "Failed to list tools from server %s: %s. Continuing with other servers.", server.name, e
3752 )
3753 return []
3755 # Fetch tools from all servers in parallel
3756 tasks: Final = [_fetch_server_tools(server_id) for server_id in allowed_mcp_servers]
3757 results: Final = await asyncio.gather(*tasks)
3759 # Flatten results into single list
3760 list_tools_result: Final[list[MCPTool]] = [tool for tools in results for tool in tools]
3762 verbose_logger.info("Successfully fetched %s tools total from all servers", len(list_tools_result))
3763 return list_tools_result
3765 #########################################################
3766 # Methods that call the upstream MCP servers
3767 #########################################################
3768 @staticmethod
3769 def _extract_bearer_token(
3770 oauth2_headers: Mapping[str, str] | None,
3771 raw_headers: Mapping[str, str] | None,
3772 ) -> str | None:
3773 """Extract the bare Bearer token from oauth2_headers or raw_headers.
3775 Returns the token string without the ``Bearer `` prefix, or ``None``
3776 if no Authorization header is found.
3777 """
3778 auth_value: str | None = None
3779 if oauth2_headers and "Authorization" in oauth2_headers:
3780 auth_value = oauth2_headers["Authorization"]
3781 elif raw_headers:
3782 # raw_headers may have lowercase keys depending on the ASGI server
3783 normalized: Final = {k.lower(): v for k, v in raw_headers.items()}
3784 auth_value = normalized.get("authorization")
3785 if auth_value:
3786 return strip_auth_scheme(auth_value, "Bearer")
3787 return None
3789 @staticmethod
3790 def _extract_subject_token(
3791 oauth2_headers: Mapping[str, str] | None,
3792 raw_headers: Mapping[str, str] | None,
3793 user_api_key_auth: UserAPIKeyAuth | None,
3794 ) -> str | None:
3795 """The caller's upstream identity token, or ``None`` when the bearer is a LiteLLM key.
3797 Rejects the key admission validated and, because virtual keys always carry the ``sk-`` prefix,
3798 any other LiteLLM key a client puts in ``Authorization`` next to ``x-litellm-api-key``.
3799 """
3800 if _authorization_is_litellm_admission_credential(raw_headers, user_api_key_auth):
3801 return None
3802 bearer: Final = MCPServerManager._extract_bearer_token(oauth2_headers, raw_headers)
3803 if bearer is not None and bearer.startswith(LITELLM_VIRTUAL_KEY_PREFIX):
3804 return None
3805 return bearer
3807 def _obo_subject_token(
3808 self,
3809 server: MCPServer,
3810 raw_headers: Mapping[str, str] | None,
3811 user_api_key_auth: UserAPIKeyAuth | None,
3812 ) -> str | None:
3813 """The caller's bearer as the token_exchange (OBO) subject token, for that mode only.
3815 Prompts/resources discovery and reads on a token_exchange server must exchange the caller's
3816 token like the tools paths do, not connect with no credential. Other modes never read the
3817 inbound bearer, so return None to avoid forwarding it.
3818 """
3819 if server.auth_type != MCPAuth.oauth2_token_exchange:
3820 return None
3821 return self._extract_subject_token(None, raw_headers, user_api_key_auth)
3823 def _build_stdio_env(
3824 self,
3825 server: MCPServer,
3826 raw_headers: dict[str, str] | None = None,
3827 ) -> dict[str, str] | None:
3828 """Resolve stdio env values, supporting header-driven placeholders."""
3830 if server.transport != MCPTransport.stdio or not server.env: 3830 ↛ 3833line 3830 didn't jump to line 3833 because the condition on line 3830 was always true
3831 return None
3833 resolved_env: Final[dict[str, str]] = {}
3834 normalized_headers: Final = {k.lower(): v for k, v in (raw_headers or {}).items()}
3836 for env_key, env_value in server.env.items():
3837 stripped_value = env_value.strip()
3838 match = self._STDIO_ENV_TEMPLATE_PATTERN.match(stripped_value)
3839 if match:
3840 header_name = match.group(1)
3841 header_value = normalized_headers.get(header_name.lower())
3842 if header_value is None:
3843 continue
3844 resolved_env[env_key] = header_value
3845 else:
3846 resolved_env[env_key] = env_value
3848 return resolved_env
3850 def _references_per_user_env_var(self, server: MCPServer) -> bool:
3851 """True when ``server.static_headers`` reference a per-user ``${NAME}`` env var.
3853 Such placeholders can only be filled from a calling user's stored values,
3854 so a userless probe (health check / instructions prefetch) would forward
3855 the literal ``${NAME}`` upstream and get rejected. Callers skip the probe
3856 and report ``unknown`` instead of a misleading ``unhealthy``.
3857 """
3858 static_headers: Final = server.static_headers
3859 env_vars: Final[_EnvVarList | None] = getattr(server, "env_vars", None)
3860 if not static_headers or not env_vars: 3860 ↛ 3862line 3860 didn't jump to line 3862 because the condition on line 3860 was always true
3861 return False
3862 _global_values, user_specs = parse_admin_env_vars(env_vars)
3863 user_var_names: Final = {spec["name"] for spec in user_specs}
3864 if not user_var_names:
3865 return False
3866 referenced: Final = collect_env_var_references(strings=static_headers.values())
3867 return bool(referenced & user_var_names)
3869 async def _resolve_static_headers_with_env_vars(
3870 self,
3871 server: MCPServer,
3872 user_api_key_auth: UserAPIKeyAuth | None,
3873 *,
3874 raise_on_missing: bool = True,
3875 ) -> dict[str, str] | None:
3876 """Return server.static_headers with ``${NAME}`` interpolated.
3878 Globals come from ``server.env_vars`` entries with ``scope=="global"``.
3879 Per-user values come from the ``LiteLLM_MCPUserEnvVars`` row for the
3880 calling user.
3882 When ``raise_on_missing`` is ``True`` (the tool-*call* path), raises
3883 ``MCPMissingUserEnvVarsError`` if ``static_headers`` reference a per-user
3884 variable the calling user has not yet supplied — converted into a
3885 user-facing 412 by the REST layer.
3887 When ``raise_on_missing`` is ``False`` (the tool-*list* path), missing
3888 per-user vars are non-blocking: we interpolate whatever is available and
3889 leave unfilled ``${NAME}`` references untouched, so the server's tools
3890 still appear in the listing. The user only hits the friendly error when
3891 they actually invoke a tool that needs the missing value.
3892 """
3893 static_headers: Final = server.static_headers
3894 env_vars: Final = getattr(server, "env_vars", None)
3895 if not static_headers and not env_vars:
3896 return static_headers
3898 global_values, user_specs = parse_admin_env_vars(env_vars)
3899 # An empty-valued global is treated as unset: it must not mask a per-user
3900 # var the user still has to supply, nor override a value the user did
3901 # supply. The unresolved ${NAME} is then left untouched, like any other
3902 # undefined reference.
3903 global_values = {name: value for name, value in global_values.items() if value}
3904 user_var_names: Final = {spec["name"] for spec in user_specs}
3906 # If no env vars are configured, return static_headers as-is.
3907 if not global_values and not user_specs: 3907 ↛ 3908line 3907 didn't jump to line 3908 because the condition on line 3907 was never true
3908 return static_headers
3910 # Figure out which user-scoped vars are actually referenced. A var that
3911 # also carries a global value is always covered by that global (globals
3912 # win in the merge below), so it can never be genuinely "missing" even if
3913 # the user hasn't filled it in -- only vars without a global fallback do.
3914 referenced: Final = collect_env_var_references(strings=(static_headers or {}).values())
3915 referenced_user_vars: Final = referenced & user_var_names
3916 required_user_vars: Final = {name for name in referenced_user_vars if name not in global_values}
3918 user_values: dict[str, str] = {}
3919 if required_user_vars: 3919 ↛ 3920line 3919 didn't jump to line 3920 because the condition on line 3919 was never true
3920 try:
3921 user_values = await self._load_user_env_vars(server, user_api_key_auth)
3922 except Exception as exc:
3923 # On the tool-call path a DB failure must surface as a real
3924 # server error, not a misleading "set up your credentials" 412.
3925 # On the listing path we stay best-effort and leave the
3926 # unfilled ${NAME} references untouched so tools still appear.
3927 if raise_on_missing:
3928 raise
3929 verbose_logger.warning(
3930 "MCPServerManager: best-effort user env var load failed for server=%s: %s",
3931 server.server_id,
3932 exc,
3933 )
3935 if raise_on_missing:
3936 missing = sorted(name for name in required_user_vars if not user_values.get(name))
3937 if missing:
3938 # A cached negative must never produce a 412: cache
3939 # invalidation is process-local, so a user who just stored
3940 # values on another worker would otherwise be told their
3941 # credentials are missing until the entry expires. Confirm
3942 # against the DB before raising.
3943 user_values = await self._load_user_env_vars(server, user_api_key_auth, force_refresh=True)
3944 missing = sorted(name for name in required_user_vars if not user_values.get(name))
3945 if missing:
3946 raise MCPMissingUserEnvVarsError(
3947 server_id=server.server_id,
3948 server_name=server.server_name or server.name,
3949 missing=missing,
3950 setup_url=build_env_var_setup_url(server.server_id),
3951 )
3953 # Only honor stored user values for currently user-scoped vars, and let
3954 # admin globals win, so a stale row from when a var was user-scoped can
3955 # never override the global value the admin set after switching it.
3956 scoped_user_values: Final = {name: value for name, value in user_values.items() if name in user_var_names}
3957 merged_vars: Final[dict[str, str]] = {**scoped_user_values, **global_values}
3958 if not static_headers: 3958 ↛ 3960line 3958 didn't jump to line 3960 because the condition on line 3958 was always true
3959 return static_headers
3960 return interpolate_headers(static_headers, merged_vars)
3962 async def _load_user_env_vars(
3963 self,
3964 server: MCPServer,
3965 user_api_key_auth: UserAPIKeyAuth | None,
3966 *,
3967 force_refresh: bool = False,
3968 ) -> dict[str, str]:
3969 """Look up the calling user's env var values for ``server``.
3971 Returns an empty dict when no user is available. Results are cached in a
3972 short-lived in-memory map keyed by (user_id, server_id) so the tool-call
3973 and tool-listing paths avoid a DB round-trip per request within the TTL
3974 window; the cache is invalidated when the user stores or clears values.
3975 Pass ``force_refresh`` to bypass the cache read and re-fetch from the DB
3976 (used before raising a "missing credentials" error so a process-local
3977 stale entry cannot mask values stored on another worker). A missing DB
3978 connection and any other DB error propagate so the caller can decide
3979 between failing the request (tool-call path) and staying best-effort
3980 (listing path); they must never be mistaken for "user has no values",
3981 which would send the user a misleading "set up your credentials" 412.
3982 """
3983 if user_api_key_auth is None:
3984 return {}
3985 user_id: Final[str | None] = getattr(user_api_key_auth, "user_id", None)
3986 if not user_id:
3987 return {}
3989 cache_key: Final = (user_id, server.server_id)
3990 if not force_refresh:
3991 cached: Final = _user_env_vars_cache.get(cache_key)
3992 if cached is not None:
3993 values, ts = cached
3994 if time.monotonic() - ts < _USER_ENV_VARS_CACHE_TTL:
3995 return values
3997 from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415
3999 if prisma_client is None:
4000 raise RuntimeError(
4001 "MCP per-user env vars require a database connection, but none "
4002 "is configured. Connect a database to your proxy to use per-user "
4003 "MCP env vars."
4004 )
4005 from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415
4006 get_user_env_vars,
4007 )
4009 values = await get_user_env_vars(prisma_client, user_id, server.server_id)
4010 _write_user_env_vars_cache(user_id, server.server_id, values)
4011 return values
4013 async def _resolve_v2_auth(
4014 self,
4015 *,
4016 server: MCPServer,
4017 spec: ServerSpec,
4018 provider: UpstreamCredentialProvider,
4019 subject_token: str | None,
4020 user_api_key_auth: UserAPIKeyAuth | None,
4021 extra_headers: dict[str, str] | None,
4022 ) -> tuple[httpx2.Auth | None, dict[str, str] | None]:
4023 """Resolve a v2-owned server's upstream credential into ``(resolved_auth, extra_headers)``.
4025 On a missing/rejected per-user credential this raises the mode's discovery challenge
4026 (authorization_code's browser-OAuth 401, token_exchange's RFC 9728 challenge) or maps any
4027 other ``CredError`` onto its public HTTP status; it never returns an error as a value.
4028 """
4029 match await resolve_credentials_with_source(provider, to_subject(user_api_key_auth, subject_token), spec):
4030 case Ok(credential): 4030 ↛ 4064line 4030 didn't jump to line 4064 because the pattern on line 4030 always matched
4031 auth: Final = credential.auth
4032 # NoOpAuth has no header_name and so never conflicts.
4033 header_name: Final[str | None] = getattr(auth, "header_name", None)
4034 if header_name is None or not extra_headers: 4034 ↛ 4042line 4034 didn't jump to line 4042 because the condition on line 4034 was always true
4035 source: Final = (
4036 AuthResolution.extra_headers
4037 if credential.source == AuthResolution.no_auth and extra_headers
4038 else credential.source
4039 )
4040 record_auth_resolution(server.server_id, source)
4041 return auth, extra_headers
4042 if not has_header(extra_headers, header_name):
4043 record_auth_resolution(server.server_id, credential.source)
4044 return auth, extra_headers
4045 if isinstance(
4046 spec.config,
4047 (TokenExchangeConfig, AuthorizationCodeConfig, IdJagConfig, ClientCredentialsConfig),
4048 ):
4049 # The resolver owns the credential here (token_exchange's exchanged token,
4050 # authorization_code's stored token, id_jag's minted assertion,
4051 # client_credentials' gateway-minted M2M token). It is authoritative: a
4052 # guardrail such as MCPJWTSigner, static_headers, or any other injected
4053 # Authorization must NOT shadow it (otherwise the upstream gets e.g. the
4054 # signer's JWT instead of the minted token and rejects it, and for M2M the
4055 # one-shot 401 refetch is lost with it). Drop only the header the resolved
4056 # credential is about to occupy, so a static credential the operator aimed at a
4057 # DIFFERENT header still reaches upstream.
4058 record_auth_resolution(server.server_id, credential.source)
4059 return auth, without_header(extra_headers, header_name)
4060 # Other modes: an Authorization already supplied via extra_headers (a forwarded caller
4061 # header or static_headers) is intentional and wins; v1 applies those last.
4062 record_auth_resolution(server.server_id, AuthResolution.extra_headers)
4063 return None, extra_headers
4064 case Error(err):
4065 record_auth_resolution(server.server_id, AuthResolution.failed)
4066 if err.tag == "unauthorized" and isinstance(spec.config, AuthorizationCodeConfig):
4067 # authorization_code's missing per-user token -> the per-server browser-OAuth
4068 # challenge, built here where the full MCPServer is in hand.
4069 raise_user_oauth_challenge(server, root_path=get_request_root_path())
4070 if err.tag == "unauthorized" and isinstance(spec.config, TokenExchangeConfig):
4071 # token_exchange (OBO): a missing/rejected subject token -> the RFC 9728 challenge
4072 # pointing at the IdP the client must SSO with to obtain one, rather than an opaque
4073 # 401. No gateway-side browser flow. An IdP step-up rejection (Entra Conditional
4074 # Access) threads its claims blob into the challenge for the client to satisfy.
4075 raise_token_exchange_challenge(
4076 server,
4077 root_path=get_request_root_path(),
4078 claims=err.unauthorized.claims,
4079 )
4080 raise_public(err)
4082 async def preflight_token_exchange(
4083 self,
4084 server: MCPServer,
4085 oauth2_headers: dict[str, str] | None,
4086 user_api_key_auth: UserAPIKeyAuth | None,
4087 raw_headers: Mapping[str, str] | None = None,
4088 ) -> None:
4089 """Mint an exchange-backed server's upstream credential at the transport edge.
4091 Single-server routes call this before the MCP session opens, where an HTTP status and
4092 ``WWW-Authenticate`` still reach the client. A rejected subject raises the RFC 9728
4093 challenge and any other ``CredError`` maps onto its public HTTP status, so an exchange
4094 failure surfaces as a failure instead of the session continuing into an empty tool list.
4095 A successful exchange is cached by the exchanger, so the session's list/call reuses it.
4097 Each mode pre-flights only where it would resolve the subject the session goes on to use,
4098 which is what keeps the pre-flight from reaching a verdict the session would contradict.
4099 ``oauth2_token_exchange`` mints from the caller's inbound bearer, so without one there is
4100 nothing to exchange and the missing-subject case stays the preemptive challenge's job.
4101 ``oauth2_id_jag`` is the mirror image: tool listing resolves it from the identity assertion
4102 captured for this user at SSO login and never from the inbound bearer, so the pre-flight is
4103 faithful exactly when no identity bearer was sent (a LiteLLM key in ``Authorization`` is not one),
4104 and a caller that did send one is passed through
4105 untouched rather than judged against a subject the listing will not use. That store-sourced
4106 case is the one whose missing-assertion 412 and store-outage 503 the session cannot report.
4107 Only OBO has a discovery challenge to raise; ID-JAG's failures are plain statuses whose body
4108 already names what the user has to do, so they map through ``raise_public`` as at egress.
4109 """
4110 subject_token: Final = self._extract_subject_token(oauth2_headers, raw_headers, user_api_key_auth)
4111 match server.auth_type:
4112 case MCPAuth.oauth2_token_exchange:
4113 if not self._extract_bearer_token(oauth2_headers, None):
4114 return
4115 case MCPAuth.oauth2_id_jag:
4116 if subject_token is not None:
4117 return
4118 case _:
4119 return
4120 resolved_server: Final = await self.ensure_oauth_metadata_discovered(server)
4121 spec: Final = _to_server_spec_fail_closed(resolved_server)
4122 if spec is None or not isinstance(spec.config, (TokenExchangeConfig, IdJagConfig)):
4123 return
4124 if subject_token is None and isinstance(spec.config, TokenExchangeConfig):
4125 raise_token_exchange_challenge(resolved_server, root_path=get_request_root_path())
4126 match await self._cred_provider.resolve_credentials(to_subject(user_api_key_auth, subject_token), spec):
4127 case Ok(_):
4128 return
4129 case Error(err):
4130 if err.tag == "unauthorized" and isinstance(spec.config, TokenExchangeConfig):
4131 raise_token_exchange_challenge(
4132 resolved_server,
4133 root_path=get_request_root_path(),
4134 claims=err.unauthorized.claims,
4135 )
4136 raise_public(err)
4138 async def _create_mcp_client(
4139 self,
4140 server: MCPServer,
4141 mcp_auth_header: str | dict[str, str] | None = None,
4142 extra_headers: dict[str, str] | None = None,
4143 stdio_env: dict[str, str] | None = None,
4144 subject_token: str | None = None,
4145 user_api_key_auth: UserAPIKeyAuth | None = None,
4146 cred_provider: UpstreamCredentialProvider | None = None,
4147 raw_headers: Mapping[str, str] | None = None,
4148 client_ip: str | None = None,
4149 ) -> MCPClient:
4150 """
4151 Create an MCPClient instance for the given server.
4153 Auth resolution (single place for all auth logic):
4154 1. ``mcp_auth_header`` — per-request/per-user override
4155 2. OAuth2 Token Exchange (OBO) — exchange user token for scoped token
4156 3. OAuth2 client_credentials token — auto-fetched and cached
4157 4. ``server.authentication_token`` — static token from config/DB
4159 Args:
4160 server: The server configuration.
4161 mcp_auth_header: Optional per-request auth override.
4162 extra_headers: Additional headers to forward.
4163 stdio_env: Environment variables for stdio transport.
4164 subject_token: Optional user JWT for token exchange (OBO) flow.
4165 user_api_key_auth: Optional auth context for sampling callbacks.
4167 Returns:
4168 Configured MCP client instance.
4169 """
4170 record_auth_resolution(server.server_id, AuthResolution.unresolved)
4171 resolved_server: Final = await self.ensure_oauth_metadata_discovered(server)
4172 transport: Final = resolved_server.transport or MCPTransport.sse
4173 spec = None if transport == MCPTransport.stdio else _to_server_spec_fail_closed(resolved_server)
4174 provider: Final = cred_provider or self._cred_provider
4175 # A caller-supplied per-request override (mcp_auth_header / x-mcp-*) defers to the v1 path
4176 # so it wins - except for the modes the v2 resolver owns per-caller (authorization_code's
4177 # stored token, token_exchange's RFC 8693 minted token, id_jag's minted assertion, and the
4178 # passthrough modes' forwarded caller token). A caller must not be able to substitute another
4179 # user's stored credential, nor silently disable the OBO / ID-JAG exchange and forward an
4180 # arbitrary bearer upstream, so we keep the v2 spec and ignore the override for these; the
4181 # REST tools preview supplies its not-yet-persisted token through the resolver
4182 # (cred_provider), never this path.
4183 if ( 4183 ↛ 4191line 4183 didn't jump to line 4191 because the condition on line 4183 was never true
4184 spec is not None
4185 and mcp_auth_header
4186 and not isinstance(
4187 spec.config,
4188 (AuthorizationCodeConfig, IdJagConfig, PassthroughConfig, TokenExchangeConfig),
4189 )
4190 ):
4191 spec = None
4192 auth_value: Final = await resolve_mcp_auth(resolved_server, mcp_auth_header) if spec is None else None
4193 auth_header_name: Final = resolved_token_header(resolved_server, mcp_auth_header) if spec is None else None
4195 # Create sampling and elicitation callbacks for this client
4196 sampling_cb = (
4197 _create_sampling_callback(
4198 operation_context=OperationContext(
4199 _caller=user_api_key_auth, raw_headers=raw_headers, client_ip=client_ip
4200 )
4201 )
4202 if resolved_server.allow_sampling
4203 else None
4204 )
4205 elicitation_cb: Final = _create_elicitation_callback() if resolved_server.allow_elicitation else None
4207 # Handle stdio transport
4208 if transport == MCPTransport.stdio: 4208 ↛ 4209line 4208 didn't jump to line 4209 because the condition on line 4208 was never true
4209 if not is_mcp_stdio_enabled():
4210 raise HTTPException(status_code=403, detail=MCP_STDIO_DISABLED_MESSAGE)
4211 resolved_env: Final = (
4212 stdio_env
4213 if stdio_env is not None
4214 else (dict(resolved_server.env) if resolved_server.env is not None else None)
4215 )
4217 # Ensure npm-based STDIO MCP servers have a writable cache dir.
4218 # In containers the default (~/.npm or /app/.npm) may not exist
4219 # or be read-only, causing npx to fail with ENOENT.
4220 if resolved_env is not None and "NPM_CONFIG_CACHE" not in resolved_env:
4221 resolved_env["NPM_CONFIG_CACHE"] = MCP_NPM_CACHE_DIR
4222 # Defense-in-depth: block commands not in the allowlist.
4223 # The Pydantic validator blocks new servers; this catches legacy
4224 # config/DB records predating the allowlist.
4225 if resolved_server.command:
4226 base_command: Final = os.path.basename(resolved_server.command)
4227 # Strip .exe/.cmd/.bat/.com suffix for Windows compatibility
4228 base_command_no_ext = base_command.lower()
4229 for ext in [".exe", ".cmd", ".bat", ".com"]:
4230 if base_command.lower().endswith(ext):
4231 base_command_no_ext = base_command[: -len(ext)].lower()
4232 break
4233 if (
4234 base_command.lower() not in MCP_STDIO_ALLOWED_COMMANDS
4235 and base_command_no_ext not in MCP_STDIO_ALLOWED_COMMANDS
4236 ):
4237 raise HTTPException(
4238 status_code=403,
4239 detail=f"MCP stdio command '{resolved_server.command}' is not in the allowlist ({sorted(MCP_STDIO_ALLOWED_COMMANDS)}). "
4240 f"Add it to LITELLM_MCP_STDIO_EXTRA_COMMANDS to allow this command.",
4241 )
4243 stdio_config: MCPStdioConfig | None = None
4244 if resolved_server.command and resolved_server.args is not None:
4245 stdio_config = MCPStdioConfig(
4246 command=resolved_server.command,
4247 args=resolved_server.args,
4248 env=resolved_env,
4249 )
4251 record_auth_resolution(server.server_id, AuthResolution.not_applicable)
4252 return MCPClient(
4253 server_url="", # Not used for stdio
4254 transport_type=transport,
4255 auth_type=resolved_server.auth_type,
4256 auth_value=auth_value,
4257 timeout=(resolved_server.timeout if resolved_server.timeout is not None else MCP_CLIENT_TIMEOUT),
4258 stdio_config=stdio_config,
4259 extra_headers=extra_headers,
4260 sampling_callback=sampling_cb,
4261 elicitation_callback=elicitation_cb,
4262 )
4263 else:
4264 # For HTTP/SSE transports
4265 server_url: Final = resolved_server.url or ""
4267 if spec is not None:
4268 inbound_token = subject_token
4269 if isinstance(spec.config, PassthroughConfig):
4270 inbound_token, extra_headers = _take_forwarded_authorization(extra_headers)
4271 per_server_token: Final = _passthrough_token_from_mcp_auth_header(mcp_auth_header)
4272 if per_server_token is not None: 4272 ↛ 4273line 4272 didn't jump to line 4273 because the condition on line 4272 was never true
4273 inbound_token = per_server_token
4274 resolved_auth, extra_headers = await self._resolve_v2_auth(
4275 server=resolved_server,
4276 spec=spec,
4277 provider=provider,
4278 subject_token=inbound_token,
4279 user_api_key_auth=user_api_key_auth,
4280 extra_headers=extra_headers,
4281 )
4282 return await prepare_mcp_client(
4283 resolved_server,
4284 MCPClient(
4285 server_url=server_url,
4286 transport_type=transport,
4287 auth_type=resolved_server.auth_type,
4288 timeout=(
4289 resolved_server.timeout if resolved_server.timeout is not None else MCP_CLIENT_TIMEOUT
4290 ),
4291 extra_headers=extra_headers,
4292 resolved_auth=resolved_auth,
4293 sampling_callback=sampling_cb,
4294 elicitation_callback=elicitation_cb,
4295 ),
4296 )
4298 # Create SigV4 auth if configured
4299 aws_auth = None
4300 if resolved_server.auth_type == MCPAuth.aws_sigv4:
4301 aws_auth = MCPSigV4Auth(
4302 aws_access_key_id=resolved_server.aws_access_key_id,
4303 aws_secret_access_key=resolved_server.aws_secret_access_key,
4304 aws_session_token=resolved_server.aws_session_token,
4305 aws_region_name=resolved_server.aws_region_name,
4306 aws_service_name=resolved_server.aws_service_name,
4307 aws_role_name=resolved_server.aws_role_name,
4308 aws_session_name=resolved_server.aws_session_name,
4309 )
4311 legacy_source: Final = (
4312 AuthResolution.aws_sigv4
4313 if aws_auth is not None
4314 else AuthResolution.extra_headers
4315 if extra_headers and has_header(extra_headers, auth_header_name or "Authorization")
4316 else AuthResolution.per_request_header
4317 if mcp_auth_header
4318 else AuthResolution.static_token
4319 if auth_value
4320 else AuthResolution.extra_headers
4321 if extra_headers
4322 else AuthResolution.no_auth
4323 )
4324 record_auth_resolution(server.server_id, legacy_source)
4325 return await prepare_mcp_client(
4326 resolved_server,
4327 MCPClient(
4328 server_url=server_url,
4329 transport_type=transport,
4330 auth_type=resolved_server.auth_type,
4331 auth_value=auth_value,
4332 auth_header_name=auth_header_name,
4333 timeout=(resolved_server.timeout if resolved_server.timeout is not None else MCP_CLIENT_TIMEOUT),
4334 extra_headers=extra_headers,
4335 aws_auth=aws_auth,
4336 sampling_callback=sampling_cb,
4337 elicitation_callback=elicitation_cb,
4338 ),
4339 )
4341 async def _get_tools_from_server(
4342 self,
4343 server: MCPServer,
4344 mcp_auth_header: str | dict[str, str] | None = None,
4345 extra_headers: dict[str, str] | None = None,
4346 add_prefix: bool = True,
4347 raw_headers: dict[str, str] | None = None,
4348 user_api_key_auth: UserAPIKeyAuth | None = None,
4349 oauth2_headers: dict[str, str] | None = None,
4350 client_ip: str | None = None,
4351 ) -> list[MCPTool]:
4352 """
4353 Helper method to get tools from a single MCP server with prefixed names.
4355 Args:
4356 server (MCPServer): The server to query tools from
4357 mcp_auth_header: Optional auth header for MCP server
4359 Returns:
4360 List[MCPTool]: List of tools available on the server with prefixed names
4361 """
4362 from litellm.proxy._experimental.mcp_server.tool_registry import (
4363 global_mcp_tool_registry,
4364 )
4366 if self._skip_blocked_stdio_listing(server, "tool"): 4366 ↛ 4367line 4366 didn't jump to line 4367 because the condition on line 4366 was never true
4367 return []
4369 verbose_logger.debug("Connecting to url: %s", server.url)
4370 verbose_logger.info("_get_tools_from_server for %s...", server.name)
4372 client = None
4374 try:
4375 # Tool *listing* must not be blocked by missing per-user env vars —
4376 # the server's tools should still appear so the client connects. The
4377 # friendly "missing vars" error is raised only on the tool-*call*
4378 # path (see _call_regular_mcp_tool).
4379 resolved_static_headers: Final = await self._resolve_static_headers_with_env_vars(
4380 server, user_api_key_auth, raise_on_missing=False
4381 )
4382 if resolved_static_headers: 4382 ↛ 4383line 4382 didn't jump to line 4383 because the condition on line 4382 was never true
4383 if extra_headers is None:
4384 extra_headers = {}
4385 extra_headers.update(resolved_static_headers)
4387 # MCPJWTSigner: inject signed JWT for tools/list (list path skips pre_call_hook).
4388 # Skip entirely when the signer is not configured (avoid an unnecessary
4389 # dict copy on every list call), when the server has its own static
4390 # Authorization header, when a per-user mcp_auth_header has already
4391 # been resolved, or when the caller already supplied an Authorization
4392 # entry in extra_headers (e.g. a per-user OAuth token resolved
4393 # upstream) — admin-configured static auth and per-user OAuth must
4394 # take precedence so the signer doesn't silently overwrite e.g. an
4395 # upstream API key or a user's OAuth token (MCPClient._get_auth_headers
4396 # applies extra_headers after writing Authorization from auth_value, so
4397 # an injected JWT would otherwise clobber the per-user token).
4398 if user_api_key_auth is not None and not server.spec_path:
4399 from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import (
4400 get_mcp_jwt_signer,
4401 inject_mcp_jwt_headers_for_upstream,
4402 )
4404 static_headers: Final = server.static_headers or {}
4405 has_static_authorization: Final = any(
4406 isinstance(k, str) and k.lower() == "authorization" for k in static_headers
4407 )
4408 has_extra_authorization: Final = bool(extra_headers) and any(
4409 isinstance(k, str) and k.lower() == "authorization" for k in (extra_headers or {})
4410 )
4412 if ( 4412 ↛ 4418line 4412 didn't jump to line 4418 because the condition on line 4412 was never true
4413 get_mcp_jwt_signer() is not None
4414 and not has_static_authorization
4415 and not mcp_auth_header
4416 and not has_extra_authorization
4417 ):
4418 extra_headers = await inject_mcp_jwt_headers_for_upstream(
4419 user_api_key_dict=user_api_key_auth,
4420 extra_headers=extra_headers,
4421 raw_headers=raw_headers,
4422 for_list_tools=True,
4423 )
4425 stdio_env: Final = self._build_stdio_env(server, raw_headers)
4427 # token_exchange (OBO) discovery needs the caller's token too: list it with the user's own
4428 # token (mirrors the call path), not v1's deleted client_credentials fallback. Other modes
4429 # never read the inbound bearer, so leave subject_token None to avoid forwarding it.
4430 subject_token: Final = (
4431 self._extract_subject_token(oauth2_headers, raw_headers, user_api_key_auth)
4432 if server.auth_type == MCPAuth.oauth2_token_exchange
4433 else None
4434 )
4436 client = await self._create_mcp_client(
4437 server=server,
4438 mcp_auth_header=mcp_auth_header,
4439 extra_headers=extra_headers,
4440 stdio_env=stdio_env,
4441 subject_token=subject_token,
4442 user_api_key_auth=user_api_key_auth,
4443 raw_headers=raw_headers,
4444 client_ip=client_ip,
4445 )
4447 ## HANDLE OPENAPI TOOLS
4448 if server.spec_path:
4449 # OpenAPI tools were stored in the registry under the prefix
4450 # active at registration time — fetch by that same prefix.
4451 _tools: Final = global_mcp_tool_registry.list_tools(tool_prefix=get_server_prefix(server))
4452 tools = global_mcp_tool_registry.convert_tools_to_mcp_sdk_tool_type(_tools)
4453 # OpenAPI tools are stored in the registry with their prefix already
4454 # applied (e.g. "test_petstore-getinventory"). Do NOT pass them
4455 # through _create_prefixed_tools — that would add the prefix a second
4456 # time producing "test_petstore-test_petstore-getinventory".
4457 if not add_prefix: 4457 ↛ 4458line 4457 didn't jump to line 4458 because the condition on line 4457 was never true
4458 prefix: Final = get_server_prefix(server)
4459 sep: Final = MCP_TOOL_PREFIX_SEPARATOR
4460 tools = [
4461 (
4462 t.model_copy(update={"name": t.name[len(prefix) + len(sep) :]})
4463 if t.name.startswith(f"{prefix}{sep}")
4464 else t
4465 )
4466 for t in tools
4467 ]
4468 return tools
4469 else:
4470 tools = await self._fetch_tools_with_timeout(client, server.name)
4471 self._remember_upstream_initialize_instructions(server, client)
4473 prefixed_or_original_tools: Final = self._create_prefixed_tools(tools, server, add_prefix=add_prefix)
4475 return prefixed_or_original_tools
4477 except MCPUpstreamAuthError as upstream_auth_error:
4478 # Pass-through 401 must surface to single-server routes so the
4479 # client triggers the upstream OAuth flow. The multi-server
4480 # aggregator catches this explicitly to keep absorbing.
4481 if server.is_dcr_bridge and upstream_auth_error.www_authenticate is not None:
4482 raise MCPUpstreamAuthError(
4483 status_code=upstream_auth_error.status_code,
4484 www_authenticate=None,
4485 server_name=upstream_auth_error.server_name,
4486 ) from upstream_auth_error
4487 raise
4488 except HTTPException as e:
4489 # A v2 resolver auth challenge (token_exchange's RFC 9728 401, authorization_code's
4490 # browser-OAuth 401, or a 403) is raised at client-build time, inside this try. Route it
4491 # through the same MCPUpstreamAuthError channel as pass-through so single-server routes
4492 # surface the challenge (the client re-authenticates) while the aggregator keeps absorbing.
4493 # Non-auth HTTP errors stay absorbed so one misconfigured server can't blank the listing.
4494 if e.status_code in (401, 403): 4494 ↛ 4495line 4494 didn't jump to line 4495 because the condition on line 4494 was never true
4495 headers: Final = e.headers or {}
4496 challenge_header: Final = headers.get("WWW-Authenticate") or headers.get("www-authenticate")
4497 raise MCPUpstreamAuthError(
4498 status_code=e.status_code,
4499 www_authenticate=None if server.is_dcr_bridge else challenge_header,
4500 server_name=server.name,
4501 ) from e
4502 verbose_logger.warning("Failed to get tools from server %s: %s", server.name, e)
4503 raise MCPServerListError(ServerListFault(tag="internal", status_code=e.status_code), server.name) from e
4504 except MCPServerListError:
4505 raise
4506 except Exception as e:
4507 verbose_logger.warning(
4508 "Failed to get tools from server %s: %s%s", server.name, type(e).__name__, _upstream_failure_suffix(e)
4509 )
4510 raise_classified_list_failure(e, server.name, suppress_challenge=server.is_dcr_bridge)
4512 def _invalidate_discovery_lists(self, server_id: str) -> None:
4513 self._prompt_discovery_cache.invalidate(server_id)
4514 self._resource_discovery_cache.invalidate(server_id)
4515 self._template_discovery_cache.invalidate(server_id)
4517 def _discovery_key(
4518 self,
4519 server: MCPServer,
4520 user_api_key_auth: UserAPIKeyAuth | None,
4521 mcp_auth_header: str | dict[str, str] | None,
4522 extra_headers: dict[str, str] | None,
4523 stdio_env: dict[str, str] | None,
4524 subject_token: str | None,
4525 credential_fingerprint: str | None = None,
4526 ) -> _DiscoveryKey:
4527 per_user: Final = (
4528 server.requires_per_user_auth
4529 or self._references_per_user_env_var(server)
4530 or server.delegate_auth_to_upstream
4531 or server.auth_type in (MCPAuth.oauth2_token_exchange, MCPAuth.oauth2_id_jag)
4532 )
4533 if not (per_user or mcp_auth_header or extra_headers or stdio_env or subject_token):
4534 return server.server_id, None
4535 identity: Final = (
4536 (user_api_key_auth.user_id, user_api_key_auth.api_key)
4537 if per_user and user_api_key_auth is not None
4538 else None
4539 )
4540 material: Final = json.dumps(
4541 (identity, mcp_auth_header, extra_headers, stdio_env, subject_token, credential_fingerprint),
4542 sort_keys=True,
4543 separators=(",", ":"),
4544 )
4545 return server.server_id, hashlib.sha256(material.encode()).hexdigest()
4547 @staticmethod
4548 def _warn_if_newly_blocked_stdio(row: LiteLLM_MCPServerTable, previous: MCPServer | None) -> None:
4549 if previous is None or previous.transport != row.transport:
4550 warn_if_mcp_stdio_blocked(row.alias or row.server_name, row.transport)
4552 def _skip_blocked_stdio_listing(self, server: MCPServer, listing: str) -> bool:
4553 if not is_mcp_stdio_blocked(server.transport): 4553 ↛ 4555line 4553 didn't jump to line 4555 because the condition on line 4553 was always true
4554 return False
4555 verbose_logger.debug(
4556 "Skipping %s listing for MCP server %s: %s", listing, server.name, MCP_STDIO_DISABLED_MESSAGE
4557 )
4558 return True
4560 async def get_prompts_from_server(
4561 self,
4562 server: MCPServer,
4563 user_api_key_auth: UserAPIKeyAuth | None,
4564 mcp_auth_header: str | dict[str, str] | None = None,
4565 extra_headers: dict[str, str] | None = None,
4566 add_prefix: bool = True,
4567 raw_headers: dict[str, str] | None = None,
4568 client_ip: str | None = None,
4569 ) -> list[Prompt]:
4570 if self._skip_blocked_stdio_listing(server, "prompt"):
4571 return []
4572 try:
4573 headers: Final = (
4574 dict(
4575 chain(
4576 extra_headers.items() if extra_headers else (),
4577 server.static_headers.items() if server.static_headers else (),
4578 )
4579 )
4580 or None
4581 )
4582 stdio_env: Final = self._build_stdio_env(server, raw_headers)
4583 subject_token: Final = self._obo_subject_token(server, raw_headers, user_api_key_auth)
4584 client: Final = await self._create_mcp_client(
4585 server=server,
4586 mcp_auth_header=mcp_auth_header,
4587 extra_headers=headers,
4588 stdio_env=stdio_env,
4589 subject_token=subject_token,
4590 user_api_key_auth=user_api_key_auth,
4591 raw_headers=raw_headers,
4592 client_ip=client_ip,
4593 )
4594 credential_fingerprint: Final = await client.discovery_auth_fingerprint()
4595 key: Final = self._discovery_key(
4596 server, user_api_key_auth, mcp_auth_header, headers, stdio_env, subject_token, credential_fingerprint
4597 )
4599 async def fetch() -> list[Prompt]:
4600 return await client.list_prompts(raise_on_error=True)
4602 items: Final = await self._prompt_discovery_cache.get(key, fetch)
4603 return self._create_prefixed_prompts(items, server, add_prefix=add_prefix)
4604 except Exception as error:
4605 verbose_logger.warning("Failed to get prompts from server %s: %s", server.name, error)
4606 return []
4608 async def get_resources_from_server(
4609 self,
4610 server: MCPServer,
4611 user_api_key_auth: UserAPIKeyAuth | None,
4612 mcp_auth_header: str | dict[str, str] | None = None,
4613 extra_headers: dict[str, str] | None = None,
4614 add_prefix: bool = True,
4615 raw_headers: dict[str, str] | None = None,
4616 client_ip: str | None = None,
4617 ) -> list[Resource]:
4618 if self._skip_blocked_stdio_listing(server, "resource"):
4619 return []
4620 try:
4621 headers: Final = (
4622 dict(
4623 chain(
4624 extra_headers.items() if extra_headers else (),
4625 server.static_headers.items() if server.static_headers else (),
4626 )
4627 )
4628 or None
4629 )
4630 stdio_env: Final = self._build_stdio_env(server, raw_headers)
4631 subject_token: Final = self._obo_subject_token(server, raw_headers, user_api_key_auth)
4632 client: Final = await self._create_mcp_client(
4633 server=server,
4634 mcp_auth_header=mcp_auth_header,
4635 extra_headers=headers,
4636 stdio_env=stdio_env,
4637 subject_token=subject_token,
4638 user_api_key_auth=user_api_key_auth,
4639 raw_headers=raw_headers,
4640 client_ip=client_ip,
4641 )
4642 credential_fingerprint: Final = await client.discovery_auth_fingerprint()
4643 key: Final = self._discovery_key(
4644 server, user_api_key_auth, mcp_auth_header, headers, stdio_env, subject_token, credential_fingerprint
4645 )
4647 async def fetch() -> list[Resource]:
4648 return await client.list_resources(raise_on_error=True)
4650 items: Final = await self._resource_discovery_cache.get(key, fetch)
4651 return self._create_prefixed_resources(items, server, add_prefix=add_prefix)
4652 except Exception as error:
4653 verbose_logger.warning("Failed to get resources from server %s: %s", server.name, error)
4654 return []
4656 async def get_resource_templates_from_server(
4657 self,
4658 server: MCPServer,
4659 user_api_key_auth: UserAPIKeyAuth | None,
4660 mcp_auth_header: str | dict[str, str] | None = None,
4661 extra_headers: dict[str, str] | None = None,
4662 add_prefix: bool = True,
4663 raw_headers: dict[str, str] | None = None,
4664 client_ip: str | None = None,
4665 ) -> list[ResourceTemplate]:
4666 if self._skip_blocked_stdio_listing(server, "resource template"):
4667 return []
4668 try:
4669 headers: Final = (
4670 dict(
4671 chain(
4672 extra_headers.items() if extra_headers else (),
4673 server.static_headers.items() if server.static_headers else (),
4674 )
4675 )
4676 or None
4677 )
4678 stdio_env: Final = self._build_stdio_env(server, raw_headers)
4679 subject_token: Final = self._obo_subject_token(server, raw_headers, user_api_key_auth)
4680 client: Final = await self._create_mcp_client(
4681 server=server,
4682 mcp_auth_header=mcp_auth_header,
4683 extra_headers=headers,
4684 stdio_env=stdio_env,
4685 subject_token=subject_token,
4686 user_api_key_auth=user_api_key_auth,
4687 raw_headers=raw_headers,
4688 client_ip=client_ip,
4689 )
4690 credential_fingerprint: Final = await client.discovery_auth_fingerprint()
4691 key: Final = self._discovery_key(
4692 server, user_api_key_auth, mcp_auth_header, headers, stdio_env, subject_token, credential_fingerprint
4693 )
4695 async def fetch() -> list[ResourceTemplate]:
4696 return await client.list_resource_templates(raise_on_error=True)
4698 items: Final = await self._template_discovery_cache.get(key, fetch)
4699 return self._create_prefixed_resource_templates(items, server, add_prefix=add_prefix)
4700 except Exception as error:
4701 verbose_logger.warning("Failed to get resource_templates from server %s: %s", server.name, error)
4702 return []
4704 async def read_resource_from_server(
4705 self,
4706 server: MCPServer,
4707 user_api_key_auth: UserAPIKeyAuth | None,
4708 url: AnyUrl,
4709 mcp_auth_header: str | dict[str, str] | None = None,
4710 extra_headers: dict[str, str] | None = None,
4711 raw_headers: dict[str, str] | None = None,
4712 client_ip: str | None = None,
4713 ) -> ReadResourceResult:
4714 """Read resource contents from a specific MCP server."""
4716 verbose_logger.debug("Connecting to url: %s", server.url)
4717 verbose_logger.info("read_resource_from_server for %s...", server.name)
4719 if server.static_headers:
4720 if extra_headers is None:
4721 extra_headers = {}
4722 extra_headers.update(server.static_headers)
4724 stdio_env: Final = self._build_stdio_env(server, raw_headers)
4725 subject_token: Final = self._obo_subject_token(server, raw_headers, user_api_key_auth)
4727 client: Final = await self._create_mcp_client(
4728 server=server,
4729 mcp_auth_header=mcp_auth_header,
4730 extra_headers=extra_headers,
4731 stdio_env=stdio_env,
4732 subject_token=subject_token,
4733 raw_headers=raw_headers,
4734 client_ip=client_ip,
4735 user_api_key_auth=user_api_key_auth,
4736 )
4738 return await client.read_resource(url)
4740 async def get_prompt_from_server(
4741 self,
4742 server: MCPServer,
4743 user_api_key_auth: UserAPIKeyAuth | None,
4744 prompt_name: str,
4745 arguments: dict[str, str] | None = None,
4746 mcp_auth_header: str | dict[str, str] | None = None,
4747 extra_headers: dict[str, str] | None = None,
4748 raw_headers: dict[str, str] | None = None,
4749 client_ip: str | None = None,
4750 ) -> GetPromptResult:
4751 """Fetch a specific prompt definition from a single MCP server."""
4753 verbose_logger.debug("Connecting to url: %s", server.url)
4754 verbose_logger.info("get_prompt_from_server for %s...", server.name)
4756 if server.static_headers:
4757 if extra_headers is None:
4758 extra_headers = {}
4759 extra_headers.update(server.static_headers)
4761 stdio_env: Final = self._build_stdio_env(server, raw_headers)
4762 subject_token: Final = self._obo_subject_token(server, raw_headers, user_api_key_auth)
4764 client: Final = await self._create_mcp_client(
4765 server=server,
4766 mcp_auth_header=mcp_auth_header,
4767 extra_headers=extra_headers,
4768 stdio_env=stdio_env,
4769 subject_token=subject_token,
4770 raw_headers=raw_headers,
4771 client_ip=client_ip,
4772 user_api_key_auth=user_api_key_auth,
4773 )
4775 get_prompt_request_params: Final = GetPromptRequestParams(
4776 name=prompt_name,
4777 arguments=arguments,
4778 )
4779 return await client.get_prompt(get_prompt_request_params)
4781 @staticmethod
4782 def _is_same_authority_metadata_url(url: str, server_url: str) -> bool:
4783 """
4784 Whether ``url`` shares scheme, host, and port with ``server_url``.
4786 Same-authority metadata URLs are produced by our well-known discovery
4787 construction and by resource servers that publish protected-resource
4788 metadata on the resource origin. These must keep working for
4789 administrator-configured internal MCP servers, so they are fetched
4790 directly. Cross-origin URLs are fetched through ``async_safe_get``.
4791 """
4792 try:
4793 target: Final = urlparse(url)
4794 base: Final = urlparse(server_url)
4795 except Exception:
4796 return False
4798 if target.scheme not in ("http", "https") or not target.hostname:
4799 return False
4801 target_port: Final = target.port or (443 if target.scheme == "https" else 80)
4802 base_port: Final = base.port or (443 if base.scheme == "https" else 80)
4803 return (
4804 base.scheme == target.scheme
4805 and (base.hostname or "").lower() == target.hostname.lower()
4806 and base_port == target_port
4807 )
4809 async def _fetch_oauth_discovery_url(self, url: str, server_url: str) -> httpx.Response:
4810 client: Final = get_async_httpx_client(
4811 llm_provider=httpxSpecialProvider.MCP,
4812 params={"timeout": MCP_METADATA_TIMEOUT},
4813 )
4814 if self._is_same_authority_metadata_url(url, server_url):
4815 # Same-authority URLs may point at administrator-configured
4816 # internal MCP servers. Do not run them through user URL
4817 # validation, but also do not follow redirects because the
4818 # redirect target would not inherit the same-authority guarantee.
4819 return await client.get(url, follow_redirects=False)
4820 return await async_safe_get(client, url)
4822 async def _descovery_metadata(
4823 self,
4824 server_url: str,
4825 *,
4826 allow_origin_fallback: bool = True,
4827 warn_when_no_metadata: bool = False,
4828 ) -> MCPOAuthMetadata | None:
4829 """Discover OAuth metadata by following RFC 9728 (protected resource metadata discovery).
4831 ``allow_origin_fallback`` controls the last-resort guess that treats the resource server's own
4832 origin as its authorization server when nothing is advertised. The browser ``oauth2`` flow keeps
4833 it (a human sees the redirect), but token_exchange (OBO) sets it False so the gateway never
4834 exchanges a subject token against an endpoint it inferred rather than one explicitly configured
4835 or authoritatively advertised via RFC 9728 / RFC 8414.
4837 ``warn_when_no_metadata`` makes an all-empty result log one WARNING with the per-step attempt
4838 outcomes (LIT-4658), so a misconfigured server url is diagnosable from default-level logs. The
4839 server loaders set it; the issuer-anchored resource-scopes lookup keeps it off because empty
4840 scopes are not a fault there.
4841 """
4842 metadata, attempts = await self._discover_metadata_recording_attempts(
4843 server_url, allow_origin_fallback=allow_origin_fallback
4844 )
4845 if metadata is None and warn_when_no_metadata:
4846 verbose_logger.warning(
4847 "MCP OAuth endpoint discovery against %s found no authorization server metadata. Attempts: %s. "
4848 "The MCP server url may be misconfigured, or the upstream may not support OAuth discovery "
4849 "(RFC 9728 / RFC 8414)",
4850 _redact_mcp_resource_url(server_url) or "<unparseable url>",
4851 "; ".join(attempts) if attempts else "none recorded",
4852 )
4853 return metadata
4855 async def _discover_metadata_recording_attempts(
4856 self,
4857 server_url: str,
4858 *,
4859 allow_origin_fallback: bool,
4860 ) -> tuple[MCPOAuthMetadata | None, tuple[str, ...]]:
4861 origin: Final = _redact_mcp_resource_url(server_url) or "<unparseable url>"
4862 try:
4863 client: Final = get_async_httpx_client(
4864 llm_provider=httpxSpecialProvider.MCP,
4865 params={"timeout": MCP_METADATA_TIMEOUT}, # mutable-ok: HTTP client factory requires a dict
4866 )
4867 response: Final = await client.get(server_url)
4868 response.raise_for_status()
4869 (
4870 authorization_servers,
4871 resource_scopes,
4872 ) = await self._attempt_well_known_discovery(server_url)
4873 metadata: Final = await self._fetch_authorization_server_metadata(authorization_servers, server_url)
4874 if metadata is None and not resource_scopes and authorization_servers and response.status_code == 200:
4875 verbose_logger.warning(
4876 "MCP OAuth discovery for %s received 200 OK without RFC 9728 challenge and no discoverable authorization metadata.",
4877 origin,
4878 )
4879 attempts: Final = (
4880 f"GET {origin}: HTTP {response.status_code} (no RFC 9728 challenge)",
4881 *(
4882 ("well-known protected-resource lookup found no authorization servers",)
4883 if not authorization_servers
4884 else ()
4885 ),
4886 *(
4887 (f"authorization server metadata fetch failed for: {_redacted_origin_list(authorization_servers)}",)
4888 if authorization_servers and metadata is None
4889 else ()
4890 ),
4891 )
4892 if metadata is None and resource_scopes:
4893 return MCPOAuthMetadata(scopes=resource_scopes), attempts
4894 if metadata is not None and resource_scopes:
4895 metadata.scopes = resource_scopes
4896 return metadata, attempts
4897 except HTTPStatusError as exc:
4898 return await self._discover_after_status_error(server_url, exc, allow_origin_fallback=allow_origin_fallback)
4899 except Exception as exc: # pragma: no cover - network/transient issues
4900 verbose_logger.debug("MCP OAuth discovery failed for %s: %s", server_url, exc)
4901 return None, (f"GET {origin}: {type(exc).__name__}: {_sanitized_error_text(exc)}",)
4903 async def _discover_after_status_error(
4904 self,
4905 server_url: str,
4906 exc: HTTPStatusError,
4907 *,
4908 allow_origin_fallback: bool,
4909 ) -> tuple[MCPOAuthMetadata | None, tuple[str, ...]]:
4910 origin: Final = _redact_mcp_resource_url(server_url) or "<unparseable url>"
4911 verbose_logger.debug(
4912 "MCP OAuth discovery for %s received status error: %s",
4913 server_url,
4914 exc,
4915 )
4917 header_value: str | None = None
4918 if exc.response is not None:
4919 header_value = exc.response.headers.get("WWW-Authenticate") or exc.response.headers.get("www-authenticate")
4920 status_attempt: Final = (
4921 f"GET {origin}: HTTP {exc.response.status_code}"
4922 if exc.response is not None
4923 else f"GET {origin}: status error"
4924 )
4926 resource_metadata_url, scopes = self._parse_www_authenticate_header(header_value)
4928 authorization_servers = []
4929 resource_scopes = None
4930 if resource_metadata_url:
4931 (
4932 authorization_servers,
4933 resource_scopes,
4934 ) = await self._fetch_oauth_metadata_from_resource(resource_metadata_url, server_url)
4935 lookup_attempt = (
4936 None
4937 if authorization_servers
4938 else "challenge-advertised resource metadata yielded no authorization servers"
4939 )
4940 else:
4941 (
4942 authorization_servers,
4943 resource_scopes,
4944 ) = await self._attempt_well_known_discovery(server_url)
4945 lookup_attempt = (
4946 None
4947 if authorization_servers
4948 else "no challenge-advertised resource metadata; well-known protected-resource lookup found no authorization servers"
4949 )
4951 metadata = None
4952 used_origin_fallback = False
4953 if allow_origin_fallback and not authorization_servers:
4954 try:
4955 parsed_url: Final = urlparse(server_url)
4956 if parsed_url.scheme and parsed_url.netloc:
4957 authorization_servers = [f"{parsed_url.scheme}://{parsed_url.netloc}"]
4958 used_origin_fallback = True
4959 except Exception:
4960 authorization_servers = []
4962 fallback_attempt = None
4963 if authorization_servers:
4964 metadata = await self._fetch_authorization_server_metadata(authorization_servers, server_url)
4965 if metadata is not None and used_origin_fallback:
4966 metadata.from_origin_fallback = True
4967 if metadata is None:
4968 fallback_attempt = (
4969 f"origin fallback: no authorization server metadata at {origin}"
4970 if used_origin_fallback
4971 else f"authorization server metadata fetch failed for: {_redacted_origin_list(authorization_servers)}"
4972 )
4974 attempts: Final = tuple(entry for entry in (status_attempt, lookup_attempt, fallback_attempt) if entry)
4976 preferred_scopes: Final = scopes or resource_scopes
4977 if metadata is None and preferred_scopes:
4978 return MCPOAuthMetadata(scopes=preferred_scopes), attempts
4979 if metadata is not None and preferred_scopes:
4980 metadata.scopes = preferred_scopes
4982 return metadata, attempts
4984 def _parse_www_authenticate_header(self, header_value: str | None) -> tuple[str | None, list[str] | None]:
4985 if not header_value:
4986 return None, None
4988 _, _, params_section = header_value.partition(" ")
4989 params_section = params_section or header_value
4991 param_pattern: Final = re.compile(r"([a-zA-Z0-9_]+)\s*=\s*\"?([^\",]+)\"?")
4992 params: Final[dict[str, str]] = {
4993 match.group(1).lower(): match.group(2).strip() for match in param_pattern.finditer(params_section)
4994 }
4996 resource_metadata_url: Final = params.get("resource_metadata")
4998 scope_value: Final = params.get("scope")
4999 scopes_list: Final = [s for s in (scope_value.split() if scope_value else []) if s]
5000 scopes: Final = scopes_list or None
5002 return resource_metadata_url, scopes
5004 async def _fetch_oauth_metadata_from_resource(
5005 self, resource_metadata_url: str, server_url: str
5006 ) -> tuple[list[str], list[str] | None]:
5007 if not resource_metadata_url:
5008 return [], None
5010 try:
5011 response: Final = await self._fetch_oauth_discovery_url(resource_metadata_url, server_url)
5012 response.raise_for_status()
5013 data: Final[_ProtectedResourceMetadataPayload] = response.json()
5014 except SSRFError as exc:
5015 verbose_logger.warning(
5016 "MCP OAuth discovery: refusing to fetch resource metadata from %s "
5017 "(rejected by SSRF guard for server %s): %s",
5018 resource_metadata_url,
5019 server_url,
5020 exc,
5021 )
5022 return [], None
5023 except Exception as exc: # pragma: no cover - network issues
5024 verbose_logger.debug(
5025 "Failed to fetch MCP OAuth metadata from %s: %s",
5026 resource_metadata_url,
5027 exc,
5028 )
5029 return [], None
5031 raw_servers: Final = data.get("authorization_servers")
5032 if isinstance(raw_servers, list):
5033 authorization_servers = [entry for entry in raw_servers if isinstance(entry, str) and entry.strip() != ""]
5034 else:
5035 authorization_servers = []
5037 scopes: Final = self._extract_scopes(data.get("scopes_supported") or data.get("scopes"))
5039 return authorization_servers, scopes
5041 async def _attempt_well_known_discovery(self, server_url: str) -> tuple[list[str], list[str] | None]:
5042 try:
5043 parsed: Final = urlparse(server_url)
5044 except Exception:
5045 return [], None
5047 if not parsed.scheme or not parsed.netloc:
5048 return [], None
5050 base: Final = f"{parsed.scheme}://{parsed.netloc}"
5051 path = parsed.path or ""
5052 path = path.strip("/")
5054 candidate_urls: Final[list[str]] = []
5055 if path:
5056 candidate_urls.append(f"{base}/.well-known/oauth-protected-resource/{path}")
5057 candidate_urls.append(f"{base}/.well-known/oauth-protected-resource")
5059 for url in candidate_urls:
5060 (
5061 authorization_servers,
5062 scopes,
5063 ) = await self._fetch_oauth_metadata_from_resource(url, server_url)
5064 if authorization_servers:
5065 return authorization_servers, scopes
5067 return [], None
5069 async def _fetch_authorization_server_metadata(
5070 self, authorization_servers: list[str], server_url: str
5071 ) -> MCPOAuthMetadata | None:
5072 for issuer in authorization_servers:
5073 metadata = await self._fetch_single_authorization_server_metadata(issuer, server_url)
5074 if metadata is not None:
5075 return metadata
5076 return None
5078 async def _fetch_issuer_anchored_oauth_metadata(
5079 self, issuer: str, server_url: str | None
5080 ) -> MCPOAuthMetadata | None:
5081 """RFC 8414 issuer-anchored discovery for the OAuth endpoints, with resource-driven scopes.
5083 Fetch authorization-server metadata from the admin-configured issuer's own origin and adopt
5084 its ``token_endpoint``/``registration_endpoint`` only when the document self-attests that same
5085 issuer (RFC 8414 §3.3). Because the trust anchor is the pinned issuer rather than anything the
5086 MCP resource advertises, the endpoints are authoritative for that issuer and cannot be
5087 substituted by a compromised resource. Fails closed (returns None) on a §3.3 mismatch or a
5088 fetch failure. The issuer is passed as its own ``server_url`` so the endpoint fetch is treated
5089 as same-authority and is not subject to the resource-scoped SSRF shortcut.
5091 Scopes are NOT taken from the issuer document. Per the MCP authorization spec Scope Selection
5092 Strategy and RFC 9728, the scopes a client requests are resource-driven (the WWW-Authenticate
5093 challenge or the protected-resource ``scopes_supported``), so the resource's advertised scopes
5094 are fetched separately and used; the resource can influence only the requested scope, which
5095 the authorization server and user consent bound (RFC 6749 §3.3), never the token endpoint.
5096 """
5097 metadata: Final = await self._fetch_single_authorization_server_metadata(issuer, issuer, require_issuer=issuer)
5098 if metadata is None: 5098 ↛ 5105line 5098 didn't jump to line 5105 because the condition on line 5098 was always true
5099 verbose_logger.warning(
5100 "MCP OAuth issuer-anchored discovery for issuer %s yielded no metadata whose issuer "
5101 "matched (RFC 8414 §3.3); OAuth endpoints stay unresolved until a rebuild succeeds",
5102 issuer,
5103 )
5104 return None
5105 resource_metadata: Final = (
5106 await self._descovery_metadata(server_url, allow_origin_fallback=False) if server_url else None
5107 )
5108 resource_scopes: Final = resource_metadata.scopes if resource_metadata else None
5109 return metadata.model_copy(update={"scopes": resource_scopes})
5111 async def _fetch_single_authorization_server_metadata(
5112 self, issuer_url: str, server_url: str, require_issuer: str | None = None
5113 ) -> MCPOAuthMetadata | None:
5114 try:
5115 parsed: Final = urlparse(issuer_url)
5116 except Exception:
5117 return None
5119 if not parsed.scheme or not parsed.netloc: 5119 ↛ 5122line 5119 didn't jump to line 5122 because the condition on line 5119 was always true
5120 return None
5122 base: Final = f"{parsed.scheme}://{parsed.netloc}"
5123 path: Final = (parsed.path or "").strip("/")
5125 candidate_urls: Final[list[str]] = []
5126 if path:
5127 candidate_urls.append(f"{base}/.well-known/oauth-authorization-server/{path}")
5128 candidate_urls.append(f"{base}/.well-known/openid-configuration/{path}")
5129 candidate_urls.append(f"{issuer_url.rstrip('/')}/.well-known/openid-configuration")
5130 candidate_urls.append(f"{base}/.well-known/oauth-authorization-server")
5131 candidate_urls.append(f"{base}/.well-known/openid-configuration")
5132 candidate_urls.append(issuer_url.rstrip("/"))
5134 for url in candidate_urls:
5135 try:
5136 response = await self._fetch_oauth_discovery_url(url, server_url)
5137 response.raise_for_status()
5138 data: _AuthorizationServerMetadataPayload = response.json()
5139 except SSRFError as exc:
5140 verbose_logger.warning(
5141 "MCP OAuth discovery: refusing to fetch authorization-server "
5142 "metadata from %s (rejected by SSRF guard for server %s): %s",
5143 url,
5144 server_url,
5145 exc,
5146 )
5147 continue
5148 except Exception as exc: # pragma: no cover - network issues
5149 verbose_logger.debug(
5150 "Failed to fetch authorization metadata from %s: %s",
5151 url,
5152 exc,
5153 )
5154 continue
5156 claimed_issuer = data.get("issuer")
5157 verbose_logger.debug(
5158 "Authorization server metadata from %s: issuer=%s grant_types_supported=%s "
5159 "token_endpoint_auth_methods_supported=%s",
5160 url,
5161 claimed_issuer,
5162 data.get("grant_types_supported"),
5163 data.get("token_endpoint_auth_methods_supported"),
5164 )
5165 if require_issuer is not None and not _issuer_matches(claimed_issuer, require_issuer):
5166 verbose_logger.warning(
5167 "MCP OAuth issuer-anchored discovery: metadata at %s self-attests issuer %r, which "
5168 "does not match the configured issuer %r (RFC 8414 §3.3); rejecting so a compromised "
5169 "resource cannot substitute an attacker authorization server",
5170 url,
5171 claimed_issuer,
5172 require_issuer,
5173 )
5174 continue
5176 scopes = self._extract_scopes(data.get("scopes_supported"))
5177 metadata = MCPOAuthMetadata(
5178 scopes=scopes,
5179 authorization_url=data.get("authorization_endpoint"),
5180 token_url=data.get("token_endpoint"),
5181 registration_url=data.get("registration_endpoint"),
5182 discovered_issuer=claimed_issuer if isinstance(claimed_issuer, str) and claimed_issuer else None,
5183 )
5185 if any(
5186 [
5187 metadata.scopes,
5188 metadata.authorization_url,
5189 metadata.token_url,
5190 metadata.registration_url,
5191 ]
5192 ):
5193 return metadata
5195 return self._build_azure_authorization_server_metadata(parsed)
5197 @staticmethod
5198 def _build_azure_authorization_server_metadata(
5199 parsed_issuer_url: ParseResult,
5200 ) -> MCPOAuthMetadata | None:
5201 path_parts: Final = [part for part in (parsed_issuer_url.path or "").split("/") if part]
5202 if parsed_issuer_url.netloc not in _AZURE_ENTRA_HOSTS or len(path_parts) != 2 or path_parts[1] != "v2.0":
5203 return None
5205 tenant: Final = path_parts[0]
5206 base: Final = f"{parsed_issuer_url.scheme}://{parsed_issuer_url.netloc}/{tenant}"
5207 return MCPOAuthMetadata(
5208 authorization_url=f"{base}/oauth2/v2.0/authorize",
5209 token_url=f"{base}/oauth2/v2.0/token",
5210 )
5212 @staticmethod
5213 def _decrypt_credential_field(
5214 encrypted_value: str | None,
5215 key: str,
5216 credentials_are_encrypted: bool,
5217 ) -> str | None:
5218 """Decrypt a single credential field, or return as-is if not encrypted."""
5219 if not encrypted_value: 5219 ↛ 5221line 5219 didn't jump to line 5221 because the condition on line 5219 was always true
5220 return None
5221 if credentials_are_encrypted:
5222 return decrypt_value_helper(
5223 value=encrypted_value,
5224 key=key,
5225 exception_type="debug",
5226 return_original_value=True,
5227 )
5228 return encrypted_value
5230 def _extract_aws_credentials(
5231 self,
5232 credentials_dict: dict[str, str] | None,
5233 credentials_are_encrypted: bool,
5234 ) -> dict[str, str | None]:
5235 """Extract and decrypt AWS SigV4 credential fields from credentials dict."""
5236 if not credentials_dict: 5236 ↛ 5238line 5236 didn't jump to line 5238 because the condition on line 5236 was always true
5237 return {}
5238 return {
5239 "aws_access_key_id": self._decrypt_credential_field(
5240 credentials_dict.get("aws_access_key_id"),
5241 "aws_access_key_id",
5242 credentials_are_encrypted,
5243 ),
5244 "aws_secret_access_key": self._decrypt_credential_field(
5245 credentials_dict.get("aws_secret_access_key"),
5246 "aws_secret_access_key",
5247 credentials_are_encrypted,
5248 ),
5249 "aws_session_token": self._decrypt_credential_field(
5250 credentials_dict.get("aws_session_token"),
5251 "aws_session_token",
5252 credentials_are_encrypted,
5253 ),
5254 "aws_region_name": credentials_dict.get("aws_region_name"),
5255 "aws_service_name": credentials_dict.get("aws_service_name"),
5256 "aws_role_name": credentials_dict.get("aws_role_name"),
5257 "aws_session_name": credentials_dict.get("aws_session_name"),
5258 }
5260 def _extract_scopes(self, scopes_value: str | Sequence[object] | None) -> list[str] | None:
5261 if isinstance(scopes_value, str):
5262 scopes = [s.strip() for s in scopes_value.split() if s.strip()]
5263 return scopes or None
5264 if isinstance(scopes_value, list):
5265 scopes = [s for s in scopes_value if isinstance(s, str) and s.strip()]
5266 return scopes or None
5267 return None
5269 async def _fetch_tools_with_timeout(
5270 self,
5271 client: MCPClient,
5272 server_name: str,
5273 ) -> list[MCPTool]:
5274 """
5275 Fetch tools from MCP client with timeout and error handling.
5277 Uses anyio.fail_after() instead of asyncio.wait_for() to avoid conflicts
5278 with the MCP SDK's anyio TaskGroup. See GitHub issue #20715 for details.
5280 Failures never return an empty tool list. An upstream 401 or 403 raises
5281 :class:`MCPUpstreamAuthError` carrying the upstream's own
5282 ``WWW-Authenticate`` challenge when one was sent (a challenge is only
5283 ever fabricated at the HTTP edge, and only for a 401: a 403 means the
5284 caller is authenticated but not allowed, so prompting re-auth would be
5285 wrong, while an upstream-sent 403 challenge is the RFC 6750
5286 insufficient_scope step-up and relays verbatim). Every other failure
5287 raises :class:`MCPServerListError` with a classified fault. Each
5288 boundary then applies its own policy: single-server routes relay the
5289 truthful status, the multi-server aggregator absorbs the failure into
5290 that server's listing outcome.
5292 Args:
5293 client: MCP client instance
5294 server_name: Name of the server for logging
5296 Returns:
5297 List of tools from the server
5298 """
5299 try:
5300 with anyio.fail_after(MCP_TOOL_LISTING_TIMEOUT):
5301 tools: Final = await client.list_tools(raise_on_error=True)
5302 verbose_logger.debug("Tools from %s: %s", server_name, tools)
5303 return tools
5304 except TimeoutError as e:
5305 verbose_logger.warning("Timeout while listing tools from %s", server_name)
5306 raise MCPServerListError(ServerListFault(tag="timeout"), server_name) from e
5307 except asyncio.CancelledError as e:
5308 verbose_logger.warning("Task cancelled while listing tools from %s", server_name)
5309 raise MCPServerListError(ServerListFault(tag="internal"), server_name) from e
5310 except ConnectionError as e:
5311 verbose_logger.warning("Connection error while listing tools from %s: %s", server_name, e)
5312 raise MCPServerListError(ServerListFault(tag="unreachable"), server_name) from e
5313 except Exception as e:
5314 verbose_logger.warning(
5315 "Error listing tools from %s: %s%s", server_name, type(e).__name__, _upstream_failure_suffix(e)
5316 )
5317 raise_classified_list_failure(e, server_name)
5319 _SHORT_PREFIX_MAX_REHASH_ATTEMPTS = 1024
5321 def _assign_unique_short_prefix(
5322 self,
5323 server: MCPServer,
5324 registry: dict[str, MCPServer] | None = None,
5325 ) -> None:
5326 """Resolve and cache a collision-free short tool prefix on ``server``.
5328 Called at registration time for every MCP server entering the
5329 registry. Mutates ``server.short_prefix`` in place. No-ops when
5330 ``LITELLM_USE_SHORT_MCP_TOOL_PREFIX`` is disabled, when the server
5331 has no ``server_id`` (synthetic temp-server objects), or when a
5332 prefix is already cached.
5334 Collision strategy: take the natural hash; if it's already used by
5335 a *different* server in the combined registry, rehash with an
5336 incrementing attempt counter until we find an unused slot. The
5337 attempt counter is folded into the hash so the resulting prefix is
5338 still deterministic for a given (server_id, set-of-other-server-ids)
5339 pair within one process.
5340 """
5341 if not is_short_mcp_tool_prefix_enabled(): 5341 ↛ 5343line 5341 didn't jump to line 5343 because the condition on line 5341 was always true
5342 return
5343 if server.short_prefix:
5344 return
5345 if not server.server_id:
5346 return
5348 used: Final[dict[str, str]] = {}
5349 registry_for_collision_check: Final = registry or self.get_registry()
5350 for other in registry_for_collision_check.values():
5351 if other.server_id == server.server_id:
5352 continue
5353 if other.short_prefix:
5354 used[other.short_prefix] = other.server_id
5356 for attempt in range(self._SHORT_PREFIX_MAX_REHASH_ATTEMPTS):
5357 candidate = compute_short_server_prefix(server.server_id, attempt=attempt)
5358 if candidate not in used:
5359 server.short_prefix = candidate
5360 if attempt > 0:
5361 verbose_logger.info(
5362 "MCP short-prefix collision resolved for server %s: "
5363 "natural hash collided with %s, using rehashed prefix "
5364 "%s (attempt=%d).",
5365 server.server_id,
5366 used.get(
5367 compute_short_server_prefix(server.server_id, attempt=0),
5368 "<unknown>",
5369 ),
5370 candidate,
5371 attempt,
5372 )
5373 return
5375 raise RuntimeError(
5376 f"Unable to assign a unique short MCP tool prefix for server "
5377 f"{server.server_id} after {self._SHORT_PREFIX_MAX_REHASH_ATTEMPTS} "
5378 "attempts; the 3-character prefix space is too crowded."
5379 )
5381 def _create_prefixed_tools(self, tools: list[MCPTool], server: MCPServer, add_prefix: bool = True) -> list[MCPTool]:
5382 """
5383 Create prefixed tools and update tool mapping.
5385 Args:
5386 tools: List of original tools from server
5387 server: Server instance
5389 Returns:
5390 List of tools with prefixed names
5391 """
5392 prefixed_tools: Final = []
5393 prefix: Final = get_server_prefix(server)
5395 for tool in tools:
5396 tool_copy = tool.model_copy(deep=True)
5398 original_name = tool_copy.name
5399 prefixed_name = add_server_prefix_to_name(original_name, prefix)
5401 name_to_use = prefixed_name if add_prefix else original_name
5403 # Preserve all tool fields including metadata/_meta by avoiding mutation
5404 tool_copy.name = name_to_use
5405 prefixed_tools.append(tool_copy)
5407 # Register every known prefix form (alias, server_name, server_id,
5408 # short ID) so call_tool can resolve regardless of which form a
5409 # caller / cached client is using.
5410 for spelling in iter_known_tool_name_spellings(original_name, server):
5411 self.tool_name_to_mcp_server_name_mapping[spelling] = prefix
5413 verbose_logger.info("Successfully fetched %s tools from server %s", len(prefixed_tools), server.name)
5414 return prefixed_tools
5416 def _create_prefixed_prompts(
5417 self, prompts: Sequence[Prompt], server: MCPServer, add_prefix: bool = True
5418 ) -> list[Prompt]:
5419 """
5420 Create prefixed prompts and update prompt mapping.
5422 Args:
5423 prompts: List of original prompts from server
5424 server: Server instance
5426 Returns:
5427 List of prompts with prefixed names
5428 """
5429 prefixed_prompts: Final = []
5430 prefix: Final = get_server_prefix(server)
5432 for prompt in prompts:
5433 prefixed_name = add_server_prefix_to_name(prompt.name, prefix)
5435 name_to_use = prefixed_name if add_prefix else prompt.name
5437 prompt.name = name_to_use
5438 prefixed_prompts.append(prompt)
5440 verbose_logger.info("Successfully fetched %s prompts from server %s", len(prefixed_prompts), server.name)
5441 return prefixed_prompts
5443 def _create_prefixed_resources(
5444 self, resources: Sequence[Resource], server: MCPServer, add_prefix: bool = True
5445 ) -> list[Resource]:
5446 """Prefix resource names and track origin server for read requests."""
5448 prefixed_resources: Final[list[Resource]] = []
5449 prefix: Final = get_server_prefix(server)
5451 for resource in resources:
5452 name_to_use = add_server_prefix_to_name(resource.name, prefix) if add_prefix else resource.name
5453 resource.name = name_to_use
5454 prefixed_resources.append(resource)
5456 verbose_logger.info("Successfully fetched %s resources from server %s", len(prefixed_resources), server.name)
5457 return prefixed_resources
5459 def _create_prefixed_resource_templates(
5460 self,
5461 resource_templates: Sequence[ResourceTemplate],
5462 server: MCPServer,
5463 add_prefix: bool = True,
5464 ) -> list[ResourceTemplate]:
5465 """Prefix resource template names for multi-server scenarios."""
5467 prefixed_templates: Final[list[ResourceTemplate]] = []
5468 prefix: Final = get_server_prefix(server)
5470 for resource_template in resource_templates:
5471 name_to_use = (
5472 add_server_prefix_to_name(resource_template.name, prefix) if add_prefix else resource_template.name
5473 )
5474 resource_template.name = name_to_use
5475 prefixed_templates.append(resource_template)
5477 verbose_logger.info(
5478 "Successfully fetched %s resource templates from server %s", len(prefixed_templates), server.name
5479 )
5480 return prefixed_templates
5482 def check_allowed_or_banned_tools(self, tool_name: str, server: MCPServer) -> bool:
5483 """
5484 Check if the tool is allowed or banned for the given server.
5486 ``tool_name`` is bare: every caller resolves the boundary against the server's
5487 registered prefixes before dispatch (``server.py``'s ``original_tool_name``, the
5488 Responses handler's ``sanitized_tool_name``). Configured entries are matched by
5489 deriving the spellings routing accepts, never by stripping the entry, which would
5490 cut a second boundary out of a native name that opens with the server prefix.
5491 """
5492 from litellm.proxy._experimental.mcp_server.utils import (
5493 server_applies_tool_allowlist,
5494 )
5496 if server_applies_tool_allowlist(server):
5497 return match_known_tool_name(tool_name, server, server.allowed_tools or ()) is not None
5498 return match_known_tool_name(tool_name, server, server.disallowed_tools or ()) is None
5500 def validate_allowed_params(self, tool_name: str, arguments: _ToolArguments, server: MCPServer) -> None:
5501 """
5502 Filter arguments to only include allowed parameters for the given tool.
5504 Args:
5505 tool_name: Bare tool name, already resolved against the server's
5506 registered prefixes by the caller
5507 arguments: Dictionary of arguments to filter
5508 server: MCPServer configuration
5510 Returns:
5511 Filtered dictionary containing only allowed parameters
5513 Raises:
5514 HTTPException: If allowed_params is configured for this tool but arguments contain disallowed params
5515 """
5516 allowed_params: Final = server.allowed_params or {}
5517 matched: Final = match_known_tool_name(tool_name, server, allowed_params)
5518 if matched is None:
5519 return
5521 allowed_params_list: Final = allowed_params[matched]
5523 # Filter arguments to only include allowed parameters
5524 disallowed_params: Final = [param for param in arguments if param not in allowed_params_list]
5526 if disallowed_params:
5527 raise HTTPException(
5528 status_code=403,
5529 detail={
5530 "error": f"Parameters {disallowed_params} are not allowed for tool {tool_name}. "
5531 f"Allowed parameters: {allowed_params_list}. "
5532 f"Contact proxy admin to allow these parameters."
5533 },
5534 )
5536 async def check_tool_permission_for_key_team(
5537 self,
5538 tool_name: str,
5539 server: MCPServer,
5540 user_api_key_auth: UserAPIKeyAuth | None,
5541 ) -> None:
5542 """
5543 Check if a tool is allowed based on key/team object_permission.mcp_tool_permissions.
5544 Uses MCPRequestHandler.is_tool_allowed_for_server for consistent inheritance logic.
5545 Raises HTTPException if tool is not allowed.
5547 Args:
5548 tool_name: Name of the tool to check
5549 server: MCPServer object
5550 user_api_key_auth: User authentication
5552 Raises:
5553 HTTPException: If tool is not allowed for this key/team
5554 """
5555 from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
5556 MCPRequestHandler,
5557 )
5559 if not user_api_key_auth:
5560 return
5562 # Check if tool is allowed
5563 is_allowed: Final = await MCPRequestHandler.is_tool_allowed_for_server(
5564 tool_name=tool_name,
5565 server_id=server.server_id,
5566 user_api_key_auth=user_api_key_auth,
5567 )
5569 if not is_allowed:
5570 raise HTTPException(
5571 status_code=403,
5572 detail={
5573 "error": f"Tool '{tool_name}' is not allowed for your key/team on server '{server.name}'. Contact proxy admin for access."
5574 },
5575 )
5577 async def _call_openapi_tool_handler(
5578 self,
5579 server: MCPServer,
5580 tool_name: str,
5581 arguments: _ToolArguments,
5582 ) -> CallToolResult:
5583 """
5584 Call an OpenAPI tool handler directly.
5586 For OpenAPI servers, instead of using MCP protocol, we call the tool handler
5587 that was registered during OpenAPI spec parsing. This handler makes direct
5588 HTTP requests to the API.
5590 Args:
5591 tool_name: The full tool name (with prefix) to call
5592 arguments: Tool arguments to pass to the handler
5594 Returns:
5595 CallToolResult with the response from the API
5596 """
5597 from mcp.types import TextContent
5599 from litellm.proxy._experimental.mcp_server.tool_registry import (
5600 global_mcp_tool_registry,
5601 )
5603 # Registration used add_server_prefix_to_name(base, get_server_prefix(server)),
5604 # and tool_name is the bare base name by the time call_tool reaches here, so
5605 # rebuilding the key the same way reproduces it exactly
5606 registry_key: Final = add_server_prefix_to_name(tool_name, get_server_prefix(server))
5607 tool: Final = global_mcp_tool_registry.get_tool(registry_key)
5608 if tool is None:
5609 # Tool not found in registry
5610 error_msg = f"OpenAPI tool {tool_name} not found in registry"
5611 verbose_logger.error(error_msg)
5612 return CallToolResult(
5613 content=[TextContent(type="text", text=error_msg)],
5614 is_error=True,
5615 )
5617 try:
5618 # Call the tool handler with the arguments
5619 # The handler is an async function that makes the HTTP request
5620 handler_result: Final = await tool.handler(**arguments)
5622 # Convert the handler result (string response) to CallToolResult format
5623 result: Final = CallToolResult(
5624 content=[TextContent(type="text", text=str(handler_result))],
5625 is_error=False,
5626 )
5628 return result
5630 except MCPUpstreamAuthError:
5631 # The caller must re-authenticate upstream, so this keeps its type all the way to the
5632 # renderers: the streamable path turns it into an isError result naming the status, and
5633 # the REST path relays a real 401 with the upstream's WWW-Authenticate. Flattening it
5634 # into the generic message below would lose both.
5635 raise
5636 except Exception as e:
5637 error_msg = f"Error calling OpenAPI tool {tool_name}: {e}"
5638 verbose_logger.error(error_msg)
5639 return CallToolResult(
5640 content=[TextContent(type="text", text=error_msg)],
5641 is_error=True,
5642 )
5644 async def pre_call_tool_check(
5645 self,
5646 name: str,
5647 arguments: _ToolArguments,
5648 server_name: str,
5649 user_api_key_auth: UserAPIKeyAuth | None,
5650 proxy_logging_obj: ProxyLogging | None,
5651 server: MCPServer,
5652 raw_headers: dict[str, str] | None = None,
5653 litellm_logging_obj: "LiteLLMLoggingObj | None" = None,
5654 guardrail_context: Mapping[str, object] | None = None,
5655 ) -> dict[str, Any]:
5656 """
5657 Run pre-call checks and guardrail hooks for an MCP tool call.
5659 Authorization runs unconditionally; only the guardrail hooks, which are
5660 dispatched through ``proxy_logging_obj``, depend on a logger being
5661 present. An absent logger must never be able to turn an authorization
5662 decision into a no-op.
5664 ``litellm_logging_obj`` is the request's logger, and it is what lands a
5665 ``pre_mcp_call`` evaluation (or a block) on the spend-log row the Guardrails
5666 Monitor counts. It stays optional so callers that do no logging are unchanged.
5668 Returns a dict that may contain:
5669 - "arguments": hook-modified tool arguments (only if changed)
5670 - "extra_headers": headers injected by pre_mcp_call guardrail hooks
5671 """
5672 ## check if the tool is allowed or banned for the given server
5673 if not self.check_allowed_or_banned_tools(name, server):
5674 raise HTTPException(
5675 status_code=403,
5676 detail={
5677 "error": f"Tool {name} is not allowed for server {server.name}. Contact proxy admin to allow this tool."
5678 },
5679 )
5681 ## check tool-level permissions from object_permission
5682 await self.check_tool_permission_for_key_team(
5683 tool_name=name,
5684 server=server,
5685 user_api_key_auth=user_api_key_auth,
5686 )
5688 ## filter parameters based on allowed_params configuration
5689 self.validate_allowed_params(
5690 tool_name=name,
5691 arguments=arguments,
5692 server=server,
5693 )
5695 hook_result: Final[dict[str, Any]] = {}
5696 if proxy_logging_obj is None:
5697 return hook_result
5699 # Extract incoming Bearer token from raw request headers so
5700 # guardrails like MCPJWTSigner can verify + re-sign it (FR-5).
5701 normalized_raw: Final = {k.lower(): v for k, v in (raw_headers or {}).items()}
5702 incoming_bearer_token: str | None = None
5703 auth_hdr: Final = normalized_raw.get("authorization", "")
5704 if auth_hdr.lower().startswith("bearer "):
5705 incoming_bearer_token = auth_hdr[len("bearer ") :]
5707 pre_hook_kwargs: Final = {
5708 "guardrail_context": guardrail_context,
5709 "name": name,
5710 "arguments": arguments,
5711 "server_name": server_name,
5712 "mcp_rate_limit_server_name": server.alias or server.server_name or server.name,
5713 "user_api_key_auth": user_api_key_auth,
5714 "user_api_key_user_id": (getattr(user_api_key_auth, "user_id", None) if user_api_key_auth else None),
5715 "user_api_key_team_id": (getattr(user_api_key_auth, "team_id", None) if user_api_key_auth else None),
5716 "user_api_key_end_user_id": (
5717 getattr(user_api_key_auth, "end_user_id", None) if user_api_key_auth else None
5718 ),
5719 "user_api_key_hash": (getattr(user_api_key_auth, "api_key_hash", None) if user_api_key_auth else None),
5720 "incoming_bearer_token": incoming_bearer_token,
5721 "headers": logging_safe_mcp_headers(raw_headers),
5722 }
5724 # Create MCP request object for processing
5725 mcp_request_obj: Final = proxy_logging_obj._create_mcp_request_object_from_kwargs(pre_hook_kwargs)
5727 # Convert to LLM format for existing guardrail compatibility.
5728 # Unified guardrails read the seeded logger off the request dict and pass it
5729 # into ``apply_guardrail``, so ``@log_guardrail_information`` bridges their
5730 # evaluations itself; the ``finally`` below covers native guardrails, which
5731 # never receive it. Same seeding the pass-through routes do.
5732 synthetic_llm_data: Final = proxy_logging_obj._convert_mcp_to_llm_format(mcp_request_obj, pre_hook_kwargs)
5733 synthetic_llm_data["litellm_logging_obj"] = litellm_logging_obj
5735 try:
5736 # Use standard pre_call_hook
5737 modified_data: Final = await proxy_logging_obj.pre_call_hook(
5738 user_api_key_dict=user_api_key_auth,
5739 data=synthetic_llm_data,
5740 call_type=CallTypes.call_mcp_tool.value,
5741 )
5742 if modified_data:
5743 # Convert response back to MCP format and apply modifications
5744 modified_kwargs = proxy_logging_obj._convert_mcp_hook_response_to_kwargs(modified_data, pre_hook_kwargs)
5745 if modified_kwargs.get("arguments") != arguments:
5746 hook_result["arguments"] = modified_kwargs["arguments"]
5747 if modified_kwargs.get("extra_headers"):
5748 hook_result["extra_headers"] = modified_kwargs["extra_headers"]
5750 except (
5751 BlockedPiiEntityError,
5752 GuardrailRaisedException,
5753 HTTPException,
5754 ) as e:
5755 # Re-raise guardrail exceptions to properly fail the MCP call
5756 verbose_logger.error("Guardrail blocked MCP tool call pre call: %s", e)
5757 raise e
5758 finally:
5759 # ``finally`` rather than after the ``try``: a block raises straight out of
5760 # here, and the failure spend-log row that "Total Blocked" counts is built
5761 # from this logger further up the stack, so the record has to be attached
5762 # before the exception leaves this frame.
5763 _record_mcp_guardrail_evaluations(synthetic_llm_data, litellm_logging_obj)
5765 return hook_result
5767 def _create_during_hook_task(
5768 self,
5769 name: str,
5770 arguments: _ToolArguments,
5771 server_name_from_prefix: str | None,
5772 user_api_key_auth: UserAPIKeyAuth | None,
5773 proxy_logging_obj: ProxyLogging,
5774 start_time: datetime.datetime,
5775 litellm_logging_obj: "LiteLLMLoggingObj | None" = None,
5776 guardrail_context: Mapping[str, object] | None = None,
5777 ):
5778 """Create and return a during hook task for MCP tool calls.
5780 ``litellm_logging_obj`` is the request's logger; see ``pre_call_tool_check``.
5781 The task is awaited before the tool call's success logging runs, so a
5782 ``during_mcp_call`` evaluation recorded on it is serialized with that call.
5783 """
5784 from litellm.types.llms.base import HiddenParams
5785 from litellm.types.mcp import MCPDuringCallRequestObject
5787 request_obj: Final = MCPDuringCallRequestObject(
5788 tool_name=name,
5789 arguments=arguments,
5790 server_name=server_name_from_prefix,
5791 start_time=start_time.timestamp() if start_time else None,
5792 hidden_params=HiddenParams(),
5793 )
5795 during_hook_kwargs: Final = {
5796 "guardrail_context": guardrail_context,
5797 "name": name,
5798 "arguments": arguments,
5799 "server_name": server_name_from_prefix,
5800 "user_api_key_auth": user_api_key_auth,
5801 }
5803 # Seeded for the same reason as in ``pre_call_tool_check``.
5804 synthetic_llm_data: Final = proxy_logging_obj._convert_mcp_to_llm_format(request_obj, during_hook_kwargs)
5805 synthetic_llm_data["litellm_logging_obj"] = litellm_logging_obj
5807 # Wrapped so the bridge runs inside the task: the caller only holds the task and
5808 # gathers it later, so there is no other point that still sees a block here.
5809 async def _run_during_call_hook() -> Mapping[str, object] | None:
5810 try:
5811 return await proxy_logging_obj.during_call_hook(
5812 user_api_key_dict=user_api_key_auth,
5813 data=synthetic_llm_data,
5814 call_type=CallTypes.call_mcp_tool.value,
5815 )
5816 finally:
5817 _record_mcp_guardrail_evaluations(synthetic_llm_data, litellm_logging_obj)
5819 return asyncio.create_task(_run_during_call_hook())
5821 def _get_call_semaphore(self, mcp_server: MCPServer) -> asyncio.Semaphore | None:
5822 limit: Final = mcp_server.max_concurrent_requests
5823 if limit is None or limit <= 0:
5824 return None
5825 cached: Final = self._server_call_semaphores.get(mcp_server.server_id)
5826 if cached is not None and cached[0] == limit:
5827 return cached[1]
5828 semaphore: Final = asyncio.Semaphore(limit)
5829 self._server_call_semaphores[mcp_server.server_id] = (limit, semaphore)
5830 return semaphore
5832 @asynccontextmanager
5833 async def _limit_outbound_concurrency(self, mcp_server: MCPServer) -> AsyncIterator[None]:
5834 semaphore: Final = self._get_call_semaphore(mcp_server)
5835 if semaphore is None:
5836 yield
5837 return
5838 async with semaphore:
5839 yield
5841 async def _obo_call_tool_with_retry(
5842 self,
5843 *,
5844 client: MCPClient,
5845 call_tool_params: MCPCallToolRequestParams,
5846 host_progress_callback: Callable | None,
5847 mcp_server: MCPServer,
5848 server_auth_header: str | dict[str, str] | None,
5849 extra_headers: dict[str, str] | None,
5850 stdio_env: dict[str, str] | None,
5851 subject_token: str | None,
5852 user_api_key_auth: UserAPIKeyAuth | None,
5853 raw_headers: Mapping[str, str] | None = None,
5854 client_ip: str | None = None,
5855 ) -> CallToolResult:
5856 """Call a token_exchange (OBO) tool; on an upstream 401/403 re-mint the token once and retry.
5858 The exchanged token is baked into the client at build time, so the retry invalidates the
5859 cached exchange and rebuilds the client (which re-exchanges). One retry only: a non-auth
5860 failure or a second auth failure degrades to the normal ``isError`` result, and a re-exchange
5861 that now fails surfaces its own 401 challenge from ``_create_mcp_client``.
5862 """
5863 try:
5864 return await client.call_tool(
5865 call_tool_params, host_progress_callback=host_progress_callback, raise_on_error=True
5866 )
5867 except Exception as exc:
5868 if _extract_upstream_auth_failure(exc) is None:
5869 return MCPClient.error_tool_result(exc)
5870 spec: Final = to_server_spec(mcp_server)
5871 if spec is not None:
5872 await self._cred_provider.invalidate_credentials(to_subject(user_api_key_auth, subject_token), spec)
5873 retry_client: Final = await self._create_mcp_client(
5874 server=mcp_server,
5875 mcp_auth_header=server_auth_header,
5876 extra_headers=extra_headers,
5877 stdio_env=stdio_env,
5878 subject_token=subject_token,
5879 user_api_key_auth=user_api_key_auth,
5880 raw_headers=raw_headers,
5881 client_ip=client_ip,
5882 )
5883 return await retry_client.call_tool(call_tool_params, host_progress_callback=host_progress_callback)
5885 async def _call_regular_mcp_tool(
5886 self,
5887 mcp_server: MCPServer,
5888 original_tool_name: str,
5889 arguments: _ToolArguments,
5890 tasks: list,
5891 mcp_auth_header: str | None,
5892 mcp_server_auth_headers: dict[str, dict[str, str]] | None,
5893 oauth2_headers: dict[str, str] | None,
5894 raw_headers: dict[str, str] | None,
5895 proxy_logging_obj: ProxyLogging | None,
5896 host_progress_callback: Callable | None = None,
5897 hook_extra_headers: dict[str, str] | None = None,
5898 user_api_key_auth: UserAPIKeyAuth | None = None,
5899 client_ip: str | None = None,
5900 ) -> CallToolResult:
5901 """
5902 Call a regular MCP tool using the MCP client.
5904 Args:
5905 mcp_server: The MCP server configuration
5906 original_tool_name: The original tool name (without prefix)
5907 arguments: Tool arguments
5908 tasks: List of async tasks to append to (for during hooks)
5909 mcp_auth_header: MCP auth header (deprecated)
5910 mcp_server_auth_headers: Optional dict of server-specific auth headers
5911 oauth2_headers: Optional OAuth2 headers
5912 raw_headers: Optional raw headers from the request
5913 proxy_logging_obj: Optional ProxyLogging object for hook integration
5914 host_progress_callback: Optional callback for progress updates
5915 hook_extra_headers: Optional headers injected by pre_mcp_call guardrail
5916 hooks. Merged last into outbound request headers, except a hook
5917 Authorization header is dropped when an upstream credential already
5918 occupies the Authorization slot.
5920 Returns:
5921 CallToolResult from the MCP server
5923 Raises:
5924 BlockedPiiEntityError: If PII is blocked by guardrails
5925 GuardrailRaisedException: If guardrails block the call
5926 HTTPException: If an HTTP error occurs
5927 """
5928 # Get server-specific auth header if available (case-insensitive)
5929 # FIX: Added case-insensitive matching to handle auth header keys that may not match
5930 # the exact case of server alias/name (e.g., '1litellmagcgateway' vs '1LiteLLMAGCGateway')
5931 server_auth_header: dict[str, str] | str | None = None
5932 if mcp_server_auth_headers:
5933 server_auth_header = lookup_mcp_server_auth_in_headers(
5934 mcp_server_auth_headers,
5935 alias=mcp_server.alias,
5936 server_name=mcp_server.server_name,
5937 access_groups=mcp_server.access_groups,
5938 )
5940 # Fall back to deprecated mcp_auth_header if no server-specific header found
5941 if server_auth_header is None:
5942 server_auth_header = mcp_auth_header
5944 # Extract subject token for OAuth2 Token Exchange (OBO) and ID-JAG flows
5945 subject_token: str | None = None
5946 extra_headers: dict[str, str] | None = None
5947 if mcp_server.auth_type in (
5948 MCPAuth.oauth2_token_exchange,
5949 MCPAuth.oauth2_id_jag,
5950 ):
5951 subject_token = self._extract_subject_token(oauth2_headers, raw_headers, user_api_key_auth)
5952 elif mcp_server.auth_type == MCPAuth.oauth2:
5953 if mcp_server.has_client_credentials:
5954 # For M2M OAuth servers, Authorization must come from token fetch.
5955 extra_headers = None
5956 else:
5957 extra_headers = oauth2_headers
5958 # Migrated authorization_code: the v2 resolver injects the stored per-user
5959 # token, so drop the caller-forwarded Authorization (apply-if-absent would
5960 # otherwise let it shadow the resolved token). Delegate keeps it. Centralized
5961 # via _should_strip_caller_authorization to match _prepare_mcp_server_headers.
5962 if extra_headers and _should_strip_caller_authorization(
5963 mcp_server=mcp_server,
5964 raw_headers=raw_headers,
5965 user_api_key_auth=user_api_key_auth,
5966 ):
5967 extra_headers = without_header(extra_headers, DEFAULT_CREDENTIAL_HEADER)
5968 elif mcp_server.is_client_forwarded_token:
5969 extra_headers = _client_forwarded_authorization_headers(
5970 mcp_server=mcp_server,
5971 oauth2_headers=oauth2_headers,
5972 raw_headers=raw_headers,
5973 user_api_key_auth=user_api_key_auth,
5974 )
5976 if mcp_server.extra_headers and raw_headers:
5977 if extra_headers is None:
5978 extra_headers = {}
5980 normalized_raw_headers: Final = {str(k).lower(): v for k, v in raw_headers.items() if isinstance(k, str)}
5981 strip_caller_authorization: Final = _should_strip_caller_authorization(
5982 mcp_server=mcp_server,
5983 raw_headers=raw_headers,
5984 user_api_key_auth=user_api_key_auth,
5985 )
5987 for header in mcp_server.extra_headers:
5988 if not isinstance(header, str):
5989 continue
5990 if header.lower() == "authorization" and strip_caller_authorization:
5991 continue
5992 header_value = normalized_raw_headers.get(header.lower())
5993 if header_value is None:
5994 continue
5995 extra_headers[header] = header_value
5997 # Interpolate env vars into static_headers. Raises
5998 # MCPMissingUserEnvVarsError when the calling user has not filled in
5999 # a required per-user variable — the REST layer converts that into
6000 # a friendly 412 with a setup URL.
6001 resolved_static_headers: Final = await self._resolve_static_headers_with_env_vars(mcp_server, user_api_key_auth)
6002 if resolved_static_headers:
6003 if extra_headers is None:
6004 extra_headers = {}
6005 extra_headers.update(resolved_static_headers)
6007 if hook_extra_headers:
6008 if extra_headers is None:
6009 extra_headers = {}
6010 hook_has_authorization: Final = any(k.lower() == "authorization" for k in hook_extra_headers)
6011 existing_has_authorization: Final = any(k.lower() == "authorization" for k in extra_headers)
6012 server_auth_occupies_authorization: Final = (
6013 any(k.lower() == "authorization" for k in server_auth_header)
6014 if isinstance(server_auth_header, dict)
6015 else server_auth_header is not None and mcp_server.auth_type != MCPAuth.api_key
6016 )
6017 if hook_has_authorization and (existing_has_authorization or server_auth_occupies_authorization):
6018 # Mirror the tools/list signer guard: an upstream credential (user OAuth,
6019 # static header, or configured authentication_token) already occupies the
6020 # Authorization slot, so the hook must not replace it.
6021 verbose_logger.warning(
6022 "MCPServerManager: dropping hook-injected 'Authorization' header for "
6023 "server '%s' because an upstream credential already occupies the "
6024 "Authorization slot; the existing credential is kept.",
6025 mcp_server.server_name or mcp_server.name,
6026 )
6027 extra_headers.update({k: v for k, v in hook_extra_headers.items() if k.lower() != "authorization"})
6028 else:
6029 extra_headers.update(hook_extra_headers)
6031 # Reset to None if no headers were actually added
6032 if extra_headers is not None and len(extra_headers) == 0:
6033 extra_headers = None
6035 stdio_env: Final = self._build_stdio_env(mcp_server, raw_headers)
6037 client: Final = await self._create_mcp_client(
6038 server=mcp_server,
6039 mcp_auth_header=server_auth_header,
6040 extra_headers=extra_headers,
6041 stdio_env=stdio_env,
6042 subject_token=subject_token,
6043 user_api_key_auth=user_api_key_auth,
6044 raw_headers=raw_headers,
6045 client_ip=client_ip,
6046 )
6048 call_tool_params: Final = MCPCallToolRequestParams(
6049 name=original_tool_name,
6050 arguments=arguments,
6051 )
6053 if _obo_retry_applies(mcp_server, subject_token):
6054 # OBO / ID-JAG: the exchanged token may have been revoked/rotated upstream since it was
6055 # cached, so an upstream 401 gets one invalidate + re-mint + retry. Gated to these modes;
6056 # all others keep the plain single call below.
6057 async def _obo_call_tool_limited():
6058 async with self._limit_outbound_concurrency(mcp_server):
6059 return await self._obo_call_tool_with_retry(
6060 client=client,
6061 call_tool_params=call_tool_params,
6062 host_progress_callback=host_progress_callback,
6063 mcp_server=mcp_server,
6064 server_auth_header=server_auth_header,
6065 extra_headers=extra_headers,
6066 stdio_env=stdio_env,
6067 subject_token=subject_token,
6068 user_api_key_auth=user_api_key_auth,
6069 raw_headers=raw_headers,
6070 client_ip=client_ip,
6071 )
6073 tool_call_coro = _obo_call_tool_limited()
6074 else:
6075 # Scoped to the two client-forwarded token modes this stack introduced; legacy
6076 # oauth2 + delegate_auth_to_upstream (is_oauth_passthrough) is being removed, so it is not
6077 # added here even though the list path still relays for it.
6078 relays_upstream_auth: Final = mcp_server.is_client_forwarded_token
6079 server_label: Final = mcp_server.name or mcp_server.server_name or mcp_server.alias or ""
6081 async def _call_tool_via_client(client, params):
6082 async with self._limit_outbound_concurrency(mcp_server):
6083 if not relays_upstream_auth:
6084 return await client.call_tool(params, host_progress_callback=host_progress_callback)
6085 # The client-forwarded modes carry the caller's own upstream token, so an upstream
6086 # 401 (expired/invalid token) is the caller's to resolve: relay it as
6087 # MCPUpstreamAuthError so single-server REST callers turn it into a 401 +
6088 # WWW-Authenticate and re-run the upstream OAuth flow. Only 401 is a re-auth signal
6089 # (mirrors the list path and MCPUpstreamAuthError's contract); a 403 is a genuine
6090 # authorization failure that re-auth won't fix, so it takes the non-auth branch and
6091 # stays a visible warning. raise_on_error only re-raises transport failures
6092 # (tool-level isError results are still returned normally); a non-auth failure keeps
6093 # the same isError degradation the default path produces.
6094 try:
6095 return await client.call_tool(
6096 params, host_progress_callback=host_progress_callback, raise_on_error=True
6097 )
6098 except Exception as e:
6099 auth_info: Final = _extract_upstream_auth_failure(e)
6100 if auth_info is None or auth_info[0] != 401:
6101 # A genuine (non-auth or 403-forbidden) upstream/transport failure.
6102 # raise_on_error demoted the client-layer log to debug, so surface it here at
6103 # warning level to keep the outage visible; the caller still gets the graceful
6104 # isError result the default masking path would have produced. Log the
6105 # exception type only, never str(e), which for an httpx error embeds the
6106 # upstream URL (a credential can hide in it).
6107 verbose_logger.warning(
6108 "Pass-through MCP tool call failed against %s (non-auth, %s)",
6109 server_label,
6110 type(e).__name__,
6111 )
6112 return client.error_tool_result(e)
6113 _, www_authenticate = auth_info
6114 raise MCPUpstreamAuthError(
6115 status_code=401,
6116 www_authenticate=www_authenticate,
6117 server_name=server_label,
6118 ) from e
6120 tool_call_coro = _call_tool_via_client(client, call_tool_params)
6122 tasks.append(asyncio.create_task(tool_call_coro))
6124 _timeout: Final = mcp_server.timeout if mcp_server.timeout is not None else MCP_CLIENT_TIMEOUT
6125 try:
6126 mcp_responses: Final = await asyncio.wait_for(asyncio.gather(*tasks), timeout=_timeout)
6127 except asyncio.TimeoutError:
6128 raise HTTPException(
6129 status_code=504,
6130 detail={
6131 "error": "timeout",
6132 "message": f"MCP tool call timed out after {_timeout}s",
6133 },
6134 )
6135 except (
6136 BlockedPiiEntityError,
6137 GuardrailRaisedException,
6138 HTTPException,
6139 ) as e:
6140 verbose_logger.error("Guardrail blocked MCP tool call during result check: %s", e)
6141 raise e
6143 # If proxy_logging_obj is None, the tool call result is at index 0
6144 # If proxy_logging_obj is not None, the tool call result is at index 1 (after the during hook task)
6145 result_index: Final = 1 if proxy_logging_obj else 0
6146 result: Final = mcp_responses[result_index]
6147 self._remember_upstream_initialize_instructions(mcp_server, client)
6149 return cast(CallToolResult, result)
6151 def _resolve_mcp_server_for_tool_call(
6152 self,
6153 server_name: str,
6154 name: str,
6155 ) -> MCPServer:
6156 """Resolve MCP server for call_tool (prefixed name, registry, fallback)."""
6157 prefixed_tool_name: Final = add_server_prefix_to_name(name, server_name)
6158 mcp_server = self._get_mcp_server_from_tool_name(prefixed_tool_name)
6159 resolved_by_server_name_only = False
6160 normalized_server_name: Final = normalize_server_name(server_name)
6162 def _candidate_matches_server_name(candidate: MCPServer) -> bool:
6163 for identifier in (
6164 candidate.alias,
6165 candidate.server_name,
6166 candidate.name,
6167 ):
6168 if identifier and normalize_server_name(identifier) == (normalized_server_name):
6169 return True
6170 return False
6172 if mcp_server is None:
6173 for candidate in self.get_registry().values():
6174 if _candidate_matches_server_name(candidate):
6175 mcp_server = candidate
6176 resolved_by_server_name_only = True
6177 break
6178 if mcp_server is None:
6179 fallback: Final = self._get_mcp_server_from_tool_name(name)
6180 if fallback is not None and (not server_name or _candidate_matches_server_name(fallback)):
6181 mcp_server = fallback
6182 if mcp_server is None:
6183 raise ValueError(f"Tool {name} not found")
6184 if is_mcp_stdio_blocked(mcp_server.transport):
6185 raise HTTPException(status_code=403, detail=MCP_STDIO_DISABLED_MESSAGE)
6187 if resolved_by_server_name_only and not self.server_exposes_tool(mcp_server, name):
6188 raise ValueError(f"Tool {name} not found")
6190 return mcp_server
6192 async def has_user_oauth_token(self, server: MCPServer, user_api_key_auth: UserAPIKeyAuth | None) -> bool:
6193 """Whether the v2 resolver can produce a per-user token for this server right now.
6195 This is the preemptive 401's existence check, routed through the same resolver that drives
6196 the egress so every authorization_code resolution (egress and the discovery challenge) runs
6197 through v2. Returns False for a server the resolver does not own (a None spec).
6198 """
6199 spec: Final = to_server_spec(server)
6200 if spec is None:
6201 return False
6202 return await self._cred_provider.has_user_token(to_subject(user_api_key_auth, None), spec)
6204 async def invalidate_user_oauth_token_cache(self, user_id: str, server_id: str) -> None:
6205 """Drop every cached token for ``(user_id, server_id)`` after the credential row changes
6206 (re-auth, revoke, config-change purge): the v2 chain's cache and the legacy per-user token
6207 cache, so the next resolve reads the new row instead of serving the replaced token until its
6208 cache TTL, whichever path resolves it. This is the single invalidation point for per-user
6209 OAuth tokens; callers must not evict individual caches directly. Best-effort: a cache-drop
6210 failure is logged, never raised, because the DB write already succeeded and the TTL remains
6211 the backstop.
6212 """
6213 self._invalidate_discovery_lists(server_id)
6214 try:
6215 await self._per_user_oauth_token_store.invalidate(user_id, server_id)
6216 except Exception as exc: # noqa: BLE001 - cache drop is best-effort; TTL is the backstop
6217 verbose_logger.warning(
6218 "Failed to invalidate cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc
6219 )
6220 try:
6221 await self._per_user_token_cache.delete(user_id, server_id)
6222 except Exception as exc: # noqa: BLE001 - cache drop is best-effort; TTL is the backstop
6223 verbose_logger.warning(
6224 "Failed to drop legacy cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc
6225 )
6227 async def _resolve_oauth2_headers_for_tool_call(
6228 self,
6229 mcp_server: MCPServer,
6230 oauth2_headers: dict[str, str] | None,
6231 user_api_key_auth: UserAPIKeyAuth | None,
6232 ) -> dict[str, str] | None:
6233 """Look up per-user OAuth headers when the client did not supply a token."""
6234 if not mcp_server.needs_user_oauth_token or oauth2_headers or user_api_key_auth is None:
6235 return oauth2_headers
6237 if to_server_spec(mcp_server) is not None:
6238 # Migrated to v2: the resolver owns this server's per-user token (inject or fail-closed
6239 # 401). Building it into extra_headers here would let the v2 graft defer to it and
6240 # shadow the resolver, double-resolving and hiding the per-server challenge.
6241 return oauth2_headers
6243 user_id: Final[str | None] = getattr(user_api_key_auth, "user_id", None)
6244 if not user_id:
6245 return oauth2_headers
6247 try:
6248 from litellm.proxy._experimental.mcp_server.operations import ( # noqa: PLC0415
6249 _get_user_oauth_extra_headers_from_db,
6250 )
6252 stored_headers: Final = await _get_user_oauth_extra_headers_from_db(
6253 server=mcp_server,
6254 user_api_key_auth=user_api_key_auth,
6255 )
6256 if stored_headers:
6257 return stored_headers
6258 except Exception as _lookup_exc:
6259 verbose_logger.debug(
6260 "call_tool: per-user token lookup failed for user=%s server=%s: %s",
6261 user_id,
6262 mcp_server.server_id,
6263 _lookup_exc,
6264 )
6265 return oauth2_headers
6267 async def resolve_openapi_upstream_auth(
6268 self,
6269 *,
6270 mcp_server: MCPServer,
6271 oauth2_headers: dict[str, str] | None,
6272 raw_headers: dict[str, str] | None,
6273 mcp_auth_header: str | dict[str, str] | None,
6274 user_api_key_auth: UserAPIKeyAuth | None,
6275 forwarded_headers: dict[str, str] | None,
6276 ) -> tuple[dict[str, str] | None, dict[str, str] | None]:
6277 """Resolve the gateway-owned upstream credential for a spec_path (OpenAPI) tool call.
6279 OpenAPI tools egress through a plain httpx call assembled from ContextVars, never through
6280 ``_create_mcp_client``, so the v2 resolver graft there does not run for them and a resolved
6281 credential (authorization_code's stored per-user token, client_credentials' minted M2M
6282 token, token_exchange's exchanged token, passthrough's forwarded caller token) must be
6283 materialized into headers here. Returns ``(resolved_auth_headers, forwarded_headers)``:
6284 the resolved headers are authoritative over every other Authorization source (the same
6285 rule ``_resolve_v2_auth`` applies on the MCPClient path) and ``forwarded_headers`` comes
6286 back with any header the resolver claimed already dropped. Unmigrated (v1) servers resolve
6287 through the stored-token lookup instead, and a missing per-user credential raises the same
6288 discovery challenge the MCPClient path serves, rather than egressing unauthenticated.
6290 The resolved headers carry only credentials the gateway itself resolved (a stored per-user
6291 token, a minted or exchanged token). Caller-supplied ``oauth2_headers`` are never promoted
6292 into them: on the v2 arm they feed only subject-token extraction (the designed RFC 8693
6293 input), and on the v1 arm their presence disables the stored lookup entirely, so a
6294 caller's gateway credential can never displace a per-server BYOK header or leak upstream
6295 as the resolved credential.
6296 """
6297 spec: Final = to_server_spec(mcp_server)
6298 if spec is None:
6299 if oauth2_headers:
6300 return None, forwarded_headers
6301 stored_headers = await self._resolve_oauth2_headers_for_tool_call(mcp_server, None, user_api_key_auth)
6302 return stored_headers, forwarded_headers
6304 subject_token: str | None = None
6305 if isinstance(spec.config, (TokenExchangeConfig, IdJagConfig)):
6306 subject_token = self._extract_subject_token(oauth2_headers, raw_headers, user_api_key_auth)
6307 elif isinstance(spec.config, PassthroughConfig):
6308 inbound_token, forwarded_headers = _take_forwarded_authorization(forwarded_headers)
6309 per_server_token: Final = _passthrough_token_from_mcp_auth_header(mcp_auth_header)
6310 subject_token = per_server_token if per_server_token is not None else inbound_token
6312 resolved_auth, forwarded_headers = await self._resolve_v2_auth(
6313 server=mcp_server,
6314 spec=spec,
6315 provider=self._cred_provider,
6316 subject_token=subject_token,
6317 user_api_key_auth=user_api_key_auth,
6318 extra_headers=forwarded_headers,
6319 )
6320 return await _materialize_auth_headers(resolved_auth), forwarded_headers
6322 async def _gather_openapi_tool_tasks(
6323 self,
6324 tasks: Sequence[Awaitable[object]],
6325 proxy_logging_obj: ProxyLogging | None,
6326 ) -> CallToolResult:
6327 """Await OpenAPI tool tasks and return the tool call result."""
6328 try:
6329 mcp_responses: Final = await asyncio.gather(*tasks)
6330 result_index: Final = 1 if proxy_logging_obj else 0
6331 return cast(CallToolResult, mcp_responses[result_index])
6332 except (
6333 BlockedPiiEntityError,
6334 GuardrailRaisedException,
6335 HTTPException,
6336 ) as e:
6337 verbose_logger.error("Guardrail blocked MCP tool call during result check: %s", e)
6338 raise e
6340 async def call_tool(
6341 self,
6342 server_name: str,
6343 name: str,
6344 arguments: _ToolArguments,
6345 user_api_key_auth: UserAPIKeyAuth | None = None,
6346 mcp_auth_header: str | None = None,
6347 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
6348 proxy_logging_obj: ProxyLogging | None = None,
6349 oauth2_headers: dict[str, str] | None = None,
6350 raw_headers: dict[str, str] | None = None,
6351 host_progress_callback: Callable | None = None,
6352 litellm_logging_obj: "LiteLLMLoggingObj | None" = None,
6353 guardrail_context: Mapping[str, object] | None = None,
6354 client_ip: str | None = None,
6355 ) -> CallToolResult:
6356 """
6357 Call a tool with the given name and arguments
6359 Args:
6360 server_name: Server name
6361 name: Tool name
6362 arguments: Tool arguments
6363 user_api_key_auth: User authentication
6364 mcp_auth_header: MCP auth header (deprecated)
6365 mcp_server_auth_headers: Optional dict of server-specific auth headers {server_alias: auth_value}
6366 proxy_logging_obj: Optional ProxyLogging object for hook integration
6367 litellm_logging_obj: Optional request logger the guardrail hooks record
6368 their evaluations onto, so MCP guardrail activity reaches the
6369 Guardrails Monitor. See ``pre_call_tool_check``
6372 Returns:
6373 CallToolResult from the MCP server
6374 """
6375 start_time: Final = datetime.datetime.now()
6376 mcp_server: Final = self._resolve_mcp_server_for_tool_call(server_name, name)
6378 # Resolved before any hook runs so a missing BYOK credential (401) never
6379 # leaves during-hook side effects (audit logging, rate-limit bookkeeping)
6380 # recorded against a call that ultimately fails.
6381 mcp_auth_header = await _resolve_byok_mcp_auth_header(
6382 mcp_server,
6383 user_api_key_auth,
6384 mcp_auth_header,
6385 )
6387 #########################################################
6388 # Pre MCP Tool Call Hook
6389 # Allow validation and modification of tool calls before execution
6390 # Using standard pre_call_hook
6391 #########################################################
6392 hook_result: Final[dict[str, Any]] = await self.pre_call_tool_check(
6393 name=name,
6394 arguments=arguments,
6395 server_name=server_name,
6396 user_api_key_auth=user_api_key_auth,
6397 proxy_logging_obj=proxy_logging_obj,
6398 server=mcp_server,
6399 raw_headers=raw_headers,
6400 litellm_logging_obj=litellm_logging_obj,
6401 guardrail_context=guardrail_context,
6402 )
6403 if "arguments" in hook_result:
6404 arguments = hook_result["arguments"]
6406 # Prepare tasks for during hooks
6407 tasks: Final = []
6408 if proxy_logging_obj:
6409 during_hook_task: Final = self._create_during_hook_task(
6410 name=name,
6411 arguments=arguments,
6412 server_name_from_prefix=server_name,
6413 user_api_key_auth=user_api_key_auth,
6414 proxy_logging_obj=proxy_logging_obj,
6415 start_time=start_time,
6416 litellm_logging_obj=litellm_logging_obj,
6417 guardrail_context=guardrail_context,
6418 )
6419 tasks.append(during_hook_task)
6421 caller_oauth2_headers: Final = oauth2_headers
6422 oauth2_headers = await self._resolve_oauth2_headers_for_tool_call(mcp_server, oauth2_headers, user_api_key_auth)
6424 # For OpenAPI servers, call the tool handler directly instead of via MCP client
6425 if mcp_server.spec_path:
6426 verbose_logger.debug("Calling OpenAPI tool %s directly via HTTP handler", name)
6427 if hook_result.get("extra_headers"):
6428 verbose_logger.warning(
6429 "pre_mcp_call hook returned extra_headers for OpenAPI-backed "
6430 "MCP server '%s' — header injection is not supported for "
6431 "OpenAPI servers; headers will be ignored. Use SSE/HTTP "
6432 "transport to enable hook header injection.",
6433 server_name,
6434 )
6436 auth_header_value, openapi_forwarded_headers, upstream_credential = _resolve_openapi_tool_auth(
6437 mcp_server=mcp_server,
6438 mcp_auth_header=mcp_auth_header,
6439 mcp_server_auth_headers=mcp_server_auth_headers,
6440 raw_headers=raw_headers,
6441 user_api_key_auth=user_api_key_auth,
6442 )
6443 resolved_auth_headers, forwarded_headers = await self.resolve_openapi_upstream_auth(
6444 mcp_server=mcp_server,
6445 oauth2_headers=caller_oauth2_headers,
6446 raw_headers=raw_headers,
6447 mcp_auth_header=upstream_credential,
6448 user_api_key_auth=user_api_key_auth,
6449 forwarded_headers=openapi_forwarded_headers,
6450 )
6452 async def _call_openapi_via_handler():
6453 from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
6454 _request_auth_header,
6455 _request_extra_headers,
6456 _request_resolved_auth_headers,
6457 )
6459 auth_token: Final = _request_auth_header.set(auth_header_value)
6460 extra_token: Final = _request_extra_headers.set(forwarded_headers)
6461 resolved_token: Final = _request_resolved_auth_headers.set(resolved_auth_headers)
6462 try:
6463 async with self._limit_outbound_concurrency(mcp_server):
6464 return await self._call_openapi_tool_handler(mcp_server, name, arguments)
6465 finally:
6466 _request_auth_header.reset(auth_token)
6467 _request_extra_headers.reset(extra_token)
6468 _request_resolved_auth_headers.reset(resolved_token)
6470 tasks.append(asyncio.create_task(_call_openapi_via_handler()))
6471 else:
6472 return await self._call_regular_mcp_tool(
6473 mcp_server=mcp_server,
6474 original_tool_name=name,
6475 arguments=arguments,
6476 tasks=tasks,
6477 mcp_auth_header=mcp_auth_header,
6478 mcp_server_auth_headers=mcp_server_auth_headers,
6479 oauth2_headers=oauth2_headers,
6480 raw_headers=raw_headers,
6481 client_ip=client_ip,
6482 proxy_logging_obj=proxy_logging_obj,
6483 host_progress_callback=host_progress_callback,
6484 hook_extra_headers=hook_result.get("extra_headers"),
6485 user_api_key_auth=user_api_key_auth,
6486 )
6488 return await self._gather_openapi_tool_tasks(tasks, proxy_logging_obj)
6490 #########################################################
6491 # End of Methods that call the upstream MCP servers
6492 #########################################################
6494 def initialize_tool_name_to_mcp_server_name_mapping(self):
6495 """
6496 On startup, initialize the tool name to MCP server name mapping
6497 """
6498 try:
6499 if asyncio.get_running_loop():
6500 asyncio.create_task(self._initialize_tool_name_to_mcp_server_name_mapping())
6501 except RuntimeError as e: # no running event loop
6502 verbose_logger.exception(
6503 "No running event loop - skipping tool name to MCP server name mapping initialization: %s", e
6504 )
6506 async def _initialize_tool_name_to_mcp_server_name_mapping(self):
6507 """
6508 Call list_tools for each server and update the tool name to MCP server name mapping
6509 Note: This now handles prefixed tool names
6510 """
6511 for server in self.get_registry().values():
6512 if self._oauth_discovery_slot(server.server_id) is not None:
6513 continue
6514 if server.needs_user_oauth_token:
6515 # Skip OAuth2 servers that rely on user-provided tokens
6516 continue
6517 try:
6518 tools = await self._get_tools_from_server(server)
6519 except MCPUpstreamAuthError as e:
6520 # Pass-through servers expect a user-supplied bearer token;
6521 # at startup we have none, so an upstream 401 is normal.
6522 # Swallow it so we keep mapping the remaining servers.
6523 verbose_logger.debug(
6524 "Skipping tool name mapping for server %s due to upstream auth error: %s", server.name, e
6525 )
6526 continue
6527 except Exception as e:
6528 verbose_logger.warning(
6529 "Failed to get tools from server %s during tool name mapping initialization: %s", server.name, e
6530 )
6531 continue
6532 for tool in tools:
6533 # The tool.name here is already prefixed from _get_tools_from_server
6534 # Extract original name for mapping
6535 original_name = strip_known_server_prefix(tool.name, server)
6536 self.tool_name_to_mcp_server_name_mapping[original_name] = server.name
6537 self.tool_name_to_mcp_server_name_mapping[tool.name] = server.name
6539 def _get_mcp_server_from_tool_name(self, tool_name: str) -> MCPServer | None:
6540 """
6541 Get the MCP Server from the tool name (handles both prefixed and non-prefixed names)
6543 Args:
6544 tool_name: Tool name (can be prefixed or non-prefixed)
6546 Returns:
6547 MCPServer if found, None otherwise
6548 """
6549 registry_servers: Final = list(self.get_registry().values())
6550 prefix_to_server: Final = self._known_prefix_to_server()
6552 # First try with the original tool name
6553 if tool_name in self.tool_name_to_mcp_server_name_mapping:
6554 server_name: Final = self.tool_name_to_mcp_server_name_mapping[tool_name]
6555 normalised_lookup: Final = normalize_server_name(server_name)
6556 if normalised_lookup in prefix_to_server:
6557 return prefix_to_server[normalised_lookup]
6558 for server in registry_servers:
6559 if normalize_server_name(server.name) == normalised_lookup:
6560 return server
6562 # If not found and tool name is prefixed, extract the prefix and
6563 # match against any known form.
6564 matched: Final = match_known_server_prefix(tool_name, prefix_to_server.keys())
6565 if matched is not None:
6566 matched_prefix, original_tool_name = matched
6567 matched_server: Final = prefix_to_server.get(matched_prefix)
6568 if matched_server is not None and (
6569 self.server_exposes_tool(matched_server, original_tool_name)
6570 or is_mcp_stdio_blocked(matched_server.transport)
6571 ):
6572 return matched_server
6574 return None
6576 async def reload_servers_from_database(self):
6577 """Re-synchronize the in-memory MCP server registry with the database."""
6578 from litellm.proxy.management_endpoints.mcp_management_endpoints import (
6579 get_prisma_client_or_throw,
6580 )
6582 verbose_logger.debug("Loading MCP servers from database into registry...")
6583 self._upstream_initialize_instructions_by_server_id.clear()
6584 self._upstream_initialize_instructions_probed_at.clear()
6586 # perform authz check to filter the mcp servers user has access to
6587 prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
6588 # Load only "active", legacy "approved", and NULL (no approval workflow) rows.
6589 # Pending/rejected servers are excluded at the DB level so we never load them.
6590 from litellm.proxy._experimental.mcp_server.db import LiteLLM_MCPServerTable
6592 raw_rows: Final[Sequence[BaseModel]] = await MCPServerRepository(prisma_client).table.find_many(
6593 where={
6594 "OR": [
6595 {"approval_status": None},
6596 {"approval_status": {"in": ["active", "approved"]}},
6597 ]
6598 }
6599 )
6600 verbose_logger.info("Found %s MCP servers in database", len(raw_rows))
6602 previous_registry: Final = self.registry
6603 new_registry: Final[dict[str, MCPServer]] = {}
6605 # Stage one: build every server. Stage two assigns short prefixes
6606 # against the *full* set so dedup is deterministic regardless of
6607 # iteration order.
6608 for row in raw_rows:
6609 try:
6610 server = LiteLLM_MCPServerTable.model_validate(row.model_dump())
6611 existing_server = previous_registry.get(server.server_id)
6613 if (
6614 existing_server is not None
6615 and existing_server.updated_at is not None
6616 and server.updated_at is not None
6617 and existing_server.updated_at == server.updated_at
6618 and (
6619 self._oauth_discovery_slot(server.server_id) is not None
6620 or not _oauth_endpoints_unresolved(existing_server)
6621 )
6622 ):
6623 # Re-use existing server instance to avoid re-running build_mcp_server_from_table()
6624 # which can perform network discovery for OAuth2 servers.
6625 new_registry[server.server_id] = existing_server
6626 continue
6628 _warn_on_server_name_fields(
6629 server_id=server.server_id,
6630 alias=getattr(server, "alias", None),
6631 server_name=getattr(server, "server_name", None),
6632 )
6633 self._warn_if_newly_blocked_stdio(server, existing_server)
6634 verbose_logger.debug("Building server from DB: %s (%s)", server.server_id, server.server_name)
6635 # raw_rows come straight from the DB, so their global env var
6636 # values (like credentials) are still encrypted here, unlike the
6637 # already-decrypted records add_server/update_server are handed.
6638 # Decrypt them while building the registry entry.
6639 new_server = await self.build_mcp_server_from_table(server, env_vars_are_encrypted=True)
6640 # Carry the cached short_prefix from the previous registry entry
6641 # (if any) so the prefix is stable across reloads.
6642 if existing_server is not None and existing_server.short_prefix: 6642 ↛ 6643line 6642 didn't jump to line 6643 because the condition on line 6642 was never true
6643 new_server.short_prefix = existing_server.short_prefix
6644 _carry_forward_resolved_oauth_endpoints(new_server=new_server, previous_server=existing_server)
6645 new_registry[server.server_id] = new_server
6646 except Exception as e:
6647 verbose_logger.exception(
6648 "Skipping MCP server %s (%s) during DB reload: %s",
6649 getattr(row, "server_id", None),
6650 getattr(row, "alias", None),
6651 e,
6652 )
6654 # Assign short prefixes against the full candidate set without
6655 # publishing the staged registry to concurrent callers.
6656 registered_registry: Final[dict[str, MCPServer]] = {}
6657 registered_openapi_tools = False
6658 for server_id, new_server in new_registry.items():
6659 try:
6660 self._assign_unique_short_prefix(new_server, registry=new_registry)
6661 # Register OpenAPI tools *after* the final short prefix is assigned
6662 # so the tools are stored in the global registry under the same
6663 # prefix that lookups will use.
6664 await self._maybe_register_openapi_tools(new_server, initialize_mapping=False)
6665 registered_registry[server_id] = new_server
6666 if new_server.spec_path: 6666 ↛ 6667line 6666 didn't jump to line 6667 because the condition on line 6666 was never true
6667 registered_openapi_tools = True
6668 except Exception as e:
6669 verbose_logger.exception(
6670 "Skipping MCP server %s (%s) during DB reload: %s",
6671 new_server.server_id,
6672 getattr(new_server, "alias", None),
6673 e,
6674 )
6676 dropped_registry_keys: Final = previous_registry.keys() - registered_registry.keys()
6677 for registry_key in dropped_registry_keys:
6678 self._invalidate_oauth_discovery_state(previous_registry[registry_key].server_id)
6680 for server_id in previous_registry.keys() | registered_registry.keys():
6681 if previous_registry.get(server_id) != registered_registry.get(server_id):
6682 self._invalidate_discovery_lists(server_id)
6683 self.registry = registered_registry
6684 _warn_on_shared_identifier_prefixes(registered_registry.values())
6685 # A discovery task may have published into ``previous_registry`` while
6686 # this replacement was being staged. Reconcile every published entry
6687 # synchronously after the swap so a lost publication cannot also leave
6688 # the replacement unresolved with no retry slot.
6689 registered_servers: Final = tuple(registered_registry.values())
6690 self._reconcile_oauth_discovery_slots_for_servers(registered_servers)
6691 self._prime_oauth_metadata_discovery_for_servers(registered_servers)
6692 if registered_openapi_tools: 6692 ↛ 6693line 6692 didn't jump to line 6693 because the condition on line 6692 was never true
6693 self.initialize_tool_name_to_mcp_server_name_mapping()
6695 verbose_logger.debug("MCP registry refreshed (%s servers in registry)", len(registered_registry))
6697 # get_registry() is ``config_mcp_servers | registry``, so a database row sharing an id with a
6698 # config.yaml server hides that server everywhere. Only reachable once an operator pins
6699 # ``server_id`` in config.yaml; say so rather than letting the server disappear silently.
6700 shadowed_config_server_ids: Final = frozenset(self.config_mcp_servers.keys() & registered_registry.keys())
6701 if shadowed_config_server_ids and shadowed_config_server_ids != self._warned_shadowed_config_server_ids: 6701 ↛ 6702line 6701 didn't jump to line 6702 because the condition on line 6701 was never true
6702 verbose_logger.warning(
6703 "config.yaml MCP server_id(s) %s are also database-backed MCP servers. The database "
6704 "entry takes precedence, so the config.yaml server is unreachable. Give the config "
6705 "entry a different server_id.",
6706 ", ".join(sorted(shadowed_config_server_ids)),
6707 )
6708 self._warned_shadowed_config_server_ids = shadowed_config_server_ids
6710 # The mirror image of the block above: a config server_id that is a database server's name
6711 # answers that server's grants instead, because ids are matched before names.
6712 capturing_config_server_ids: Final = _config_ids_capturing_db_identifiers(
6713 self.config_mcp_servers.keys(), registered_registry.values()
6714 )
6715 if capturing_config_server_ids and capturing_config_server_ids != self._warned_capturing_config_server_ids: 6715 ↛ 6716line 6715 didn't jump to line 6716 because the condition on line 6715 was never true
6716 verbose_logger.warning(
6717 "config.yaml MCP server_id(s) %s are the name or alias of a database-backed MCP "
6718 "server. Permission entries naming them resolve to the config.yaml server, not the "
6719 "database one. Give the config entry a different server_id.",
6720 ", ".join(sorted(capturing_config_server_ids)),
6721 )
6722 self._warned_capturing_config_server_ids = capturing_config_server_ids
6724 await self._hydrate_config_servers_dcr_clients()
6726 def get_mcp_servers_from_ids(self, server_ids: list[str]) -> list[MCPServer]:
6727 servers: Final = []
6728 registry: Final = self.get_registry()
6729 for server in registry.values():
6730 if server.server_id in server_ids:
6731 servers.append(server)
6732 return servers
6734 def _get_general_settings(self) -> dict[str, Any]:
6735 """Get general_settings, importing lazily to avoid circular imports."""
6736 try:
6737 from litellm.proxy.proxy_server import (
6738 general_settings as proxy_general_settings,
6739 )
6741 return proxy_general_settings
6742 except ImportError:
6743 # Fallback if proxy_server not available
6744 return {}
6746 def _is_server_accessible_from_ip(self, server: MCPServer, client_ip: str | None) -> bool:
6747 """
6748 Check if a server is accessible from the given client IP.
6750 - If client_ip is None, no IP filtering is applied (internal callers).
6751 - If the server has available_on_public_internet=True, it's always accessible.
6752 - Otherwise, only internal/private IPs can access it.
6753 """
6754 if client_ip is None:
6755 return True
6756 if server.available_on_public_internet:
6757 return True
6758 # Check backwards compat: litellm.public_mcp_servers
6759 public_ids: Final = set(litellm.public_mcp_servers or [])
6760 if server.server_id in public_ids: 6760 ↛ 6761line 6760 didn't jump to line 6761 because the condition on line 6760 was never true
6761 return True
6762 # Non-public server: only accessible from internal IPs
6763 general_settings: Final = self._get_general_settings()
6764 internal_networks = IPAddressUtils.parse_internal_networks(general_settings.get("mcp_internal_ip_ranges"))
6765 return IPAddressUtils.is_internal_ip(client_ip, internal_networks)
6767 def get_mcp_server_by_id(self, server_id: str, client_ip: str | None = None) -> MCPServer | None:
6768 """Get the MCP Server from the server id."""
6769 registry: Final = self.get_registry()
6770 for server in registry.values():
6771 if server.server_id == server_id:
6772 if not self._is_server_accessible_from_ip(server, client_ip): 6772 ↛ 6773line 6772 didn't jump to line 6773 because the condition on line 6772 was never true
6773 return None
6774 return server
6775 return None
6777 def get_public_mcp_servers(self) -> list[MCPServer]:
6778 """
6779 Return the MCP servers published to the AI Hub via /v1/mcp/make_public.
6781 Default (litellm.public_mcp_hub_strict_whitelist=True): mirrors
6782 /public/model_hub and /public/agent_hub — gates strictly on the
6783 litellm.public_mcp_servers whitelist. Returns an empty list when no
6784 servers have been published. The per-server available_on_public_internet
6785 flag is unrelated — it governs IP-based access in
6786 _is_server_accessible_from_ip, not hub visibility.
6788 Legacy (litellm.public_mcp_hub_strict_whitelist=False): preserves the
6789 pre-fix behavior where any server with available_on_public_internet=True
6790 is also included. Intended as a one-release migration window for
6791 deployments that relied on the OR-with-default semantics; will be
6792 removed in a future release.
6793 """
6794 if litellm.public_mcp_hub_strict_whitelist: 6794 ↛ 6800line 6794 didn't jump to line 6800 because the condition on line 6794 was always true
6795 if litellm.public_mcp_servers is None: 6795 ↛ 6796line 6795 didn't jump to line 6796 because the condition on line 6795 was never true
6796 return []
6797 public_ids = set(litellm.public_mcp_servers)
6798 return [server for server in self.get_registry().values() if server.server_id in public_ids]
6800 public_ids = set(litellm.public_mcp_servers or [])
6801 return [
6802 server
6803 for server in self.get_registry().values()
6804 if server.available_on_public_internet or server.server_id in public_ids
6805 ]
6807 def expand_permission_list(self, identifiers: list[str]) -> list[str]:
6808 """
6809 Expand a permission list of server_ids/names/aliases into concrete
6810 server_ids against the current region's config + DB registry union.
6812 Entries that match a server_id pass through unchanged. Entries that
6813 match an alias/server_name/name are replaced with every matching
6814 server_id (duplicate names grant access to all matches). Entries
6815 that resolve to nothing pass through as-is and a debug log is
6816 emitted so admins can diagnose stale/typo permission entries — the
6817 downstream access-check denies them when compared against the
6818 concrete request server_id.
6819 """
6820 if not identifiers:
6821 return []
6822 registry: Final = self.get_registry()
6823 expanded: Final[set[str]] = set()
6824 for identifier in identifiers:
6825 if identifier in registry: 6825 ↛ 6826line 6825 didn't jump to line 6826 because the condition on line 6825 was never true
6826 expanded.add(identifier)
6827 continue
6828 matches: list[str] = [
6829 server_id
6830 for server_id, server in registry.items()
6831 if server.alias == identifier or server.server_name == identifier or server.name == identifier
6832 ]
6833 if matches: 6833 ↛ 6834line 6833 didn't jump to line 6834 because the condition on line 6833 was never true
6834 expanded.update(matches)
6835 else:
6836 # %r quotes and escapes control chars so an admin-controlled
6837 # identifier with newlines cannot forge log lines.
6838 verbose_logger.debug(
6839 "MCP permission entry %r does not resolve to any known "
6840 "server (config + DB union). Passing through — the "
6841 "downstream access check will deny it if it's stale.",
6842 identifier,
6843 )
6844 expanded.add(identifier)
6845 return list(expanded)
6847 def expand_tool_permissions(
6848 self,
6849 tool_permissions: dict[str, list[str]] | None,
6850 ) -> dict[str, list[str]]:
6851 """
6852 Rewrite an ``mcp_tool_permissions`` dict keyed by id/name/alias so
6853 every key is a concrete server_id where possible. Tool lists from
6854 keys that point at the same server are unioned and deduplicated
6855 first-seen, matching the "duplicate names grant access to all
6856 matches" semantics of ``expand_permission_list``; the
6857 ``MCP_ALL_TOOLS_WILDCARD`` entry is preserved as an ordinary list
6858 entry for the caller to interpret.
6860 Required so name-based keys don't silently drop their tool
6861 restrictions when the lookup uses the resolved server_id. Unresolved
6862 keys pass through via ``expand_permission_list`` so stale id-keyed
6863 restrictions still apply when the same string is used for lookup.
6864 """
6865 if not tool_permissions:
6866 return {}
6867 expanded: Final = tuple(
6868 (server_id, tuple(tools or ()))
6869 for key, tools in tool_permissions.items()
6870 for server_id in self.expand_permission_list([key])
6871 )
6872 return {
6873 server_id: list(dict.fromkeys(tool for _, tools in group for tool in tools))
6874 for server_id, group in groupby(sorted(expanded, key=itemgetter(0)), key=itemgetter(0))
6875 }
6877 def get_mcp_server_by_name(self, server_name: str, client_ip: str | None = None) -> MCPServer | None:
6878 """
6879 Get the MCP Server from the server name.
6881 Uses priority-based matching to avoid collisions:
6882 1. First pass: exact alias match (highest priority)
6883 2. Second pass: exact server_name match
6884 3. Third pass: exact name match (lowest priority)
6886 Args:
6887 server_name: The server name to look up.
6888 client_ip: Optional client IP for access control. When provided,
6889 non-public servers are hidden from external IPs.
6890 """
6891 registry: Final = self.get_registry()
6892 # Pass 1: Match by alias (highest priority)
6893 for server in registry.values():
6894 if server.alias == server_name: 6894 ↛ 6895line 6894 didn't jump to line 6895 because the condition on line 6894 was never true
6895 if not self._is_server_accessible_from_ip(server, client_ip):
6896 return None
6897 return server
6898 # Pass 2: Match by server_name
6899 for server in registry.values():
6900 if server.server_name == server_name: 6900 ↛ 6901line 6900 didn't jump to line 6901 because the condition on line 6900 was never true
6901 if not self._is_server_accessible_from_ip(server, client_ip):
6902 return None
6903 return server
6904 # Pass 3: Match by name (lowest priority)
6905 for server in registry.values():
6906 if server.name == server_name:
6907 if not self._is_server_accessible_from_ip(server, client_ip): 6907 ↛ 6908line 6907 didn't jump to line 6908 because the condition on line 6907 was never true
6908 return None
6909 return server
6910 return None
6912 def get_filtered_registry(self, client_ip: str | None = None) -> dict[str, MCPServer]:
6913 """
6914 Get registry filtered by client IP access control.
6916 Args:
6917 client_ip: Optional client IP. When provided, non-public servers
6918 are hidden from external IPs. When None, returns all servers.
6919 """
6920 registry: Final = self.get_registry()
6921 if client_ip is None: 6921 ↛ 6922line 6921 didn't jump to line 6922 because the condition on line 6921 was never true
6922 return registry
6923 return {k: v for k, v in registry.items() if self._is_server_accessible_from_ip(v, client_ip)}
6925 def _generate_stable_server_id(
6926 self,
6927 server_name: str,
6928 url: str,
6929 transport: str,
6930 auth_type: str | None = None,
6931 alias: str | None = None,
6932 ) -> str:
6933 """
6934 Generate a stable server ID based on server parameters using a hash function.
6936 This is critical to ensure the server_id is stable across server restarts.
6937 Some users store MCPs on the config.yaml and permission management is based on server_ids.
6939 Eg a key might have mcp_servers = ["1234"], if the server_id changes across restarts, the key will no longer have access to the MCP.
6941 Args:
6942 server_name: Name of the server
6943 url: Server URL
6944 transport: Transport type (sse, http, etc.)
6945 auth_type: Authentication type (optional)
6946 alias: Server alias (optional)
6948 Returns:
6949 A deterministic server ID string
6950 """
6951 # Create a string from all the identifying parameters
6952 params_string: Final = f"{server_name}|{url}|{transport}|{auth_type or ''}|{alias or ''}"
6954 # Generate SHA-256 hash
6955 hash_object: Final = hashlib.sha256(params_string.encode("utf-8"))
6956 hash_hex: Final = hash_object.hexdigest()
6958 # Take first 32 characters and format as UUID-like string
6959 return hash_hex[:32]
6961 async def health_check_server(self, server_id: str, mcp_auth_header: str | None = None) -> LiteLLM_MCPServerTable:
6962 """
6963 Perform a health check on a specific MCP server.
6965 Args:
6966 server_id: The ID of the server to health check
6967 mcp_auth_header: Optional authentication header for the MCP server
6969 Returns:
6970 Dict containing health check results
6971 """
6972 from datetime import datetime
6974 server: Final = self.get_mcp_server_by_id(server_id)
6975 if not server:
6976 verbose_logger.warning("MCP Server %s not found", server_id)
6977 return LiteLLM_MCPServerTable(
6978 server_id=server_id,
6979 server_name=None,
6980 transport=MCPTransport.http, # Default transport for not found servers
6981 status="unknown",
6982 health_check_error="Server not found",
6983 last_health_check=datetime.now(),
6984 )
6986 if server.spec_path: 6986 ↛ 6987line 6986 didn't jump to line 6987 because the condition on line 6986 was never true
6987 spec_status, spec_error, spec_checked_at = await self._openapi_health_probes(server.spec_path).check()
6988 return self._build_mcp_server_table(server).model_copy(
6989 update=MappingProxyType(
6990 {
6991 "status": spec_status,
6992 "health_check_error": spec_error,
6993 "last_health_check": spec_checked_at,
6994 }
6995 )
6996 )
6998 status: Literal["healthy", "unhealthy", "unknown"] = "unknown"
6999 health_check_error = None
7001 # Check if we should skip health check based on auth configuration
7002 should_skip_health_check = False
7004 # Skip if server requires per-user authentication (OAuth2 or passthrough auth)
7005 if (
7006 server.requires_per_user_auth
7007 or (
7008 server.auth_type
7009 and server.auth_type != MCPAuth.none
7010 and server.auth_type != MCPAuth.aws_sigv4
7011 and not server.authentication_token
7012 )
7013 or self._references_per_user_env_var(server)
7014 ):
7015 should_skip_health_check = True
7017 if not should_skip_health_check:
7018 try:
7019 resolved_static_headers: Final = await self._resolve_static_headers_with_env_vars(
7020 server=server,
7021 user_api_key_auth=None,
7022 raise_on_missing=False,
7023 )
7024 extra_headers: Final = dict(resolved_static_headers) if resolved_static_headers else {}
7025 client: Final = await self._create_mcp_client(
7026 server=server,
7027 mcp_auth_header=None,
7028 extra_headers=extra_headers,
7029 stdio_env=None,
7030 )
7032 async def _noop(session):
7033 return "ok"
7035 # Add timeout wrapper to prevent hanging
7036 await asyncio.wait_for(client.run_with_session(_noop), timeout=MCP_HEALTH_CHECK_TIMEOUT)
7037 self._remember_upstream_initialize_instructions(server, client)
7038 status = "healthy"
7039 except asyncio.TimeoutError:
7040 health_check_error = f"Health check timed out after {MCP_HEALTH_CHECK_TIMEOUT} seconds"
7041 status = "unhealthy"
7042 except asyncio.CancelledError:
7043 health_check_error = "Health check was cancelled"
7044 status = "unknown"
7045 except Exception as e:
7046 health_check_error = str(e)
7047 status = "unhealthy"
7049 return LiteLLM_MCPServerTable(
7050 server_id=server.server_id,
7051 server_name=server.server_name,
7052 alias=server.alias,
7053 description=(server.mcp_info.get("description") if server.mcp_info else None),
7054 url=server.url,
7055 transport=server.transport,
7056 auth_type=server.auth_type,
7057 created_at=server.created_at,
7058 updated_at=server.updated_at,
7059 teams=[],
7060 mcp_access_groups=server.access_groups or [],
7061 allowed_tools=server.allowed_tools or [],
7062 extra_headers=server.extra_headers or [],
7063 mcp_info=server.mcp_info,
7064 static_headers=server.static_headers,
7065 env_vars=self._env_vars_to_models(server.env_vars),
7066 status=status,
7067 last_health_check=datetime.now(),
7068 health_check_error=health_check_error,
7069 command=getattr(server, "command", None),
7070 args=getattr(server, "args", None) or [],
7071 env=getattr(server, "env", None) or {},
7072 issuer=server.issuer,
7073 authorization_url=server.configured_authorization_url or server.authorization_url,
7074 token_url=server.configured_token_url or server.token_url,
7075 registration_url=server.configured_registration_url or server.registration_url,
7076 oauth2_flow=server.oauth2_flow,
7077 dcr_bridge=server.dcr_bridge,
7078 per_server_oauth_discovery=server.per_server_oauth_discovery,
7079 token_exchange_endpoint=server.token_exchange_endpoint,
7080 audience=server.audience,
7081 subject_token_type=server.subject_token_type,
7082 token_exchange_profile=server.token_exchange_profile,
7083 allow_all_keys=server.allow_all_keys,
7084 instructions=server.instructions,
7085 timeout=server.timeout,
7086 max_concurrent_requests=server.max_concurrent_requests,
7087 )
7089 async def get_all_mcp_servers_with_health_and_teams(
7090 self,
7091 user_api_key_auth: UserAPIKeyAuth | None = None,
7092 server_ids: list[str] | None = None,
7093 ) -> list[LiteLLM_MCPServerTable]:
7094 """
7095 Get all MCP servers that the user has access to, with health status and team information.
7097 Args:
7098 user_api_key_auth: User authentication info for access control
7099 server_ids: Optional list of server IDs to filter. If provided, only these servers
7100 will be checked (subject to access control). If None, all accessible servers are checked.
7102 Returns:
7103 List of MCP server objects with health and team data
7104 """
7106 # Get allowed server IDs
7107 allowed_server_ids: Final = await self.get_allowed_mcp_servers(user_api_key_auth)
7109 # Filter by requested server_ids if provided
7110 if server_ids:
7111 # Only check servers that are both requested AND accessible
7112 target_server_ids = [sid for sid in server_ids if sid in allowed_server_ids]
7113 else:
7114 # Check all accessible servers
7115 target_server_ids = allowed_server_ids
7117 return await self._run_health_checks(target_server_ids)
7119 async def get_all_allowed_mcp_servers(
7120 self,
7121 user_api_key_auth: UserAPIKeyAuth | None = None,
7122 ) -> list[LiteLLM_MCPServerTable]:
7123 """
7124 Get all MCP servers that the user has access to.
7126 Args:
7127 user_api_key_auth: User authentication info for access control
7129 Returns:
7130 List of MCP server objects without health status
7131 """
7132 # Get allowed server IDs
7133 allowed_server_ids: Final = await self.get_allowed_mcp_servers(user_api_key_auth)
7135 list_mcp_servers: Final[list[LiteLLM_MCPServerTable]] = []
7137 for server_id in allowed_server_ids:
7138 server = self.get_mcp_server_by_id(server_id)
7139 if not server: 7139 ↛ 7140line 7139 didn't jump to line 7140 because the condition on line 7139 was never true
7140 verbose_logger.warning("MCP Server %s not found in registry", server_id)
7141 continue
7143 mcp_server_table = self._build_mcp_server_table(server)
7144 list_mcp_servers.append(mcp_server_table)
7146 return list_mcp_servers
7148 @staticmethod
7149 def _env_vars_to_models(
7150 env_vars: _EnvVarList | None,
7151 ) -> list[MCPEnvVar] | None:
7152 if env_vars is None:
7153 return None
7154 return [MCPEnvVar.model_validate(env_var) for env_var in env_vars]
7156 def _build_mcp_server_table(self, server: MCPServer) -> LiteLLM_MCPServerTable:
7157 return LiteLLM_MCPServerTable(
7158 server_id=server.server_id,
7159 is_config=self.is_config_declared_server(server.server_id) and server.server_id not in self.registry,
7160 server_name=server.server_name,
7161 alias=server.alias,
7162 description=(server.mcp_info.get("description") if server.mcp_info else None),
7163 url=server.url,
7164 spec_path=server.spec_path,
7165 transport=server.transport,
7166 auth_type=server.auth_type,
7167 credentials=(
7168 {"scopes": list(server.configured_scopes)} # mutable-ok: MCPCredentials requires a JSON-array list
7169 if server.configured_scopes
7170 else None
7171 ),
7172 created_at=server.created_at,
7173 updated_at=server.updated_at,
7174 teams=[],
7175 mcp_access_groups=server.access_groups or [],
7176 allowed_tools=server.allowed_tools or [],
7177 tool_name_to_display_name=server.tool_name_to_display_name,
7178 tool_name_to_description=server.tool_name_to_description,
7179 extra_headers=server.extra_headers or [],
7180 mcp_info=server.mcp_info,
7181 static_headers=server.static_headers,
7182 env_vars=self._env_vars_to_models(server.env_vars),
7183 status=None, # No health check performed
7184 last_health_check=None, # No health check performed
7185 health_check_error=None,
7186 command=getattr(server, "command", None),
7187 args=getattr(server, "args", None) or [],
7188 env=getattr(server, "env", None) or {},
7189 issuer=server.issuer,
7190 authorization_url=server.configured_authorization_url or server.authorization_url,
7191 token_url=server.configured_token_url or server.token_url,
7192 registration_url=server.configured_registration_url or server.registration_url,
7193 oauth2_flow=server.oauth2_flow,
7194 token_exchange_endpoint=server.token_exchange_endpoint,
7195 audience=server.audience,
7196 subject_token_type=server.subject_token_type,
7197 token_exchange_profile=server.token_exchange_profile,
7198 allow_all_keys=server.allow_all_keys,
7199 available_on_public_internet=server.available_on_public_internet,
7200 delegate_auth_to_upstream=server.delegate_auth_to_upstream,
7201 oauth_passthrough=getattr(server, "oauth_passthrough", False),
7202 dcr_bridge=server.dcr_bridge,
7203 per_server_oauth_discovery=server.per_server_oauth_discovery,
7204 is_byok=server.is_byok,
7205 byok_description=server.byok_description,
7206 byok_api_key_help_url=server.byok_api_key_help_url,
7207 source_url=server.source_url,
7208 instructions=server.instructions,
7209 timeout=server.timeout,
7210 max_concurrent_requests=server.max_concurrent_requests,
7211 )
7213 async def get_all_mcp_servers_unfiltered(self) -> list[LiteLLM_MCPServerTable]:
7214 """Return all MCP servers from registry without applying access controls."""
7216 registry: Final = self.get_registry()
7217 if not registry:
7218 return []
7220 servers: Final[list[LiteLLM_MCPServerTable]] = []
7221 for server in registry.values():
7222 servers.append(self._build_mcp_server_table(server))
7223 return servers
7225 async def get_all_mcp_servers_with_health_unfiltered(
7226 self, server_ids: list[str] | None = None
7227 ) -> list[LiteLLM_MCPServerTable]:
7228 """Return health info for all servers in registry regardless of user access."""
7230 registry: Final = self.get_registry()
7231 if not registry:
7232 return []
7234 if server_ids:
7235 target_server_ids = [sid for sid in server_ids if sid in registry]
7236 else:
7237 target_server_ids = list(registry.keys())
7239 if not target_server_ids:
7240 return []
7242 return await self._run_health_checks(target_server_ids)
7244 async def _run_health_checks(self, target_server_ids: list[str]) -> list[LiteLLM_MCPServerTable]:
7245 if not target_server_ids:
7246 return []
7248 tasks: Final = [self.health_check_server(server_id) for server_id in target_server_ids]
7249 results: Final = await asyncio.gather(*tasks)
7250 return [server for server in results if server is not None]
7253global_mcp_server_manager: Final[MCPServerManager] = MCPServerManager()