MCP Server Versioning: Handling Breaking Changes Gracefully
Why MCP versioning breaks quietly, not loudly
Most engineers assume a breaking API change will announce itself. A client calls an endpoint, gets a 404 or a 400, someone opens a ticket, and the fix gets prioritized. MCP servers do not fail this way, and that is the trap.
When you change a tool's input schema on an MCP server, the failure mode is rarely an error. It's a silent behavior change. The agent still calls the tool. The tool still returns 200. But the LLM either misinterprets a renamed parameter, drops a required field it no longer recognizes, or hallucinates a value for a field that used to have a sensible default. You don't get a stack trace — you get a support ticket three days later saying "the assistant gave a wrong answer" with no reproducible steps.
This is the core problem with MCP server versioning: the consumer of your interface is not a compiler or a type checker. It's a language model reading a JSON schema and a natural-language description, deciding on the fly how to call your tool. Traditional semantic versioning discipline still applies, but the failure surface is different, the blast radius is harder to see, and the tooling to catch regressions is much less mature than what you'd expect from REST or gRPC.
If you're building or maintaining MCP servers that other teams, agents, or customers depend on, you need a versioning strategy that accounts for this. This article walks through what actually breaks, how to structure your server so changes are additive by default, and how to roll out real breaking changes without taking every downstream agent down with you.
What actually counts as a breaking change in MCP
Before you can version anything sensibly, you need a shared definition of "breaking." In REST APIs this is well understood — removing a field, changing a status code, renaming a route. In MCP, the surface area is different because the contract includes both the schema *and* the natural-language description that the model reads to decide how to use the tool.
Here's the practical list of changes that break MCP consumers, roughly ordered by how often teams miss them:
- Renaming a tool (e.g.
get_usertofetch_user). Any agent with a cached tool list, a fine-tuned routing prompt, or a hardcoded reference to the tool name will silently stop finding it. - Renaming or removing an input parameter. If
customer_idbecomescustomerIdoraccount_id, older clients that pass the old key either get a schema validation error or, worse, the model just omits the parameter and the server falls back to some default. - Changing a parameter's type. A
datefield that goes from an ISO string to a Unix timestamp will pass schema validation in some SDKs (both are technically strings or numbers) but produce wrong results. - Making an optional parameter required. This is the single most common accidental break. It looks like a minor tightening of validation, but it turns a working call into an error for every client that wasn't already passing that field.
- Changing the semantics of a return value without changing its shape. If
statusused to mean "HTTP-like state" and now means "workflow stage," the JSON schema looks identical, but every consumer that branches on that field is now wrong. - Rewriting the tool description. This sounds harmless because it's "just documentation," but the description is what the model uses to decide *when* to call the tool at all. Reword it badly and the model stops calling a tool it used to call correctly, or starts calling the wrong tool for a given task.
- Changing pagination or list-response defaults, like flipping default page size or the sort order of results. Nothing in the schema forces you to bump a version, but consumer behavior (and any downstream caching) can silently degrade.
Notice how many of these have zero effect on schema validation but full effect on model behavior. That's the crux of why you can't just borrow OpenAPI's breaking-change checklist wholesale — you need one that also treats prompt-adjacent surfaces (tool names, descriptions, parameter descriptions) as part of the contract.
Semantic versioning for MCP servers, adapted
Semantic versioning (MAJOR.MINOR.PATCH) still gives you the right skeleton. The adaptation is in what triggers each bump.
PATCH — no observable behavior change for any correctly-formed call. Bug fixes, performance improvements, internal refactors, typo fixes in descriptions that don't change meaning.
MINOR — additive, backward-compatible changes. New tools, new optional parameters with sensible defaults, new fields in a response object that old clients simply ignore, expanded enum values (if consumers are expected to handle unknown values gracefully — more on that below).
MAJOR — anything from the breaking-changes list above. Renamed tools or parameters, newly required parameters, type changes, semantic changes to existing fields, materially rewritten tool descriptions.
The part teams get wrong is treating "the server's version" as a single global number when in practice an MCP server exposes a *bundle* of independent tools. A change to search_orders shouldn't force every consumer of create_invoice to re-certify their integration. Two patterns help here:
- Version the server, not just individual tools, but keep a changelog that's tool-scoped so consumers can see at a glance whether a bump affects the tools they actually use.
- Namespace tool names with a version when you truly can't avoid a breaking change, e.g.
search_orders_v2alongsidesearch_orders, and deprecate the old one on a schedule (covered later).
Here's a minimal example of how you might structure server metadata to make this legible to both humans and tooling:
{
"name": "billing-mcp-server",
"version": "3.4.0",
"tools": [
{
"name": "create_invoice",
"version": "1.2.0",
"since": "2.0.0",
"deprecated": false
},
{
"name": "search_orders",
"version": "2.0.0",
"since": "1.0.0",
"deprecated": false,
"changelog": "v2.0.0: 'status' enum now includes 'partially_refunded'"
},
{
"name": "search_orders_v1",
"version": "1.4.0",
"since": "1.0.0",
"deprecated": true,
"sunset_date": "2026-10-01"
}
]
}This isn't part of the MCP spec itself, but nothing stops you from exposing this as a resource (mcp://billing-mcp-server/version-manifest) that agents or CI pipelines can read before wiring up to your server. Treat it the same way you'd treat an OpenAPI spec file — a machine-readable source of truth that your docs and your tests both derive from.
Designing tools to be additive by default
The cheapest way to avoid breaking changes is to make your initial design harder to break. A few concrete habits pay off repeatedly:
Prefer optional parameters with explicit defaults over required ones. If you think a field might someday become mandatory, ship it optional first with a clearly documented default, and only tighten it later behind a major version — never retroactively.
Use structured, extensible response objects instead of flat scalars. Returning { "status": "shipped" } is harder to extend safely than { "status": { "code": "shipped", "detail": "Left warehouse on 2026-07-01" } }. The second shape lets you add fields to status without anyone's branching logic on status.code breaking.
Treat enums as open sets unless you say otherwise. Document explicitly whether a client should expect the enum to grow ("unknown values should be handled gracefully, e.g. render as 'other'") or whether it's a closed, stable set. If you don't say, assume every consumer wrote a switch statement that will throw on an unrecognized value — because someone did.
Version your tool descriptions like code, not prose. Small wording tweaks to a description can change which tool an LLM picks in an ambiguous situation. Keep description changes in the same pull request as a version bump, and run a regression check (see the testing section below) rather than treating description edits as free-form copyediting.
Here's an example of a tool definition that's built to absorb future growth without a major bump:
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
from typing import Optional
mcp = FastMCP("billing-mcp-server")
class InvoiceQuery(BaseModel):
customer_id: str = Field(..., description="Customer identifier")
status: Optional[str] = Field(
default=None,
description=(
"Filter by invoice status. Known values: 'draft', 'sent', "
"'paid', 'overdue'. Treat unrecognized values as informational "
"only — do not branch on unknown statuses."
),
)
include_line_items: bool = Field(
default=False,
description="If true, include a 'line_items' array in each result.",
)
@mcp.tool()
def search_invoices(query: InvoiceQuery) -> dict:
"""
Search invoices for a customer, optionally filtered by status.
New optional fields may be added to both the request and each
invoice result in future minor versions. Clients should ignore
unrecognized fields rather than failing validation.
"""
results = billing_db.search(
customer_id=query.customer_id,
status=query.status,
)
invoices = [_serialize(inv, query.include_line_items) for inv in results]
return {"invoices": invoices, "count": len(invoices)}Notice the description explicitly tells the model (and any human reading the schema) what to do with values it doesn't recognize. That single sentence prevents a whole category of "minor version broke my client" bug reports.
Deprecation: give agents a runway, not a cliff
When a breaking change is genuinely unavoidable — you renamed a field because the old name was actively misleading, or a required parameter really does need a default users must consciously choose — don't do it in place. Run the old and new versions in parallel for a defined window.
A practical deprecation sequence:
- Ship the new behavior under a new tool name or a new major version, while keeping the old tool fully functional.
- Add a deprecation notice to the old tool's description, not just your changelog. The model reads tool descriptions at call time; a line like
"DEPRECATED: use search_invoices_v2 instead. This tool will be removed after 2026-11-01."is far more effective than a note in a README nobody's agent ever reads. - Emit a deprecation warning in the tool's response, not just an out-of-band log. Something like
{"_deprecated": true, "message": "This tool is deprecated, see search_invoices_v2", ...actual_data}means even a client that never reads your docs gets nudged every time it calls the tool. - Set and communicate an actual sunset date. Open-ended deprecations never die; they just accumulate. Pick a date, put it in the manifest, put it in the tool description, and hold to it.
- Remove it, and treat removal itself as a major version bump with its own changelog entry, even though you already bumped major when you introduced the replacement.
The reason step 2 and step 3 matter so much more in MCP than in a typical REST deprecation is that the "developer" reading your deprecation notice might genuinely be an LLM mid-conversation, not a human doing a scheduled dependency audit. If the only place you say "deprecated" is a CHANGELOG.md, an agent calling your tool directly from a live session will never see it. Put the warning where the model actually looks: the schema, the description, and the response payload.
Backward compatibility patterns that actually work
A few patterns consistently reduce breakage without requiring you to freeze your API forever:
Dual-read, single-write for renamed fields. If you're renaming customer_id to account_id, accept both keys on input for a full deprecation cycle, mapping customer_id to account_id internally and logging when the old key is used so you know when usage has dropped to zero.
def normalize_query(payload: dict) -> dict:
if "customer_id" in payload and "account_id" not in payload:
logger.warning("deprecated_field_used", field="customer_id")
payload["account_id"] = payload.pop("customer_id")
return payloadAdditive response fields, never repurposed ones. If a field's meaning needs to change, add a new field with a new name rather than redefining what an existing field returns. total_amount staying "total before tax" forever, with a new total_amount_with_tax added alongside it, costs you a slightly uglier schema in exchange for zero broken consumers.
Capability negotiation at connection time. MCP's initialization handshake is a natural place to advertise what a client supports. If your server can detect that a connecting client only understands an older tool set, you can choose to expose the legacy tool names for that session instead of forcing every client onto the newest surface simultaneously.
Contract tests driven by real transcripts. Don't just unit-test your schema — replay actual historical tool-call transcripts (the JSON your agents sent last month) against the new server version and assert the responses are structurally compatible. This catches the "technically valid but semantically different" class of break that pure schema validation misses.
# pseudo-CI step: replay recorded tool calls against the candidate build
mcp-replay --transcripts ./fixtures/tool-calls-2026-06.jsonl \
--server ./dist/billing-mcp-server \
--diff-mode structural \
--fail-on-breakNone of this is exotic — it's the same discipline that mature REST and gRPC teams already apply. The difference is you have to apply it to a slightly wider surface (descriptions, not just schemas) and assume your primary consumer can't file a bug report the way a human developer would.
Communicating changes to an audience that includes both humans and agents
Traditional changelogs are written for humans who read release notes before upgrading a dependency. MCP servers often get consumed by agents that connect, read the current tool list, and start calling things — with no human in the loop checking a changelog first. That means your communication channel has to work at two different speeds:
- For human maintainers integrating your server, a conventional CHANGELOG.md with semantic version entries is still valuable, especially for planning migrations and understanding *why* something changed.
- For agents connecting live, the version manifest resource and in-schema deprecation notices described earlier are the channel that actually gets read. Don't rely on a human reading a changelog and manually reconfiguring an agent — assume the agent needs to discover deprecation status itself, at connection time or at call time.
A good middle ground is exposing a get_server_info or list_capabilities tool (or an MCP resource) that returns the version manifest, so agents can be prompted to check it before relying on a tool that might be near end-of-life. It costs you one extra tool definition and saves you from a wave of confused calls three weeks after a removal you announced only in a GitHub release.
Testing strategy: catch breaks before your consumers do
Schema validation tests are necessary but not sufficient for MCP servers, because — as covered above — plenty of breaking changes pass schema validation cleanly. A more complete test suite includes:
- Schema diffing between the previous and candidate version of every tool, flagging any removed field, any newly required field, or any changed type automatically in CI.
- Description diffing with a required human sign-off. You can't fully automate "does this new description change model behavior," but you can at least force a reviewer to consciously approve any wording change to a shipped tool, rather than letting it slip through in an unrelated PR.
- Transcript replay, as shown above — the highest-signal test because it uses real historical calls, not synthetic ones you wrote to match your own mental model of how the tool gets used.
- A live model smoke test, where you actually run a small agent against a staging version of the server with a handful of realistic prompts and confirm it still picks the right tool and passes the right arguments. This catches the subtle case where a reworded description causes an LLM to route to a different tool entirely — something no amount of schema or transcript testing will surface, because the "old" transcript never had a chance to be wrong in the old world.
Budget for the model smoke test explicitly. It's the one category of regression that's genuinely unique to MCP versioning versus classic API versioning, and it's the one teams skip because it doesn't fit neatly into existing CI pipelines.
Rolling out a breaking change without an incident
When you've decided a major version bump is genuinely necessary, sequence the rollout like this:
- Ship the new tool or schema under a new name or major version, running alongside the old one.
- Update your version manifest and add deprecation notices to the old tool's description and response payload.
- Notify known integrators directly if you can identify them — API keys, registered webhooks, or a developer portal all give you a channel that's more reliable than "they'll read the changelog."
- Monitor call volume on the deprecated tool. Don't guess when usage has dropped — measure it.
- Announce a hard removal date once usage is near zero or a generous grace period has passed, and stick to that date.
- Remove the old tool, bump the major version again, and keep the deprecated version's schema in your test fixtures so you can verify the removal didn't silently break something you missed.
The teams that handle this well treat MCP tool contracts with the same seriousness as a public REST API — versioned, tested, deprecated on a schedule — while also respecting that their primary "reader" of the interface is a model interpreting natural language, not just a type checker. Get both halves right and breaking changes become routine maintenance instead of production incidents.
If you want to go deeper on building MCP servers that hold up in production — schema design, deprecation tooling, transcript-based regression testing, and real-world server architecture — our course Building & Integrating MCP Servers walks through all of it hands-on, from a first tool definition to a versioned, multi-client production deployment.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.