Three Tools Named read_file: Catching MCP Name Collisions Before Startup
Bundle enough MCP servers and tool names collide quietly. Here is what a real 5-server, 21-tool setup measured at 43% collision, plus a Python preflight that catches them before startup and assigns deterministic aliases.
I asked the agent to read an attached spec and summarize the diff.
What came back was the contents of a completely different file — a JSON sitting in my local working directory that happened to have a vaguely similar name.
The agent did not think it had made a mistake. The log showed a call to read_file, a successful response, and a summary built from that response. Nothing had failed.
It took counting my own config to understand why. There were three tools named read_file in my setup, on three different servers.
The initialization stopped failing, and that made the failure quiet
Antigravity 2.4.2 fixed a bug where duplicate tool names in a customization would cause agent initialization itself to fail. The details are in the Antigravity changelog.
As a fix, that is the right direction. A single typo in a config file taking down startup is a poor fit for unattended operation.
For me, though, the outcome ran opposite to what I expected.
Before the fix, a collision announced itself loudly at startup. Something was obviously wrong. After the fix, startup succeeds, the tool list assembles, the agent runs — and one tool quietly shadows the other.
The failure mode shifted from "stops" to "returns the wrong answer." Operationally, the second one is far worse. You notice the thing that stops. You can go days without noticing the thing that returns something plausible.
Since then, I run a collision check every time I add an MCP server.
Collisions come in three layers
Once I started counting, "names that clash" turned out to cover several genuinely different situations. Without separating them, there is no clean line between what a machine can fix and what a person has to decide.
Exact matches
read_file versus read_file — identical as strings. A machine can detect these with certainty and rename them without breaking any meaning. This is the automation target.
Semantic proximity
read_file versus get_file_contents — different names, nearly identical roles. From the agent's point of view, either choice works, which is exactly the problem.
These cannot be renamed automatically. The question shifts to design: do you actually need both? Detecting them and putting them in front of a human is where the machine's job ends.
Overlapping descriptions
Different names, different roles, but description fields that open the same way. Agents use descriptions for tool selection too, so similarity here makes selection wobble.
The third layer is not fully reachable by static inspection. The preflight below automates layer one, warns on layer two, and leaves layer three alone.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A real 5-server, 21-tool setup where 43% of tool names collided across servers
✦A Python preflight that fails before startup — 28 ms across 305 tools, byte-identical output regardless of key order
✦Prefixing every tool inflates total name length by 76%; prefixing only collisions holds it to 35%
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Read the config, cross-reference tool names, exit non-zero on any exact match. That is the whole tool.
#!/usr/bin/env python3"""Detect MCP tool name collisions before startup and emit a deterministic alias map.exit 0: no collisions / exit 1: exact collisions / exit 2: config load failure"""from __future__ import annotationsimport hashlibimport jsonimport reimport sysfrom collections import defaultdictNAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")MAX_LEN = 64# Absorb morphological variation. Left side folds into the canonical form.SYNONYMS = { "get": "read", "fetch": "read", "load": "read", "find": "search", "query": "search", "grep": "search", "ls": "list", "dir": "list", "directory": "list", "put": "write", "save": "write", "update": "write", "rm": "delete", "remove": "delete", "file": "file", "files": "file", "contents": "file", "content": "file", "page": "page", "pages": "page",}def normalize(name: str) -> str: """Canonical form for comparison: drop separators, fold synonyms per word.""" words = [w for w in re.split(r"[^a-z0-9]+", name.lower()) if w] folded = sorted({SYNONYMS.get(w, w) for w in words}) return "_".join(folded)def short(server: str, budget: int) -> str: """Turn a server name into a safe prefix, shrinking deterministically if needed.""" slug = re.sub(r"[^A-Za-z0-9]+", "_", server).strip("_").lower() or "srv" if len(slug) <= budget: return slug digest = hashlib.sha1(server.encode("utf-8")).hexdigest()[:4] return slug[: max(1, budget - 5)] + "_" + digestdef load(path: str) -> dict[str, list[str]]: try: with open(path, encoding="utf-8") as fh: raw = json.load(fh) except FileNotFoundError: print(f"error: config not found: {path}", file=sys.stderr) raise SystemExit(2) except json.JSONDecodeError as exc: # Hand-edited configs fail here more than anywhere else print(f"error: cannot parse JSON (line {exc.lineno}): {exc.msg}", file=sys.stderr) raise SystemExit(2) servers = raw.get("mcpServers") if not isinstance(servers, dict): print("error: no mcpServers object", file=sys.stderr) raise SystemExit(2) catalog: dict[str, list[str]] = {} for server, spec in sorted(servers.items()): tools = spec.get("tools") if isinstance(spec, dict) else None if not tools: # Servers without a static tool list need a live tools/list call. # Say so out loud rather than skipping in silence. print(f"warn: {server}: no static tools, excluded from check", file=sys.stderr) continue catalog[server] = list(dict.fromkeys(str(t) for t in tools)) return catalogdef audit(catalog: dict[str, list[str]]): exact: dict[str, list[str]] = defaultdict(list) near: dict[str, list[tuple[str, str]]] = defaultdict(list) invalid: list[tuple[str, str]] = [] for server, tools in catalog.items(): for tool in tools: if not NAME_RE.match(tool): invalid.append((server, tool)) exact[tool].append(server) near[normalize(tool)].append((server, tool)) exact_hits = {n: s for n, s in exact.items() if len(s) > 1} near_hits = {} for key, pairs in near.items(): distinct_names = {t for _, t in pairs} distinct_servers = {s for s, _ in pairs} if len(distinct_servers) > 1 and len(distinct_names) > 1: near_hits[key] = sorted(pairs) return exact_hits, near_hits, invaliddef alias_map(catalog: dict[str, list[str]], exact_hits: dict[str, list[str]]): """Prefix only the names that actually collide. Leave the rest untouched.""" taken = {t for tools in catalog.values() for t in tools} mapping: dict[tuple[str, str], str] = {} for server in sorted(catalog): for tool in catalog[server]: if tool not in exact_hits: continue budget = MAX_LEN - len(tool) - 1 if budget < 3: # The name itself is too long. Fail loudly instead of truncating. raise ValueError(f"{server}.{tool}: alias will not fit in {MAX_LEN} chars") candidate = f"{short(server, budget)}_{tool}" if candidate in taken: tail = hashlib.sha1(f"{server}/{tool}".encode()).hexdigest()[:4] candidate = f"{candidate[: MAX_LEN - 5]}_{tail}" taken.add(candidate) mapping[(server, tool)] = candidate return mappingdef main(argv: list[str]) -> int: path = argv[1] if len(argv) > 1 else "mcp_config.json" catalog = load(path) total = sum(len(v) for v in catalog.values()) exact_hits, near_hits, invalid = audit(catalog) print(f"checked: {len(catalog)} servers / {total} tools") for server, tool in invalid: print(f"NG invalid name {server}.{tool} (must match ^[A-Za-z0-9_-]{{1,64}}$)") if exact_hits: print(f"\nexact collisions: {len(exact_hits)}") for name in sorted(exact_hits): print(f" {name} <- {', '.join(sorted(exact_hits[name]))}") try: mapping = alias_map(catalog, exact_hits) except ValueError as exc: print(f"NG cannot generate alias: {exc}") return 1 print("\nalias map (deterministic, order fixed):") for (server, tool), alias in sorted(mapping.items()): print(f" {server}.{tool} -> {alias}") if near_hits: print(f"\nsemantically close: {len(near_hits)} (not auto-renamed, review by hand)") for key in sorted(near_hits): pairs = ", ".join(f"{s}.{t}" for s, t in near_hits[key]) print(f" [{key}] {pairs}") if not exact_hits and not near_hits and not invalid: print("no collisions") return 1 if (exact_hits or invalid) else 0if __name__ == "__main__": sys.exit(main(sys.argv))
A couple of design notes.
sorted() wraps both the server iteration and every output path. Dictionary order follows the order things appear in the config file, so without this, the same setup could produce different alias assignments on different runs. A check whose output shifts between runs stops being trusted the moment it lands in code review.
Servers without a static tools list are reported via warn rather than skipped silently. Swallowing them would create the worst possible outcome: a check that passes while collisions remain. Recording what could not be checked belongs in the check results.
What the measurement showed
The setup I tested against is filesystem, GitHub, a lightweight Notion server, SQLite, and Google Drive — 5 servers, 21 tools total. Nothing exotic; just the things I reach for in ordinary indie developer work.
Before counting, I would have guessed one or two. Seeing 43% stopped me for a moment.
The triple read_file is the one that really bites. Reading a file is the single highest-frequency choice an agent makes. If that is indeterminate, everything downstream inherits the uncertainty.
I was also glad github.get_file_contents landed in the semantic cluster. It does not collide by so much as a single character, yet it competes for the same slot in the agent's decision.
I measured speed too, generating a synthetic 24-server, 305-tool config and taking the minimum of five runs with time.perf_counter().
Config
Elapsed (process startup included, best of 5)
5 servers / 21 tools
25 ms
24 servers / 305 tools
28 ms
Fourteen times the tools costs 3 additional milliseconds. Python's process startup dominates; the check itself disappears into noise. Running it unconditionally on every launch costs nothing you can feel.
One caveat: the 305-tool config combines vocabulary at random, so its collision rate says nothing about real setups. I used it only for timing.
Deterministic aliasing matters more than clever aliasing
The naming scheme itself is almost disappointingly plain. Build server_originaltool, confirm it fits MCP's ^[A-Za-z0-9_-]{1,64}$. Done.
The hard part was keeping it plain without letting it break.
Determinism came first. I shuffled the keys in the config file, reran the check, and confirmed the output was byte-identical. An implementation whose aliases depend on key order falls apart the moment a second person shares the config.
Then the length boundary. The pattern caps names at 64 characters, so a 61-character original overflows the instant a prefix is added. Silently truncating the original would make it impossible to work backwards from an alias to the tool it came from.
NG cannot generate alias: alpha.aaaaaaaa… (61 chars): alias will not fit in 64 chars
So it fails explicitly instead. When the only real fix is a human shortening a name, the machine should not compromise on its behalf.
Long server names are a different case, and those do get shrunk — appending the first four characters of a SHA-1 keeps uniqueness intact after truncation.
Why I stopped prefixing everything
My first version prefixed every tool with its server name, collision or not. It was consistent, and consistency means fewer decisions.
I rolled it back, for two reasons.
The first is raw volume. Same 21 tools, measured under each policy:
Policy
Total chars in tool names
vs. original
Mean name length
Untouched
246
—
11.7
Prefix every tool
433
+76%
20.6
Prefix collisions only
331
+35%
15.8
The tool list rides along in the system prompt on every request. There is no reason to impose a 76% increase on the 12 tools that were never in conflict.
The second reason runs deeper. read_file and list_directory are names a model has seen countless times. Renaming them to srv_04_read_file throws that familiarity away. A name is an identifier, but it is also a cue for selection.
Change only what needs changing. Leave alone what can be left alone. That judgment took more thought than any other part of this.
Wiring it into the path you cannot skip
A check you remember to run is not a check. Mine lives in two places.
Repository CI — runs on any PR that touches the MCP config, blocks the merge on a non-zero exit
A local launch wrapper — runs at the top of the shell function that starts the agent, refuses to launch on failure
#!/usr/bin/env bash# agy-run — run the collision check, then start the Antigravity CLIset -euo pipefailCONFIG="${MCP_CONFIG:-$HOME/.antigravity/mcp_config.json}"if ! python3 "$(dirname "$0")/mcp_preflight.py" "$CONFIG"; then echo "---" >&2 echo "Tool names collide. Apply the alias map and try again." >&2 exit 1fiexec antigravity "$@"
set -euo pipefail is there so a failing check cannot be swallowed. Drop it and the exec still runs after the check fails — a check you believe is in place while it quietly is not, which is the worst version of this whole problem.
CI reuses the same exit code directly:
- name: MCP tool name preflight run: python3 tools/mcp_preflight.py .antigravity/mcp_config.json
What I deliberately left to humans
Semantically close names get detected and nothing more.
If a machine silently merged or renamed filesystem.read_file and github.get_file_contents, it would trade one breakage for another. Some setups genuinely need both; in others, dropping one is the right call. Only the person who wrote the config can tell which.
Description overlap is excluded for the same reason. Static string comparison cannot reach it. I considered embedding distance, but a check whose reasoning is hard to explain is a check people stop believing. For now I would rather have something simple and defensible.
Automate what cannot be gotten wrong. Lay out the rest for a person to look at. Drawing that boundary is what keeps a tool useful over time.
Where this still falls short
To be straightforward about it: this check is static.
Most MCP servers return their tool list at runtime, so any server without a tools entry in the config never gets checked. My setup emits a warn and moves on, but the correct shape is to start each server, call tools/list, and cross-reference what actually comes back. The Model Context Protocol documentation covers the specifics.
A check that boots servers costs real startup time in production, so I am weighing caching the results and re-fetching only when the config hash changes. I have not built it yet.
One more gap. Running two versions of the same server side by side collides on every single tool name, which is a configuration question more than a check result — and the current implementation buries you in warnings. Per-server exclusions belong on the list.
If you have three or more MCP servers bundled together, it is worth counting. I assumed my own setup was clean right up until I measured it. Tool name collisions do not announce themselves by breaking. They keep answering plausibly, and keep being wrong.
Thank you for reading. If the numbers from your own config surprise you, that is the tool doing its job.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.