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

1""" 

2MCP Client Manager 

3 

4This class is responsible for managing MCP clients with support for both SSE and HTTP streamable transports. 

5 

6This is a Proxy 

7""" 

8 

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 

35 

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 

53 

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 

204 

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 

208 

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 

216 

217 SEP_986_URL = "https://github.com/modelcontextprotocol/protocol/blob/main/proposals/0001-tool-name-validation.md" 

218 

219 class _ToolNameValidationResult(BaseModel): 

220 is_valid: bool = True 

221 warnings: list = [] 

222 

223 def validate_tool_name(name: str) -> _ToolNameValidationResult: 

224 return _ToolNameValidationResult() 

225 

226 

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 ) 

236 

237_AZURE_ENTRA_HOSTS: Final = { 

238 "login.microsoftonline.com", # Global 

239 "login.microsoftonline.us", # US Government 

240 "login.chinacloudapi.cn", # China 

241} 

242 

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 

250 

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) 

260 

261 

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 

268 

269 

270def _oauth_discovery_now() -> float: 

271 return time.monotonic() 

272 

273 

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 ) 

280 

281 

282def _mcp_oauth_discovery_on_startup_enabled() -> bool: 

283 """Return whether remote MCP OAuth metadata is discovered during registration. 

284 

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 

290 

291 

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) 

298 

299 

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] 

306 

307 

308@dataclass(frozen=True, slots=True) 

309class _OAuthDiscoveryResolved: 

310 server: MCPServer 

311 

312 

313@dataclass(frozen=True, slots=True) 

314class _OAuthDiscoveryFailed: 

315 server_id: str 

316 timed_out: bool 

317 

318 

319@dataclass(frozen=True, slots=True) 

320class _OAuthDiscoveryStale: 

321 server_id: str 

322 

323 

324_OAuthDiscoveryOutcome: TypeAlias = _OAuthDiscoveryResolved | _OAuthDiscoveryFailed | _OAuthDiscoveryStale 

325 

326 

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 

334 

335 

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

340 

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 

399 

400 

401class _ProtectedResourceMetadataPayload(TypedDict, total=False): 

402 """The RFC 9728 protected-resource metadata document fields this gateway reads.""" 

403 

404 authorization_servers: Sequence[object] 

405 scopes_supported: Sequence[str] 

406 scopes: Sequence[str] 

407 

408 

409class _AuthorizationServerMetadataPayload(TypedDict, total=False): 

410 """The RFC 8414 / OpenID Discovery authorization-server metadata fields this gateway reads.""" 

411 

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] 

419 

420 

421def _blank_to_none(value: str | None) -> str | None: 

422 """Collapse an absent, empty, or whitespace-only string to ``None``. 

423 

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 

434 

435 

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 

459 

460 

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. 

463 

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. 

469 

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

481 

482 

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. 

485 

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 ) 

495 

496 

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. 

501 

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 

507 

508 

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. 

511 

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) 

517 

518 

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. 

524 

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. 

529 

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. 

534 

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 ) 

549 

550 

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. 

556 

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. 

561 

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 ) 

574 

575 

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. 

585 

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. 

592 

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 ) 

617 

618 

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

621 

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 

629 

630 

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. 

633 

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 

643 

644 

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 

684 

685 

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) 

692 

693 

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) 

703 

704 

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. 

713 

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 

730 

731 

732def _oauth_endpoints_unresolved(server: MCPServer) -> bool: 

733 """``_flow_endpoints_missing`` over a built registry entry, for the reload fast-path check. 

734 

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 ) 

765 

766 

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. 

773 

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) 

789 

790 

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. 

793 

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. 

802 

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. 

809 

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 

841 

842 

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

851 

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

887 

888 

889def _redacted_origin_list(urls: Sequence[str]) -> str: 

890 return ", ".join(_redact_mcp_resource_url(url) or "<unparseable url>" for url in urls) 

891 

892 

893def _sanitized_error_text(exc: Exception) -> str: 

894 return re.sub(r"https?://\S+", "<url>", str(exc))[:200] 

895 

896 

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 

903 

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 

917 

918 

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 

926 

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 

942 

943 

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) 

952 

953 

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

969 

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 ) 

1017 

1018 

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) 

1023 

1024 

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

1034 

1035 

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. 

1043 

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. 

1047 

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 

1089 

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 ) 

1098 

1099 

1100LITELLM_VIRTUAL_KEY_PREFIX: Final = "sk-" 

1101 

1102 

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) 

1105 

1106 

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

1110 

1111 

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. 

1117 

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) 

1126 

1127 

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. 

1130 

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

1140 

1141 

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 

1165 

1166 

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. 

1175 

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

1182 

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 ) 

1200 

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 

1215 

1216 

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 

1225 

1226 from litellm.proxy._experimental.mcp_server.operations import ( 

1227 _check_byok_credential, 

1228 _get_byok_credential, 

1229 ) 

1230 

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 

1248 

1249 await _check_byok_credential(mcp_server, user_api_key_auth) 

1250 return mcp_auth_header 

1251 

1252 

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

1260 

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 

1274 

1275 

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) 

1286 

1287 

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. 

1292 

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 

1303 

1304 

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. 

1307 

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 

1331 

1332 

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 ) 

1345 

1346 

1347_REGISTRY_DUMP_SECRET_FIELDS: Final = frozenset( 

1348 {"authentication_token", "client_secret", "client_private_key", "aws_secret_access_key", "aws_session_token"} 

1349) 

1350 

1351 

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. 

1354 

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 } 

1367 

1368 

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. 

1371 

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 

1386 

1387 

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 ) 

1403 

1404 

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

1409 

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) 

1416 

1417 

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

1421 

1422 

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. 

1425 

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) 

1435 

1436 

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 

1449 

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 ) 

1458 

1459 _warn("alias", alias) 

1460 _warn("server_name", server_name) 

1461 

1462 

1463def _warn_on_shared_identifier_prefixes(servers: Iterable[MCPServer]) -> None: 

1464 """Warn once per identifier that several servers share. 

1465 

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 ) 

1490 

1491 

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 ) 

1509 

1510 

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 ) 

1523 

1524 

1525def _deserialize_json_dict(data: str | _StringMap | None) -> dict[str, str] | None: 

1526 """ 

1527 Deserialize optional JSON mappings stored in the database. 

1528 

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 

1534 

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 

1544 

1545 

1546def _deserialize_json_list(data: object) -> list[dict[str, Any]] | None: 

1547 """Deserialize a JSON array stored in the DB (``env_vars`` and friends). 

1548 

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] 

1566 

1567 

1568def _normalize_mcp_server_cost_info(mcp_info: MCPInfo) -> None: 

1569 """Coerce ``mcp_server_cost_info`` numeric fields to ``float`` at ingest. 

1570 

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 

1580 

1581 server_name: Final = mcp_info.get("server_name") 

1582 normalized: Final = dict(cost_info) 

1583 

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

1595 

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 

1610 

1611 mcp_info["mcp_server_cost_info"] = normalized 

1612 

1613 

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 

1623 

1624 return create_sampling_callback(user_api_key_auth, raw_headers, client_ip, operation_context) 

1625 

1626 

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 

1631 

1632 return create_elicitation_callback() 

1633 

1634 

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. 

1640 

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. 

1647 

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 

1654 

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) 

1662 

1663 

1664_DiscoveryItem = TypeVar("_DiscoveryItem", bound=BaseModel) 

1665_DiscoveryKey: TypeAlias = tuple[str, str | None] 

1666_DISCOVERY_CACHE_LIMIT: Final = 1024 

1667 

1668 

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 

1680 

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) 

1692 

1693 @staticmethod 

1694 def _observe_completion(task: asyncio.Task[list[_DiscoveryItem]]) -> None: 

1695 if not task.cancelled(): 

1696 task.exception() 

1697 

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) 

1715 

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

1732 

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) 

1748 

1749 

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 

1761 

1762 

1763class MCPServerManager: 

1764 _STDIO_ENV_TEMPLATE_PATTERN = re.compile(r"^\$\{(X-[^}]+)\}$") 

1765 

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 

1780 

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. 

1792 

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 

1816 

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. 

1820 

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 ) 

1836 

1837 @staticmethod 

1838 def resolve_oauth2_flow_for_request(server: "MCPServer") -> "MCPServer": 

1839 """Return ``server`` with its effective oauth2_flow applied, for egress paths. 

1840 

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

1861 

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) 

1873 

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

1917 

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, ...] = () 

1944 

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) 

1947 

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) 

1950 

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 ) 

1956 

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 ) 

1970 

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

1976 

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 

1979 

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 

2032 

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 

2045 

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 

2049 

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) 

2053 

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 

2075 

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 ) 

2103 

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

2117 

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 

2140 

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 ) 

2153 

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 

2175 

2176 def prime_oauth_metadata_discovery(self, server: MCPServer) -> None: 

2177 """Start best-effort OAuth metadata discovery for ``server``. 

2178 

2179 The call returns immediately and never delays registration. It is a no-op 

2180 when the server has no deferred discovery slot. 

2181 

2182 Args: 

2183 server: The registered MCP server to warm metadata for. 

2184 """ 

2185 self._get_or_start_oauth_discovery_task(server) 

2186 

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) 

2190 

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) 

2198 

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. 

2201 

2202 Concurrent callers share one task per server. A failed attempt remains 

2203 retryable after a per-server cooldown. 

2204 

2205 Args: 

2206 server: The MCP server whose OAuth metadata must be resolved. 

2207 

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. 

2212 

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 ) 

2243 

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

2251 

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

2256 

2257 async def _ensure_upstream_initialize_instructions_cached(self, server: MCPServer) -> None: 

2258 """ 

2259 Open one upstream session and cache InitializeResult.instructions if missing. 

2260 

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 

2291 

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 

2295 

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

2299 

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 ) 

2313 

2314 async def _noop(_session): 

2315 return "ok" 

2316 

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 ) 

2325 

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 

2331 

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 

2339 

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 

2347 

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

2355 

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) 

2363 

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) 

2375 

2376 # Use alias for name if present, else server_name 

2377 alias = server_config.get("alias", None) 

2378 

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 

2388 

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) 

2396 

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 

2416 

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

2423 

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 ) 

2470 

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 

2482 

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 

2501 

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 ) 

2514 

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 ) 

2535 

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 ) 

2548 

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 ) 

2633 

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 ) 

2643 

2644 verbose_logger.debug( 

2645 "Loaded MCP Servers: %s", json.dumps(_redacted_registry_dump(self.config_mcp_servers), indent=4) 

2646 ) 

2647 

2648 await self._hydrate_config_servers_dcr_clients() 

2649 

2650 self._prime_oauth_metadata_discovery_for_servers(tuple(self.config_mcp_servers.values())) 

2651 

2652 self.initialize_tool_name_to_mcp_server_name_mapping() 

2653 

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 ) 

2661 

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 ) 

2675 

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. 

2679 

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 

2684 

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 ) 

2702 

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) 

2706 

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) 

2711 

2712 # Get server prefix for tool naming 

2713 server_prefix: Final = get_server_prefix(server) 

2714 

2715 # Build headers from server configuration 

2716 headers: dict[str, str] = {} 

2717 

2718 # Add authentication headers if configured 

2719 if server.authentication_token: 

2720 from litellm.types.mcp import MCPAuth 

2721 

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

2730 

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 ) 

2744 

2745 verbose_logger.debug( 

2746 "Using headers for OpenAPI tools (excluding sensitive values): %s", list(headers.keys()) 

2747 ) 

2748 

2749 # Extract and register tools from OpenAPI paths 

2750 paths: Final = spec.get("paths", {}) 

2751 components: Final = spec.get("components", {}) 

2752 registered_count = 0 

2753 

2754 verbose_logger.debug("Processing %s paths from OpenAPI spec", len(paths)) 

2755 

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 

2760 

2761 operation = path_item[method] 

2762 

2763 # Resolve $ref params and merge path-level params into the operation. 

2764 resolved_operation = resolve_operation_params(operation, path_item, components) 

2765 

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) 

2769 

2770 # Add server prefix to tool name 

2771 prefixed_tool_name = add_server_prefix_to_name(base_tool_name, server_prefix) 

2772 

2773 # Get description 

2774 description = operation.get( 

2775 "summary", 

2776 operation.get("description", f"{method.upper()} {path}"), 

2777 ) 

2778 

2779 # Build input schema using imported function 

2780 input_schema = build_input_schema(resolved_operation) 

2781 

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 

2796 

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 ) 

2804 

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 

2808 

2809 registered_count += 1 

2810 verbose_logger.debug("Registered OpenAPI tool: %s for server %s", prefixed_tool_name, server.name) 

2811 

2812 verbose_logger.info("Successfully registered %s OpenAPI tools for server %s", registered_count, server.name) 

2813 

2814 except Exception as e: 

2815 verbose_logger.error("Failed to register OpenAPI tools for server %s: %s", server.name, e) 

2816 raise e 

2817 

2818 def _cleanup_server_tool_routing_artifacts(self, server: MCPServer) -> None: 

2819 """Drop OpenAPI global tools and name-mapping rows owned by ``server``. 

