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.
This commit is contained in:
Tin Chi Lo 2026-07-06 23:12:21 -07:00
parent b8248a21d2
commit 036d169276
7 changed files with 219 additions and 2 deletions

View file

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

View file

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

View file

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

View file

@ -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
(``<alias>-<tool>``) 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.

View file

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

View file

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

View file

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