If you run Model Context Protocol servers in production, versioning is not one decision. The protocol has its own date-stamped revisions. The SDK you build on has its own releases. And your tools, with their names, input schemas and behaviour, form an API that agents and their prompts depend on. Each of these changes on its own schedule, and the hardest incidents come from mixing them up, for example by upgrading an SDK and changing the wire protocol without noticing.
The 2026-07-28 revision makes this concrete. It removed the initialize handshake and moved the protocol version onto every request, so a fleet now contains modern and legacy peers at the same time. This article gives you a strategy for that world: what the spec now says, how to build dual-era servers and clients, how to set your own support window, and how to evolve your tool surface without breaking agents. The legacy handshake mechanics are covered in MCP versioning architecture. Everything here about the protocol comes from the published specification at modelcontextprotocol.io. Check it again before you ship, because the draft keeps moving.
Three version axes, kept apart
Protocol revision. MCP versions are strings in the form YYYY-MM-DD. The date marks the last time backwards-incompatible changes were made. The spec says the version is not incremented for backwards-compatible updates, so the current revision can still gain compatible changes after release. Revisions are marked Draft (not ready), Current (ready, may get compatible changes) or Final (frozen). At the time of writing the current revision is 2026-07-28. Earlier published revisions are 2024-11-05, 2025-03-26, 2025-06-18 and 2025-11-25.
SDK release. SDKs have their own version numbers and their own policy on which revisions they implement; the spec says removal from the spec does not oblige an SDK to drop a feature. Pin SDK versions and treat an upgrade that adds a protocol revision as a protocol change.
Your tool surface. MCP has no version field for an individual tool. If you rename an argument, the protocol cannot tell anyone. Your tools are a public API, and you have to version them with conventions of your own, described below.
What the 2026-07-28 revision changed about negotiation
Under the legacy revisions (2025-11-25 and earlier), the client sent initialize with a protocolVersion. The server answered with the same version if it supported it, or with another version it supported, and the client disconnected if it could not accept the answer. The agreed version then applied to the whole session. From 2025-06-18 on, HTTP clients also repeated it in an MCP-Protocol-Version header.
The 2026-07-28 revision removes the handshake and protocol-level sessions. Every request declares its version in _meta under io.modelcontextprotocol/protocolVersion, along with io.modelcontextprotocol/clientCapabilities and, as a SHOULD, io.modelcontextprotocol/clientInfo. The server accepts or rejects each request on its own. If it does not implement the requested version, it MUST answer with UnsupportedProtocolVersionError, code -32022, whose data carries a supported list and the requested value. The client then retries with a version from that list or reports the incompatibility.
Servers MUST also implement server/discover, which returns supportedVersions, capabilities and server identity in one call. Clients may call it first to pick a version up front, but they do not have to. Sending a normal request and handling the error is equally valid.
On Streamable HTTP, every POST MUST carry MCP-Protocol-Version, and it MUST equal the version in the body's _meta. A mismatch is rejected with 400 and HeaderMismatch (-32020). An unsupported version is rejected with 400 and the -32022 error. The revision also adds Mcp-Method and Mcp-Name headers, so gateways and load balancers can route on the version and the tool name without parsing JSON. Because headers and body must agree, an intermediary cannot be tricked into routing on one value while the server executes another.
Eras: modern, legacy and dual-era
The spec gives you the vocabulary to plan with. Modern means per-request metadata (2026-07-28 and later). Legacy means an initialize handshake (2025-11-25 and earlier). Dual-era means an implementation that speaks both. Its compatibility matrix comes down to four rules:
| Client | Server | Outcome |
|---|---|---|
| Modern | Modern | Works; version mismatches surface as -32022 and the client retries |
| Modern | Legacy | Fails; on stdio the client should send server/discover first to fail cleanly |
| Legacy | Modern | Fails; legacy clients have no way to move forward |
| Dual-era | either | Works; detects the era and falls back |
| Legacy | Dual-era | Works; the server answers initialize under legacy rules |
The consequence: make servers dual-era first, and make clients dual-era before any server drops legacy. A legacy client cannot recover from a modern-only server; the spec only asks such servers to name their supported versions in the error. You rarely control which clients users run, so server-side legacy support must outlive the slowest client.
Building a dual-era server
A dual-era server chooses behaviour from how the client opens. A request carrying modern _meta is served statelessly under the modern rules. An initialize request selects legacy semantics for that stdio process or HTTP session. The spec allows both eras on the same endpoint at once. The routing logic is small:
SUPPORTED_MODERN = ["2026-07-28"]
SUPPORTED_LEGACY = ["2025-11-25", "2025-06-18"]
def handle(msg, http_headers=None):
meta = (msg.get("params") or {}).get("_meta") or {}
version = meta.get("io.modelcontextprotocol/protocolVersion")
if version is not None: # modern request
if http_headers is not None and http_headers.get("mcp-protocol-version") != version:
return http_400(error(msg, -32020, "Header mismatch: MCP-Protocol-Version"))
if version not in SUPPORTED_MODERN:
return http_400(error(msg, -32022, "Unsupported protocol version",
{"supported": SUPPORTED_MODERN + SUPPORTED_LEGACY,
"requested": version}))
return modern_dispatch(msg, version, meta)
if msg.get("method") == "initialize": # legacy handshake
asked = msg["params"]["protocolVersion"]
chosen = asked if asked in SUPPORTED_LEGACY else SUPPORTED_LEGACY[0]
return legacy_initialize(msg, chosen)
return legacy_dispatch(msg) # inside an existing legacy sessionKeep one implementation of each tool and two thin wire adapters. The adapters differ in more than negotiation: the modern revision removes server-initiated requests in favour of multi round-trip results (resultType: "input_required"), requires a resultType on every result, adds cache hints (ttlMs, cacheScope) to list results, and moves change notifications to subscriptions/listen. If your tools used sampling or elicitation, the legacy path sends a request to the client, while the modern path returns an input-required result and waits for the retry. Test both paths for each such tool.
A modern-only server receiving legacy HTTP traffic should, per the spec, answer GET and DELETE with 405 and ignore Mcp-Session-Id and Last-Event-ID.
Building a dual-era client
On HTTP, the spec's detection rule is: send a modern request first. If it fails with 400, read the body before deciding. A recognised modern JSON-RPC error, such as -32022, a missing-capability error or a header mismatch, proves the server is modern, so correct the request instead of falling back. An empty body or an unrecognised error means legacy, so fall back to initialize. On stdio, where there is no HTTP status, probe with server/discover and fall back on any error that is not a recognised modern one, or on a timeout.
The era is a property of the server, not of one request. The spec says to cache it per server process (stdio) or per origin (HTTP), and allows persisting it across restarts, provided you probe again if the cached assumption later fails. A sketch:
async def call(server, method, params, preferred=("2026-07-28", "2025-11-25")):
era = cache.get(server.origin) # "modern" | "legacy" | None
if era != "legacy":
version = cache.version(server.origin) or preferred[0]
resp = await post_modern(server, method, params, version)
if resp.ok:
cache.set(server.origin, "modern", version); return resp.result
err = resp.jsonrpc_error() # None if body is empty or not JSON-RPC
if err and err.code == -32022: # modern server, different version
common = [v for v in preferred if v in err.data["supported"]]
if not common:
raise Incompatible(server.origin, err.data["supported"])
cache.set(server.origin, "modern", common[0])
return (await post_modern(server, method, params, common[0])).result
if err and is_recognized_modern_error(err):
raise err # modern server, real error: do not fall back
cache.set(server.origin, "legacy", None)
return await legacy_session(server).call(method, params) # initialize firstThe bug to avoid is falling back on any 400, which drives a modern server that rejected a bad header into a legacy handshake it does not speak.
Your support window and the deprecation policy
The spec now has a feature lifecycle: Active, Deprecated and Removed. A deprecated feature stays in the spec for a minimum window of at least twelve months, measured from the release of the revision that first marks it deprecated. Removal can be expedited to no less than ninety days only for an active security risk with a published advisory or documented exploitation. A single deprecated-features registry lists what is on the way out. The 2026-07-28 revision deprecates Roots, Sampling and Logging, the legacy HTTP+SSE transport, two includeContext values, and Dynamic Client Registration as a client registration mechanism in favour of Client ID Metadata Documents.
Use this as the template for your own policy, written down and published to your users:
- Support the Current revision and the previous one at minimum. Add older ones only while telemetry shows real traffic.
- Log the requested protocol version and
clientInfoon every request. A support window you cannot measure is a guess. - Announce the end of a revision at least as long ahead as the spec's own twelve-month floor, and return a clear error naming supported versions after the date.
- For each deprecated feature you use, such as Sampling, record the migration path the changelog gives and a target date well before its earliest removal.
Versioning your own tools
Since the protocol carries no tool version, these rules are a convention, not part of MCP; tool schema versioning covers the general problem. They matter because prompts, evaluations and saved workflows depend on tool names and argument shapes.
- Additive changes are safe. A new optional argument with a sensible default, a new output field, or a new tool can ship at any time. Order
tools/listdeterministically, as the spec now recommends, so the change does not disturb clients' caches or prompt caches more than necessary. - Breaking changes get a new name. Renaming or removing an argument, making an optional argument required, or changing what a tool does gets a new tool such as
search_tickets_v2. Keep the old one for your deprecation window and mark it deprecated in its description, so the model and a human reviewer both see it. - Semantics count as schema. Changing units, default sort order or whether a tool has side effects breaks callers even though the JSON Schema is unchanged. Treat it as breaking.
- Version the server, not each tool. Put a semantic version in your server info and changelog so operators can correlate behaviour changes with deploys. Remember that
serverInfois self-reported and the spec says clients should not change behaviour based on it.
Worked example: migrating a ticket server
A team runs an internal ticket server used by three clients: a desktop IDE extension on a legacy SDK, a hosted agent platform already on 2026-07-28, and a nightly batch job. They want to adopt the modern revision and fix a design mistake: search_tickets takes status as a free string and should take an enum list.
- Upgrade the server SDK to a release that supports both eras, deploy behind the era router, and confirm in logs that legacy
initializetraffic and modern requests both succeed. - Add
search_tickets_v2withstatuses: ["open", "pending", ...]. Leavesearch_ticketsas it is, with "Deprecated: use search_tickets_v2, removed after 2027-10-01" appended to its description. - Run the evaluation suite against both tools and both eras. Update agent prompts and saved workflows that reference the old tool by name.
- Watch per-client usage of the old tool and of legacy negotiation. When the IDE extension ships a dual-era client, legacy traffic falls to the batch job, which the team upgrades directly.
- After the published date, remove the old tool. Keep legacy protocol support until the request logs have shown no legacy clients for a full release cycle.
Testing and failure modes
In CI, test every supported revision on every era path, plus negative cases: an unsupported version returns -32022 with a correct supported list, and a header mismatch returns -32020. A tools/list snapshot test that fails on non-additive diffs catches accidental breaking changes.
- Silent era fallback. The client falls back on any 400 and hides real errors. Fall back only on unrecognised bodies.
- Stale era cache. A server upgraded to modern-only still gets legacy handshakes from clients that cached its era. Probe again on failure.
- Gateway trusting headers on old versions. The spec advises intermediaries that enforce policy on mirrored headers to reject requests whose version predates header validation, or that omit it.
- SDK upgrade as a hidden protocol change. Pin versions and review protocol support in each upgrade.
- Tool rename without notice. Agents and saved prompts fail with unknown-tool errors. Use a new name alongside the old.
For the routing layer that usually sits in front of a fleet see MCP gateways, for transport details see MCP transports, and for the capability objects themselves see capability negotiation.
What to do next
- Inventory your servers and clients and record, for each, the SDK version and the protocol revisions it speaks.
- Start logging the requested protocol version and client identity on every request, and build a dashboard of traffic by revision.
- Make servers dual-era before any of them go modern-only, and make your own clients dual-era with body-inspecting fallback and a per-origin era cache.
- Write and publish a support-window policy modelled on the spec's twelve-month deprecation floor.
- Add a
tools/listsnapshot test that fails on non-additive changes, and adopt new-name-for-breaking-change as a team rule. - Check the deprecated-features registry against your code. Plan migrations away from Sampling, Roots, Logging and HTTP+SSE if you use them.