●MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructure●EXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioning●ADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applications●QUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the window●PRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days out●FIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution●MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructure●EXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioning●ADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applications●QUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the window●PRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days out●FIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution
Resolving Tool Name Collisions When Bundling Multiple MCP Servers in the Claude Agent SDK
When the GitHub MCP and Linear MCP both expose create_issue, Sonnet 4.6 cannot tell them apart. This article covers the structure of MCP tool name collisions, a TypeScript reconciler implementation, and a measured correction: the schemaHash I originally published missed all five schema drifts I tested it against.
The day I plugged the GitHub MCP and the Linear MCP into the same Claude Agent SDK app, Sonnet 4.6 received a user instruction to "open a ticket on Linear" and confidently created a GitHub issue instead. Both servers exposed a tool named create_issue, and the GitHub schema had silently overwritten the Linear one when the second server registered. I only found the cause after dumping the SDK's tool list to the debug log.
Back when I wired REST APIs together by hand, the endpoint URL settled the routing question for me. MCP removes that handle. All the model gets is a tool name and a description, and overlapping verbs across servers turn out to be far more common than they were in the pre-MCP era.
This article walks through the namespace design and the TypeScript reconciler I now use. It also corrects something: the schemaHash I originally published in this article was broken, and I only noticed months later. The correction comes with measurements.
How tool name collisions actually happen
The Claude Agent SDK hands off the tool list from each MCP server straight into the tools array of the Anthropic Messages API and leaves collision detection to the caller. The API itself only allows alphanumerics, underscores, and hyphens in name. If the same name appears twice, the SDK implementations I've inspected push them through a Map that ends up keeping the last entry, which makes the earlier server's tool vanish silently.
From the model's perspective, a tool is a triple of name, description, and input_schema. Two tools with the same name get collapsed before the request leaves your process, so the model never sees the duplicate. If the descriptions differ, the model is choosing based on whichever description survived the merge. That mismatch is exactly what produced the GitHub-vs-Linear mistake above.
Typical collision-prone verbs include:
Verb
Likely MCP servers
create_issue
GitHub MCP / Linear MCP / Jira MCP
search
Notion MCP / Slack MCP / Web Search MCP
upload
S3 MCP / Cloudflare R2 MCP / Stripe Files MCP
send_message
Slack MCP / Discord MCP / SendGrid MCP
In my own setup, having three or more of these servers alive in a single agent session is the default rather than the exception. A workflow that pulls AdMob revenue numbers into Slack, opens a fix PR on GitHub, and closes the related Linear ticket walks straight into this minefield.
Comparing three namespacing strategies
There are several ways to resolve collisions. After running a few in production I kept the "prefix with server identifier" approach. Here is how the three candidates compare.
The first candidate is dot notation. Names like github.create_issue read well to humans, but the Anthropic Messages API rejects . in name and you have to escape or substitute it. That forces you to maintain a bidirectional mapping back to the original name on every dispatch.
The second is slash notation, which runs into the same character restriction and the same escape cost.
The third is double underscore separation: github__create_issue, linear__create_issue. It uses only API-legal characters and the model parses it as "the GitHub side's create_issue" without prompting. I ran the same prompt 50 times each across Sonnet 4.6 and Haiku 4.5 with temperature: 0.0, comparing strategies. Double underscore had the lowest misfire rate and beat a 2-character prefix like gh_ by about 7 points. Short prefixes also get confused with other meanings ("gh" vs "ghp"), so I do not recommend them.
I also keep the following rules around prefixing:
Prefixes are lowercase ASCII, between 3 and 10 characters
Original tool names stay untouched; the prefix is additive
Prefixes are applied only when there is an actual collision; never blanket-prefix everything
Rule 3 matters. Renaming every tool unconditionally hides the "idiomatic" names the model already knows (web_search, for instance) and degrades selection accuracy. Limiting prefixes to the servers that actually collide is the most model-friendly setting.
It is worth saying a word about the benchmark setup. Fifty samples is just enough to see the differences, but only if you pin the temperature to zero and replay the exact same system prompt and user message. I also kept the working set close to production, with four AdMob-related MCP servers, two GitHub-side servers, and one Slack server in the bundle — about 60 tools in total. Toy benchmarks with five tools always look rosier than reality.
✦
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
✦Observed misfire patterns when GitHub MCP and Linear MCP both ship create_issue, and whether the model relies more on name or description
✦A TypeScript Reconciler that prefixes only colliding tool names with double underscores and returns a bidirectional alias map
✦A measured correction: the schemaHash originally published here misused the JSON.stringify replacer and reported all five tested schema drifts as identical, plus a recursive canonicalization that fixes it
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.
The reconciler takes server tool bundles, detects collisions, renames them, and returns a bidirectional alias map.
// reconciler.tsimport type { Tool } from "@anthropic-ai/sdk/resources/messages.mjs";export interface ServerToolBundle { serverId: string; // "github" | "linear" | ... tools: Tool[];}export interface ReconciledTools { tools: Tool[]; forward: Map<string, { serverId: string; original: string }>; reverse: Map<string, string>;}const PREFIX_SEP = "__";const NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;export function reconcile(bundles: ServerToolBundle[]): ReconciledTools { const seen = new Map<string, string[]>(); for (const b of bundles) { for (const t of b.tools) { const list = seen.get(t.name) ?? []; list.push(b.serverId); seen.set(t.name, list); } } const conflicted = new Set( [...seen.entries()].filter(([, ids]) => ids.length > 1).map(([n]) => n), ); const tools: Tool[] = []; const forward = new Map<string, { serverId: string; original: string }>(); const reverse = new Map<string, string>(); for (const b of bundles) { for (const t of b.tools) { const needsPrefix = conflicted.has(t.name); const renamed = needsPrefix ? `${b.serverId}${PREFIX_SEP}${t.name}` : t.name; if (!NAME_RE.test(renamed)) { throw new Error(`rename produced invalid tool name: ${renamed}`); } tools.push({ ...t, name: renamed }); forward.set(renamed, { serverId: b.serverId, original: t.name }); reverse.set(`${b.serverId}:${t.name}`, renamed); } } return { tools, forward, reverse };}
Three things to flag in the code. Only colliding tools get prefixed. The forward and reverse maps let you resolve github__create_issue back to "GitHub server, original name create_issue" in O(1). And the regex check raises during build if any rename produces a name that the Messages API would reject.
The dispatcher side that runs after the model returns a tool_use block looks like this.
// dispatcher.tsimport type { ToolUseBlock } from "@anthropic-ai/sdk/resources/messages.mjs";import { reconcile, type ServerToolBundle } from "./reconciler.js";export class MCPDispatcher { private forward: Map<string, { serverId: string; original: string }>; constructor(private servers: Map<string, MCPClient>, bundles: ServerToolBundle[]) { const r = reconcile(bundles); this.forward = r.forward; } async dispatch(block: ToolUseBlock): Promise<unknown> { const meta = this.forward.get(block.name); if (!meta) { return { isError: true, content: `unknown tool: ${block.name}. available: ${[...this.forward.keys()].join(", ")}` }; } const server = this.servers.get(meta.serverId); if (!server) { return { isError: true, content: `server ${meta.serverId} not registered` }; } return server.callTool(meta.original, block.input as Record<string, unknown>); }}
One word on error handling. Throwing on unknown tool will tear down your agent loop. I made that mistake in the first revision and lost a scheduled task slot to it. The current version returns an error tool_result to the model with the list of available tools, and Sonnet 4.6 almost always corrects itself on the next turn. The wording matters — including "these tools exist: github__create_issue, linear__create_issue, ..." gives the model a runway to land on the correct name.
Normalizing descriptions with provenance tags
Renaming alone is not enough. In my measurements, when two tools share an identical description after prefixing (both literally say "Create a new issue"), the model still treats the prefix as a weak signal and picks one consistently regardless of intent. To strengthen the signal I tag descriptions by origin.
Simple, but effective. The misfire rate in my setup dropped from 38% to 6% just by adding a bracketed tag. Square brackets matter — the model reads them as an auxiliary label rather than part of the prose. For tools with long descriptions, I keep a newline after the tag so the body stays readable.
I also learned that beefier descriptions help when names collide. [GitHub] Create a new issue is too terse; [GitHub] Create a new issue in a repository. Use this for code-related bug reports, feature requests, and pull-request discussions on the GitHub side. cuts misfires by an additional 3 points. Counter-intuitive perhaps — I usually trim descriptions — but for colliding names a thicker description pays for itself.
Rebuilding when MCP servers come and go
Real Agent SDK apps see servers come and go. The user connects Slack, disconnects Discord, swaps in a new Notion workspace. Every time the tool list changes, the Anthropic prompt cache invalidates the corresponding breakpoint and the cost picture shifts. On one AdMob automation that bundled four MCP servers, I lost a meaningful slice of my monthly cache benefit before I noticed the transition was invalidating breakpoints on every reconnect.
The fix is to wrap server registration in an MCPManager that owns the rebuild.
Subscribe to onChange and you can re-plan your cache breakpoints, or stage the new tool list to take effect from the next turn of an in-flight agent session. The non-negotiable rule is to never swap the tool list mid-turn. If the model picked github__create_issue in the previous turn and the name has disappeared in the next, the API returns a 400 and the loop spins on retries. Switch at turn boundaries only.
Three production failure modes
After running this in production for a while, I want to share three specific failures.
Stale alias. GitHub MCP renamed create_issue to issues_create in an update. The reconciler picked up the new name fine, but the prompt cache still held the old system prompt with the old tool list. The model called the no-longer-existing github__create_issue, the API returned 400, and the agent retried itself into a rate limit. Fix: include a hash of the reconciler output in the cache key, and rotate breakpoints explicitly when the hash changes.
Description drift. Two tools sharing a name can have their descriptions evolve independently on each upstream side. Even after tagging by provenance, the prose underneath drifts and the model's preference flips one day for no obvious local reason. I now dump the forward map's descriptions weekly for eyeball review.
Schema collision. Two tools with the same name can have different input_schema (GitHub create_issue requires repo, Linear requires team_id). If your reconciler merges by name and ignores the schema, the model picks the wrong server's schema and the call fails on the other side. Prefixing colliding names already prevents this, but I added a safety: hash the input_schema and force a prefix whenever two same-named tools have different schema hashes, even if you would not otherwise consider them collided.
function schemaHash(t: Tool): string { return JSON.stringify(t.input_schema, Object.keys(t.input_schema).sort());}
Two smaller things worth noting alongside the big three. The first-turn misfire rate is consistently higher than later turns; I add one sentence to the system prompt ("Use the full tool name as written; github__create_issue and linear__create_issue are distinct.") and that gap closes. The other is that description length nudges the model's choice noticeably more than I expected — when names collide, expand the description rather than trim it.
The safety net was never firing: rebuilding schemaHash under measurement
That schemaHash was supposed to be the safety net — force a prefix whenever two servers share a name but disagree on the schema. Re-reading it months later, I stopped. The second argument to JSON.stringify is not a comparator. It is a replacer, and when you hand it an array it behaves as a key allowlist.
Here is the article's own code, run unmodified in a Linux sandbox on Node v22.22.3:
properties came back empty. The replacer array applies at every level of nesting, so repo and title — keys that live one level down and are not in the top-level key list — get dropped wholesale. Array elements bypass the replacer entirely, which is why required survived. The result is the worst kind of breakage: the body of the schema disappears and a plausible-looking hash still comes back.
So I measured how much it misses. Starting from a create_issue schema with repo, title, and labels, I applied five drifts of the kind an upstream server actually ships, and checked whether each implementation could tell the schemas apart.
Upstream change
Article's implementation
Corrected
labels type changes string → array
reports identical
detects
optional assignee property added
reports identical
detects
enum constraint added to labels
reports identical
detects
property renamed repo → repository
reports identical
detects
repo promoted from string to nested object
reports identical
detects
Five out of five, the published implementation answered "same schema." Nothing outside required was ever visible to it, and upstream servers rarely change their required fields when they revise a schema. The safety net was silent precisely for the drift class most likely to occur.
The corrected version canonicalizes key order recursively, then digests once:
// schema-hash.tsimport { createHash } from "node:crypto";function canonicalize(v: unknown): unknown { if (Array.isArray(v)) return v.map(canonicalize); if (v && typeof v === "object") { const out: Record<string, unknown> = {}; for (const k of Object.keys(v as object).sort()) { out[k] = canonicalize((v as Record<string, unknown>)[k]); } return out; } return v;}export function schemaHash(t: Tool): string { const canonical = JSON.stringify(canonicalize(t.input_schema)); return createHash("sha256").update(canonical).digest("hex").slice(0, 16);}
Run against the same five drifts, every pair separates. The GitHub and Linear create_issue schemas from the opening of this article land on d70c028aa9e9e8da and 49816f1ac82369fd. Schemas that differ only in key order still collapse to one hash, which is the property you want — a safety net that fires on cosmetic reordering is worse than none. That was the one thing the broken version happened to get right.
One operational addition pays for itself. Log the schemaHash per tool name when the reconciler initializes. The day an upstream server quietly revises its schema, the change shows up as a diff in your logs instead of as a mysterious uptick in misrouted tool calls. Before I had that, I spent half a day looking for the cause in my prompts.
Code you published yourself is the code you are least likely to re-examine. A hash function is especially good at hiding, because it always returns something.
Name server IDs by capability, not by project
Someone publishes a server, you fold it into your agent, and you read tool names to infer intent. Designing for collisions with strangers' servers is closer to basic courtesy than to defensive engineering — the collision is not their fault.
In production I switched from project-scoped server IDs to capability-scoped ones (admob, slack, github). When several projects pull from the same AdMob account, write to the same Slack workspace, and commit to the same GitHub organization, capability-based IDs let one server registration serve all of them. The reconciler output stays more stable too, which keeps prompt cache breakpoints intact.
The takeaway is that adding more MCP servers is not free. For each agent task I now ask "which tools does this actually need", and I bundle accordingly. AdMob aggregation does not need the GitHub MCP; a PR review does not need the Slack MCP. Task-specific bundles keep the reconciler output deterministic, and a deterministic tool list is what keeps the cache hot.
Next steps
If you do one thing today, call list_tools on every server you currently bundle and group by name to count the actual collisions. You will probably find more than you expected. Prefixing and description normalization can both wait until you have that list in front of you.
And if you already have a schema comparison of your own in place, feed it two inputs that differ in one nested field and confirm the value changes. I ran mine for three months without checking.
MCP is still in the phase where multiple vendors are publishing servers with their own naming traditions, and the variance will widen before it narrows. A reconciler layer turns server churn from a code change into a configuration change, which keeps your agent code stable and your shipping pace steady.
I share more half-formed notes from this work over on my stand.fm channel when I have something worth saying. Thanks for reading this far.
Share
Thank You for Reading
Claude 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.