◈Home ▸Blog ◇Contact
Home/Blog/Why Two MCP Servers Fight Over the Same Tool …
How toLLM EngineeringMCPAI AgentsTool Design

Why Two MCP Servers Fight Over the Same Tool Name

9 min readBy Miloš Mitrović

An MCP tool name is unique inside one server and nowhere else. The moment a host connects a second server that also exposes search or create_record, something has to decide which definition the model sees, and a surprising number of hosts decide by last writer wins, silently. The fix lives in two places: prefix tool names at the server with a stable token, and key the host registry on the pair (server ID, tool name) rather than on the bare name.

Key takeaways

  • MCP scopes tool identity to a single server's namespace, so cross-server uniqueness is the host's job, not the protocol's.
  • The three signatures are a tool that vanishes from the list, a call that executes against the wrong backend, and a JSON-RPC -32602 Unknown tool from a server that never saw your prefix.
  • Prefix names at the server (acme_billing_invoice_search) and keep the final exposed name inside the 64 character limit most provider function schemas enforce.
  • On the host, store (serverId, toolName) and strip your prefix before tools/call, because the server only recognises its own bare name.
  • Collisions also appear mid session, when a server is enabled dynamically or emits notifications/tools/list_changed, so the duplicate check belongs on every registry mutation.

What MCP guarantees about a tool name

The protocol guarantees uniqueness within one server and nothing beyond that. The MCP architecture overview is explicit that name is "a unique identifier for the tool within the server's namespace" (modelcontextprotocol.io architecture overview), and a tools/call request carries only params.name and params.arguments (MCP specification, Tools). There is no field on the wire that says which server a call is addressed to.

That routing information exists only in host memory. The host creates one MCP client per server and each client holds a dedicated connection, so the host is the single component that knows a given tool name belongs to client 3 and not client 1. The same documentation shows hosts combining tools from every connected server "into a unified tool registry", which is exactly the merge step where identity gets flattened and lost.

How a collision looks from inside the agent

Four signatures cover almost every case, and they are easy to tell apart once you know them.

  • A tool disappears. Your server advertises twelve tools, the agent lists eleven. A registry keyed on bare name was overwritten by a later server during startup.
  • The call lands on the wrong backend. Two instances of the same server (staging and production, or two client accounts) are both connected. Every call succeeds and returns plausible data, which makes this the expensive one.
  • Unknown tool errors. The host exposes a prefixed name, then forwards that prefixed name verbatim in tools/call. The server replies with the protocol error the spec defines for this case, {"code": -32602, "message": "Unknown tool: ..."}.
  • Schema mismatch on arguments. The model read server A's inputSchema and the call was routed to server B's identically named tool. You get invalid parameter errors, or silently wrong behaviour when the two schemas happen to overlap.

The discriminating test is whether the failure tracks server start order. Restart the host with the server list reversed in config. If the broken tool changes identity, you have a last writer wins registry, not a bug in either server.

Find every duplicate before you ship

Enumerate tools/list for the exact server set a given agent config loads, group by name, and fail the build on any name claimed twice. This takes about twenty lines and belongs in CI next to your schema tests.

import asyncio
from collections import defaultdict
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

SERVERS = {
    "crm": StdioServerParameters(command="uvx", args=["crm-mcp"]),
    "docs": StdioServerParameters(command="uvx", args=["docs-mcp"]),
}

async def inventory():
    owners = defaultdict(list)
    for server_id, params in SERVERS.items():
        async with stdio_client(params) as (read, write):
            async with ClientSession(read, write) as session:
                await session.initialize()
                result = await session.list_tools()
                for tool in result.tools:
                    owners[tool.name].append(server_id)

    collisions = {n: s for n, s in owners.items() if len(s) > 1}
    for name, servers in sorted(collisions.items()):
        print("COLLISION", name, "claimed by", ", ".join(servers))
    raise SystemExit(1 if collisions else 0)

asyncio.run(inventory())

There is a cheaper smoke test for an already running agent. Sum the tool counts reported by each connected server and compare that number against the size of the merged registry the model actually sees. The gap is your collision count. For inspecting one server in isolation, the MCP Inspector lists tools directly from the transport, which separates a server bug from a host merge bug (see debugging an MCP server with Inspector).

Name tools at the server so the host cannot break them

Give every tool a prefix that is stable for the lifetime of the server, because that is the only part of the name you control across hosts you did not write. MCP's own client guidance uses this shape in its examples, with names like salesforce_updateRecord and ticketing_createIssue (MCP client best practices). A four part convention works well: organisation, product, resource, verb.

from fastmcp import FastMCP

mcp = FastMCP("acme-billing")

@mcp.tool(name="acme_billing_invoice_search")
def search_invoices(customer_id: str, status: str = "open") -> dict:
    """Search Acme Billing invoices for a single customer.

    Scope: invoices only. Does not cover payments, refunds,
    subscriptions or support tickets. Use acme_billing_payment_search
    for settled payments.
    """
    ...

Watch the character budget while you do this. Provider function schemas cap the name field (OpenAI's function calling guide allows 64 characters of letters, digits, underscores and dashes), and hosts spend part of that budget themselves. Claude Code, for example, exposes MCP tools as mcp__<server>__<tool>, so a 40 character tool name plus a long server key can overflow before the model ever sees it. Aim for 30 characters or fewer at the server. Once a prefixed name is in production, treat a rename as a breaking change and stage it properly (changing an MCP tool without breaking agents).

Route calls by server ID, never by tool name

Store the pair and resolve the pair. The pseudo code in the MCP docs, client = app.find_mcp_client_for_tool(tool_name), is fine for one server and is precisely the lookup that misroutes under two. Replace it with a registry whose key is the name you expose to the model and whose value carries the origin.

type Entry = { serverId: string; toolName: string };

const registry = new Map<string, Entry>();

export function register(serverId: string, toolName: string): string {
  const exposed = `${serverId}__${toolName}`;
  if (exposed.length > 64) {
    throw new Error(`exposed name exceeds the provider limit: ${exposed}`);
  }
  if (registry.has(exposed)) {
    throw new Error(`exposed name already registered: ${exposed}`);
  }
  registry.set(exposed, { serverId, toolName });
  return exposed;
}

export async function call(exposed: string, args: Record<string, unknown>) {
  const entry = registry.get(exposed);
  if (!entry) throw new Error(`unknown tool: ${exposed}`);
  // the server only knows its bare name, so strip the prefix here
  return clients[entry.serverId].callTool({
    name: entry.toolName,
    arguments: args,
  });
}

Three details matter more than the code. Registration throws on a duplicate instead of overwriting, so a collision is a deploy time failure rather than a runtime mystery. The prefix is stripped on the way out, which is the step that produces most Unknown tool errors when it is forgotten. And serverId comes from your config key, not from serverInfo.name in the discovery response, because the server controls the latter and two vendors can ship servers that both call themselves github.

Collisions that only appear after startup

Any registry that can change during a session can collide during a session. A server that declares listChanged may add tools at any point and the host refetches on notifications/tools/list_changed. Hosts doing dynamic server management connect and disconnect servers mid task, so a name that was free at boot is taken twenty turns later. Cached tool lists extend the window further: list results carry ttlMs and cacheScope hints, and a cached list must be treated as stale once a change notification arrives (MCP caching utility).

Run the duplicate check on every registry mutation, not only at startup, and log the winner and loser by server ID when one occurs. Batch those rebuilds as well. Rewriting the tools array mid conversation invalidates the provider's cached prompt prefix, and the miss can cost more than the definitions you added, which is the same mechanism described in why your MCP server breaks prompt caching.

The collisions your registry cannot see

Two distinct names still collide if their descriptions describe the same job. notion_search and drive_search, both documented as "Search your documents", produce a coin flip on every call, and no uniqueness check will catch it because the registry is clean. The model selects on the description text, so the description has to carry scope and exclusions, as in the FastMCP example above: one sentence saying what this tool covers, one saying what it does not and which tool to use instead. This is the same failure surface covered in why your agent never calls your MCP tool.

Under progressive tool discovery the problem moves rather than disappearing. A search_tools meta tool ranks candidates by keyword or embedding similarity, so two near identical descriptions become adjacent search results and the model still has to break the tie from a one line summary. Provider side implementations behave the same way (Anthropic's tool search tool, OpenAI's tool search). Distinct prefixes help here too, because the server token gives the retriever something to discriminate on (background in tool search versus code execution).

Trade-offs and what I would do

Prefix at the server or rely on the host. Prefix at the server. It costs tokens in every tools array and makes names uglier, and it is the only defence that survives contact with hosts you do not control. Host namespacing is a second layer, not a substitute.

Short names for selection accuracy. Shorter names do read more cleanly to the model, but the failure mode they invite is a silent write to the wrong system. Spend the tokens, and buy them back by trimming description prose rather than the identifier.

Hard fail or auto suffix on a duplicate. For an agent you deploy and operate, throw at registration. A config with two servers claiming create_record is a mistake you want at deploy time. For a consumer client where killing the session is worse than degraded behaviour, auto suffixing (search_2) is defensible, with the caveat that it hands the disambiguation problem to a model reading two indistinguishable entries.

Server identity from config or from the server. Use the config key. It is the identifier the operator owns, it stays stable across server upgrades, and it keeps exposed names deterministic across restarts, which matters for prompt cache hit rates and for eval fixtures that pin tool names.

One server per environment or one server with an environment argument. Prefer a single server that takes an explicit account or environment parameter, validated server side. Running two identically named servers for staging and production is the shortest path to a production write dressed up as a staging test, and no amount of host side prefixing makes that call obvious to a model reading tool descriptions.

Sources

M
Miloš Mitrović
Email Marketing for Ecommerce

Have a question or a project?

Whether it is about this post or a system you want built, I'm happy to talk.

Get in touch

404

Post not found. It may have been moved or the link is incorrect.

← Back to the blog
Summarize with AI
ChatGPT, Perplexity, and Grok open with the prompt ready to run. Claude, Gemini, and Copilot open a chat with the prompt copied; press Ctrl+V (Cmd+V on Mac) to paste. The full text is included, so it works even without web access.