Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/rest_endpoints.py: 43%
525 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
1import asyncio
2import importlib
3from collections.abc import Awaitable, Callable, Mapping, Sequence
4from dataclasses import dataclass
5from datetime import datetime
6from traceback import walk_tb
7from types import MappingProxyType
8from typing import TYPE_CHECKING, Any, Final, Literal
9from uuid import uuid4
11import anyio
12import httpx
13import httpx2
14from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
15from pydantic import ValidationError
16from starlette.datastructures import Headers
18from litellm._logging import verbose_logger
19from litellm.constants import MCP_CLIENT_TIMEOUT, MCP_TOOL_LISTING_TIMEOUT
20from litellm.exceptions import (
21 BlockedPiiEntityError,
22 GuardrailRaisedException,
23 ModifyResponseException,
24)
25from litellm.proxy._experimental.mcp_server.exceptions import (
26 MCPServerListError,
27 MCPServerURLCredentialsError,
28 MCPUpstreamAuthError,
29)
30from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
31 ServerListOk,
32 ServerOutcome,
33 classify_list_exception,
34 list_fault_http_status,
35 outcome_wire_value,
36)
37from litellm.proxy._experimental.mcp_server.faults.traversal import iter_exception_tree
38from litellm.proxy._experimental.mcp_server.oauth_utils import _redact_mcp_resource_url
39from litellm.proxy._experimental.mcp_server.ui_session_utils import (
40 acting_user_auth,
41 build_effective_auth_contexts,
42)
43from litellm.proxy._experimental.mcp_server.utils import (
44 MCPMissingUserEnvVarsError,
45 get_server_prefix,
46 merge_mcp_headers,
47)
48from litellm.proxy._types import (
49 LitellmUserRoles,
50 UserAPIKeyAuth,
51 user_api_key_has_admin_view,
52)
53from litellm.proxy.auth.ip_address_utils import IPAddressUtils
54from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
55from litellm.responses.mcp.request_context import MCPRequestContext
57if TYPE_CHECKING: 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true
58 from mcp.types import CallToolResult
60 from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
61 from litellm.proxy._experimental.mcp_server.db import OAuthCredentialPayload
62from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers
63from litellm.types.mcp import MCPAuth
64from litellm.types.utils import CallTypes
66MCP_AVAILABLE: bool = True
67try:
68 importlib.import_module("mcp")
69except ImportError as e:
70 verbose_logger.debug("MCP module not found: %s", e)
71 MCP_AVAILABLE = False
74router: Final = APIRouter(
75 prefix="/mcp-rest",
76 tags=["mcp"],
77)
79_MCP_GUARDRAIL_REJECTIONS: Final = (
80 BlockedPiiEntityError,
81 GuardrailRaisedException,
82 ModifyResponseException,
83 HTTPException,
84)
86_CLIENT_FORWARDED_TOKEN_AUTH_TYPES: Final = frozenset((MCPAuth.true_passthrough, MCPAuth.oauth_delegate))
89def _connection_error_message(exc: BaseException, url: str | None, timeout_seconds: float) -> str:
90 reference: Final = uuid4().hex
91 verbose_logger.error(
92 "MCP connection test failed (reference=%s): %s",
93 reference,
94 tuple(
95 (
96 type(cause).__name__,
97 tuple(
98 (frame.f_code.co_filename, lineno, frame.f_code.co_name)
99 for frame, lineno in walk_tb(cause.__traceback__)
100 ),
101 )
102 for cause in iter_exception_tree(exc)
103 ),
104 )
105 return next(
106 (
107 message
108 for cause in iter_exception_tree(exc)
109 if (message := _known_connection_error_message(cause, url, timeout_seconds)) is not None
110 ),
111 "An unexpected error occurred while testing the MCP connection. "
112 f"Retry; if it persists, share reference {reference} with your gateway administrator.",
113 )
116def _known_connection_error_message(exc: BaseException, url: str | None, timeout_seconds: float) -> str | None:
117 if isinstance(exc, MCPServerURLCredentialsError): 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true
118 return str(exc.detail)
119 if isinstance(exc, TimeoutError): 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true
120 return (
121 "Failed to connect to MCP server: no valid MCP response received from "
122 f"{_redact_mcp_resource_url(url) or 'the server'} "
123 f"within {timeout_seconds:.0f}s. Check that the LiteLLM proxy can reach this URL "
124 "from its network (DNS, egress rules, firewalls) and that the server answers MCP requests."
125 )
126 if isinstance(exc, (httpx.LocalProtocolError, httpx2.LocalProtocolError)): 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true
127 return (
128 "Failed to connect to MCP server: a request header is malformed. "
129 "Check static headers for leading/trailing spaces or illegal characters."
130 )
131 if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout, httpx2.ConnectError, httpx2.ConnectTimeout)):
132 return (
133 "Failed to connect to MCP server: the server is unreachable. Check the URL and that the server is running."
134 )
135 if isinstance(exc, (httpx.TimeoutException, httpx2.TimeoutException)): 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true
136 return "Failed to connect to MCP server: the connection timed out."
137 if isinstance(exc, (httpx.HTTPStatusError, httpx2.HTTPStatusError)): 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true
138 return f"Failed to connect to MCP server: it returned HTTP {exc.response.status_code}."
139 if isinstance( 139 ↛ 149line 139 didn't jump to line 149 because the condition on line 139 was never true
140 exc,
141 (
142 httpx.NetworkError,
143 httpx.RemoteProtocolError,
144 httpx2.NetworkError,
145 httpx2.RemoteProtocolError,
146 ConnectionError,
147 ),
148 ):
149 return (
150 "Failed to connect to MCP server: the connection was interrupted. "
151 "Check the server and network connection, then retry."
152 )
153 if isinstance(exc, ValueError) and str(exc).startswith("Unexpected content type:"): 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 return (
155 "Failed to connect to MCP server: the endpoint returned an unsupported content type. "
156 "Check that the URL is an MCP endpoint, not a web page, and matches the selected transport."
157 )
158 if isinstance(exc, ValidationError) and exc.title in ("JSONRPCMessage", "InitializeResult", "ListToolsResult"): 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true
159 return (
160 "Failed to connect to MCP server: the endpoint returned invalid JSON or an invalid MCP response. "
161 "Check the MCP endpoint URL and the server's protocol implementation."
162 )
163 if MCP_AVAILABLE and isinstance(exc, MCPError): 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true
164 if exc.error.message.startswith("Unexpected content type:"):
165 return (
166 "Failed to connect to MCP server: the endpoint returned an unsupported content type. "
167 "Check that the URL is an MCP endpoint, not a web page, and matches the selected transport."
168 )
169 if exc.error.code == -32700 or exc.error.message.startswith("Failed to parse"):
170 return (
171 f"Failed to connect to MCP server: the endpoint returned invalid JSON or an invalid MCP response "
172 f"(JSON-RPC code {exc.error.code}). "
173 "Check the MCP endpoint URL and the server's protocol implementation."
174 )
175 if exc.error.code == -32000 and exc.error.message == "Connection closed":
176 return (
177 "Failed to connect to MCP server: the connection was closed before the request completed. "
178 "Check that the server stays running and returns a complete MCP response, then retry."
179 )
180 if exc.error.code == 32600 and exc.error.message == "Session terminated":
181 return (
182 "Failed to connect to MCP server: the MCP session was terminated. "
183 "Check that the URL points to an MCP endpoint and matches the selected transport, "
184 "then retry to start a new session."
185 )
186 return (
187 f"Failed to connect to MCP server: the MCP request failed (JSON-RPC code {exc.error.code}). "
188 "Check that the endpoint supports MCP initialization and tool listing, and check the upstream server logs."
189 )
190 return None
193if MCP_AVAILABLE: 193 ↛ exitline 193 didn't exit the module because the condition on line 193 was always true
194 from mcp.shared.exceptions import MCPError
195 from mcp.types import Tool as MCPTool
197 from litellm.experimental_mcp_client.client import MCPClient, as_mcp_read_timeout
198 from litellm.llms.litellm_proxy.skills.skill_search import (
199 DEFAULT_SKILL_SEARCH_TOP_K,
200 )
201 from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
202 _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES,
203 global_mcp_server_manager,
204 )
205 from litellm.proxy._experimental.mcp_server.oauth_utils import (
206 get_request_base_url,
207 )
208 from litellm.proxy._experimental.mcp_server.operations import (
209 ListMCPToolsRestAPIResponseObject,
210 MCPInfo,
211 MCPServer,
212 _aggregate_server_key,
213 _fire_mcp_tool_call_logging,
214 execute_mcp_tool,
215 filter_tools_by_allowed_tools,
216 filter_tools_by_key_team_permissions,
217 fire_mcp_tool_call_failure_logging,
218 )
219 from litellm.proxy._experimental.mcp_server.server import (
220 _apply_toolset_scope,
221 reject_disallowed_mcp_client,
222 )
224 ########################################################
225 ############ MCP Server REST API Routes #################
226 async def _safe_fire_mcp_tool_call_logging(
227 logging_obj: "LiteLLMLoggingObj | None",
228 result: "CallToolResult",
229 start_time: datetime,
230 end_time: datetime,
231 user_api_key_auth: UserAPIKeyAuth | None = None,
232 request_data: Mapping[str, object] | None = None,
233 ) -> "CallToolResult":
234 """Fire post-call logging, returning the tool result to send to the client.
236 ``post_mcp_call`` guardrails already ran on ``execute_mcp_tool``'s return
237 path, so the result arriving here is the guardrailed one. A guardrail
238 rejection raised by a native ``async_post_mcp_tool_call_hook`` is still
239 re-raised rather than swallowed as a logging failure, which would return
240 the unguarded result.
241 """
242 if logging_obj is None:
243 return result
244 logging_results: Final = await asyncio.gather(
245 _fire_mcp_tool_call_logging(
246 logging_obj,
247 result,
248 start_time,
249 end_time,
250 user_api_key_auth=user_api_key_auth,
251 request_data=request_data,
252 ),
253 return_exceptions=True,
254 )
255 outcome: Final = logging_results[0]
256 if isinstance(outcome, (asyncio.CancelledError, *_MCP_GUARDRAIL_REJECTIONS)):
257 raise outcome
258 if isinstance(outcome, BaseException):
259 verbose_logger.warning("MCP tool call logging failed (continuing): %s", outcome)
260 return result
261 return outcome
263 async def _safe_fire_mcp_tool_call_failure_logging(
264 logging_obj: "LiteLLMLoggingObj | None",
265 exception: Exception,
266 start_time: datetime,
267 user_api_key_auth: UserAPIKeyAuth,
268 request_data: Mapping[str, object],
269 ) -> None:
270 try:
271 await fire_mcp_tool_call_failure_logging(
272 logging_obj, exception, start_time, user_api_key_auth, request_data
273 )
274 except Exception as logging_error:
275 verbose_logger.warning("MCP tool call failure logging failed (continuing): %s", logging_error)
277 def _relay_upstream_auth_http_exception(e: MCPUpstreamAuthError, request: Request) -> HTTPException:
278 """Convert a client-forwarded pass-through upstream 401 into an HTTPException that preserves the
279 upstream WWW-Authenticate, so a standards-compliant MCP client can run the upstream OAuth flow
280 instead of the generic 500 the endpoint catch-all would return."""
281 return e.to_http_exception(
282 base_url=get_request_base_url(request),
283 request_path=request.scope.get("_original_path") or request.url.path,
284 )
286 async def _handle_virtual_mcp_tool(
287 request: Request,
288 data: dict[str, Any],
289 tool_name: str,
290 user_api_key_dict: UserAPIKeyAuth,
291 ) -> "CallToolResult":
292 """Handle the virtual ``mcp_tool_search`` / ``mcp_tool_call`` REST tools (gated on
293 ``mcp_tool_search_enabled``). Kept out of ``call_tool_rest_api`` so that endpoint stays a single
294 dispatch. An upstream 401 raised by the virtual ``mcp_tool_call`` propagates unhandled to the
295 caller's ``except MCPUpstreamAuthError`` relay, the same as the direct call path."""
296 from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
297 MCPRequestHandler,
298 )
299 from litellm.proxy._experimental.mcp_server.tool_search import (
300 AGENT_SEARCH_TOOL_NAME,
301 DEFAULT_AGENT_SEARCH_TOP_K,
302 MCP_TOOL_SEARCH_TOOL_NAME,
303 SKILL_SEARCH_TOOL_NAME,
304 coerce_top_k,
305 handle_agent_search,
306 handle_mcp_tool_call,
307 handle_mcp_tool_search,
308 handle_skill_search,
309 )
310 from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
311 from litellm.proxy.proxy_server import general_settings, proxy_config, proxy_logging_obj
313 if not getattr(getattr(user_api_key_dict, "object_permission", None), "mcp_tool_search_enabled", False):
314 raise HTTPException(
315 status_code=403,
316 detail={"error": "forbidden", "message": f"{tool_name} requires mcp_tool_search_enabled on the key"},
317 )
318 tool_arguments: Final = data.get("arguments") or {}
319 if tool_name == AGENT_SEARCH_TOOL_NAME:
320 return await handle_agent_search(
321 query=str(tool_arguments.get("query", "")),
322 top_k=coerce_top_k(
323 tool_arguments.get("top_k", DEFAULT_AGENT_SEARCH_TOP_K), default=DEFAULT_AGENT_SEARCH_TOP_K
324 ),
325 user_api_key_dict=user_api_key_dict,
326 )
327 if tool_name == SKILL_SEARCH_TOOL_NAME:
328 return await handle_skill_search(
329 query=str(tool_arguments.get("query", "")),
330 top_k=coerce_top_k(
331 tool_arguments.get("top_k", DEFAULT_SKILL_SEARCH_TOP_K), default=DEFAULT_SKILL_SEARCH_TOP_K
332 ),
333 user_api_key_dict=user_api_key_dict,
334 )
335 rest_client_ip: Final = IPAddressUtils.get_mcp_client_ip(request)
336 (
337 virtual_mcp_auth_header,
338 virtual_mcp_server_auth_headers,
339 virtual_raw_headers,
340 ) = _extract_mcp_headers_from_request(request, MCPRequestHandler)
341 virtual_oauth2_headers: Final = MCPRequestHandler._get_oauth2_headers_from_headers(request.headers)
342 if tool_name == MCP_TOOL_SEARCH_TOOL_NAME:
343 return await handle_mcp_tool_search(
344 query=tool_arguments.get("query", ""),
345 top_k=coerce_top_k(tool_arguments.get("top_k", 5)),
346 user_api_key_dict=user_api_key_dict,
347 client_ip=rest_client_ip,
348 mcp_auth_header=virtual_mcp_auth_header,
349 mcp_server_auth_headers=virtual_mcp_server_auth_headers,
350 oauth2_headers=virtual_oauth2_headers,
351 raw_headers=virtual_raw_headers,
352 )
353 # MCP_TOOL_CALL_TOOL_NAME: run the same pre-call pipeline as the normal path so the tool
354 # execution is spend-logged and guardrail-checked.
355 virtual_processor: Final = ProxyBaseLLMRequestProcessing(data=data)
356 _request_start_time: Final = datetime.now() # noqa: DTZ005 # naive to match the tool start time below
357 try:
358 (virtual_data, virtual_logging_obj) = await virtual_processor.common_processing_pre_call_logic(
359 request=request,
360 user_api_key_dict=user_api_key_dict,
361 proxy_config=proxy_config,
362 route_type=CallTypes.call_mcp_tool.value,
363 proxy_logging_obj=proxy_logging_obj,
364 general_settings=general_settings,
365 )
366 _tool_start_time: Final = datetime.now()
367 result: Final = await handle_mcp_tool_call(
368 tool_name=tool_arguments.get("tool_name", ""),
369 arguments=tool_arguments.get("arguments") or {},
370 user_api_key_dict=user_api_key_dict,
371 client_ip=rest_client_ip,
372 mcp_auth_header=virtual_mcp_auth_header,
373 mcp_server_auth_headers=virtual_mcp_server_auth_headers,
374 oauth2_headers=virtual_oauth2_headers,
375 raw_headers=virtual_raw_headers,
376 litellm_logging_obj=virtual_logging_obj,
377 guardrail_context=MCPRequestContext.resolve_guardrail_context(virtual_data),
378 )
379 except Exception as e:
380 virtual_request_data: Final = virtual_processor.data
381 await _safe_fire_mcp_tool_call_failure_logging(
382 virtual_request_data.get("litellm_logging_obj"),
383 e,
384 _request_start_time,
385 user_api_key_dict,
386 virtual_request_data,
387 )
388 raise
389 return await _safe_fire_mcp_tool_call_logging(
390 virtual_logging_obj,
391 result,
392 _tool_start_time,
393 datetime.now(),
394 user_api_key_auth=user_api_key_dict,
395 request_data=data,
396 )
398 def _get_server_auth_header(
399 server: MCPServer,
400 mcp_server_auth_headers: dict[str, dict[str, str]] | None,
401 mcp_auth_header: str | None,
402 ) -> dict[str, str] | str | None:
403 """Helper function to get server-specific auth header with case-insensitive matching."""
404 from litellm.proxy._experimental.mcp_server.utils import (
405 lookup_mcp_server_auth_in_headers,
406 )
408 if mcp_server_auth_headers: 408 ↛ 409line 408 didn't jump to line 409 because the condition on line 408 was never true
409 server_auth: Final = lookup_mcp_server_auth_in_headers(
410 mcp_server_auth_headers,
411 alias=server.alias,
412 server_name=server.server_name,
413 access_groups=server.access_groups,
414 )
415 if server_auth is not None:
416 return server_auth
417 return mcp_auth_header
419 def _is_v1_resolved_oauth2_server(server: MCPServer | None) -> bool:
420 """Whether this server's per-user OAuth2 token is still resolved by v1.
422 A server the v2 resolver owns reads its stored token from the resolver at connect
423 time and drops any Authorization built for it here, so the v1 lookup would be a DB
424 round-trip whose result is discarded. Mirrors the same guard on the protocol listing
425 path and in ``_resolve_oauth2_headers_for_tool_call``.
426 """
427 from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import (
428 to_server_spec,
429 )
431 if getattr(server, "auth_type", None) != MCPAuth.oauth2: 431 ↛ 433line 431 didn't jump to line 433 because the condition on line 431 was always true
432 return False
433 return to_server_spec(server) is None
435 def _v1_resolved_oauth2_server_ids(allowed_server_ids: list[str]) -> set[str]:
436 """Return the subset of *allowed_server_ids* whose per-user OAuth2 token is still
437 resolved by v1.
439 Used as a cheap pre-flight check to skip bulk credential fetching when no such
440 server is involved in the current request.
441 """
442 return {
443 sid
444 for sid in allowed_server_ids
445 if _is_v1_resolved_oauth2_server(global_mcp_server_manager.get_mcp_server_by_id(sid))
446 }
448 async def _get_user_oauth_extra_headers(
449 server: MCPServer,
450 user_api_key_dict: UserAPIKeyAuth,
451 prefetched_creds: dict[str, "OAuthCredentialPayload"] | None = None,
452 ) -> dict[str, str] | None:
453 """
454 For OAuth2 servers, look up the user's stored access token and return it
455 as extra_headers {"Authorization": "Bearer <token>"} so that it reaches
456 the MCP server the same way the admin "Add MCP / Authorize and Fetch" flow does.
457 Returns None for non-OAuth2 servers or when no credential is stored.
459 A server the v2 resolver owns is skipped; see ``_is_v1_resolved_oauth2_server``.
461 Args:
462 prefetched_creds: Optional dict keyed by server_id with credential payloads.
463 When provided, avoids a per-server DB round-trip.
464 """
465 if not _is_v1_resolved_oauth2_server(server): 465 ↛ 467line 465 didn't jump to line 467 because the condition on line 465 was always true
466 return None
467 user_id: Final[str | None] = getattr(user_api_key_dict, "user_id", None)
468 server_id: Final[str | None] = getattr(server, "server_id", None)
469 if not user_id or not server_id:
470 return None
471 try:
472 from litellm.proxy._experimental.mcp_server.db import (
473 get_user_oauth_credential,
474 resolve_valid_user_oauth_token,
475 )
477 prisma_client = None
478 if prefetched_creds is not None:
479 cred = prefetched_creds.get(server_id)
480 else:
481 from litellm.proxy.utils import get_prisma_client_or_throw
483 prisma_client = get_prisma_client_or_throw(
484 "Database not connected. Connect a database to use OAuth2 MCP tools."
485 )
486 cred = await get_user_oauth_credential(prisma_client, user_id, server_id)
487 cred = await resolve_valid_user_oauth_token(
488 user_id=user_id,
489 server=server,
490 cred=cred,
491 prisma_client=prisma_client,
492 )
493 if cred and cred.get("access_token"):
494 return {"Authorization": f"Bearer {cred['access_token']}"}
495 except Exception as e:
496 verbose_logger.warning(
497 "_get_user_oauth_extra_headers: failed to retrieve credential for user=%s server=%s: %s",
498 user_id,
499 server_id,
500 e,
501 )
502 return None
504 async def _prefetch_user_oauth_creds(
505 user_api_key_dict: UserAPIKeyAuth,
506 ) -> dict[str, "OAuthCredentialPayload"]:
507 """Fetch all OAuth2 credentials for the user in a single DB query.
509 Returns a dict keyed by server_id. Used to avoid N+1 DB queries when
510 iterating over multiple OAuth2 MCP servers.
511 """
512 user_id: Final[str | None] = getattr(user_api_key_dict, "user_id", None)
513 if not user_id:
514 return {}
515 try:
516 from litellm.proxy._experimental.mcp_server.db import (
517 list_user_oauth_credentials,
518 )
519 from litellm.proxy.utils import get_prisma_client_or_throw
521 prisma_client: Final = get_prisma_client_or_throw(
522 "Database not connected. Connect a database to use OAuth2 MCP tools."
523 )
524 creds: Final = await list_user_oauth_credentials(prisma_client, user_id)
525 return {c["server_id"]: c for c in creds if "server_id" in c}
526 except Exception as e:
527 verbose_logger.warning("_prefetch_user_oauth_creds: failed to prefetch for user=%s: %s", user_id, e)
528 return {}
530 def _create_tool_response_objects(tools, server: MCPServer):
531 """Helper function to create tool response objects.
533 Enriches the server's ``mcp_info`` with ``server_id`` and ``alias`` so
534 REST clients can map the internal ``server_name`` to the user-facing
535 alias without needing access to the ``mcp_routes``-gated server listing.
536 """
537 enriched_mcp_info: Final[MCPInfo] = {
538 **(server.mcp_info or {}),
539 "server_id": server.server_id,
540 "alias": server.alias,
541 }
542 return [
543 ListMCPToolsRestAPIResponseObject(
544 name=tool.name,
545 description=tool.description,
546 inputSchema=tool.input_schema,
547 mcp_info=enriched_mcp_info,
548 )
549 for tool in tools
550 ]
552 def _extract_mcp_headers_from_request(
553 request: Request,
554 mcp_request_handler_cls,
555 ) -> tuple:
556 """
557 Extract MCP auth headers from HTTP request.
559 Returns:
560 Tuple of (mcp_auth_header, mcp_server_auth_headers, raw_headers)
561 """
562 headers: Final = request.headers
563 raw_headers: Final = dict(headers)
564 mcp_auth_header: Final = mcp_request_handler_cls._get_mcp_auth_header_from_headers(headers)
565 mcp_server_auth_headers: Final = mcp_request_handler_cls._get_mcp_server_auth_headers_from_headers(headers)
566 return mcp_auth_header, mcp_server_auth_headers, raw_headers
568 def _resolve_mcp_server_id_for_rest(
569 server_id: str,
570 allowed_server_ids: set[str] | list[str],
571 client_ip: str | None = None,
572 ) -> str:
573 """
574 Map REST ``server_id`` (UUID, server_name, or alias) to canonical server_id.
576 tools/list already did this; tools/call must match so clients can pass
577 server names like ``order_status_mcp`` instead of only UUIDs.
578 """
579 allowed: Final = set(allowed_server_ids)
580 if server_id in allowed:
581 return server_id
582 by_name: Final = global_mcp_server_manager.get_mcp_server_by_name(server_id, client_ip=client_ip)
583 if by_name is not None and by_name.server_id in allowed:
584 return by_name.server_id
585 return server_id
587 async def _resolve_allowed_mcp_servers_with_ip_filter(
588 request: Request,
589 user_api_key_dict: UserAPIKeyAuth,
590 server_id: str,
591 ) -> tuple[list[MCPServer], str]:
592 """
593 Resolve allowed MCP servers for a tool call with IP filtering.
595 Args:
596 request: The HTTP request object
597 user_api_key_dict: The user's API key auth object
598 server_id: The server ID to validate access for
600 Returns:
601 Tuple of (allowed MCPServer objects, canonical server_id)
603 Raises:
604 HTTPException: If the server_id is not allowed or not found
605 """
606 # Get all auth contexts
607 auth_contexts: Final = await build_effective_auth_contexts(user_api_key_dict)
609 # Collect allowed server IDs from all contexts, then apply IP filtering
610 _rest_client_ip: Final = IPAddressUtils.get_mcp_client_ip(request)
611 allowed_server_ids_set = set()
612 for auth_context in auth_contexts:
613 servers = await global_mcp_server_manager.get_allowed_mcp_servers(
614 user_api_key_auth=auth_context,
615 )
616 allowed_server_ids_set.update(servers)
618 allowed_server_ids_set = set(
619 global_mcp_server_manager.filter_server_ids_by_ip(list(allowed_server_ids_set), _rest_client_ip)
620 )
622 canonical_server_id: Final = _resolve_mcp_server_id_for_rest(server_id, allowed_server_ids_set, _rest_client_ip)
624 if canonical_server_id not in allowed_server_ids_set:
625 _server: Final = global_mcp_server_manager.get_mcp_server_by_id(
626 server_id
627 ) or global_mcp_server_manager.get_mcp_server_by_name(server_id)
628 if (
629 _server is not None
630 and _rest_client_ip is not None
631 and not global_mcp_server_manager._is_server_accessible_from_ip(_server, _rest_client_ip)
632 ):
633 raise HTTPException(
634 status_code=403,
635 detail={
636 "error": "ip_filtering",
637 "message": (
638 f"MCP server '{server_id}' is not accessible from your IP address "
639 f"({_rest_client_ip}). This server is restricted to internal "
640 "networks only. To make it externally accessible, set "
641 "'available_on_public_internet: true' in the server configuration."
642 ),
643 },
644 )
645 if _server is None:
646 raise HTTPException(
647 status_code=404,
648 detail={
649 "error": "server_not_found",
650 "message": f"MCP server '{server_id}' was not found",
651 },
652 )
653 raise HTTPException(
654 status_code=403,
655 detail={
656 "error": "access_denied",
657 "message": f"The key is not allowed to access server {server_id}",
658 },
659 )
661 # Build allowed_mcp_servers list (only include allowed servers)
662 allowed_mcp_servers: Final[list[MCPServer]] = []
663 for allowed_server_id in allowed_server_ids_set:
664 server = global_mcp_server_manager.get_mcp_server_by_id(allowed_server_id)
665 if server is not None:
666 allowed_mcp_servers.append(server)
668 return allowed_mcp_servers, canonical_server_id
670 async def _get_tools_for_single_server(
671 server,
672 server_auth_header,
673 raw_headers: dict[str, str] | None = None,
674 user_api_key_auth: UserAPIKeyAuth | None = None,
675 extra_headers: dict[str, str] | None = None,
676 apply_tool_filters: bool = True,
677 client_ip: str | None = None,
678 ):
679 """Helper function to get tools for a single server.
681 When ``apply_tool_filters`` is False the raw server catalog is returned
682 without the allowed_tools/disallowed_tools gate or the per-key tool
683 permissions. This is the admin-only configuration view; every runtime
684 path keeps the default True so callable tools stay filtered.
685 """
686 tools = await global_mcp_server_manager._get_tools_from_server(
687 server=server,
688 mcp_auth_header=server_auth_header,
689 extra_headers=extra_headers,
690 add_prefix=False,
691 raw_headers=raw_headers,
692 client_ip=client_ip,
693 user_api_key_auth=user_api_key_auth,
694 )
696 if not apply_tool_filters:
697 return _create_tool_response_objects(tools, server)
699 # Always apply allowed_tools/disallowed_tools so the blacklist is
700 # enforced even when no allowlist is set (matches the SSE/HTTP path).
701 tools = filter_tools_by_allowed_tools(tools, server)
703 # Filter by the key's effective tool permissions through the same
704 # function the MCP protocol path uses (direct grants, toolset grants,
705 # and team/agent/org ceilings), so REST listing cannot drift from it.
706 # Entries here are tool names on one server, written bare by every
707 # writer, and dispatch compares them bare; matching a wider set of
708 # spellings would advertise a tool that tools/call then refuses
709 if user_api_key_auth:
710 tools = await filter_tools_by_key_team_permissions(
711 tools=tools,
712 server_id=server.server_id,
713 user_api_key_auth=user_api_key_auth,
714 )
716 return _create_tool_response_objects(tools, server)
718 async def _resolve_allowed_mcp_servers_for_tool_call(
719 user_api_key_dict: UserAPIKeyAuth,
720 server_id: str,
721 ) -> list[MCPServer]:
722 """Resolve allowed MCP servers for the given user and validate server_id access."""
723 auth_contexts: Final = await build_effective_auth_contexts(user_api_key_dict)
724 allowed_server_ids_set: Final = set()
725 for auth_context in auth_contexts:
726 servers = await global_mcp_server_manager.get_allowed_mcp_servers(user_api_key_auth=auth_context)
727 allowed_server_ids_set.update(servers)
728 if server_id not in allowed_server_ids_set:
729 raise HTTPException(
730 status_code=403,
731 detail={
732 "error": "access_denied",
733 "message": f"The key is not allowed to access server {server_id}",
734 },
735 )
736 allowed_mcp_servers: Final[list[MCPServer]] = []
737 for allowed_server_id in allowed_server_ids_set:
738 server = global_mcp_server_manager.get_mcp_server_by_id(allowed_server_id)
739 if server is not None:
740 allowed_mcp_servers.append(server)
741 return allowed_mcp_servers
743 async def _list_tools_for_single_server(
744 server_id: str,
745 allowed_server_ids: list[str],
746 rest_client_ip: str | None,
747 mcp_server_auth_headers: dict,
748 mcp_auth_header: str | None,
749 raw_headers_from_request: dict,
750 user_api_key_dict: UserAPIKeyAuth,
751 apply_tool_filters: bool = True,
752 ) -> dict:
753 """Handle tool listing for a single server_id request."""
754 # Resolve a server name to its UUID if needed
755 _name_resolved = None
756 if server_id not in allowed_server_ids: 756 ↛ 761line 756 didn't jump to line 761 because the condition on line 756 was always true
757 _name_resolved = global_mcp_server_manager.get_mcp_server_by_name(server_id)
758 if _name_resolved is not None and _name_resolved.server_id in set(allowed_server_ids): 758 ↛ 759line 758 didn't jump to line 759 because the condition on line 758 was never true
759 server_id = _name_resolved.server_id
761 if server_id not in allowed_server_ids: 761 ↛ 787line 761 didn't jump to line 787 because the condition on line 761 was always true
762 _server: Final = global_mcp_server_manager.get_mcp_server_by_id(server_id) or _name_resolved
763 if ( 763 ↛ 768line 763 didn't jump to line 768 because the condition on line 763 was never true
764 _server is not None
765 and rest_client_ip is not None
766 and not global_mcp_server_manager._is_server_accessible_from_ip(_server, rest_client_ip)
767 ):
768 raise HTTPException(
769 status_code=403,
770 detail={
771 "error": "ip_filtering",
772 "message": (
773 f"MCP server '{server_id}' is not accessible from your IP address "
774 f"({rest_client_ip}). This server is restricted to internal "
775 "networks only. To make it externally accessible, set "
776 "'available_on_public_internet: true' in the server configuration."
777 ),
778 },
779 )
780 raise HTTPException(
781 status_code=403,
782 detail={
783 "error": "access_denied",
784 "message": f"The key is not allowed to access server {server_id}",
785 },
786 )
787 server: Final = global_mcp_server_manager.get_mcp_server_by_id(server_id)
788 if server is None:
789 return {
790 "tools": [],
791 "error": "server_not_found",
792 "message": f"Server with id {server_id} not found",
793 }
795 server_auth_header: Final = _get_server_auth_header(server, mcp_server_auth_headers, mcp_auth_header)
796 user_oauth_extra_headers: Final = await _get_user_oauth_extra_headers(server, user_api_key_dict)
798 try:
799 list_tools_result: Final = await _get_tools_for_single_server(
800 server,
801 server_auth_header,
802 raw_headers_from_request,
803 user_api_key_dict,
804 extra_headers=user_oauth_extra_headers,
805 apply_tool_filters=apply_tool_filters,
806 client_ip=rest_client_ip,
807 )
808 except MCPUpstreamAuthError:
809 # Surface the upstream 401/403 to the caller so it can emit the
810 # matching status code and WWW-Authenticate challenge; that is what
811 # lets standards-compliant MCP clients run the upstream OAuth flow.
812 raise
813 except MCPServerListError as e:
814 fault: Final = classify_list_exception(e)
815 verbose_logger.info("Listing tools from %s failed with a %s fault", server.name, fault.tag)
816 raise HTTPException(
817 status_code=list_fault_http_status(fault),
818 detail={
819 "error": fault.tag,
820 "message": f"Failed to list tools from server {get_server_prefix(server)}",
821 },
822 ) from e
823 except Exception as e:
824 verbose_logger.exception("Error getting tools from %s: %s", server.name, e)
825 return {
826 "tools": [],
827 "error": "server_error",
828 "message": f"Failed to get tools from server {server.name}: {e}",
829 }
830 return {
831 "tools": list_tools_result,
832 "error": None,
833 "message": "Successfully retrieved tools",
834 }
836 def _as_query_str(value: object) -> str | None:
837 """Coerce an Optional[str] Query param to str|None, dropping unresolved FastAPI defaults."""
838 return value if isinstance(value, str) else None
840 async def _resolve_acting_auth(
841 toolset_name: str | None,
842 user_api_key_dict: UserAPIKeyAuth,
843 ) -> UserAPIKeyAuth:
844 """The one credential this tools request acts as.
846 A toolset name narrows the caller's own credential to that toolset; otherwise a dashboard
847 session is swapped for its admitted subject. The two are mutually exclusive by construction,
848 which is why they share an owner: the admitted subject resolves per grant source and a team
849 source deliberately carries none of the caller's ``object_permission``, so a toolset
850 narrowing layered on top would evaporate on every team-granted server."""
851 if not toolset_name:
852 return await acting_user_auth(user_api_key_dict)
854 from litellm.proxy.utils import get_prisma_client_or_throw
856 prisma_client: Final = get_prisma_client_or_throw("Database not available. Connect a database to your proxy")
857 toolset: Final = await global_mcp_server_manager.get_toolset_by_name_cached(prisma_client, toolset_name)
858 if toolset is None:
859 raise HTTPException(
860 status_code=404,
861 detail=f"Toolset '{toolset_name}' not found",
862 )
863 return await _apply_toolset_scope(user_api_key_dict, toolset.toolset_id)
865 @router.get("/tools/list", dependencies=[Depends(user_api_key_auth)])
866 async def list_tool_rest_api(
867 request: Request,
868 server_id: str | None = Query(None, description="The server id to list tools for"),
869 mcp_server_name: str | None = Query(None, description="Filter tools to a single MCP server by name or alias"),
870 toolset_name: str | None = Query(None, description="Filter tools to a single toolset by name"),
871 include_disabled_tools: bool = Query(
872 False,
873 description=(
874 "Admin only. Return the full server tool catalog without the "
875 "allowed_tools filter or per-key tool permissions, so the MCP "
876 "settings UI can configure the allowlist. Ignored for non-admins."
877 ),
878 ),
879 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
880 ) -> dict:
881 """
882 List all available tools with information about the server they belong to.
884 Example response:
885 {
886 "tools": [
887 {
888 "name": "create_zap",
889 "description": "Create a new zap",
890 "inputSchema": "tool_input_schema",
891 "mcp_info": {
892 "server_name": "zapier",
893 "logo_url": "https://www.zapier.com/logo.png",
894 "server_id": "a1b2c3d4-...",
895 "alias": "zapier_prod",
896 }
897 }
898 ],
899 "error": null,
900 "message": "Successfully retrieved tools"
901 }
902 """
903 from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
904 MCPRequestHandler,
905 )
907 reject_disallowed_mcp_client(request.headers, user_api_key_dict)
908 try:
909 mcp_server_name = _as_query_str(mcp_server_name)
910 toolset_name = _as_query_str(toolset_name)
911 user_api_key_dict = await _resolve_acting_auth(toolset_name, user_api_key_dict)
913 # The full catalog (allowlist filter skipped) is admin-only so the
914 # REST endpoint can't be used to enumerate deliberately-disabled tools.
915 apply_tool_filters: Final = not (include_disabled_tools and user_api_key_has_admin_view(user_api_key_dict))
917 if server_id is None:
918 server_id = mcp_server_name
920 if ( 920 ↛ 930line 920 didn't jump to line 930 because the condition on line 920 was never true
921 apply_tool_filters
922 and server_id is None
923 and toolset_name is None
924 and getattr(
925 getattr(user_api_key_dict, "object_permission", None),
926 "mcp_tool_search_enabled",
927 False,
928 )
929 ):
930 from litellm.proxy._experimental.mcp_server.tool_search import (
931 get_virtual_tool_definitions,
932 )
934 return {
935 "tools": get_virtual_tool_definitions(),
936 "error": None,
937 "message": "Successfully retrieved tools",
938 }
940 # Extract auth headers from request
941 headers: Final = request.headers
942 raw_headers_from_request: Final = dict(headers)
943 mcp_auth_header: Final = MCPRequestHandler._get_mcp_auth_header_from_headers(headers)
944 mcp_server_auth_headers: Final = MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers)
946 auth_contexts: Final = await build_effective_auth_contexts(user_api_key_dict)
948 _rest_client_ip: Final = IPAddressUtils.get_mcp_client_ip(request)
950 allowed_server_ids_set: Final = set()
951 for auth_context in auth_contexts:
952 servers = await global_mcp_server_manager.get_allowed_mcp_servers(
953 user_api_key_auth=auth_context,
954 )
955 allowed_server_ids_set.update(servers)
957 (
958 allowed_server_ids,
959 _ip_blocked_count,
960 ) = global_mcp_server_manager.filter_server_ids_by_ip_with_info(
961 list(allowed_server_ids_set), _rest_client_ip
962 )
964 # If server_id is specified, only query that specific server
965 if server_id:
966 return await _list_tools_for_single_server(
967 server_id=server_id,
968 allowed_server_ids=allowed_server_ids,
969 rest_client_ip=_rest_client_ip,
970 mcp_server_auth_headers=mcp_server_auth_headers,
971 mcp_auth_header=mcp_auth_header,
972 raw_headers_from_request=raw_headers_from_request,
973 user_api_key_dict=user_api_key_dict,
974 apply_tool_filters=apply_tool_filters,
975 )
976 else:
977 if not allowed_server_ids: 977 ↛ 978line 977 didn't jump to line 978 because the condition on line 977 was never true
978 if _ip_blocked_count > 0:
979 raise HTTPException(
980 status_code=403,
981 detail={
982 "error": "ip_filtering",
983 "message": (
984 f"No MCP tools are available for your IP address ({_rest_client_ip}). "
985 f"{_ip_blocked_count} server(s) are restricted to internal networks only. "
986 "To make servers externally accessible, set "
987 "'available_on_public_internet: true' in the server configuration."
988 ),
989 },
990 )
991 raise HTTPException(
992 status_code=403,
993 detail={
994 "error": "access_denied",
995 "message": "The key is not allowed to access any MCP servers.",
996 },
997 )
999 # Pre-fetch OAuth credentials only when at least one allowed server uses OAuth2,
1000 # to avoid an unnecessary DB round-trip on requests with no OAuth2 MCP servers.
1001 prefetched_oauth_creds: Final = (
1002 await _prefetch_user_oauth_creds(user_api_key_dict)
1003 if _v1_resolved_oauth2_server_ids(allowed_server_ids)
1004 else {}
1005 )
1007 async def list_server(
1008 server: MCPServer,
1009 ) -> tuple[Sequence[ListMCPToolsRestAPIResponseObject], ServerOutcome]:
1010 server_auth_header: Final = _get_server_auth_header(
1011 server, mcp_server_auth_headers, mcp_auth_header
1012 )
1013 user_oauth_extra_headers: Final = await _get_user_oauth_extra_headers(
1014 server,
1015 user_api_key_dict,
1016 prefetched_creds=prefetched_oauth_creds,
1017 )
1018 try:
1019 tools_result: Final = await _get_tools_for_single_server(
1020 server,
1021 server_auth_header,
1022 raw_headers_from_request,
1023 user_api_key_dict,
1024 extra_headers=user_oauth_extra_headers,
1025 apply_tool_filters=apply_tool_filters,
1026 client_ip=_rest_client_ip,
1027 )
1028 except Exception as e:
1029 verbose_logger.warning(
1030 "Error getting tools from %s: %s", server.name, classify_list_exception(e).tag
1031 )
1032 return (), classify_list_exception(e)
1033 return tools_result, ServerListOk(tool_count=len(tools_result))
1035 queried_servers: Final = tuple(
1036 server
1037 for server in map(global_mcp_server_manager.get_mcp_server_by_id, allowed_server_ids)
1038 if server is not None
1039 )
1040 listings: Final = tuple([await list_server(server) for server in queried_servers])
1041 list_tools_result: Final = [tool for tools, _ in listings for tool in tools]
1042 server_outcomes: Final = MappingProxyType(
1043 {_aggregate_server_key(server): outcome for server, (_, outcome) in zip(queried_servers, listings)}
1044 )
1045 errors: Final = tuple(
1046 f"{key}: {outcome.tag}" for key, outcome in server_outcomes.items() if outcome.tag != "ok"
1047 )
1048 error_message: Final = (
1049 "Failed to get tools from servers: " + "; ".join(errors)
1050 if errors and not list_tools_result
1051 else None
1052 )
1053 return {
1054 "tools": list_tools_result,
1055 "error": "partial_failure" if error_message else None,
1056 "message": (error_message if error_message else "Successfully retrieved tools"),
1057 "server_outcomes": {key: outcome_wire_value(outcome) for key, outcome in server_outcomes.items()},
1058 }
1060 except MCPUpstreamAuthError as e:
1061 # Surface upstream pass-through 401/403 challenges to the client so
1062 # standards-compliant MCP clients can run the upstream OAuth flow.
1063 raise e.to_http_exception(
1064 base_url=get_request_base_url(request),
1065 request_path=request.scope.get("_original_path") or request.url.path,
1066 )
1067 except HTTPException as http_exc:
1068 if http_exc.status_code == status.HTTP_404_NOT_FOUND or server_id: 1068 ↛ 1075line 1068 didn't jump to line 1075 because the condition on line 1068 was always true
1069 # Single-server requests relay the truthful status (a 502/504 upstream fault must
1070 # not masquerade as a 200 empty-success body); only the multi-server aggregate
1071 # keeps the legacy error-dict response shape below.
1072 raise
1073 # Internal access/IP 403s keep the legacy error-dict response shape
1074 # so the existing contract stays intact.
1075 verbose_logger.exception("HTTPException in list_tool_rest_api: %s", str(http_exc))
1076 return {
1077 "tools": [],
1078 "error": "unexpected_error",
1079 "message": (f"An unexpected error occurred: {http_exc.detail}"),
1080 }
1081 except Exception as e:
1082 verbose_logger.exception("Unexpected error in list_tool_rest_api: %s", str(e))
1083 return {
1084 "tools": [],
1085 "error": "unexpected_error",
1086 "message": f"An unexpected error occurred: {e}",
1087 }
1089 @router.post("/tools/call", dependencies=[Depends(user_api_key_auth)])
1090 async def call_tool_rest_api(
1091 request: Request,
1092 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
1093 ):
1094 """
1095 REST API to call a specific MCP tool with the provided arguments
1096 """
1097 from fastapi import HTTPException
1099 from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException
1100 from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
1101 MCPRequestHandler,
1102 )
1103 from litellm.proxy.common_request_processing import (
1104 ProxyBaseLLMRequestProcessing,
1105 )
1106 from litellm.proxy.proxy_server import (
1107 general_settings,
1108 proxy_config,
1109 proxy_logging_obj,
1110 )
1112 reject_disallowed_mcp_client(request.headers, user_api_key_dict)
1113 try:
1114 user_api_key_dict = await acting_user_auth(user_api_key_dict)
1115 data = await request.json()
1117 tool_name: Final[str | None] = data.get("name")
1118 tool_arguments: Final[dict[str, object]] = data.get("arguments") or {}
1120 from litellm.proxy._experimental.mcp_server.tool_search import VIRTUAL_TOOL_NAMES
1122 if tool_name in VIRTUAL_TOOL_NAMES:
1123 return await _handle_virtual_mcp_tool(request, data, tool_name, user_api_key_dict)
1125 # Validate required parameters early
1126 server_id: Final[str | None] = data.get("server_id")
1127 if not server_id:
1128 raise HTTPException(
1129 status_code=400,
1130 detail={
1131 "error": "missing_parameter",
1132 "message": "server_id is required in request body",
1133 },
1134 )
1136 if not tool_name:
1137 raise HTTPException(
1138 status_code=400,
1139 detail={
1140 "error": "missing_parameter",
1141 "message": "name is required in request body",
1142 },
1143 )
1145 proxy_base_llm_response_processor: Final = ProxyBaseLLMRequestProcessing(data=data)
1146 _request_start_time: Final = datetime.now() # noqa: DTZ005 # naive to match the tool start time below
1147 try:
1148 (
1149 data,
1150 logging_obj,
1151 ) = await proxy_base_llm_response_processor.common_processing_pre_call_logic(
1152 request=request,
1153 user_api_key_dict=user_api_key_dict,
1154 proxy_config=proxy_config,
1155 route_type=CallTypes.call_mcp_tool.value,
1156 proxy_logging_obj=proxy_logging_obj,
1157 general_settings=general_settings,
1158 skip_guardrails=True,
1159 )
1161 # Extract MCP auth headers from request and add to data dict
1162 (
1163 mcp_auth_header,
1164 mcp_server_auth_headers,
1165 raw_headers_from_request,
1166 ) = _extract_mcp_headers_from_request(request, MCPRequestHandler)
1167 if mcp_auth_header:
1168 data["mcp_auth_header"] = mcp_auth_header
1169 if mcp_server_auth_headers:
1170 data["mcp_server_auth_headers"] = mcp_server_auth_headers
1171 data["raw_headers"] = raw_headers_from_request
1173 # Extract user_api_key_auth from metadata and add to top level
1174 # call_mcp_tool expects user_api_key_auth as a top-level parameter
1175 if "metadata" in data and "user_api_key_auth" in data["metadata"]:
1176 data["user_api_key_auth"] = data["metadata"]["user_api_key_auth"]
1178 # Resolve allowed MCP servers with IP filtering
1179 (
1180 allowed_mcp_servers,
1181 canonical_server_id,
1182 ) = await _resolve_allowed_mcp_servers_with_ip_filter(request, user_api_key_dict, server_id)
1184 # Look up per-user OAuth headers for this server (mirrors list_tool_rest_api).
1185 user_oauth_extra_headers: dict[str, str] | None = None
1186 target_server: Final = next(
1187 (s for s in allowed_mcp_servers if s.server_id == canonical_server_id),
1188 None,
1189 )
1190 if target_server is not None:
1191 user_oauth_extra_headers = await _get_user_oauth_extra_headers(target_server, user_api_key_dict)
1192 caller_oauth2_headers: Final = (
1193 MCPRequestHandler._get_oauth2_headers_from_headers(request.headers)
1194 if target_server is not None and target_server.auth_type in _CLIENT_FORWARDED_TOKEN_AUTH_TYPES
1195 else None
1196 )
1198 # Call execute_mcp_tool directly (permission checks already done)
1199 _tool_start_time: Final = datetime.now()
1200 result: Final = await execute_mcp_tool(
1201 name=tool_name,
1202 arguments=tool_arguments,
1203 allowed_mcp_servers=allowed_mcp_servers,
1204 start_time=_tool_start_time,
1205 user_api_key_auth=data.get("user_api_key_auth"),
1206 mcp_auth_header=data.get("mcp_auth_header"),
1207 mcp_server_auth_headers=data.get("mcp_server_auth_headers"),
1208 oauth2_headers=user_oauth_extra_headers or caller_oauth2_headers,
1209 raw_headers=data.get("raw_headers"),
1210 client_ip=IPAddressUtils.get_mcp_client_ip(request),
1211 litellm_logging_obj=data.get("litellm_logging_obj"),
1212 guardrail_context=MCPRequestContext.resolve_guardrail_context(data),
1213 requested_server_id=canonical_server_id,
1214 )
1215 except Exception as e:
1216 request_data: Final = proxy_base_llm_response_processor.data
1217 await _safe_fire_mcp_tool_call_failure_logging(
1218 request_data.get("litellm_logging_obj"), e, _request_start_time, user_api_key_dict, request_data
1219 )
1220 raise
1221 return await _safe_fire_mcp_tool_call_logging(
1222 logging_obj,
1223 result,
1224 _tool_start_time,
1225 datetime.now(),
1226 user_api_key_auth=user_api_key_dict,
1227 request_data=data,
1228 )
1229 except MCPMissingUserEnvVarsError as e:
1230 verbose_logger.info(
1231 "MCP tool call missing per-user env vars: server_id=%s missing=%s",
1232 e.server_id,
1233 e.missing,
1234 )
1235 raise HTTPException(
1236 status_code=412,
1237 detail={
1238 "error": "missing_user_env_vars",
1239 "message": str(e),
1240 "server_id": e.server_id,
1241 "server_name": e.server_name,
1242 "missing": e.missing,
1243 "setup_url": e.setup_url,
1244 },
1245 )
1246 except BlockedPiiEntityError as e:
1247 verbose_logger.error("BlockedPiiEntityError in MCP tool call: %s", e)
1248 raise HTTPException(
1249 status_code=400,
1250 detail={
1251 "error": "blocked_pii_entity",
1252 "message": str(e),
1253 "entity_type": getattr(e, "entity_type", None),
1254 "guardrail_name": getattr(e, "guardrail_name", None),
1255 },
1256 )
1257 except (GuardrailRaisedException, ModifyResponseException) as e:
1258 verbose_logger.error("Guardrail violation in MCP tool call: %s", e)
1259 raise HTTPException(
1260 status_code=400,
1261 detail={
1262 "error": "guardrail_violation",
1263 "message": str(e),
1264 "guardrail_name": getattr(e, "guardrail_name", None),
1265 },
1266 )
1267 except MCPUpstreamAuthError as e:
1268 # A client-forwarded pass-through upstream 401 from either the direct or the virtual call
1269 # branch. Relay it as a 401 + WWW-Authenticate so the MCP client can re-run upstream OAuth,
1270 # and log at info: an expected caller-must-reauth signal, not an operator-actionable error.
1271 verbose_logger.info("MCP tool call relaying upstream HTTP %s", e.status_code)
1272 raise _relay_upstream_auth_http_exception(e, request)
1273 except HTTPException as e:
1274 # Locally generated denials (tool/server permission, IP filtering, BYOK) stay at error level
1275 # so restriction probing keeps full monitoring visibility; the relayed upstream 401 above is
1276 # the only status demoted to info.
1277 verbose_logger.error("HTTPException in MCP tool call: %s", e)
1278 raise e
1279 except Exception as e:
1280 verbose_logger.exception("Unexpected error in MCP tool call: %s", e)
1281 raise HTTPException(
1282 status_code=500,
1283 detail={
1284 "error": "internal_server_error",
1285 "message": f"An unexpected error occurred: {e}",
1286 },
1287 )
1289 ########################################################
1290 # MCP Connection testing routes
1291 # /health -> Test if we can connect to the MCP server
1292 # /health/tools/list -> List tools from MCP server
1293 # For these routes users will dynamically pass the MCP connection params, they don't need to be on the MCP registry
1294 ########################################################
1295 from litellm.proxy.management_endpoints.mcp_management_endpoints import (
1296 NewMCPServerRequest,
1297 _inherit_credentials_from_existing_server,
1298 )
1300 def _extract_credentials(
1301 request: NewMCPServerRequest,
1302 ) -> tuple:
1303 """
1304 Extract OAuth credentials from the nested ``request.credentials`` dict.
1306 Returns:
1307 (client_id, client_secret, scopes) — any value may be ``None``.
1308 """
1309 creds: Final = request.credentials if isinstance(request.credentials, dict) else {}
1310 client_id: Final[str | None] = creds.get("client_id")
1311 client_secret: Final[str | None] = creds.get("client_secret")
1312 scopes_raw: Final = creds.get("scopes")
1313 scopes: Final[list[str] | None] = scopes_raw if isinstance(scopes_raw, list) else None
1314 return client_id, client_secret, scopes
1316 _STAGED_AUTH_VALUE_AUTH_TYPES: Final = frozenset(
1317 (MCPAuth.api_key, MCPAuth.bearer_token, MCPAuth.basic, MCPAuth.authorization, MCPAuth.token)
1318 )
1320 @dataclass(frozen=True, slots=True)
1321 class _StagedServerTest:
1322 request: NewMCPServerRequest
1323 mcp_auth_header: str | None
1324 oauth2_headers: dict[str, str] | None
1326 def _preview_origin(url: str | None) -> tuple[str, str, int | None] | None:
1327 if not url:
1328 return None
1329 try:
1330 parsed: Final = httpx.URL(url)
1331 except httpx.InvalidURL:
1332 return None
1333 if parsed.scheme not in ("http", "https") or not parsed.host: 1333 ↛ 1334line 1333 didn't jump to line 1334 because the condition on line 1333 was never true
1334 return None
1335 return parsed.scheme, parsed.host, parsed.port
1337 def _stage_server_test(new_mcp_server_request: NewMCPServerRequest, headers: Headers) -> _StagedServerTest:
1338 """
1339 Resolve the credentials a not-yet-saved server config carries for a preview call.
1341 Both preview endpoints (``/test/connection`` and ``/test/tools/list``) must hand the
1342 temporary client the same credentials, or a server that the saved connection reaches
1343 fine fails one of them.
1344 """
1345 from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
1346 MCPRequestHandler,
1347 )
1349 saved_server: Final = (
1350 global_mcp_server_manager.get_mcp_server_by_id(new_mcp_server_request.server_id)
1351 if new_mcp_server_request.server_id
1352 else None
1353 )
1354 saved_origin: Final = _preview_origin(saved_server.url) if saved_server else None
1355 preview_origin: Final = _preview_origin(new_mcp_server_request.url)
1356 may_inherit: Final = new_mcp_server_request.auth_type not in _STAGED_AUTH_VALUE_AUTH_TYPES or (
1357 saved_origin is not None and saved_origin == preview_origin
1358 )
1359 request: Final = (
1360 _inherit_credentials_from_existing_server(new_mcp_server_request) if may_inherit else new_mcp_server_request
1361 )
1362 mcp_auth_header: Final = (
1363 request.credentials.get("auth_value")
1364 if request.auth_type in _STAGED_AUTH_VALUE_AUTH_TYPES and isinstance(request.credentials, dict)
1365 else None
1366 )
1367 # Authorization doubles as the admission fallback (LITELLM_API_KEY_HEADER_NAME_SECONDARY):
1368 # when the primary x-litellm-api-key header is absent, the Authorization value is the
1369 # caller's LiteLLM key, not an upstream token, and must never be forwarded upstream.
1370 oauth2_headers: Final = (
1371 MCPRequestHandler._get_oauth2_headers_from_headers(headers)
1372 if request.auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
1373 and headers.get(MCPRequestHandler.LITELLM_API_KEY_HEADER_NAME_PRIMARY)
1374 else None
1375 )
1376 return _StagedServerTest(request=request, mcp_auth_header=mcp_auth_header, oauth2_headers=oauth2_headers)
1378 async def _list_tools_within(client: MCPClient, deadline: float) -> list[MCPTool] | None:
1379 with anyio.move_on_after(deadline):
1380 return await client.list_tools(raise_on_error=True)
1381 return None
1383 async def _execute_with_mcp_client(
1384 request: NewMCPServerRequest,
1385 operation: Callable[..., Awaitable[Mapping[str, object]]],
1386 mcp_auth_header: str | dict[str, str] | None = None,
1387 oauth2_headers: dict[str, str] | None = None,
1388 raw_headers: dict[str, str] | None = None,
1389 timeout_seconds: float = MCP_TOOL_LISTING_TIMEOUT,
1390 ) -> Mapping[str, object]:
1391 """
1392 Create a temporary MCP client from *request*, run *operation*, and return the result.
1394 For M2M OAuth servers (those with ``client_id``, ``client_secret``, and
1395 ``token_url``), the incoming ``oauth2_headers`` are dropped so that
1396 ``resolve_mcp_auth`` can auto-fetch a token via ``client_credentials``.
1398 Args:
1399 request: MCP server configuration submitted by the UI.
1400 operation: Async callable that receives the created client and returns a result dict.
1401 mcp_auth_header: Pre-resolved credential header (API-key / bearer token).
1402 oauth2_headers: Headers extracted from the incoming request (may contain the
1403 litellm API key — must NOT be forwarded for M2M servers).
1404 raw_headers: Raw request headers forwarded for stdio env construction.
1405 timeout_seconds: Cap on OAuth discovery, connect, handshake, and *operation*
1406 combined. Defaults to ``MCP_TOOL_LISTING_TIMEOUT`` (30s, below common LB
1407 timeouts) so an unreachable upstream yields this endpoint's JSON error
1408 instead of an opaque load-balancer 504 with an empty body.
1410 Returns:
1411 The dict returned by *operation*, or an error dict on failure.
1412 """
1413 try:
1414 client_id, client_secret, scopes = _extract_credentials(request)
1416 _oauth2_flow: Literal["client_credentials", "authorization_code"] | None = request.oauth2_flow or (
1417 "client_credentials" if client_id and client_secret and request.token_url else None
1418 )
1419 # client_credentials requires token_url to fetch a token; without it the
1420 # incoming auth header would be dropped with nothing to replace it.
1421 if _oauth2_flow == "client_credentials" and not request.token_url:
1422 _oauth2_flow = None
1424 # Static previews inherit credentials before this step, but must not resolve back to
1425 # the saved record during client creation and discard the edited connection settings.
1426 preview_server_id: Final = (
1427 ""
1428 if request.auth_type in _STAGED_AUTH_VALUE_AUTH_TYPES or request.auth_type in (None, MCPAuth.none)
1429 else request.server_id or ""
1430 )
1431 server_model: Final = MCPServer(
1432 server_id=preview_server_id,
1433 name=request.alias or request.server_name or "",
1434 url=request.url,
1435 transport=request.transport,
1436 auth_type=request.auth_type,
1437 mcp_info=request.mcp_info,
1438 timeout=request.timeout,
1439 command=request.command,
1440 args=request.args,
1441 env=request.env,
1442 static_headers=request.static_headers,
1443 client_id=client_id,
1444 client_secret=client_secret,
1445 issuer=request.issuer,
1446 token_url=request.token_url,
1447 scopes=scopes,
1448 authorization_url=request.authorization_url,
1449 registration_url=request.registration_url,
1450 oauth2_flow=_oauth2_flow,
1451 instructions=request.instructions,
1452 )
1454 stdio_env: Final = global_mcp_server_manager._build_stdio_env(server_model, raw_headers)
1456 # For M2M OAuth servers, drop the incoming Authorization header so that
1457 # resolve_mcp_auth can auto-fetch a token via client_credentials.
1458 effective_oauth2_headers: Final = None if server_model.has_client_credentials else oauth2_headers
1460 # Interactive authorization_code tools preview: the operator holds a just-authorized
1461 # token but it is not persisted yet. Resolve it through the v2 resolver via a one-shot
1462 # presented store - the same path runtime uses for the stored token - rather than the
1463 # caller-override path _create_mcp_client refuses for authorization_code. The bare token
1464 # becomes the upstream credential, so it is not also forwarded as a caller header. Gated
1465 # to the v2-mapped oauth2 case (to_server_spec non-None); M2M (client_credentials),
1466 # delegate/passthrough, and token-exchange are unaffected.
1467 from litellm.proxy._experimental.mcp_server.outbound_credentials import ( # noqa: PLC0415
1468 UpstreamCredentialProvider,
1469 )
1470 from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import ( # noqa: PLC0415
1471 to_server_spec,
1472 )
1473 from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import ( # noqa: PLC0415
1474 OAuthToken,
1475 )
1476 from litellm.proxy._experimental.mcp_server.outbound_credentials.presented_token_store import ( # noqa: PLC0415
1477 PresentedOAuthTokenStore,
1478 )
1480 forwarded_authorization: Final = (
1481 effective_oauth2_headers.get("Authorization") if effective_oauth2_headers else None
1482 )
1483 preview_cred_provider: Final = (
1484 UpstreamCredentialProvider(
1485 oauth_token_store=PresentedOAuthTokenStore(
1486 OAuthToken(
1487 access_token=forwarded_authorization[7:]
1488 if forwarded_authorization[:7].lower() == "bearer "
1489 else forwarded_authorization
1490 )
1491 )
1492 )
1493 if (
1494 server_model.auth_type == MCPAuth.oauth2
1495 and forwarded_authorization is not None
1496 and to_server_spec(server_model) is not None
1497 )
1498 else None
1499 )
1501 merged_headers: Final = merge_mcp_headers(
1502 extra_headers=(None if preview_cred_provider else effective_oauth2_headers),
1503 static_headers=request.static_headers,
1504 )
1506 with anyio.fail_after(timeout_seconds):
1507 client: Final = await global_mcp_server_manager._create_mcp_client(
1508 server=server_model,
1509 mcp_auth_header=mcp_auth_header,
1510 extra_headers=merged_headers,
1511 stdio_env=stdio_env,
1512 cred_provider=preview_cred_provider,
1513 )
1515 return await operation(client)
1517 except (KeyboardInterrupt, SystemExit, asyncio.CancelledError):
1518 raise
1519 except BaseException as e:
1520 effective_timeout: Final = (
1521 min(request.timeout if request.timeout is not None else MCP_CLIENT_TIMEOUT, timeout_seconds)
1522 if any(
1523 isinstance(cause, MCPError) and as_mcp_read_timeout(cause) is not None
1524 for cause in iter_exception_tree(e)
1525 )
1526 else timeout_seconds
1527 )
1528 return {
1529 "status": "error",
1530 "error": True,
1531 "message": _connection_error_message(e, request.url, effective_timeout),
1532 }
1534 async def _preview_openapi_tools(spec_path: str) -> dict:
1535 """Generate tool previews from an OpenAPI spec without creating a server."""
1536 from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
1537 _OPENAPI_TOOL_NAME_MAX_LEN,
1538 build_input_schema,
1539 load_openapi_spec_async,
1540 resolve_operation_params,
1541 sanitize_openapi_tool_name,
1542 )
1544 try:
1545 spec: Final = await load_openapi_spec_async(spec_path)
1546 paths: Final = spec.get("paths", {})
1547 components: Final = spec.get("components", {})
1548 tools: Final[list[dict]] = []
1549 used_names: Final[set] = set()
1550 for path, path_item in paths.items():
1551 for method in ("get", "post", "put", "delete", "patch"):
1552 operation = path_item.get(method)
1553 if operation is None:
1554 continue
1556 resolved_op = resolve_operation_params(operation, path_item, components)
1558 raw_op_id = operation.get("operationId", f"{method}_{path}")
1559 # Match what register_tools_from_openapi does so the preview
1560 # the user sees in the dashboard equals the names that get
1561 # registered (and shipped to LLM providers, which enforce
1562 # ^[a-zA-Z0-9_-]+$). See sanitize_openapi_tool_name docstring.
1563 op_id = sanitize_openapi_tool_name(raw_op_id)
1565 unique = op_id
1566 n = 1
1567 while unique in used_names:
1568 n += 1
1569 suffix = f"_{n}"
1570 unique = op_id[: _OPENAPI_TOOL_NAME_MAX_LEN - len(suffix)] + suffix
1571 op_id = unique
1572 used_names.add(op_id)
1573 summary = operation.get("summary", "")
1574 description = operation.get("description", summary)
1575 input_schema = build_input_schema(resolved_op)
1576 tools.append(
1577 {
1578 "name": op_id,
1579 "description": description or summary or f"{method.upper()} {path}",
1580 "inputSchema": input_schema,
1581 }
1582 )
1583 return {
1584 "tools": tools,
1585 "error": None,
1586 "message": f"Found {len(tools)} tools from OpenAPI spec",
1587 }
1588 except Exception as e:
1589 verbose_logger.error("Error previewing OpenAPI tools: %s", e, exc_info=True)
1590 return {
1591 "tools": [],
1592 "error": True,
1593 "message": f"Failed to load OpenAPI spec: {e}",
1594 }
1596 @router.post("/test/connection", dependencies=[Depends(user_api_key_auth)])
1597 async def test_connection(
1598 request: Request,
1599 new_mcp_server_request: NewMCPServerRequest,
1600 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
1601 ):
1602 """
1603 Test if we can connect to the provided MCP server before adding it
1604 """
1605 if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role: 1605 ↛ 1606line 1605 didn't jump to line 1606 because the condition on line 1605 was never true
1606 raise HTTPException(
1607 status_code=status.HTTP_403_FORBIDDEN,
1608 detail={
1609 "error": "User does not have permission to test MCP server connections. Only PROXY_ADMIN users can perform this action."
1610 },
1611 )
1613 staged: Final = _stage_server_test(new_mcp_server_request, request.headers)
1615 async def _test_connection_operation(client):
1616 async def _noop(session):
1617 return "ok"
1619 await client.run_with_session(_noop)
1620 return {"status": "ok"}
1622 return await _execute_with_mcp_client(
1623 staged.request,
1624 _test_connection_operation,
1625 mcp_auth_header=staged.mcp_auth_header,
1626 oauth2_headers=staged.oauth2_headers,
1627 raw_headers=_safe_get_request_headers(request),
1628 )
1630 @router.post("/test/tools/list", dependencies=[Depends(user_api_key_auth)])
1631 async def test_tools_list(
1632 request: Request,
1633 new_mcp_server_request: NewMCPServerRequest,
1634 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
1635 ):
1636 """
1637 Preview tools available from MCP server before adding it
1638 """
1639 if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role: 1639 ↛ 1640line 1639 didn't jump to line 1640 because the condition on line 1639 was never true
1640 raise HTTPException(
1641 status_code=status.HTTP_403_FORBIDDEN,
1642 detail={
1643 "error": "User does not have permission to test MCP server tools. Only PROXY_ADMIN users can perform this action."
1644 },
1645 )
1647 staged: Final = _stage_server_test(new_mcp_server_request, request.headers)
1649 # For OpenAPI spec servers, generate tools from the spec directly
1650 if staged.request.spec_path: 1650 ↛ 1651line 1650 didn't jump to line 1651 because the condition on line 1650 was never true
1651 return await _preview_openapi_tools(staged.request.spec_path)
1653 async def _list_tools_operation(client):
1654 # Bound the whole pagination walk: without this the preview is limited only by the
1655 # per-request timeout times the page cap. max() keeps the pre-pagination guarantee
1656 # that a single slow page within the client timeout still succeeds, and a
1657 # per-server timeout above the global default extends the deadline with it.
1658 listing_deadline: Final = max(
1659 getattr(client, "timeout", MCP_CLIENT_TIMEOUT) or MCP_CLIENT_TIMEOUT,
1660 MCP_TOOL_LISTING_TIMEOUT,
1661 )
1662 list_tools_result: Final = await _list_tools_within(client, listing_deadline)
1663 if list_tools_result is None:
1664 verbose_logger.warning(
1665 "MCP tools/list preview timed out after %s seconds while paginating upstream tools",
1666 listing_deadline,
1667 )
1668 return { # mutable-ok: error response payload
1669 "status": "error",
1670 "error": True,
1671 "message": f"Timed out listing tools after {listing_deadline} seconds. "
1672 "The MCP server may be responding slowly or paginating excessively.",
1673 }
1674 model_dumped_tools: Final[list[dict]] = [tool.model_dump(by_alias=True) for tool in list_tools_result]
1675 return {
1676 "tools": model_dumped_tools,
1677 "error": None,
1678 "message": "Successfully retrieved tools",
1679 }
1681 return await _execute_with_mcp_client(
1682 staged.request,
1683 _list_tools_operation,
1684 mcp_auth_header=staged.mcp_auth_header,
1685 oauth2_headers=staged.oauth2_headers,
1686 raw_headers=_safe_get_request_headers(request),
1687 )