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
This commit is contained in:
Tin Chi Lo 2026-07-07 10:20:33 -07:00
parent c093858a67
commit fed5479113
11 changed files with 218 additions and 15 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -95,6 +95,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
// 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<CreateMCPServerProps> = ({
<MCPConnectionStatus
formValues={formValues}
tools={tools}
toolsWarnings={toolsWarnings}
isLoadingTools={isLoadingTools}
toolsError={toolsError}
toolsErrorStatus={toolsErrorStatus}

View file

@ -6,6 +6,7 @@ import { Card, Title, Text } from "@tremor/react";
interface MCPConnectionStatusProps {
formValues: Record<string, any>;
tools: any[];
toolsWarnings?: string[];
isLoadingTools: boolean;
toolsError: string | null;
toolsErrorStatus?: number | null;
@ -17,6 +18,7 @@ interface MCPConnectionStatusProps {
const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
formValues,
tools,
toolsWarnings = [],
isLoadingTools,
toolsError,
toolsErrorStatus = null,
@ -95,6 +97,21 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
</div>
)}
{toolsWarnings.length > 0 && !toolsError && (
<Alert
message="Some tool names exceed the provider limit"
description={
<ul style={{ margin: 0, paddingLeft: "16px" }}>
{toolsWarnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
}
type="warning"
showIcon
/>
)}
{toolsError && isPreviewForbidden && (
<Alert message="Tool preview unavailable" description={toolsError} type="info" showIcon />
)}

View file

@ -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 = ({
<div
key={tool.name}
className={`border rounded-lg p-3 cursor-pointer transition-all hover:shadow-xs ${
selectedTool?.name === tool.name
? "border-blue-500 bg-blue-50 ring-1 ring-blue-200"
: "border-gray-200 bg-white hover:border-gray-300"
tool.disabled
? "border-gray-200 bg-gray-50 opacity-60"
: selectedTool?.name === tool.name
? "border-blue-500 bg-blue-50 ring-1 ring-blue-200"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
onClick={() => {
setSelectedTool(tool);
@ -498,9 +500,22 @@ const MCPToolsViewer = ({
/>
)}
<div className="flex-1 min-w-0">
<h4 className="font-mono text-xs font-medium text-gray-900 truncate">
{tool.name}
</h4>
<div className="flex items-center gap-2 min-w-0">
<h4
className={`font-mono text-xs font-medium truncate ${
tool.disabled ? "text-gray-500" : "text-gray-900"
}`}
>
{tool.name}
</h4>
{tool.disabled && (
<Tooltip title={tool.disabled_reason}>
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 shrink-0">
Disabled
</span>
</Tooltip>
)}
</div>
<p className="text-xs text-gray-500 truncate">{tool.mcp_info.server_name}</p>
<p className="text-xs text-gray-600 mt-1 line-clamp-2 leading-relaxed">
{tool.description}

View file

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

View file

@ -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<any[]>([]);
const [toolsWarnings, setToolsWarnings] = useState<string[]>([]);
const [isLoadingTools, setIsLoadingTools] = useState(false);
const [toolsError, setToolsError] = useState<string | null>(null);
const [toolsErrorStatus, setToolsErrorStatus] = useState<number | null>(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,