feat(mcp): add tool_name_to_display_name and tool_name_to_description overrides for MCP servers

This commit is contained in:
Ishaan Jaffer 2026-03-04 17:25:41 -08:00
parent 09e1a06f47
commit 0b4c88d1a4
11 changed files with 279 additions and 30 deletions

View file

@ -60,6 +60,12 @@ def _prepare_mcp_server_data(
if data.env is not None:
data_dict["env"] = safe_dumps(data.env)
# Handle tool name override serialization
if data.tool_name_to_display_name is not None:
data_dict["tool_name_to_display_name"] = safe_dumps(data.tool_name_to_display_name)
if data.tool_name_to_description is not None:
data_dict["tool_name_to_description"] = safe_dumps(data.tool_name_to_description)
# mcp_access_groups is already List[str], no serialization needed
return data_dict

View file

@ -644,6 +644,12 @@ class MCPServerManager:
),
created_at=getattr(mcp_server, "created_at", None),
updated_at=getattr(mcp_server, "updated_at", None),
tool_name_to_display_name=_deserialize_json_dict(
getattr(mcp_server, "tool_name_to_display_name", None)
),
tool_name_to_description=_deserialize_json_dict(
getattr(mcp_server, "tool_name_to_description", None)
),
)
return new_server

View file

@ -737,6 +737,29 @@ if MCP_AVAILABLE:
return tools_to_return
def apply_tool_overrides(
tools: List[MCPTool],
mcp_server: MCPServer,
) -> List[MCPTool]:
"""Apply admin-configured display name/description overrides to tools.
Overrides are keyed by the unprefixed tool name, same convention as
allowed_tools configuration.
"""
display_name_map = mcp_server.tool_name_to_display_name or {}
description_map = mcp_server.tool_name_to_description or {}
if not display_name_map and not description_map:
return tools
for tool in tools:
unprefixed, _ = split_server_prefix_from_name(tool.name)
lookup_key = unprefixed or tool.name
if lookup_key in display_name_map:
tool.name = display_name_map[lookup_key]
if lookup_key in description_map:
tool.description = description_map[lookup_key]
return tools
def _get_client_ip_from_context() -> Optional[str]:
"""
Extract client_ip from auth context.
@ -984,6 +1007,7 @@ if MCP_AVAILABLE:
raw_headers=raw_headers,
)
filtered_tools = filter_tools_by_allowed_tools(tools, server)
filtered_tools = apply_tool_overrides(filtered_tools, server)
filtered_tools = await filter_tools_by_key_team_permissions(
tools=filtered_tools,

View file

@ -1090,6 +1090,8 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
mcp_info: Optional[MCPInfo] = None
mcp_access_groups: List[str] = Field(default_factory=list)
allowed_tools: Optional[List[str]] = None
tool_name_to_display_name: Optional[Dict[str, str]] = None
tool_name_to_description: Optional[Dict[str, str]] = None
extra_headers: Optional[List[str]] = None
static_headers: Optional[Dict[str, str]] = None
# Stdio-specific fields
@ -1144,6 +1146,8 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
mcp_info: Optional[MCPInfo] = None
mcp_access_groups: List[str] = Field(default_factory=list)
allowed_tools: Optional[List[str]] = None
tool_name_to_display_name: Optional[Dict[str, str]] = None
tool_name_to_description: Optional[Dict[str, str]] = None
extra_headers: Optional[List[str]] = None
static_headers: Optional[Dict[str, str]] = None
# Stdio-specific fields
@ -1193,6 +1197,8 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
teams: List[Dict[str, Optional[str]]] = Field(default_factory=list)
mcp_access_groups: List[str] = Field(default_factory=list)
allowed_tools: List[str] = Field(default_factory=list)
tool_name_to_display_name: Optional[Dict[str, str]] = None
tool_name_to_description: Optional[Dict[str, str]] = None
extra_headers: List[str] = Field(default_factory=list)
mcp_info: Optional[MCPInfo] = None
static_headers: Optional[Dict[str, str]] = None

View file

@ -287,6 +287,8 @@ model LiteLLM_MCPServerTable {
mcp_info Json? @default("{}")
mcp_access_groups String[]
allowed_tools String[] @default([])
tool_name_to_display_name Json? @default("{}")
tool_name_to_description Json? @default("{}")
extra_headers String[] @default([])
static_headers Json? @default("{}")
// Health check status

View file

@ -33,6 +33,8 @@ class MCPServer(BaseModel):
] = None # allow admin to specify which headers to forward from client to the MCP server
allowed_tools: Optional[List[str]] = None
disallowed_tools: Optional[List[str]] = None
tool_name_to_display_name: Optional[Dict[str, str]] = None
tool_name_to_description: Optional[Dict[str, str]] = None
allowed_params: Optional[
Dict[str, List[str]]
] = None # map of tool names to allowed parameter lists

View file

@ -28,6 +28,7 @@ def cleanup_mcp_global_state():
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
# Clear before test
global_mcp_server_manager.registry.clear()
global_mcp_server_manager.tool_name_to_mcp_server_name_mapping.clear()
@ -1788,6 +1789,79 @@ def test_filter_tools_by_allowed_tools():
assert filtered_tools[1].name == "my_api_mcp-findpetsbystatus"
def test_apply_tool_overrides():
"""Test that apply_tool_overrides applies custom display names and descriptions."""
from mcp.types import Tool
from litellm.proxy._experimental.mcp_server.server import apply_tool_overrides
from litellm.types.mcp import MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
mcp_server = MCPServer(
server_id="my_api_mcp",
name="my_api_mcp",
transport=MCPTransport.http,
tool_name_to_display_name={"getpetbyid": "Get Pet"},
tool_name_to_description={"getpetbyid": "Custom description for get pet"},
)
tools = [
Tool(
name="my_api_mcp-getpetbyid",
title=None,
description="Original description",
inputSchema={"type": "object", "properties": {}},
outputSchema=None,
annotations=None,
),
Tool(
name="my_api_mcp-findpetsbystatus",
title=None,
description="Finds Pets by status",
inputSchema={"type": "object", "properties": {}},
outputSchema=None,
annotations=None,
),
]
result = apply_tool_overrides(tools, mcp_server)
# First tool should have overridden name and description
assert result[0].name == "Get Pet"
assert result[0].description == "Custom description for get pet"
# Second tool should be unchanged
assert result[1].name == "my_api_mcp-findpetsbystatus"
assert result[1].description == "Finds Pets by status"
def test_apply_tool_overrides_no_overrides():
"""Test that apply_tool_overrides returns tools unchanged when no overrides are set."""
from mcp.types import Tool
from litellm.proxy._experimental.mcp_server.server import apply_tool_overrides
from litellm.types.mcp import MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
mcp_server = MCPServer(
server_id="my_api_mcp",
name="my_api_mcp",
transport=MCPTransport.http,
)
tools = [
Tool(
name="my_api_mcp-getpetbyid",
title=None,
description="Original description",
inputSchema={"type": "object", "properties": {}},
outputSchema=None,
annotations=None,
),
]
result = apply_tool_overrides(tools, mcp_server)
assert result[0].name == "my_api_mcp-getpetbyid"
assert result[0].description == "Original description"
def _make_db_mcp_server(server_id: str, updated_at: datetime) -> LiteLLM_MCPServerTable:
return LiteLLM_MCPServerTable(
server_id=server_id,

View file

@ -54,6 +54,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false);
const [tools, setTools] = useState<any[]>([]);
const [allowedTools, setAllowedTools] = useState<string[]>([]);
const [toolNameToDisplayName, setToolNameToDisplayName] = useState<Record<string, string>>({});
const [toolNameToDescription, setToolNameToDescription] = useState<Record<string, string>>({});
const [transportType, setTransportType] = useState<string>("");
const [searchValue, setSearchValue] = useState<string>("");
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
@ -353,6 +355,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
mcp_access_groups: accessGroups,
alias: restValues.alias,
allowed_tools: allowedTools.length > 0 ? allowedTools : null,
tool_name_to_display_name: Object.keys(toolNameToDisplayName).length > 0 ? toolNameToDisplayName : null,
tool_name_to_description: Object.keys(toolNameToDescription).length > 0 ? toolNameToDescription : null,
allow_all_keys: Boolean(allowAllKeysRaw),
available_on_public_internet: Boolean(availableOnPublicInternetRaw),
static_headers: staticHeaders,
@ -712,6 +716,10 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
allowedTools={allowedTools}
existingAllowedTools={null}
onAllowedToolsChange={setAllowedTools}
toolNameToDisplayName={toolNameToDisplayName}
toolNameToDescription={toolNameToDescription}
onToolNameToDisplayNameChange={setToolNameToDisplayName}
onToolNameToDescriptionChange={setToolNameToDescription}
/>
</div>

View file

@ -38,6 +38,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
const [searchValue, setSearchValue] = useState<string>("");
const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false);
const [allowedTools, setAllowedTools] = useState<string[]>([]);
const [toolNameToDisplayName, setToolNameToDisplayName] = useState<Record<string, string>>({});
const [toolNameToDescription, setToolNameToDescription] = useState<Record<string, string>>({});
const [pendingRestoredValues, setPendingRestoredValues] = useState<Record<string, any> | null>(null);
const authType = Form.useWatch("auth_type", form) as string | undefined;
const transportType = Form.useWatch("transport", form) as string | undefined;
@ -195,11 +197,13 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
}
}, [mcpServer]);
// Initialize allowed tools from existing server data
// Initialize allowed tools and tool overrides from existing server data
useEffect(() => {
if (mcpServer.allowed_tools) {
setAllowedTools(mcpServer.allowed_tools);
}
setToolNameToDisplayName(mcpServer.tool_name_to_display_name ?? {});
setToolNameToDescription(mcpServer.tool_name_to_description ?? {});
}, [mcpServer]);
useEffect(() => {
@ -541,6 +545,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
// Include permission management fields
extra_headers: restValues.extra_headers || [],
allowed_tools: allowedTools.length > 0 ? allowedTools : null,
tool_name_to_display_name: Object.keys(toolNameToDisplayName).length > 0 ? toolNameToDisplayName : null,
tool_name_to_description: Object.keys(toolNameToDescription).length > 0 ? toolNameToDescription : null,
disallowed_tools: restValues.disallowed_tools || [],
static_headers: staticHeaders,
allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys),
@ -906,6 +912,10 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
allowedTools={allowedTools}
existingAllowedTools={mcpServer.allowed_tools || null}
onAllowedToolsChange={setAllowedTools}
toolNameToDisplayName={toolNameToDisplayName}
toolNameToDescription={toolNameToDescription}
onToolNameToDisplayNameChange={setToolNameToDisplayName}
onToolNameToDescriptionChange={setToolNameToDescription}
/>
</div>

View file

@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import { Card, Title, Text } from "@tremor/react";
import { ToolOutlined, CheckCircleOutlined, SearchOutlined } from "@ant-design/icons";
import { ToolOutlined, CheckCircleOutlined, SearchOutlined, EditOutlined } from "@ant-design/icons";
import { Badge, Spin, Checkbox, Input } from "antd";
import { useTestMCPConnection } from "../../hooks/useTestMCPConnection";
@ -11,6 +11,10 @@ interface MCPToolConfigurationProps {
allowedTools: string[];
existingAllowedTools: string[] | null;
onAllowedToolsChange: (tools: string[]) => void;
toolNameToDisplayName: Record<string, string>;
toolNameToDescription: Record<string, string>;
onToolNameToDisplayNameChange: (map: Record<string, string>) => void;
onToolNameToDescriptionChange: (map: Record<string, string>) => void;
}
const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
@ -20,10 +24,15 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
allowedTools,
existingAllowedTools,
onAllowedToolsChange,
toolNameToDisplayName,
toolNameToDescription,
onToolNameToDisplayNameChange,
onToolNameToDescriptionChange,
}) => {
const previousToolsRef = useRef<any[]>([]);
const [toolSearchTerm, setToolSearchTerm] = useState("");
const hasInitializedRef = useRef(false);
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
const { tools, isLoadingTools, toolsError, canFetchTools } = useTestMCPConnection({
accessToken,
@ -96,6 +105,39 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
onAllowedToolsChange([]);
};
const handleToggleEditExpanded = (toolName: string, e: React.MouseEvent) => {
e.stopPropagation();
setExpandedTools((prev) => {
const next = new Set(prev);
if (next.has(toolName)) {
next.delete(toolName);
} else {
next.add(toolName);
}
return next;
});
};
const handleDisplayNameChange = (toolName: string, value: string) => {
const next = { ...toolNameToDisplayName };
if (value) {
next[toolName] = value;
} else {
delete next[toolName];
}
onToolNameToDisplayNameChange(next);
};
const handleDescriptionChange = (toolName: string, value: string) => {
const next = { ...toolNameToDescription };
if (value) {
next[toolName] = value;
} else {
delete next[toolName];
}
onToolNameToDescriptionChange(next);
};
// Don't show anything if required fields aren't filled
if (!canFetchTools && !formValues.url && !formValues.spec_path) {
return null;
@ -213,39 +255,106 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
<Text>No tools found matching &quot;{toolSearchTerm}&quot;</Text>
</div>
) : (
filteredTools.map((tool, index) => (
<div
key={index}
className={`p-4 rounded-lg border transition-colors cursor-pointer ${
allowedTools.includes(tool.name)
? "bg-blue-50 border-blue-300 hover:border-blue-400"
: "bg-gray-50 border-gray-200 hover:border-gray-300"
}`}
onClick={() => handleToolToggle(tool.name)}
>
<div className="flex items-start gap-3">
<Checkbox checked={allowedTools.includes(tool.name)} onChange={() => handleToolToggle(tool.name)} />
<div className="flex-1">
<div className="flex items-center gap-2">
<Text className="font-medium text-gray-900">{tool.name}</Text>
<span
className={`px-2 py-0.5 text-xs rounded-full font-medium ${
allowedTools.includes(tool.name) ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
filteredTools.map((tool, index) => {
const isEnabled = allowedTools.includes(tool.name);
const isEditExpanded = expandedTools.has(tool.name);
return (
<div
key={index}
className={`rounded-lg border transition-colors ${
isEnabled
? "bg-blue-50 border-blue-300 hover:border-blue-400"
: "bg-gray-50 border-gray-200 hover:border-gray-300"
}`}
>
{/* Main tool row */}
<div
className="p-4 cursor-pointer"
onClick={() => handleToolToggle(tool.name)}
>
<div className="flex items-start gap-3">
<Checkbox checked={isEnabled} onChange={() => handleToolToggle(tool.name)} />
<div className="flex-1">
<div className="flex items-center gap-2">
<Text className="font-medium text-gray-900">
{toolNameToDisplayName[tool.name] || tool.name}
</Text>
<span
className={`px-2 py-0.5 text-xs rounded-full font-medium ${
isEnabled ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
}`}
>
{isEnabled ? "Enabled" : "Disabled"}
</span>
{toolNameToDisplayName[tool.name] && (
<span className="px-2 py-0.5 text-xs rounded-full font-medium bg-purple-100 text-purple-800">
Custom name
</span>
)}
</div>
{(toolNameToDescription[tool.name] || tool.description) && (
<Text className="text-gray-500 text-sm block mt-1">
{toolNameToDescription[tool.name] || tool.description}
</Text>
)}
<Text className="text-gray-400 text-xs block mt-1">
{isEnabled ? "✓ Users can call this tool" : "✗ Users cannot call this tool"}
</Text>
</div>
{/* Edit toggle button */}
<button
type="button"
onClick={(e) => handleToggleEditExpanded(tool.name, e)}
className={`p-1.5 rounded-md transition-colors ${
isEditExpanded
? "bg-blue-100 text-blue-600"
: "text-gray-400 hover:text-gray-600 hover:bg-gray-100"
}`}
title="Edit display name and description"
>
{allowedTools.includes(tool.name) ? "Enabled" : "Disabled"}
</span>
<EditOutlined />
</button>
</div>
{tool.description && <Text className="text-gray-500 text-sm block mt-1">{tool.description}</Text>}
<Text className="text-gray-400 text-xs block mt-1">
{allowedTools.includes(tool.name)
? "✓ Users can call this tool"
: "✗ Users cannot call this tool"}
</Text>
</div>
{/* Inline edit section */}
{isEditExpanded && (
<div
className="px-4 pb-4 pt-3 border-t border-gray-200 space-y-3 bg-gray-50 rounded-b-lg"
onClick={(e) => e.stopPropagation()}
>
<div>
<Text className="text-xs font-medium text-gray-600 mb-1 block">
Display Name
</Text>
<Input
placeholder={tool.name}
value={toolNameToDisplayName[tool.name] || ""}
onChange={(e) => handleDisplayNameChange(tool.name, e.target.value)}
/>
<Text className="text-xs text-gray-400 mt-1 block">
Override how this tool&apos;s name appears to users. Leave blank to use original.
</Text>
</div>
<div>
<Text className="text-xs font-medium text-gray-600 mb-1 block">
Description
</Text>
<Input.TextArea
placeholder={tool.description || "No description"}
value={toolNameToDescription[tool.name] || ""}
onChange={(e) => handleDescriptionChange(tool.name, e.target.value)}
rows={2}
/>
<Text className="text-xs text-gray-400 mt-1 block">
Override the tool description shown to users. Leave blank to use original.
</Text>
</div>
</div>
)}
</div>
</div>
))
);
})
)}
</div>
</div>

View file

@ -169,6 +169,8 @@ export interface MCPServer {
teams?: Team[];
mcp_access_groups?: string[];
allowed_tools?: string[];
tool_name_to_display_name?: Record<string, string>;
tool_name_to_description?: Record<string, string>;
allow_all_keys?: boolean;
available_on_public_internet?: boolean;