Serving two MCP protocol eras: deprecations and a migration checklist

Serving two MCP protocol eras: deprecations and a migration checklist

Moving a server to the 2026-07-28 revision of the Model Context Protocol (MCP) doesn’t mean you can leave older clients behind. For the foreseeable future, real servers will meet clients from two protocol eras: the initialize-handshake era (2025-11-25 and earlier) and the stateless era (2026-07-28). What surprised me in the deprecation rules is that features don’t all run on the same clock, and that no clock removes anything by itself.

This is part 22 of my series on what an MCP server does under the 2026-07-28 specification, after the threat model in part 21. It turns the changes of part 2 into a plan for your own server. The facts are as I read them in October 2026.

In brief

  1. Your server will meet clients from both protocol eras, so serve both from one codebase.
  2. Treat the era as a property of each request, not a server-wide mode.
  3. A deprecated feature keeps working for at least twelve months, with two shorter exceptions, and even after that it only becomes eligible for removal.
  4. Much is already gone, not merely deprecated, from the handshake and Mcp-Session-Id to server-initiated requests.
  5. My advice: drop the legacy path when your traffic says you can, not when the specification does.

One server, two eras

A clean approach, and the one several software development kits (SDKs) take, is to decide the era per request, not for the whole server:

Incoming message → Method is server/discover?; Method is server/discover? → Return supportedVersions, capabilities, instructions: yes; Method is server/discover? → _meta has io.modelcontextprotocol/ protocolVersion?: no; _meta has io.modelcontextprotocol/ protocolVersion? → Stateless lifecycle validate envelope per request: yes; _meta has io.modelcontextprotocol/ protocolVersion? → Method is initialize?: no; Method is initialize? → Legacy lifecycle handshake, session, server-initiated requests: yes; Method is initialize? → Error: protocol version or handshake required: noIncoming messageMethod isserver/discover?Return supportedVersions,capabilities, instructions_meta hasio.modelcontextprotocol/protocolVersion?Stateless lifecyclevalidate envelope per requestMethod isinitialize?Legacy lifecyclehandshake, session,server-initiated requestsError: protocol versionor handshake requiredyesnoyesnoyesno

server/discover needs no handshake; part 3 shows how clients use it to pick a version or detect an older server. A request whose _meta carries the protocol version gets the stateless lifecycle, its envelope validated every time (part 5).

What the SDKs do

Examples, as of October 2026:

SDKServing both eras
Ruby SDKIts bundled transports serve server/discover and the per-request envelope alongside the initialize handshake, with no configuration. Its client probes server/discover and falls back to the handshake.
C# SDK v2Supports the versions from 2024-11-05 through 2026-07-28. Pinned to 2026-07-28, it rejects initialize; pinned to an earlier version, it rejects the per-request envelope. Its sampling application programming interfaces (APIs) are marked obsolete.
TypeScript SDK 2.0Adds the 2026-07-28 revision. The v2 MCP runtime of Claude Code is built on it, and uses the newer revision with servers that support it.
Python SDK, mcp 2.3.0MCPServer serves both eras with no configuration, as the sample below shows.

One Python server, both eras

One MCPServer, with nothing configured for either era, has a tool that reports the protocol version its request arrived on. Two in-memory clients call it: the default mode probes server/discover, and mode="legacy" uses the initialize handshake. Part 3 used the same two modes to read discovery; here the point is the server.

import anyio
from mcp import Client
from mcp.server import MCPServer
from mcp.server.mcpserver import Context

mcp = MCPServer("acme-docs")


@mcp.tool()
def which_era(ctx: Context) -> str:
    """Report the protocol version this request arrived on."""
    return ctx.protocol_version


async def main():
    for client in (Client(mcp), Client(mcp, mode="legacy")):
        async with client:
            how = "discover" if client.session.discover_result else "initialize"
            result = await client.call_tool("which_era", {})
            print(f"{client.mode}: {how}, {client.protocol_version}, "
                  f"tool saw {result.content[0].text}")


anyio.run(main)

It prints:

auto: discover, 2026-07-28, tool saw 2026-07-28
legacy: initialize, 2025-11-25, tool saw 2025-11-25

The same tool answers both clients without a line for either era; ctx.protocol_version is there for the rare handler that must tell them apart. From the package’s source and short probes:

  • Routing. Over Streamable HTTP, mcp 2.3.0 routes each request by its MCP-Protocol-Version header. On standard input and output (stdio), the first request settles the era for the whole connection, so there the decision is per connection rather than per request: a later initialize on a modern connection gets -32022 (UnsupportedProtocolVersion), and a request carrying the 2026-07-28 _meta envelope on a legacy connection gets -32600 (Invalid Request).
  • Asking the user. A Resolve question (part 13) works in both eras; the push-style ctx.elicit() raises NoBackChannelError on a 2026-07-28 request.
  • Logging. ctx.info() and its siblings run in both eras, with an MCPDeprecationWarning, but on a 2026-07-28 request the message is sent only when the request carries io.modelcontextprotocol/logLevel in _meta.

What is deprecated, and what is already gone

The 2026-07-28 revision adopted a formal feature lifecycle, SEP-2596 (a Specification Enhancement Proposal, or SEP), with a registry of deprecated features. When a SEP marks a feature deprecated, it stays fully functional, but new implementations should not adopt it. It stays for at least twelve months, or at least 90 days when it presents an active security risk, and even then it only becomes eligible for removal: taking it out is a decision for the core maintainers.