2820 

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 ) 

2830 

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) 

2836 

2837 owned_normalized: Final = self._owned_mapping_values(server) 

2838 

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 ) 

2844 

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] 

2847 

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 ) 

2852 

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) 

2860 

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 } 

2869 

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

2874 

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) 

2888 

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 ) 

2900 

2901 decrypt_global_env_var_values(env_vars_list) 

2902 return env_vars_list 

2903 

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 

2969 

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

2987 

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

2995 

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 

3007 

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 

3019 

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 

3031 

3032 # AWS SigV4 credential fields 

3033 aws_creds: Final = self._extract_aws_credentials(credentials_dict, credentials_are_encrypted) 

3034 

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) 

3040 

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 ) 

3052 

3053 name_for_prefix: Final = mcp_server.alias or mcp_server.server_name or mcp_server.server_id 

3054 

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) 

3061 

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 ) 

3100 

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 

3108 

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 

3205 

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

3217 

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) 

3241 

3242 except Exception as e: 

3243 verbose_logger.debug("Failed to add MCP server: %s", e) 

3244 raise e 

3245 

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) 

3278 

3279 except Exception as e: 

3280 verbose_logger.debug("Failed to udpate MCP server: %s", e) 

3281 raise e 

3282 

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} 

3289 

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] 

3293 

3294 @staticmethod 

3295 def get_byom_submitted_servers_cache_key(user_id: str) -> str: 

3296 return f"byom_submitted_servers:{user_id}" 

3297 

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 

3303 

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) 

3307 

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 [] 

3314 

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 [] 

3323 

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) 

3332 

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) 

3352 

3353 return [server_id for server_id in submitted_server_ids if self.get_mcp_server_by_id(server_id) is not None] 

3354 

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. 

3365 

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. 

3371 

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

3376 

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 

3400 

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 

3412 

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. 

3422 

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 

3429 

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

3432 

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) 

3440 

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 [] 

3450 

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) 

3457 

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 ) 

3465 

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 ) 

3489 

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) 

3505 

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 ] 

3533 

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. 

3540 

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. 

3545 

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 

3555 

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

3558 

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 

3563 

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

3581 

3582 def invalidate_toolset_cache(self, toolset_id: str | None = None) -> None: 

3583 """Evict cached toolset permission entries. 

3584 

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 

3594 

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) 

3615 

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. 

3623 

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 

3631 

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 

3643 

3644 from litellm.proxy._experimental.mcp_server.toolset_db import ( 

3645 get_mcp_toolset_by_name, 

3646 ) 

3647 

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 

3655 

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. 

3659 

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 

3664 

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. 

3668 

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 

3684 

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 [] 

3698 

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. 

3707 

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 

3713 

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) 

3718 

3719 verbose_logger.debug("SERVER MANAGER LISTING TOOLS") 

3720 

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 [] 

3727 

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 ) 

3737 

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 

3741 

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 [] 

3754 

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) 

3758 

3759 # Flatten results into single list 

3760 list_tools_result: Final[list[MCPTool]] = [tool for tools in results for tool in tools] 

3761 

3762 verbose_logger.info("Successfully fetched %s tools total from all servers", len(list_tools_result)) 

3763 return list_tools_result 

3764 

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. 

3774 

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 

3788 

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. 

3796 

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 

3806 

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. 

3814 

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) 

3822 

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

3829 

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 

3832 

3833 resolved_env: Final[dict[str, str]] = {} 

3834 normalized_headers: Final = {k.lower(): v for k, v in (raw_headers or {}).items()} 

3835 

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 

3847 

3848 return resolved_env 

3849 

3850 def _references_per_user_env_var(self, server: MCPServer) -> bool: 

3851 """True when ``server.static_headers`` reference a per-user ``${NAME}`` env var. 

3852 

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) 

3868 

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. 

3877 

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. 

3881 

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. 

3886 

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 

3897 

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} 

3905 

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 

3909 

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} 

3917 

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 ) 

3934 

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 ) 

3952 

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) 

3961 

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

3970 

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

3988 

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 

3996 

3997 from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 

3998 

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 ) 

4008 

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 

4012 

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

4024 

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) 

4081 

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. 

4090 

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. 

4096 

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) 

4137 

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. 

4152 

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 

4158 

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. 

4166 

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 

4194 

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 

4206 

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 ) 

4216 

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 ) 

4242 

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 ) 

4250 

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

4266 

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 ) 

4297 

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 ) 

4310 

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 ) 

4340 

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. 

4354 

4355 Args: 

4356 server (MCPServer): The server to query tools from 

4357 mcp_auth_header: Optional auth header for MCP server 

4358 

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 ) 

4365 

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 [] 

4368 

4369 verbose_logger.debug("Connecting to url: %s", server.url) 

4370 verbose_logger.info("_get_tools_from_server for %s...", server.name) 

4371 

4372 client = None 

4373 

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) 

4386 

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 ) 

4403 

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 ) 

4411 

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 ) 

4424 

4425 stdio_env: Final = self._build_stdio_env(server, raw_headers) 

4426 

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 ) 

4435 

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 ) 

4446 

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) 

4472 

4473 prefixed_or_original_tools: Final = self._create_prefixed_tools(tools, server, add_prefix=add_prefix) 

4474 

4475 return prefixed_or_original_tools 

4476 

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) 

4511 

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) 

4516 

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

4546 

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) 

4551 

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 

4559 

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 ) 

4598 

4599 async def fetch() -> list[Prompt]: 

4600 return await client.list_prompts(raise_on_error=True) 

4601 

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 [] 

4607 

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 ) 

4646 

4647 async def fetch() -> list[Resource]: 

4648 return await client.list_resources(raise_on_error=True) 

4649 

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 [] 

4655 

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 ) 

4694 

4695 async def fetch() -> list[ResourceTemplate]: 

4696 return await client.list_resource_templates(raise_on_error=True) 

4697 

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 [] 

4703 

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

4715 

4716 verbose_logger.debug("Connecting to url: %s", server.url) 

4717 verbose_logger.info("read_resource_from_server for %s...", server.name) 

4718 

4719 if server.static_headers: 

4720 if extra_headers is None: 

4721 extra_headers = {} 

4722 extra_headers.update(server.static_headers) 

4723 

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) 

4726 

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 ) 

4737 

4738 return await client.read_resource(url) 

4739 

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

4752 

4753 verbose_logger.debug("Connecting to url: %s", server.url) 

4754 verbose_logger.info("get_prompt_from_server for %s...", server.name) 

4755 

4756 if server.static_headers: 

4757 if extra_headers is None: 

4758 extra_headers = {} 

4759 extra_headers.update(server.static_headers) 

4760 

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) 

4763 

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 ) 

4774 

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) 

4780 

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

4785 

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 

4797 

4798 if target.scheme not in ("http", "https") or not target.hostname: 

4799 return False 

4800 

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 ) 

4808 

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) 

4821 

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

4830 

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. 

4836 

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 

4854 

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

4902 

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 ) 

4916 

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 ) 

4925 

4926 resource_metadata_url, scopes = self._parse_www_authenticate_header(header_value) 

4927 

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 ) 

4950 

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 = [] 

4961 

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 ) 

4973 

4974 attempts: Final = tuple(entry for entry in (status_attempt, lookup_attempt, fallback_attempt) if entry) 

4975 

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 

4981 

4982 return metadata, attempts 

4983 

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 

4987 

4988 _, _, params_section = header_value.partition(" ") 

4989 params_section = params_section or header_value 

4990 

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 } 

4995 

4996 resource_metadata_url: Final = params.get("resource_metadata") 

4997 

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 

5001 

5002 return resource_metadata_url, scopes 

5003 

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 

5009 

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 

5030 

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 = [] 

5036 

5037 scopes: Final = self._extract_scopes(data.get("scopes_supported") or data.get("scopes")) 

5038 

5039 return authorization_servers, scopes 

5040 

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 

5046 

5047 if not parsed.scheme or not parsed.netloc: 

5048 return [], None 

5049 

5050 base: Final = f"{parsed.scheme}://{parsed.netloc}" 

5051 path = parsed.path or "" 

5052 path = path.strip("/") 

