technique

Version negotiation

How 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.

Before this

This page assumes you are comfortable with:

Why you need this

The host and the server are usually written by different people and updated on different schedules. A host released this month will meet servers that were last touched a year ago, and a server you ship today will be called by hosts that have not updated. Version negotiation is how the two sides find a protocol revision they both speak, or fail with a clear message instead of a confusing one.

The idea

What the date means

MCP names each protocol revision by a date, such as 2026-07-28. Per the specification's versioning policy, the date is the last day a backwards-incompatible change was made. Additive changes can land in the current revision without changing its name. So a version string is a promise about compatibility: two implementations that name the same revision can talk, even if one picked up later additive changes. (See API contracts and versioning for additive versus breaking.)

The specification sorts implementations into two eras:

Term Revisions How the version is chosen
Modern 2026-07-28 and later Every request names its version. No handshake.
Legacy 2025-11-25 and earlier An initialize handshake picks one version for the whole connection.
Dual-era Both An implementation that supports modern and legacy versions.

Per request, not per connection

In the modern era there is no negotiation step. Every request carries its version in _meta under io.modelcontextprotocol/protocolVersion (see Anatomy of an MCP request), and the server accepts or rejects each request on its own.

On the Streamable HTTP transport the same value also travels in an HTTP header, MCP-Protocol-Version: 2026-07-28, so load balancers and gateways can route on it without reading the body. The header and the body must agree. If they do not, the server answers 400 Bad Request with error -32020 (header mismatch), because a proxy reading the header and a server reading the body would otherwise act on different versions.

The error that tells you what to do

If a server does not support the requested version, it answers with UnsupportedProtocolVersionError: code -32022, with the versions it does support in data.supported and the one you asked for in data.requested. On HTTP this comes with status 400. The client picks a version from supported that it also speaks and sends the request again. If there is none, the client tells the person, naming both lists.

That makes negotiation lazy: a client can send its real request straight away and only pay for a retry when it guessed wrong.

server/discover up front

A client that would rather know first can call server/discover, which every modern server must implement. Its result includes supportedVersions, so the client can pick a version before sending anything else. It is optional for modern servers. It becomes important when the server might be legacy.

Meeting a legacy server

A legacy server expects initialize as the first message, and before that it may answer anything else with an arbitrary error, or not answer at all. The specification gives each transport a way to tell the eras apart.

  • stdio: a dual-era client sends server/discover first, at its preferred modern version. If it gets a discover result, the server is modern. If it gets a recognized modern error such as -32022, the server is modern but wants another version: retry with one from supported, and do not fall back. Any other error, or no answer within a reasonable timeout, means the server is legacy: fall back to initialize. The fallback must not be keyed to one specific error code, since legacy servers disagree on which code they send (commonly -32601 or -32602).
  • Streamable HTTP: the client sends a normal modern request. If it gets 400 Bad Request, it reads the body first: a recognized modern error means retry or fix the request; an empty body or anything else means fall back to initialize.

Either way, the era is a property of the server, so a client remembers the verdict for the life of that server process (stdio) or origin (HTTP), instead of probing before every request.

A client that speaks only the modern era cannot fall back. It still benefits from probing on stdio: some legacy servers do not check that initialize came first and would run a tools/call under old rules. A probe turns that into a clean, early failure.

Meeting a legacy client

A legacy client sends initialize and nothing else will make sense to it. A dual-era server answers initialize and then serves that connection (or, on HTTP, that session) under the old revision's rules. It may serve modern requests on the same endpoint at the same time, each one statelessly. A modern-only server should reject initialize with an error that names the versions it supports, because a legacy client has no way to move forward and that message may be the only clue a person sees.

Worked example

A 2026 client meets a server that only knows 2025-11-25, over stdio.

The client uses the official Python SDK (mcp 2.2.0). Its Client class takes a mode: "auto" probes and falls back, a version string such as "2026-07-28" pins that version with no probe, and "legacy" goes straight to initialize.

"""Connect to a server and report which protocol revision was chosen.

Usage: python auto_client.py <server.py> [mode]   (mode defaults to "auto")
"""
import asyncio
import sys

from mcp import Client, StdioServerParameters


async def main(server_file: str, mode: str) -> None:
    params = StdioServerParameters(command=sys.executable, args=[server_file])
    async with Client(params, mode=mode) as client:
        print("protocol version in use:", client.protocol_version)
        tools = await client.list_tools()
        print("tools:", [t.name for t in tools.tools])
        result = await client.call_tool("get_forecast", {"city": "Oslo", "days": 2})
        print("result:", result.content[0].text)


asyncio.run(main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "auto"))

The server is a stand-in written for this page, so you can see exactly what a legacy server does. It speaks the 2025-11-25 rules: initialize first, results without resultType, and "Method not found" for anything it does not know. It logs every message it receives and sends to stderr, which the client passes through to your terminal.

"""A stand-in for a server that only knows the 2025-11-25 revision.

Hand-written so the page can show exactly what a legacy server does with a
modern request. It speaks newline-delimited JSON-RPC on stdin/stdout and
logs every message it sees and sends to stderr.
"""
import json
import sys

initialized = False


def log(direction, msg):
    print(f"{direction} {json.dumps(msg)}", file=sys.stderr, flush=True)


def send(msg):
    log("server ->", msg)
    sys.stdout.write(json.dumps(msg) + "\n")
    sys.stdout.flush()


TOOLS = [{
    "name": "get_forecast",
    "description": "Daily weather forecast for a city, up to 7 days ahead.",
    "inputSchema": {
        "type": "object",
        "properties": {"city": {"type": "string"}, "days": {"type": "integer"}},
        "required": ["city", "days"],
    },
}]

for line in sys.stdin:
    msg = json.loads(line)
    log("server <-", msg)
    method, mid = msg.get("method"), msg.get("id")
    if method == "initialize":
        initialized = True
        send({"jsonrpc": "2.0", "id": mid, "result": {
            "protocolVersion": "2025-11-25",
            "capabilities": {"tools": {}},
            "serverInfo": {"name": "old-forecast", "version": "1.4.0"},
        }})
    elif mid is None:
        continue  # notifications, such as notifications/initialized
    elif method == "tools/list" and initialized:
        send({"jsonrpc": "2.0", "id": mid, "result": {"tools": TOOLS}})
    elif method == "tools/call" and initialized:
        args = msg["params"]["arguments"]
        text = f"{args['city']}: sunny for {args['days']} days"
        send({"jsonrpc": "2.0", "id": mid, "result": {"content": [{"type": "text", "text": text}]}})
    else:
        send({"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": "Method not found"}})

Run python auto_client.py legacy_server.py. It prints:

server <- {"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": {"name": "mcp", "version": "0.1.0"}, "io.modelcontextprotocol/clientCapabilities": {}}}}
server -> {"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}}
server <- {"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "mcp", "version": "0.1.0"}, "_meta": {}}}
server -> {"jsonrpc": "2.0", "id": 2, "result": {"protocolVersion": "2025-11-25", "capabilities": {"tools": {}}, "serverInfo": {"name": "old-forecast", "version": "1.4.0"}}}
server <- {"jsonrpc": "2.0", "method": "notifications/initialized"}
server <- {"jsonrpc": "2.0", "id": 3, "method": "tools/list", "params": {"_meta": {}}}
server -> {"jsonrpc": "2.0", "id": 3, "result": {"tools": [{"name": "get_forecast", "description": "Daily weather forecast for a city, up to 7 days ahead.", "inputSchema": {"type": "object", "properties": {"city": {"type": "string"}, "days": {"type": "integer"}}, "required": ["city", "days"]}}]}}
server <- {"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "get_forecast", "arguments": {"city": "Oslo", "days": 2}, "_meta": {}}}
server -> {"jsonrpc": "2.0", "id": 4, "result": {"content": [{"type": "text", "text": "Oslo: sunny for 2 days"}]}}
protocol version in use: 2025-11-25
tools: ['get_forecast']
result: Oslo: sunny for 2 days

Message by message:

# Direction Message What it means
1 client to server server/discover at 2026-07-28 The probe. A modern server would answer with its versions.
2 server to client error -32601 Not a discover result and not a recognized modern error, so the client concludes: legacy.
3 client to server initialize with protocolVersion: "2025-11-25" The fallback. The client offers the newest legacy revision it knows.
4 server to client result with protocolVersion: "2025-11-25" The server agrees. In the legacy era, this one exchange fixes the version for the whole connection.
5 client to server notifications/initialized Legacy rule: tell the server the handshake is done. No reply.
6 to 9 both tools/list, tools/call Legacy-style requests: no version in _meta, and results with no resultType, which the client reads as "complete".

The probe cost one extra round trip, once, and the client's own code above did not change: the same list_tools and call_tool calls worked in both eras.

Now pin the client to the modern revision, so it cannot fall back: python auto_client.py legacy_server.py 2026-07-28.

server <- {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": {"name": "mcp", "version": "0.1.0"}, "io.modelcontextprotocol/clientCapabilities": {}}}}
server -> {"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}}
protocol version in use: 2026-07-28
...
mcp.shared.exceptions.MCPError: Method not found

No probe was sent, so the first real request hit the legacy server and failed with "Method not found" for tools/list, a method the server does have. That misleading message is exactly what probing avoids.

For contrast, the same auto client against a modern server built with the SDK (the forecast server from Anatomy of an MCP request) stays modern after the probe:

protocol version in use: 2026-07-28
tools: ['get_forecast']
result: Oslo: sunny for 2 days (metric)

And when that modern server is sent a request naming a revision it does not know, here 2027-01-15, it answers:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version",
    "data": {
      "supported": [
        "2026-07-28"
      ],
      "requested": "2027-01-15"
    }
  }
}

The supported list names only 2026-07-28 even though this SDK server also answers initialize for older revisions: those are reached through the handshake, not by naming them per request. Sent initialize with protocolVersion: "2025-11-25", the same server replied with a 2025-11-25 initialize result, so a legacy client can still use it.

In a server's life

This is stage 1, speak the protocol, and it returns in stage 6, maintain it. When a new revision ships, a server that keeps answering the old one while adding the new one (dual-era) lets every host upgrade on its own schedule. The specification's deprecation policy, which keeps a deprecated feature for at least twelve months, gives you the window to do it.

Common mistakes

  • Falling back on one specific error code. A client that falls back only on -32601 hangs or fails against a legacy server that sends -32600 or nothing. Treat "anything that is not a discover result or a recognized modern error" as legacy, with a timeout.
  • Falling back on a modern error. A client that sees -32022 and switches to initialize downgrades a perfectly good modern server. Retry with a version from data.supported instead.
  • Header and body disagree on HTTP. A client updates the _meta version but not the MCP-Protocol-Version header. Symptom: every request returns 400 with -32020.
  • Probing on every request. The era does not change between requests. Symptom: double the round trips. Cache the verdict per process or origin.
  • A modern-only server that rejects initialize silently. The legacy client shows a generic connection error. Name your supported versions in the error message.
  • Reading supported as "every version this server can speak". As the worked example showed, a dual-era server may list only its modern revisions there and still accept initialize for older ones. Use the list to pick a modern version, not to decide that a legacy client is unwelcome.

Cost

On a modern server with a matching version, negotiation costs nothing: the version rides along in _meta. A wrong guess costs one extra round trip, once, after which the client knows a good version. A dual-era client talking to a legacy server pays one probe round trip, plus the timeout if the server never answers, then the initialize round trip, once per server process or origin. The larger cost is engineering: a dual-era server maintains two sets of behavior and two sets of tests until the old era is retired, which is the price of not breaking existing hosts.

Going further

  • The Versioning section of the 2026-07-28 specification, including its compatibility matrix of every client and server era.
  • The Backward Compatibility subsections of the stdio and Streamable HTTP transport sections.
  • Extension negotiation, the same per-request idea applied to optional features.
  • Evolving a server without breaking clients, for versioning your own tools rather than the protocol.

Leads to

Back to Building and maintaining MCP servers