feat(mcp): opt-in short-ID tool prefix to stay under 60-char tool name limit

Adds LITELLM_USE_SHORT_MCP_TOOL_PREFIX. When enabled, tool / prompt /
resource / resource-template names emitted from MCP servers are prefixed
with a deterministic three-character base62 ID derived from the server's
server_id (SHA-256 → base62) instead of the (potentially long)
alias / server_name. This keeps namespaced tool names well under the
60-character upper bound enforced by some model APIs while still letting
us distinguish MCP-routed tools from local tools.

Behavioural notes:

- Default off — when the env var is unset, the long-prefix behaviour
  is unchanged. The plan is to flip the default in a future release
  and remove the gate after a deprecation window.
- Prefix derivation is deterministic, so it is stable across processes,
  workers and restarts without any persistence layer.
- Reverse-lookup is tolerant: _create_prefixed_tools registers every
  known prefix form (alias / server_name / server_id / short ID) in
  the routing map and _get_mcp_server_from_tool_name resolves any of
  them. Old clients holding cached long-prefixed names continue to
  route correctly even after the flag is enabled.
- _get_allowed_mcp_servers_from_mcp_server_names accepts the short
  prefix in /mcp/{server_name}-style URLs.
- The OpenAPI tool-listing path now filters by the active server
  prefix instead of server.name so spec-backed servers benefit too.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-04-29 01:41:24 +00:00
co-authored by Mateo Wang
parent 60bab9828f
commit fc49c181bc
4 changed files with 362 additions and 40 deletions
@@ -52,6 +52,7 @@ from litellm.proxy._experimental.mcp_server.utils import (
add_server_prefix_to_name,
get_server_prefix,
is_tool_name_prefixed,
iter_known_server_prefixes,
merge_mcp_headers,
normalize_server_name,
split_server_prefix_from_name,
@@ -1236,7 +1237,11 @@ class MCPServerManager:
## HANDLE OPENAPI TOOLS
if server.spec_path:
_tools = global_mcp_tool_registry.list_tools(tool_prefix=server.name)
# OpenAPI tools were stored in the registry under the prefix
# active at registration time — fetch by that same prefix.
_tools = global_mcp_tool_registry.list_tools(
tool_prefix=get_server_prefix(server)
)
tools = global_mcp_tool_registry.convert_tools_to_mcp_sdk_tool_type(
_tools
)
@@ -1838,9 +1843,13 @@ class MCPServerManager:
tool_copy.name = name_to_use
prefixed_tools.append(tool_copy)
# Update tool to server mapping for resolution (support both forms)
# Register every known prefix form (alias, server_name, server_id,
# short ID) so call_tool can resolve regardless of which form a
# caller / cached client is using.
self.tool_name_to_mcp_server_name_mapping[original_name] = prefix
self.tool_name_to_mcp_server_name_mapping[prefixed_name] = prefix
for known_prefix in iter_known_server_prefixes(server):
qualified = add_server_prefix_to_name(original_name, known_prefix)
self.tool_name_to_mcp_server_name_mapping[qualified] = prefix
verbose_logger.info(
f"Successfully fetched {len(prefixed_tools)} tools from server {server.name}"
@@ -2601,37 +2610,43 @@ class MCPServerManager:
Returns:
MCPServer if found, None otherwise
"""
registry_servers = list(self.get_registry().values())
# Build prefix → server lookup covering every known form a tool name
# may take (alias / server_name / server_id / short ID). This is what
# makes the short-prefix mode work without breaking historical names.
prefix_to_server: Dict[str, MCPServer] = {}
for server in registry_servers:
for known_prefix in iter_known_server_prefixes(server):
normalised = normalize_server_name(known_prefix)
prefix_to_server.setdefault(normalised, server)
# First try with the original tool name
if tool_name in self.tool_name_to_mcp_server_name_mapping:
server_name = self.tool_name_to_mcp_server_name_mapping[tool_name]
for server in self.get_registry().values():
if normalize_server_name(server.name) == normalize_server_name(
server_name
):
normalised_lookup = normalize_server_name(server_name)
if normalised_lookup in prefix_to_server:
return prefix_to_server[normalised_lookup]
for server in registry_servers:
if normalize_server_name(server.name) == normalised_lookup:
return server
# If not found and tool name is prefixed, try extracting server name from prefix
known_prefixes = {
normalize_server_name(get_server_prefix(s))
for s in self.get_registry().values()
if get_server_prefix(s)
}
if is_tool_name_prefixed(tool_name, known_server_prefixes=known_prefixes):
# If not found and tool name is prefixed, extract the prefix and
# match against any known form.
if is_tool_name_prefixed(
tool_name, known_server_prefixes=set(prefix_to_server.keys())
):
(
original_tool_name,
server_name_from_prefix,
) = split_server_prefix_from_name(tool_name)
if original_tool_name in self.tool_name_to_mcp_server_name_mapping:
for server in self.get_registry().values():
if server.server_name is None:
if normalize_server_name(server.name) == normalize_server_name(
server_name_from_prefix
):
return server
elif normalize_server_name(
server.server_name
) == normalize_server_name(server_name_from_prefix):
return server
normalised_prefix = normalize_server_name(server_name_from_prefix)
server = prefix_to_server.get(normalised_prefix)
if server is not None and (
original_tool_name in self.tool_name_to_mcp_server_name_mapping
or tool_name in self.tool_name_to_mcp_server_name_mapping
):
return server
return None
@@ -49,6 +49,7 @@ from litellm.proxy._experimental.mcp_server.utils import (
LITELLM_MCP_SERVER_VERSION,
add_server_prefix_to_name,
get_server_prefix,
iter_known_server_prefixes,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
@@ -711,14 +712,13 @@ if MCP_AVAILABLE:
for server in allowed_mcp_servers:
if server:
match_list = [
s.lower()
for s in [
server.alias,
server.server_name,
server.server_id,
]
if s is not None
s.lower() for s in iter_known_server_prefixes(server) if s
]
# Always accept server_id even if it isn't part of the
# current prefix form (iter_known_server_prefixes only
# yields it when no other identifier exists).
if server.server_id:
match_list.append(server.server_id.lower())
if server_or_group.lower() in match_list:
filtered_server[server.server_id] = server
@@ -2031,11 +2031,13 @@ if MCP_AVAILABLE:
# Remove prefix from tool name for logging and processing
original_tool_name, server_name = split_server_prefix_from_name(name)
# If tool name is unprefixed, resolve its server so we can enforce permissions
if not server_name:
mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(name)
if mcp_server:
server_name = mcp_server.name
# Resolve the actual MCP server up-front so the permission check uses
# the canonical server.name even when the tool name is prefixed with a
# short ID (LITELLM_USE_SHORT_MCP_TOOL_PREFIX) that doesn't match the
# server's display name directly.
mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(name)
if mcp_server is not None:
server_name = mcp_server.name
# Only enforce server-level permissions when we can resolve a server
if server_name:
+101 -3
View File
@@ -2,10 +2,11 @@
MCP Server Utilities
"""
from typing import Any, Dict, Mapping, Optional, Tuple
from typing import Any, Dict, Iterator, Mapping, Optional, Tuple
import os
import hashlib
import importlib
import os
# Constants
LITELLM_MCP_SERVER_NAME = "litellm-mcp-server"
@@ -14,6 +15,63 @@ LITELLM_MCP_SERVER_DESCRIPTION = "MCP Server for LiteLLM"
MCP_TOOL_PREFIX_SEPARATOR = os.environ.get("MCP_TOOL_PREFIX_SEPARATOR", "-")
MCP_TOOL_PREFIX_FORMAT = "{server_name}{separator}{tool_name}"
# ---------------------------------------------------------------------------
# Short-ID tool prefix (opt-in)
# ---------------------------------------------------------------------------
# When LITELLM_USE_SHORT_MCP_TOOL_PREFIX is truthy the prefix attached to MCP
# tool / prompt / resource / resource-template names switches from the
# (potentially long) human-readable server name to a deterministic three
# character base62 ID derived from the server's ``server_id``.
#
# Why three characters and base62 ([0-9A-Za-z])?
# * 62**3 = 238_328 distinct IDs — the chance of a real local tool name
# happening to begin with the exact prefix LiteLLM assigned to a given
# MCP server is negligible in practice.
# * The IDs are short enough that prefixed tool names stay well under the
# 60-character upper bound enforced by some model APIs (Anthropic etc.)
# even for long upstream tool names.
# * The mapping is deterministic (SHA-256 of ``server_id`` → first three
# base62 chars), which means the prefix is stable across processes,
# workers and restarts without any persistence layer. Two servers with
# different ``server_id`` values can in principle hash to the same
# three chars, but for the reverse-lookup path we register every known
# form of the prefix anyway, so a collision only affects the cosmetic
# emitted name, not routing correctness.
#
# This flag is intentionally opt-in for the first release so customers can
# migrate. It will become the default in a future release.
SHORT_MCP_TOOL_PREFIX_LENGTH = 3
_BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
def is_short_mcp_tool_prefix_enabled() -> bool:
"""Return True when the short-ID tool prefix mode is enabled.
Read at call time (not import time) so tests and runtime config changes
take effect without reimporting the module.
"""
raw = os.environ.get("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "")
return raw.strip().lower() in ("1", "true", "yes", "on")
def compute_short_server_prefix(server_id: str) -> str:
"""Derive the deterministic three-character base62 prefix for a server.
Uses SHA-256 of the server_id and folds the first eight bytes into a
base62 string. An empty server_id raises ValueError — short prefixes
require a stable identifier to be deterministic.
"""
if not server_id:
raise ValueError("compute_short_server_prefix requires a non-empty server_id")
digest = hashlib.sha256(server_id.encode("utf-8")).digest()
value = int.from_bytes(digest[:8], "big")
chars = []
for _ in range(SHORT_MCP_TOOL_PREFIX_LENGTH):
value, idx = divmod(value, len(_BASE62_ALPHABET))
chars.append(_BASE62_ALPHABET[idx])
return "".join(reversed(chars))
def is_mcp_available() -> bool:
"""
@@ -82,7 +140,18 @@ def add_server_prefix_to_name(name: str, server_name: str) -> str:
def get_server_prefix(server: Any) -> str:
"""Return the prefix for a server: alias if present, else server_name, else server_id"""
"""Return the prefix for a server.
When the short-prefix mode is enabled (``LITELLM_USE_SHORT_MCP_TOOL_PREFIX``)
a deterministic three-character base62 ID derived from ``server_id`` is
returned. Otherwise we fall back to the historical behaviour: alias if
present, else server_name, else server_id.
"""
if is_short_mcp_tool_prefix_enabled():
server_id = getattr(server, "server_id", None)
if server_id:
return compute_short_server_prefix(server_id)
if hasattr(server, "alias") and server.alias:
return server.alias
if hasattr(server, "server_name") and server.server_name:
@@ -92,6 +161,35 @@ def get_server_prefix(server: Any) -> str:
return ""
def iter_known_server_prefixes(server: Any) -> Iterator[str]:
"""Yield every prefix form that may appear in tool names for ``server``.
Always includes the *current* prefix returned by ``get_server_prefix``.
Additionally yields the historical (alias / server_name / server_id) and
short-ID forms so the routing layer can resolve tool names regardless of
which prefix mode was active when the client first observed them.
"""
seen = set()
def _emit(value: Optional[str]) -> Iterator[str]:
if value and value not in seen:
seen.add(value)
yield value
yield from _emit(get_server_prefix(server))
server_id = getattr(server, "server_id", None)
if server_id:
try:
yield from _emit(compute_short_server_prefix(server_id))
except ValueError:
pass
yield from _emit(getattr(server, "alias", None))
yield from _emit(getattr(server, "server_name", None))
yield from _emit(server_id)
def split_server_prefix_from_name(prefixed_name: str) -> Tuple[str, str]:
"""Return the unprefixed name plus the server name used as prefix."""
if MCP_TOOL_PREFIX_SEPARATOR in prefixed_name:
@@ -0,0 +1,207 @@
"""
Tests for the short-ID MCP tool prefix (LITELLM_USE_SHORT_MCP_TOOL_PREFIX).
The short-prefix mode swaps the historical alias/server_name prefix on
tool names for a deterministic three-character base62 ID derived from the
server's ``server_id``. This keeps tool names well below the 60-char
upper bound enforced by some model APIs while remaining stable across
processes/restarts and tolerant of mixed-version clients.
"""
from typing import List
import pytest
from mcp.types import Tool as MCPTool
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
from litellm.proxy._experimental.mcp_server.utils import (
SHORT_MCP_TOOL_PREFIX_LENGTH,
add_server_prefix_to_name,
compute_short_server_prefix,
get_server_prefix,
is_short_mcp_tool_prefix_enabled,
iter_known_server_prefixes,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
def _make_server(
*,
server_id: str = "abcdef-1234",
server_name: str = "github_onprem",
alias: str = "github_onprem",
) -> MCPServer:
return MCPServer(
server_id=server_id,
name=alias or server_name,
alias=alias,
server_name=server_name,
transport="http",
)
@pytest.fixture(autouse=True)
def _reset_env(monkeypatch):
monkeypatch.delenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", raising=False)
yield
# ---------------------------------------------------------------------------
# Pure helpers
# ---------------------------------------------------------------------------
class TestShortPrefixHelpers:
def test_short_prefix_is_three_base62_chars(self):
prefix = compute_short_server_prefix("any-server-id")
assert len(prefix) == SHORT_MCP_TOOL_PREFIX_LENGTH
assert prefix.isalnum() and prefix.isascii()
def test_short_prefix_is_deterministic(self):
assert compute_short_server_prefix("abc") == compute_short_server_prefix("abc")
assert compute_short_server_prefix("abc") != compute_short_server_prefix("abd")
def test_short_prefix_requires_server_id(self):
with pytest.raises(ValueError):
compute_short_server_prefix("")
def test_flag_defaults_to_false(self):
assert is_short_mcp_tool_prefix_enabled() is False
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "On"])
def test_flag_truthy_values(self, monkeypatch, value):
monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", value)
assert is_short_mcp_tool_prefix_enabled() is True
@pytest.mark.parametrize("value", ["0", "false", "no", "off", ""])
def test_flag_falsey_values(self, monkeypatch, value):
monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", value)
assert is_short_mcp_tool_prefix_enabled() is False
# ---------------------------------------------------------------------------
# get_server_prefix behaviour
# ---------------------------------------------------------------------------
class TestGetServerPrefix:
def test_default_mode_uses_alias(self):
server = _make_server(alias="github_onprem", server_name="github_onprem")
assert get_server_prefix(server) == "github_onprem"
def test_short_mode_uses_short_id(self, monkeypatch):
monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
server = _make_server(server_id="abcdef-1234")
prefix = get_server_prefix(server)
assert prefix == compute_short_server_prefix("abcdef-1234")
assert len(prefix) == SHORT_MCP_TOOL_PREFIX_LENGTH
def test_short_mode_falls_back_when_no_server_id(self, monkeypatch):
monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
class _Bare:
alias = "fallback_alias"
server_name = None
server_id = None
assert get_server_prefix(_Bare()) == "fallback_alias"
# ---------------------------------------------------------------------------
# iter_known_server_prefixes — covers reverse-lookup tolerance
# ---------------------------------------------------------------------------
class TestIterKnownServerPrefixes:
def test_default_mode_includes_short_id_too(self):
server = _make_server()
prefixes = list(iter_known_server_prefixes(server))
# Contains the live prefix and every known form so that mixed-mode
# clients can be resolved.
assert "github_onprem" in prefixes
assert compute_short_server_prefix(server.server_id) in prefixes
def test_short_mode_still_yields_long_forms(self, monkeypatch):
monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
server = _make_server()
prefixes = list(iter_known_server_prefixes(server))
assert "github_onprem" in prefixes
assert compute_short_server_prefix(server.server_id) in prefixes
# ---------------------------------------------------------------------------
# Manager-level behaviour: list + reverse-lookup
# ---------------------------------------------------------------------------
def _stub_tools() -> List[MCPTool]:
return [
MCPTool(name="get_repo", description="", inputSchema={"type": "object"}),
MCPTool(name="list_issues", description="", inputSchema={"type": "object"}),
]
class TestManagerShortPrefix:
def test_list_tools_uses_short_prefix_when_flag_on(self, monkeypatch):
monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
manager = MCPServerManager()
server = _make_server()
out = manager._create_prefixed_tools(_stub_tools(), server)
short = compute_short_server_prefix(server.server_id)
assert {t.name for t in out} == {f"{short}-get_repo", f"{short}-list_issues"}
def test_call_tool_lookup_resolves_short_prefix(self, monkeypatch):
monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
manager = MCPServerManager()
server = _make_server()
manager.registry[server.server_id] = server
manager._create_prefixed_tools(_stub_tools(), server)
short = compute_short_server_prefix(server.server_id)
resolved = manager._get_mcp_server_from_tool_name(f"{short}-get_repo")
assert resolved is server
def test_call_tool_lookup_resolves_long_prefix_in_short_mode(self, monkeypatch):
"""Old clients that cached the long-prefix name must still route."""
monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
manager = MCPServerManager()
server = _make_server()
manager.registry[server.server_id] = server
manager._create_prefixed_tools(_stub_tools(), server)
resolved = manager._get_mcp_server_from_tool_name("github_onprem-get_repo")
assert resolved is server
def test_default_mode_unchanged(self):
manager = MCPServerManager()
server = _make_server()
out = manager._create_prefixed_tools(_stub_tools(), server)
assert {t.name for t in out} == {
"github_onprem-get_repo",
"github_onprem-list_issues",
}
assert (
manager._get_mcp_server_from_tool_name("github_onprem-get_repo") is None
) # registry empty
manager.registry[server.server_id] = server
assert (
manager._get_mcp_server_from_tool_name("github_onprem-get_repo") is server
)
def test_total_tool_name_length_short_enough(self, monkeypatch):
"""The short prefix keeps tool names under the 60-char limit even
when the upstream tool name is itself reasonably long."""
monkeypatch.setenv("LITELLM_USE_SHORT_MCP_TOOL_PREFIX", "true")
long_server_name = "a" * 50
server = _make_server(
server_id="server-id-1",
server_name=long_server_name,
alias=long_server_name,
)
prefix = get_server_prefix(server)
full = add_server_prefix_to_name("get_repo", prefix)
assert len(full) < 60