diff --git a/litellm/experimental_mcp_client/tools.py b/litellm/experimental_mcp_client/tools.py index c65b266bd02..1bd65847616 100644 --- a/litellm/experimental_mcp_client/tools.py +++ b/litellm/experimental_mcp_client/tools.py @@ -9,6 +9,7 @@ from openai.types.chat import ChatCompletionToolParam from openai.types.responses.function_tool_param import FunctionToolParam from openai.types.shared_params.function_definition import FunctionDefinition +from litellm.types.llms.anthropic import AnthropicInputSchema, AnthropicMessagesTool from litellm.types.utils import ChatCompletionMessageToolCall @@ -75,6 +76,18 @@ def transform_mcp_tool_to_openai_responses_api_tool( ) +def transform_mcp_tool_to_anthropic_tool(mcp_tool: MCPTool) -> AnthropicMessagesTool: + """Convert an MCP tool to an Anthropic Messages API tool.""" + normalized_parameters = _normalize_mcp_input_schema(mcp_tool.inputSchema) + + return AnthropicMessagesTool( + name=mcp_tool.name, + description=mcp_tool.description or "", + input_schema=AnthropicInputSchema(**normalized_parameters), + type="custom", + ) + + async def load_mcp_tools( session: ClientSession, format: Literal["mcp", "openai"] = "mcp" ) -> Union[List[MCPTool], List[ChatCompletionToolParam]]: diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index dd983f0c344..499f5bc486c 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -477,6 +477,41 @@ def anthropic_messages_handler( mock_response=litellm_params.mock_response, ) + # Expand litellm_proxy MCP references through the MCP gateway before dispatch, so every + # downstream path (native passthrough and both bridges) gets real tools rather than a + # reference the provider cannot resolve. Popped from kwargs so it never reaches the provider. + skip_mcp_handler = kwargs.pop("_skip_mcp_handler", False) + if not skip_mcp_handler and tools: + from litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler import ( + anthropic_messages_with_mcp, + ) + from litellm.responses.mcp.litellm_proxy_mcp_handler import ( + LiteLLM_Proxy_MCP_Handler, + ) + + if LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway(tools=tools): + return anthropic_messages_with_mcp( + max_tokens=max_tokens, + messages=messages, + model=model, + metadata=metadata, + stop_sequences=stop_sequences, + stream=stream, + system=system, + temperature=temperature, + thinking=thinking, + tool_choice=tool_choice, + tools=tools, + top_k=top_k, + top_p=top_p, + container=container, + api_key=api_key, + api_base=api_base, + client=client, + custom_llm_provider=custom_llm_provider, + **kwargs, + ) + anthropic_messages_provider_config: Optional[BaseAnthropicMessagesConfig] = None if custom_llm_provider is not None and custom_llm_provider in [provider.value for provider in LlmProviders]: diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/mcp_handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/mcp_handler.py new file mode 100644 index 00000000000..392b9e2e02d --- /dev/null +++ b/litellm/llms/anthropic/experimental_pass_through/messages/mcp_handler.py @@ -0,0 +1,174 @@ +""" +MCP gateway support for the Anthropic `/v1/messages` API. + +Mirrors ``litellm.responses.mcp.chat_completions_handler`` but speaks the +Anthropic Messages shapes: tools carry an ``input_schema``, the model asks for a +tool through a ``tool_use`` content block, and results are fed back as +``tool_result`` blocks in a user message. +""" + +from typing import Any, AsyncIterator, Mapping, Sequence, Union + +from litellm._logging import verbose_logger +from litellm.types.llms.anthropic import ( + AnthropicMessagesTool, + AnthropicMessagesToolResultParam, + AnthropicMessagesUserMessageParam, +) +from litellm.types.llms.anthropic_messages.anthropic_response import ( + AnthropicMessagesResponse, +) + +MAX_MCP_TOOL_USE_ITERATIONS = 10 + + +def _get_response_content(response: AnthropicMessagesResponse) -> Sequence[Mapping[str, Any]]: + content = response.get("content") + if not isinstance(content, list): + return () + return tuple(block for block in content if isinstance(block, dict)) + + +def _extract_tool_use_blocks(response: AnthropicMessagesResponse) -> Sequence[Mapping[str, Any]]: + """Return the ``tool_use`` content blocks the model emitted.""" + return tuple(block for block in _get_response_content(response) if block.get("type") == "tool_use") + + +def _get_stop_reason(response: AnthropicMessagesResponse) -> Union[str, None]: + stop_reason = response.get("stop_reason") + return stop_reason if isinstance(stop_reason, str) else None + + +def _build_tool_result_message(tool_results: Sequence[Mapping[str, Any]]) -> AnthropicMessagesUserMessageParam: + """Turn executed tool results into the user message Anthropic expects.""" + return AnthropicMessagesUserMessageParam( + role="user", + content=tuple( + AnthropicMessagesToolResultParam( + type="tool_result", + tool_use_id=str(result.get("tool_call_id") or ""), + content=str(result.get("result") or ""), + ) + for result in tool_results + ), + ) + + +def _resolve_user_api_key_auth( + kwargs: Mapping[str, Any], +) -> Any: # any-ok: UserAPIKeyAuth is proxy-only, importing it here would create a cycle + """`/v1/messages` is a LITELLM_METADATA_ROUTE, so the auth object rides in litellm_metadata.""" + litellm_metadata = kwargs.get("litellm_metadata") or {} + metadata = kwargs.get("metadata") or {} + return ( + kwargs.get("user_api_key_auth") + or litellm_metadata.get("user_api_key_auth") + or metadata.get("user_api_key_auth") + ) + + +async def anthropic_messages_with_mcp( + max_tokens: int, + messages: Sequence[Mapping[str, Any]], + model: str, + tools: Union[Sequence[Mapping[str, Any]], None] = None, + **kwargs: Any, # kwargs-ok: forwarded verbatim to litellm.anthropic_messages, which owns the param contract +) -> Union[AnthropicMessagesResponse, AsyncIterator[Any]]: + """ + Expand litellm_proxy MCP references for `/v1/messages` and run the tool loop. + + The MCP gateway owns the expansion so the reference resolves against the + caller's own credentials and access control, rather than being handed to the + upstream provider as a url it cannot reach. + """ + import litellm + from litellm.experimental_mcp_client.tools import ( + transform_mcp_tool_to_anthropic_tool, + ) + from litellm.responses.mcp.litellm_proxy_mcp_handler import ( + LiteLLM_Proxy_MCP_Handler, + ) + + mcp_references, other_tools = LiteLLM_Proxy_MCP_Handler._parse_mcp_tools(tools) + + if not mcp_references: + return await litellm.anthropic_messages( + max_tokens=max_tokens, + messages=list(messages), + model=model, + tools=list(tools) if tools else None, + _skip_mcp_handler=True, + **kwargs, + ) + + user_api_key_auth = _resolve_user_api_key_auth(kwargs) + + ( + deduplicated_mcp_tools, + tool_server_map, + ) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform( + user_api_key_auth, + mcp_references, + litellm_trace_id=kwargs.get("litellm_trace_id"), + ) + + anthropic_tools: Sequence[AnthropicMessagesTool] = tuple( + transform_mcp_tool_to_anthropic_tool(mcp_tool) for mcp_tool in deduplicated_mcp_tools + ) + all_tools = [*anthropic_tools, *(other_tools or ())] + + should_auto_execute = LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools( + mcp_tools_with_litellm_proxy=mcp_references + ) + stream = bool(kwargs.pop("stream", False)) + + base_call_args: Mapping[str, Any] = { + "max_tokens": max_tokens, + "model": model, + "tools": all_tools or None, + "_skip_mcp_handler": True, + **kwargs, + } + + if not should_auto_execute: + return await litellm.anthropic_messages(messages=list(messages), stream=stream, **base_call_args) + + working_messages: Sequence[Mapping[str, Any]] = tuple(messages) + response: AnthropicMessagesResponse = await litellm.anthropic_messages( + messages=list(working_messages), stream=False, **base_call_args + ) + + for _ in range(MAX_MCP_TOOL_USE_ITERATIONS): + if _get_stop_reason(response) != "tool_use": + break + + tool_use_blocks = _extract_tool_use_blocks(response) + if not tool_use_blocks: + break + + tool_results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls( + tool_server_map=tool_server_map, + tool_calls=list(tool_use_blocks), + user_api_key_auth=user_api_key_auth, + litellm_trace_id=kwargs.get("litellm_trace_id"), + ) + + working_messages = ( + *working_messages, + {"role": "assistant", "content": list(_get_response_content(response))}, + _build_tool_result_message(tool_results), + ) + response = await litellm.anthropic_messages(messages=list(working_messages), stream=False, **base_call_args) + else: + verbose_logger.warning( + f"MCP tool loop hit its {MAX_MCP_TOOL_USE_ITERATIONS} iteration cap for model {model}; " + "returning the last response" + ) + + if stream: + from litellm.llms.anthropic.experimental_pass_through.messages.fake_stream_iterator import ( + FakeAnthropicMessagesStreamIterator, + ) + + return FakeAnthropicMessagesStreamIterator(response) + return response diff --git a/litellm/responses/mcp/litellm_proxy_mcp_handler.py b/litellm/responses/mcp/litellm_proxy_mcp_handler.py index e03f0296109..d2c9f220690 100644 --- a/litellm/responses/mcp/litellm_proxy_mcp_handler.py +++ b/litellm/responses/mcp/litellm_proxy_mcp_handler.py @@ -541,7 +541,10 @@ class LiteLLM_Proxy_MCP_Handler: tool_arguments = function_block.get("arguments") else: tool_name = tool_call.get("name") + # Anthropic tool_use blocks carry the arguments under `input` tool_arguments = tool_call.get("arguments") + if tool_arguments is None: + tool_arguments = tool_call.get("input") else: tool_call_id = getattr(tool_call, "call_id", None) or getattr(tool_call, "id", None) @@ -552,6 +555,8 @@ class LiteLLM_Proxy_MCP_Handler: else: tool_name = getattr(tool_call, "name", None) tool_arguments = getattr(tool_call, "arguments", None) + if tool_arguments is None: + tool_arguments = getattr(tool_call, "input", None) return tool_name, tool_arguments, tool_call_id diff --git a/tests/test_litellm/experimental_mcp_client/test_tools.py b/tests/test_litellm/experimental_mcp_client/test_tools.py index 786bbf7dcc9..625ab56951f 100644 --- a/tests/test_litellm/experimental_mcp_client/test_tools.py +++ b/tests/test_litellm/experimental_mcp_client/test_tools.py @@ -18,6 +18,7 @@ from mcp.types import ( from mcp.types import Tool as MCPTool from litellm.experimental_mcp_client.tools import ( + transform_mcp_tool_to_anthropic_tool, _get_function_arguments, _normalize_mcp_input_schema, call_mcp_tool, @@ -250,3 +251,51 @@ def test_transform_mcp_tool_to_openai_responses_api_tool(): assert "query" in openai_tool["parameters"]["properties"] assert openai_tool["parameters"]["required"] == ["query"] assert openai_tool["parameters"]["additionalProperties"] == False + + +def test_transform_mcp_tool_to_anthropic_tool(): + """ + Regression test (LIT-4517): MCP tools must reach /v1/messages in Anthropic's + own tool shape. + + Given: An MCP tool + When: It is transformed for the Anthropic Messages API + Then: It carries name/description/input_schema, the shape that endpoint + accepts, rather than an OpenAI function block + + /v1/messages rejects an OpenAI-shaped tool outright ("Input tag 'function' + does not match any of the expected tags"), so reusing either OpenAI + transform here loses every MCP tool. + """ + tool = MCPTool( + name="read_wiki_structure", + description="Get a list of documentation topics", + inputSchema={ + "type": "object", + "properties": {"repoName": {"type": "string"}}, + "required": ["repoName"], + }, + ) + + anthropic_tool = transform_mcp_tool_to_anthropic_tool(tool) + + assert anthropic_tool["name"] == "read_wiki_structure" + assert anthropic_tool["description"] == "Get a list of documentation topics" + assert anthropic_tool["type"] == "custom" + assert anthropic_tool["input_schema"]["type"] == "object" + assert "repoName" in anthropic_tool["input_schema"]["properties"] + assert anthropic_tool["input_schema"]["required"] == ["repoName"] + assert "function" not in anthropic_tool, "Anthropic tools must not carry an OpenAI function block" + assert "parameters" not in anthropic_tool, "Anthropic names the schema input_schema, not parameters" + + +def test_transform_mcp_tool_to_anthropic_tool_normalizes_empty_schema(): + """A tool with no declared arguments must still present a valid object schema.""" + anthropic_tool = transform_mcp_tool_to_anthropic_tool( + MCPTool(name="noargs", description=None, inputSchema={}) + ) + + assert anthropic_tool["name"] == "noargs" + assert anthropic_tool["description"] == "" + assert anthropic_tool["input_schema"]["type"] == "object" + assert anthropic_tool["input_schema"]["properties"] == {} diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_mcp_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_mcp_handler.py new file mode 100644 index 00000000000..3faa6b1e4e2 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_mcp_handler.py @@ -0,0 +1,122 @@ +import os +import sys +from unittest.mock import AsyncMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../../../../../..")) + +from litellm.llms.anthropic.experimental_pass_through.messages.handler import ( + anthropic_messages_handler, +) +from litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler import ( + _build_tool_result_message, + _extract_tool_use_blocks, +) + +MCP_REFERENCE = { + "type": "mcp", + "server_label": "litellm", + "server_url": "litellm_proxy/mcp/deepwiki", + "require_approval": "never", +} + + +def test_anthropic_messages_handler_routes_litellm_proxy_mcp_to_the_gateway(): + """ + Regression test (LIT-4517): /v1/messages must expand a litellm_proxy MCP + reference through the MCP gateway. + + Given: A /v1/messages request whose tools carry a litellm_proxy MCP reference + When: The handler dispatches + Then: It hands off to the MCP gateway instead of the provider + + Without this hook the reference is forwarded to Anthropic verbatim and the API + rejects the request ("Input tag 'mcp' found using 'type' does not match any of + the expected tags"), because only /v1/chat/completions and /v1/responses ever + had a gateway entry point. This pins the wiring, not the helper: deleting the + dispatch makes the whole feature unreachable while every unit test still passes. + """ + with patch( + "litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler.anthropic_messages_with_mcp", + new=AsyncMock(return_value={"routed": True}), + ) as routed: + result = anthropic_messages_handler( + max_tokens=100, + messages=[{"role": "user", "content": "hi"}], + model="claude-sonnet-4-5", + tools=[MCP_REFERENCE], + custom_llm_provider="anthropic", + ) + + assert routed.called, "A litellm_proxy MCP reference must be dispatched to the MCP gateway" + assert routed.call_args.kwargs["tools"] == [MCP_REFERENCE] + assert routed.call_args.kwargs["model"] == "claude-sonnet-4-5" + assert result is not None + + +def test_anthropic_messages_handler_skips_the_gateway_on_recursion(): + """The gateway's own follow-up call must not re-enter the gateway.""" + with patch( + "litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler.anthropic_messages_with_mcp", + new=AsyncMock(return_value={"routed": True}), + ) as routed: + with pytest.raises(Exception): + anthropic_messages_handler( + max_tokens=100, + messages=[{"role": "user", "content": "hi"}], + model="claude-sonnet-4-5", + tools=[MCP_REFERENCE], + custom_llm_provider="anthropic", + _skip_mcp_handler=True, + ) + + assert not routed.called, "_skip_mcp_handler must stop the gateway from recursing" + + +def test_anthropic_messages_handler_leaves_native_tools_alone(): + """A plain Anthropic tool is not an MCP reference and must not reach the gateway.""" + with patch( + "litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler.anthropic_messages_with_mcp", + new=AsyncMock(return_value={"routed": True}), + ) as routed: + with pytest.raises(Exception): + anthropic_messages_handler( + max_tokens=100, + messages=[{"role": "user", "content": "hi"}], + model="claude-sonnet-4-5", + tools=[{"name": "get_weather", "input_schema": {"type": "object"}}], + custom_llm_provider="anthropic", + ) + + assert not routed.called, "Only litellm_proxy MCP references belong to the gateway" + + +def test_extract_tool_use_blocks_ignores_text_blocks(): + """Only tool_use blocks drive the loop; text blocks are the model's prose.""" + response = { + "content": [ + {"type": "text", "text": "let me look that up"}, + {"type": "tool_use", "id": "toolu_1", "name": "read_wiki_structure", "input": {"repoName": "a/b"}}, + ] + } + + blocks = _extract_tool_use_blocks(response) + + assert len(blocks) == 1 + assert blocks[0]["name"] == "read_wiki_structure" + + +def test_build_tool_result_message_uses_anthropic_tool_result_blocks(): + """ + Results must go back as tool_result blocks in a user message. + + Anthropic pairs each result to its request by tool_use_id; the OpenAI shape + (a role="tool" message keyed by tool_call_id) is rejected here. + """ + message = _build_tool_result_message([{"tool_call_id": "toolu_1", "result": "9 sections", "name": "read_wiki"}]) + + assert message["role"] == "user" + assert list(message["content"]) == [ + {"type": "tool_result", "tool_use_id": "toolu_1", "content": "9 sections"} + ] diff --git a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py index 6fdbb0741aa..1c23b1a8b98 100644 --- a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py +++ b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py @@ -605,3 +605,45 @@ def test_completion_with_function_tools_works_without_fastapi_installed(): timeout=120, ) assert result.returncode == 0, result.stderr + + +def test_extract_tool_call_details_reads_anthropic_tool_use_input(): + """ + Regression test (LIT-4517): an Anthropic tool_use block carries its arguments + under `input`, not `arguments`. + + Given: A tool_use content block as /v1/messages returns it + When: The shared extractor reads it + Then: The arguments come back, so the MCP tool is called with them + + Reading only `arguments` fails silently rather than loudly: _parse_tool_arguments + turns the resulting None into {}, so the tool still executes, just with every + argument dropped. + """ + tool_use_block = { + "type": "tool_use", + "id": "toolu_01ABC", + "name": "read_wiki_structure", + "input": {"repoName": "BerriAI/litellm"}, + } + + name, arguments, call_id = LiteLLM_Proxy_MCP_Handler._extract_tool_call_details(tool_use_block) + + assert name == "read_wiki_structure" + assert call_id == "toolu_01ABC" + assert arguments == {"repoName": "BerriAI/litellm"} + assert LiteLLM_Proxy_MCP_Handler._parse_tool_arguments(arguments) == {"repoName": "BerriAI/litellm"} + + +def test_extract_tool_call_details_still_prefers_openai_arguments(): + """The OpenAI chat shape must keep winning; `input` is only the fallback.""" + openai_tool_call = { + "id": "call_123", + "function": {"name": "get_weather", "arguments": '{"city": "Paris"}'}, + } + + name, arguments, call_id = LiteLLM_Proxy_MCP_Handler._extract_tool_call_details(openai_tool_call) + + assert name == "get_weather" + assert call_id == "call_123" + assert arguments == '{"city": "Paris"}' diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx index d8f927b9a63..d2cf27e0c8b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx @@ -98,7 +98,12 @@ interface ChatUIProps { fixedModel?: string; } -const MCP_SUPPORTED_ENDPOINTS = new Set([EndpointType.CHAT, EndpointType.RESPONSES, EndpointType.MCP]); +const MCP_SUPPORTED_ENDPOINTS = new Set([ + EndpointType.CHAT, + EndpointType.RESPONSES, + EndpointType.MCP, + EndpointType.ANTHROPIC_MESSAGES, +]); const CUSTOM_MODEL_DEBOUNCE_WAIT_MS = 500; @@ -870,8 +875,11 @@ const ChatUI: React.FC = ({ selectedVectorStores.length > 0 ? selectedVectorStores : undefined, selectedGuardrails.length > 0 ? selectedGuardrails : undefined, selectedPolicies.length > 0 ? selectedPolicies : undefined, - selectedMCPServers, // Pass the selected tools array + selectedMCPServers, customProxyBaseUrl || undefined, + mcpServers, + mcpServerToolRestrictions, + mcpToolsets, ); } else if (endpointType === EndpointType.EMBEDDINGS) { await makeOpenAIEmbeddingsRequest( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.tsx index ed2b4280b79..4319315396a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.tsx @@ -1,6 +1,8 @@ import Anthropic from "@anthropic-ai/sdk"; import { MessageType } from "@/components/chat_ui/types"; import { TokenUsage } from "@/components/chat_ui/ResponseMetrics"; +import { buildMcpToolBlocks } from "@/components/llm_calls/mcp_tool_blocks"; +import { MCPServer, MCPToolset } from "@/components/mcp_tools/types"; import { getProxyBaseUrl } from "@/components/networking"; import NotificationManager from "@/components/molecules/notifications_manager"; @@ -18,8 +20,11 @@ export async function makeAnthropicMessagesRequest( vector_store_ids?: string[], guardrails?: string[], policies?: string[], - selectedMCPTools?: string[], + selectedMCPServers?: string[], customBaseUrl?: string, + mcpServers?: MCPServer[], + mcpServerToolRestrictions?: Record, + mcpToolsets?: MCPToolset[], ) { if (!accessToken) { throw new Error("Virtual Key is required"); @@ -58,6 +63,13 @@ export async function makeAnthropicMessagesRequest( litellm_trace_id: traceId, }; + const tools = buildMcpToolBlocks({ + selectedMCPServers, + mcpServers, + mcpToolsets, + mcpServerToolRestrictions, + }); + if (tools.length > 0) requestBody.tools = tools; if (vector_store_ids) requestBody.vector_store_ids = vector_store_ids; if (guardrails) requestBody.guardrails = guardrails; if (policies) requestBody.policies = policies; diff --git a/ui/litellm-dashboard/src/components/llm_calls/mcp_tool_blocks.ts b/ui/litellm-dashboard/src/components/llm_calls/mcp_tool_blocks.ts new file mode 100644 index 00000000000..42fa94d8208 --- /dev/null +++ b/ui/litellm-dashboard/src/components/llm_calls/mcp_tool_blocks.ts @@ -0,0 +1,79 @@ +import { MCPServer, MCPToolset } from "@/components/mcp_tools/types"; + +export const ALL_MCP_SERVERS_SENTINEL = "__all__"; +const TOOLSET_PREFIX = "toolset:"; + +export interface McpToolBlock { + type: "mcp"; + server_label: string; + server_url: string; + require_approval: "never"; + allowed_tools?: string[]; +} + +export interface BuildMcpToolBlocksArgs { + selectedMCPServers?: string[]; + mcpServers?: MCPServer[]; + mcpToolsets?: MCPToolset[]; + mcpServerToolRestrictions?: Record; +} + +/** + * Build the litellm_proxy MCP reference blocks for a playground request. + * + * Every endpoint that supports MCP sends the same reference shape; the gateway + * expands it server side and each endpoint's own transformation decides the + * final tool shape. Keeping one builder here stops the endpoints from drifting + * apart on routing name, label uniqueness, or escaping. + * + * server_name is used for both routing and labelling because it is the unique + * registered identifier; aliases can collide across servers, and a duplicated + * server_label causes silent tool-routing failures. + */ +export function buildMcpToolBlocks({ + selectedMCPServers, + mcpServers, + mcpToolsets, + mcpServerToolRestrictions, +}: BuildMcpToolBlocksArgs): McpToolBlock[] { + if (!selectedMCPServers || selectedMCPServers.length === 0) { + return []; + } + + if (selectedMCPServers.includes(ALL_MCP_SERVERS_SENTINEL)) { + return [ + { + type: "mcp", + server_label: "litellm", + server_url: "litellm_proxy/mcp", + require_approval: "never", + }, + ]; + } + + return selectedMCPServers.map((serverId) => { + if (serverId.startsWith(TOOLSET_PREFIX)) { + const toolsetId = serverId.slice(TOOLSET_PREFIX.length); + const toolset = mcpToolsets?.find((t) => t.toolset_id === toolsetId); + const toolsetName = toolset?.toolset_name || toolsetId; + return { + type: "mcp", + server_label: toolsetName, + server_url: `litellm_proxy/mcp/${encodeURIComponent(toolsetName)}`, + require_approval: "never", + }; + } + + const server = mcpServers?.find((s) => s.server_id === serverId); + const routeName = server?.server_name || serverId; + const allowedTools = mcpServerToolRestrictions?.[serverId] || []; + + return { + type: "mcp", + server_label: routeName, + server_url: `litellm_proxy/mcp/${encodeURIComponent(routeName)}`, + require_approval: "never", + ...(allowedTools.length > 0 ? { allowed_tools: allowedTools } : {}), + }; + }); +}