---
title: "MCP Went Stateless: Migrating Your Server to the 2026-07-28 Spec"
description: "The 2026-07-28 MCP revision removes sessions, the initialize handshake, and server-initiated requests. Here's what actually breaks in your server, the new wire format, the requestState and MRTR patterns that replace sessions, and the SDK v2 migration path."
image: "https://foundrysoft.co/api/og?type=article&title=MCP+Went+Stateless%3A+Migrating+Your+Server+to+the+2026-07-28+Spec&cat=Tutorial+%2F%2F+MCP&rt=21+min+read&au=Varun+Raj+Manoharan&dt=2026-07-31"
url: "https://foundrysoft.co/blog/mcp-stateless-spec-migration"
---

Tutorial // MCP 2026-07-31 21 min read

# MCP Went Stateless: Migrating Your Server to the 2026-07-28 Spec

The 2026-07-28 MCP revision removes sessions, the initialize handshake, and server-initiated requests. Here's what actually breaks in your server, the new wire format, the requestState and MRTR patterns that replace sessions, and the SDK v2 migration path.

![Varun Raj Manoharan](https://foundrysoft.co/images/about/founder.webp)

Varun Raj Manoharan Founder & Principal Engineer

MCP Model Context Protocol AI Agents TypeScript Migration

## Key takeaways

-   Sessions are gone. No \`Mcp-Session-Id\`, no \`initialize\` handshake, no GET stream, no SSE resumability. Every request now carries its own protocol version and client capabilities in \`\_meta\`, so any server instance can answer any request.
-   Servers can no longer send requests to clients. Sampling, elicitation, and roots are replaced by Multi Round-Trip Requests: your handler returns \`input_required\` with an opaque \`requestState\`, and the client retries the original call with the answers.
-   \`requestState\` passes through the client, which makes it attacker-controlled input. HMAC or AEAD it, bind it to the principal and the originating request, and give it a short TTL. The SDK ships \`createRequestStateCodec\` so you don't hand-roll this.
-   SDK v2 is a package split (\`@modelcontextprotocol/core\`, \`/client\`, \`/server\`) currently in beta, and nothing speaks 2026-07-28 unless you opt in. Run \`npx @modelcontextprotocol/codemod@latest v1-to-v2 .\` first, then adopt the revision deliberately.

## Summary

**TL;DR:** The 2026-07-28 MCP revision makes the protocol stateless. The `initialize` handshake, `Mcp-Session-Id`, the GET stream, and server-initiated requests are all gone, replaced by a per-request `_meta` envelope, a mandatory `server/discover` RPC, `subscriptions/listen` for change notifications, and the Multi Round-Trip Request pattern for anything that needs client input. This walks through the new wire format, the two patterns that replace sessions, what it means for deployment, and the actual SDK v2 migration including the codemod.

Three days ago the MCP spec landed a revision that deletes most of the connection lifecycle. Not deprecates. Deletes.

If you run an MCP server in production, here's the short version of what changed: your server can no longer assume that two requests arriving on the same connection have anything to do with each other. There's no handshake to establish who the client is. There's no session id to hang state on. And if your server ever needed to ask the client something mid-request, sampling or elicitation or roots, that entire mechanism no longer exists.

I'll be honest about my own position here, because it's relevant. We've published five posts on this site about building MCP servers, and two of them now teach APIs that this revision removed or deprecated. [The OAuth server tutorial](https://foundrysoft.co/blog/oauth-secured-mcp-server) walks through `initialize` and `Mcp-Session-Id`. [The AI SDK integration post](https://foundrysoft.co/blog/ai-sdk-6-mcp-integration) is built on elicitation and dynamic client registration. That's what happens with a protocol moving this fast, and it's exactly why this post exists: I had to work out the migration for our own code, so here's the whole thing.

## What actually changed

Let me get the full list out of the way first, because the summaries floating around have been undersellings it as "MCP is stateless now" without saying which of your code stops compiling.

| Area | Before (2025-11-25 and earlier) | Now (2026-07-28) |
| --- | --- | --- |
| Connection setup | `initialize` + `notifications/initialized` handshake | Nothing. Every request carries its own `_meta` envelope |
| Version negotiation | Agreed once during `initialize` | Per request, with a mandatory `server/discover` RPC |
| Sessions | `Mcp-Session-Id` header, DELETE to end | Removed. Server-minted handles passed as tool arguments |
| Server asking the client for input | `sampling/createMessage`, `elicitation/create`, `roots/list` sent as server requests | Multi Round-Trip Requests: return `input_required`, client retries |
| Change notifications | Unsolicited, on a GET-opened SSE stream | Opt-in via a `subscriptions/listen` stream |
| Resource subscriptions | `resources/subscribe` / `unsubscribe` | The `resourceSubscriptions` field of the listen filter |
| Stream resumption | `Last-Event-ID` replay | Removed. A broken stream loses the request; re-issue it |
| Log level | Session-wide `logging/setLevel` | Per request, via `_meta` |
| Cancellation (HTTP) | `notifications/cancelled` | Close the request's SSE stream |
| List results | Plain results | Must carry `ttlMs` and `cacheScope` |
| Tasks | Experimental, in core | Moved to the `io.modelcontextprotocol/tasks` extension |

Also removed outright: `ping`, `logging/setLevel`, and `notifications/roots/list_changed`.

Deprecated with a twelve-month window: **Roots, Sampling, and Logging** as features, the HTTP+SSE transport, and OAuth Dynamic Client Registration in favor of Client ID Metadata Documents.

That's a lot. But nearly all of it follows from one decision, and if you understand the decision the rest stops feeling arbitrary.

## The one idea behind all of it

Old MCP treated a connection as a conversation. You opened it, you shook hands, the server remembered who you were and what you'd negotiated, and every subsequent request was interpreted in that context. Perfectly reasonable design, and it works beautifully for a local server on stdio.

It falls apart the second you deploy remotely. A session means the server holds state, which means the client has to come back to the _same_ server instance, which means sticky sessions, which means your load balancer needs to understand MCP, which means you can't just run four replicas behind a round-robin and call it a day. Every remote MCP deployment I've seen has fought this, usually by shoving session state into Redis and pretending the problem is solved.

The new spec's answer is blunt: **the connection is not a conversation.** From the spec:

> Servers **MUST NOT** rely on prior requests over the same connection to establish context (e.g., capabilities, protocol version, client identity).

Every request is self-describing and independently processable. Any instance can answer any request. Your MCP endpoint becomes an ordinary stateless HTTP service, which is the kind of thing every piece of infrastructure you already own knows how to run.

Once you accept that, the rest is consequences. No shared connection state means no handshake. No handshake means the version has to travel on every request. No server-held session means the server can't hold a half-finished operation while it waits for the client to answer a question, which is why sampling and elicitation had to be redesigned. It's coherent. It's just a lot to absorb at once.

## The new wire format

Here's a `tools/call` under the new revision. The envelope is the part to look at.

HTTP

Copy

```http
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
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": {}
    }
  }
}
```

Three things worth flagging.

**The `_meta` envelope is mandatory.** `protocolVersion` and `clientCapabilities` are required on every single request. `clientInfo` is optional but you SHOULD send it. Miss a required field and the server rejects with `-32602` and HTTP 400. There's a matching rule on the way back: servers SHOULD put `io.modelcontextprotocol/serverInfo` in every result's `_meta`.

**Headers mirror the body, and they have to agree.** `MCP-Protocol-Version` and `Mcp-Method` are required on every POST. `Mcp-Name` is required for `tools/call`, `resources/read`, and `prompts/get`. The point is that a load balancer or gateway can route and rate-limit on the header without parsing JSON. The catch is that any server processing the body **MUST** validate that the headers match it, and reject a mismatch with HTTP 400 and error `-32020`:

JSON

Copy

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32020,
    "message": "Header mismatch: Mcp-Name header value 'foo' does not match body value 'bar'"
  }
}
```