5053 

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

5058 

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 

5066 

5067 return [], None 

5068 

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 

5077 

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. 

5082 

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. 

5090 

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

5110 

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 

5118 

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 

5121 

5122 base: Final = f"{parsed.scheme}://{parsed.netloc}" 

5123 path: Final = (parsed.path or "").strip("/") 

5124 

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

5133 

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 

5155 

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 

5175 

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 ) 

5184 

5185 if any( 

5186 [ 

5187 metadata.scopes, 

5188 metadata.authorization_url, 

5189 metadata.token_url, 

5190 metadata.registration_url, 

5191 ] 

5192 ): 

5193 return metadata 

5194 

5195 return self._build_azure_authorization_server_metadata(parsed) 

5196 

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 

5204 

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 ) 

5211 

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 

5229 

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 } 

5259 

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 

5268 

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. 

5276 

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. 

5279 

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. 

5291 

5292 Args: 

5293 client: MCP client instance 

5294 server_name: Name of the server for logging 

5295 

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) 

5318 

5319 _SHORT_PREFIX_MAX_REHASH_ATTEMPTS = 1024 

5320 

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

5327 

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. 

5333 

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 

5347 

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 

5355 

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 

5374 

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 ) 

5380 

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. 

5384 

5385 Args: 

5386 tools: List of original tools from server 

5387 server: Server instance 

5388 

5389 Returns: 

5390 List of tools with prefixed names 

5391 """ 

5392 prefixed_tools: Final = [] 

5393 prefix: Final = get_server_prefix(server) 

5394 

5395 for tool in tools: 

5396 tool_copy = tool.model_copy(deep=True) 

5397 

5398 original_name = tool_copy.name 

5399 prefixed_name = add_server_prefix_to_name(original_name, prefix) 

5400 

5401 name_to_use = prefixed_name if add_prefix else original_name 

5402 

5403 # Preserve all tool fields including metadata/_meta by avoiding mutation 

5404 tool_copy.name = name_to_use 

5405 prefixed_tools.append(tool_copy) 

5406 

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 

5412 

5413 verbose_logger.info("Successfully fetched %s tools from server %s", len(prefixed_tools), server.name) 

5414 return prefixed_tools 

5415 

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. 

5421 

5422 Args: 

5423 prompts: List of original prompts from server 

5424 server: Server instance 

5425 

5426 Returns: 

5427 List of prompts with prefixed names 

5428 """ 

5429 prefixed_prompts: Final = [] 

5430 prefix: Final = get_server_prefix(server) 

5431 

5432 for prompt in prompts: 

5433 prefixed_name = add_server_prefix_to_name(prompt.name, prefix) 

5434 

5435 name_to_use = prefixed_name if add_prefix else prompt.name 

5436 

5437 prompt.name = name_to_use 

5438 prefixed_prompts.append(prompt) 

5439 

5440 verbose_logger.info("Successfully fetched %s prompts from server %s", len(prefixed_prompts), server.name) 

5441 return prefixed_prompts 

5442 

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

5447 

5448 prefixed_resources: Final[list[Resource]] = [] 

5449 prefix: Final = get_server_prefix(server) 

5450 

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) 

5455 

5456 verbose_logger.info("Successfully fetched %s resources from server %s", len(prefixed_resources), server.name) 

5457 return prefixed_resources 

5458 

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

5466 

5467 prefixed_templates: Final[list[ResourceTemplate]] = [] 

5468 prefix: Final = get_server_prefix(server) 

5469 

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) 

5476 

5477 verbose_logger.info( 

5478 "Successfully fetched %s resource templates from server %s", len(prefixed_templates), server.name 

5479 ) 

5480 return prefixed_templates 

5481 

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. 

5485 

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 ) 

5495 

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 

5499 

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. 

5503 

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 

5509 

5510 Returns: 

5511 Filtered dictionary containing only allowed parameters 

5512 

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 

5520 

5521 allowed_params_list: Final = allowed_params[matched] 

5522 

5523 # Filter arguments to only include allowed parameters 

5524 disallowed_params: Final = [param for param in arguments if param not in allowed_params_list] 

5525 

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 ) 

5535 

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. 

5546 

5547 Args: 

5548 tool_name: Name of the tool to check 

5549 server: MCPServer object 

5550 user_api_key_auth: User authentication 

