fix: add MCP tool name collision protection (#3077)

- Registry now warns when a tool name is overwritten by a different
  toolset (silent dict overwrite was the previous behavior)
- MCP tool registration checks for collisions with non-MCP (built-in)
  tools before registering. If an MCP tool's prefixed name matches an
  existing built-in, the MCP tool is skipped and a warning is logged.
  MCP-to-MCP collisions are allowed (last server wins).
- Both regular MCP tools and utility tools (resources/prompts) are
  guarded.
- Adds 5 tests covering: registry overwrite warning, same-toolset
  re-registration silence, built-in collision skip, normal registration,
  and MCP-to-MCP collision pass-through.

Reported by k_sze (KONG) — MiniMax MCP server's web_search tool could
theoretically shadow Hermes's built-in web_search if prefixing failed.
This commit is contained in:
Teknium
2026-03-25 16:52:04 -07:00
committed by GitHub
parent 3bc953a666
commit 0cfc1f88a3
3 changed files with 180 additions and 2 deletions

View File

@@ -1532,6 +1532,16 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]:
schema = _convert_mcp_schema(name, mcp_tool)
tool_name_prefixed = schema["name"]
# Guard against collisions with built-in (non-MCP) tools.
existing_toolset = registry.get_toolset_for_tool(tool_name_prefixed)
if existing_toolset and not existing_toolset.startswith("mcp-"):
logger.warning(
"MCP server '%s': tool '%s' (→ '%s') collides with built-in "
"tool in toolset '%s' — skipping to preserve built-in",
name, mcp_tool.name, tool_name_prefixed, existing_toolset,
)
continue
registry.register(
name=tool_name_prefixed,
toolset=toolset_name,
@@ -1556,9 +1566,20 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]:
schema = entry["schema"]
handler_key = entry["handler_key"]
handler = _handler_factories[handler_key](name, server.tool_timeout)
util_name = schema["name"]
# Same collision guard for utility tools.
existing_toolset = registry.get_toolset_for_tool(util_name)
if existing_toolset and not existing_toolset.startswith("mcp-"):
logger.warning(
"MCP server '%s': utility tool '%s' collides with built-in "
"tool in toolset '%s' — skipping to preserve built-in",
name, util_name, existing_toolset,
)
continue
registry.register(
name=schema["name"],
name=util_name,
toolset=toolset_name,
schema=schema,
handler=handler,
@@ -1566,7 +1587,7 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]:
is_async=False,
description=schema["description"],
)
registered_names.append(schema["name"])
registered_names.append(util_name)
server._registered_tool_names = list(registered_names)

View File

@@ -66,6 +66,13 @@ class ToolRegistry:
emoji: str = "",
):
"""Register a tool. Called at module-import time by each tool file."""
existing = self._tools.get(name)
if existing and existing.toolset != toolset:
logger.warning(
"Tool name collision: '%s' (toolset '%s') is being "
"overwritten by toolset '%s'",
name, existing.toolset, toolset,
)
self._tools[name] = ToolEntry(
name=name,
toolset=toolset,