Someone renames search_orders to orders_search because the naming is inconsistent with the rest of the catalog. It's a five-minute cleanup, the schema is byte-identical, and every test passes.
Three days later, support tickets arrive: "the assistant says it can't find orders anymore." The agent had memorized the old name from its prompt, from a cached tool list, and from a hundred successful calls in previous conversations. It never re-reads the catalog unless something makes it. It just calls a tool that no longer exists.
Tool renaming is the most common breaking change in MCP servers and the least dramatic-looking. Versioning an MCP server is mostly about accepting that your consumers are not code that compiles — they're models with habits, caches, and prompts you don't control.
Two version numbers that people conflate
There are two independent versioning problems in an MCP deployment, and mixing them up produces bad decisions.
The protocol version describes the wire contract: how requests are framed, what metadata they carry, which methods exist. It changes when the specification does, and it's negotiated between client and server.
Your tool surface describes your domain: which tools exist, what they're called, what arguments they take, what they return. It changes when your product does, on your schedule.
A protocol upgrade is usually invisible to users. A tool rename is invisible to tests and highly visible to users. Most of this article is about the second problem, because that's where the breakage lives — but the first problem changed substantially in 2026, so it's worth getting straight.
What the 2026-07-28 revision changed about protocol versioning
Before this revision, version negotiation happened once, at connection time, through the initialize handshake. A client and server agreed on a version, opened a session, and lived with that agreement until the connection closed.
The current spec removed both the handshake and the session. Every request now travels on its own, carrying its protocol version and client capabilities in _meta. The practical consequences for versioning are significant:
- There's no negotiation point. A request either matches a version the server speaks or it doesn't.
- Any request can land on any instance. A round-robin load balancer in front of ten pods works without shared session storage, because there's nothing to share.
- The version is per request, not per connection. Two consecutive calls from the same client can, in principle, carry different versions during a client rollout.
- Mismatches are explicit. An unsupported version returns
UnsupportedProtocolVersionError, which the changelog renumbered to-32022under the new error-code allocation policy.
Here's what a request looks like on the wire, with the version carried both as a header and inside the body:
POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"search","arguments":{"q":"otters"},
"_meta":{"io.modelcontextprotocol/clientInfo":{"name":"my-app","version":"1.0"}}}}
The Mcp-Method and Mcp-Name headers are mandatory on Streamable HTTP POSTs, which is good news for versioning: a gateway can reject or route a call to a retired tool without parsing JSON, and it can do it with a policy that doesn't depend on your application code.
There's also a new server/discover RPC. Servers must implement it and use it to advertise their supported protocol versions, capabilities, and identity. Clients may call it before anything else to select a version up front, or use it as a backward-compatibility probe on stdio. It's optional to call and mandatory to implement, which is a sensible split — a client that doesn't care can just send requests.
💡 Practical note: because discovery is optional, "the client called
server/discoverfirst" is not an assumption your server can make. Every individual request must still be independently valid.
The compatibility matrix you actually need to test
| Client | Server | Expected behavior |
|---|---|---|
| 2026-07-28 | 2026-07-28 | Normal operation |
| 2025-11-25 (stateful) | 2026-07-28 | Fails without a compatibility shim; no session to establish |
| 2026-07-28 | 2025-11-25 | Client must fall back to the older era's handshake |
| 2026-07-28 | Older, stdio | server/discover probe tells the client which era to use |
| Mixed fleet during rollout | Mixed | Each request must be self-sufficient; no cross-instance state |
The second row is the one that hurts. If you deploy the stateless spec and a meaningful share of your clients are still on 2025-11-25, they will not connect at all, because the handshake they depend on no longer exists. The spec's versioning documentation covers backward compatibility with a compatibility matrix for implementors, and the short version is: you either run two protocol paths or you keep a stateful compatibility layer until your clients move.
Renaming a tool is a breaking change, even when nothing else changes
An agent's relationship with your catalog is not a compile-time dependency. It's built from three sources that all go stale independently:
- The tool list it fetched, which may be cached for the
ttlMsyou advertised. - The prompt (system message, examples, or few-shot transcripts) that names specific tools. This is often where the rename breaks first, because a prompt with
search_ordersin an example is a hardcoded dependency. - Conversation history. A long-running thread contains prior tool calls by name. The model may reproduce them from context.
Because of the third source, the blast radius of a rename extends into conversations that started before the deploy. That's a property no versioning scheme fully solves, which is why the cheapest strategy is not to rename at all.
If the name is genuinely wrong — it exposes an internal codename, or it collides with another server's tool — the safe pattern is add-then-remove, with a real deprecation window:
@mcp.tool(
name="orders_search",
description="Search orders. Replaces search_orders, which is deprecated "
"and will be removed on 2027-03-01.",
)
async def orders_search(q: str, limit: int = 20) -> dict: ...
@mcp.tool(
name="search_orders",
description="DEPRECATED: use orders_search. Kept for compatibility until "
"2027-03-01. Arguments and response are identical.",
)
async def search_orders_legacy(q: str, limit: int = 20) -> dict:
return await orders_search(q, limit)
Two things matter here beyond the code. The deprecated tool's description tells the model what to do instead, in the first line, where it will be read. And the removal date is concrete, because "deprecated" with no date is a permanent maintenance cost that everyone learns to ignore.
Change semantics behind a new name, not under an old one
The inverse mistake is subtler and worse: keeping the name and changing what it means.
A tool called get_customer that returned a customer record and now returns a customer plus their subscription summary has the same name and a different contract. The model's expectations were formed by the old behavior, and it will misread the new response. If the field names also changed, it may silently pick the wrong one.
The rule I'd apply: if a model that has never seen your changelog would misinterpret the result, it's a new tool name. Concretely:
- Adding a field to a response: safe, same name.
- Adding an optional argument with a default that preserves old behavior: safe, same name.
- Changing an argument's units: new name.
- Changing what an argument means (
statusgoing from a single value to a list): new name. - Changing the shape of the primary return value: new name.
- Changing pagination from offset to cursor: new name, or accept both arguments for a window.
That last case is common and usually handled badly. If the response shape changes but the tool keeps its name, every cached prompt example returns stale instructions.
Schema evolution rules that survive contact with a model
JSON Schema changes have asymmetric risk. Additions are almost always safe; removals and tightenings almost never are.
Safe:
{
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["open", "closed", "pending"]},
"include_archived": {"type": "boolean", "default": false}
},
"required": ["status"]
}
Adding include_archived with a default, and adding "pending" to an enum, are backward compatible in the protocol sense. The enum addition still has a behavioral risk: a client that switches on the enum values may not know what to do with pending. For a tool whose result feeds a model, "unknown value" usually produces a confident wrong summary rather than an error.
Breaking, even though the schema validates:
- Renaming a property.
amountbecomingamount_centsis a silent factor-of-100 bug if the model reuses an old example. - Adding a
requiredproperty toinputSchema. Cached clients send calls without it and get validation errors. - Narrowing an enum. A call with the removed value now fails.
- Making an optional property required in
outputSchema. Clients validating structured output will reject responses. - Changing
additionalPropertiesfromtruetofalse. Clients that pass extra hints start failing.
The unit-change case deserves special handling because it's the most dangerous form of "the schema is fine." If you must change units, change the field name too. amount (major units) to amount_cents (minor units) is self-documenting; amount to amount is a trap that survives code review.
⚠️ Gotcha:
outputSchemais a contract in both directions. If you tighten it, clients that validatestructuredContentagainst it will reject the server's own responses as invalid.
Caches make your deploy lag by minutes
The current spec makes list results cacheable. tools/list, prompts/list, resources/list, resources/templates/list, and resources/read all carry ttlMs and cacheScope, with ttlMs acting as a freshness hint and cacheScope (public or private) controlling whether shared intermediaries may store the response. Servers should also return tools in a deterministic order, so the client's cached copy stays stable and prompt caches don't churn.
{"jsonrpc":"2.0","id":2,"result":{
"resultType":"complete",
"tools":[ ... ],
"ttlMs": 300000,
"cacheScope": "private"
}}
A five-minute ttlMs means your renamed tool can remain callable-looking for five minutes after the deploy. That's fine if the old handler still exists, and disastrous if it doesn't.
Two rules follow:
Never remove a tool in the same deploy that starts returning a shorter ttlMs. The cache doesn't care about your intent; it cares about the number it was given.
Set ttlMs from how often the catalog actually changes, not from how fast you'd like clients to notice. A stable catalog can advertise an hour. A catalog that changes weekly should advertise minutes, and should send a change notification when it does.
Change notifications now flow through subscriptions/listen: a single long-lived POST-response stream that clients opt into per notification type, including toolsListChanged. If a client has opted in, it will re-fetch without waiting for the TTL. If it hasn't, TTL is your only lever.
Rolling out a new surface without a flag day
The stateless core makes rolling deploys easier in one way and harder in another. Easier, because there's no session affinity requirement — any request can hit any pod. Harder, because during a rollout some pods serve the old catalog and some serve the new one, and a client that calls tools/list twice may get two different answers.
A workable rollout for a surface change:
- Deploy the new tools to all pods, alongside the old ones. No removals yet. Both surfaces are served identically by every instance.
- Update the descriptions so the old tool points at the new one, and the new tool explains the migration.
- Wait for at least two cache cycles and one notification broadcast. If your
ttlMsis five minutes, wait fifteen. - Watch call volume per tool name. The old tool's traffic should fall to near zero before you touch it. This is the only reliable signal that prompts and histories have moved.
- Remove the old handler in a separate deploy, and update
ttlMsat the same time if the catalog changed shape.
The metric in step four is the one teams skip. It's easy to add: your tool-call log already has the name. If search_orders is still 3% of calls after a month, something in your ecosystem still references it — likely an internal prompt or a customer's saved transcript — and deleting it now will produce a support ticket you'll recognize immediately.
Versioned tool names versus versioned servers
There are two ways to expose a breaking change, and they have different costs.
| Approach | Client impact | Operational cost | Best for |
|---|---|---|---|
| New tool name, old one deprecated | Explicit; model sees both | Two handlers, one alias | Most changes |
Version in the name (search_v2) |
Clear but clutters the catalog | Many tools, confused selection | Genuinely different semantics |
| Separate server per major version | Clean isolation, separate credentials | Two deployments, two catalogs | Different audiences or auth |
| Silent change under the same name | No signal at all | Low now, high later | Never |
The third row is underrated. If version 2 of your surface changes authorization scope or data exposure, a separate server is not just tidier — it's the only way to give it a different credential, a different rate limit, and a different audit trail. Tool names inside a single server share all of those.
The second row is usually a mistake. search_v2 alongside search puts the choice in front of the model on every turn, and models are not reliable at choosing between two similarly described tools. If the semantics really differ, the names should differ in meaning (search_full_text versus search_by_id), not in number.
Borrow the protocol's deprecation window instead of inventing one
The specification now has a formal feature lifecycle with an Active, Deprecated, and Removed state, a minimum twelve-month deprecation window, and a registry of deprecated features. That policy exists for protocol features, not for your tools, but it's a useful precedent to copy: it encodes the assumption that consumers need a year to move.
Your tools probably don't. If you control the client, two weeks may be plenty. If third parties connect, a year is realistic, and the cost is one forwarding handler and a description line.
A pragmatic default that has held up across integrations:
| Audience | Minimum window | Why |
|---|---|---|
| Your own client, same repo | One release | You can update the prompt and catalog together |
| Internal clients, other teams | One quarter | Their release cadence is not yours |
| Third-party or customer-built clients | Twelve months | Matches the protocol's own promise |
| Anything referenced in customer prompts | Twelve months, plus usage telemetry | You can't see their prompts |
The last row is the one people underestimate. A customer who built a workflow around search_orders has it in their automation, their saved transcripts, and possibly their own system prompt. Removing it after a quarter produces a bug report that reads like a mystery, because from their side nothing changed.
The cached prompt you can't update
There's a category of consumer you have no migration path for: the prompt embedded in a client you don't control, containing an example tool call by name.
When the user asks about an order, call search_orders with the order number.
No deprecation notice reaches that string. The model will keep calling search_orders until someone edits the prompt. Your only defenses are to keep the tool alive longer than you'd like, and to return an error the model can act on when it eventually disappears.
That error matters more than it seems. Compare:
Error: tool not found— the model has no recovery path, so it reports failure to the user.Unknown tool 'search_orders'. This tool was replaced by 'orders_search', which takes the same arguments.— the model retries correctly on the next turn.
One is a support ticket; the other is a self-healing migration. The cost of the second is a lookup table of retired names, which is a dozen lines and a monotonically growing JSON file.
RETIRED = {
"search_orders": {
"replacement": "orders_search",
"note": "same arguments; renamed 2026-09-01",
"removed": "2027-03-01",
},
}
async def handle_unknown_tool(name: str) -> dict:
retired = RETIRED.get(name)
if retired:
raise ToolError(
"tool_renamed",
message=(f"'{name}' was replaced by '{retired['replacement']}'. "
f"{retired['note']}"),
hint=f"call {retired['replacement']} with the same arguments",
retryable=True,
)
raise ToolError("unknown_tool", message=f"No tool named '{name}'.")
Keep the table after removal. It's the closest thing you have to a forwarding address for a rename, and it's the difference between an agent that recovers and one that gives up.
Testing that a rename didn't break anyone
You can't test a model's memory, but you can test the surface your server publishes. A manifest snapshot is cheap and catches accidental drift:
import json
def test_tool_surface_matches_manifest(server, tmp_path):
manifest = json.loads(open("mcp/tool_manifest.json").read())
current = {
tool.name: {
"required": sorted(tool.input_schema.get("required", [])),
"properties": sorted(tool.input_schema.get("properties", {})),
}
for tool in server.list_tools()
}
removed = set(manifest) - set(current)
assert not removed, f"tools removed without a deprecation window: {removed}"
for name, spec in manifest.items():
if name not in current:
continue
assert current[name]["required"] == spec["required"], (
f"{name}: required arguments changed"
)
added = set(current[name]["properties"]) - set(spec["properties"])
assert not added or spec.get("allow_additions"), (
f"{name}: new properties {added} need a manifest update"
)
The assertion that earns its keep is the first one. Renames show up as a removal plus an addition, and the removal fires the failure. If the PR genuinely intends to remove a tool, updating the manifest is the deliberate act that records the decision.
Add a second test that every deprecated tool has a prefer pointer in its description and a removal date in the future. It's a lint, and it prevents the most common form of deprecation debt: a tool marked deprecated in 2026 with no plan.
Don't ship a protocol upgrade and a tool migration together
These are two independent changes with different failure modes, and combining them makes diagnosis nearly impossible. If a call fails after a deploy that both moved to the stateless spec and renamed three tools, you have to determine whether the request was rejected for its version, its _meta, its headers, or the tool name — from logs that may only record one of those.
Sequence them instead. First, move to the protocol version with an unchanged tool catalog and run both protocol paths if any client still speaks the old one. That change is mechanical: requests carry _meta, the handshake goes away, list results gain ttlMs and cacheScope, and server/discover starts answering. Once the fleet is stable, do the tool migration against a stable transport.
The reverse order has one advantage — you only ask clients to update once — and it's rarely worth it. Protocol errors and unknown-tool errors look similar from the model's side (the call failed), so a combined rollout produces a single indistinguishable symptom for two very different bugs.
There's a practical wrinkle for the protocol step: during the transition, some pods may speak 2026-07-28 and some may still expect the handshake. The stateless spec's detection mechanics differ per transport, and a mixed fleet can produce intermittent failures that resolve themselves as pods roll. If you can't do a blue-green deploy, keep the old protocol handler in place until the rollout completes rather than relying on retries to paper over the window.
A release checklist for surface changes
- Does any tool name change? If yes, both names exist in the same release, and the old one forwards.
- Does any argument change units or meaning? If yes, new name.
- Did
inputSchemagain a required property? That's a breaking change for cached clients. - Did
outputSchemagain a required property or narrow a type? Clients validating output will reject responses. - Is
ttlMsconsistent with the change you're shipping? A long TTL plus a removal is an outage with a delay fuse. - Will a client that hasn't opted into
subscriptions/listenstill recover? It recovers at TTL expiry, if the old tool is still there. - Is there a metric for old-tool call volume? Without it, you're guessing when removal is safe.
- Does the deprecated tool's description say what to use instead and until when? The model reads that line; a changelog entry is invisible to it.
- Does
server/discoverstill report the protocol versions you actually support? It's mandatory to implement, and it's the first thing a new client may probe.
The uncomfortable truth about MCP versioning is that your most important consumer has no compiler, no type checker, and no memory of your release notes. It has a catalog, a prompt, and a conversation. Versioning well means treating all three as production dependencies — and accepting that the cheapest migration is the rename you decide not to make.
The tool catalog is the part of your server that other people's systems depend on, and it's the part most likely to be edited casually because a rename looks like tidying. Give it the review weight of a public API, and the rest of the versioning problem becomes a matter of discipline rather than recovery.
If you take one operational habit from this, make it the retired-names table. It costs nothing today, it turns the worst case into a recoverable error, and it's the only part of your versioning strategy that still works on the clients you'll never be able to update.