5551 

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 ) 

5558 

5559 if not user_api_key_auth: 

5560 return 

5561 

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 ) 

5568 

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 ) 

5576 

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. 

5585 

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. 

5589 

5590 Args: 

5591 tool_name: The full tool name (with prefix) to call 

5592 arguments: Tool arguments to pass to the handler 

5593 

5594 Returns: 

5595 CallToolResult with the response from the API 

5596 """ 

5597 from mcp.types import TextContent 

5598 

5599 from litellm.proxy._experimental.mcp_server.tool_registry import ( 

5600 global_mcp_tool_registry, 

5601 ) 

5602 

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 ) 

5616 

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) 

5621 

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 ) 

5627 

5628 return result 

5629 

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 ) 

5643 

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. 

5658 

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. 

5663 

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. 

5667 

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 ) 

5680 

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 ) 

5687 

5688 ## filter parameters based on allowed_params configuration 

5689 self.validate_allowed_params( 

5690 tool_name=name, 

5691 arguments=arguments, 

5692 server=server, 

5693 ) 

5694 

5695 hook_result: Final[dict[str, Any]] = {} 

5696 if proxy_logging_obj is None: 

5697 return hook_result 

5698 

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

5706 

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 } 

5723 

5724 # Create MCP request object for processing 

5725 mcp_request_obj: Final = proxy_logging_obj._create_mcp_request_object_from_kwargs(pre_hook_kwargs) 

5726 

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 

5734 

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

5749 

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) 

5764 

5765 return hook_result 

5766 

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. 

5779 

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 

5786 

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 ) 

5794 

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 } 

5802 

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 

5806 

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) 

5818 

5819 return asyncio.create_task(_run_during_call_hook()) 

5820 

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 

5831 

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 

5840 

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. 

5857 

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) 

5884 

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. 

5903 

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. 

5919 

5920 Returns: 

5921 CallToolResult from the MCP server 

5922 

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 ) 

5939 

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 

5943 

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 ) 

5975 

5976 if mcp_server.extra_headers and raw_headers: 

5977 if extra_headers is None: 

5978 extra_headers = {} 

5979 

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 ) 

5986 

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 

5996 

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) 

6006 

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) 

6030 

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 

6034 

6035 stdio_env: Final = self._build_stdio_env(mcp_server, raw_headers) 

6036 

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 ) 

6047 

6048 call_tool_params: Final = MCPCallToolRequestParams( 

6049 name=original_tool_name, 

6050 arguments=arguments, 

6051 ) 

6052 

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 ) 

6072 

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

6080 

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 

6119 

6120 tool_call_coro = _call_tool_via_client(client, call_tool_params) 

6121 

6122 tasks.append(asyncio.create_task(tool_call_coro)) 

6123 

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 

6142 

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) 

6148 

6149 return cast(CallToolResult, result) 

6150 

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) 

6161 

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 

6171 

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) 

6186 

6187 if resolved_by_server_name_only and not self.server_exposes_tool(mcp_server, name): 

6188 raise ValueError(f"Tool {name} not found") 

6189 

6190 return mcp_server 

6191 

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. 

6194 

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) 

6203 

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 ) 

6226 

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 

6236 

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 

6242 

6243 user_id: Final[str | None] = getattr(user_api_key_auth, "user_id", None) 

6244 if not user_id: 

6245 return oauth2_headers 

6246 

6247 try: 

6248 from litellm.proxy._experimental.mcp_server.operations import ( # noqa: PLC0415 

6249 _get_user_oauth_extra_headers_from_db, 

6250 ) 

6251 

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 

6266 

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. 

6278 

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. 

6289 

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 

6303 

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 

6311 

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 

6321 

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 

6339 

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 

6358 

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

6370 

6371 

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) 

6377 

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 ) 

6386 

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

6405 

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) 

6420 

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) 

6423 

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 ) 

6435 

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 ) 

6451 

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 ) 

6458 

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) 

6469 

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 ) 

6487 

6488 return await self._gather_openapi_tool_tasks(tasks, proxy_logging_obj) 

6489 

6490 ######################################################### 

6491 # End of Methods that call the upstream MCP servers 

6492 ######################################################### 

6493 

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 ) 

6505 

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 

6538 

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) 

6542 

6543 Args: 

6544 tool_name: Tool name (can be prefixed or non-prefixed) 

6545 

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

6551 

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 

6561 

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 

6573 

6574 return None 

6575 

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 ) 

6581 

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

6585 

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 

6591 

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

6601 

6602 previous_registry: Final = self.registry 

6603 new_registry: Final[dict[str, MCPServer]] = {} 

6604 

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) 

6612 

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 

6627 

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 ) 

6653 

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 ) 

6675 

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) 

6679 

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

6694 

6695 verbose_logger.debug("MCP registry refreshed (%s servers in registry)", len(registered_registry)) 

6696 

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 

6709 

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 

6723 

6724 await self._hydrate_config_servers_dcr_clients() 

6725 

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 

6733 

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 ) 

6740 

6741 return proxy_general_settings 

6742 except ImportError: 

6743 # Fallback if proxy_server not available 

6744 return {} 

6745 

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. 

6749 

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) 

6766 

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 

6776 

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. 

6780 

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. 

6787 

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] 

6799 

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 ] 

6806 

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. 

6811 

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) 

6846 

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. 

6859 

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 } 

6876 

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. 

6880 

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) 

6885 

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 

6911 

6912 def get_filtered_registry(self, client_ip: str | None = None) -> dict[str, MCPServer]: 

6913 """ 