Active → Deprecated still fully functional, not for new implementations: SEP marks feature deprecated; Deprecated still fully functional, not for new implementations → Removed: at least twelve months, then a decision; Deprecated still fully functional, not for new implementations → Removed: active security risk: at least 90 daysActiveDeprecatedstill fully functional,not for new implementationsRemovedSEP marks feature deprecatedat least twelve months,then a decisionactive security risk:at least 90 days

Deprecated, still working

Part 2 gave the rule in brief; here is each feature with its earliest removal. One of them gets less than twelve months: the HTTP with Server-Sent Events (SSE) transport, HTTP+SSE.

FeatureDeprecatedUse insteadEarliest removal
Roots2026-07-28Pass directories or files as tool parameters, as resource Uniform Resource Identifiers (URIs) or in server configurationFirst revision on or after 2027-07-28
Sampling2026-07-28Call your own model provider from the serverFirst revision on or after 2027-07-28
Logging (notifications/message)2026-07-28stderr on stdio, or OpenTelemetry (part 20)First revision on or after 2027-07-28
Dynamic Client Registration2026-07-28Client ID Metadata Documents; registration stays for older authorization serversFirst revision on or after 2027-07-28
HTTP+SSE transportSoft-deprecated 2025-03-26, formally 2026-07-28Streamable HTTPThree months after SEP-2596 reaches Final
includeContext values thisServer and allServersSoft-deprecated 2025-11-25Omit the field, or use "none"Follows sampling’s schedule

Removed outright in 2026-07-28

  • the initialize and notifications/initialized handshake;
  • the Mcp-Session-Id header;
  • ping and logging/setLevel;
  • notifications/roots/list_changed;
  • the HTTP GET endpoint;
  • resources/subscribe and resources/unsubscribe;
  • SSE resumability (Last-Event-ID);
  • server-initiated requests, replaced by Multi Round-Trip Requests (MRTR);
  • notifications/elicitation/complete and elicitationId;
  • core experimental tasks, tasks/result and tasks/list included; tasks now live in the redesigned Tasks extension (a later part), without those two methods.

Migration checklist: 2025-11-25 to 2026-07-28

Most of the move swaps an old pattern for a new one; where an earlier part explains the new one, the row links to it.

If you used to… now you…

Old patternNew pattern
Stored per-session state keyed by Mcp-Session-IdReturn an opaque handle from a “create” tool, accept it as an argument, and keep the state behind it in a shared store (part 5)
Read the version and capabilities once at initializeRead them from _meta on every request (part 5)
Sent elicitation/create from inside a tool callReturn input_required, let the client retry (part 13), and protect requestState (part 14)
Called sampling/createMessage or roots/listCall your own model provider, and take directories or files as tool parameters. Until both features are removed, an input_required result can still ask the client for them.
Pushed notifications over the GET stream, or after resources/subscribeDeliver them on subscriptions/listen: acknowledge first, and tag every message with subscriptionId (part 17)
Relied on SSE Last-Event-ID to resumeMake tools idempotent, or move long work to Tasks

The checklist

  • Implement server/discover: versions, capabilities, instructions, ttlMs and cacheScope.
  • Make each change in the table above.
  • Add resultType to every result.
  • Add ttlMs and cacheScope to complete results of the list methods and resources/read (part 6; an input_required result carries none, per the caching page), and sort lists deterministically.
  • Require and verify the Streamable HTTP headers (part 7): MCP-Protocol-Version and Mcp-Method on every request, Mcp-Name only on tools/call, prompts/get and resources/read (the Streamable HTTP page). Return -32020 when a header and the body disagree.
  • Change resource-not-found from -32002 to -32602 (part 10).
  • Move experimental tasks to the Tasks extension: tasks/get polling plus tasks/update, with no tasks/result or tasks/list.
  • Stop emitting logs unless the request sets logLevel, and move to OpenTelemetry.
  • Plan the removals of roots, sampling, logging and HTTP+SSE, on the schedule in the table of deprecated features above.
  • Move from Dynamic Client Registration to Client ID Metadata Documents (part 18).
  • Test against at least one modern and one legacy client. In my review of MCP testing tools, the official conformance suite “carries per-revision requirement sets for 2025-11-25 and 2026-07-28”, and the official MCP Inspector “speaks stdio, SSE and Streamable HTTP in both protocol eras”.

When to drop the legacy path

The deprecation windows say when a feature may leave the specification, not when your server should stop serving older clients. My advice: don’t rush. Hosts adopt new protocol revisions at very different speeds, and a server that only speaks 2026-07-28 will look broken in any host that hasn’t caught up. Track the protocol versions you see in your clientInfo and protocolVersion telemetry, and remove the legacy path when your traffic says you can, not when the specification does.

Method and caveats

  • Built from two chapters of my guide, on deprecations and on what changed, and written against the 2026-07-28 specification. The SDKs’ and Claude Code’s behaviour is as I read it in October 2026.
  • The Python sample ran against mcp 2.3.0 on 9 October 2026 with the in-memory client; the output is what it printed, and the notes under it come from the package’s source and short probes.
  • Where the specification is more precise than my notes (how long deprecated features last, that the move from Dynamic Client Registration belongs in the migration, which requests need Mcp-Name, which results carry caching hints), this article follows the specification’s registry of deprecated features, its Streamable HTTP page and its caching page, as checked on 8 October 2026.