The 2026-07-28 MCP spec kills the session header and initialize handshake. Here's how to migrate a stateful Streamable-HTTP server without breaking it.
If you run a remote MCP server over Streamable HTTP, the 2026-07-28 release candidate changes how it deploys, not just what it returns. Two things you probably rely on without noticing are gone: the initialize handshake and the Mcp-Session-Id header. Every tools/list and resources/list response stops varying per connection. Bump your SDK and redeploy, and a server that quietly leaned on either one will start throwing UnsupportedProtocolVersionError or losing state between calls that used to share a session.
I hit this migrating an internal server that fronts a document pipeline. It looked stateless from the outside, but it wasn't. Here's what actually broke, what the fix looks like in code, and the infrastructure gotcha that cost me an afternoon of blaming the wrong layer.
The RC completes a plan MCP has been building toward since late 2025. Two SEPs carry most of the weight. SEP-2567 removes protocol-level sessions and the Mcp-Session-Id header from Streamable HTTP. SEP-2575 removes the initialize / notifications/initialized exchange entirely. Instead of a handshake, every request now carries its protocol version, client identity, and client capabilities inline in _meta, under keys like io.modelcontextprotocol/protocolVersion.
The word "stateless" is doing something specific and it's worth being precise, because it's the part people get wrong. It does not mean your server can't have state. It means the protocol no longer holds state on your behalf. Before, the transport handed you a session id and you hung a context object off it. Now, if two calls need to share anything, you mint an explicit handle and pass it back and forth as an ordinary tool argument. The state didn't disappear. It moved from the transport into your payloads, where any server instance can pick it up.
That's the whole point: no sticky routing, no shared session store just to keep a connection coherent, no one instance that "owns" a client. You can scale a remote MCP server behind a plain round-robin load balancer, the same way you'd scale any stateless HTTP service.
Here's the shape of the thing I had. A tool starts a long export, and a second tool checks on it. The link between them was the session id the transport gave me for free.
// BEFORE (2025-11-25 style) — state keyed off the transport session
const sessions = new Map();
server.setRequestHandler("initialize", (req, ctx) => {
// transport minted ctx.sessionId for us
sessions.set(ctx.sessionId, { exports: new Map() });
return { capabilities: { tools: {} } };
});
server.tool("start_export", async ({ documentId }, ctx) => {
const s = sessions.get(ctx.sessionId); // implicit per-connection state
const jobId = crypto.randomUUID();
s.exports.set(jobId, startJob(documentId));
return { content: [{ type: "text", text: jobId }] };
});
server.tool("check_export", async ({ jobId }, ctx) => {
const s = sessions.get(ctx.sessionId); // same connection assumed
const job = s.exports.get(jobId);
return { content: [{ type: "text", text: job.status }] };
});
This works right up until a load balancer sends check_export to a different instance than start_export, or the transport stops minting a session id at all. Under the RC, both happen. ctx.sessionId is undefined, the Map lookup returns nothing, and the second tool call insists the job doesn't exist.
The migration is mechanical once you see it. Kill the per-connection map. Make the job id a real, server-issued handle that stands on its own, and put the job state somewhere any instance can read — Redis, Postgres, whatever you already run. The handle travels in the tool arguments, so there's nothing to pin.
// AFTER (2026-07-28 style) — explicit handle, shared backing store
import { createClient } from "redis";
const store = createClient({ url: process.env.REDIS_URL });
await store.connect();
server.tool("start_export", async ({ documentId }, ctx) => {
// protocol version now rides in _meta, not a handshake
const version = ctx._meta?.["io.modelcontextprotocol/protocolVersion"];
const jobId = `exp_${crypto.randomUUID()}`;
await store.set(jobId, JSON.stringify(startJob(documentId)), { EX: 3600 });
return {
content: [{ type: "text", text: jobId }],
_meta: { protocolVersion: version }
};
});
server.tool("check_export", async ({ jobId }) => {
const raw = await store.get(jobId); // any instance can resolve it
if (!raw) return { content: [{ type: "text", text: "unknown handle" }], isError: true };
return { content: [{ type: "text", text: JSON.parse(raw).status }] };
});
Notice what changed and what didn't. The tools do the same work. But check_export no longer cares which instance ran start_export, because the handle is self-contained and the state lives in a store both instances share. There's no initialize handler anymore; the protocol version I need comes off _meta on the request. If a client sends a version I can't speak, I return UnsupportedProtocolVersionError instead of silently proceeding.
One more required piece: the RC adds a server/discover RPC that servers MUST implement. It advertises the protocol versions, capabilities, and identity you support, and it's what replaces the discovery half of the old handshake. Clients may call it up front to pick a version, or as a backwards-compatibility probe over STDIO.
Here's the opinion, and it's the part I wish someone had told me. When your migrated server starts failing in staging, your first instinct will be to re-read your handler code. Don't. Check the request path first.
SEP-2243 makes two headers mandatory on every Streamable HTTP POST: Mcp-Method and Mcp-Name, mirroring the JSON-RPC method and the tool name from the body. The entire reason they exist is so a gateway can route and rate-limit on the operation without cracking open the payload. Good design. The trap is that a WAF, reverse proxy, or firewall rule that strips or blocks unknown Mcp-* headers will kill every request — and from the client's side it looks exactly like a client bug. You'll see failures with no useful body, and you'll waste an hour in the wrong file.
# nginx: let the new headers through AND route on them
location /mcp {
proxy_pass http://mcp_upstream;
proxy_pass_request_headers on;
# some hardened configs drop non-allowlisted headers — don't
proxy_set_header Mcp-Method $http_mcp_method;
proxy_set_header Mcp-Name $http_mcp_name;
}
# and if you route by operation, do it on the header, not the body
map $http_mcp_method $mcp_upstream {
"tools/call" mcp_tools;
default mcp_core;
}
The server is also required to reject a request where the headers and the body disagree, so you can't just paper over a stripping proxy by ignoring the headers server-side. Walk the full path — CDN, WAF, proxy, load balancer — and confirm Mcp-* passes through untouched before you touch application code. This is the single highest-leverage thing you can check, and it's invisible from inside your handler.
Because list endpoints are now connection-independent, clients would otherwise re-fetch your entire tool catalogue on every call. SEP-2549 fixes that with a CacheableResult shape. Attach ttlMs and cacheScope to your tools/list, prompts/list, resources/list, resources/read, and resources/templates/list results.
server.setRequestHandler("tools/list", async () => ({
tools: await loadToolCatalogue(),
ttlMs: 300_000, // clients may cache for 5 minutes
cacheScope: "public" // shared intermediaries may cache too; use "private" for per-user catalogues
}));
Get cacheScope wrong and you leak a user-specific tool list into a shared cache, so treat "public" as a deliberate choice, not a default. It's borrowed straight from HTTP Cache-Control, and it complements listChanged notifications rather than replacing them.
Two reassurances, because the RC also comes with a formal deprecation policy (SEP-2596) and it's genuinely generous. Roots, Sampling, and Logging are deprecated as of the RC, along with the old HTTP+SSE transport. Deprecated is not removed. A feature must stay Deprecated for at least twelve months from the revision that marks it before it's eligible for removal, with a 90-day floor even for expedited security removals. So nothing you depend on today vanishes on July 28.
And the date itself: 2026-07-28 is a version label, not a deadline you're behind on. The RC was locked on May 21, 2026; the current finalized version is still 2025-11-25. The ten-week gap is a validation window for SDK maintainers, and the Tier-1 SDK betas are already out. Treat field names from the draft schema — InputRequiredResult, inputRequests, requestState for the new Multi Round-Trip pattern — as subject to change until final.
If you maintain a remote MCP server, do these in sequence and you'll avoid the traps I hit. First, confirm your gateway passes Mcp-Method and Mcp-Name through untouched — before anything else, because it masquerades as an application bug. Second, delete per-connection state and replace transport session ids with explicit, server-issued handles backed by a store any instance can read. Third, drop the initialize handler and read protocol version and capabilities from _meta, then implement server/discover. Fourth, attach ttlMs and cacheScope to your list and read results. If you used the experimental Tasks feature, move to the extension and switch from blocking tasks/result to polling with tasks/get.
None of it is urgent. But the stateless core is the one change worth planning for now, because it's the one that touches your deployment topology and not just your code. The server rewrite is an afternoon. Discovering your load balancer was the problem, on July 29, in production, is a worse afternoon.