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:

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:

  1. 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.
  2. 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.
  3. Watch the call rate. Per-tool call counts, from Observability and operations, tell you when the old tool has gone quiet.
  4. 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/list result carries ttlMs, how many milliseconds the client may treat it as fresh, and cacheScope, "public" or "private". The official Python SDK defaults to ttlMs: 0 and "private", meaning re-fetch every time. MCPServer("library", cache_hints={"tools/list": CacheHint(ttl_ms=300_000, scope="public")}) (with from mcp.server.caching import CacheHint) made our test server return ttlMs 300000, 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/listen request 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/list is 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.

Back to Building and maintaining MCP servers