Backend
MCP 2026-07-28 Spec Migration: Stateless Guide for Servers
October 202612 min read

The protocol became stateless. The initialize handshake and the Mcp-Session-Id header are gone, and every request carries its protocol version and client capabilities in _meta. Server-initiated requests are replaced by Multi Round-Trip Requests, and Streamable HTTP adds required Mcp-Method and Mcp-Name headers plus cache hints on list results.
Move any state that must outlive one call into shared storage and hand the client an explicit, server-minted handle as an ordinary tool argument. For state that only spans one multi-step interaction, use an integrity-protected requestState that the client echoes back. Either way, check on every call that the caller is allowed to use the handle.
They replace server-to-client requests such as elicitation/create and sampling/createMessage. The server returns a result with resultType input_required and an inputRequests map, and the client retries the original call with a new request id, matching inputResponses and the exact requestState. Only tools/call, prompts/get and resources/read may return input_required.
Not for the 2026-07-28 revision, because every request is self-contained and no protocol session exists. You can use round-robin or least-connections, and route by the Mcp-Method and Mcp-Name headers without parsing the JSON body. Servers must still validate those headers against the body and reject mismatches with error -32020.
Roots, Sampling and Logging are deprecated, as are Dynamic Client Registration in favour of Client ID Metadata Documents and the legacy HTTP+SSE transport. Deprecated features keep working for at least twelve months under the new lifecycle policy. Some items are removed outright, including ping, logging/setLevel, the GET stream endpoint and Last-Event-ID resumption.

