shareAI-lab/learn-claude-code · error · ValueError

MCP tool name collision after normalization: {prefixed!r} ma

Error message

MCP tool name collision after normalization: {prefixed!r} maps both {origins[prefixed]} and {origin}

What it means

Raised when two different tools normalize to the same mcp__<server>__<tool> name. normalize_mcp_name collapses every character outside [a-zA-Z0-9_-] into '_', so 'doc.search' and 'doc/search' on one server — or identical server/tool pairs registered under server names that normalize together — produce identical prefixed names. The harness keeps an origins map and refuses ambiguous wiring rather than silently shadowing a tool.

Source

Thrown at s15_integrated_harness/code.py:2557

    global mcp_tool_policies
    tools = list(BUILTIN_TOOLS)
    handlers = dict(BUILTIN_HANDLERS)
    policies: dict[str, str] = {}
    origins = {tool["name"]: f"built-in tool {tool['name']!r}"
               for tool in tools}
    for server_name, mcp_client in mcp_clients.items():
        safe_server = normalize_mcp_name(server_name)
        for tool_def in mcp_client.tools:
            raw_name = tool_def["name"]
            safe_tool = normalize_mcp_name(raw_name)
            prefixed = f"mcp__{safe_server}__{safe_tool}"
            if len(prefixed) > 64:
                raise ValueError(
                    f"MCP tool name is longer than 64 characters: {prefixed}"
                )
            origin = f"MCP tool {server_name!r}/{raw_name!r}"
            if prefixed in origins:
                raise ValueError(
                    "MCP tool name collision after normalization: "
                    f"{prefixed!r} maps both {origins[prefixed]} and {origin}"
                )
            schema = tool_def.get("inputSchema", {})
            if not isinstance(schema, dict) or schema.get("type", "object") != "object":
                raise ValueError(f"Invalid input schema for {origin}")
            origins[prefixed] = origin
            tools.append({
                "name": prefixed,
                "description": tool_def.get("description", ""),
                "input_schema": schema,
            })
            handlers[prefixed] = (
                lambda *, client=mcp_client, tool=raw_name, **kwargs:
                client.call_tool(tool, kwargs)
            )
            policies[prefixed] = MCP_HOST_POLICY.get(
                (server_name, raw_name), "confirm"

View on GitHub (pinned to 985456f4ad)

Solutions

  1. Rename the colliding tool on the MCP server so post-normalization names differ
  2. Give each server a distinct, already-normalized alias (only [a-zA-Z0-9_-]) as the mcp_clients dict key
  3. The error names both origins — use them to identify exactly which pair collided before renaming

Example fix

# before
# server 'docs' exposes 'get.version' and 'get_version'
# both normalize to mcp__docs__get_version -> collision

# after
# rename on the server: 'get.version' -> 'get_api_version'
# now mcp__docs__get_api_version is unique
Defensive patterns

Strategy: validation

Validate before calling

import re

def prefixed_names(mcp_clients):
    disallowed = re.compile(r"[^a-zA-Z0-9_-]")
    seen = {}
    for server, client in mcp_clients.items():
        s = disallowed.sub("_", server)
        for t in client.tools:
            n = f"mcp__{s}__{disallowed.sub('_', t['name'])}"
            if n in seen:
                raise ValueError(f"collision: {n} <- {seen[n]} and {server}/{t['name']}")
            seen[n] = f"{server}/{t['name']}"
    return seen

prefixed_names(mcp_clients)  # run before wiring the harness

Type guard

def names_are_unique_after_normalization(server: str, tool: str, taken: set[str]) -> bool:
    import re
    n = f"mcp__{re.sub(r'[^a-zA-Z0-9_-]', '_', server)}__{re.sub(r'[^a-zA-Z0-9_-]', '_', tool)}"
    return n not in taken

Try / catch

try:
    harness = build_harness(mcp_clients)
except ValueError as exc:
    if "name collision after normalization" in str(exc):
        # message names both origins; rename one tool or server alias and rebuild
        raise SystemExit(str(exc)) from exc
    raise

Prevention

When it happens

Trigger: Registering one MCP server exposing both 'get-version' and 'get_version' (or 'get.version'). Registering two servers named 'docs' and 'docs!' whose tools share names. A collision between an MCP-prefixed name and a built-in tool is also possible in principle since built-ins are seeded into the same origins map.

Common situations: Merging MCP servers from different vendors that each define generically named tools. Version skew: a server upgrade renames 'search.v2' which normalizes onto an existing 'search_v2'.

Related errors


AI-assisted analysis of shareAI-lab/learn-claude-code@985456f4ad (2026-08-14). Data as JSON: /api/errors/a9ddc90f8c23d1af. Report an issue: GitHub.