From fed5479113ad4ea4a98d756d48a5ad9ce45ef750 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Tue, 7 Jul 2026 10:20:33 -0700 Subject: [PATCH] feat(mcp): flag disabled overlong tools in UI and reject their calls Per review of the exclusion contract: the admin UI tool listing now keeps tools whose prefixed name exceeds the provider limit visible, flagged disabled with the reason (grayed row and tooltip in the dashboard), while LLM-facing listings keep excluding them. Direct calls to a disabled tool are rejected at the shared call_tool seam with a clean 400 tool_name_too_long error on every surface (REST, MCP JSON-RPC, Responses API) instead of being forwarded upstream. The add-time preview warnings now render in the create-server form --- .../mcp_server/mcp_server_manager.py | 30 +++++++-- .../mcp_server/rest_endpoints.py | 16 ++++- .../proxy/_experimental/mcp_server/server.py | 7 ++ .../proxy/_experimental/mcp_server/utils.py | 22 +++++- .../mcp_server/test_mcp_server_manager.py | 67 +++++++++++++++++++ .../mcp_server/test_rest_endpoints.py | 32 +++++++++ .../mcp_tools/create_mcp_server.tsx | 2 + .../mcp_tools/mcp_connection_status.tsx | 17 +++++ .../src/components/mcp_tools/mcp_tools.tsx | 29 ++++++-- .../src/components/mcp_tools/types.tsx | 4 ++ .../src/hooks/useTestMCPConnection.tsx | 7 ++ 11 files changed, 218 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 53905c7b7a7..c10e0d7256c 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -100,6 +100,7 @@ from litellm.proxy._experimental.mcp_server.utils import ( split_server_prefix_from_name, split_tools_by_name_length, strip_known_server_prefix, + tool_name_length_disabled_reason, validate_mcp_server_name, ) from litellm.proxy._types import ( @@ -2355,6 +2356,7 @@ class MCPServerManager: raw_headers: Optional[dict[str, str]] = None, user_api_key_auth: Optional[UserAPIKeyAuth] = None, oauth2_headers: Optional[dict[str, str]] = None, + drop_overlong_names: bool = True, ) -> list[MCPTool]: """ Helper method to get tools from a single MCP server with prefixed names. @@ -2362,6 +2364,10 @@ class MCPServerManager: Args: server (MCPServer): The server to query tools from mcp_auth_header: Optional auth header for MCP server + drop_overlong_names: When True (every LLM-facing path), tools whose + final listed name exceeds ``MCP_MAX_TOOL_NAME_LENGTH`` are + excluded. The admin UI listing passes False so those tools stay + visible and can be rendered as disabled. Returns: List[MCPTool]: List of tools available on the server with prefixed names @@ -2467,13 +2473,15 @@ class MCPServerManager: ) for t in tools ] - return self._drop_tools_exceeding_name_length(tools, server) + return self._drop_tools_exceeding_name_length(tools, server) if drop_overlong_names else tools else: tools = await self._fetch_tools_with_timeout(client, server.name) self._remember_upstream_initialize_instructions(server, client) prefixed_or_original_tools = self._create_prefixed_tools(tools, server, add_prefix=add_prefix) + if not drop_overlong_names: + return prefixed_or_original_tools return self._drop_tools_exceeding_name_length(prefixed_or_original_tools, server) except MCPUpstreamAuthError: @@ -3192,8 +3200,9 @@ class MCPServerManager: Providers such as AWS Bedrock, OpenAI, and Gemini reject tool names longer than 64 characters, so listing them would make every downstream LLM request - carrying the full tool list fail. The tools stay callable by name; they are - only excluded from listings. + carrying the full tool list fail. The admin UI listing keeps these tools + visible (rendered as disabled) via ``drop_overlong_names=False``, and + ``call_tool`` rejects direct calls to them with the same reason. """ kept, dropped = split_tools_by_name_length(tools, MCP_MAX_TOOL_NAME_LENGTH) if dropped: @@ -3205,7 +3214,8 @@ class MCPServerManager: ) verbose_logger.warning( "MCP server %s has %d tool(s) whose name exceeds %d characters, which providers such as " - "AWS Bedrock, OpenAI, and Gemini reject. Excluding them from tool listings: %s. " + "AWS Bedrock, OpenAI, and Gemini reject. Disabling them: excluded from tool listings " + "sent to LLMs and direct calls are rejected: %s. " "Use a shorter server alias, rename the tools on the MCP server, or set " "LITELLM_MCP_MAX_TOOL_NAME_LENGTH to change the limit.", server_label, @@ -4030,6 +4040,18 @@ class MCPServerManager: start_time = datetime.datetime.now() mcp_server = self._resolve_mcp_server_for_tool_call(server_name, name) + # Callers may pass the name prefixed or unprefixed; normalize to the + # canonical listed form before measuring against the provider limit. + canonical_name = add_server_prefix_to_name( + strip_known_server_prefix(name, mcp_server), get_server_prefix(mcp_server) + ) + disabled_reason = tool_name_length_disabled_reason(canonical_name, MCP_MAX_TOOL_NAME_LENGTH) + if disabled_reason is not None: + raise HTTPException( + status_code=400, + detail={"error": "tool_name_too_long", "message": disabled_reason}, + ) + # Resolved before any hook runs so a missing BYOK credential (401) never # leaves during-hook side effects (audit logging, rate-limit bookkeeping) # recorded against a call that ultimately fails. diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 955a418c208..1481a027f4b 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -25,9 +25,12 @@ from litellm.proxy._experimental.mcp_server.ui_session_utils import ( from litellm.constants import MCP_MAX_TOOL_NAME_LENGTH from litellm.proxy._experimental.mcp_server.utils import ( MCPMissingUserEnvVarsError, + add_server_prefix_to_name, get_server_prefix, is_short_mcp_tool_prefix_enabled, merge_mcp_headers, + strip_known_server_prefix, + tool_name_length_disabled_reason, tool_name_length_warnings, ) from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth @@ -262,14 +265,24 @@ if MCP_AVAILABLE: "server_id": server.server_id, "alias": server.alias, } + server_prefix = get_server_prefix(server) + disabled_reasons = [ + tool_name_length_disabled_reason( + add_server_prefix_to_name(strip_known_server_prefix(tool.name, server), server_prefix), + MCP_MAX_TOOL_NAME_LENGTH, + ) + for tool in tools + ] return [ ListMCPToolsRestAPIResponseObject( name=tool.name, description=tool.description, inputSchema=tool.inputSchema, mcp_info=enriched_mcp_info, + disabled=reason is not None, + disabled_reason=reason, ) - for tool in tools + for tool, reason in zip(tools, disabled_reasons) ] def _extract_mcp_headers_from_request( @@ -412,6 +425,7 @@ if MCP_AVAILABLE: add_prefix=False, raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + drop_overlong_names=False, ) if not apply_tool_filters: diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index e3812522ded..e73c087fd30 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -359,9 +359,16 @@ if MCP_AVAILABLE: class ListMCPToolsRestAPIResponseObject(MCPTool): """ Object returned by the /tools/list REST API route. + + ``disabled`` is True when the tool's prefixed name exceeds the + ``MCP_MAX_TOOL_NAME_LENGTH`` provider limit: the tool is excluded from + LLM-facing listings and direct calls are rejected, with the + explanation in ``disabled_reason``. """ mcp_info: Optional[MCPInfo] = None + disabled: bool = False + disabled_reason: Optional[str] = None model_config = ConfigDict(arbitrary_types_allowed=True) def _normalize_resource_contents(contents: list) -> List[ReadResourceContents]: diff --git a/litellm/proxy/_experimental/mcp_server/utils.py b/litellm/proxy/_experimental/mcp_server/utils.py index d1a554c0c93..e26c614db05 100644 --- a/litellm/proxy/_experimental/mcp_server/utils.py +++ b/litellm/proxy/_experimental/mcp_server/utils.py @@ -283,11 +283,27 @@ def split_tools_by_name_length(tools: Sequence["MCPTool"], max_length: int) -> T return kept, dropped +def tool_name_length_disabled_reason(prefixed_name: str, max_length: int) -> Optional[str]: + """The reason a tool is disabled by the name-length limit, or ``None`` when it fits. + + A ``max_length`` of zero or less disables the check entirely. + """ + if max_length <= 0 or len(prefixed_name) <= max_length: + return None + return ( + f"Tool name '{prefixed_name}' is {len(prefixed_name)} characters, which exceeds the " + f"{max_length} character tool name limit enforced by providers such as AWS Bedrock, OpenAI, and " + f"Gemini. LiteLLM disables it: it is excluded from tool listings sent to LLMs and direct calls " + f"are rejected. Use a shorter server alias, rename the tool on the MCP server, or set " + f"LITELLM_MCP_MAX_TOOL_NAME_LENGTH to change the limit." + ) + + def tool_name_length_warnings(tool_names: Iterable[str], server_prefix: str, max_length: int) -> List[str]: """Warnings for tools whose prefixed name would exceed ``max_length``. Used at server add/preview time, before the server is persisted, to flag - tool names that the runtime listing will exclude once the server prefix + tool names that will be disabled once the server prefix (``-``) is applied. """ if max_length <= 0: @@ -297,8 +313,8 @@ def tool_name_length_warnings(tool_names: Iterable[str], server_prefix: str, max ( f"Tool '{name}' will be listed as '{prefixed}' ({len(prefixed)} characters), which exceeds the " f"{max_length} character tool name limit enforced by providers such as AWS Bedrock, OpenAI, and " - f"Gemini. LiteLLM will exclude it from tool listings. Use a shorter server alias or rename the " - f"tool on the MCP server." + f"Gemini. LiteLLM will disable it: it will be excluded from tool listings sent to LLMs and " + f"direct calls will be rejected. Use a shorter server alias or rename the tool on the MCP server." ) for name, prefixed in prefixed_by_name if len(prefixed) > max_length diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 2c9393f862a..f820f9cce78 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -6372,3 +6372,70 @@ class TestToolNameLengthExclusion: tools = await self._list_tools(self._server(alias), [raw_at_limit, raw_too_long], add_prefix=False) assert [t.name for t in tools] == [raw_at_limit] + + @pytest.mark.asyncio + async def test_ui_listing_keeps_overlong_tools_when_drop_disabled(self): + from litellm.constants import MCP_MAX_TOOL_NAME_LENGTH + + alias = "network_config_audit" + too_long = "t" * (MCP_MAX_TOOL_NAME_LENGTH - len(alias)) + + manager = MCPServerManager() + manager._create_mcp_client = AsyncMock(return_value=object()) + manager._fetch_tools_with_timeout = AsyncMock(return_value=[MCPTool(name=too_long, inputSchema={})]) + tools = await manager._get_tools_from_server( + server=self._server(alias), add_prefix=False, drop_overlong_names=False + ) + + assert [t.name for t in tools] == [too_long] + + @pytest.mark.asyncio + async def test_call_tool_rejects_overlong_tool_with_clean_error(self): + from litellm.constants import MCP_MAX_TOOL_NAME_LENGTH + + alias = "network_config_audit" + too_long = "t" * (MCP_MAX_TOOL_NAME_LENGTH - len(alias)) + prefixed = f"{alias}-{too_long}" + assert len(prefixed) == MCP_MAX_TOOL_NAME_LENGTH + 1 + + manager = MCPServerManager() + server = self._server(alias) + manager.registry = {server.server_id: server} + manager.tool_name_to_mcp_server_name_mapping[too_long] = alias + manager.tool_name_to_mcp_server_name_mapping[prefixed] = alias + manager._create_mcp_client = AsyncMock( + side_effect=AssertionError("must reject before connecting upstream") + ) + + for call_name in (too_long, prefixed): + with pytest.raises(HTTPException) as exc_info: + await manager.call_tool(server_name=alias, name=call_name, arguments={}) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["error"] == "tool_name_too_long" + assert "exceeds the 64 character tool name limit" in exc_info.value.detail["message"] + + @pytest.mark.asyncio + async def test_call_tool_allows_tool_at_the_limit(self): + from litellm.constants import MCP_MAX_TOOL_NAME_LENGTH + + alias = "network_config_audit" + fitting = "t" * (MCP_MAX_TOOL_NAME_LENGTH - len(alias) - 1) + prefixed = f"{alias}-{fitting}" + assert len(prefixed) == MCP_MAX_TOOL_NAME_LENGTH + + manager = MCPServerManager() + server = self._server(alias) + manager.registry = {server.server_id: server} + manager.tool_name_to_mcp_server_name_mapping[fitting] = alias + manager.tool_name_to_mcp_server_name_mapping[prefixed] = alias + + mock_client = AsyncMock() + call_result = MagicMock(spec=CallToolResult) + call_result.isError = False + call_result.content = [] + mock_client.call_tool = AsyncMock(return_value=call_result) + manager._create_mcp_client = AsyncMock(return_value=mock_client) + + result = await manager.call_tool(server_name=alias, name=fitting, arguments={}) + + assert result.isError is False diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index a9cddc676a1..4d8248e6782 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -2496,3 +2496,35 @@ class TestToolResponseMcpInfoEnrichment: "server_id": "server-uuid", "alias": None, } + + +class TestToolResponseDisabledAnnotation: + """LIT-4216: the UI listing keeps over-limit tools visible but flags them disabled.""" + + def test_marks_overlong_tool_disabled_with_reason(self): + from mcp.types import Tool as MCPTool + + from litellm.constants import MCP_MAX_TOOL_NAME_LENGTH + from litellm.types.mcp_server.mcp_server_manager import MCPServer + from litellm.types.mcp import MCPTransport + + alias = "network_config_audit" + fitting = "t" * (MCP_MAX_TOOL_NAME_LENGTH - len(alias) - 1) + too_long = "t" * (MCP_MAX_TOOL_NAME_LENGTH - len(alias)) + server = MCPServer( + server_id="len-limit", + name="len-limit-server", + alias=alias, + url="https://up.example.com", + transport=MCPTransport.http, + ) + + objects = rest_endpoints._create_tool_response_objects( + [MCPTool(name=fitting, inputSchema={}), MCPTool(name=too_long, inputSchema={})], + server, + ) + + assert [o.disabled for o in objects] == [False, True] + assert objects[0].disabled_reason is None + assert f"{alias}-{too_long}" in objects[1].disabled_reason + assert "direct calls" in objects[1].disabled_reason diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 9cac103d1a7..f0a33e5536b 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -95,6 +95,7 @@ const CreateMCPServer: React.FC = ({ // Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests. const { tools, + toolsWarnings, isLoadingTools, toolsError, toolsErrorStatus, @@ -1169,6 +1170,7 @@ const CreateMCPServer: React.FC = ({ ; tools: any[]; + toolsWarnings?: string[]; isLoadingTools: boolean; toolsError: string | null; toolsErrorStatus?: number | null; @@ -17,6 +18,7 @@ interface MCPConnectionStatusProps { const MCPConnectionStatus: React.FC = ({ formValues, tools, + toolsWarnings = [], isLoadingTools, toolsError, toolsErrorStatus = null, @@ -95,6 +97,21 @@ const MCPConnectionStatus: React.FC = ({ )} + {toolsWarnings.length > 0 && !toolsError && ( + + {toolsWarnings.map((warning) => ( +
  • {warning}
  • + ))} + + } + type="warning" + showIcon + /> + )} + {toolsError && isPreviewForbidden && ( )} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx index 4cd8acfc07e..57d471158d0 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx @@ -13,7 +13,7 @@ import { setSecureItem } from "@/utils/secureStorage"; import { Card, Title, Text } from "@tremor/react"; import { RobotOutlined, ToolOutlined, SearchOutlined, KeyOutlined, LockOutlined } from "@ant-design/icons"; -import { Input, Button as AntdButton } from "antd"; +import { Input, Button as AntdButton, Tooltip } from "antd"; const MCPToolsViewer = ({ serverId, @@ -479,9 +479,11 @@ const MCPToolsViewer = ({
    { setSelectedTool(tool); @@ -498,9 +500,22 @@ const MCPToolsViewer = ({ /> )}
    -

    - {tool.name} -

    +
    +

    + {tool.name} +

    + {tool.disabled && ( + + + Disabled + + + )} +

    {tool.mcp_info.server_name}

    {tool.description} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 583191791a9..b0406684085 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -148,6 +148,10 @@ export interface MCPTool { description?: string; inputSchema: InputSchema | string; // API returns string "tool_input_schema" or the actual schema mcp_info: MCPInfo; + // True when the tool's prefixed name exceeds the provider tool-name length + // limit; the tool is excluded from LLM-facing listings and calls are rejected + disabled?: boolean; + disabled_reason?: string | null; // Function to select a tool (added in the component) onToolSelect?: (tool: MCPTool) => void; } diff --git a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx index 055e15350ca..38e9679a850 100644 --- a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx +++ b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx @@ -31,6 +31,7 @@ interface UseTestMCPConnectionProps { interface UseTestMCPConnectionReturn { tools: any[]; + toolsWarnings: string[]; isLoadingTools: boolean; toolsError: string | null; toolsErrorStatus: number | null; @@ -48,6 +49,7 @@ export const useTestMCPConnection = ({ enabled = true, }: UseTestMCPConnectionProps): UseTestMCPConnectionReturn => { const [tools, setTools] = useState([]); + const [toolsWarnings, setToolsWarnings] = useState([]); const [isLoadingTools, setIsLoadingTools] = useState(false); const [toolsError, setToolsError] = useState(null); const [toolsErrorStatus, setToolsErrorStatus] = useState(null); @@ -157,6 +159,7 @@ export const useTestMCPConnection = ({ if (toolsResponse.tools && !toolsResponse.error) { setTools(toolsResponse.tools); + setToolsWarnings(Array.isArray(toolsResponse.warnings) ? toolsResponse.warnings : []); setToolsError(null); setToolsErrorStatus(null); setToolsErrorStackTrace(null); @@ -169,6 +172,7 @@ export const useTestMCPConnection = ({ setToolsErrorStatus(typeof toolsResponse.status === "number" ? toolsResponse.status : null); setToolsErrorStackTrace(toolsResponse.status === 403 ? null : toolsResponse.stack_trace || null); setTools([]); + setToolsWarnings([]); setHasShownSuccessMessage(false); } } catch (error) { @@ -177,6 +181,7 @@ export const useTestMCPConnection = ({ setToolsErrorStatus(null); setToolsErrorStackTrace(null); setTools([]); + setToolsWarnings([]); setHasShownSuccessMessage(false); } finally { setIsLoadingTools(false); @@ -185,6 +190,7 @@ export const useTestMCPConnection = ({ const clearTools = useCallback(() => { setTools([]); + setToolsWarnings([]); setToolsError(null); setToolsErrorStatus(null); setToolsErrorStackTrace(null); @@ -218,6 +224,7 @@ export const useTestMCPConnection = ({ return { tools, + toolsWarnings, isLoadingTools, toolsError, toolsErrorStatus,