6914 Get registry filtered by client IP access control. 

6915 

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

6924 

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. 

6935 

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. 

6938 

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. 

6940 

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) 

6947 

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

6953 

6954 # Generate SHA-256 hash 

6955 hash_object: Final = hashlib.sha256(params_string.encode("utf-8")) 

6956 hash_hex: Final = hash_object.hexdigest() 

6957 

6958 # Take first 32 characters and format as UUID-like string 

6959 return hash_hex[:32] 

6960 

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. 

6964 

6965 Args: 

6966 server_id: The ID of the server to health check 

6967 mcp_auth_header: Optional authentication header for the MCP server 

6968 

6969 Returns: 

6970 Dict containing health check results 

6971 """ 

6972 from datetime import datetime 

6973 

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 ) 

6985 

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 ) 

6997 

6998 status: Literal["healthy", "unhealthy", "unknown"] = "unknown" 

6999 health_check_error = None 

7000 

7001 # Check if we should skip health check based on auth configuration 

7002 should_skip_health_check = False 

7003 

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 

7016 

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 ) 

7031 

7032 async def _noop(session): 

7033 return "ok" 

7034 

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" 

7048 

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 ) 

7088 

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. 

7096 

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. 

7101 

7102 Returns: 

7103 List of MCP server objects with health and team data 

7104 """ 

7105 

7106 # Get allowed server IDs 

7107 allowed_server_ids: Final = await self.get_allowed_mcp_servers(user_api_key_auth) 

7108 

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 

7116 

7117 return await self._run_health_checks(target_server_ids) 

7118 

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. 

7125 

7126 Args: 

7127 user_api_key_auth: User authentication info for access control 

7128 

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) 

7134 

7135 list_mcp_servers: Final[list[LiteLLM_MCPServerTable]] = [] 

7136 

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 

7142 

7143 mcp_server_table = self._build_mcp_server_table(server) 

7144 list_mcp_servers.append(mcp_server_table) 

7145 

7146 return list_mcp_servers 

7147 

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] 

7155 

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 ) 

7212 

7213 async def get_all_mcp_servers_unfiltered(self) -> list[LiteLLM_MCPServerTable]: 

7214 """Return all MCP servers from registry without applying access controls.""" 

7215 

7216 registry: Final = self.get_registry() 

7217 if not registry: 

7218 return [] 

7219 

7220 servers: Final[list[LiteLLM_MCPServerTable]] = [] 

7221 for server in registry.values(): 

7222 servers.append(self._build_mcp_server_table(server)) 

7223 return servers 

7224 

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

7229 

7230 registry: Final = self.get_registry() 

7231 if not registry: 

7232 return [] 

7233 

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

7238 

7239 if not target_server_ids: 

7240 return [] 

7241 

7242 return await self._run_health_checks(target_server_ids) 

7243 

7244 async def _run_health_checks(self, target_server_ids: list[str]) -> list[LiteLLM_MCPServerTable]: 

7245 if not target_server_ids: 

7246 return [] 

7247 

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] 

7251 

7252 

7253global_mcp_server_manager: Final[MCPServerManager] = MCPServerManager()