diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 7027d9e8ff..f9a333af52 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -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____ 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 diff --git a/tools/mcp_tool_schema.py b/tools/mcp_tool_schema.py index dba5de056d..e0967b0126 100644 --- a/tools/mcp_tool_schema.py +++ b/tools/mcp_tool_schema.py @@ -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____`` 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____``.""" - return f"{MCP_TOOL_NAME_PREFIX}{sanitize_mcp_name_component(server_name)}__{sanitize_mcp_name_component(tool_name)}" + """Registry/wire name: ``mcp____``, 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: