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

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 

10 

11import anyio 

12import httpx 

13import httpx2 

14from fastapi import APIRouter, Depends, HTTPException, Query, Request, status 

15from pydantic import ValidationError 

16from starlette.datastructures import Headers 

17 

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 

56 

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 

59 

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 

65 

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 

72 

73 

74router: Final = APIRouter( 

75 prefix="/mcp-rest", 

76 tags=["mcp"], 

77) 

78 

79_MCP_GUARDRAIL_REJECTIONS: Final = ( 

80 BlockedPiiEntityError, 

81 GuardrailRaisedException, 

82 ModifyResponseException, 

83 HTTPException, 

84) 

85 

86_CLIENT_FORWARDED_TOKEN_AUTH_TYPES: Final = frozenset((MCPAuth.true_passthrough, MCPAuth.oauth_delegate)) 

87 

88 

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 ) 

114 

115 

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 

191 

192 

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 

196 

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 ) 

223 

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. 

235 

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 

262 

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) 

276 

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 ) 

285 

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 

312 

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 ) 

397 

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 ) 

407 

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 

418 

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. 

421 

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 ) 

430 

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 

434 

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. 

438 

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 } 

447 

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. 

458 

459 A server the v2 resolver owns is skipped; see ``_is_v1_resolved_oauth2_server``. 

460 

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 ) 

476 

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 

482 

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 

503 

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. 

508 

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 

520 

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

529 

530 def _create_tool_response_objects(tools, server: MCPServer): 

531 """Helper function to create tool response objects. 

532 

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 ] 

551 

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. 

558 

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 

567 

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. 

575 

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 

586 

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. 

594 

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 

599 

600 Returns: 

601 Tuple of (allowed MCPServer objects, canonical server_id) 

602 

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) 

608 

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) 

617 

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 ) 

621 

622 canonical_server_id: Final = _resolve_mcp_server_id_for_rest(server_id, allowed_server_ids_set, _rest_client_ip) 

623 

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 ) 

660 

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) 

667 

668 return allowed_mcp_servers, canonical_server_id 

669 

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. 

680 

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 ) 

695 

696 if not apply_tool_filters: 

697 return _create_tool_response_objects(tools, server) 

698 

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) 

702 

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 ) 

715 

716 return _create_tool_response_objects(tools, server) 

717 

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 

742 

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 

760 

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 } 

794 

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) 

797 

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 } 

835 

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 

839 

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. 

845 

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) 

853 

854 from litellm.proxy.utils import get_prisma_client_or_throw 

855 

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) 

864 

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. 

883 

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 ) 

906 

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) 

912 

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

916 

917 if server_id is None: 

918 server_id = mcp_server_name 

919 

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 ) 

933 

934 return { 

935 "tools": get_virtual_tool_definitions(), 

936 "error": None, 

937 "message": "Successfully retrieved tools", 

938 } 

939 

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) 

945 

946 auth_contexts: Final = await build_effective_auth_contexts(user_api_key_dict) 

947 

948 _rest_client_ip: Final = IPAddressUtils.get_mcp_client_ip(request) 

949 

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) 

956 

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 ) 

963 

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 ) 

998 

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 ) 

1006 

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

1034 

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 } 

1059 

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 } 

1088 

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 

1098 

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 ) 

1111 

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

1116 

1117 tool_name: Final[str | None] = data.get("name") 

1118 tool_arguments: Final[dict[str, object]] = data.get("arguments") or {} 

1119 

1120 from litellm.proxy._experimental.mcp_server.tool_search import VIRTUAL_TOOL_NAMES 

1121 

1122 if tool_name in VIRTUAL_TOOL_NAMES: 

1123 return await _handle_virtual_mcp_tool(request, data, tool_name, user_api_key_dict) 

1124 

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 ) 

1135 

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 ) 

1144 

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 ) 

1160 

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 

1172 

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

1177 

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) 

1183 

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 ) 

1197 

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 ) 

1288 

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 ) 

1299 

1300 def _extract_credentials( 

1301 request: NewMCPServerRequest, 

1302 ) -> tuple: 

1303 """ 

1304 Extract OAuth credentials from the nested ``request.credentials`` dict. 

1305 

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 

1315 

1316 _STAGED_AUTH_VALUE_AUTH_TYPES: Final = frozenset( 

1317 (MCPAuth.api_key, MCPAuth.bearer_token, MCPAuth.basic, MCPAuth.authorization, MCPAuth.token) 

1318 ) 

1319 

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 

1325 

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 

1336 

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. 

1340 

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 ) 

1348 

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) 

1377 

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 

1382 

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. 

1393 

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

1397 

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. 

1409 

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) 

1415 

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 

1423 

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 ) 

1453 

1454 stdio_env: Final = global_mcp_server_manager._build_stdio_env(server_model, raw_headers) 

1455 

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 

1459 

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 ) 

1479 

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 ) 

1500 

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 ) 

1505 

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 ) 

1514 

1515 return await operation(client) 

1516 

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 } 

1533 

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 ) 

1543 

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 

1555 

1556 resolved_op = resolve_operation_params(operation, path_item, components) 

1557 

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) 

1564 

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 } 

1595 

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 ) 

1612 

1613 staged: Final = _stage_server_test(new_mcp_server_request, request.headers) 

1614 

1615 async def _test_connection_operation(client): 

1616 async def _noop(session): 

1617 return "ok" 

1618 

1619 await client.run_with_session(_noop) 

1620 return {"status": "ok"} 

1621 

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 ) 

1629 

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 ) 

1646 

1647 staged: Final = _stage_server_test(new_mcp_server_request, request.headers) 

1648 

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) 

1652 

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 } 

1680 

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 )