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

Published 2026-10-26 by mcp.tc. Canonical: https://mcp.tc/blog/mcp-stateless-migration

**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](https://modelcontextprotocol.io/specification/2026-07-28/changelog) lists nine major changes, most of them removals with a documented replacement.

The [release post](https://blog.modelcontextprotocol.io/posts/2026-07-28/) 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](https://blog.modelcontextprotocol.io/posts/2026-07-28-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](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning) 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](https://modelcontextprotocol.io/community/feature-lifecycle), adopted in this revision: a feature stays Deprecated for at least twelve months before it becomes eligible for removal. The [deprecated features registry](https://modelcontextprotocol.io/specification/2026-07-28/deprecated) 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](https://mcp.tc/blog/build-remote-mcp-server-typescript) covers the Express setup.

For TypeScript, the [upgrade guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2) 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`:

```bash
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](https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28) 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:

```ts
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](https://ts.sdk.modelcontextprotocol.io/v2/serving/legacy-clients) exports `isLegacyRequest` for deployments that keep an existing sessionful transport for old clients next to a strict modern handler.

For Python, the [migration guide](https://py.sdk.modelcontextprotocol.io/migration/) 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()`:

```bash
pip install "mcp>=2,<3"
```

```python
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](https://py.sdk.modelcontextprotocol.io/protocol-versions/) 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) 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:

```http
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](https://modelcontextprotocol.io/specification/2026-07-28/basic/index) 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](https://mcp.tc/docs/changes) 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](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) spells out the pattern, where a creation tool returns a handle and later tools take it as an argument.

```jsonc
// 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr), 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions) 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`](https://modelcontextprotocol.io/specification/2026-07-28/server/discover), and the result shows what it believes it supports:

```bash
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](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio) 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](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions) 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](https://mcp.tc/cli) 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.

```bash
npx mcp-tc check https://mcp.example.com/mcp
```

The 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](https://mcp.tc/docs/server-owners) 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:

1. Bump the version in `serverInfo` when 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](https://mcp.tc/changes) shows the newest of these across the directory, and [mcp.tc's own listing](https://mcp.tc/i/mcp-tc#history) shows what a History tab looks like.
2. 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".
3. If the server is not listed yet, [suggest it](https://mcp.tc/submit) 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.

## Sources

- <https://modelcontextprotocol.io/specification/2026-07-28/changelog>
- <https://blog.modelcontextprotocol.io/posts/2026-07-28/>
- <https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/>
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning>
- <https://modelcontextprotocol.io/community/feature-lifecycle>
- <https://modelcontextprotocol.io/specification/2026-07-28/deprecated>
- <https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2>
- <https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28>
- <https://ts.sdk.modelcontextprotocol.io/v2/serving/legacy-clients>
- <https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions>
- <https://py.sdk.modelcontextprotocol.io/migration/>
- <https://py.sdk.modelcontextprotocol.io/protocol-versions/>
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http>
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/index>
- <https://modelcontextprotocol.io/specification/2026-07-28/server/tools>
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr>
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions>
- <https://modelcontextprotocol.io/specification/2026-07-28/server/discover>
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio>
