From 036d1692766c8af04073363e215a8f8fbf1fc203 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Mon, 6 Jul 2026 23:12:21 -0700 Subject: [PATCH] fix(mcp): exclude tools whose prefixed name exceeds the 64 char provider limit Providers such as AWS Bedrock, OpenAI, and Gemini reject tool names longer than 64 characters, and the MCP gateway prefixes every upstream tool name with the server alias, so long prefixed names flowed into LLM requests and failed with a provider 400. Tool listings now exclude names over the limit with an actionable warning, and the add-time tools preview flags them. Excluded tools stay callable by name; LITELLM_MCP_MAX_TOOL_NAME_LENGTH overrides or disables the limit. --- litellm/constants.py | 5 ++ .../mcp_server/mcp_server_manager.py | 29 ++++++++++- .../mcp_server/rest_endpoints.py | 9 ++++ .../proxy/_experimental/mcp_server/utils.py | 39 ++++++++++++++ .../mcp_server/test_mcp_server_manager.py | 48 +++++++++++++++++ .../mcp_server/test_rest_endpoints.py | 51 +++++++++++++++++++ .../mcp_server/test_tool_name_length_limit.py | 40 +++++++++++++++ 7 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 tests/test_litellm/proxy/_experimental/mcp_server/test_tool_name_length_limit.py diff --git a/litellm/constants.py b/litellm/constants.py index 7423d9b2211..b3cc28527ec 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -115,6 +115,11 @@ MCP_PER_USER_TOKEN_DEFAULT_TTL = int( ) MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS = int(os.getenv("MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS", "60")) +# Providers such as AWS Bedrock, OpenAI, and Gemini reject tool names longer than +# 64 characters, so MCP tools whose final (prefixed) name exceeds this are excluded +# from tool listings. Set to 0 or a negative value to disable the exclusion. +MCP_MAX_TOOL_NAME_LENGTH = int(os.getenv("LITELLM_MCP_MAX_TOOL_NAME_LENGTH", "64")) + # MCP timeout defaults (seconds). Override via env vars for slow/custom MCP servers. MCP_CLIENT_TIMEOUT = float(os.getenv("LITELLM_MCP_CLIENT_TIMEOUT", "60.0")) MCP_TOOL_LISTING_TIMEOUT = float(os.getenv("LITELLM_MCP_TOOL_LISTING_TIMEOUT", "30.0")) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index d347c694366..0f92485f0b0 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -38,6 +38,7 @@ from litellm._logging import verbose_logger from litellm.constants import ( MCP_CLIENT_TIMEOUT, MCP_HEALTH_CHECK_TIMEOUT, + MCP_MAX_TOOL_NAME_LENGTH, MCP_METADATA_TIMEOUT, MCP_NPM_CACHE_DIR, MCP_STDIO_ALLOWED_COMMANDS, @@ -97,6 +98,7 @@ from litellm.proxy._experimental.mcp_server.utils import ( normalize_server_name, parse_admin_env_vars, split_server_prefix_from_name, + split_tools_by_name_length, strip_known_server_prefix, validate_mcp_server_name, ) @@ -2465,14 +2467,14 @@ class MCPServerManager: ) for t in tools ] - return tools + return self._drop_tools_exceeding_name_length(tools, server) 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) - return prefixed_or_original_tools + return self._drop_tools_exceeding_name_length(prefixed_or_original_tools, server) except MCPUpstreamAuthError: # Pass-through 401 must surface to single-server routes so the @@ -3185,6 +3187,29 @@ class MCPServerManager: "attempts; the 3-character prefix space is too crowded." ) + def _drop_tools_exceeding_name_length(self, tools: list[MCPTool], server: MCPServer) -> list[MCPTool]: + """Exclude tools whose final listed name exceeds ``MCP_MAX_TOOL_NAME_LENGTH``. + + 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. + """ + kept, dropped = split_tools_by_name_length(tools, MCP_MAX_TOOL_NAME_LENGTH) + if dropped: + dropped_names = ", ".join(f"{tool.name} ({len(tool.name)} chars)" for tool in dropped) + 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. " + "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.name, + len(dropped), + MCP_MAX_TOOL_NAME_LENGTH, + dropped_names, + ) + return kept + def _create_prefixed_tools(self, tools: list[MCPTool], server: MCPServer, add_prefix: bool = True) -> list[MCPTool]: """ Create prefixed tools and update tool mapping. diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index d482e537c5d..581a2f5dc72 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -22,9 +22,12 @@ from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthErr from litellm.proxy._experimental.mcp_server.ui_session_utils import ( build_effective_auth_contexts, ) +from litellm.constants import MCP_MAX_TOOL_NAME_LENGTH from litellm.proxy._experimental.mcp_server.utils import ( MCPMissingUserEnvVarsError, + get_server_prefix, merge_mcp_headers, + tool_name_length_warnings, ) from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.ip_address_utils import IPAddressUtils @@ -1308,10 +1311,16 @@ if MCP_AVAILABLE: list_tools_response = await client.run_with_session(_list_tools_session_operation) list_tools_result: List[MCPTool] = list_tools_response.tools model_dumped_tools: List[dict] = [tool.model_dump() for tool in list_tools_result] + warnings = tool_name_length_warnings( + [tool.name for tool in list_tools_result], + get_server_prefix(new_mcp_server_request), + MCP_MAX_TOOL_NAME_LENGTH, + ) return { "tools": model_dumped_tools, "error": None, "message": "Successfully retrieved tools", + "warnings": warnings, } return await _execute_with_mcp_client( diff --git a/litellm/proxy/_experimental/mcp_server/utils.py b/litellm/proxy/_experimental/mcp_server/utils.py index c9c60030dbc..d1a554c0c93 100644 --- a/litellm/proxy/_experimental/mcp_server/utils.py +++ b/litellm/proxy/_experimental/mcp_server/utils.py @@ -5,6 +5,7 @@ MCP Server Utilities import json import re from typing import ( + TYPE_CHECKING, Any, Dict, Iterable, @@ -12,11 +13,15 @@ from typing import ( List, Mapping, Optional, + Sequence, Set, Tuple, Union, ) +if TYPE_CHECKING: + from mcp.types import Tool as MCPTool + import hashlib import importlib import os @@ -266,6 +271,40 @@ def add_server_prefix_to_name(name: str, server_name: str) -> str: ) +def split_tools_by_name_length(tools: Sequence["MCPTool"], max_length: int) -> Tuple[List["MCPTool"], List["MCPTool"]]: + """Split ``tools`` into (kept, dropped) by whether ``tool.name`` fits ``max_length``. + + A ``max_length`` of zero or less disables the check and keeps every tool. + """ + if max_length <= 0: + return list(tools), [] + kept = [tool for tool in tools if len(tool.name) <= max_length] + dropped = [tool for tool in tools if len(tool.name) > max_length] + return kept, dropped + + +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 + (``-``) is applied. + """ + if max_length <= 0: + return [] + prefixed_by_name = [(name, add_server_prefix_to_name(name, server_prefix)) for name in tool_names] + return [ + ( + 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." + ) + for name, prefixed in prefixed_by_name + if len(prefixed) > max_length + ] + + def get_server_prefix(server: Any) -> str: """Return the prefix for a server. 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 e0e890558af..2c9393f862a 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 @@ -6324,3 +6324,51 @@ def test_build_mcp_server_table_carries_null_oauth2_flow(): table = manager._build_mcp_server_table(server) assert table.oauth2_flow is None + + +class TestToolNameLengthExclusion: + """LIT-4216: tools whose final listed name exceeds MCP_MAX_TOOL_NAME_LENGTH must not be listed, + because providers such as AWS Bedrock, OpenAI, and Gemini reject tool names longer than 64 chars.""" + + def _server(self, alias: str) -> MCPServer: + return MCPServer( + server_id="len-limit", + name="len-limit-server", + alias=alias, + url="https://up.example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + ) + + async def _list_tools(self, server: MCPServer, tool_names: list, add_prefix: bool = True): + manager = MCPServerManager() + upstream_tools = [MCPTool(name=name, inputSchema={}) for name in tool_names] + manager._create_mcp_client = AsyncMock(return_value=object()) + manager._fetch_tools_with_timeout = AsyncMock(return_value=upstream_tools) + return await manager._get_tools_from_server(server=server, add_prefix=add_prefix) + + @pytest.mark.asyncio + async def test_drops_tool_whose_prefixed_name_exceeds_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) + too_long = "t" * (MCP_MAX_TOOL_NAME_LENGTH - len(alias)) + assert len(f"{alias}-{fitting}") == MCP_MAX_TOOL_NAME_LENGTH + assert len(f"{alias}-{too_long}") == MCP_MAX_TOOL_NAME_LENGTH + 1 + + tools = await self._list_tools(self._server(alias), [fitting, too_long]) + + assert [t.name for t in tools] == [f"{alias}-{fitting}"] + + @pytest.mark.asyncio + async def test_unprefixed_listing_measures_the_raw_name(self): + from litellm.constants import MCP_MAX_TOOL_NAME_LENGTH + + alias = "network_config_audit" + raw_at_limit = "t" * MCP_MAX_TOOL_NAME_LENGTH + raw_too_long = "t" * (MCP_MAX_TOOL_NAME_LENGTH + 1) + + 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] 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 e114f46e866..1e609dbc882 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 @@ -593,6 +593,57 @@ class TestTestToolsList: assert captured["oauth2_headers"] == oauth_headers assert oauth_call_counter["count"] == 1 + async def test_flags_tool_names_exceeding_provider_limit(self, monkeypatch): + """LIT-4216: the add-time preview must warn when a tool's prefixed name will exceed + the 64-char tool name limit providers such as Bedrock/OpenAI/Gemini enforce.""" + from types import SimpleNamespace + + from mcp.types import Tool as MCPTool + + from litellm.constants import MCP_MAX_TOOL_NAME_LENGTH + from litellm.proxy._types import LitellmUserRoles + + alias = "network_config_audit" + fitting = "t" * (MCP_MAX_TOOL_NAME_LENGTH - len(alias) - 1) + too_long = "t" * (MCP_MAX_TOOL_NAME_LENGTH - len(alias)) + + class FakeClient: + async def run_with_session(self, operation): + return SimpleNamespace( + tools=[ + MCPTool(name=fitting, inputSchema={}), + MCPTool(name=too_long, inputSchema={}), + ] + ) + + async def fake_execute( + request, + operation, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers=None, + ): + return await operation(FakeClient()) + + monkeypatch.setattr( + rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False + ) + + result = await rest_endpoints.test_tools_list( + _build_request(), + NewMCPServerRequest( + server_name="example", + alias=alias, + url="https://example.com", + auth_type=MCPAuth.none, + ), + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert len(result["tools"]) == 2 + assert len(result["warnings"]) == 1 + assert f"{alias}-{too_long}" in result["warnings"][0] + class TestListToolsRestAPI: pytestmark = pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_tool_name_length_limit.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_tool_name_length_limit.py new file mode 100644 index 00000000000..40918d5917a --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_tool_name_length_limit.py @@ -0,0 +1,40 @@ +"""LIT-4216: helpers gating MCP tool names against the 64-char provider limit.""" + +from mcp.types import Tool as MCPTool + +from litellm.proxy._experimental.mcp_server.utils import ( + split_tools_by_name_length, + tool_name_length_warnings, +) + + +def _tool(name: str) -> MCPTool: + return MCPTool(name=name, inputSchema={}) + + +def test_split_keeps_names_at_the_limit_and_drops_longer(): + kept, dropped = split_tools_by_name_length([_tool("a" * 64), _tool("b" * 65)], 64) + + assert [tool.name for tool in kept] == ["a" * 64] + assert [tool.name for tool in dropped] == ["b" * 65] + + +def test_split_zero_or_negative_limit_disables_the_check(): + tools = [_tool("a" * 300)] + + for limit in (0, -1): + kept, dropped = split_tools_by_name_length(tools, limit) + assert [tool.name for tool in kept] == ["a" * 300] + assert dropped == [] + + +def test_warnings_flag_only_tools_whose_prefixed_name_exceeds_the_limit(): + warnings = tool_name_length_warnings(["short", "t" * 60], "server_alias", 64) + + assert len(warnings) == 1 + assert f"server_alias-{'t' * 60}" in warnings[0] + assert "(73 characters)" in warnings[0] + + +def test_warnings_disabled_when_limit_is_zero(): + assert tool_name_length_warnings(["t" * 300], "server_alias", 0) == []