A published MCP tool name plus its input schema is a contract, and the party holding the other end of it is a language model that may be working from a copy of your schema minutes or hours old. Additive optional fields with defaults are safe to edit in place. Anything that adds a required field, renames or removes a field, narrows an enum, or changes what an existing field means needs a second tool name, a deprecation window, and a way to observe who is still calling the old one. This post covers how to tell those cases apart and how to ship each one.
Key takeaways
- Additive and optional is the only class of change you can make in place. Required fields, renames, removals and narrowed enums are breaking, even when your server accepts them without an error.
- The
protocolVersionnegotiated atinitializeversions MCP itself, not your tool schemas. Your schema version belongs in the tool name. - Clients cache
tools/list. If you do not declare thelistChangedcapability and emitnotifications/tools/list_changed, a live session keeps generating calls against the old schema. - Even a notified client can keep sending old arguments, because tool definitions sit in a cached prompt prefix that a long conversation does not rewrite.
- A single tool that normalises both argument shapes usually beats a second tool name, unless the change alters how the caller should think about the operation.
- Before you remove anything, log arguments per tool per client and wait for a period of zero non-canary traffic. Silence in your dashboards is the only safe removal signal.
Which tool changes break a running agent
A change is breaking if a model that memorised yesterday's schema can produce arguments your handler now mishandles. That is a wider set than "arguments your validator now rejects", because a rejected call is the good outcome. The dangerous class is the change that still validates and quietly means something else.
| Change | Verdict | How to ship it |
|---|---|---|
| Add an optional field with a default | Safe | Edit in place |
| Widen an enum, raise a maximum | Safe | Edit in place |
| Add a required field | Breaking | Optional with a default, or a new tool name |
| Rename a field | Breaking | Accept both keys, normalise in one handler |
| Remove a field | Breaking | Keep it, ignore it, warn in the result |
| Narrow an enum, tighten a pattern or limit | Breaking | New tool name, or a recoverable error listing valid values |
| Change the meaning of an existing field | Breaking and invisible | New field name, never reuse the old one |
| Change the result shape | Breaking downstream | Version the tool, or add fields without removing any |
| Rewrite the description | Behavioural | Re-run your tool selection evals |
The row worth staring at is the meaning change. If limit meant results per page and now means total results across pages, every existing call type checks and returns the wrong volume of data. Pick a new field name (page_size) and treat the old one as frozen forever.
The description rewrite row matters more than it looks, because descriptions are what drive selection. A model chooses tools from names and descriptions before it ever sees your handler, so a wording change is a behavioural deploy. That failure mode is covered in more depth in why your agent never calls your MCP tool.
Version the tool name, not the protocol
Put your schema version in the tool name, because nothing else in MCP is visible to the model. MCP has its own version string, negotiated when the client sends initialize with a protocolVersion such as 2025-06-18 and the server replies with the version it will speak, per the lifecycle specification. Those date-based revisions version the wire format and capability set, and the versioning page is explicit that a new revision is issued only for backwards incompatible changes to the protocol. That machinery says nothing about the shape of search_orders.
Two related mistakes follow from confusing the two. The first is bumping your server's version field in serverInfo and expecting a client to adapt. The model never sees it. The second is branching handler behaviour on the negotiated protocol version, or on the MCP-Protocol-Version header that HTTP clients send after initialization (described in the transports specification). That header tells you which MCP revision the client speaks, not which generation of your schema its context happens to contain.
How to ship v2 alongside v1
Register both tools in the same server, mark the old one deprecated in its description, and route both handlers to one internal function. With the TypeScript SDK, registerTool returns a handle you can use to update or disable the tool later, and those handle methods emit the list changed notification for you.
// v1 keeps working. Its description tells the model where to go instead.
const v1 = server.registerTool(
"search_orders",
{
description:
"DEPRECATED, prefer search_orders_v2. Search orders by customer email.",
inputSchema: { email: z.string().email() },
},
async ({ email }) =>
runSearch({ filters: [{ field: "email", op: "eq", value: email }] })
);
server.registerTool(
"search_orders_v2",
{
description:
"Search orders by email, order number or SKU. Replaces search_orders.",
inputSchema: {
filters: z
.array(
z.object({
field: z.enum(["email", "order_number", "sku"]),
op: z.enum(["eq", "contains"]),
value: z.string().min(1),
})
)
.min(1),
page_size: z.number().int().min(1).max(100).default(20),
},
},
async (args) => runSearch(args)
);
// Later, when v1 traffic hits zero. Emits notifications/tools/list_changed.
// v1.disable();
Three details make this work in practice. Both handlers call runSearch, so behaviour cannot drift between them. The v1 description leads with the deprecation so it survives truncation in clients that trim long descriptions. And page_size is a new name rather than a redefinition of limit. The TypeScript SDK and Python SDK both expose equivalent registration and notification paths.
Why clients miss your new schema
Clients cache the tool list, so a deploy alone does not update them. Under the tools specification, a server that wants to change its tool list at runtime declares the listChanged capability during initialization and then sends notifications/tools/list_changed, at which point clients are expected to re-fetch tools/list. Skip the capability declaration and your notification is out of contract, so a well behaved client is entitled to ignore it and keep the list it fetched at session start.
Two structural cases break even correct notification. A stateless HTTP deployment has no open stream to push a server-initiated notification onto, so changes land only at the next initialization. If that is your topology, plan for tool list updates being tied to new sessions and read where MCP server state lives without sessions. The other case is a gateway, registry or aggregator between you and the client, which may hold its own cached copy on its own refresh interval.
The subtler failure survives all of this. Once a client has injected your tool definitions into a conversation, those definitions sit near the front of the prompt for the rest of it. A mid-conversation re-fetch does not retroactively rewrite the turns the model has already seen, and few clients rebuild an in-flight prompt on a list change. Assume old-shape arguments will arrive for as long as your longest running session, which for a coding agent can be hours.
When one tool should accept both shapes
Prefer a single tool that normalises both argument shapes when the change is a field-level migration and the operation still means the same thing. You keep one entry in the tool list, one handler, and one place where compatibility logic lives. Here it is with FastMCP and a Pydantic validator.
class Filter(BaseModel):
field: Literal["email", "order_number", "sku"]
op: Literal["eq", "contains"] = "eq"
value: str
class OrderQuery(BaseModel):
filters: list[Filter] = Field(default_factory=list)
email: str | None = Field(
default=None, description="Deprecated, use filters instead."
)
page_size: int = Field(default=20, ge=1, le=100)
@model_validator(mode="after")
def normalise(self):
if self.email and not self.filters:
self.filters = [Filter(field="email", value=self.email)]
self.email = None
if not self.filters:
raise ValueError(
"filters is required, for example "
'[{"field": "email", "op": "eq", "value": "[email protected]"}]. '
"The email argument was removed."
)
return self
@mcp.tool()
def search_orders(query: OrderQuery) -> SearchResult:
metrics.increment("search_orders.legacy_email" if query.email else "search_orders.v2")
return run_search(query)
The error string is the part that pays for itself. A model that receives "filters is required" plus a valid example usually retries correctly on the next turn, while a bare schema validation dump often produces another malformed call. That pattern is worth applying to every tool, not only migrating ones, and is covered in how to return MCP tool errors agents can recover from.
Reach for two tool names instead when the change alters the caller's mental model: a synchronous tool becoming a job submitter that returns an ID, a single-record fetch becoming a paginated cursor walk, or a read-only query gaining write side effects. In those cases a normalising shim hides a difference the model needs to see.
What tool churn costs in cache and context
Every tool edit has a token bill attached, because tool definitions are serialised into the prompt ahead of the conversation. Anthropic's prompt caching documentation describes the cache as a prefix match over that prompt, with tools sitting at the front of the cacheable block, so modifying a tool definition invalidates the cached prefix from that point onward. A schema tweak deployed at midday therefore charges full input price on the next turn of every warm conversation. If prompt caching is load bearing for your cost model, batch schema changes into scheduled windows rather than trickling them out, and see how to cut LLM API costs with prompt caching for the surrounding mechanics.
The second cost is standing context. A duplicated tool is a duplicated JSON Schema in every request for the length of the deprecation window, and Anthropic's tool use overview notes that tool definitions count toward input tokens like any other content. On a server with a dozen tools that is noise. On a server with sixty, running two generations of several tools at once measurably degrades selection accuracy and inflates every call. That is the same pressure discussed in fixing MCP tool bloat, which is why I cap concurrent deprecations rather than letting them accumulate.
How to test a schema change before agents hit it
Test three separate things, because a schema change can pass one layer and fail the next. First, a contract test that replays production arguments. Log every tools/call argument payload with the tool name and client identity, sample a few hundred, and validate them against the candidate schema in CI. Anything that now fails is a call some agent is currently making.
@pytest.mark.parametrize("payload", load_sampled_args("search_orders", n=300))
def test_recorded_args_still_accepted(payload):
# Old-shape calls must normalise, not raise, during the window.
assert OrderQuery.model_validate(payload).filters
Second, a task level eval. Schema validity does not tell you whether the model picks the tool and fills it correctly, so run a fixed set of tasks against the old and new tool definitions and compare success rate and calls per task. Description and parameter naming changes show up here and nowhere else. See how to run evals on your LLM app with Promptfoo for a harness, and measuring AI agent reliability for why a single pass rate is a weak signal on tool calling.
Third, a canary. Expose the new tool to one client or workspace before all of them, and keep a per-tool, per-client counter for legacy shape usage. Removal is a data decision: when the legacy counter has been zero for longer than your longest session plus a margin (two weeks is a reasonable default for internal agents), disable the old tool and emit the list changed notification. Deleting on a calendar date instead produces exactly the class of failure this whole exercise exists to avoid.
Trade-offs and what I would ship
The genuine tension is between tool list size and handler clarity. Dual tool names keep each handler clean and make the change legible to the model, at the cost of doubled schema tokens, a wider selection surface, and cache invalidation on every registration change. A normalising single tool keeps the list small and the context stable, at the cost of a handler that carries compatibility branches for months and a schema that advertises fields you want gone.
My default: normalise inside one tool for field-level migrations (renames, removals, added requirements), and split into a new name only when the operation's semantics change. Cap concurrent deprecations at two across the whole server, which forces you to finish migrations instead of collecting suffixes. Never ship _v3. If you have reached that point, the tool boundary is wrong, not the version.
Two situations override this. On a server under roughly fifteen tools with a small set of known clients, dual names are cheap and clearer, so take them. On a server past forty tools, or any public server with clients you cannot enumerate, treat the schema as effectively immutable: additive only, permanently, with breaking work done behind a new tool that earns its slot on merit while the old one stays until traffic dies.
Sources
- MCP specification, Tools (listChanged capability and notifications/tools/list_changed)
- MCP specification, Lifecycle (initialize and capability negotiation)
- MCP specification, Versioning
- MCP specification, Transports (MCP-Protocol-Version header)
- Anthropic documentation, Prompt caching
- Anthropic documentation, Tool use overview
- MCP TypeScript SDK
- MCP Python SDK