That rule exists to close a real hole. If your gateway authorizes on the header and your server executes on the body, an attacker who can make them disagree gets to route as one tenant and execute as another.

**Servers must implement `server/discover`.** Clients don't have to call it, they're free to fire a request and handle the error, but the RPC has to exist. If a client asks for a version you don't serve, you return `-32022` with the list you do serve:

JSON

Copy

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version",
    "data": {
      "supported": ["2026-07-28", "2025-11-25"],
      "requested": "1900-01-01"
    }
  }
}
```

There are three new error codes in total, and the spec now formally partitions the JSON-RPC server-error range so this stops being a free-for-all. `-32000` to `-32019` is grandfathered legacy, `-32020` to `-32099` belongs to the spec. New codes are `-32020` HeaderMismatch, `-32021` MissingRequiredClientCapability, `-32022` UnsupportedProtocolVersion. One quiet change that will bite you if you match on it: resource-not-found moved from `-32002` to `-32602`.

## Your sessions are gone. Here's what replaces them.

If your server keyed anything on `ctx.sessionId`, that code has no equivalent. The spec's guidance is that state spanning multiple requests must be referenced by an explicit identifier the client passes on each request, and it names two shapes: server-minted handles passed as ordinary tool arguments, and `requestState` for state within a single logical operation.

**Handles** are the easy case and you've probably built them already. A tool returns an id, later tools take that id as an argument, the id resolves to a row in your database. `open_cursor` gives back `cursor_a1b2`, `fetch_page` takes it. Nothing protocol-specific, just don't derive authorization from the handle alone, because the client can send you any string it likes.

**`requestState`** is the interesting one, and it's tied to the pattern that replaces server-initiated requests. Which brings us to the biggest code change in the revision.

## MRTR: the change that will break your handlers

Under the old protocol, a tool handler that needed input from the user did this:

TypeScript

Copy

```typescript
// OLD. This no longer works on a 2026-07-28 connection.
server.registerTool("create_issue", { /* ... */ }, async (args, ctx) => {
  const answer = await ctx.mcpReq.elicitInput({
    mode: "form",
    message: "Which repository?",
    requestedSchema: {
      type: "object",
      properties: { repo: { type: "string" } },
      required: ["repo"]
    }
  });

  return createIssue(answer.content.repo, args.title);
});
```

The server sent a request _to_ the client and blocked on the answer. That required an open, stateful, bidirectional channel, which is precisely what a stateless protocol can't have. The spec is unambiguous that this is finished:

> Servers **MUST** send server-to-client requests (such as `roots/list`, `sampling/createMessage`, or `elicitation/create`) using the MRTR pattern. The previous pattern of server-initiated requests is no longer supported. This is a breaking change.

**Multi Round-Trip Requests** invert the flow. Instead of blocking, your handler _returns_. It says "I can't finish, here's what I need," the request ends, and the client comes back with a brand new request carrying the answers.

The result looks like this:

JSON

Copy

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "github_login": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Please provide your GitHub username",
          "requestedSchema": {
            "type": "object",
            "properties": { "name": { "type": "string" } },
            "required": ["name"]
          }
        }
      }
    },
    "requestState": "AEAD-protected blob"
  }
}
```

`inputRequests` is a map with server-assigned keys. The client fulfills each one, then retries the original call with `inputResponses` under the same keys and the `requestState` echoed back byte for byte.

A few rules that matter in practice:

-   Only `tools/call`, `prompts/get`, and `resources/read` can return `input_required`. Nothing else.
-   The retry **must** use a different JSON-RPC id. These are genuinely independent requests, not a continuation.
-   You can't request a capability the client didn't declare. No `elicitation/create` in `inputRequests` if the client never said it supports elicitation.
-   `inputResponses` are **per round**, not accumulated. Round three does not include round one's answers. This one catches people.
-   Every result now carries a `resultType` field: `"complete"` for a normal result, `"input_required"` for these. It exists because results became polymorphic and clients need to know which shape they're holding before parsing. If a server on an older revision omits it, clients treat it as `"complete"`.

### Writing it with the SDK

The v2 SDK gives you a helper so you're not building these envelopes by hand:

TypeScript

Copy

```typescript
import { inputRequired, acceptedContent } from "@modelcontextprotocol/server";

server.registerTool("create_issue", { /* ... */ }, async (args, ctx) => {
  const repo = acceptedContent(ctx.mcpReq.inputResponses, "repo", RepoSchema);

  if (!repo) {
    return inputRequired({
      inputRequests: {
        repo: inputRequired.elicit({
          mode: "form",
          message: "Which repository?",
          requestedSchema: {
            type: "object",
            properties: { repo: { type: "string" } },
            required: ["repo"]
          }
        })
      }
    });
  }

  return createIssue(repo.repo, args.title);
});
```

Same handler, inverted control flow. Read what you were given, and if it isn't there, ask.

Two readers ship for `inputResponses`. `acceptedContent(responses, key, schema)` validates the untrusted content against a schema and returns it typed, or `undefined` on a mismatch or a decline. `inputResponse(responses, key)` gives you a discriminated view (`{kind: 'missing' | 'elicit' | 'sampling' | 'roots'}`) when you need to distinguish "the user declined" from "the user never answered."

Use the schema-aware overload. Elicitation content is **not** re-validated against your `requestedSchema` by the SDK or the client, on either era. A mistyped form field arrives in your handler as-is, and validating it yourself means you can re-ask instead of throwing.

### Multi-step flows and the phase switch

Because responses don't accumulate, any flow with more than one round has to thread its own state through `requestState`. The pattern the SDK docs recommend, and the one I'd use, is an explicit discriminated union of phases:

TypeScript

Copy

```typescript
import {
  inputRequired,
  acceptedContent,
  createRequestStateCodec
} from "@modelcontextprotocol/server";

type BrainstormState =
  | { step: "awaiting-count" }
  | { step: "awaiting-custom-count"; topic: string }
  | { step: "awaiting-ideas"; topic: string; count: number };

const stateCodec = createRequestStateCodec<BrainstormState>({
  key: process.env.REQUEST_STATE_KEY!,
  ttlSeconds: 600
});

// Wire the verify hook in ServerOptions:
//   { requestState: { verify: stateCodec.verify } }

async (args, ctx) => {
  const state = ctx.mcpReq.requestState<BrainstormState>();

  switch (state?.step) {
    case undefined:
      return inputRequired({
        inputRequests: { count: inputRequired.elicit({ /* ... */ }) },
        requestState: await stateCodec.mint({ step: "awaiting-count" })
      });

    case "awaiting-count": {
      const count = acceptedContent(ctx.mcpReq.inputResponses, "count", CountSchema);
      // decide the next round, carrying everything learned so far
      // into the newly minted state
    }

    case "awaiting-ideas": {
      const ideas = inputResponse(ctx.mcpReq.inputResponses, "ideas");
      return finish(ideas.kind === "sampling" ? ideas.result : undefined, state.count);
    }
  }
};
```

Switch on the phase, never on which response keys happen to have shown up. Each branch knows exactly what's in scope. It reads like a state machine because that's what it is now.

### Treat requestState as hostile

This is the part I'd flag in code review every time. `requestState` travels through the client, which means the client can modify it. The spec is direct about it:

> If a client request contains a `requestState` field, servers **MUST** treat `requestState` as an attacker-controlled input.

If that state influences authorization, resource access, or business logic, it needs integrity protection (HMAC or AEAD) and you must reject anything that fails verification. The spec also says you SHOULD put three things inside the protected payload: the authenticated principal, so state minted for one user can't be replayed by another; a short TTL; and an identifier for the originating request, so state from one call can't be presented on a different one.

`createRequestStateCodec({ key, ttlSeconds, bind })` does this for you and returns `{ mint, verify }`. Note that it is **signed, not encrypted**, so the client can base64url-decode and read the payload. Don't put anything secret in there.

And if a given state must be consumed exactly once, signing isn't enough. Signing bounds the replay window; it doesn't make it single-use. Enforce that server-side.

## Change notifications: subscriptions/listen

Under the old transport, a client opened a GET stream and the server pushed whatever it felt like. Both halves of that are gone. The GET endpoint is removed, and servers never send an unrequested notification type.

Instead the client POSTs `subscriptions/listen` with a filter of what it wants, and the response _is_ a long-lived SSE stream carrying only those types. The server acknowledges, then tags every notification with `io.modelcontextprotocol/subscriptionId` in `_meta`.

Worth keeping straight: request-scoped notifications like `notifications/progress` and `notifications/message` do **not** ride the listen stream. They flow on the response stream of the request they belong to. The listen stream is for `list_changed` and `resources/updated` only.

On the server side, the SDK handles the plumbing. `createMcpHandler` hands you typed publish sugar:

TypeScript

Copy

```typescript
const handler = createMcpHandler(() => {
  const server = new McpServer(
    { name: "my-server", version: "1.0.0" },
    { capabilities: { tools: {} } }
  );
  server.registerTool(/* ... */);
  return server;
});

// Publish a change to every subscriber:
handler.notify.toolsChanged();
handler.notify.resourceUpdated("file:///projects/app/config.json");
```

That's an in-process bus by default. Running more than one replica? Supply your own `ServerEventBus`, because a notification published on instance two won't reach a stream held open on instance one.

Two more things while you're here. `resources/subscribe` and `resources/unsubscribe` no longer exist; use the `resourceSubscriptions` field of the listen filter. And there's no stream resumption anymore, no `Last-Event-ID`, no replay. If a stream breaks, the in-flight request is lost and the client re-issues it as a new request with a new id. Build for that.

## Caching, and why your list endpoints got faster

Small change, real payoff. Results from `tools/list`, `prompts/list`, `resources/list`, `resources/read`, and `resources/templates/list` must now carry two fields:

JSON

Copy

```json
{
  "tools": [ /* ... */ ],
  "ttlMs": 300000,
  "cacheScope": "public"
}
```

`ttlMs` is a freshness hint. `cacheScope` is `"public"` or `"private"` and controls whether shared intermediaries may cache it. Combined with list endpoints no longer varying per connection (they can't, there are no connections), a plain CDN or reverse proxy can now serve `tools/list` for you.

The SDK defaults to the most conservative policy, `ttlMs: 0` and `cacheScope: 'private'`, so you have to opt into anything useful. Do it. Every client polling `tools/list` on every turn is a cost you're currently paying for nothing.

Related: servers SHOULD return tools in a deterministic order. Stable ordering means stable prompts, which means better prompt-cache hit rates at the model layer. That's free money for a one-line sort.

## What's deprecated, and what to do instead

These still work, and will for at least twelve months under the new lifecycle policy. But don't build anything new on them.

**Roots, Sampling, and Logging** are deprecated as features. The suggested replacements are specific and, I think, correct:

-   **Roots** → pass directories and files as tool parameters, resource URIs, or server configuration. Roots was always a slightly awkward way to say "here's my workspace."
-   **Sampling** → call the LLM provider API directly. Borrowing the client's model was a neat trick that turned out to be a lot of protocol surface for something you can do with an API key.
-   **Logging** → write to `stderr` on stdio, or use OpenTelemetry. Which fits, since this revision also formalizes `traceparent`, `tracestate`, and `baggage` as reserved `_meta` keys for W3C trace context propagation. Your MCP calls can now join the same distributed trace as the rest of your stack, which is a genuine upgrade.

**Dynamic Client Registration** is deprecated in favor of Client ID Metadata Documents. If you followed [our OAuth MCP tutorial](https://foundrysoft.co/blog/oauth-secured-mcp-server), this affects you. Two adjacent hardening rules landed too: authorization servers SHOULD return the `iss` parameter per RFC 9207 and clients MUST validate it, and client credentials are now explicitly bound to the issuer that minted them, so you MUST key persisted credentials by issuer and re-register when it changes.

**The HTTP+SSE transport** from 2024-11-05, deprecated since March 2025, is now formally Deprecated under the lifecycle policy and eligible for removal.

## Actually migrating

Here's the practical sequence. It's two separate migrations and conflating them is the main way this goes badly.

### Step 1: understand the package split

SDK v2 is not a version bump of `@modelcontextprotocol/sdk`. It's a split:

-   `@modelcontextprotocol/core`, schemas and protocol constants
-   `@modelcontextprotocol/client`
-   `@modelcontextprotocol/server`
-   `@modelcontextprotocol/node`, `/hono`, `/fastify`, the runtime adapters
-   `@modelcontextprotocol/server-legacy`, the old surface, for gradual migration

It requires **Node 20+**, and it's ESM-first while still shipping a CommonJS build, so both `import` and `require` resolve.

Be clear-eyed about status: this is a **beta**. The v2 packages went out on July 27 described as the "first beta release of SDK v2 with support for the MCP 2026-07-28 specification revision," and v1 is still at 1.30.0 on `latest`. If your MCP server is load-bearing, migrate on a branch, not on Friday afternoon.

### Step 2: run the codemod

There's an official one, and it does more than rewrite imports:

Shell

Copy

```bash
npx @modelcontextprotocol/codemod@latest v1-to-v2 .
```

Run it at the package root, not `./src`. It rewrites `package.json` too, and real projects import the SDK from tests, scripts, and fixtures.

It handles the import map, symbol renames (`McpError` → `ProtocolError`, `RequestHandlerExtra` → `ServerContext`, `StreamableHTTPError` → `SdkHttpError`), converts `setRequestHandler(Schema, ...)` to string method names, rewrites `.tool()` / `.prompt()` / `.resource()` to the `register*` forms, remaps `extra.*` to `ctx.mcpReq.*` / `ctx.http?.*`, and swaps `IsomorphicHeaders` for the standard `Headers` type.

Then find what it couldn't do safely:

Shell

Copy

```bash
grep -rn '@mcp-codemod-error' .
tsc --noEmit
```

The known manual bits: converting header bracket access to `.get()` (because `Headers` isn't a plain object), OAuth error-class consolidation, picking the right `SdkErrorCode` branch in catch blocks, namespace schema imports it can't split per-symbol, and any file that receives the SDK through dependency injection and therefore has no import for the codemod to follow. Also note that experimental tasks handlers aren't rewritten at all, since tasks moved out to an extension.

The codemod doesn't reformat, so run your formatter afterward. It prints the exact command.

### Step 3: opt into the revision

This is the part people miss. Getting onto v2 does not put a single 2026-07-28 byte on the wire:

> Nothing in v2 puts a 2026-07-28 byte on the wire by default: a hand-constructed `Client` / `Server` / `McpServer` keeps speaking the 2025-era protocol it was written for.

That's a good default. It means step 2 is safe to ship on its own, and you adopt the new revision as a separate, deliberate change.

**Server over HTTP** moves to `createMcpHandler`, which serves both eras from one factory:

TypeScript

Copy

```typescript
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { toNodeHandler } from "@modelcontextprotocol/node";

const handler = createMcpHandler(() => {
  const server = new McpServer(
    { name: "my-server", version: "1.0.0" },
    { capabilities: { tools: {} } }
  );
  // register tools/resources/prompts once; the same factory backs both eras
  return server;
});

// Web-standard runtimes:
export default handler;

// Node frameworks:
app.all("/mcp", toNodeHandler(handler));
```

The factory runs per request and builds a fresh server, which is the stateless model made concrete. If your v1 server was already stateless (`sessionIdGenerator: undefined`, fresh transport per request), which is the shape [our database server tutorial](https://foundrysoft.co/blog/build-mcp-server-claude-database) used, this maps over directly. If it was sessionful, this is where the real work is, and you should have finished deciding which state becomes a handle and which becomes `requestState` before you get here.

On stdio, the equivalent entry is `serveStdio(factory)`.

**Client side** opts in through `versionNegotiation`:

TypeScript

Copy

```typescript
const client = new Client(
  { name: "my-client", version: "1.0.0" },
  { versionNegotiation: { mode: "auto" } }
);

await client.connect(transport);
client.getProtocolEra(); // 'modern' | 'legacy'
```

Three modes. Default (absent, or `'legacy'`) is today's handshake with no probe. `'auto'` probes with `server/discover` and falls back to the 2025 handshake against an old server, costing one extra round trip. `{ pin: '2026-07-28' }` is modern-only and rejects with `SdkError(EraNegotiationFailed)` against a legacy server.

One caveat from the docs worth repeating: `'auto'` is a bad default for spawn-per-invocation CLI and debugging tools. On stdio, a legacy server that ignores unknown pre-`initialize` requests will stall `connect()` for the whole probe timeout before falling back, and the extra round trip changes recorded transcripts. Expose it as a flag there instead.

### Step 4: know what your clients can and can't do

This is the table to look at before you flip anything, because two of these combinations simply fail:

| Client | Server | Outcome |
| --- | --- | --- |
| Modern | Modern | Works |
| Modern | Legacy | **Fails.** No fall-back from the client side |
| Dual-era | Modern | Works, stays modern |
| Dual-era | Legacy | Works, falls back to `initialize` |
| Legacy | Modern | **Fails.** Legacy clients have no fall-forward |
| Legacy | Dual-era | Works, served as legacy |

Read that last-but-one row carefully. **A legacy client hitting a modern-only server fails, and the client has no mechanism to recover.** If anyone else consumes your server, do not go modern-only. Run dual-era, which `createMcpHandler` does by default (`legacy: 'stateless'`), and give your consumers a deprecation window.

If you support legacy clients, the spec also tells you exactly how to respond to their old traffic: `405 Method Not Allowed` for GET or DELETE on the MCP endpoint, ignore any `Mcp-Session-Id` without minting or echoing one, and ignore `Last-Event-ID` since nothing is resumable.

There's one nice payoff in the SDK here. Handlers written in the `inputRequired(...)` style run on **both** eras: a legacy shim turns them back into real server-to-client requests for 2025-era connections and re-enters your handler with the responses. You write the new style once, and it serves everyone. The shim caps at 8 rounds by default versus the modern client driver's 10, because it holds a live wire request open for the whole flow.

## What you actually get for this

I've been describing costs, so let me be fair about the return, because it isn't small.

Your MCP endpoint becomes a normal HTTP service. Round-robin across as many replicas as you want. No sticky sessions, no session store, no affinity rules. An instance dying takes one in-flight request with it, not a session. Autoscaling works the way it does for everything else you run.

List endpoints become cacheable by ordinary infrastructure, and `Mcp-Method` means your gateway can route, rate-limit, and authorize by method without parsing a JSON body.

And every long-lived operation now has an explicit, inspectable state token instead of an implicit blob of server memory. That's harder to write and considerably easier to debug at 2am.

I think this is the right trade. Sessions were the single thing making remote MCP servers annoying to operate, and this deletes the category.

## A migration checklist

If you own an MCP server, here's the order I'd work in:

1.  **Inventory your session state.** Every read of `ctx.sessionId` or `extra.sessionId`. Sort each into "becomes a handle" or "becomes `requestState`." Do this before writing code.
2.  **Inventory your server-initiated requests.** Every `elicitInput`, `requestSampling`, `listRoots`, and `UrlElicitationRequiredError`. Each becomes an `inputRequired(...)` return, and each multi-step flow needs a phase union.
3.  **Get on v2 with the codemod**, ship it, confirm nothing changed on the wire. This step is safe on its own.
4.  **Add `createMcpHandler` / `serveStdio`**, keeping legacy serving on.
5.  **Seal your `requestState`** with `createRequestStateCodec`, bound to principal, method, and a TTL. Wire the verify hook.
6.  **Set real cache fields** on your list endpoints and sort your tools deterministically.
7.  **Move off the deprecated features** on your own schedule: roots to parameters, sampling to a direct provider call, logging to OTel, DCR to Client ID Metadata Documents.
8.  **Only then** consider going modern-only, and only if you know every client.

## Frequently Asked Questions (FAQ)

**Does my existing MCP server stop working on July 28?** No. Nothing breaks on a date. The 2026-07-28 revision is a new protocol version that clients and servers opt into, and the previous revisions keep working. Even after you move to SDK v2, nothing speaks the new revision until you explicitly enable it via `createMcpHandler` / `serveStdio` on the server or `versionNegotiation` on the client. The deprecated features (Roots, Sampling, Logging, HTTP+SSE, Dynamic Client Registration) have a minimum twelve-month window under the new lifecycle policy.

**What replaces `Mcp-Session-Id` for state that has to span requests?** Two things, depending on the shape. State that lives across separate logical operations becomes an explicit server-minted handle passed as an ordinary tool argument, exactly like a cursor or job id. State within one logical operation that is waiting on client input becomes `requestState`, an opaque string the server returns with `input_required` and the client echoes back on retry. Neither is stored in the connection, which is why any server instance can process any request.

**How do I ask the user a question now that elicitation can't be pushed from the server?** Return instead of awaiting. Your `tools/call` handler returns an `InputRequiredResult` with `resultType: "input_required"` and an `inputRequests` map, the original request completes, and the client retries the whole call with `inputResponses` plus your echoed `requestState`. In the TypeScript SDK that's `return inputRequired({ inputRequests: { key: inputRequired.elicit({...}) } })`, and you read the answer back with `acceptedContent(ctx.mcpReq.inputResponses, 'key', schema)`. Only `tools/call`, `prompts/get`, and `resources/read` may return it.

**Is `requestState` secure? Can the client tamper with it?** Yes, it can, and the spec requires you to assume it will. `requestState` passes through the client, so treat it as attacker-controlled. If it influences authorization, resource access, or business logic you MUST integrity-protect it with HMAC or AEAD and reject anything that fails verification. Include the authenticated principal, a short TTL, and an identifier for the originating request inside the protected payload to block cross-user and cross-request replay. `createRequestStateCodec` handles the sealing, but note it signs rather than encrypts, so the client can read the payload. Single-use semantics still need server-side enforcement.

**Will old clients break against a server on the new revision?** Yes, if you serve only the new revision. A legacy client that expects an `initialize` handshake hitting a modern-only server fails, and it has no fall-forward mechanism to recover. This is why `createMcpHandler` defaults to `legacy: 'stateless'` and serves both eras from one factory on one endpoint. Keep dual-era serving on until you know every client that talks to you has moved.

**Do I have to rewrite my handlers twice to support both eras?** No, and this is the best part of the SDK design. Write handlers once in the 2026 `inputRequired(...)` style and the SDK's legacy shim serves them to 2025-era connections by converting each embedded request back into a real server-to-client request over the live session. The handler can't tell which era fulfilled it. The shim allows 8 handler re-entries per request by default, slightly tighter than the modern client driver's 10.

**What happened to Tasks?** They moved out of the experimental core into an official extension, `io.modelcontextprotocol/tasks`, and got redesigned along the way. The blocking `tasks/result` is replaced by polling with `tasks/get`, there's a new `tasks/update` for client-to-server input, `tasks/list` is gone, and servers can now return task handles unsolicited without a per-request opt-in. The codemod deliberately does not rewrite task handler registrations; it flags them for you to handle by hand.

## Where this leaves things

This revision is the moment MCP stopped being a protocol designed around a local subprocess and became one designed around a deployed service. The handshake, the session, the server pushing requests down an open channel: all of that is the shape of a thing running on your laptop. Deleting it is what it takes for MCP servers to be ordinary infrastructure.

The migration is real work. The MRTR inversion in particular will make you rewrite handlers, not just rename imports, and the beta status of v2 means I'd branch rather than ship straight to production this week. But the end state is a server you can run four replicas of behind a dumb load balancer, which is what most teams wanted from the beginning.

Next in this series, we're rewriting [the OAuth MCP server](https://foundrysoft.co/blog/oauth-secured-mcp-server) on Client ID Metadata Documents, since Dynamic Client Registration is now on the clock too.

#### Related reading

[Why Trusting AI Generated Code Is the Wrong Goal

Trusting AI generated code was never the right goal, and the 4 percent of developers who say they fully trust it prove nothing is broken: the fix is an AI code review process that makes verification cheap instead of asking how much to trust the output.

AI Code Review Developer Trust Code Quality

](https://foundrysoft.co/blog/developers-dont-trust-ai-generated-code)[Five AI Agents, One Bug: When Missing Data Looks Like a Clean Result

We built and shipped five open-source vertical AI agents. Every single one had the same class of defect: absent or unreadable input rendered as a confident, clean answer. Here is what that bug looks like, why tests miss it, and what actually catches it.

AI Agents Testing Open Source

](https://foundrysoft.co/blog/five-ai-agents-one-bug-missing-data-clean-result)[Best Open Weight LLMs for Agents in 2026

A practical look at the best open weight LLMs for agents in 2026, organized by which constraint, cost, latency, or data residency, should actually decide the pick.

Open Weight LLMs AI Agents LLM Comparison

](https://foundrysoft.co/blog/best-open-weight-llms-agents-2026)

#### Next Article

[

How to Use Stacked Pull Requests on GitHub

](https://foundrysoft.co/blog/stacked-pull-requests-github-guide)

Available for new projects

## Let's build something great.

Have a project in mind? We are an elite software and AI development studio ready to bring your ideas to production. Let's talk about your roadmap.

Start a Project [See our work](https://foundrysoft.co/work)

```json
{
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "FoundrySoft",
  "url": "https://foundrysoft.co",
  "logo": "https://foundrysoft.co/logo.svg",
  "description": "FoundrySoft builds production-grade software and AI systems for US companies, from an India-based team of senior engineers.",
  "sameAs": [
    "https://github.com/foundrysofthq",
    "https://www.linkedin.com/company/foundrysoft",
    "https://www.instagram.com/foundrysoft/"
  ]
}
```

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "FoundrySoft",
  "url": "https://foundrysoft.co"
}
```

```json
{
  "@context": "https://schema.org",
  "@type": "TechArticle",
  "headline": "MCP Went Stateless: Migrating Your Server to the 2026-07-28 Spec",
  "description": "The 2026-07-28 MCP revision removes sessions, the initialize handshake, and server-initiated requests. Here's what actually breaks in your server, the new wire format, the requestState and MRTR patterns that replace sessions, and the SDK v2 migration path.",
  "url": "https://foundrysoft.co/blog/mcp-stateless-spec-migration",
  "mainEntityOfPage": "https://foundrysoft.co/blog/mcp-stateless-spec-migration",
  "image": [
    "https://foundrysoft.co/api/og?type=article&title=MCP+Went+Stateless%3A+Migrating+Your+Server+to+the+2026-07-28+Spec&cat=Tutorial+%2F%2F+MCP&rt=21+min+read&au=Varun+Raj+Manoharan&dt=2026-07-31"
  ],
  "datePublished": "2026-07-31",
  "dateModified": "2026-07-31",
  "keywords": "MCP, Model Context Protocol, AI Agents, TypeScript, Migration",
  "author": {
    "@type": "Person",
    "name": "Varun Raj Manoharan"
  },
  "publisher": {
    "@type": "Organization",
    "name": "FoundrySoft",
    "logo": {
      "@type": "ImageObject",
      "url": "https://foundrysoft.co/logo.svg"
    }
  }
}
```

```json
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Home",
      "item": "https://foundrysoft.co/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "Blog",
      "item": "https://foundrysoft.co/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "MCP Went Stateless: Migrating Your Server to the 2026-07-28 Spec",
      "item": "https://foundrysoft.co/blog/mcp-stateless-spec-migration"
    }
  ]
}
```

```json
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "Does my existing MCP server stop working on July 28?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "No. Nothing breaks on a date. The 2026-07-28 revision is a new protocol version that clients and servers opt into, and the previous revisions keep working. Even after you move to SDK v2, nothing speaks the new revision until you explicitly enable it via createMcpHandler / serveStdio on the server or versionNegotiation on the client. The deprecated features (Roots, Sampling, Logging, HTTP+SSE, Dynamic Client Registration) have a minimum twelve-month window under the new lifecycle policy."
      }
    },
    {
      "@type": "Question",
      "name": "What replaces Mcp-Session-Id for state that has to span requests?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Two things, depending on the shape. State that lives across separate logical operations becomes an explicit server-minted handle passed as an ordinary tool argument, exactly like a cursor or job id. State within one logical operation that is waiting on client input becomes requestState, an opaque string the server returns with input_required and the client echoes back on retry. Neither is stored in the connection, which is why any server instance can process any request."
      }
    },
    {
      "@type": "Question",
      "name": "How do I ask the user a question now that elicitation can't be pushed from the server?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Return instead of awaiting. Your tools/call handler returns an InputRequiredResult with resultType: \"input_required\" and an inputRequests map, the original request completes, and the client retries the whole call with inputResponses plus your echoed requestState. In the TypeScript SDK that's return inputRequired({ inputRequests: { key: inputRequired.elicit({...}) } }), and you read the answer back with acceptedContent(ctx.mcpReq.inputResponses, 'key', schema). Only tools/call, prompts/get, and resources/read may return it."
      }
    },
    {
      "@type": "Question",
      "name": "Is requestState secure? Can the client tamper with it?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Yes, it can, and the spec requires you to assume it will. requestState passes through the client, so treat it as attacker-controlled. If it influences authorization, resource access, or business logic you MUST integrity-protect it with HMAC or AEAD and reject anything that fails verification. Include the authenticated principal, a short TTL, and an identifier for the originating request inside the protected payload to block cross-user and cross-request replay. createRequestStateCodec handles the sealing, but note it signs rather than encrypts, so the client can read the payload. Single-use semantics still need server-side enforcement."
      }
    },
    {
      "@type": "Question",
      "name": "Will old clients break against a server on the new revision?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Yes, if you serve only the new revision. A legacy client that expects an initialize handshake hitting a modern-only server fails, and it has no fall-forward mechanism to recover. This is why createMcpHandler defaults to legacy: 'stateless' and serves both eras from one factory on one endpoint. Keep dual-era serving on until you know every client that talks to you has moved."
      }
    },
    {
      "@type": "Question",
      "name": "Do I have to rewrite my handlers twice to support both eras?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "No, and this is the best part of the SDK design. Write handlers once in the 2026 inputRequired(...) style and the SDK's legacy shim serves them to 2025-era connections by converting each embedded request back into a real server-to-client request over the live session. The handler can't tell which era fulfilled it. The shim allows 8 handler re-entries per request by default, slightly tighter than the modern client driver's 10."
      }
    },
    {
      "@type": "Question",
      "name": "What happened to Tasks?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "They moved out of the experimental core into an official extension, io.modelcontextprotocol/tasks, and got redesigned along the way. The blocking tasks/result is replaced by polling with tasks/get, there's a new tasks/update for client-to-server input, tasks/list is gone, and servers can now return task handles unsolicited without a per-request opt-in. The codemod deliberately does not rewrite task handler registrations; it flags them for you to handle by hand."
      }
    }
  ]
}
```