Key Takeaway
The MCP 2026-07-28 specification removes the initialize handshake and the Mcp-Session-Id header, so every request carries its own version and capabilities in _meta. Migrating a server means moving session state into explicit handles or signed requestState, returning input_required instead of server-initiated elicitation, sending cache hints on list results, and dropping sticky sessions at the load balancer.
The MCP 2026-07-28 spec migration is the first one that touches every server, not just the ones using a new feature. I read the changelog line by line against the shape of a typical sessionful TypeScript server: a map of transports keyed by Mcp-Session-Id, a draft object hanging off that same id, an elicitation call that waits on an open SSE stream, and an nginx upstream pinned with ip_hash so the session stays on the node that created it. Every one of those four things is either removed or reworked in this revision.
This guide walks through each breaking change in the order you will meet it, with the before-and-after code for a TypeScript server on the v2 SDK packages and the load-balancer config that no longer needs affinity. Everything here is taken from the 2026-07-28 specification, its changelog, the announcement post by the lead maintainers, and the TypeScript SDK's own migration guide. The announcement lists TypeScript, Python, Go and C# as Tier 1 SDKs supporting the revision at release, with Rust in beta.
The headline is that MCP is now stateless at the protocol level. SEP-2575 removes the initialize and notifications/initialized exchange, and SEP-2567 removes protocol-level sessions and the Mcp-Session-Id header. Everything else in the table follows from that decision: once no request can rely on an earlier one, server-to-client requests, change notifications and stream resumption all need a new shape.
| Area | 2025-11-25 | 2026-07-28 | What you change |
|---|---|---|---|
| Connection setup | initialize, then notifications/initialized | No handshake. Version, client capabilities and client info travel in each request's _meta. Servers must implement server/discover | Read identity per request, not per connection |
| Sessions | Mcp-Session-Id header, DELETE to end | Removed. List results no longer vary per connection | Replace session-keyed state with explicit handles |
| Server-to-client requests | elicitation/create, sampling/createMessage, roots/list sent on an SSE stream | Multi Round-Trip Requests: return resultType input_required, client retries with inputResponses | Rewrite blocking calls as return-and-retry |
| Change notifications | HTTP GET stream, resources/subscribe | One subscriptions/listen POST stream the client opts in to | Publish through an event bus shared by all nodes |
| HTTP headers | MCP-Protocol-Version | Adds required Mcp-Method and Mcp-Name, optional Mcp-Param-* via x-mcp-header | Validate headers against the body, route on them |
| List results | No cache metadata | ttlMs and cacheScope required on tools/list, prompts/list, resources/list, resources/read, resources/templates/list | Declare cache hints per operation |
| Log level | logging/setLevel RPC | io.modelcontextprotocol/logLevel in each request's _meta | Expect silence when the client omits it |
| Stream resumption | Last-Event-ID replay | Removed. A broken stream loses the request, which the client re-issues with a new id | Delete the event store |
| Tasks | Experimental, in core | io.modelcontextprotocol/tasks extension, polled with tasks/get, input via tasks/update | Re-implement against the extension if you used it |
Two smaller rules catch people out. Every result now carries a required resultType field, either complete or input_required, and clients must treat a result from an older server that omits it as complete. And the resource-not-found error code moved from -32002 to -32602, the standard JSON-RPC Invalid Params code, so any client-side check on the old number silently stops matching.
Under 2025-11-25 a client spent two messages on initialize before it could call a tool, and the server minted a session id that every later request had to present. Under 2026-07-28 the first request can be a tools/call. Its _meta carries io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities, and should carry io.modelcontextprotocol/clientInfo. The server should answer with io.modelcontextprotocol/serverInfo in each result's _meta. The HTTP side mirrors the body: the MCP-Protocol-Version header must match the _meta version, or the server rejects the request.
# BEFORE (2025-11-25): two handshake messages, then every call
# leans on a session the server minted and must remember.
POST /mcp {"method":"initialize", ...}
<- 200 Mcp-Session-Id: 1868a90c-4c1e-4b6e-9a0f-2f1d1c0c9e11
POST /mcp {"method":"notifications/initialized"}
POST /mcp
Mcp-Session-Id: 1868a90c-4c1e-4b6e-9a0f-2f1d1c0c9e11
MCP-Protocol-Version: 2025-11-25
{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"post_journal","arguments":{"draftId":"JD-2026-0412"}}}
# AFTER (2026-07-28): one self-contained POST. Any node can answer it.
POST /mcp
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: post_journal
{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"post_journal","arguments":{"draftId":"JD-2026-0412"},
"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"finance-desk","version":"3.2.0"},
"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
# Header and body disagree (or a required header is missing):
# 400 Bad Request {"error":{"code":-32020,"message":"Header mismatch: ..."}}
# Version the server does not speak:
# 400 Bad Request UnsupportedProtocolVersionError (-32022) + supported listserver/discover is the one new method every server must implement. It advertises supported versions, capabilities and identity, and clients may call it first for up-front version selection or as a backward-compatibility probe. The practical consequence for server authors is that capability checks move from connection setup into the request handler. If a tool wants to ask the user something, it must look at the clientCapabilities on that request, because the spec forbids sending an input request the client did not declare support for.
The v1 SDK pattern for a sessionful Streamable HTTP server is a map from session id to transport, a route that handles POST, GET and DELETE, and an initialize check that creates the transport. In the v2 packages the 2026-07-28 entry point is createMcpHandler from @modelcontextprotocol/server, which builds a fresh server from your factory for every request and holds nothing in between. Below is the same journal-draft server in both shapes.
// BEFORE: @modelcontextprotocol/sdk v1, sessionful Streamable HTTP.
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
const sessions = new Map<string, StreamableHTTPServerTransport>();
// The real problem: business state keyed by a protocol session id,
// living in ONE process. A second replica cannot see it.
const draftsBySession = new Map<string, JournalDraft>();
const route = async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (sessionId && sessions.has(sessionId)) {
return sessions.get(sessionId)!.handleRequest(req, res, req.body);
}
if (!sessionId && isInitializeRequest(req.body)) {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => sessions.set(id, transport),
});
await buildServer().connect(transport);
return transport.handleRequest(req, res, req.body);
}
res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Session ID required" }, id: null });
};
app.post("/mcp", route);
app.get("/mcp", route); // standalone SSE stream for server pushes
app.delete("/mcp", route); // session teardown
// AFTER: v2 packages, one factory, a fresh server per request.
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";
function buildServer(): McpServer {
const server = new McpServer(
{ name: "erp-journal", version: "2.0.0" },
{ capabilities: { tools: {} } },
);
// SEP-2567: cross-call state becomes an explicit, server-minted handle
// passed as an ordinary tool argument, stored where every node can read it.
server.registerTool(
"create_journal_draft",
{ description: "Open a journal draft", inputSchema: z.object({ memo: z.string() }) },
async ({ memo }, ctx) => {
const draftId = await drafts.insert({ memo, owner: ctx.http?.authInfo?.clientId });
return { content: [{ type: "text", text: "Draft " + draftId + " opened" }] };
},
);
server.registerTool(
"add_journal_line",
{
description: "Add a debit or credit line to a draft",
inputSchema: z.object({ draftId: z.string(), account: z.string(), amount: z.number() }),
},
async ({ draftId, account, amount }, ctx) => {
// The handle is client-visible: check ownership on EVERY call.
await drafts.assertOwner(draftId, ctx.http?.authInfo?.clientId);
await drafts.addLine(draftId, { account, amount });
return { content: [{ type: "text", text: "Line added to " + draftId }] };
},
);
return server;
}
// Default legacy: 'stateless' also answers 2025-era clients per request.
const handler = createMcpHandler(buildServer);
const app = createMcpExpressApp({ host: "0.0.0.0", allowedHosts: ["mcp.example.com"] });
const node = toNodeHandler(handler);
app.all("/mcp", (req, res) => void node(req, res, req.body));
app.listen(3000);Three details matter here. First, v2 does not opt you in by accident: the SDK guide states that a hand-constructed Server or McpServer keeps speaking the 2025-era protocol until you serve it through createMcpHandler or serveStdio. Second, the default legacy: 'stateless' option serves 2025-era clients from the same factory, but only the stateless idiom. If your v1 server was sessionful, the guide's route is a strict createMcpHandler with legacy: 'reject', with isLegacyRequest sending old traffic to your existing handler. Third, the draft id is now visible to the client and typed by the model, so ownership must be checked on every call, not assumed from a session.
SEP-2322 removes the server-to-client request channel entirely. A tool that needs confirmation returns an InputRequiredResult with resultType input_required, an inputRequests map of elicitation, sampling or roots requests, and an optional opaque requestState. The client gathers the answers and retries the original call with a new JSON-RPC id, inputResponses keyed to match, and the exact requestState string. Only tools/call, prompts/get and resources/read may return it. In the TypeScript SDK this is the inputRequired helper, read back with acceptedContent and ctx.mcpReq.requestState.
// BEFORE (v1): the handler blocks on a server->client request that
// travels back over the open SSE stream of THIS process.
const answer = await server.server.elicitInput({
message: "Post draft " + draftId + " to the ledger?",
requestedSchema: {
type: "object",
properties: { confirm: { type: "boolean" } },
required: ["confirm"],
},
});
// AFTER (2026-07-28): return input_required and finish on the retry,
// which may land on a different node.
import {
acceptedContent, createRequestStateCodec, inputRequired, McpServer,
} from "@modelcontextprotocol/server";
import type { CallToolResult, InputRequiredResult } from "@modelcontextprotocol/server";
const CONFIRM = z.object({ confirm: z.boolean() });
type PostState = { step: "confirm"; draftId: string };
// HMAC-signed, NOT encrypted: the client can read it, but not forge it.
// The key must be shared by every replica, or a retry that lands on
// another node fails verification with -32602.
const stateCodec = createRequestStateCodec<PostState>({
key: Buffer.from(process.env.MCP_STATE_KEY!, "base64"),
ttlSeconds: 300,
});
const server = new McpServer(
{ name: "erp-journal", version: "2.0.0" },
{ capabilities: { tools: {} }, requestState: { verify: stateCodec.verify } },
);
server.registerTool(
"post_journal",
{ description: "Post a balanced draft to the ledger", inputSchema: z.object({ draftId: z.string() }) },
async ({ draftId }, ctx): Promise<CallToolResult | InputRequiredResult> => {
const state = ctx.mcpReq.requestState<PostState>(); // already verified
const answer = acceptedContent(ctx.mcpReq.inputResponses, "confirm", CONFIRM);
// Wrong: trusting answer alone. Bind the confirmation to THIS draft.
if (state?.draftId !== draftId || answer?.confirm !== true) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: "Post draft " + draftId + " to the ledger?",
requestedSchema: CONFIRM,
}),
},
requestState: await stateCodec.mint({ step: "confirm", draftId }),
});
}
// requestState is replayable inside its TTL. Single-use must be
// enforced here: posting an already-posted draft is a no-op.
const posted = await ledger.postOnce(draftId);
return { content: [{ type: "text", text: posted ? "Posted " + draftId : draftId + " was already posted" }] };
},
);The SDK's legacy shim lets you write the handler once in this style and still serve 2025-era clients, by sending real server-to-client requests over the live session. The guide is explicit about the limit: on stateless legacy HTTP there is no return path, so the shim degrades to a clean capability refusal. Also note that inputResponses are per round and never accumulate. A flow with two questions must carry the first answer forward inside requestState, as a discriminated union of phases, rather than expecting both answers on the final retry.
requestState passes through the client, so the spec requires servers to treat it as attacker-controlled and to integrity-protect it with HMAC or AEAD whenever it influences authorisation or business logic. createRequestStateCodec signs it but does not encrypt it, so never put secrets inside. And signing does not make it single-use: within its TTL the same state can be replayed, so an ERP posting or payment must be made idempotent on the server side.
SEP-2243 makes Mcp-Method required on every Streamable HTTP request and Mcp-Name required on tools/call, resources/read and prompts/get, carrying params.name or params.uri. Its stated purpose is that gateways, WAFs and load balancers can route and meter without parsing JSON. Combined with the stateless core, the upstream no longer needs affinity at all, and you can send expensive tools to a separately sized pool by name.
# BEFORE: sessions live in one node's memory, so the client must be pinned.
# hash $http_mcp_session_id does NOT work: initialize carries no session
# header, so the session is minted on one node and later requests hash
# to another. Client IP was the usual workaround, and NAT broke it.
upstream mcp_nodes {
ip_hash;
server 10.0.0.11:3000;
server 10.0.0.12:3000;
}
# AFTER: every request is self-contained. Spread load however you like,
# and route on the mirrored headers without parsing JSON bodies.
upstream mcp_nodes {
least_conn;
server 10.0.0.11:3000;
server 10.0.0.12:3000;
server 10.0.0.13:3000;
}
upstream mcp_reporting {
server 10.0.0.21:3000; # same code, sized for long report tools
}
# Mcp-Name arrives as $http_mcp_name. A Base64-sentinel value
# (=?base64?...?=) will not match and falls through to the default.
map $http_mcp_name $mcp_pool {
default mcp_nodes;
run_aging_report mcp_reporting;
run_month_end_close mcp_reporting;
}
server {
listen 443 ssl;
server_name mcp.example.com;
location = /mcp {
# GET and DELETE are gone in 2026-07-28; the spec says answer 405.
if ($request_method != POST) { return 405; }
proxy_pass http://$mcp_pool;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_buffering off; # per-request SSE responses
proxy_read_timeout 300s; # subscriptions/listen stays open
}
}The safety rule behind header routing is that the node must check the headers against the body. A missing or mismatched header returns 400 with JSON-RPC error -32020 HeaderMismatch, and names that are not plain ASCII arrive in a =?base64?...?= sentinel form, which the server must decode before comparing. That is why the pools above run the same code: a lying header gets the request rejected by whichever node receives it, rather than executing a different tool. The spec also tells intermediaries enforcing policy on these headers to reject requests whose MCP-Protocol-Version predates header validation.
SEP-2549 adds a CacheableResult interface. tools/list, prompts/list, resources/list, resources/read and resources/templates/list must return ttlMs, a freshness hint in milliseconds, and cacheScope, either public or private, which tells shared intermediaries whether they may cache the response. The changelog also asks servers to return tools in a deterministic order, which helps client caches and the model provider's prompt cache. In the TypeScript SDK the policy is set with ServerOptions.cacheHints.
const server = new McpServer(
{ name: "erp-journal", version: "2.0.0" },
{
capabilities: { tools: {}, resources: {} },
cacheHints: {
// Same tool catalogue for every caller: shared caches may keep it.
"tools/list": { ttlMs: 60_000, cacheScope: "public" },
// Chart of accounts filtered by the caller's company: never public.
"resources/read": { ttlMs: 5_000, cacheScope: "private" },
},
},
);
// With no hint the SDK emits ttlMs: 0 and cacheScope: "private",
// so nothing is ever served from cache. The client caps any ttlMs at
// 24 hours, and a list_changed notification still evicts early.The SDK defaults are deliberately conservative, ttlMs 0 and private, so migrating changes nothing until you opt in. Mark a result public only when it is identical for every caller. In an ERP server most resources are filtered by company, role or warehouse, and those must stay private, because a public hint invites a shared cache to hand one tenant's chart of accounts to another.
Logging changes quietly. logging/setLevel is removed, and servers must not emit notifications/message for a request whose _meta lacks io.modelcontextprotocol/logLevel. The TypeScript guide notes that its own Client does not attach that key by default, so ctx.mcpReq.log() output vanishes after the upgrade. Move operational logs to stderr or OpenTelemetry, which is also what the Logging deprecation recommends.
This revision also introduces a formal feature lifecycle with Active, Deprecated and Removed states and a minimum twelve-month deprecation window, tracked in a registry of deprecated features. Deprecated features still work during that window, but new implementations should not adopt them.
| Feature | Change | Suggested replacement |
|---|---|---|
| Roots | Deprecated by SEP-2577 | Pass directories or files as tool parameters, resource URIs or server configuration |
| Sampling | Deprecated by SEP-2577 | Call the LLM provider's API directly from the server |
| Logging | Deprecated by SEP-2577 | stderr on stdio, OpenTelemetry elsewhere |
| Dynamic Client Registration | Deprecated by PR 2858, kept for older authorization servers | Client ID Metadata Documents |
| HTTP+SSE transport, 2024-11-05 | Reclassified as Deprecated by SEP-2596 | Streamable HTTP |
| includeContext thisServer and allServers | Reclassified as Deprecated by SEP-2596 | Omit the field or use none |
Deprecated and removed are different lists. ping, logging/setLevel, notifications/roots/list_changed, the GET stream endpoint and Last-Event-ID resumability are gone from 2026-07-28 outright, with no window. The authorisation side tightens too: clients must validate the RFC 9207 iss parameter before redeeming a code, must key stored credentials by issuer and never reuse them with another authorization server, and must send an application_type during Dynamic Client Registration.
The order matters because the stateless core removes the floor that the other changes stand on. Doing it in this sequence lets every step ship on its own while 2025-era clients keep working.
Steps one and two carry most of the risk, and they are also the ones that pay back first: once nothing lives in process memory between requests, the server scales by adding replicas, and a rolling deploy no longer drops the conversations that happened to be pinned to the node being replaced.
The SDK guide notes there is no in-memory serving entry for 2026-07-28, because InMemoryTransport only links 2025-era instances. Test the modern path by giving a StreamableHTTPClientTransport a fetch function that calls handler.fetch directly. The URL is never dialled, so header validation, MRTR retries and cache hints all run in-process inside a unit test.
The rule I took from this revision is simple: if a value must survive between two MCP requests, it belongs in your database or in a signed requestState, never in the transport. Servers already built that way migrate in an afternoon. Servers that hung business state off Mcp-Session-Id have a real refactor ahead, and the twelve-month deprecation window is the time to do it, not a reason to wait.