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) == []