technique
Evolving a server without breaking clients
Changing tools, schemas, and resources after people depend on them: which changes are safe, how to deprecate, how to tell clients the list changed, and how to follow the spec's own revisions.
Before this
This page assumes you are comfortable with:
- techniqueVersion negotiationHow a client and server that speak different protocol revisions find one they share, per request, and how a 2026-era server still serves older handshake-based clients.
- techniqueDesigning toolsNaming, describing, and shaping a tool so a model picks it correctly and calls it with valid arguments: input and output schemas, structured content, and errors.
- techniqueTesting MCP serversHow to know a server works before a model touches it: unit tests for handlers, an in-process client for protocol tests, schema contract tests, and an interactive inspector.
Why you need this
The first version of a server is the easy one. After that, every change lands on clients you did not write and cannot update: scripts that call a tool by name, hosts that cached your tool list, models whose instructions mention a tool that no longer exists. Meanwhile the protocol itself changes under you. This is stage 6 of a server's life, "Maintain it".
The idea
A server lives under two contracts at once.
| Contract | Who sets it | What it covers | How it changes |
|---|---|---|---|
| Your server's | You | Tool names, argument names and types, output fields, descriptions, resource URIs, prompt names | Whenever you ship |
| The protocol's | The MCP specification | Message shapes, _meta keys, methods, features |
Date-stamped revisions such as 2026-07-28, negotiated per request; see Version negotiation |
An MCP server offers tools; a client connects to it for a host (the app a person uses), and the model (the language model inside the host) picks tools by reading their names and descriptions. "Old client" below means anything built against the server as it was: a script, a host with a cached tool list, or a saved prompt that names a tool.
Classifying a change to your server
| Change | Old client, new server | Kind |
|---|---|---|
| Add a tool | Never calls it | Safe |
| Add an optional argument with a default | Never sends it; the default applies | Safe |
| Add an output field | Ignores it, unless it rejects unknown fields | Safe for tolerant clients |
| Add an enum value | Never sends it, but may not understand it if it comes back | Gray |
| Reword a description | Nothing fails; the model may choose the tool more or less often | Gray |
| Add a required argument | Calls without it and is rejected | Breaking |
| Rename a tool or argument | Calls the old name and gets an error | Breaking |
| Change an output field's type | Parses the old type and fails or misreads | Breaking |
| Remove a tool | Still calls it | Breaking |
The other direction matters too: a client built for the new server may meet an old one, say a host talking to a copy you have not upgraded yet. Pick a change below to see both directions.
The gray zone is where MCP differs from an ordinary API. A description is code that a model executes. Changing "Convert units" to "Convert units of length only" breaks no schema, yet a model that used to call the tool for kilograms now stops. No test that checks schemas catches it; the snapshot from Testing MCP servers at least shows it in review, and evaluation with a real model measures it.
Breaking changes, done safely
You rarely need to make a breaking change in place. Instead:
- Add the new shape beside the old one. A new tool name, such as
find_book_by_author, with the new required argument. Old clients keep calling the old tool. - Deprecate the old one in its description. The description is the one thing every model reads: "Deprecated: use find_book_by_author. Kept until 2027-06-01." A model reading it has a reason to pick the replacement, and people reading the tool list see the date.
- Watch the call rate. Per-tool call counts, from Observability and operations, tell you when the old tool has gone quiet.
- Remove it after the date, with a list-changed notification, and update your snapshot.
Telling clients the list changed
Clients cache your tool list; when it changes, two mechanisms tell them.
- Cache hints. Every
tools/listresult carriesttlMs, how many milliseconds the client may treat it as fresh, andcacheScope,"public"or"private". The official Python SDK defaults tottlMs: 0and"private", meaning re-fetch every time.MCPServer("library", cache_hints={"tools/list": CacheHint(ttl_ms=300_000, scope="public")})(withfrom mcp.server.caching import CacheHint) made our test server returnttlMs300000, five minutes. A longer TTL saves requests and means slower pickup of changes. - List-changed notifications. A client that wants pushes opens a long-lived
subscriptions/listenrequest with"toolsListChanged": true. When your tools change, the server sends this on that stream, and the client re-fetches at once, whatever the TTL said:
{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed",
"params": {
"_meta": {
"io.modelcontextprotocol/subscriptionId": 1
}
}
}
The subscriptionId is the JSON-RPC id of the subscriptions/listen request that opened the stream. In the Python SDK, a handler calls await ctx.notify_tools_changed() to publish it. Older servers used separate subscribe methods and an HTTP GET stream, both removed in 2026-07-28.
Following the protocol's revisions
The specification has a formal feature lifecycle. Every feature is in one of three states:
| State | Meaning |
|---|---|
| Active | Part of the current revision. Implement it. |
| Deprecated | Still specified, scheduled for removal, with a documented migration path. Do not adopt it in new code; migrate existing code. |
| Removed | Gone from the next revision, still documented in the last revision that had it. |
A deprecation must last at least twelve months, counted from the release of the revision that first marks the feature Deprecated. The feature becomes eligible for removal in the first revision released on or after that point, its earliest removal; actual removal can come later. The window can be cut, to no less than ninety days, only for an active security risk with no fix in place. A deprecated features registry in the specification lists every feature on its way out, so you check one page instead of reading every changelog.
The 2026-07-28 revision deprecated three server-facing features at once:
| Deprecated | Migrate to | Earliest removal |
|---|---|---|
| Roots (the client telling the server which folders it may use) | Pass directories or files as tool arguments, resource URIs, or server configuration | First revision released on or after 2027-07-28 |
| Sampling (the server asking the client's model for a completion) | Call a model provider's API directly from the server | Same |
Logging (log lines sent to the client as notifications/message) |
stderr for stdio servers, OpenTelemetry for structured observability | Same |
Official SDKs mark deprecated features with the language's own mechanism. The Python SDK 2.2.0 tags them with @deprecated(..., category=MCPDeprecationWarning); for example, Context.info() and Context.log() carry "The logging capability is deprecated as of 2026-07-28 (SEP-2577)." Run your tests with warnings visible and they list what to migrate.
Worked example
A library server's find_book tool through three versions, run against a client written for version 1. That client sends only title and reads only isbn. The tools return fixed data.
"""Three versions of a book-lookup tool, and an old client calling each one."""
import asyncio
from typing import TypedDict
from mcp import Client
from mcp.server import MCPServer
class BookV1(TypedDict):
isbn: str
class BookV2(TypedDict):
isbn: str
year: int
# v1: the version clients were built against.
v1 = MCPServer("library")
@v1.tool()
def find_book(title: str) -> BookV1:
"""Look up a book by title and return its ISBN."""
return {"isbn": "978-0-00-000000-2"}
# v2: an optional argument and a new output field. Both additive.
v2 = MCPServer("library")
@v2.tool(name="find_book")
def find_book_v2(title: str, language: str = "en") -> BookV2:
"""Look up a book by title (in `language`, default English) and return its ISBN and year."""
return {"isbn": "978-0-00-000000-2", "year": 2019}
# v3, the breaking way: titles are ambiguous, so author becomes required.
v3_breaking = MCPServer("library")
@v3_breaking.tool(name="find_book")
def find_book_v3_breaking(title: str, author: str, language: str = "en") -> BookV2:
"""Look up a book by title and author and return its ISBN and year."""
return {"isbn": "978-0-00-000000-2", "year": 2019}
# v3, the compatible way: keep the old tool, add a new one beside it.
v3 = MCPServer("library")
@v3.tool(name="find_book")
def find_book_v3_old(title: str, language: str = "en") -> BookV2:
"""Deprecated: use find_book_by_author, which is exact. Kept until 2027-06-01."""
return {"isbn": "978-0-00-000000-2", "year": 2019}
@v3.tool()
def find_book_by_author(title: str, author: str, language: str = "en") -> BookV2:
"""Look up a book by title and author (in `language`, default English) and return its ISBN and year."""
return {"isbn": "978-0-00-000000-2", "year": 2019}
async def old_client(label: str, server: MCPServer) -> None:
"""A client written against v1: sends only title, reads only isbn."""
async with Client(server) as client:
r = await client.call_tool("find_book", {"title": "Night Garden"})
if r.is_error:
print(f"{label:13} ERROR {r.content[0].text.splitlines()[0]}")
else:
print(f"{label:13} isbn={r.structured_content['isbn']}")
async def main():
for label, server in [("v1", v1), ("v2", v2), ("v3 breaking", v3_breaking), ("v3", v3)]:
await old_client(label, server)
async with Client(v1) as client: # a v2-era client meets a v1 server
r = await client.call_tool("find_book", {"title": "Night Garden", "language": "de"})
print(f"{'v1, language':13} is_error={r.is_error} {r.structured_content}")
async with Client(v3) as client:
for tool in (await client.list_tools()).tools:
print(f"v3 lists {tool.name}: required={tool.input_schema['required']}")
asyncio.run(main())
python book_versions.py printed the lines below. The first comes from the server's log on stderr, which appeared before the buffered stdout lines.
Tool 'find_book' rejected arguments: ['author']
v1 isbn=978-0-00-000000-2
v2 isbn=978-0-00-000000-2
v3 breaking ERROR Error executing tool find_book: 1 validation error for find_book_v3_breakingArguments
v3 isbn=978-0-00-000000-2
v1, language is_error=False {'isbn': '978-0-00-000000-2'}
v3 lists find_book: required=['title']
v3 lists find_book_by_author: required=['title', 'author']
Each step, classified:
| Step | Change | Kind | What the run showed |
|---|---|---|---|
| v1 to v2 | Optional language with a default |
Safe | The old client still works |
| v1 to v2 | New output field year |
Safe for this client | It reads only isbn |
| v1 to v2, reversed | A v2-era client sends language: "de" to a v1 server |
Degrades | No error, but the v1 server ignored the argument and answered as if for English |
| v2 to v3, breaking | author becomes required on find_book |
Breaking | The old client's call was rejected |
| v2 to v3, compatible | New tool find_book_by_author; find_book kept, marked deprecated |
Safe | The old client still works, and the list shows both tools |
The "reversed" row is the easiest to miss: nothing failed, and the answer was quietly wrong.
Renaming instead of adding would also break: with this SDK, calling a tool name the server does not have returned a tool result with isError: true and the text Unknown tool: <name>. The Tools section of the specification lists an unknown tool as a protocol error (code -32602), so other servers may answer with a JSON-RPC error instead. A client should handle both.
In a server's life
- Maintain it (stage 6) is this page. Every change starts with the classification table above.
- Test and ship (stage 5): the snapshot test of
tools/listis the gate that makes you classify each diff before it ships. - Speak the protocol (stage 1): protocol revisions arrive through version negotiation; the feature lifecycle tells you how long you have to move.
Common mistakes
- Renaming for tidiness. Symptom: every existing caller fails with an unknown-tool error the day you ship. Add the new name and deprecate the old one.
- Making an optional argument required. Symptom: old callers rejected with a validation error. Keep a default, or add a new tool.
- Rewording descriptions with no evaluation. Symptom: a tool's call rate drops sharply after a "harmless" copy edit. Treat description changes as behavior changes and measure them.
- Long TTL and no list-changed notifications. Symptom: clients keep calling a removed tool for minutes after deploy. Pair a TTL with notifications, or keep the TTL short.
- Removing a deprecated tool on schedule without checking use. Symptom: a quiet but important client breaks. Check the per-tool call rate first.
- Adopting a deprecated protocol feature in new code. Symptom: rework once its earliest removal arrives. Check the deprecated features registry before building on Roots, Sampling, or Logging.
Cost
Keeping an old tool beside its replacement costs a little code and, more importantly, tokens: every tool definition is sent to the model on every turn, so a deprecated tool costs its full definition in input tokens on every request until you remove it. That is the argument for a firm removal date. A list-changed notification costs one small message per change per listening client, plus one tools/list re-fetch from each. A twelve-month protocol deprecation window gives you time, but migration is still engineering work; budget for it when a revision is published, not when the removal lands.
Going further
- The deprecated features registry and the feature lifecycle policy in the MCP specification.
- The Subscriptions and Caching sections of the 2026-07-28 specification.
- Semantic versioning, and why MCP's own revisions use dates instead.
- Contract testing between services, the general form of the snapshot test.
- Measuring tool choice with an evaluation set before and after a description change.