Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/operations.py: 21%
903 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""Shared MCP operation policy and dispatch."""
3import asyncio
4import traceback
5import types
6import uuid
7from collections.abc import Mapping, Sequence
8from datetime import datetime
9from typing import Any, Final, NoReturn, TypeAlias, overload
11from fastapi import HTTPException
12from mcp import ReadResourceResult, Resource
13from mcp.types import (
14 CallToolRequest,
15 CallToolRequestParams,
16 CallToolResult,
17 GetPromptRequest,
18 GetPromptRequestParams,
19 GetPromptResult,
20 ListPromptsRequest,
21 ListPromptsResult,
22 ListResourcesRequest,
23 ListResourcesResult,
24 ListResourceTemplatesRequest,
25 ListResourceTemplatesResult,
26 ListToolsRequest,
27 ListToolsResult,
28 PaginatedRequestParams,
29 Prompt,
30 ReadResourceRequest,
31 ReadResourceRequestParams,
32 ResourceTemplate,
33 TextContent,
34)
35from mcp.types import Tool as MCPTool
36from pydantic import AnyUrl, ConfigDict, Field, TypeAdapter
37from typing_extensions import ReadOnly, TypedDict, assert_never
39from litellm._logging import verbose_logger
40from litellm.constants import (
41 MAXIMUM_TRACEBACK_LINES_TO_LOG,
42)
43from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
44from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
45 MCPRequestHandler,
46)
47from litellm.proxy._experimental.mcp_server.byok_credential_cache import (
48 byok_credential_cache,
49 byok_credential_cache_key,
50 cache_byok_credential,
51 get_cached_byok_credential,
52)
53from litellm.proxy._experimental.mcp_server.contracts import (
54 AuthorizedToolCall,
55 OperationContext,
56 ProgressCallback,
57)
58from litellm.proxy._experimental.mcp_server.db import OAuthCredentialPayload
59from litellm.proxy._experimental.mcp_server.exceptions import (
60 MCPToolResultError,
61 MCPUpstreamAuthError,
62)
63from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
64 SERVER_OUTCOMES_META_KEY,
65 AggregateToolListing,
66 ServerListOk,
67 ServerOutcome,
68 classify_list_exception,
69 outcome_wire_value,
70)
71from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
72 MCPServerManager,
73 _caller_authorization_fans_out,
74 _client_forwarded_authorization_headers,
75 _resolve_openapi_tool_auth,
76 _should_strip_caller_authorization,
77 global_mcp_server_manager,
78)
79from litellm.proxy._experimental.mcp_server.oauth_utils import (
80 _redact_mcp_resource_url,
81 get_byok_www_authenticate,
82)
83from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
84 _request_auth_header,
85 _request_extra_headers,
86 _request_resolved_auth_headers,
87)
88from litellm.proxy._experimental.mcp_server.tool_registry import (
89 global_mcp_tool_registry,
90)
91from litellm.proxy._experimental.mcp_server.utils import (
92 MCP_TOOL_PREFIX_SEPARATOR,
93 MCPMissingUserEnvVarsError,
94 add_server_prefix_to_name,
95 build_synthetic_mcp_request,
96 extract_mcp_tool_result_error_message,
97 get_server_prefix,
98 is_tool_name_prefixed,
99 iter_known_server_prefixes,
100 logging_safe_mcp_headers,
101 match_known_tool_name,
102 normalize_server_name,
103 split_server_prefix_from_name,
104 strip_known_server_prefix,
105)
106from litellm.proxy._types import (
107 UserAPIKeyAuth,
108)
109from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
110 publish_auth_cache_invalidation,
111)
112from litellm.proxy.litellm_pre_call_utils import (
113 LiteLLMProxyRequestSetup,
114 get_chain_id_from_headers,
115)
116from litellm.types.mcp import (
117 DEFAULT_CREDENTIAL_HEADER,
118 MCPAuth,
119 without_header,
120)
121from litellm.types.mcp_server.mcp_server_manager import MCPInfo, MCPServer
122from litellm.types.utils import CallTypes, StandardLoggingMCPToolCall
123from litellm.utils import Rules, client, function_setup
125__all__ = (
126 "_MCP_CREDENTIAL_REQUEST_FIELDS",
127 "ListMCPToolsRestAPIResponseObject",
128 "MCPInfo",
129 "MCPServer",
130 "_McpDeniedDetail",
131 "_aggregate_server_key",
132 "_build_virtual_call_logging_obj",
133 "_check_byok_credential",
134 "_client_has_passthrough_authorization",
135 "_client_has_per_server_auth_header",
136 "_dispatch_virtual_mcp_tool",
137 "_fire_mcp_tool_call_logging",
138 "_get_allowed_mcp_servers",
139 "_get_allowed_mcp_servers_from_mcp_server_names",
140 "_get_byok_credential",
141 "_get_prompts_from_mcp_servers",
142 "_get_resource_templates_from_mcp_servers",
143 "_get_resources_from_mcp_servers",
144 "_get_standard_logging_mcp_tool_call",
145 "_get_tools_from_mcp_servers",
146 "_get_user_oauth_extra_headers_from_db",
147 "_handle_local_mcp_tool",
148 "_handle_managed_mcp_tool",
149 "_http_detail_message",
150 "_invalidate_byok_cred_cache",
151 "_list_mcp_prompts",
152 "_list_mcp_resource_templates",
153 "_list_mcp_resources",
154 "_list_mcp_tools",
155 "_list_tools_before_first_call",
156 "_mcp_session_id_from_headers",
157 "_merge_gateway_initialize_instructions",
158 "_prefetch_oauth_creds_for_user",
159 "_prepare_mcp_server_headers",
160 "_raise_if_initialize_grants_no_mcp_servers",
161 "_resolve_display_name_to_original",
162 "_run_post_mcp_call_guardrails",
163 "_server_answers_to",
164 "_tool_name_matches",
165 "apply_tool_overrides",
166 "call_mcp_tool",
167 "execute_mcp_tool",
168 "filter_tools_by_allowed_tools",
169 "filter_tools_by_key_team_permissions",
170 "fire_mcp_tool_call_failure_logging",
171 "mcp_get_prompt",
172 "mcp_read_resource",
173 "raise_denied_scoped_mcp_access",
174)
177async def _invalidate_byok_cred_cache(user_id: str, server_id: str) -> None:
178 """Drop a stored-or-deleted BYOK credential from this worker's cache and from every peer worker's."""
179 cache_key: Final = byok_credential_cache_key(user_id, server_id)
180 byok_credential_cache.delete_cache(cache_key)
181 await publish_auth_cache_invalidation(cache_key=cache_key)
184def _mcp_session_id_from_headers(
185 raw_headers: dict[str, str] | None,
186) -> str | None:
187 """The ``mcp-session-id`` of a stateful MCP session, read case-insensitively
188 from the request headers. ``None`` for stateless calls (no such header)."""
189 if not raw_headers:
190 return None
191 for key, value in raw_headers.items():
192 if isinstance(key, str) and key.lower() == "mcp-session-id":
193 return value or None
194 return None
197class ListMCPToolsRestAPIResponseObject(MCPTool):
198 """
199 Object returned by the /tools/list REST API route.
200 """
202 mcp_info: MCPInfo | None = Field(default=None, alias="mcp_info")
203 model_config = ConfigDict(arbitrary_types_allowed=True)
206async def _build_virtual_call_logging_obj(
207 name: str,
208 arguments: dict[str, object],
209 user_api_key_auth: UserAPIKeyAuth,
210 raw_headers: Mapping[str, str] | None = None,
211 client_ip: str | None = None,
212) -> LiteLLMLoggingObj | None:
213 """Run the pre-call pipeline (guardrails + logging setup) for a virtual
214 mcp_tool_call so the SSE path spend-logs like the REST path."""
215 from litellm.proxy.common_request_processing import (
216 ProxyBaseLLMRequestProcessing,
217 )
218 from litellm.proxy.proxy_server import (
219 general_settings,
220 proxy_config,
221 proxy_logging_obj,
222 )
224 request: Final = build_synthetic_mcp_request(
225 path="/mcp/tools/call",
226 raw_headers=raw_headers,
227 client_ip=client_ip,
228 )
229 _, virtual_logging_obj = await ProxyBaseLLMRequestProcessing(
230 data={"name": name, "arguments": arguments}
231 ).common_processing_pre_call_logic(
232 request=request,
233 user_api_key_dict=user_api_key_auth,
234 proxy_config=proxy_config,
235 route_type=CallTypes.call_mcp_tool.value,
236 proxy_logging_obj=proxy_logging_obj,
237 general_settings=general_settings,
238 )
239 return virtual_logging_obj
242async def _dispatch_virtual_mcp_tool(
243 name: str,
244 arguments: dict[str, object] | None,
245 user_api_key_auth: UserAPIKeyAuth | None,
246 client_ip: str | None,
247 mcp_servers: list[str] | None = None,
248 mcp_auth_header: str | None = None,
249 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
250 oauth2_headers: dict[str, str] | None = None,
251 raw_headers: dict[str, str] | None = None,
252 mcp_proxy_mode: bool = False,
253) -> CallToolResult | None:
254 """Handle the mcp_tool_search / mcp_tool_call virtual tools.
256 Returns a CallToolResult when ``name`` is a virtual tool, else ``None`` so
257 the caller falls through to normal tool routing.
258 """
259 from litellm.llms.litellm_proxy.skills.skill_search import DEFAULT_SKILL_SEARCH_TOP_K
260 from litellm.proxy._experimental.mcp_server.tool_search import (
261 AGENT_SEARCH_TOOL_NAME,
262 DEFAULT_AGENT_SEARCH_TOP_K,
263 MCP_PROXY_CALL_TOOL_NAME,
264 MCP_PROXY_TOOL_NAMES,
265 MCP_TOOL_SEARCH_TOOL_NAME,
266 SKILL_SEARCH_TOOL_NAME,
267 VIRTUAL_TOOL_NAMES,
268 coerce_top_k,
269 handle_agent_search,
270 handle_mcp_proxy_tool,
271 handle_mcp_tool_call,
272 handle_mcp_tool_search,
273 handle_skill_search,
274 )
276 if mcp_proxy_mode and name not in MCP_PROXY_TOOL_NAMES:
277 return CallToolResult(
278 content=[ # mutable-ok: MCP result content
279 TextContent(type="text", text=f"Tool {name} is unavailable on /mcp/proxy")
280 ],
281 is_error=True,
282 )
284 if mcp_proxy_mode and name in MCP_PROXY_TOOL_NAMES:
285 assert user_api_key_auth is not None
286 proxy_call_start: Final = datetime.now() # noqa: DTZ005 # logging pipeline uses naive datetimes
287 proxy_logging_obj: Final = (
288 await _build_virtual_call_logging_obj(
289 name=name,
290 arguments=arguments or {}, # mutable-ok: logging pipeline payload
291 user_api_key_auth=user_api_key_auth,
292 raw_headers=raw_headers,
293 client_ip=client_ip,
294 )
295 if name == MCP_PROXY_CALL_TOOL_NAME
296 else None
297 )
298 try:
299 proxy_result: Final = await handle_mcp_proxy_tool(
300 name=name,
301 arguments=arguments or {}, # mutable-ok: proxy handler payload
302 user_api_key_dict=user_api_key_auth,
303 client_ip=client_ip,
304 mcp_servers=mcp_servers,
305 mcp_auth_header=mcp_auth_header,
306 mcp_server_auth_headers=mcp_server_auth_headers,
307 oauth2_headers=oauth2_headers,
308 raw_headers=raw_headers,
309 litellm_logging_obj=proxy_logging_obj,
310 )
311 except Exception as exc:
312 if proxy_logging_obj is not None:
313 from litellm.proxy.proxy_server import proxy_logging_obj as request_logging_obj
315 failure_end: Final = datetime.now() # noqa: DTZ005 # matches the logging pipeline start time
316 failure_traceback: Final = traceback.format_exc(limit=MAXIMUM_TRACEBACK_LINES_TO_LOG)
317 try:
318 proxy_logging_obj.failure_handler(exc, failure_traceback, proxy_call_start, failure_end)
319 await proxy_logging_obj.async_failure_handler(exc, failure_traceback, proxy_call_start, failure_end)
320 if not isinstance(exc, MCPUpstreamAuthError):
321 await request_logging_obj.post_call_failure_hook(
322 request_data={ # mutable-ok: failure hook mutates its request payload
323 "name": name,
324 "arguments": arguments,
325 "litellm_logging_obj": proxy_logging_obj,
326 },
327 original_exception=exc,
328 user_api_key_dict=user_api_key_auth,
329 route="/mcp/call_tool",
330 traceback_str=failure_traceback,
331 )
332 except Exception: # noqa: BLE001 # a failing failure hook must not mask the tool call's own error
333 verbose_logger.exception("Error logging failed MCP proxy tool call")
334 raise
335 if proxy_logging_obj is not None:
336 return await _fire_mcp_tool_call_logging(
337 logging_obj=proxy_logging_obj,
338 result=proxy_result,
339 start_time=proxy_call_start,
340 end_time=datetime.now(), # noqa: DTZ005 # matches the logging pipeline start time
341 user_api_key_auth=user_api_key_auth,
342 request_data=types.MappingProxyType({"name": name, "arguments": arguments}),
343 )
344 return proxy_result
346 if name not in VIRTUAL_TOOL_NAMES:
347 return None
349 if not getattr(
350 getattr(user_api_key_auth, "object_permission", None),
351 "mcp_tool_search_enabled",
352 False,
353 ):
354 return CallToolResult(
355 content=[
356 TextContent(
357 type="text",
358 text=f"Tool {name} requires mcp_tool_search_enabled on the key",
359 )
360 ],
361 is_error=True,
362 )
364 args: Final = arguments or {}
365 if name == MCP_TOOL_SEARCH_TOOL_NAME:
366 return await handle_mcp_tool_search(
367 query=TypeAdapter(str).validate_python(args.get("query", "")),
368 top_k=coerce_top_k(args.get("top_k", 5)),
369 user_api_key_dict=user_api_key_auth,
370 client_ip=client_ip,
371 mcp_servers=mcp_servers,
372 mcp_auth_header=mcp_auth_header,
373 mcp_server_auth_headers=mcp_server_auth_headers,
374 oauth2_headers=oauth2_headers,
375 raw_headers=raw_headers,
376 )
378 assert user_api_key_auth is not None # guaranteed by the flag check above
379 if name == AGENT_SEARCH_TOOL_NAME:
380 return await handle_agent_search(
381 query=str(args.get("query", "")),
382 top_k=coerce_top_k(args.get("top_k", DEFAULT_AGENT_SEARCH_TOP_K), default=DEFAULT_AGENT_SEARCH_TOP_K),
383 user_api_key_dict=user_api_key_auth,
384 )
385 if name == SKILL_SEARCH_TOOL_NAME:
386 return await handle_skill_search(
387 query=str(args.get("query", "")),
388 top_k=coerce_top_k(args.get("top_k", DEFAULT_SKILL_SEARCH_TOP_K), default=DEFAULT_SKILL_SEARCH_TOP_K),
389 user_api_key_dict=user_api_key_auth,
390 )
391 virtual_logging_obj: Final = await _build_virtual_call_logging_obj(
392 name=name,
393 arguments=args,
394 user_api_key_auth=user_api_key_auth,
395 raw_headers=raw_headers,
396 client_ip=client_ip,
397 )
398 tool_request: Final = CallToolRequestParams.model_validate(
399 types.MappingProxyType({"name": args.get("tool_name", ""), "arguments": args.get("arguments") or {}})
400 )
401 return await handle_mcp_tool_call(
402 tool_name=tool_request.name,
403 arguments=tool_request.arguments or {},
404 user_api_key_dict=user_api_key_auth,
405 client_ip=client_ip,
406 mcp_servers=mcp_servers,
407 mcp_auth_header=mcp_auth_header,
408 mcp_server_auth_headers=mcp_server_auth_headers,
409 oauth2_headers=oauth2_headers,
410 raw_headers=raw_headers,
411 litellm_logging_obj=virtual_logging_obj,
412 )
415async def _get_allowed_mcp_servers_from_mcp_server_names(
416 mcp_servers: Sequence[str] | None,
417 allowed_mcp_servers: list[MCPServer],
418) -> list[MCPServer]:
419 """
420 Get the filtered MCP servers from the MCP server names.
422 Fails closed when ``mcp_servers`` is explicitly provided (path- or
423 header-derived) but none of the names resolve to a server alias or
424 access group the caller can access. The previous behavior returned
425 the full ``allowed_mcp_servers`` set, which silently widened scope
426 when a client targeted ``/mcp/<unknown>/`` and made URL/header
427 namespacing appear to work when it did not.
428 """
430 filtered_server: Final[dict[str, MCPServer]] = {}
431 # Filter servers based on mcp_servers parameter if provided
432 if mcp_servers is not None:
433 for server_or_group in mcp_servers:
434 server_name_matched = False
436 for server in allowed_mcp_servers:
437 if server and _server_answers_to(server, server_or_group):
438 filtered_server[server.server_id] = server
439 server_name_matched = True
440 break
442 if not server_name_matched:
443 try:
444 access_group_server_ids = await MCPRequestHandler._get_mcp_servers_from_access_groups(
445 [server_or_group]
446 )
447 # Only include servers that the user has access to
448 for server_id in access_group_server_ids:
449 for server in allowed_mcp_servers:
450 if server_id == server.server_id:
451 filtered_server[server.server_id] = server
452 except Exception as e:
453 verbose_logger.debug("Could not resolve '%s' as access group: %s", server_or_group, e)
455 if filtered_server:
456 return list(filtered_server.values())
458 if mcp_servers is not None:
459 # Caller asked for a specific scope but nothing resolved. Fail
460 # closed so URL/header namespacing cannot silently fall back to
461 # the caller's full allowed-server set.
462 verbose_logger.debug(
463 "MCP scope filter resolved to no servers for requested names %s; returning empty list (fail-closed).",
464 mcp_servers,
465 )
466 return []
468 return allowed_mcp_servers
471def _http_detail_message(detail: object) -> str:
472 return str(detail.get("error")) if isinstance(detail, dict) and detail.get("error") else str(detail)
475def _server_answers_to(server: MCPServer, name: str) -> bool:
476 requested: Final = name.lower()
477 return any(requested == known.lower() for known in iter_known_server_prefixes(server) if known)
480async def raise_denied_scoped_mcp_access(
481 requested_names: Sequence[str],
482 user_api_key_auth: UserAPIKeyAuth | None,
483 client_ip: str | None = None,
484) -> None:
485 """A scoped request (``/mcp/<name>`` path or ``x-mcp-servers`` header) resolved to zero
486 allowed servers, so the denial must be loud: a silent 200 with no tools reads as a healthy
487 server with no tools. Unknown, unauthorized, and access-group names all share one generic
488 error so scoping cannot probe which servers exist; the agent variant fires only when the
489 same request resolves once the agent binding is stripped, proving the binding caused the veto."""
490 agent_id: Final = user_api_key_auth.agent_id if user_api_key_auth else None
491 if user_api_key_auth is not None and agent_id:
492 resolved_without_agent: Final = await _get_allowed_mcp_servers(
493 user_api_key_auth=user_api_key_auth.model_copy(update=types.MappingProxyType({"agent_id": None})),
494 mcp_servers=requested_names,
495 client_ip=client_ip,
496 )
498 def _resolved_to_server(name: str) -> bool:
499 return any(_server_answers_to(server, name) for server in resolved_without_agent)
501 vetoed_server: Final = next((name for name in requested_names if _resolved_to_server(name)), None)
502 if vetoed_server is not None:
503 agent_denial: Final[_McpDeniedDetail] = {
504 "error": (
505 f"MCP server '{vetoed_server}' is not available to this key: the key is bound to "
506 f"agent '{agent_id}', whose MCP grants do not include this server. Add the server "
507 f"to the agent's object_permission.mcp_servers (edit the agent in the Admin UI or "
508 f"PATCH /v1/agents/{agent_id}), or use a key that is not bound to the agent."
509 )
510 }
511 raise HTTPException(status_code=403, detail=agent_denial)
512 vetoed_group: Final = next(
513 (
514 name
515 for name in requested_names
516 if not _resolved_to_server(name)
517 and any(name in (server.access_groups or ()) for server in resolved_without_agent)
518 ),
519 None,
520 )
521 if vetoed_group is not None:
522 group_denial: Final[_McpDeniedDetail] = {
523 "error": (
524 f"MCP access group '{vetoed_group}' is not available to this key: the key is bound to "
525 f"agent '{agent_id}', whose MCP grants do not include it. Add the group to the "
526 f"agent's object_permission.mcp_access_groups (edit the agent in the Admin UI or "
527 f"PATCH /v1/agents/{agent_id}), or use a key that is not bound to the agent."
528 )
529 }
530 raise HTTPException(status_code=403, detail=group_denial)
531 generic_denial: Final[_McpDeniedDetail] = {
532 "error": f"The key is not allowed to access the requested MCP servers: {', '.join(requested_names)}"
533 }
534 raise HTTPException(status_code=403, detail=generic_denial)
537def _tool_name_matches(tool_name: str, filter_list: list[str], mcp_server: MCPServer) -> bool:
538 """
539 Check if a tool name matches any name in the filter list.
541 Reads the same owner the server-level permission checks use, so discovery hides
542 exactly what dispatch refuses. ``mcp_server`` is required: guessing the boundary
543 at the first separator mismatches every tool on a server whose prefix contains
544 the separator.
545 """
546 bare_name: Final = strip_known_server_prefix(tool_name, mcp_server)
547 return match_known_tool_name(bare_name, mcp_server, filter_list) is not None
550def filter_tools_by_allowed_tools(
551 tools: list[MCPTool],
552 mcp_server: MCPServer,
553) -> list[MCPTool]:
554 """
555 Filter tools by allowed/disallowed tools configuration.
557 If allowed_tools is set, only tools in that list are returned.
558 If disallowed_tools is set, tools in that list are excluded.
559 Tool names are matched with and without server prefixes for flexibility.
561 Args:
562 tools: List of tools to filter
563 mcp_server: Server configuration with allowed_tools/disallowed_tools
565 Returns:
566 Filtered list of tools
567 """
568 from litellm.proxy._experimental.mcp_server.utils import (
569 server_applies_tool_allowlist,
570 )
572 tools_to_return = tools
574 # Filter by allowed_tools (whitelist)
575 if server_applies_tool_allowlist(mcp_server): 575 ↛ 576line 575 didn't jump to line 576 because the condition on line 575 was never true
576 if not mcp_server.allowed_tools:
577 return []
578 tools_to_return = [
579 tool for tool in tools if _tool_name_matches(tool.name, mcp_server.allowed_tools, mcp_server)
580 ]
582 # Filter by disallowed_tools (blacklist)
583 if mcp_server.disallowed_tools: 583 ↛ 584line 583 didn't jump to line 584 because the condition on line 583 was never true
584 tools_to_return = [
585 tool
586 for tool in tools_to_return
587 if not _tool_name_matches(tool.name, mcp_server.disallowed_tools, mcp_server)
588 ]
590 return tools_to_return
593def apply_tool_overrides(
594 tools: list[MCPTool],
595 mcp_server: MCPServer,
596) -> list[MCPTool]:
597 """Apply admin-configured display name/description overrides to tools.
599 Overrides are keyed by the unprefixed tool name, same convention as
600 allowed_tools configuration.
601 """
602 display_name_map: Final = mcp_server.tool_name_to_display_name or {}
603 description_map: Final = mcp_server.tool_name_to_description or {}
604 if not display_name_map and not description_map: 604 ↛ 607line 604 didn't jump to line 607 because the condition on line 604 was always true
605 return tools
607 for tool in tools:
608 unprefixed = strip_known_server_prefix(tool.name, mcp_server)
609 lookup_key = unprefixed or tool.name
610 if lookup_key in display_name_map:
611 tool.name = display_name_map[lookup_key]
612 if lookup_key in description_map:
613 tool.description = description_map[lookup_key]
614 return tools
617async def _get_allowed_mcp_servers(
618 user_api_key_auth: UserAPIKeyAuth | None,
619 mcp_servers: Sequence[str] | None,
620 client_ip: str | None = None,
621) -> list[MCPServer]:
622 """Return allowed MCP servers for a request after applying filters.
624 Args:
625 user_api_key_auth: The authenticated user's API key info.
626 mcp_servers: Optional list of server names to filter to.
627 client_ip: Client IP for IP-based access control. If None, falls back to
628 auth context. Pass explicitly from request handlers for safety.
629 Note: If client_ip is None and auth context is not set, IP filtering is skipped.
630 This is intentional for internal callers but may indicate a bug if called
631 from a request handler without proper context setup.
632 """
633 allowed_mcp_server_ids = await global_mcp_server_manager.get_allowed_mcp_servers(user_api_key_auth)
634 (
635 allowed_mcp_server_ids,
636 _ip_blocked,
637 ) = global_mcp_server_manager.filter_server_ids_by_ip_with_info(allowed_mcp_server_ids, client_ip)
638 verbose_logger.debug(
639 "MCP IP filter: client_ip=%s, allowed_server_ids=%s",
640 client_ip,
641 allowed_mcp_server_ids,
642 )
643 if _ip_blocked > 0: 643 ↛ 644line 643 didn't jump to line 644 because the condition on line 643 was never true
644 verbose_logger.debug(
645 "MCP IP filtering: %d server(s) are not accessible from client IP %s "
646 "because they are restricted to internal networks. "
647 "No tools from those servers will be returned. "
648 "To expose a server externally, set 'available_on_public_internet: true' "
649 "in its configuration.",
650 _ip_blocked,
651 client_ip,
652 )
653 allowed_mcp_servers: list[MCPServer] = []
654 for allowed_mcp_server_id in allowed_mcp_server_ids:
655 mcp_server = global_mcp_server_manager.get_mcp_server_by_id(allowed_mcp_server_id)
656 if mcp_server is not None: 656 ↛ 654line 656 didn't jump to line 654 because the condition on line 656 was always true
657 # Apply the request-time oauth2_flow backstop for legacy null rows.
658 mcp_server = MCPServerManager.resolve_oauth2_flow_for_request(mcp_server)
659 allowed_mcp_servers.append(mcp_server)
661 if mcp_servers is not None: 661 ↛ 662line 661 didn't jump to line 662 because the condition on line 661 was never true
662 allowed_mcp_servers = await _get_allowed_mcp_servers_from_mcp_server_names(
663 mcp_servers=mcp_servers,
664 allowed_mcp_servers=allowed_mcp_servers,
665 )
667 return allowed_mcp_servers
670def _client_has_per_server_auth_header(
671 server: MCPServer,
672 mcp_server_auth_headers: dict[str, dict[str, str]] | None,
673) -> bool:
674 """True if the request carries a per-server ``x-mcp-{alias}-authorization``
675 header for this server. This is the multi-server binding: it names one
676 upstream, so it is unambiguously the caller's upstream token regardless of
677 auth mode (never the LiteLLM admission credential).
679 Resolves through the same ``lookup_mcp_server_auth_in_headers`` egress uses, so
680 the connect gate and egress agree on which per-server header names match: a
681 dashboard client sends ``x-mcp-{sanitize_mcp_alias_for_header(alias)}-authorization``,
682 and matching only the raw alias here would 401 a token egress would forward.
683 """
684 if not mcp_server_auth_headers:
685 return False
686 from litellm.proxy._experimental.mcp_server.utils import (
687 lookup_mcp_server_auth_in_headers,
688 )
690 server_headers: Final = lookup_mcp_server_auth_in_headers(
691 mcp_server_auth_headers,
692 alias=server.alias,
693 server_name=server.server_name,
694 access_groups=server.access_groups,
695 )
696 if isinstance(server_headers, str):
697 return bool(server_headers.strip())
698 if isinstance(server_headers, dict):
699 return any(isinstance(hk, str) and hk.lower() == "authorization" for hk in server_headers)
700 return False
703def _client_has_passthrough_authorization(
704 server: MCPServer,
705 oauth2_headers: dict[str, str] | None,
706 mcp_server_auth_headers: dict[str, dict[str, str]] | None,
707) -> bool:
708 """True if the incoming request already carries an ``Authorization``
709 header the gateway will forward to this pass-through server.
711 The client may supply the bearer as either the top-level
712 ``Authorization`` header (surfaced via ``oauth2_headers``) or a
713 per-server ``x-mcp-auth-<alias>`` style header (surfaced via
714 ``mcp_server_auth_headers``). Either form skips the pre-emptive 401.
715 """
716 if oauth2_headers:
717 for k in oauth2_headers:
718 if k.lower() == "authorization":
719 return True
720 return _client_has_per_server_auth_header(server, mcp_server_auth_headers)
723async def _get_user_oauth_extra_headers_from_db(
724 server: MCPServer,
725 user_api_key_auth: UserAPIKeyAuth | None,
726 prefetched_creds: 'Mapping[str, "OAuthCredentialPayload"] | None' = None,
727) -> dict[str, str] | None:
728 """Stored OAuth2 token for (user, server) as an ``Authorization: Bearer`` header, or None.
730 Thin wrapper over ``resolve_user_oauth_access_token`` (Redis cache, else DB + refresh);
731 ``prefetched_creds`` skips the per-server Redis/DB lookups for the batch path.
732 """
733 if server.auth_type != MCPAuth.oauth2 or user_api_key_auth is None:
734 return None
735 from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415
736 resolve_user_oauth_access_token,
737 )
739 token: Final = await resolve_user_oauth_access_token(
740 getattr(user_api_key_auth, "user_id", None), server, prefetched_creds
741 )
742 return {"Authorization": f"Bearer {token}"} if token else None
745async def _prefetch_oauth_creds_for_user(
746 user_api_key_auth: UserAPIKeyAuth | None,
747) -> dict[str, "OAuthCredentialPayload"]:
748 """Fetch all OAuth2 credentials for the user in one DB query.
750 Returns a dict keyed by server_id to avoid N+1 queries in asyncio.gather loops.
751 """
752 user_id: Final[str | None] = getattr(user_api_key_auth, "user_id", None) if user_api_key_auth else None
753 if not user_id:
754 return {}
755 try:
756 from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415
757 list_user_oauth_credentials,
758 )
759 from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415
761 prisma_client: Final = get_prisma_client_or_throw(
762 "Database not connected. Connect a database to use OAuth2 MCP tools."
763 )
764 creds: Final = await list_user_oauth_credentials(prisma_client, user_id)
765 return {c["server_id"]: c for c in creds if "server_id" in c}
766 except Exception:
767 verbose_logger.warning("_prefetch_oauth_creds_for_user: failed to prefetch OAuth credentials")
768 return {}
771def _prepare_mcp_server_headers(
772 server: MCPServer,
773 mcp_server_auth_headers: dict[str, dict[str, str]] | None,
774 mcp_auth_header: str | None,
775 oauth2_headers: dict[str, str] | None,
776 raw_headers: dict[str, str] | None,
777 user_api_key_auth: UserAPIKeyAuth | None = None,
778 scope_servers: list[MCPServer] | None = None,
779) -> tuple[dict[str, str] | str | None, dict[str, str] | None]:
780 """Build auth and extra headers for a server.
782 ``scope_servers`` is the full server list a fan-out handler iterates. Passing it lets the
783 client-forwarded token modes withhold the caller's request-wide ``Authorization`` when
784 another server in the scope would also receive it (``_caller_authorization_fans_out``);
785 explicitly-addressed operations leave it None. Per-server ``x-mcp-{alias}-authorization``
786 headers are unaffected — they bind one token to one server and are the multi-server shape.
787 """
788 server_auth_header: dict[str, str] | str | None = None
789 if mcp_server_auth_headers: 789 ↛ 790line 789 didn't jump to line 790 because the condition on line 789 was never true
790 from litellm.proxy._experimental.mcp_server.utils import (
791 lookup_mcp_server_auth_in_headers,
792 )
794 server_auth_header = lookup_mcp_server_auth_in_headers(
795 mcp_server_auth_headers,
796 alias=server.alias,
797 server_name=server.server_name,
798 access_groups=server.access_groups,
799 )
801 extra_headers: dict[str, str] | None = None
802 is_client_forwarded_mode: Final = server.is_client_forwarded_token
803 # In a multi-server listing scope the request-wide Authorization can only carry one token,
804 # so it is withheld from a client-forwarded server when another server in scope also consumes
805 # it (RFC 9700 cross-resource replay); such scopes must bind per-server via
806 # x-mcp-{alias}-authorization. The decision is computed once so BOTH the forwarding branch and
807 # the extra_headers copy loop below honor it — otherwise a server that lists Authorization in
808 # extra_headers would re-copy the withheld bearer from raw_headers and replay it anyway.
809 withhold_forwarded_authorization: Final = is_client_forwarded_mode and _caller_authorization_fans_out(
810 server, scope_servers
811 )
812 if server.auth_type == MCPAuth.oauth2: 812 ↛ 815line 812 didn't jump to line 815 because the condition on line 812 was never true
813 # For OAuth2 M2M servers, upstream Authorization must come from
814 # client_credentials token fetch, never from caller headers.
815 if server.has_client_credentials:
816 extra_headers = None
817 else:
818 # Copy to avoid mutating the original dict (important for parallel fetching)
819 extra_headers = oauth2_headers.copy() if oauth2_headers else None
820 # Migrated authorization_code: the v2 resolver injects the stored per-user
821 # token, so drop the caller-forwarded Authorization (apply-if-absent would
822 # otherwise let it shadow the resolved token). Delegate keeps it. Centralized
823 # via _should_strip_caller_authorization to match _call_regular_mcp_tool.
824 if extra_headers and _should_strip_caller_authorization(
825 mcp_server=server,
826 raw_headers=raw_headers,
827 user_api_key_auth=user_api_key_auth,
828 ):
829 extra_headers = without_header(extra_headers, DEFAULT_CREDENTIAL_HEADER)
830 elif is_client_forwarded_mode:
831 if not withhold_forwarded_authorization: 831 ↛ 832line 831 didn't jump to line 832 because the condition on line 831 was never true
832 extra_headers = _client_forwarded_authorization_headers(
833 mcp_server=server,
834 oauth2_headers=oauth2_headers,
835 raw_headers=raw_headers,
836 user_api_key_auth=user_api_key_auth,
837 )
839 if server.extra_headers and raw_headers: 839 ↛ 840line 839 didn't jump to line 840 because the condition on line 839 was never true
840 if extra_headers is None:
841 extra_headers = {}
843 normalized_raw_headers: Final = {str(k).lower(): v for k, v in raw_headers.items() if isinstance(k, str)}
845 # Centralized strip decision shared with
846 # ``MCPServerManager._call_regular_mcp_tool`` so the two
847 # code paths cannot drift on this security-sensitive choice.
848 # See ``_should_strip_caller_authorization`` for the rules.
849 strip_caller_authorization: Final = _should_strip_caller_authorization(
850 mcp_server=server,
851 raw_headers=raw_headers,
852 user_api_key_auth=user_api_key_auth,
853 )
855 for header in server.extra_headers:
856 if not isinstance(header, str):
857 continue
858 if header.lower() == "authorization" and (strip_caller_authorization or withhold_forwarded_authorization):
859 continue
860 header_value = normalized_raw_headers.get(header.lower())
861 if header_value is None:
862 continue
863 extra_headers[header] = header_value
865 # Reset to None if no headers were actually added
866 if extra_headers is not None and len(extra_headers) == 0: 866 ↛ 867line 866 didn't jump to line 867 because the condition on line 866 was never true
867 extra_headers = None
869 if server_auth_header is None: 869 ↛ 872line 869 didn't jump to line 872 because the condition on line 869 was always true
870 server_auth_header = mcp_auth_header
872 return server_auth_header, extra_headers
875def _merge_gateway_initialize_instructions(
876 allowed_mcp_servers: list[MCPServer],
877) -> str | None:
878 """YAML/DB override, else upstream text (prefetch on init, or list_tools / health_check / call_tool cache)."""
879 if not allowed_mcp_servers:
880 return None
882 texts: Final[list[tuple[str, str]]] = []
883 for server in allowed_mcp_servers:
884 label = server.alias or server.server_name or server.name or server.server_id or "mcp"
885 if server.instructions and server.instructions.strip(): 885 ↛ 886line 885 didn't jump to line 886 because the condition on line 885 was never true
886 texts.append((label, server.instructions.strip()))
887 continue
888 if server.spec_path: 888 ↛ 889line 888 didn't jump to line 889 because the condition on line 888 was never true
889 continue
890 cached = global_mcp_server_manager._upstream_initialize_instructions_by_server_id.get(server.server_id)
891 if cached and cached.strip(): 891 ↛ 892line 891 didn't jump to line 892 because the condition on line 891 was never true
892 texts.append((label, cached.strip()))
894 if not texts: 894 ↛ 896line 894 didn't jump to line 896 because the condition on line 894 was always true
895 return None
896 if len(texts) == 1:
897 return texts[0][1]
898 return "\n\n---\n\n".join(f"[{lbl}]\n{txt}" for lbl, txt in texts)
901async def _raise_if_initialize_grants_no_mcp_servers(
902 allowed: Sequence[MCPServer],
903 user_api_key_auth: UserAPIKeyAuth | None,
904 mcp_servers: Sequence[str] | None,
905 client_ip: str | None,
906) -> None:
907 if allowed or user_api_key_auth is None or not user_api_key_auth.api_key:
908 return
909 if mcp_servers:
910 await raise_denied_scoped_mcp_access(
911 requested_names=mcp_servers,
912 user_api_key_auth=user_api_key_auth,
913 client_ip=client_ip,
914 )
915 no_servers_denial: Final[_McpDeniedDetail] = {
916 "error": (
917 "The key has no MCP servers granted, or none of its granted servers is loaded and allowed for "
918 "this client IP. Grant servers or access groups to the key, its team, or its organization "
919 "(object_permission.mcp_servers), check the server's allowed IPs, and reconnect."
920 )
921 }
922 raise HTTPException(status_code=403, detail=no_servers_denial)
925def _aggregate_server_key(server: MCPServer) -> str:
926 """The client-visible key for a server in listing outcomes and spend metadata: the same
927 display prefix (alias, or the short prefix when that mode is enabled) the caller already
928 sees on the tool names. Canonical internal server names never key a caller-readable
929 surface; when the display naming deliberately hides them, the outcome keys must too."""
930 return get_server_prefix(server) or "unknown"
933async def _get_tools_from_mcp_servers(
934 user_api_key_auth: UserAPIKeyAuth | None,
935 mcp_auth_header: str | None,
936 mcp_servers: list[str] | None,
937 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
938 oauth2_headers: dict[str, str] | None = None,
939 raw_headers: dict[str, str] | None = None,
940 log_list_tools_to_spendlogs: bool = False,
941 list_tools_log_source: str | None = None,
942 litellm_trace_id: str | None = None,
943 request_tags: list[str] | None = None,
944 client_ip: str | None = None,
945 mcp_proxy_mode: bool = False,
946) -> AggregateToolListing:
947 """
948 Helper method to fetch tools from MCP servers based on server filtering criteria.
950 Args:
951 user_api_key_auth: User authentication info for access control
952 mcp_auth_header: Optional auth header for MCP server (deprecated)
953 mcp_servers: Optional list of server names/aliases to filter by
954 mcp_server_auth_headers: Optional dict of server-specific auth headers
955 oauth2_headers: Optional dict of oauth2 headers
957 Returns:
958 AggregateToolListing: Combined tools from filtered servers plus each server's
959 classified listing outcome
960 """
962 list_tools_start_time: Final = datetime.now()
963 litellm_logging_obj: LiteLLMLoggingObj | None = None
964 list_tools_request_data: dict[str, object] = {}
966 if log_list_tools_to_spendlogs: 966 ↛ 968line 966 didn't jump to line 968 because the condition on line 966 was never true
967 # This is intentionally minimal: only async_success_handler / post_call_failure_hook
968 rules_obj: Final = Rules()
969 list_tools_call_id: Final = str(uuid.uuid4())
970 # Derive trace_id from raw_headers when not explicitly passed (same as A2A / MCP call_tool)
971 effective_litellm_trace_id: Final = litellm_trace_id or get_chain_id_from_headers(raw_headers)
972 spend_logs_metadata: Final[dict[str, object]] = {
973 "mcp_operation": "list_tools",
974 }
975 if isinstance(list_tools_log_source, str):
976 spend_logs_metadata["source"] = list_tools_log_source
977 if isinstance(mcp_servers, list):
978 spend_logs_metadata["requested_mcp_servers"] = mcp_servers
980 list_tools_request_data = {
981 "model": "MCP: list_tools",
982 "call_type": CallTypes.list_mcp_tools.value,
983 "litellm_call_id": list_tools_call_id,
984 "litellm_trace_id": effective_litellm_trace_id,
985 "metadata": {
986 "spend_logs_metadata": spend_logs_metadata,
987 "headers": logging_safe_mcp_headers(raw_headers),
988 **({"tags": request_tags} if request_tags else {}),
989 },
990 # Provide a small input payload for standard logging
991 "input": [
992 {
993 "role": "system",
994 "content": {
995 "mcp_operation": "list_tools",
996 "requested_mcp_servers": mcp_servers,
997 },
998 }
999 ],
1000 }
1002 # Attach user identifiers using the standard helper
1003 if user_api_key_auth is not None:
1004 LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata(
1005 data=list_tools_request_data,
1006 user_api_key_dict=user_api_key_auth,
1007 _metadata_variable_name="metadata",
1008 )
1010 user_identifier: Final = getattr(user_api_key_auth, "end_user_id", None) or getattr(
1011 user_api_key_auth, "user_id", None
1012 )
1013 if user_identifier:
1014 list_tools_request_data["user"] = user_identifier
1016 try:
1017 litellm_logging_obj, _ = function_setup(
1018 original_function="list_mcp_tools",
1019 is_async_call=False,
1020 rules_obj=rules_obj,
1021 start_time=list_tools_start_time,
1022 **list_tools_request_data,
1023 )
1024 if litellm_logging_obj:
1025 litellm_logging_obj.call_type = CallTypes.list_mcp_tools.value
1026 litellm_logging_obj.model = "MCP: list_tools"
1027 except Exception as logging_error:
1028 verbose_logger.debug("Failed to initialize logging for MCP list_tools: %s", logging_error)
1029 litellm_logging_obj = None
1031 try:
1032 allowed_mcp_servers: Final = await _get_allowed_mcp_servers(
1033 user_api_key_auth=user_api_key_auth,
1034 mcp_servers=mcp_servers,
1035 client_ip=client_ip,
1036 )
1037 if mcp_servers and not allowed_mcp_servers: 1037 ↛ 1038line 1037 didn't jump to line 1038 because the condition on line 1037 was never true
1038 await raise_denied_scoped_mcp_access(
1039 requested_names=mcp_servers,
1040 user_api_key_auth=user_api_key_auth,
1041 client_ip=client_ip,
1042 )
1044 # Pre-fetch OAuth credentials only when at least one server uses OAuth2,
1045 # to avoid an unnecessary DB round-trip on requests with no OAuth2 MCP servers.
1046 _has_oauth2_server = any(getattr(s, "auth_type", None) == MCPAuth.oauth2 for s in allowed_mcp_servers)
1047 _prefetched_oauth_creds: Final = (
1048 await _prefetch_oauth_creds_for_user(user_api_key_auth) if _has_oauth2_server else {}
1049 )
1051 async def _fetch_and_filter_server_tools(
1052 server: MCPServer,
1053 ) -> "tuple[list[MCPTool], ServerOutcome]":
1054 """Fetch and filter tools from a single server, classifying any failure into that
1055 server's outcome so the aggregate can keep serving the healthy subset without a
1056 broken server masquerading as an empty one."""
1057 if server is None: 1057 ↛ 1058line 1057 didn't jump to line 1058 because the condition on line 1057 was never true
1058 return [], ServerListOk(tool_count=0)
1060 server_auth_header, extra_headers = _prepare_mcp_server_headers(
1061 server=server,
1062 mcp_server_auth_headers=mcp_server_auth_headers,
1063 mcp_auth_header=mcp_auth_header,
1064 oauth2_headers=oauth2_headers,
1065 raw_headers=raw_headers,
1066 user_api_key_auth=user_api_key_auth,
1067 scope_servers=allowed_mcp_servers,
1068 )
1070 # Prefer server-stored per-user OAuth when configured, so a stale
1071 # Authorization header from the MCP client cannot override Redis/DB
1072 # (same issue as call_tool in mcp_server_manager: VS Code caches tokens).
1073 from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import ( # noqa: PLC0415
1074 to_server_spec,
1075 )
1077 # A server migrated to the v2 resolver gets its token from the resolver at connect
1078 # time; building it here would double-resolve and be shadowed by the v2 graft. The
1079 # preemptive 401 already challenged a missing token, so one exists for the connect.
1080 migrated_to_v2: Final = to_server_spec(server) is not None
1081 if ( 1081 ↛ 1087line 1081 didn't jump to line 1087 because the condition on line 1081 was never true
1082 not migrated_to_v2
1083 and server.auth_type == MCPAuth.oauth2
1084 and getattr(server, "needs_user_oauth_token", False)
1085 and user_api_key_auth is not None
1086 ):
1087 db_headers: Final = await _get_user_oauth_extra_headers_from_db(
1088 server,
1089 user_api_key_auth,
1090 prefetched_creds=_prefetched_oauth_creds,
1091 )
1092 if db_headers:
1093 extra_headers = db_headers
1095 # If still no OAuth2 token, fall back to pre-fetched creds (non-stale-client path)
1096 elif not migrated_to_v2 and extra_headers is None and server.auth_type == MCPAuth.oauth2: 1096 ↛ 1097line 1096 didn't jump to line 1097 because the condition on line 1096 was never true
1097 extra_headers = await _get_user_oauth_extra_headers_from_db(
1098 server,
1099 user_api_key_auth,
1100 prefetched_creds=_prefetched_oauth_creds,
1101 )
1103 if server.is_byok and server.auth_type != MCPAuth.oauth2 and server_auth_header is None:
1104 server_auth_header = await _get_byok_credential(server, user_api_key_auth)
1106 try:
1107 tools: Final = await global_mcp_server_manager._get_tools_from_server(
1108 server=server,
1109 mcp_auth_header=server_auth_header,
1110 extra_headers=extra_headers,
1111 add_prefix=True, # Always add server prefix
1112 raw_headers=raw_headers,
1113 client_ip=client_ip,
1114 user_api_key_auth=user_api_key_auth,
1115 oauth2_headers=oauth2_headers,
1116 )
1117 filtered_tools = filter_tools_by_allowed_tools(tools, server)
1119 filtered_tools = await filter_tools_by_key_team_permissions(
1120 tools=filtered_tools,
1121 server_id=server.server_id,
1122 user_api_key_auth=user_api_key_auth,
1123 )
1125 if mcp_proxy_mode: 1125 ↛ 1126line 1125 didn't jump to line 1126 because the condition on line 1125 was never true
1126 from litellm.proxy._experimental.mcp_server.tool_search import with_mcp_proxy_identity
1128 filtered_tools = [ # mutable-ok: MCP tool pipeline
1129 with_mcp_proxy_identity(tool, server.server_id) for tool in filtered_tools
1130 ]
1131 else:
1132 filtered_tools = apply_tool_overrides(filtered_tools, server)
1134 verbose_logger.debug(
1135 "Successfully fetched %s tools from server %s, %s after filtering",
1136 len(tools),
1137 server.name,
1138 len(filtered_tools),
1139 )
1140 return filtered_tools, ServerListOk(tool_count=len(filtered_tools))
1141 except MCPUpstreamAuthError as e:
1142 # Absorb so one unauthenticated server does not empty every other server's
1143 # tools. Surfacing the upstream 401 to the client as a re-auth challenge is
1144 # intentionally not done here: raising from this list handler cannot produce a
1145 # 401 + WWW-Authenticate (the MCP session manager serializes it as a JSON-RPC
1146 # error). Single-server routes surface it via the request-scope preemptive
1147 # check in _raise_preemptive_401_for_unauthenticated_servers instead.
1148 verbose_logger.debug("MCP list_tools: omitting %s; it needs upstream auth", server.name)
1149 return [], classify_list_exception(e)
1150 except Exception as e:
1151 verbose_logger.exception("Error getting tools from server %s: %s", server.name, e)
1152 return [], classify_list_exception(e)
1154 # Fetch tools from all servers in parallel
1155 tasks: Final = [_fetch_and_filter_server_tools(server) for server in allowed_mcp_servers]
1156 results: Final = await asyncio.gather(*tasks)
1158 # Flatten results into single list
1159 all_tools: Final[list[MCPTool]] = [tool for tools, _ in results for tool in tools]
1160 server_outcomes: Final[dict[str, ServerOutcome]] = {
1161 _aggregate_server_key(server): outcome
1162 for server, (_, outcome) in zip(allowed_mcp_servers, results)
1163 if server is not None
1164 }
1166 # If logging is enabled, enrich spend_logs_metadata with counts
1167 if litellm_logging_obj: 1167 ↛ 1168line 1167 didn't jump to line 1168 because the condition on line 1167 was never true
1168 per_server_tool_counts: Final[dict[str, int]] = {
1169 _aggregate_server_key(server): len(server_tools)
1170 for server, (server_tools, _) in zip(allowed_mcp_servers, results)
1171 if server is not None
1172 }
1174 metadata_dict: Final = litellm_logging_obj.model_call_details.get("metadata")
1175 if isinstance(metadata_dict, dict):
1176 spend_meta = metadata_dict.get("spend_logs_metadata")
1177 if not isinstance(spend_meta, dict):
1178 spend_meta = {}
1179 metadata_dict["spend_logs_metadata"] = spend_meta
1180 spend_meta["allowed_server_count"] = len(allowed_mcp_servers)
1181 spend_meta["tool_count_total"] = len(all_tools)
1182 spend_meta["per_server_tool_counts"] = per_server_tool_counts
1183 spend_meta["per_server_list_outcomes"] = {
1184 key: outcome_wire_value(outcome) for key, outcome in server_outcomes.items()
1185 }
1187 end_time: Final = datetime.now()
1188 try:
1189 await litellm_logging_obj.async_success_handler(
1190 result=[tool.model_dump(mode="json") if isinstance(tool, MCPTool) else tool for tool in all_tools],
1191 start_time=list_tools_start_time,
1192 end_time=end_time,
1193 )
1194 except Exception as log_exc:
1195 # list_tools responses must not be dropped due to non-blocking
1196 # observability/serialization failures.
1197 verbose_logger.warning(
1198 "MCP list_tools success logging failed (continuing): %s",
1199 log_exc,
1200 )
1202 verbose_logger.info("Successfully fetched %s tools total from all MCP servers", len(all_tools))
1204 return AggregateToolListing(tools=all_tools, outcomes=server_outcomes)
1205 except Exception as e:
1206 # Only fire failure hook if logging was requested for this list-tools execution
1207 if log_list_tools_to_spendlogs and user_api_key_auth is not None:
1208 try:
1209 from litellm.proxy.proxy_server import proxy_logging_obj
1211 if proxy_logging_obj:
1212 traceback_str: Final = traceback.format_exc(limit=MAXIMUM_TRACEBACK_LINES_TO_LOG)
1213 await proxy_logging_obj.post_call_failure_hook(
1214 request_data=list_tools_request_data or {},
1215 original_exception=e,
1216 user_api_key_dict=user_api_key_auth,
1217 route="/mcp/list_tools",
1218 traceback_str=traceback_str,
1219 )
1220 except Exception:
1221 verbose_logger.debug("Failed to log MCP list_tools failure via post_call_failure_hook")
1222 raise
1225async def _get_prompts_from_mcp_servers(
1226 user_api_key_auth: UserAPIKeyAuth | None,
1227 mcp_auth_header: str | None,
1228 mcp_servers: list[str] | None,
1229 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
1230 oauth2_headers: dict[str, str] | None = None,
1231 raw_headers: dict[str, str] | None = None,
1232 client_ip: str | None = None,
1233) -> list[Prompt]:
1234 """
1235 Helper method to fetch prompt from MCP servers based on server filtering criteria.
1237 Args:
1238 user_api_key_auth: User authentication info for access control
1239 mcp_auth_header: Optional auth header for MCP server (deprecated)
1240 mcp_servers: Optional list of server names/aliases to filter by
1241 mcp_server_auth_headers: Optional dict of server-specific auth headers
1242 oauth2_headers: Optional dict of oauth2 headers
1244 Returns:
1245 List[Prompt]: Combined list of prompts from filtered servers
1246 """
1248 allowed_mcp_servers: Final = await _get_allowed_mcp_servers(
1249 user_api_key_auth=user_api_key_auth,
1250 mcp_servers=mcp_servers,
1251 client_ip=client_ip,
1252 )
1254 # Get prompts from each allowed server
1255 all_prompts: Final = []
1256 for server in allowed_mcp_servers:
1257 if server is None:
1258 continue
1260 server_auth_header, extra_headers = _prepare_mcp_server_headers(
1261 server=server,
1262 mcp_server_auth_headers=mcp_server_auth_headers,
1263 mcp_auth_header=mcp_auth_header,
1264 oauth2_headers=oauth2_headers,
1265 raw_headers=raw_headers,
1266 user_api_key_auth=user_api_key_auth,
1267 scope_servers=allowed_mcp_servers,
1268 )
1270 try:
1271 prompts = await global_mcp_server_manager.get_prompts_from_server(
1272 server=server,
1273 user_api_key_auth=user_api_key_auth,
1274 mcp_auth_header=server_auth_header,
1275 extra_headers=extra_headers,
1276 add_prefix=True, # Always add server prefix
1277 raw_headers=raw_headers,
1278 client_ip=client_ip,
1279 )
1281 all_prompts.extend(prompts)
1283 verbose_logger.debug("Successfully fetched %s prompts from server %s", len(prompts), server.name)
1284 except Exception as e:
1285 verbose_logger.exception("Error getting prompts from server %s: %s", server.name, e)
1286 # Continue with other servers instead of failing completely
1288 verbose_logger.info("Successfully fetched %s prompts total from all MCP servers", len(all_prompts))
1290 return all_prompts
1293async def _get_resources_from_mcp_servers(
1294 user_api_key_auth: UserAPIKeyAuth | None,
1295 mcp_auth_header: str | None,
1296 mcp_servers: list[str] | None,
1297 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
1298 oauth2_headers: dict[str, str] | None = None,
1299 raw_headers: dict[str, str] | None = None,
1300 client_ip: str | None = None,
1301) -> list[Resource]:
1302 """Fetch resources from allowed MCP servers."""
1304 allowed_mcp_servers: Final = await _get_allowed_mcp_servers(
1305 user_api_key_auth=user_api_key_auth,
1306 mcp_servers=mcp_servers,
1307 client_ip=client_ip,
1308 )
1310 all_resources: Final[list[Resource]] = []
1311 for server in allowed_mcp_servers:
1312 if server is None:
1313 continue
1315 server_auth_header, extra_headers = _prepare_mcp_server_headers(
1316 server=server,
1317 mcp_server_auth_headers=mcp_server_auth_headers,
1318 mcp_auth_header=mcp_auth_header,
1319 oauth2_headers=oauth2_headers,
1320 raw_headers=raw_headers,
1321 user_api_key_auth=user_api_key_auth,
1322 scope_servers=allowed_mcp_servers,
1323 )
1325 try:
1326 resources = await global_mcp_server_manager.get_resources_from_server(
1327 server=server,
1328 user_api_key_auth=user_api_key_auth,
1329 mcp_auth_header=server_auth_header,
1330 extra_headers=extra_headers,
1331 add_prefix=True, # Always add server prefix
1332 raw_headers=raw_headers,
1333 client_ip=client_ip,
1334 )
1335 all_resources.extend(resources)
1337 verbose_logger.debug("Successfully fetched %s resources from server %s", len(resources), server.name)
1338 except Exception as e:
1339 verbose_logger.exception("Error getting resources from server %s: %s", server.name, e)
1341 verbose_logger.info("Successfully fetched %s resources total from all MCP servers", len(all_resources))
1343 return all_resources
1346async def _get_resource_templates_from_mcp_servers(
1347 user_api_key_auth: UserAPIKeyAuth | None,
1348 mcp_auth_header: str | None,
1349 mcp_servers: list[str] | None,
1350 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
1351 oauth2_headers: dict[str, str] | None = None,
1352 raw_headers: dict[str, str] | None = None,
1353 client_ip: str | None = None,
1354) -> list[ResourceTemplate]:
1355 """Fetch resource templates from allowed MCP servers."""
1357 allowed_mcp_servers: Final = await _get_allowed_mcp_servers(
1358 user_api_key_auth=user_api_key_auth,
1359 mcp_servers=mcp_servers,
1360 client_ip=client_ip,
1361 )
1363 all_resource_templates: Final[list[ResourceTemplate]] = []
1364 for server in allowed_mcp_servers:
1365 if server is None:
1366 continue
1368 server_auth_header, extra_headers = _prepare_mcp_server_headers(
1369 server=server,
1370 mcp_server_auth_headers=mcp_server_auth_headers,
1371 mcp_auth_header=mcp_auth_header,
1372 oauth2_headers=oauth2_headers,
1373 raw_headers=raw_headers,
1374 user_api_key_auth=user_api_key_auth,
1375 scope_servers=allowed_mcp_servers,
1376 )
1378 try:
1379 resource_templates = await global_mcp_server_manager.get_resource_templates_from_server(
1380 server=server,
1381 user_api_key_auth=user_api_key_auth,
1382 mcp_auth_header=server_auth_header,
1383 extra_headers=extra_headers,
1384 add_prefix=True, # Always add server prefix
1385 raw_headers=raw_headers,
1386 client_ip=client_ip,
1387 )
1388 all_resource_templates.extend(resource_templates)
1389 verbose_logger.debug(
1390 "Successfully fetched %s resource templates from server %s",
1391 len(resource_templates),
1392 server.name,
1393 )
1394 except Exception as e:
1395 verbose_logger.exception(
1396 "Error getting resource templates from server %s: %s",
1397 server.name,
1398 str(e),
1399 )
1401 verbose_logger.info(
1402 "Successfully fetched %s resource templates total from all MCP servers",
1403 len(all_resource_templates),
1404 )
1406 return all_resource_templates
1409async def filter_tools_by_key_team_permissions(
1410 tools: list[MCPTool],
1411 server_id: str,
1412 user_api_key_auth: UserAPIKeyAuth | None,
1413) -> list[MCPTool]:
1414 """
1415 Filter tools based on key/team mcp_tool_permissions.
1417 Note: Tool names in the DB are stored without server prefixes,
1418 but tool names from MCP servers are prefixed. We need to strip
1419 the prefix before comparing.
1420 """
1421 # Filter by key/team tool-level permissions
1422 allowed_tool_names: Final = await MCPRequestHandler.get_allowed_tools_for_server(
1423 server_id=server_id,
1424 user_api_key_auth=user_api_key_auth,
1425 )
1427 # Tools arrive prefixed with the server's own prefix; strip exactly that
1428 # prefix (resolved from the server) rather than the first separator, so a
1429 # prefix containing the separator still reduces to the stored bare name.
1430 server: Final = global_mcp_server_manager.get_mcp_server_by_id(server_id)
1431 return [
1432 t
1433 for t in tools
1434 if MCPRequestHandler.tool_is_granted(strip_known_server_prefix(t.name, server), allowed_tool_names)
1435 ]
1438async def _list_mcp_tools(
1439 user_api_key_auth: UserAPIKeyAuth | None = None,
1440 mcp_auth_header: str | None = None,
1441 mcp_servers: list[str] | None = None,
1442 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
1443 oauth2_headers: dict[str, str] | None = None,
1444 raw_headers: dict[str, str] | None = None,
1445 log_list_tools_to_spendlogs: bool = False,
1446 list_tools_log_source: str | None = None,
1447 client_ip: str | None = None,
1448 mcp_proxy_mode: bool = False,
1449) -> AggregateToolListing:
1450 """
1451 List all available MCP tools.
1453 Args:
1454 user_api_key_auth: User authentication info for access control
1455 mcp_auth_header: Optional auth header for MCP server (deprecated)
1456 mcp_servers: Optional list of server names/aliases to filter by
1457 mcp_server_auth_headers: Optional dict of server-specific auth headers {server_alias: auth_value}
1458 client_ip: Client IP for IP-based server access control
1460 Returns:
1461 AggregateToolListing: Combined tools from all accessible servers plus each server's
1462 classified listing outcome
1463 """
1465 try:
1466 listing: Final = await _get_tools_from_mcp_servers(
1467 user_api_key_auth=user_api_key_auth,
1468 mcp_auth_header=mcp_auth_header,
1469 mcp_servers=mcp_servers,
1470 mcp_server_auth_headers=mcp_server_auth_headers,
1471 oauth2_headers=oauth2_headers,
1472 raw_headers=raw_headers,
1473 log_list_tools_to_spendlogs=log_list_tools_to_spendlogs,
1474 list_tools_log_source=list_tools_log_source,
1475 client_ip=client_ip,
1476 mcp_proxy_mode=mcp_proxy_mode,
1477 )
1478 verbose_logger.debug("Successfully fetched %s tools from managed MCP servers", len(listing.tools))
1479 return listing
1480 except HTTPException:
1481 raise
1482 except Exception as e:
1483 verbose_logger.exception("Error getting tools from managed MCP servers: %s", e)
1484 # Continue with an empty listing instead of failing completely
1485 return AggregateToolListing(tools=[], outcomes={})
1488async def _list_mcp_prompts(
1489 user_api_key_auth: UserAPIKeyAuth | None = None,
1490 mcp_auth_header: str | None = None,
1491 mcp_servers: list[str] | None = None,
1492 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
1493 oauth2_headers: dict[str, str] | None = None,
1494 raw_headers: dict[str, str] | None = None,
1495 client_ip: str | None = None,
1496) -> list[Prompt]:
1497 """
1498 List all available MCP prompts.
1500 Args:
1501 user_api_key_auth: User authentication info for access control
1502 mcp_auth_header: Optional auth header for MCP server (deprecated)
1503 mcp_servers: Optional list of server names/aliases to filter by
1504 mcp_server_auth_headers: Optional dict of server-specific auth headers {server_alias: auth_value}
1506 Returns:
1507 List[Prompt]: Combined list of tools from all accessible servers
1508 """
1509 # Get tools from managed MCP servers with error handling
1510 managed_prompts = []
1511 try:
1512 managed_prompts = await _get_prompts_from_mcp_servers(
1513 user_api_key_auth=user_api_key_auth,
1514 mcp_auth_header=mcp_auth_header,
1515 mcp_servers=mcp_servers,
1516 mcp_server_auth_headers=mcp_server_auth_headers,
1517 oauth2_headers=oauth2_headers,
1518 raw_headers=raw_headers,
1519 client_ip=client_ip,
1520 )
1521 verbose_logger.debug("Successfully fetched %s prompts from managed MCP servers", len(managed_prompts))
1522 except Exception as e:
1523 verbose_logger.exception("Error getting tools from managed MCP servers: %s", e)
1524 # Continue with empty managed tools list instead of failing completely
1526 return managed_prompts
1529async def _list_mcp_resources(
1530 user_api_key_auth: UserAPIKeyAuth | None = None,
1531 mcp_auth_header: str | None = None,
1532 mcp_servers: list[str] | None = None,
1533 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
1534 oauth2_headers: dict[str, str] | None = None,
1535 raw_headers: dict[str, str] | None = None,
1536 client_ip: str | None = None,
1537) -> list[Resource]:
1538 """List all available MCP resources."""
1540 managed_resources: list[Resource] = []
1541 try:
1542 managed_resources = await _get_resources_from_mcp_servers(
1543 user_api_key_auth=user_api_key_auth,
1544 mcp_auth_header=mcp_auth_header,
1545 mcp_servers=mcp_servers,
1546 mcp_server_auth_headers=mcp_server_auth_headers,
1547 oauth2_headers=oauth2_headers,
1548 raw_headers=raw_headers,
1549 client_ip=client_ip,
1550 )
1551 verbose_logger.debug("Successfully fetched %s resources from managed MCP servers", len(managed_resources))
1552 except Exception as e:
1553 verbose_logger.exception("Error getting resources from managed MCP servers: %s", e)
1555 return managed_resources
1558async def _list_mcp_resource_templates(
1559 user_api_key_auth: UserAPIKeyAuth | None = None,
1560 mcp_auth_header: str | None = None,
1561 mcp_servers: list[str] | None = None,
1562 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
1563 oauth2_headers: dict[str, str] | None = None,
1564 raw_headers: dict[str, str] | None = None,
1565 client_ip: str | None = None,
1566) -> list[ResourceTemplate]:
1567 """List all available MCP resource templates."""
1569 managed_resource_templates: list[ResourceTemplate] = []
1570 try:
1571 managed_resource_templates = await _get_resource_templates_from_mcp_servers(
1572 user_api_key_auth=user_api_key_auth,
1573 mcp_auth_header=mcp_auth_header,
1574 mcp_servers=mcp_servers,
1575 mcp_server_auth_headers=mcp_server_auth_headers,
1576 oauth2_headers=oauth2_headers,
1577 raw_headers=raw_headers,
1578 client_ip=client_ip,
1579 )
1580 verbose_logger.debug(
1581 "Successfully fetched %s resource templates from managed MCP servers",
1582 len(managed_resource_templates),
1583 )
1584 except Exception as e:
1585 verbose_logger.exception(
1586 "Error getting resource templates from managed MCP servers: %s",
1587 str(e),
1588 )
1590 return managed_resource_templates
1593def _resolve_display_name_to_original(
1594 name: str,
1595 allowed_mcp_servers: list[MCPServer],
1596) -> str:
1597 """Translate a display-name override back to the original prefixed tool name.
1599 When a client received a customised display name from tools/list (e.g.
1600 "Get Pet") it will call tools/call with that same string. We need to
1601 reverse-map it to the original prefixed name (e.g.
1602 "petstore_mcp-getPetById") before any routing or permission logic runs.
1603 """
1604 for server in allowed_mcp_servers:
1605 display_map = server.tool_name_to_display_name or {}
1606 for unprefixed_name, display_name in display_map.items():
1607 if display_name == name:
1608 return add_server_prefix_to_name(unprefixed_name, get_server_prefix(server))
1609 return name
1612async def _get_byok_credential(
1613 mcp_server: MCPServer,
1614 user_api_key_auth: UserAPIKeyAuth | None,
1615) -> str | None:
1616 """Retrieve the stored BYOK credential for a user+server pair, served from the worker cache within its TTL."""
1617 if not mcp_server.is_byok: 1617 ↛ 1618line 1617 didn't jump to line 1618 because the condition on line 1617 was never true
1618 return None
1619 user_id: Final = (user_api_key_auth.user_id if user_api_key_auth else None) or ""
1620 if not user_id: 1620 ↛ 1621line 1620 didn't jump to line 1621 because the condition on line 1620 was never true
1621 return None
1623 cached: Final = get_cached_byok_credential(user_id, mcp_server.server_id)
1624 if cached is not None:
1625 return cached.credential
1627 from litellm.proxy._experimental.mcp_server.db import get_user_credential
1628 from litellm.proxy.proxy_server import prisma_client
1630 if prisma_client is None: 1630 ↛ 1631line 1630 didn't jump to line 1631 because the condition on line 1630 was never true
1631 return None
1632 credential: Final = await get_user_credential(
1633 prisma_client=prisma_client,
1634 user_id=user_id,
1635 server_id=mcp_server.server_id,
1636 )
1637 cache_byok_credential(user_id, mcp_server.server_id, credential)
1638 return credential
1641async def _check_byok_credential(
1642 mcp_server: MCPServer,
1643 user_api_key_auth: UserAPIKeyAuth | None,
1644) -> None:
1645 """
1646 If the MCP server is BYOK-enabled, verify that the requesting user has a
1647 stored credential. When no credential is found, raise an HTTP 401 with a
1648 WWW-Authenticate header that points the MCP client to our OAuth metadata
1649 endpoint so it can drive the authorization flow.
1650 """
1651 if not mcp_server.is_byok:
1652 return
1654 user_id: Final = (user_api_key_auth.user_id if user_api_key_auth else None) or ""
1655 if not user_id:
1656 raise HTTPException(
1657 status_code=401,
1658 detail={
1659 "error": "byok_auth_required",
1660 "server_id": mcp_server.server_id,
1661 "server_name": mcp_server.server_name or mcp_server.name,
1662 "message": "User identity is required for BYOK servers",
1663 },
1664 headers={"WWW-Authenticate": get_byok_www_authenticate()},
1665 )
1667 cached: Final = get_cached_byok_credential(user_id, mcp_server.server_id)
1668 if cached is not None:
1669 if cached.credential is None:
1670 raise HTTPException(
1671 status_code=401,
1672 detail={
1673 "error": "byok_auth_required",
1674 "server_id": mcp_server.server_id,
1675 "server_name": mcp_server.server_name or mcp_server.name,
1676 "message": (
1677 "No stored credential found for this BYOK server. "
1678 "Complete the OAuth authorization flow to provide your API key."
1679 ),
1680 },
1681 headers={"WWW-Authenticate": get_byok_www_authenticate()},
1682 )
1683 return
1685 from litellm.proxy._experimental.mcp_server.db import get_user_credential
1686 from litellm.proxy.proxy_server import prisma_client
1688 if prisma_client is None:
1689 # Fail closed on DB unavailability: returning here previously
1690 # bypassed the ownership check and let any proxy-authenticated
1691 # caller invoke BYOK tools during outage windows.
1692 raise HTTPException(
1693 status_code=503,
1694 detail={
1695 "error": "byok_auth_unavailable",
1696 "server_id": mcp_server.server_id,
1697 "server_name": mcp_server.server_name or mcp_server.name,
1698 "message": "BYOK credential check requires a database connection.",
1699 },
1700 )
1702 credential: Final = await get_user_credential(
1703 prisma_client=prisma_client,
1704 user_id=user_id,
1705 server_id=mcp_server.server_id,
1706 )
1707 cache_byok_credential(user_id, mcp_server.server_id, credential)
1708 if credential is None:
1709 raise HTTPException(
1710 status_code=401,
1711 detail={
1712 "error": "byok_auth_required",
1713 "server_id": mcp_server.server_id,
1714 "server_name": mcp_server.server_name or mcp_server.name,
1715 "message": (
1716 "No stored credential found for this BYOK server. "
1717 "Complete the OAuth authorization flow to provide your API key."
1718 ),
1719 },
1720 headers={"WWW-Authenticate": get_byok_www_authenticate()},
1721 )
1724def _challenge_missing_token_exchange_subject(
1725 server: MCPServer | None,
1726 requested_server: MCPServer | None,
1727 allowed_mcp_servers: list[MCPServer],
1728 user_api_key_auth: UserAPIKeyAuth | None,
1729 oauth2_headers: dict[str, str] | None,
1730 raw_headers: dict[str, str] | None,
1731) -> None:
1732 """Raise the RFC 9728 challenge when a token-exchange server is called without a subject token.
1734 The listing that fills a cold catalog absorbs the upstream 401 by design, so without this
1735 check a missing subject surfaces as an unknown-tool error instead of the challenge the
1736 warm path already raises. Gated to servers the key may reach so an unauthorized caller
1737 learns nothing about the catalog.
1738 """
1739 if server is None or server.auth_type != MCPAuth.oauth2_token_exchange:
1740 return
1741 if requested_server is not None and requested_server.server_id != server.server_id:
1742 return
1743 if all(allowed.server_id != server.server_id for allowed in allowed_mcp_servers):
1744 return
1745 if global_mcp_server_manager._extract_subject_token(oauth2_headers, raw_headers, user_api_key_auth) is not None:
1746 return
1747 from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import ( # noqa: PLC0415 # lazy: adapter pulls MCP subgraph
1748 raise_token_exchange_challenge,
1749 )
1750 from litellm.proxy.middleware.per_request_root_path_middleware import ( # noqa: PLC0415 # lazy: middleware imports proxy utils
1751 get_request_root_path,
1752 )
1754 raise_token_exchange_challenge(server, root_path=get_request_root_path())
1757async def _list_tools_before_first_call(
1758 server: MCPServer | None,
1759 tool_name: str,
1760 allowed_mcp_servers: list[MCPServer],
1761 user_api_key_auth: UserAPIKeyAuth | None,
1762 mcp_auth_header: str | None,
1763 mcp_server_auth_headers: dict[str, dict[str, str]] | None,
1764 oauth2_headers: dict[str, str] | None,
1765 raw_headers: dict[str, str] | None,
1766 client_ip: str | None = None,
1767) -> None:
1768 """List ``server`` with the caller's own credentials when it does not yet expose ``tool_name`` here.
1770 The startup fill skips a server whose upstream wants the caller's token, and mcp 2 no
1771 longer lists before an uncached tools/call, so a worker that has not served tools/list
1772 for this caller would otherwise answer 404 for a tool the caller can see. Gating on the
1773 requested tool, not on any prior listing, keeps callers with different upstream catalogs
1774 from masking each other.
1775 """
1776 if server is None or global_mcp_server_manager.server_exposes_tool(server, tool_name):
1777 return
1778 if all(allowed.server_id != server.server_id for allowed in allowed_mcp_servers):
1779 return
1780 try:
1781 await _get_tools_from_mcp_servers(
1782 user_api_key_auth=user_api_key_auth,
1783 mcp_auth_header=mcp_auth_header,
1784 mcp_servers=[server.server_id],
1785 mcp_server_auth_headers=mcp_server_auth_headers,
1786 oauth2_headers=oauth2_headers,
1787 raw_headers=raw_headers,
1788 client_ip=client_ip,
1789 )
1790 except Exception as e: # noqa: BLE001 # best effort: resolution below answers as it did before
1791 verbose_logger.debug("MCP tools/call: listing %s before its first call failed: %s", server.name, e)
1794async def execute_mcp_tool(
1795 name: str,
1796 arguments: dict[str, object],
1797 allowed_mcp_servers: list[MCPServer],
1798 start_time: datetime,
1799 user_api_key_auth: UserAPIKeyAuth | None = None,
1800 mcp_auth_header: str | None = None,
1801 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
1802 oauth2_headers: dict[str, str] | None = None,
1803 raw_headers: dict[str, str] | None = None,
1804 host_progress_callback: ProgressCallback | None = None,
1805 guardrail_context: Mapping[str, object] | None = None,
1806 client_ip: str | None = None,
1807 **kwargs: object, # kwargs-ok: preserves the existing REST and decorated logging call contract
1808) -> CallToolResult:
1809 context: Final = prepare_context(
1810 user_api_key_auth=user_api_key_auth,
1811 mcp_auth_header=mcp_auth_header,
1812 mcp_server_auth_headers=mcp_server_auth_headers,
1813 oauth2_headers=oauth2_headers,
1814 raw_headers=raw_headers,
1815 client_ip=client_ip,
1816 )
1817 operation: Final = AuthorizedToolCall(
1818 name=name,
1819 arguments=arguments,
1820 allowed_mcp_servers=tuple(allowed_mcp_servers),
1821 start_time=start_time,
1822 host_progress_callback=host_progress_callback,
1823 guardrail_context=guardrail_context,
1824 logging_data=types.MappingProxyType(kwargs),
1825 )
1826 return await GatewayOperations().execute(operation, context)
1829async def _execute_mcp_tool(
1830 name: str,
1831 arguments: dict[str, object],
1832 allowed_mcp_servers: list[MCPServer],
1833 start_time: datetime,
1834 user_api_key_auth: UserAPIKeyAuth | None = None,
1835 mcp_auth_header: str | None = None,
1836 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
1837 oauth2_headers: dict[str, str] | None = None,
1838 raw_headers: dict[str, str] | None = None,
1839 host_progress_callback: ProgressCallback | None = None,
1840 guardrail_context: Mapping[str, object] | None = None,
1841 client_ip: str | None = None,
1842 **kwargs: Any,
1843) -> CallToolResult:
1844 """
1845 Execute MCP tool.
1847 This function assumes permission checks have already been performed.
1849 Args:
1850 name: Tool name (may include server prefix)
1851 arguments: Tool arguments
1852 allowed_mcp_servers: Pre-validated list of servers the user can access
1853 start_time: Start time for logging
1854 user_api_key_auth: Optional user API key auth for logging
1855 mcp_auth_header: Optional MCP auth header
1856 mcp_server_auth_headers: Optional server-specific auth headers
1857 oauth2_headers: Optional OAuth2 headers
1858 raw_headers: Optional raw HTTP headers
1859 **kwargs: Additional arguments (e.g., litellm_logging_obj)
1861 Returns:
1862 CallToolResult: Tool execution result
1863 """
1864 # Track resolved MCP server for both permission checks and dispatch
1865 mcp_server: MCPServer | None = None
1866 requested_server_id: Final[str | None] = kwargs.get("requested_server_id")
1868 # If the client called with a display-name override (e.g. "Get Pet"),
1869 # translate it back to the original prefixed name before any routing.
1870 name = _resolve_display_name_to_original(name, allowed_mcp_servers)
1872 # Remove prefix from tool name for logging and processing
1873 original_tool_name, server_name = split_server_prefix_from_name(name)
1875 requested_server: MCPServer | None = None
1876 if requested_server_id:
1877 requested_server = next(
1878 (s for s in allowed_mcp_servers if s.server_id == requested_server_id),
1879 None,
1880 )
1882 name_is_prefixed = False
1883 if requested_server is not None and MCP_TOOL_PREFIX_SEPARATOR in name:
1884 all_registry_prefixes: Final[set[str]] = set()
1885 for registry_server in global_mcp_server_manager.get_registry().values():
1886 for known_prefix in iter_known_server_prefixes(registry_server):
1887 all_registry_prefixes.add(normalize_server_name(known_prefix))
1888 name_is_prefixed = is_tool_name_prefixed(name, known_server_prefixes=all_registry_prefixes)
1890 first_call_target: Final = (
1891 requested_server
1892 if requested_server is not None and not name_is_prefixed
1893 else global_mcp_server_manager.server_owning_tool_name_prefix(name)
1894 )
1895 first_call_tool_name: Final = (
1896 name
1897 if first_call_target is None or (requested_server is not None and not name_is_prefixed)
1898 else strip_known_server_prefix(name, first_call_target)
1899 )
1900 _challenge_missing_token_exchange_subject(
1901 server=first_call_target,
1902 requested_server=requested_server,
1903 allowed_mcp_servers=allowed_mcp_servers,
1904 user_api_key_auth=user_api_key_auth,
1905 oauth2_headers=oauth2_headers,
1906 raw_headers=raw_headers,
1907 )
1908 await _list_tools_before_first_call(
1909 server=first_call_target,
1910 tool_name=first_call_tool_name,
1911 allowed_mcp_servers=allowed_mcp_servers,
1912 user_api_key_auth=user_api_key_auth,
1913 mcp_auth_header=mcp_auth_header,
1914 mcp_server_auth_headers=mcp_server_auth_headers,
1915 oauth2_headers=oauth2_headers,
1916 raw_headers=raw_headers,
1917 client_ip=client_ip,
1918 )
1920 if requested_server is not None and not name_is_prefixed:
1921 # REST callers may pass server_id with the upstream tool name (no
1922 # LiteLLM prefix). The first segment is not a registered server
1923 # prefix, so the whole string is the upstream tool name and may
1924 # legitimately contain the separator (e.g. "text-to-speech").
1925 # server_id is authoritative for routing and auth.
1926 mcp_server = requested_server
1927 server_name = requested_server.name
1928 original_tool_name = name
1929 else:
1930 # Resolve from tool name (MCP JSON-RPC or prefixed REST tool names).
1931 mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(name)
1932 if mcp_server is None and requested_server is not None:
1933 for known_prefix in iter_known_server_prefixes(requested_server):
1934 candidate = global_mcp_server_manager._get_mcp_server_from_tool_name(
1935 add_server_prefix_to_name(name, known_prefix)
1936 )
1937 if candidate is not None:
1938 mcp_server = candidate
1939 break
1940 if mcp_server is not None:
1941 server_name = mcp_server.name
1942 original_tool_name = strip_known_server_prefix(name, mcp_server)
1944 if requested_server is not None:
1945 if mcp_server is not None and mcp_server.server_id != requested_server.server_id:
1946 raise HTTPException(
1947 status_code=403,
1948 detail={
1949 "error": "tool_server_mismatch",
1950 "message": (
1951 f"Tool '{name}' belongs to MCP server "
1952 f"'{mcp_server.name}' but request specified "
1953 f"server_id for '{requested_server.name}'."
1954 ),
1955 },
1956 )
1957 if mcp_server is None:
1958 mcp_server = requested_server
1959 server_name = requested_server.name
1960 original_tool_name = strip_known_server_prefix(name, requested_server)
1962 # Only enforce server-level permissions when we can resolve a server
1963 if server_name:
1964 if not MCPRequestHandler.is_tool_allowed(
1965 allowed_mcp_servers=[server.name for server in allowed_mcp_servers],
1966 server_name=server_name,
1967 ):
1968 raise HTTPException(
1969 status_code=403,
1970 detail="User not allowed to call this tool.",
1971 )
1973 standard_logging_mcp_tool_call: Final[StandardLoggingMCPToolCall] = _get_standard_logging_mcp_tool_call(
1974 name=original_tool_name, # Use original name for logging
1975 arguments=arguments,
1976 server_name=server_name,
1977 session_id=_mcp_session_id_from_headers(raw_headers),
1978 )
1979 litellm_logging_obj: Final[LiteLLMLoggingObj | None] = kwargs.get("litellm_logging_obj", None)
1980 if litellm_logging_obj:
1981 litellm_logging_obj.model_call_details["mcp_tool_call_metadata"] = standard_logging_mcp_tool_call
1982 litellm_logging_obj.model = f"MCP: {name}"
1983 litellm_logging_obj.model_call_details["model"] = f"MCP: {name}"
1984 # Resolve the MCP server early so BYOK checks and credential injection
1985 # apply to ALL dispatch paths (local tool registry AND managed MCP server).
1986 if mcp_server is None:
1987 mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(name)
1989 if mcp_server:
1990 standard_logging_mcp_tool_call["mcp_server_cost_info"] = (mcp_server.mcp_info or {}).get("mcp_server_cost_info")
1991 if litellm_logging_obj:
1992 litellm_logging_obj.model_call_details["mcp_tool_call_metadata"] = standard_logging_mcp_tool_call
1994 # BYOK: retrieve the stored per-user credential. A single DB call
1995 # both checks existence and fetches the value, avoiding a double query.
1996 if mcp_server.is_byok and not mcp_auth_header:
1997 byok_cred: Final = await _get_byok_credential(mcp_server, user_api_key_auth)
1998 if byok_cred is None:
1999 raise HTTPException(
2000 status_code=401,
2001 detail={
2002 "error": "byok_auth_required",
2003 "server_id": mcp_server.server_id,
2004 "server_name": mcp_server.server_name or mcp_server.name,
2005 "message": (
2006 "No stored credential found for this BYOK server. "
2007 "Complete the OAuth authorization flow to provide your API key."
2008 ),
2009 },
2010 headers={"WWW-Authenticate": get_byok_www_authenticate()},
2011 )
2012 mcp_auth_header = byok_cred
2013 elif mcp_server.is_byok:
2014 # External auth header supplied; still enforce user-identity check.
2015 await _check_byok_credential(mcp_server, user_api_key_auth)
2017 # Check if tool exists in local registry first (for OpenAPI-based tools)
2018 # These tools are registered with their prefixed names
2019 #########################################################
2020 local_tool: Final = global_mcp_tool_registry.get_tool(name)
2021 if local_tool:
2022 # OpenAPI-backed tools used to bypass `pre_call_tool_check` —
2023 # only the managed path ran allowed/banned-tool checks, key/team
2024 # tool permissions, and parameter validation. Run the same checks
2025 # before dispatching to the local registry. Refuse the call if
2026 # we cannot resolve a server: tools registered via
2027 # openapi_to_mcp_generator are always tied to a server, so a
2028 # missing mcp_server here means the tool->server mapping has
2029 # not finished initializing or the registry entry is orphaned.
2030 # Skipping the check would re-open the same authorization gap.
2031 if mcp_server is None:
2032 raise HTTPException(
2033 status_code=503,
2034 detail=(
2035 f"MCP server for tool '{name}' is not available; "
2036 "refusing to dispatch without authorization checks. "
2037 "Retry once the server is registered."
2038 ),
2039 )
2041 # `pre_call_tool_check` calls into `proxy_logging_obj` for the
2042 # pre-call guardrail hooks, so source it from the canonical
2043 # `proxy_server` module the same way `_handle_managed_mcp_tool`
2044 # does. `kwargs.get("proxy_logging_obj")` is None on the MCP
2045 # entry path and would crash with AttributeError after the
2046 # security checks pass.
2047 from litellm.proxy.proxy_server import proxy_logging_obj
2049 hook_result = await global_mcp_server_manager.pre_call_tool_check(
2050 name=original_tool_name,
2051 arguments=arguments or {},
2052 server_name=server_name or mcp_server.name,
2053 user_api_key_auth=user_api_key_auth,
2054 proxy_logging_obj=proxy_logging_obj,
2055 server=mcp_server,
2056 raw_headers=raw_headers,
2057 litellm_logging_obj=litellm_logging_obj,
2058 guardrail_context=guardrail_context,
2059 )
2060 # `pre_call_tool_check` may return guardrail-modified
2061 # arguments; honor them on the local path too.
2062 if isinstance(hook_result, dict) and "arguments" in hook_result:
2063 arguments = hook_result["arguments"]
2065 verbose_logger.debug("Executing local registry tool: %s", name)
2066 # The credential rides ContextVars because the tool function has its
2067 # headers baked into the closure at registration time.
2068 auth_header_value, openapi_forwarded_headers, upstream_credential = _resolve_openapi_tool_auth(
2069 mcp_server=mcp_server,
2070 mcp_auth_header=mcp_auth_header,
2071 mcp_server_auth_headers=mcp_server_auth_headers,
2072 raw_headers=raw_headers,
2073 user_api_key_auth=user_api_key_auth,
2074 )
2075 (
2076 resolved_auth_headers,
2077 forwarded_headers,
2078 ) = await global_mcp_server_manager.resolve_openapi_upstream_auth(
2079 mcp_server=mcp_server,
2080 oauth2_headers=oauth2_headers,
2081 raw_headers=raw_headers,
2082 mcp_auth_header=upstream_credential,
2083 user_api_key_auth=user_api_key_auth,
2084 forwarded_headers=openapi_forwarded_headers,
2085 )
2087 _auth_token: Final = _request_auth_header.set(auth_header_value)
2088 _extra_token: Final = _request_extra_headers.set(forwarded_headers)
2089 _resolved_token: Final = _request_resolved_auth_headers.set(resolved_auth_headers)
2090 try:
2091 response = await _handle_local_mcp_tool(name, arguments)
2092 finally:
2093 _request_auth_header.reset(_auth_token)
2094 _request_extra_headers.reset(_extra_token)
2095 _request_resolved_auth_headers.reset(_resolved_token)
2097 # Try managed MCP server tool (the name is bare; the prefix boundary was
2098 # already resolved above against this server's registered prefixes)
2099 # Primary and recommended way to use external MCP servers
2100 #########################################################
2101 elif mcp_server:
2102 response = await _handle_managed_mcp_tool(
2103 server_name=server_name,
2104 name=original_tool_name,
2105 arguments=arguments,
2106 user_api_key_auth=user_api_key_auth,
2107 mcp_auth_header=mcp_auth_header,
2108 mcp_server_auth_headers=mcp_server_auth_headers,
2109 oauth2_headers=oauth2_headers,
2110 raw_headers=raw_headers,
2111 client_ip=client_ip,
2112 litellm_logging_obj=litellm_logging_obj,
2113 guardrail_context=guardrail_context,
2114 host_progress_callback=host_progress_callback,
2115 )
2117 # Fall back to local tool registry with original name (legacy support)
2118 #########################################################
2119 # Deprecated: Local MCP Server Tool
2120 #########################################################
2121 else:
2122 # Gate only what can actually dispatch. When the unprefixed name is
2123 # not in the registry either, `_handle_local_mcp_tool` below reports
2124 # 404 and nothing runs, so demanding a server here would turn every
2125 # unknown tool name into a misleading 503.
2126 if global_mcp_tool_registry.get_tool(original_tool_name) is not None:
2127 # `mcp_server` is None here because the tool name is not in the
2128 # tool -> server mapping, but the name still carries a prefix
2129 # that the server-level check above compared against the
2130 # caller's `allowed_mcp_servers` by exact `name`. So the named
2131 # server is in that list and can carry the tool-level checks,
2132 # even with the mapping cold. Resolve it from
2133 # `allowed_mcp_servers` rather than the registry: the registry
2134 # would happily return a server the caller holds no grant for,
2135 # and matching anything other than `name` would accept a server
2136 # the check never validated.
2137 prefix_server: Final = next(
2138 (candidate for candidate in allowed_mcp_servers if candidate.name == server_name),
2139 None,
2140 )
2141 if prefix_server is None:
2142 # A non-empty prefix that passed the server-level check
2143 # always matches here, so this arm only fires when the
2144 # prefix was empty, which is exactly the case that check
2145 # skips. Fail closed rather than dispatch with no server to
2146 # evaluate a tool ceiling against.
2147 raise HTTPException(
2148 status_code=503,
2149 detail=(
2150 f"MCP server for tool '{original_tool_name}' is not available; "
2151 "refusing to dispatch without authorization checks. "
2152 "Retry once the server is registered."
2153 ),
2154 )
2156 from litellm.proxy.proxy_server import proxy_logging_obj
2158 hook_result = await global_mcp_server_manager.pre_call_tool_check(
2159 name=original_tool_name,
2160 arguments=arguments,
2161 server_name=server_name,
2162 user_api_key_auth=user_api_key_auth,
2163 proxy_logging_obj=proxy_logging_obj,
2164 server=prefix_server,
2165 raw_headers=raw_headers,
2166 litellm_logging_obj=litellm_logging_obj,
2167 guardrail_context=guardrail_context,
2168 )
2169 if "arguments" in hook_result:
2170 arguments = hook_result["arguments"] # pyright: ignore[reportAny] # hook returns untyped args
2172 response = await _handle_local_mcp_tool(original_tool_name, arguments)
2174 return await _run_post_mcp_call_guardrails(
2175 result=response,
2176 litellm_logging_obj=litellm_logging_obj,
2177 user_api_key_auth=user_api_key_auth,
2178 request_data=kwargs,
2179 )
2182async def _run_post_mcp_call_guardrails(
2183 result: CallToolResult,
2184 litellm_logging_obj: LiteLLMLoggingObj | None,
2185 user_api_key_auth: UserAPIKeyAuth | None,
2186 request_data: Mapping[str, object],
2187) -> CallToolResult:
2188 """Run ``post_mcp_call`` guardrails over an executed tool result.
2190 Lives on ``execute_mcp_tool``'s return path rather than inside
2191 ``_fire_mcp_tool_call_logging`` so enforcement never depends on logging
2192 being configured, and so every dispatch route gets it: the MCP protocol
2193 handler, the REST endpoint, and tool search all funnel through here.
2194 A guardrail that rejects the result raises, matching ``pre_mcp_call``.
2195 """
2196 from litellm.proxy.proxy_server import proxy_logging_obj
2198 if proxy_logging_obj is None:
2199 return result
2200 return await proxy_logging_obj.post_mcp_call_hook(
2201 response=result,
2202 request_data=(
2203 litellm_logging_obj.model_call_details if litellm_logging_obj is not None else dict(request_data)
2204 ),
2205 user_api_key_dict=user_api_key_auth,
2206 )
2209async def _fire_mcp_tool_call_logging(
2210 logging_obj: LiteLLMLoggingObj,
2211 result: CallToolResult,
2212 start_time: datetime,
2213 end_time: datetime,
2214 user_api_key_auth: UserAPIKeyAuth | None = None,
2215 request_data: Mapping[str, object] | None = None,
2216) -> CallToolResult:
2217 """Fire post-call logging for an executed MCP tool call, returning the result to send.
2219 The returned result is what the caller must forward to the client: a
2220 ``post_mcp_call`` guardrail may rewrite the tool output (e.g. mask
2221 sensitive values) or reject it, in which case its exception propagates.
2222 Guardrails run before the success/failure logging so the masked text, not
2223 the raw one, is what gets logged.
2225 A result with ``is_error=True`` is logged as a failure (``status="failure"``
2226 payload, so OTel marks the span ERROR) while the HTTP wire behavior stays
2227 200 + ``isError: true`` per the MCP spec. The error check runs after
2228 ``async_post_mcp_tool_call_hook`` because guardrails may flip the result
2229 to ``is_error=True`` in that hook. Raised exceptions never reach here (the
2230 ``@client`` wrapper and ``call_mcp_tool``'s except path log those), so
2231 this cannot double-log a failure.
2233 ``request_data`` may carry credential-bearing fields (the REST path puts
2234 ``raw_headers``, ``mcp_auth_header``, ``mcp_server_auth_headers``, and
2235 ``oauth2_headers`` at the top level of its data dict), so those are
2236 stripped before the dict is handed to ``post_call_failure_hook``
2237 callbacks.
2238 """
2239 from litellm.proxy.proxy_server import proxy_logging_obj
2241 logging_obj.post_call(original_response=result)
2242 result = await logging_obj.async_post_mcp_tool_call_hook(
2243 kwargs=logging_obj.model_call_details,
2244 response_obj=result,
2245 start_time=start_time,
2246 end_time=end_time,
2247 )
2248 logging_obj.call_type = CallTypes.call_mcp_tool.value
2249 error_message: Final = extract_mcp_tool_result_error_message(result)
2250 if error_message is None:
2251 await logging_obj.async_success_handler(result=result, start_time=start_time, end_time=end_time)
2252 return result
2254 logging_obj.has_run_logging(event_type="sync_success")
2255 logging_obj.has_run_logging(event_type="async_success")
2256 tool_error: Final = MCPToolResultError(error_message)
2257 logging_obj.failure_handler(tool_error, "", start_time, end_time)
2258 await logging_obj.async_failure_handler(tool_error, "", start_time, end_time)
2260 if user_api_key_auth is None:
2261 return result
2263 if proxy_logging_obj:
2264 sanitized_request_data: Final = {
2265 key: value for key, value in (request_data or {}).items() if key not in _MCP_CREDENTIAL_REQUEST_FIELDS
2266 }
2267 await proxy_logging_obj.post_call_failure_hook(
2268 request_data=sanitized_request_data,
2269 original_exception=tool_error,
2270 user_api_key_dict=user_api_key_auth,
2271 route="/mcp/call_tool",
2272 )
2273 return result
2276async def fire_mcp_tool_call_failure_logging(
2277 logging_obj: LiteLLMLoggingObj | None,
2278 exception: Exception,
2279 start_time: datetime,
2280 user_api_key_auth: UserAPIKeyAuth | None,
2281 request_data: Mapping[str, object],
2282) -> None:
2283 """Failure logging shared by the ``/mcp`` path and the REST endpoint. Call from
2284 inside the ``except`` block so the traceback is still available.
2286 The failure handlers run first because ``_ProxyDBLogger.async_post_call_failure_hook``
2287 builds the failure spend-log row from the ``standard_logging_object`` they produce;
2288 both gate on ``should_run_logging``, so the ``@client`` wrapper does not log twice.
2289 A relayed upstream 401 (``MCPUpstreamAuthError``) is an expected caller-must-reauth
2290 signal and skips ``post_call_failure_hook``, which fires the ``llm_exceptions`` alert.
2291 """
2292 from litellm.proxy.proxy_server import proxy_logging_obj
2294 traceback_str: Final = traceback.format_exc(limit=MAXIMUM_TRACEBACK_LINES_TO_LOG)
2295 if logging_obj is not None:
2296 end_time: Final = datetime.now() # noqa: DTZ005 # naive to match `start_time`, which it is subtracted from
2297 logging_obj.failure_handler(exception, traceback_str, start_time, end_time)
2298 await logging_obj.async_failure_handler(exception, traceback_str, start_time, end_time)
2300 if isinstance(exception, MCPUpstreamAuthError) or not proxy_logging_obj or user_api_key_auth is None:
2301 return
2302 sanitized_request_data: Final = {
2303 key: value for key, value in request_data.items() if key not in _MCP_CREDENTIAL_REQUEST_FIELDS
2304 }
2305 await proxy_logging_obj.post_call_failure_hook(
2306 request_data=sanitized_request_data,
2307 original_exception=exception,
2308 user_api_key_dict=user_api_key_auth,
2309 route="/mcp/call_tool",
2310 traceback_str=traceback_str,
2311 )
2314@client
2315async def call_mcp_tool(
2316 name: str,
2317 arguments: dict[str, object] | None = None,
2318 user_api_key_auth: UserAPIKeyAuth | None = None,
2319 mcp_auth_header: str | None = None,
2320 mcp_servers: list[str] | None = None,
2321 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
2322 oauth2_headers: dict[str, str] | None = None,
2323 raw_headers: dict[str, str] | None = None,
2324 client_ip: str | None = None,
2325 **kwargs: Any,
2326) -> CallToolResult:
2327 """
2328 Call a specific tool with the provided arguments (handles prefixed tool names).
2329 """
2330 start_time: Final = datetime.now()
2331 litellm_logging_obj: Final[LiteLLMLoggingObj | None] = kwargs.get("litellm_logging_obj", None)
2333 try:
2334 if arguments is None:
2335 raise HTTPException(status_code=400, detail="Request arguments are required")
2337 ## CHECK IF USER IS ALLOWED TO CALL THIS TOOL
2338 allowed_mcp_server_ids: Final = await global_mcp_server_manager.get_allowed_mcp_servers(
2339 user_api_key_auth=user_api_key_auth,
2340 )
2342 allowed_mcp_servers: list[MCPServer] = []
2343 for allowed_mcp_server_id in allowed_mcp_server_ids:
2344 allowed_server = global_mcp_server_manager.get_mcp_server_by_id(allowed_mcp_server_id)
2345 if allowed_server is not None:
2346 # Same request-time oauth2_flow backstop the listing path applies,
2347 # so a null-flow M2M-shape row is treated as M2M on tool calls too.
2348 allowed_server = MCPServerManager.resolve_oauth2_flow_for_request(allowed_server)
2349 allowed_mcp_servers.append(allowed_server)
2351 allowed_mcp_servers = await _get_allowed_mcp_servers_from_mcp_server_names(
2352 mcp_servers=mcp_servers,
2353 allowed_mcp_servers=allowed_mcp_servers,
2354 )
2355 if mcp_servers and not allowed_mcp_servers:
2356 await raise_denied_scoped_mcp_access(
2357 requested_names=mcp_servers,
2358 user_api_key_auth=user_api_key_auth,
2359 client_ip=client_ip,
2360 )
2361 if not allowed_mcp_servers:
2362 raise HTTPException(
2363 status_code=403,
2364 detail="User not allowed to call this tool.",
2365 )
2367 # Delegate to execute_mcp_tool for execution
2368 response = await execute_mcp_tool(
2369 name=name,
2370 arguments=arguments,
2371 allowed_mcp_servers=allowed_mcp_servers,
2372 start_time=start_time,
2373 user_api_key_auth=user_api_key_auth,
2374 mcp_auth_header=mcp_auth_header,
2375 mcp_server_auth_headers=mcp_server_auth_headers,
2376 oauth2_headers=oauth2_headers,
2377 raw_headers=raw_headers,
2378 client_ip=client_ip,
2379 **kwargs,
2380 )
2381 except Exception as e:
2382 await fire_mcp_tool_call_failure_logging(litellm_logging_obj, e, start_time, user_api_key_auth, kwargs)
2383 raise
2385 if litellm_logging_obj:
2386 response = await _fire_mcp_tool_call_logging(
2387 logging_obj=litellm_logging_obj,
2388 result=response,
2389 start_time=start_time,
2390 end_time=datetime.now(),
2391 user_api_key_auth=user_api_key_auth,
2392 request_data=kwargs,
2393 )
2394 return response
2397async def mcp_get_prompt(
2398 name: str,
2399 arguments: dict[str, str] | None = None,
2400 user_api_key_auth: UserAPIKeyAuth | None = None,
2401 mcp_auth_header: str | None = None,
2402 mcp_servers: list[str] | None = None,
2403 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
2404 oauth2_headers: dict[str, str] | None = None,
2405 raw_headers: dict[str, str] | None = None,
2406 client_ip: str | None = None,
2407) -> GetPromptResult:
2408 """
2409 Fetch a specific MCP prompt, handling both prefixed and unprefixed names.
2410 """
2411 allowed_mcp_servers: Final = await _get_allowed_mcp_servers(
2412 user_api_key_auth=user_api_key_auth,
2413 mcp_servers=mcp_servers,
2414 client_ip=client_ip,
2415 )
2417 if not allowed_mcp_servers:
2418 raise HTTPException(
2419 status_code=403,
2420 detail="User not allowed to get this prompt.",
2421 )
2423 # Extract server name from prefixed prompt name
2424 original_prompt_name, server_name = split_server_prefix_from_name(name)
2426 server: Final = next((s for s in allowed_mcp_servers if s.name == server_name), None)
2427 if server is None:
2428 raise HTTPException(
2429 status_code=403,
2430 detail="User not allowed to get this prompt.",
2431 )
2433 server_auth_header, extra_headers = _prepare_mcp_server_headers(
2434 server=server,
2435 mcp_server_auth_headers=mcp_server_auth_headers,
2436 mcp_auth_header=mcp_auth_header,
2437 oauth2_headers=oauth2_headers,
2438 raw_headers=raw_headers,
2439 user_api_key_auth=user_api_key_auth,
2440 )
2442 return await global_mcp_server_manager.get_prompt_from_server(
2443 server=server,
2444 user_api_key_auth=user_api_key_auth,
2445 prompt_name=original_prompt_name,
2446 arguments=arguments,
2447 mcp_auth_header=server_auth_header,
2448 extra_headers=extra_headers,
2449 raw_headers=raw_headers,
2450 client_ip=client_ip,
2451 )
2454async def mcp_read_resource(
2455 url: AnyUrl,
2456 user_api_key_auth: UserAPIKeyAuth | None = None,
2457 mcp_auth_header: str | None = None,
2458 mcp_servers: list[str] | None = None,
2459 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
2460 oauth2_headers: dict[str, str] | None = None,
2461 raw_headers: dict[str, str] | None = None,
2462 client_ip: str | None = None,
2463) -> ReadResourceResult:
2464 """Read resource contents from upstream MCP servers."""
2466 allowed_mcp_servers: Final = await _get_allowed_mcp_servers(
2467 user_api_key_auth=user_api_key_auth,
2468 mcp_servers=mcp_servers,
2469 client_ip=client_ip,
2470 )
2472 if not allowed_mcp_servers:
2473 raise HTTPException(
2474 status_code=403,
2475 detail="User not allowed to read this resource.",
2476 )
2478 if len(allowed_mcp_servers) != 1:
2479 raise HTTPException(
2480 status_code=400,
2481 detail=("Multiple MCP servers configured; read_resource currently supports exactly one allowed server."),
2482 )
2484 server: Final = allowed_mcp_servers[0]
2486 server_auth_header, extra_headers = _prepare_mcp_server_headers(
2487 server=server,
2488 mcp_server_auth_headers=mcp_server_auth_headers,
2489 mcp_auth_header=mcp_auth_header,
2490 oauth2_headers=oauth2_headers,
2491 raw_headers=raw_headers,
2492 user_api_key_auth=user_api_key_auth,
2493 )
2495 return await global_mcp_server_manager.read_resource_from_server(
2496 server=server,
2497 user_api_key_auth=user_api_key_auth,
2498 url=url,
2499 mcp_auth_header=server_auth_header,
2500 extra_headers=extra_headers,
2501 raw_headers=raw_headers,
2502 client_ip=client_ip,
2503 )
2506def _get_standard_logging_mcp_tool_call(
2507 name: str,
2508 arguments: dict[str, object],
2509 server_name: str | None,
2510 session_id: str | None = None,
2511) -> StandardLoggingMCPToolCall:
2512 mcp_server: Final = global_mcp_server_manager._get_mcp_server_from_tool_name(
2513 add_server_prefix_to_name(name, server_name) if server_name else name
2514 )
2515 namespaced_tool_name: Final = f"{server_name}/{name}" if server_name else name
2516 if mcp_server:
2517 mcp_info: Final = mcp_server.mcp_info or {}
2518 return StandardLoggingMCPToolCall(
2519 name=name,
2520 arguments=arguments,
2521 mcp_server_name=mcp_info.get("server_name"),
2522 mcp_server_logo_url=mcp_info.get("logo_url"),
2523 namespaced_tool_name=namespaced_tool_name,
2524 mcp_session_id=session_id,
2525 mcp_auth_mode=mcp_server.auth_type,
2526 mcp_server_resource=_redact_mcp_resource_url(mcp_server.url),
2527 )
2528 else:
2529 return StandardLoggingMCPToolCall(
2530 name=name,
2531 arguments=arguments,
2532 namespaced_tool_name=namespaced_tool_name,
2533 mcp_session_id=session_id,
2534 )
2537async def _handle_managed_mcp_tool(
2538 server_name: str,
2539 name: str,
2540 arguments: dict[str, object],
2541 user_api_key_auth: UserAPIKeyAuth | None = None,
2542 mcp_auth_header: str | None = None,
2543 mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
2544 oauth2_headers: dict[str, str] | None = None,
2545 raw_headers: dict[str, str] | None = None,
2546 litellm_logging_obj: LiteLLMLoggingObj | None = None,
2547 host_progress_callback: ProgressCallback | None = None,
2548 guardrail_context: Mapping[str, object] | None = None,
2549 client_ip: str | None = None,
2550) -> CallToolResult:
2551 """Handle tool execution for managed server tools"""
2552 # Import here to avoid circular import
2553 from litellm.proxy.proxy_server import proxy_logging_obj
2555 call_tool_result: Final = await global_mcp_server_manager.call_tool(
2556 server_name=server_name,
2557 name=name,
2558 arguments=arguments,
2559 user_api_key_auth=user_api_key_auth,
2560 mcp_auth_header=mcp_auth_header,
2561 mcp_server_auth_headers=mcp_server_auth_headers,
2562 oauth2_headers=oauth2_headers,
2563 raw_headers=raw_headers,
2564 client_ip=client_ip,
2565 proxy_logging_obj=proxy_logging_obj,
2566 host_progress_callback=host_progress_callback,
2567 litellm_logging_obj=litellm_logging_obj,
2568 guardrail_context=guardrail_context,
2569 )
2570 verbose_logger.debug("CALL TOOL RESULT: %s", call_tool_result)
2571 return call_tool_result
2574async def _handle_local_mcp_tool(name: str, arguments: dict[str, object]) -> CallToolResult:
2575 """Execute a local-registry tool and report whether it succeeded.
2577 Returns the result rather than bare content because the verdict is part of it: the content
2578 alone cannot say whether the handler failed, so callers used to stamp is_error=False on every
2579 outcome and an upstream rejection was served as tool output.
2581 A failure is reported as ``is_error=True`` here rather than raised, because the REST surface
2582 turns an unrecognized exception into a 500 and an upstream 403 or 429 is not a gateway crash.
2583 ``MCPUpstreamAuthError`` is the exception: it propagates so the caller is told to
2584 re-authenticate, which both renderers already know how to say.
2586 Note: Local tools don't use prefixes, so we use the original name
2587 """
2588 import inspect
2590 tool: Final = global_mcp_tool_registry.get_tool(name)
2591 if not tool:
2592 raise HTTPException(status_code=404, detail=f"Tool '{name}' not found")
2594 try:
2595 if inspect.iscoroutinefunction(tool.handler):
2596 result = await tool.handler(**arguments)
2597 else:
2598 result = tool.handler(**arguments)
2599 except MCPUpstreamAuthError:
2600 raise
2601 except Exception as e:
2602 verbose_logger.exception("Error executing local tool %s: %s", name, e)
2603 return CallToolResult(
2604 content=[TextContent(text=f"Error: {e}", type="text")], # mutable-ok: MCP result content
2605 is_error=True,
2606 )
2607 return CallToolResult(
2608 content=[TextContent(text=str(result), type="text")], # mutable-ok: MCP result content
2609 is_error=False,
2610 )
2613_MCP_CREDENTIAL_REQUEST_FIELDS: Final = frozenset(
2614 {
2615 "raw_headers",
2616 "mcp_auth_header",
2617 "mcp_server_auth_headers",
2618 "oauth2_headers",
2619 "user_api_key_auth",
2620 }
2621)
2624class _McpDeniedDetail(TypedDict):
2625 error: ReadOnly[str]
2628async def _execute_handle_list_tools(
2629 context: OperationContext, params: PaginatedRequestParams, host_progress_callback: ProgressCallback | None = None
2630) -> ListToolsResult:
2631 try:
2632 (
2633 user_api_key_auth,
2634 mcp_auth_header,
2635 mcp_servers,
2636 mcp_server_auth_headers,
2637 oauth2_headers,
2638 raw_headers,
2639 _client_ip,
2640 ) = context.legacy_auth()
2641 verbose_logger.debug("MCP list_tools - User API Key Auth from context: %s", user_api_key_auth)
2642 verbose_logger.debug("MCP list_tools - MCP servers from context: %s", mcp_servers)
2643 verbose_logger.debug(
2644 "MCP list_tools - MCP server auth headers: %s",
2645 list(mcp_server_auth_headers.keys()) if mcp_server_auth_headers else None,
2646 )
2647 from mcp.types import Tool
2649 from litellm.proxy._experimental.mcp_server.tool_search import (
2650 get_mcp_proxy_tool_definitions,
2651 get_virtual_tool_definitions,
2652 )
2654 if context.mcp_proxy_mode:
2655 return ListToolsResult(tools=[Tool.model_validate(d) for d in get_mcp_proxy_tool_definitions()])
2656 if getattr(
2657 getattr(user_api_key_auth, "object_permission", None),
2658 "mcp_tool_search_enabled",
2659 False,
2660 ):
2661 return ListToolsResult(tools=[Tool.model_validate(d) for d in get_virtual_tool_definitions()])
2663 # Get mcp_servers from context variable
2664 verbose_logger.debug("MCP list_tools - Calling _list_mcp_tools")
2665 listing: Final = await _list_mcp_tools(
2666 user_api_key_auth=user_api_key_auth,
2667 mcp_auth_header=mcp_auth_header,
2668 mcp_servers=mcp_servers,
2669 mcp_server_auth_headers=mcp_server_auth_headers,
2670 oauth2_headers=oauth2_headers,
2671 raw_headers=raw_headers,
2672 log_list_tools_to_spendlogs=True,
2673 list_tools_log_source="mcp_protocol",
2674 client_ip=_client_ip,
2675 )
2676 verbose_logger.info("MCP list_tools - Successfully returned %s tools", len(listing.tools))
2677 if not listing.outcomes:
2678 return ListToolsResult(tools=listing.tools)
2679 outcome_meta: Final = {
2680 SERVER_OUTCOMES_META_KEY: {key: outcome_wire_value(outcome) for key, outcome in listing.outcomes.items()}
2681 }
2682 return ListToolsResult.model_validate({"tools": listing.tools, "_meta": outcome_meta})
2683 except HTTPException as e:
2684 from mcp.shared.exceptions import MCPError
2685 from mcp.types import INVALID_REQUEST
2687 raise MCPError(code=INVALID_REQUEST, message=_http_detail_message(e.detail)) from e
2688 except Exception as e:
2689 verbose_logger.exception("Error in list_tools endpoint: %s", e)
2690 # Return empty list instead of failing completely
2691 # This prevents the HTTP stream from failing and allows the client to get a response
2692 return ListToolsResult(tools=[]) # mutable-ok: MCP result payload
2695async def _execute_mcp_server_tool_call(
2696 context: OperationContext, params: CallToolRequestParams, host_progress_callback: ProgressCallback | None = None
2697) -> CallToolResult:
2698 from mcp.types import CallToolResult
2700 from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException
2701 from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request
2702 from litellm.proxy.proxy_server import proxy_config
2704 (
2705 user_api_key_auth,
2706 mcp_auth_header,
2707 mcp_servers,
2708 mcp_server_auth_headers,
2709 oauth2_headers,
2710 raw_headers,
2711 _client_ip,
2712 ) = context.legacy_auth()
2713 verbose_logger.debug(
2714 "MCP mcp_server_tool_call - user_api_key_auth=%s, user_role=%s",
2715 user_api_key_auth,
2716 getattr(user_api_key_auth, "user_role", "N/A"),
2717 )
2719 verbose_logger.debug("MCP mcp_server_tool_call - User API Key Auth from context: %s", user_api_key_auth)
2721 try:
2722 # Inside this try so virtual-tool errors convert to isError
2723 # CallToolResult instead of raising out of the protocol handler.
2724 virtual_tool_result: Final = await _dispatch_virtual_mcp_tool(
2725 name=params.name,
2726 arguments=params.arguments,
2727 user_api_key_auth=user_api_key_auth,
2728 client_ip=_client_ip,
2729 mcp_servers=mcp_servers,
2730 mcp_auth_header=mcp_auth_header,
2731 mcp_server_auth_headers=mcp_server_auth_headers,
2732 oauth2_headers=oauth2_headers,
2733 raw_headers=raw_headers,
2734 mcp_proxy_mode=context.mcp_proxy_mode,
2735 )
2736 if virtual_tool_result is not None:
2737 return virtual_tool_result
2739 # Create a body date for logging
2740 body_data: Final = {"name": params.name, "arguments": params.arguments} # mutable-ok: logging payload
2741 # Set trace/session id from raw_headers so spend logs and logging_obj stay consistent (same as A2A)
2742 chain_id: Final = get_chain_id_from_headers(raw_headers)
2743 if chain_id:
2744 body_data["litellm_trace_id"] = chain_id
2745 body_data["litellm_session_id"] = chain_id
2747 request: Final = build_synthetic_mcp_request(
2748 path="/mcp/tools/call",
2749 raw_headers=raw_headers,
2750 client_ip=_client_ip,
2751 )
2752 if user_api_key_auth is not None:
2753 data = await add_litellm_data_to_request(
2754 data=body_data,
2755 request=request,
2756 # Bill a team-derived call to the team that granted it. A keyless admitted
2757 # subject carries no team_id, so spend skipped team updates entirely and
2758 # charged the user's PRIMARY org — the granting team's budget never
2759 # accumulated (so it could never begin to block) and, cross-org, the wrong
2760 # organization was charged. This is the ACCOUNTING half; the enforcement
2761 # half (an already-over-budget team stops granting) lives in the source gate.
2762 # Authorization is unaffected: it ran before this, and the union is resolved
2763 # from the untouched auth object passed to call_mcp_tool below.
2764 user_api_key_dict=await MCPRequestHandler.billing_auth_for_tool_call(
2765 user_api_key_auth, tool_name=params.name
2766 ),
2767 proxy_config=proxy_config,
2768 )
2769 else:
2770 data = body_data
2772 response: Final = await call_mcp_tool(
2773 user_api_key_auth=user_api_key_auth,
2774 mcp_auth_header=mcp_auth_header,
2775 mcp_servers=mcp_servers,
2776 mcp_server_auth_headers=mcp_server_auth_headers,
2777 oauth2_headers=oauth2_headers,
2778 raw_headers=raw_headers,
2779 client_ip=_client_ip,
2780 host_progress_callback=host_progress_callback,
2781 **data, # for logging
2782 )
2783 except MCPMissingUserEnvVarsError as e:
2784 verbose_logger.info(
2785 "MCP mcp_server_tool_call missing per-user env vars: server_id=%s missing=%s",
2786 e.server_id,
2787 e.missing,
2788 )
2789 return CallToolResult(
2790 content=[TextContent(text=str(e), type="text")],
2791 is_error=True,
2792 )
2793 except BlockedPiiEntityError as e:
2794 verbose_logger.error("BlockedPiiEntityError in MCP tool call: %s", e)
2795 return CallToolResult(
2796 content=[
2797 TextContent(
2798 text=f"Error: Blocked PII entity detected - {e}",
2799 type="text",
2800 )
2801 ],
2802 is_error=True,
2803 )
2804 except GuardrailRaisedException as e:
2805 verbose_logger.error("GuardrailRaisedException in MCP tool call: %s", e)
2806 return CallToolResult(
2807 content=[TextContent(text=f"Error: Guardrail violation - {e}", type="text")],
2808 is_error=True,
2809 )
2810 except HTTPException as e:
2811 verbose_logger.error("HTTPException in MCP tool call: %s", e)
2812 return CallToolResult(
2813 content=[TextContent(text=f"Error: {_http_detail_message(e.detail)}", type="text")],
2814 is_error=True,
2815 )
2816 except MCPUpstreamAuthError as e:
2817 # The MCP session manager serializes handler exceptions as JSON-RPC errors, so a
2818 # mid-session tool call cannot emit a raw 401 + WWW-Authenticate the way the REST
2819 # call path and the connect-time preemptive check do. Return an explicit isError
2820 # naming the upstream status (at info level, not a traceback) so the client still
2821 # learns it must re-authenticate upstream and expected pass-through 401s don't spam.
2822 verbose_logger.info("Upstream auth failure calling MCP tool: HTTP %s", e.status_code)
2823 return CallToolResult(
2824 content=[
2825 TextContent(
2826 text=f"Error: upstream authentication required (HTTP {e.status_code})",
2827 type="text",
2828 )
2829 ],
2830 is_error=True,
2831 )
2832 except Exception as e:
2833 verbose_logger.exception("MCP mcp_server_tool_call - error: %s", e)
2834 return CallToolResult(
2835 content=[TextContent(text=f"Error: {e}", type="text")],
2836 is_error=True,
2837 )
2839 return response
2842async def _execute_list_prompts(
2843 context: OperationContext, params: PaginatedRequestParams, host_progress_callback: ProgressCallback | None = None
2844) -> ListPromptsResult:
2845 if context.mcp_proxy_mode:
2846 _reject_mcp_proxy_operation()
2847 try:
2848 (
2849 user_api_key_auth,
2850 mcp_auth_header,
2851 mcp_servers,
2852 mcp_server_auth_headers,
2853 oauth2_headers,
2854 raw_headers,
2855 _client_ip,
2856 ) = context.legacy_auth()
2857 verbose_logger.debug("MCP list_prompts - User API Key Auth from context: %s", user_api_key_auth)
2858 verbose_logger.debug("MCP list_prompts - MCP servers from context: %s", mcp_servers)
2859 verbose_logger.debug(
2860 "MCP list_prompts - MCP server auth headers: %s",
2861 list(mcp_server_auth_headers.keys()) if mcp_server_auth_headers else None,
2862 )
2863 # Get mcp_servers from context variable
2864 verbose_logger.debug("MCP list_prompts - Calling _list_prompts")
2865 prompts: Final = await _list_mcp_prompts(
2866 user_api_key_auth=user_api_key_auth,
2867 mcp_auth_header=mcp_auth_header,
2868 mcp_servers=mcp_servers,
2869 mcp_server_auth_headers=mcp_server_auth_headers,
2870 oauth2_headers=oauth2_headers,
2871 raw_headers=raw_headers,
2872 client_ip=_client_ip,
2873 )
2874 verbose_logger.info("MCP list_prompts - Successfully returned %s prompts", len(prompts))
2875 return ListPromptsResult(prompts=prompts)
2876 except Exception as e:
2877 verbose_logger.exception("Error in list_prompts endpoint: %s", e)
2878 # Return empty list instead of failing completely
2879 # This prevents the HTTP stream from failing and allows the client to get a response
2880 return ListPromptsResult(prompts=[]) # mutable-ok: MCP result payload
2883async def _execute_get_prompt(
2884 context: OperationContext, params: GetPromptRequestParams, host_progress_callback: ProgressCallback | None = None
2885) -> GetPromptResult:
2886 if context.mcp_proxy_mode:
2887 _reject_mcp_proxy_operation()
2888 (
2889 user_api_key_auth,
2890 mcp_auth_header,
2891 mcp_servers,
2892 mcp_server_auth_headers,
2893 oauth2_headers,
2894 raw_headers,
2895 _client_ip,
2896 ) = context.legacy_auth()
2898 verbose_logger.debug("MCP mcp_server_tool_call - User API Key Auth from context: %s", user_api_key_auth)
2899 return await mcp_get_prompt(
2900 name=params.name,
2901 arguments=params.arguments,
2902 user_api_key_auth=user_api_key_auth,
2903 mcp_auth_header=mcp_auth_header,
2904 mcp_servers=mcp_servers,
2905 mcp_server_auth_headers=mcp_server_auth_headers,
2906 oauth2_headers=oauth2_headers,
2907 raw_headers=raw_headers,
2908 client_ip=_client_ip,
2909 )
2912async def _execute_list_resources(
2913 context: OperationContext, params: PaginatedRequestParams, host_progress_callback: ProgressCallback | None = None
2914) -> ListResourcesResult:
2915 if context.mcp_proxy_mode:
2916 _reject_mcp_proxy_operation()
2917 try:
2918 (
2919 user_api_key_auth,
2920 mcp_auth_header,
2921 mcp_servers,
2922 mcp_server_auth_headers,
2923 oauth2_headers,
2924 raw_headers,
2925 _client_ip,
2926 ) = context.legacy_auth()
2927 verbose_logger.debug("MCP list_resources - User API Key Auth from context: %s", user_api_key_auth)
2928 verbose_logger.debug("MCP list_resources - MCP servers from context: %s", mcp_servers)
2929 verbose_logger.debug(
2930 "MCP list_resources - MCP server auth headers: %s",
2931 list(mcp_server_auth_headers.keys()) if mcp_server_auth_headers else None,
2932 )
2934 resources: Final = await _list_mcp_resources(
2935 user_api_key_auth=user_api_key_auth,
2936 mcp_auth_header=mcp_auth_header,
2937 mcp_servers=mcp_servers,
2938 mcp_server_auth_headers=mcp_server_auth_headers,
2939 oauth2_headers=oauth2_headers,
2940 raw_headers=raw_headers,
2941 client_ip=_client_ip,
2942 )
2943 verbose_logger.info("MCP list_resources - Successfully returned %s resources", len(resources))
2944 return ListResourcesResult(resources=resources)
2945 except Exception as e:
2946 verbose_logger.exception("Error in list_resources endpoint: %s", e)
2947 return ListResourcesResult(resources=[]) # mutable-ok: MCP result payload
2950async def _execute_list_resource_templates(
2951 context: OperationContext, params: PaginatedRequestParams, host_progress_callback: ProgressCallback | None = None
2952) -> ListResourceTemplatesResult:
2953 if context.mcp_proxy_mode:
2954 _reject_mcp_proxy_operation()
2955 try:
2956 (
2957 user_api_key_auth,
2958 mcp_auth_header,
2959 mcp_servers,
2960 mcp_server_auth_headers,
2961 oauth2_headers,
2962 raw_headers,
2963 _client_ip,
2964 ) = context.legacy_auth()
2965 verbose_logger.debug("MCP list_resource_templates - User API Key Auth from context: %s", user_api_key_auth)
2966 verbose_logger.debug("MCP list_resource_templates - MCP servers from context: %s", mcp_servers)
2967 verbose_logger.debug(
2968 "MCP list_resource_templates - MCP server auth headers: %s",
2969 list(mcp_server_auth_headers.keys()) if mcp_server_auth_headers else None,
2970 )
2972 resource_templates: Final = await _list_mcp_resource_templates(
2973 user_api_key_auth=user_api_key_auth,
2974 mcp_auth_header=mcp_auth_header,
2975 mcp_servers=mcp_servers,
2976 mcp_server_auth_headers=mcp_server_auth_headers,
2977 oauth2_headers=oauth2_headers,
2978 raw_headers=raw_headers,
2979 client_ip=_client_ip,
2980 )
2981 verbose_logger.info(
2982 "MCP list_resource_templates - Successfully returned %s resource templates", len(resource_templates)
2983 )
2984 return ListResourceTemplatesResult(resource_templates=resource_templates)
2985 except Exception as e:
2986 verbose_logger.exception("Error in list_resource_templates endpoint: %s", e)
2987 return ListResourceTemplatesResult(resource_templates=[]) # mutable-ok: MCP result payload
2990async def _execute_read_resource(
2991 context: OperationContext, params: ReadResourceRequestParams, host_progress_callback: ProgressCallback | None = None
2992) -> ReadResourceResult:
2993 if context.mcp_proxy_mode:
2994 _reject_mcp_proxy_operation()
2995 (
2996 user_api_key_auth,
2997 mcp_auth_header,
2998 mcp_servers,
2999 mcp_server_auth_headers,
3000 oauth2_headers,
3001 raw_headers,
3002 _client_ip,
3003 ) = context.legacy_auth()
3005 read_resource_result: Final = await mcp_read_resource(
3006 url=params.uri,
3007 user_api_key_auth=user_api_key_auth,
3008 mcp_auth_header=mcp_auth_header,
3009 mcp_servers=mcp_servers,
3010 mcp_server_auth_headers=mcp_server_auth_headers,
3011 oauth2_headers=oauth2_headers,
3012 raw_headers=raw_headers,
3013 client_ip=_client_ip,
3014 )
3016 return read_resource_result
3019def _reject_mcp_proxy_operation() -> NoReturn:
3020 from mcp.shared.exceptions import MCPError
3021 from mcp.types import METHOD_NOT_FOUND
3023 raise MCPError(code=METHOD_NOT_FOUND, message="Operation unavailable on /mcp/proxy")
3026def prepare_context(
3027 user_api_key_auth: UserAPIKeyAuth | None = None,
3028 mcp_auth_header: str | None = None,
3029 mcp_servers: Sequence[str] | None = None,
3030 mcp_server_auth_headers: Mapping[str, Mapping[str, str]] | None = None,
3031 oauth2_headers: Mapping[str, str] | None = None,
3032 raw_headers: Mapping[str, str] | None = None,
3033 client_ip: str | None = None,
3034 mcp_proxy_mode: bool = False,
3035) -> OperationContext:
3036 return OperationContext(
3037 _caller=user_api_key_auth,
3038 mcp_auth_header=mcp_auth_header,
3039 mcp_servers=tuple(mcp_servers) if mcp_servers is not None else None,
3040 mcp_server_auth_headers=mcp_server_auth_headers,
3041 oauth2_headers=oauth2_headers,
3042 raw_headers=raw_headers,
3043 client_ip=client_ip,
3044 mcp_proxy_mode=mcp_proxy_mode,
3045 )
3048GatewayOperation: TypeAlias = (
3049 AuthorizedToolCall
3050 | ListToolsRequest
3051 | CallToolRequest
3052 | ListPromptsRequest
3053 | GetPromptRequest
3054 | ListResourcesRequest
3055 | ListResourceTemplatesRequest
3056 | ReadResourceRequest
3057)
3058GatewayResult: TypeAlias = (
3059 ListToolsResult
3060 | CallToolResult
3061 | ListPromptsResult
3062 | GetPromptResult
3063 | ListResourcesResult
3064 | ListResourceTemplatesResult
3065 | ReadResourceResult
3066)
3069class GatewayOperations:
3070 def __init__(self, host_progress_callback: ProgressCallback | None = None) -> None:
3071 self._host_progress_callback = host_progress_callback
3073 @overload
3074 async def execute(self, operation: AuthorizedToolCall, context: OperationContext) -> CallToolResult: ... 3074 ↛ exitline 3074 didn't return from function 'execute' because
3076 @overload
3077 async def execute(self, operation: ListToolsRequest, context: OperationContext) -> ListToolsResult: ... 3077 ↛ exitline 3077 didn't return from function 'execute' because
3079 @overload
3080 async def execute(self, operation: CallToolRequest, context: OperationContext) -> CallToolResult: ... 3080 ↛ exitline 3080 didn't return from function 'execute' because
3082 @overload
3083 async def execute(self, operation: ListPromptsRequest, context: OperationContext) -> ListPromptsResult: ... 3083 ↛ exitline 3083 didn't return from function 'execute' because
3085 @overload
3086 async def execute(self, operation: GetPromptRequest, context: OperationContext) -> GetPromptResult: ... 3086 ↛ exitline 3086 didn't return from function 'execute' because
3088 @overload
3089 async def execute(self, operation: ListResourcesRequest, context: OperationContext) -> ListResourcesResult: ... 3089 ↛ exitline 3089 didn't return from function 'execute' because
3091 @overload
3092 async def execute( 3092 ↛ exitline 3092 didn't return from function 'execute' because
3093 self, operation: ListResourceTemplatesRequest, context: OperationContext
3094 ) -> ListResourceTemplatesResult: ...
3096 @overload
3097 async def execute(self, operation: ReadResourceRequest, context: OperationContext) -> ReadResourceResult: ... 3097 ↛ exitline 3097 didn't return from function 'execute' because
3099 async def execute(self, operation: GatewayOperation, context: OperationContext) -> GatewayResult:
3100 match operation:
3101 case AuthorizedToolCall():
3102 auth, token, _servers, server_headers, oauth_headers, headers, _client_ip = context.legacy_auth()
3103 return await _execute_mcp_tool(
3104 name=operation.name,
3105 arguments=dict(operation.arguments), # mutable-ok: existing tool hooks own mutable argument data
3106 allowed_mcp_servers=list(operation.allowed_mcp_servers),
3107 start_time=operation.start_time,
3108 user_api_key_auth=auth,
3109 mcp_auth_header=token,
3110 mcp_server_auth_headers=server_headers,
3111 oauth2_headers=oauth_headers,
3112 raw_headers=headers,
3113 client_ip=_client_ip,
3114 host_progress_callback=operation.host_progress_callback,
3115 guardrail_context=operation.guardrail_context,
3116 **operation.logging_data,
3117 )
3118 case ListToolsRequest(params=params):
3119 return await _execute_handle_list_tools(
3120 context, params or PaginatedRequestParams(), self._host_progress_callback
3121 )
3122 case CallToolRequest(params=params):
3123 return await _execute_mcp_server_tool_call(context, params, self._host_progress_callback)
3124 case ListPromptsRequest(params=params):
3125 return await _execute_list_prompts(
3126 context, params or PaginatedRequestParams(), self._host_progress_callback
3127 )
3128 case GetPromptRequest(params=params):
3129 return await _execute_get_prompt(context, params, self._host_progress_callback)
3130 case ListResourcesRequest(params=params):
3131 return await _execute_list_resources(
3132 context, params or PaginatedRequestParams(), self._host_progress_callback
3133 )
3134 case ListResourceTemplatesRequest(params=params):
3135 return await _execute_list_resource_templates(
3136 context, params or PaginatedRequestParams(), self._host_progress_callback
3137 )
3138 case ReadResourceRequest(params=params):
3139 return await _execute_read_resource(context, params, self._host_progress_callback)
3140 case _:
3141 return assert_never(operation)