MCP Stateless Migration: Moving a Server to the 2026-07-28 Spec
MCP stateless migration, step by step: server/discover, per-request _meta, Mcp-Method and Mcp-Name headers, handles instead of sessions, MRTR, and testing.
Este artículo está en inglés.
TL;DR. The 2026-07-28 revision of the Model Context Protocol removes the initialize handshake, the Mcp-Session-Id header, the HTTP GET stream and server-initiated requests. An MCP stateless migration therefore comes down to five changes on the server: answer server/discover and read the protocol version from each request's _meta, accept the MCP-Protocol-Version, Mcp-Method and Mcp-Name headers, replace sessions with explicit handles passed as tool arguments, return an InputRequiredResult instead of calling the client for elicitation or sampling, and serve change notifications through subscriptions/listen. The official TypeScript and Python SDKs handle the wire details once you move your entry point; this guide covers what is left, and how to check the result with curl and npx mcp-tc check.
What changed in the 2026-07-28 spec?
The 2026-07-28 specification turns MCP into a stateless protocol: every request carries its protocol version and the client's capabilities in _meta, the server processes each request on its own, and nothing is inferred from earlier requests on the same connection, stream or process. The changelog lists nine major changes, most of them removals with a documented replacement.
The release post of July 28, 2026 says "The new release is MCP's most important since remote MCP first launched over a year ago" and that "All four Tier 1 SDKs speak 2026-07-28 as of today: TypeScript, Python, Go, and C#." It followed a release candidate published on May 21, 2026, which gave SDK maintainers and client implementers a "ten-week window" to validate the changes.
| Removed in 2026-07-28 | What replaces it |
|---|---|
initialize and notifications/initialized | Per-request _meta fields, plus server/discover for up-front version selection |
Mcp-Session-Id and protocol-level sessions | Server-minted handles passed as ordinary tool arguments |
HTTP GET stream, resources/subscribe, resources/unsubscribe | subscriptions/listen, one long-lived POST response stream |
Server-initiated elicitation/create, sampling/createMessage, roots/list | Multi Round-Trip Requests: an InputRequiredResult the client answers by retrying |
ping, logging/setLevel, notifications/roots/list_changed | Removed; the log level travels per request in io.modelcontextprotocol/logLevel |
SSE resumability and Last-Event-ID | Removed; a broken stream loses the request and the client re-issues it with a new ID |
| Experimental tasks in the core protocol | The io.modelcontextprotocol/tasks extension, polled with tasks/get |
Two additions come with the removals. Every result now carries a required resultType field, "complete" for ordinary results and "input_required" for interim MRTR results, and clients must treat a missing field from an older server as "complete". List results (tools/list, prompts/list, resources/list, resources/read and resources/templates/list) must carry ttlMs and cacheScope so clients can cache them.
Does an existing MCP server have to migrate now?
An existing MCP server keeps working for the clients it works with today, because the 2025-11-25 and earlier revisions remain defined and a server may implement both eras at once; what changes is reach, since a client that only speaks 2026-07-28 cannot use a server that only answers initialize, and the reverse fails too. The versioning page names the three cases: "Modern" is 2026-07-28 and later, "Legacy" is 2025-11-25 and earlier with the initialize handshake, and "Dual-era" supports both.
Its compatibility matrix decides your plan:
| Client | Server | Outcome in the spec |
|---|---|---|
| Modern | Legacy | Fails; the server may reject the request, stay silent, or process an era-ambiguous method under legacy semantics |
| Legacy | Modern | Fails; on HTTP the request lacks the required headers and is rejected with 400 Bad Request |
| Legacy | Dual-era | Works; the server answers initialize and serves the negotiated legacy revision |
| Dual-era | Legacy or Modern | Works; the client detects the server's era and stays modern or falls back to initialize |
For a public server, dual-era is the practical target this year, and the TypeScript SDK makes it the default once you use its new entry points (next section). A modern-only server "SHOULD name the protocol versions it supports in any error it returns to an initialize request", because legacy clients have no other way to show the user what went wrong.
The timeline for the rest is set by the feature lifecycle policy, adopted in this revision: a feature stays Deprecated for at least twelve months before it becomes eligible for removal. The deprecated features registry lists Roots, Sampling, Logging and OAuth Dynamic Client Registration as deprecated in 2026-07-28, each with an earliest removal in the "First revision released on or after 2027-07-28".
Step 1: upgrade the SDK and move the entry point
In both official SDKs, the migration starts with a major version bump; the TypeScript SDK v2 adds an entry point that builds a fresh server instance per request, because a stateless server cannot keep a long-lived instance per connection, and ships a codemod for the mechanical edits, and the Python SDK v2 renames its main class. If you are starting from nothing instead, the earlier post on building a remote MCP server with the TypeScript SDK v2 covers the Express setup.
For TypeScript, the upgrade guide splits @modelcontextprotocol/sdk into @modelcontextprotocol/client, @modelcontextprotocol/server and @modelcontextprotocol/core, requires Node.js 20 or later, and asks you to run the codemod at the package root because it also rewrites package.json:
npx @modelcontextprotocol/codemod@latest v1-to-v2 .
grep -rn '@mcp-codemod-error' .The guide is explicit that the package upgrade alone is not the migration: "a server that still calls server.connect(new StdioServerTransport()) or hands requests to a *StreamableHTTPServerTransport serves 2025-era clients only". To serve 2026-07-28 clients, the 2026-07-28 support page has you replace the transport with a handler factory, serveStdio(() => buildServer()) for stdio or createMcpHandler(factory) for HTTP. The factory takes no parameters and returns a new server each time it is called:
const handler = createMcpHandler(() => {
const server = new McpServer(
{ name: 'example-server', version: '2.0.0' },
{ capabilities: { tools: {} } }
);
// register tools, resources and prompts here
return server;
});createMcpHandler returns a web-standard handler; on Node frameworks you wrap it with toNodeHandler(handler) from @modelcontextprotocol/node. Its legacy option defaults to 'stateless', which serves 2025-era and 2026-07-28 clients from the same endpoint, each legacy request from a fresh instance with no sessions. legacy: 'reject' makes the endpoint modern-only and answers legacy openings with HTTP 400 and an unsupported-protocol-version error. The legacy clients page exports isLegacyRequest for deployments that keep an existing sessionful transport for old clients next to a strict modern handler.
For Python, the migration guide pins the new major version and renames FastMCP to MCPServer; handlers receive the context as an explicit parameter, and transport settings move from the constructor to run():
pip install "mcp>=2,<3"from mcp.server.mcpserver import MCPServer, Context
mcp = MCPServer("example-server")
@mcp.tool()
async def lookup(query: str, ctx: Context) -> str:
await ctx.report_progress(1, 2)
return f"results for {query}"
mcp.run(transport="streamable-http", port=8000)The SDK's protocol versions page says MCPServer answers server/discover on "Streamable HTTP, stdio, and the in-process connection", and that its Client auto-detects the era by default, with mode="2026-07-28" to pin a version and mode="legacy" to force the handshake.
Step 2: read the protocol version from each request, and the headers
A 2026-07-28 server identifies the client from each request on its own: the required _meta fields io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities, the optional io.modelcontextprotocol/clientInfo, and on HTTP three headers that mirror the body so intermediaries can route without parsing JSON. With an SDK in place, your job is to make sure nothing in front of the server strips or rewrites it.
The Streamable HTTP transport requires on every POST an Accept header listing both application/json and text/event-stream, an MCP-Protocol-Version header, an Mcp-Method header equal to the JSON-RPC method, and, for tools/call, resources/read and prompts/get, an Mcp-Name header equal to params.name or params.uri. A server may also mark tool parameters with x-mcp-header so clients mirror them as Mcp-Param-{name} headers; the spec says not to do that for passwords, keys, tokens or personal data, since header values are visible to intermediaries.
This is what a modern request looks like on the wire, using the spec's own example:
POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "Seattle, WA" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}The error codes a migrated server returns are new; the base protocol page reserves -32020 to -32099 for the specification.
| Situation | HTTP status | JSON-RPC error |
|---|---|---|
| Header missing, malformed, or different from the body | 400 | -32020 HeaderMismatch |
| Client did not declare a capability the request needs | 400 | -32021 MissingRequiredClientCapability |
| Protocol version not implemented by the server | 400 | -32022 UnsupportedProtocolVersion, with data.supported |
Required _meta field missing | 400 | -32602 Invalid params |
| Method not implemented | 404 | -32601 Method not found |
Origin header present and invalid | 403 | Optional JSON-RPC error without id |
One allowance exists for old clients: a server that still supports clients older than 2025-06-18 may treat a request without MCP-Protocol-Version as 2025-03-26; a server that does not support them must reject the request.
Servers should also put their identity in every result, as io.modelcontextprotocol/serverInfo in the result's _meta. That field is where clients and directories read a server's name and version; on mcp.tc, a changed version number is one of the things a listing's History tab records.
Step 3: replace sessions with explicit handles
Without Mcp-Session-Id, a server cannot relate one tool call to the next through the connection, so any state that spans calls has to be referenced by an identifier the client passes on each request; the tools page spells out the pattern, where a creation tool returns a handle and later tools take it as an argument.
// tools/call create_basket -> result
{ "structuredContent": { "basket_id": "bsk_a1b2c3" } }
// tools/call add_item
{ "name": "add_item", "arguments": { "basket_id": "bsk_a1b2c3", "sku": "..." } }The model carries the handle forward and the server stores the state under that key. The spec's non-normative guidance on handle design has four points: validate the caller's authorization against the handle on every call (for an unauthenticated server the handle is a bearer token, so give it entropy such as a UUIDv4 and a bounded lifetime), keep handles opaque, state the retention policy in the creation tool's description so the model sees it, and answer an expired or unknown handle with a tool execution error the model can recover from.
The same page changes what tools/list may return. The set "MUST NOT vary per-connection or as a side effect of other requests on the connection", though it "MAY vary by the authorization presented on the request". Servers should return tools in a deterministic order, which lets clients cache the list and improves prompt cache hits, and every list result must include ttlMs (a freshness hint in milliseconds) and cacheScope ("public" or "private").
That rule matches how a directory reads a server. mcp.tc reads the tool list of each remote server that shows it without sign-in every 3 hours and compares it with the last read; tools shown only after sign-in are not in that view. If you edit tool descriptions during the migration, for instance to add the handle lifetime the spec recommends, the change appears on the listing's History tab once two reads agree, and people who follow the listing get an email.
Step 4: convert elicitation, sampling and roots to MRTR
Under the Multi Round-Trip Requests pattern, a server that needs something from the client no longer sends a request of its own; it ends the current request with an InputRequiredResult, and the client retries the original request with the answers attached. The spec calls the old pattern "no longer supported" and the change "a breaking change".
An InputRequiredResult has resultType: "input_required", an optional inputRequests map of server-assigned keys to ElicitRequest, CreateMessageRequest or ListRootsRequest objects, and an optional opaque requestState string. At least one of the two fields must be present. The client fulfills the requests, then retries with a new JSON-RPC id, an inputResponses map under the same keys, and the exact requestState it received. Only tools/call, prompts/get and resources/read may return it, and a server must not ask for an input type the client did not declare in io.modelcontextprotocol/clientCapabilities.
Because requestState passes through the client, the spec treats it as attacker-controlled input. If it influences authorization, resource access or business logic, the server must protect its integrity (HMAC or AEAD) and reject state that fails verification, and it should bind the state to the authenticated principal, a short expiry and the originating request.
In the TypeScript SDK, a handler that used to await ctx.mcpReq.elicitInput({...}) now returns inputRequired({ inputRequests: { id: inputRequired.elicit({...}) } }) and, on re-entry, reads the answer with acceptedContent(ctx.mcpReq.inputResponses, 'id', SCHEMA); the support page says the same handler works on both eras, because a legacy shim turns the return value into a server-to-client request on 2025 connections. In the Python SDK, call_tool(), get_prompt() and read_resource() may return an InputRequiredResult that the caller checks with isinstance.
Keep the deprecation registry in mind here. Roots and Sampling are deprecated as features, with suggested migrations of passing paths through tool parameters or configuration, and calling LLM provider APIs directly. The deprecation window keeps them working for at least twelve months; MRTR is not a reason to add new uses.
Step 5: move change notifications to subscriptions/listen
A 2026-07-28 server delivers notifications/tools/list_changed and the other list-change notifications only to clients that asked for them with a subscriptions/listen request, whose response is an SSE stream that stays open until one side closes it. The subscriptions page replaces both the HTTP GET endpoint and resources/subscribe with this one mechanism.
The request carries a notifications filter with four optional fields: toolsListChanged, promptsListChanged and resourcesListChanged (booleans) and resourceSubscriptions (a list of resource URIs). The server must send notifications/subscriptions/acknowledged first, echoing the subset it will honor, and must tag every message on the stream with io.modelcontextprotocol/subscriptionId in _meta, whose value is the JSON-RPC id of the listen request. Progress and log notifications never travel on this stream; they stay on the response stream of the request they belong to.
Three operational notes from the transport page: a modern-only server answers GET or DELETE on the MCP endpoint with 405 Method Not Allowed and ignores any Mcp-Session-Id or Last-Event-ID it receives; servers should send X-Accel-Buffering: no on SSE responses so reverse proxies do not buffer events; and on long-lived streams they are encouraged to emit an SSE comment line (a line starting with a colon) now and then as a keep-alive.
How do I test a migrated MCP server?
Test a migrated MCP server from the outside with three curl requests, for discovery, header validation and legacy fallback, and then with a tool that reads it the way clients and directories do, such as mcp.tc's command-line check. Discovery comes first; a modern server must implement server/discover, and the result shows what it believes it supports:
curl -s https://mcp.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'Expect resultType: "complete", a supportedVersions array, capabilities, ttlMs, cacheScope and your serverInfo under _meta. Then send the same body with Mcp-Method: tools/list and expect 400 with error -32020; a 200 means the header never reached the server or was not compared with the body. Finally send a GET with Accept: text/event-stream and expect 405 from a modern-only server, or from a TypeScript v2 handler in its default stateless posture. On stdio there is no status code to drive this; the stdio transport page has dual-era clients probe with server/discover and fall back to initialize on any error that is not a recognized modern error.
For the client side of a dual-era check, the TypeScript SDK's protocol versions page documents versionNegotiation: { mode: 'auto' } (probe with server/discover, fall back to initialize) and mode: { pin: '2026-07-28' }, which throws if the server does not offer that version; the client's default is 'legacy'. The Python Client has the equivalent mode= argument.
mcp.tc's command-line tool runs the same kind of read from your computer. mcp-tc needs Node.js 20 or later, and its check command "reads your server the way mcp.tc reads it and lists errors, warnings and suggestions". For a URL it covers "the handshake in both protocol styles: server/discover (2026-07-28) and initialize (2025-11-25, 2025-06-18, 2025-03-26), and the older HTTP+SSE transport; JSON and SSE replies; sessions", then serverInfo, every tool's schema, description and readOnlyHint and destructiveHint annotations, the sign-in metadata, the server card, and for a server on your own computer whether a foreign Host and Origin are refused.
npx mcp-tc check https://mcp.example.com/mcpThe README is also clear about the limit: check "tells you whether clients and mcp.tc can read your server. It does not tell you whether mcp.tc will list it: on mcp.tc an AI model checks each suggestion, and a person may review it."
What to do next on mcp.tc
If your server is listed on mcp.tc, a migration to 2026-07-28 does not change how the directory reads it: the server owner docs describe the handshake as server/discover or initialize, repeated once a day with every remote server to refresh its health, and the tool list is read again every 3 hours for remote servers that show it without sign-in. On 25 October 2026 the directory listed 1,009 servers, 721 of them remote, and 285 listings had a tool list that mcp.tc compares at that interval.
Three things are worth doing around the release:
- Bump the version in
serverInfowhen the new build ships. The listing's History tab records new version numbers (at most one version change a day) with the date and time, and anyone following the listing is emailed. The recent changes page shows the newest of these across the directory, and mcp.tc's own listing shows what a History tab looks like. - Claim the listing. A DNS TXT record, an HTML file or a meta tag on the server's domain proves it is yours; after that you can edit the listing directly, and the edits appear in the History labeled "Server owner".
- If the server is not listed yet, suggest it with its URL, or run
npx mcp-tc submit <url>from the terminal. Listings are built from the live handshake and tools list, the official MCP Registry, server cards and package pages, with an AI-assisted review and a human moderator.
The History shows what mcp.tc's own reads, made without signing in, saw at each check. It is not a security review, and it cannot see tools a server shows only after sign-in.
FAQ
Is the Mcp-Session-Id header gone for good?
In the 2026-07-28 revision, yes. The Streamable HTTP transport page says a server that supports only this revision should ignore an Mcp-Session-Id header and "do not mint or echo session IDs". Servers that also serve legacy clients keep sessions for those clients only, scoped to the negotiated 2025-era revision. State that spans calls now travels as an explicit handle in tool arguments, and tools/list may no longer vary per connection.
Do I still need to answer initialize?
Only if you want legacy clients (2025-11-25 and earlier) to work. The spec's compatibility matrix marks a legacy client against a modern-only server as "Fails", and a modern-only server should name the versions it supports in whatever error it returns to initialize. The TypeScript SDK's createMcpHandler serves both eras by default (legacy: 'stateless'); the Python SDK's MCPServer answers server/discover on every transport, and its Client falls back to initialize when a server does not answer the probe.
What are the Mcp-Method and Mcp-Name headers for?
They mirror fields from the JSON-RPC body into HTTP headers so load balancers, gateways and observability tools can route and inspect requests without parsing the body. Mcp-Method carries method on every request; Mcp-Name carries params.name or params.uri on tools/call, resources/read and prompts/get. The server must compare them with the body and reject mismatches with 400 and error -32020, so an intermediary and the server never act on different values.
How long do Roots, Sampling and Logging keep working?
They are Deprecated, not removed, as of 2026-07-28. The feature lifecycle policy sets a minimum twelve-month window, and the deprecated features registry gives each an earliest removal in the first revision released on or after 2027-07-28. The suggested migrations are tool parameters or configuration instead of Roots, direct LLM provider APIs instead of Sampling, and stderr or OpenTelemetry instead of the Logging feature. New implementations should not adopt them.
Can mcp.tc read a server that only speaks 2026-07-28?
Yes. mcp.tc's server owner docs describe the handshake it performs as server/discover or initialize, so a modern-only server, a dual-era server and a legacy server can all be read. The daily health check repeats that handshake for every remote server, and the 3-hour tool reads cover remote servers that list their tools without sign-in. To see what the checker sees before you ship, run npx mcp-tc check against your endpoint.
Fuentes
- modelcontextprotocol.io/specification/2026-07-28/changelog
- blog.modelcontextprotocol.io/posts/2026-07-28/
- blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/
- modelcontextprotocol.io/specification/2026-07-28/basic/versioning
- modelcontextprotocol.io/community/feature-lifecycle
- modelcontextprotocol.io/specification/2026-07-28/deprecated
- ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2
- ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28
- ts.sdk.modelcontextprotocol.io/v2/serving/legacy-clients
- ts.sdk.modelcontextprotocol.io/v2/protocol-versions
- py.sdk.modelcontextprotocol.io/migration/
- py.sdk.modelcontextprotocol.io/protocol-versions/
- modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http
- modelcontextprotocol.io/specification/2026-07-28/basic/index
- modelcontextprotocol.io/specification/2026-07-28/server/tools
- modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
- modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions
- modelcontextprotocol.io/specification/2026-07-28/server/discover
- modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio