The 2026-07-28 MCP spec makes the protocol stateless: no initialize handshake, no Mcp-Session-Id, capabilities in _meta on every request. That's good news for how you run a server — any replica answers any request — but it means real changes to how you write one. This is the concrete migration, step by step, with the before-and-after for each.

The code below is illustrative TypeScript-flavored pseudocode; the shape is what matters, not the exact SDK surface, which is still settling as of the release candidate.

Step 1 — Delete the handshake, read _meta#

The old server had a lifecycle: complete initialize, remember the client's capabilities, then serve requests against that remembered session. Delete all of it. Capabilities now arrive on every request inside _meta, so your handler reads them fresh each time.

// BEFORE — capabilities learned once, at handshake
server.on("initialize", (params) => {
  session.capabilities = params.capabilities;   // <- stored per connection
  return { protocolVersion, serverInfo };
});
server.on("tools/call", (req) => {
  const caps = session.capabilities;             // <- read from memory
  // ...
});

// AFTER — capabilities travel with the request
server.on("tools/call", (req) => {
  const caps = req._meta?.clientCapabilities;    // <- read from _meta, every time
  const clientInfo = req._meta?.clientInfo;
  // no session object exists; nothing is remembered between calls
});

If any part of your server keys behavior off a stored session, that's the code to pull out. The mental model: each request is a stranger that introduces itself.

Step 2 — Rewrite elicitation as Multi Round-Trip Requests#

This is the step that changes your handler's shape, so it's the one to get right.

Before, when a tool needed input mid-run — a confirmation, a missing argument — the server pushed a prompt down a held-open SSE stream and awaited the reply in place. The function stayed on the stack, in memory, on one instance, the whole time.

Stateless kills that. Instead, your tool returns a request for input and ends. The client collects the answer and re-issues the original call. Your handler runs again — possibly on a different instance — and resumes from a requestState blob you handed out the first time.

// BEFORE — block mid-handler on a pushed prompt
server.on("tools/call", async (req) => {
  const region = await server.elicit({           // <- held connection, in-memory wait
    prompt: "Which region?",
    schema: RegionSchema,
  });
  return deploy(req.args, region);
});

// AFTER — return, then resume from state the client carries back
server.on("tools/call", async (req) => {
  if (!req.inputResponses?.region) {
    return {
      type: "InputRequiredResult",             // <- return, don't block
      inputRequests: [
        { name: "region", schema: RegionSchema, prompt: "Which region?" },
      ],
      requestState: seal({                       // opaque to the client
        tool: "deploy",
        args: req.args,
        step: "awaiting-region",
      }),
    };
  }
  const state = open(req.requestState);          // client echoed it back
  return deploy(state.args, req.inputResponses.region);
});

Two rules make this safe:

  1. requestState must be self-sufficient. It has to carry everything needed to resume from a cold instance — tool name, args so far, a step marker, any partial work. Treat it as a continuation token, not a session ID.
  2. It round-trips through the client, so protect it. Sign it (to detect tampering) or encrypt it (if it holds anything the client shouldn't read). Never trust it blindly on the way back in.

If your tool needs several rounds of input, add more step values and branch on them. It's a small state machine where the state lives in the payload instead of on the stack.

Step 3 — Move Tasks to the handle lifecycle#

Long-running work switches from the experimental Tasks core feature to the extension's handle-based lifecycle. A tools/call returns a handle immediately; the client polls and steers with tasks/get, tasks/update, and tasks/cancel.

// AFTER — hand back a handle, let the client drive it
server.on("tools/call", (req) => {
  const handle = tasks.start(() => runLongJob(req.args));
  return { taskHandle: handle };                 // client polls tasks/get
});

Note what's missing: tasks/list is gone. A stateless server has no per-session inventory to enumerate, so if your client wants a list of in-flight tasks, it remembers the handles it was given. Don't build a feature on a server-side task registry keyed by session — there is no session.

Step 4 — Fix the missing-resource error code#

The smallest change and the easiest to forget, because it fails silently. A missing resource now returns the JSON-RPC-standard -32602, not the old MCP-specific -32002.

// BEFORE
throw new RpcError(-32002, "Resource not found");
// AFTER
throw new RpcError(-32602, "Resource not found");

Grep your codebase for -32002 on both the server and any client you own — a client that still switches on the old code will stop recognizing the error entirely and take the wrong branch with no exception to tell you.

Step 5 — Emit the required headers#

Mcp-Method and Mcp-Name are now mandatory on requests. If you own the transport layer, make sure they're set; if you use an SDK, upgrade it so it emits them for you.

headers.set("Mcp-Method", req.method);
headers.set("Mcp-Name", serverName);

Step 6 — While you're in here: shed the deprecated primitives#

These aren't on the July 28 clock — Sampling, Roots, and Logging keep working for at least twelve months as annotation-only deprecations. But they're exactly the features the old socket existed to justify, so a stateless migration is the natural moment to retire them:

And pick up the two operational niceties the new spec adds: return ttlMs + cacheScope on cacheable results, and propagate W3C traceparent so a tool call shows up in the same distributed trace as everything else it touches.

The one-line test#

You've migrated correctly when this is true: you can kill any server instance mid-conversation, and the next request lands on a fresh one and just works. No sticky session, no lost elicitation, no orphaned task. If any flow in your server can't survive that, it's still holding state the socket used to hold for it — and that's the thread to pull.

The final spec lands July 28, 2026. Old servers keep serving old clients, but new clients speak stateless, so migrate before you want their traffic — not after.