· 10 min read · Nadeera Silva
MCP Stateless Update: Migrate Your Server to 2026-07-28
MCP's 2026-07-28 spec drops sessions and the initialize handshake. What breaks in your MCP server, and how to migrate a TypeScript server step by step.

The MCP stateless update is the biggest change to the Model Context Protocol since remote servers arrived. The 2026-07-28 specification removes the initialize handshake and the Mcp-Session-Id header, makes every request self-describing, and replaces server-to-client requests with a retry pattern. If you run an MCP server for Claude, Cursor, ChatGPT or your own agent, some of that code is now running on deprecated paths. This post covers what changed, what breaks in a typical TypeScript server, and a migration order that keeps older clients working.
What the MCP stateless update changes
The release post describes MCP moving "from a bidirectional stateful protocol into a request/response stateless protocol". In practice that means six changes you will touch:
| Area | Before (2025-11-25 and earlier) | Now (2026-07-28) |
|---|---|---|
| Connection setup | initialize then notifications/initialized | No handshake. Optional server/discover, which servers must implement |
| Sessions | Mcp-Session-Id header on Streamable HTTP | Removed. Each request carries version, client info and capabilities in _meta |
| HTTP headers | Body only | MCP-Protocol-Version, Mcp-Method and Mcp-Name required on POSTs |
| Asking the user or the model | Server sends elicitation/create or sampling/createMessage | Server returns resultType: "input_required", client retries with inputResponses |
| List results | No cache guidance | ttlMs and cacheScope required, tools listed in a deterministic order |
| Change notifications | HTTP GET stream, resources/subscribe | One opt-in subscriptions/listen stream |
Every result now also carries a resultType of "complete" or "input_required", and clients treat results without it (from older servers) as complete.
The payoff is operational. Any request can land on any instance behind a plain round-robin load balancer, with no sticky sessions and no shared session store. That is why hosts like Cloudflare, Netlify and AWS Bedrock AgentCore announced day-one support in the release post.
What breaks in an existing MCP server
Not everything breaks at once. Older clients still speak the 2025 protocol, and the SDKs serve both. But these are the places where a server written last year needs work:
- Session-keyed state. If you stored anything against
Mcp-Session-Id(a selected workspace, a shopping cart, an auth context), there is no session to key on any more. - Push-style elicitation and sampling. In the TypeScript SDK, calling
ctx.mcpReq.elicitInput()orrequestSampling()on a 2026-era request fails with a typed local error before anything reaches the wire. - Hand-rolled clients and gateways. A 2026-era POST without
Mcp-Namefortools/callis rejected with HTTP 400 and JSON-RPC error-32020(HeaderMismatch). - Logging.
logging/setLevelis gone. The log level now travels per request in_meta, and if the client doesn't send it, the server must not emit log notifications for that request. - Error codes. "Resource not found" moved from
-32002to-32602, and the new protocol errors live in the-32020to-32099range. - SSE resumability.
Last-Event-IDredelivery is removed. A broken response stream loses the in-flight request and the client re-issues it.
Here is what the missing header looks like against a 2026-07-28 server, captured from the example below:
{
"jsonrpc": "2.0",
"error": {
"code": -32020,
"message": "Bad Request: the request headers and body disagree: the body carries params.name=\"delete_project\" but the required Mcp-Name header is absent"
},
"id": 3
}Multi Round-Trip Requests: confirmations without a session
Multi Round-Trip Requests (MRTR) are how a stateless server still asks the user something mid-call. Instead of opening a request back to the client, the tool returns an interim result that lists what it needs. The client collects the answers and calls the same tool again with them attached.

Any state the server needs between rounds goes into requestState, an opaque string the client echoes back byte for byte. The spec's own advice for longer-lived state is similar: mint an explicit handle from a tool and let the model pass it back as an argument.
Migrating a TypeScript MCP server to 2026-07-28
The TypeScript SDK v2 packages are split: @modelcontextprotocol/server for the server, @modelcontextprotocol/node for Node framework adapters. The legacy @modelcontextprotocol/sdk package is still on the 1.x line.
npm install @modelcontextprotocol/server @modelcontextprotocol/node express zodThe example below is a small Express server with two tools. delete_project asks for confirmation using MRTR, and tools/list advertises a five-minute public cache. createMcpHandler builds a fresh server for each request and, by default, also serves 2025-era clients statelessly, so one endpoint covers both.
import express from 'express';
import * as z from 'zod/v4';
import {
McpServer,
createMcpHandler,
createRequestStateCodec,
inputRequired,
acceptedContent,
} from '@modelcontextprotocol/server';
import { toNodeHandler } from '@modelcontextprotocol/node';
type DeleteState = { step: 'confirm'; projectId: string };
// The same secret on every instance, so any of them can verify the state.
const stateKey = process.env.MCP_STATE_KEY;
if (!stateKey || stateKey.length < 32) throw new Error('Set MCP_STATE_KEY (32+ chars)');
const stateCodec = createRequestStateCodec<DeleteState>({
key: new TextEncoder().encode(stateKey),
ttlSeconds: 300,
});
const CONFIRM = z.object({ confirm: z.boolean() });
function buildServer(): McpServer {
const server = new McpServer(
{ name: 'projects-mcp', version: '2.0.0' },
{
capabilities: { tools: {} },
requestState: { verify: stateCodec.verify }, // rejects tampered or expired state
cacheHints: { 'tools/list': { ttlMs: 300_000, cacheScope: 'public' } },
},
);
server.registerTool(
'list_projects',
{
description: 'List projects for the signed-in account.',
inputSchema: z.object({ limit: z.number().int().max(50).default(20) }),
},
async ({ limit }) => ({
content: [{ type: 'text', text: JSON.stringify(await db.listProjects(limit)) }],
}),
);
server.registerTool(
'delete_project',
{
description: 'Permanently delete a project. Asks the user to confirm first.',
inputSchema: z.object({ projectId: z.string() }),
},
async ({ projectId }, ctx) => {
const state = ctx.mcpReq.requestState<DeleteState>();
const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', CONFIRM);
// First call, a declined answer, or state minted for another project: ask again.
if (!state || state.projectId !== projectId || answer?.confirm !== true) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Delete project ${projectId}? This cannot be undone.`,
requestedSchema: CONFIRM,
}),
},
requestState: await stateCodec.mint({ step: 'confirm', projectId }),
});
}
await db.deleteProject(projectId);
return { content: [{ type: 'text', text: `Deleted ${projectId}` }] };
},
);
return server;
}
// Replace with your real data layer.
const db = {
async listProjects(limit: number) { return [{ id: 'p1', name: 'Demo' }].slice(0, limit); },
async deleteProject(_id: string) {},
};
const handler = createMcpHandler(buildServer); // legacy: 'stateless' is the default
const app = express();
app.all('/mcp', toNodeHandler(handler));
app.listen(3000, () => console.log('MCP server on http://localhost:3000/mcp'));The first tools/call to delete_project returns "resultType": "input_required" with an elicitation form and a signed requestState. When the client retries with inputResponses: { confirm: { action: "accept", content: { confirm: true } } } and the same state, the tool completes. A modified requestState is rejected with -32602 "Invalid or expired requestState" before the handler runs.
If you run a sessionful v1 server
A v1 server that used StreamableHTTPServerTransport with sessionIdGenerator: undefined maps straight onto createMcpHandler. If yours is sessionful, keep it for old clients and put a strict 2026 handler in front, using isLegacyRequest() to route, as the migration guide shows:
import { createMcpHandler, isLegacyRequest } from '@modelcontextprotocol/server';
const modern = createMcpHandler(buildServer, { legacy: 'reject' });
export default {
async fetch(request: Request) {
if (await isLegacyRequest(request)) return myExistingLegacyHandler(request);
return modern.fetch(request);
},
};stdio servers
A server that calls server.connect(new StdioServerTransport()) keeps speaking only the 2025 protocol, even after the SDK upgrade. Switch to serveStdio(() => buildServer()) from @modelcontextprotocol/server/stdio to serve both eras on one connection.
Authorization changes for remote MCP servers
If your remote MCP server uses OAuth, three rules matter:
- Clients must validate the
issparameter (RFC 9207) before redeeming an authorization code, which closes an authorization-server mix-up attack. Your authorization server should send it. - Dynamic Client Registration (DCR) is deprecated in favour of Client ID Metadata Documents (CIMD). DCR still works for backward compatibility. Clients that use DCR must now set
application_type, which fixes theredirect_urierrors CLI and desktop clients hit withlocalhostredirects. - Client credentials are bound to the issuer that minted them and must not be reused with another authorization server.
If you verify tokens yourself on the server side, the same discipline applies as in verifying Supabase JWTs with JWKS: pin the issuer and audience, and never trust a token just because it parses.
Deprecations and timelines
The suggested replacements are straightforward: pass directories or files as tool parameters or server configuration instead of Roots, call your LLM provider's API directly instead of Sampling, and log to stderr or OpenTelemetry instead of Logging. Tasks moved out of the core into the io.modelcontextprotocol/tasks extension, with polling via tasks/get and a new tasks/update.
All four Tier 1 SDKs (TypeScript, Python, Go and C#) support 2026-07-28, and the Rust SDK has it in beta.
Migration checklist
- Upgrade to the v2 TypeScript packages (or your language's updated SDK) and serve HTTP through
createMcpHandler, or stdio throughserveStdio. - Search for anything keyed on the session ID and move it into tool arguments, explicit handles or a signed
requestState. - Replace
elicitInput()andrequestSampling()calls withinputRequired(...)returns. - Load one
requestStatesigning secret on every instance, and set a short TTL. - Make sure gateways, WAF rules and proxies pass
MCP-Protocol-Version,Mcp-MethodandMcp-Namethrough, and route or rate limit on them if useful. - Return tools in a stable order and set
cacheHintsfor list results that rarely change. - Move logging to
stderror OpenTelemetry. - Update "resource not found" handling from
-32002to-32602. - For OAuth, send
issfrom your authorization server and plan the move from DCR to CIMD. - Test with both a 2025-era client and a 2026-era client before you remove anything.
Should you migrate now?
If your MCP server runs on more than one instance, or you have been fighting sticky sessions and dropped SSE streams, yes: this release removes exactly that class of problem. If it's a single stdio server used by one desktop client, there's no rush, since 2025-era clients keep working and the deprecations run until at least July 2027. Either way, write new tools in the inputRequired(...) style now, because that works on both protocol versions.
If your agent also answers questions from your own documents, the same "make every call carry what it needs" idea shows up in RAG with citations on pgvector. And for a production example of tools that touch customer data, see AI agents for WhatsApp CRM automation.
Frequently asked questions
What is the MCP stateless update?
It is the 2026-07-28 revision of the Model Context Protocol. It removes the initialize handshake and the Mcp-Session-Id header, so every request carries its own protocol version, client info and capabilities and can be served by any server instance.
Is the Mcp-Session-Id header still required?
No. The 2026-07-28 spec removes protocol-level sessions entirely. Servers that need state across calls should pass explicit handles as tool arguments or use a signed requestState in a multi round-trip request.
Do old MCP clients still work with a 2026-07-28 server?
Yes, if the server serves both versions. In the TypeScript SDK, createMcpHandler serves 2025-era clients statelessly by default, and serveStdio does the same for stdio connections.
How does elicitation work in stateless MCP?
The tool returns a result with resultType input_required that lists the questions. The client asks the user, then retries the same call with the answers in inputResponses and echoes back the server's requestState.
When will Sampling, Roots and Logging be removed?
They are deprecated, and the new policy keeps deprecated features working for at least twelve months from the 28 July 2026 release. New implementations should not adopt them.
Sources
- The 2026-07-28 Specification (Model Context Protocol Blog, 2026-07-28)
- Key Changes (2026-07-28 changelog) (Model Context Protocol)
- Supporting protocol revision 2026-07-28 (TypeScript SDK migration guide) (GitHub)
- Protocol versions (TypeScript SDK v2 docs) (Model Context Protocol)
- MRTR example server (GitHub)
- RFC 9207: OAuth 2.0 Authorization Server Issuer Identification (IETF)
Keep reading
- RAG with Citations on PostgreSQL and pgvector: How I Built ItHow I built RAG with citations on PostgreSQL and pgvector for Pactroom: schema, project-scoped retrieval, citation prompts and the HNSW filtering trap.
- How to Verify a Supabase JWT in Fastify with JWKSVerify Supabase JWTs in a Fastify API with JWKS and jose, handle the legacy JWT secret, rotate keys safely, and secure Socket.IO connections too.
- WhatsApp AI Agent for Business: Cloud API, Webhooks and CRMBuild a WhatsApp AI agent on the Cloud API that updates your CRM: webhook setup, the 24-hour rule, templates, human handoff and October 2026 pricing.
Comments
Share a question or note about this article.
Loading comments…