fix(mcp): clamp generated MCP tool names to 64 chars

Portable Agent Plugin packages fold the plugin name into the MCP
registry name three times over (slug, digest, and again as the server
key), so mcp__<server>__<tool> routinely exceeds the 64-char function
name limit OpenAI-compatible providers enforce — while the same server
registered via `hermes mcp add` stays well under it. The oversized name
is never rejected loudly; the tool just becomes unreachable. Clamp
mcp_prefixed_tool_name() to 64 chars with a deterministic, collision-
safe hash suffix, mirroring the existing property-key clamp in
schema_sanitizer.py. Dispatch is unaffected since handlers already
close over the original unprefixed tool name.

Fixes #81331
This commit is contained in:
chelsealong
2026-08-08 00:45:38 +00:00
committed by Teknium
parent d9e88e19e2
commit ef0136385c
2 changed files with 49 additions and 2 deletions

View File

@@ -553,6 +553,33 @@ class TestSchemaConversion:
assert schema["name"] == "mcp__my_server__get_sum"
assert "-" not in schema["name"]
def test_long_names_are_clamped_to_64_chars(self):
"""Portable Agent Plugin names can push mcp__<server>__<tool> past the
64-char limit OpenAI-compatible providers enforce on function names
(issue #81331). The registry name must be clamped with a stable hash
suffix, distinct long names must not collide, and the same inputs
must always produce the same shortened name.
"""
from tools.mcp_tool_schema import _convert_mcp_schema, mcp_prefixed_tool_name
server_name = "agent_plugin_my_server_997167c9__my_server"
mcp_tool = _make_mcp_tool(name="reply_communication_todo")
schema = _convert_mcp_schema(server_name, mcp_tool)
assert len(schema["name"]) <= 64
assert schema["name"] == mcp_prefixed_tool_name(server_name, "reply_communication_todo")
other_tool = _make_mcp_tool(name="reply_communication_task")
other_schema = _convert_mcp_schema(server_name, other_tool)
assert other_schema["name"] != schema["name"]
assert len(other_schema["name"]) <= 64
# Deterministic across repeated calls with the same inputs.
assert (
mcp_prefixed_tool_name(server_name, "reply_communication_todo")
== schema["name"]
)
# ---------------------------------------------------------------------------
# Check function

View File

@@ -3,6 +3,7 @@ compatibility, mcp__server__tool naming, utility-tool schemas, include/exclude f
description injection scanning."""
import logging
import hashlib
import fnmatch
import re
from typing import Any, List
@@ -135,9 +136,28 @@ def sanitize_mcp_name_component(value: str) -> str:
MCP_TOOL_NAME_PREFIX = "mcp__"
# OpenAI-compatible providers validate function names against ``^[a-zA-Z0-9_-]{1,64}$`` and 400 the
# whole request when one generated name is longer. Portable plugin server keys fold the plugin name in
# several times, so ``mcp__<server>__<tool>`` routinely passes 64 chars there (#81331). Clamp with a
# deterministic hash suffix (same idea as ``schema_sanitizer.sanitize_property_key``); dispatch is
# unaffected because handlers close over the original unprefixed tool name.
_MCP_TOOL_NAME_MAX_LENGTH = 64
_MCP_TOOL_NAME_HASH_LENGTH = 8
_clamped_names_warned: set[str] = set()
def mcp_prefixed_tool_name(server_name: str, tool_name: str) -> str:
"""Registry/wire name: ``mcp__<sanitizedServer>__<sanitizedTool>``."""
return f"{MCP_TOOL_NAME_PREFIX}{sanitize_mcp_name_component(server_name)}__{sanitize_mcp_name_component(tool_name)}"
"""Registry/wire name: ``mcp__<sanitizedServer>__<sanitizedTool>``, clamped to 64 chars with a
stable hash suffix when the natural name is longer."""
full_name = f"{MCP_TOOL_NAME_PREFIX}{sanitize_mcp_name_component(server_name)}__{sanitize_mcp_name_component(tool_name)}"
if len(full_name) <= _MCP_TOOL_NAME_MAX_LENGTH:
return full_name
suffix = "_" + hashlib.sha256(full_name.encode("utf-8")).hexdigest()[:_MCP_TOOL_NAME_HASH_LENGTH]
if full_name not in _clamped_names_warned: # recomputed on every health refresh; warn once
_clamped_names_warned.add(full_name)
logger.warning("MCP tool name %r (%d chars) exceeds the %d-char provider limit; shortened to a "
"deterministic hash-suffixed name", full_name, len(full_name), _MCP_TOOL_NAME_MAX_LENGTH)
return full_name[:_MCP_TOOL_NAME_MAX_LENGTH - len(suffix)] + suffix
def _convert_mcp_schema(server_name: str, mcp_tool) -> dict: