Stateless MCP Servers: What the 2026-07-28 Spec Changed and How to Migrate
MCP is stateless since the 2026-07-28 spec: no initialize, no Mcp-Session-Id. What changed on the wire, a tested TypeScript SDK v2 server on two instances, handles, requestState, MRTR elicitation and a migration checklist.

On this page
MCP servers are stateless now. The 2026-07-28 Model Context Protocol specification removed the initialize handshake and the Mcp-Session-Id header. Every request now carries its own protocol version and client capabilities, so any server instance can answer any request.
That is great for scaling and painful if your server kept state in the session. In this guide I build a stateless MCP server with the official TypeScript SDK v2 (@modelcontextprotocol/server 2.3.1). I run two copies behind different ports and send real requests to both with curl and the SDK client. You will see exactly what changed on the wire, how to replace session state with handles and requestState, and the errors you get when something is wrong. Every rule is cited to the spec.
What is a stateless MCP server?#
A stateless MCP server handles every JSON-RPC request on its own, with no handshake and no session. Under the 2026-07-28 spec each request carries its protocol version, client capabilities and client info in _meta, plus MCP-Protocol-Version, Mcp-Method and Mcp-Name HTTP headers. Any instance behind a plain load balancer can answer it, with no sticky sessions or session store.
The official 2026-07-28 release post says it directly: "MCP is transforming from a bidirectional stateful protocol into a request/response stateless protocol," and it calls this "one of the most highly-requested features" from developers. It also says "any request can land on any instance behind a plain round-robin load balancer."
What changed in the MCP 2026-07-28 spec?#
The 2026-07-28 revision removed sessions, the initialize handshake, the HTTP GET stream, SSE resumability and server-to-client requests. It added server/discover, per-request _meta, required routing headers, a resultType on every result, cache fields on list results, subscriptions/listen for change notifications, and Multi Round-Trip Requests for elicitation and sampling.
The full list from the official changelog, grouped by what you have to do:
The spec also deprecated Roots, Sampling, Logging, the old HTTP+SSE transport and Dynamic Client Registration. Under the new feature lifecycle policy deprecated features get a minimum twelve-month window before removal.
Should an MCP server be stateless?#
Yes. Since 2026-07-28 the protocol itself is stateless, so every modern MCP server is stateless at the protocol level. Your application can still keep state, but in your own database, addressed by explicit handles that tools return and the model passes back. That works better for models too, because they can see and reuse the handle.
The release post explains why: "If your server needs to carry state across calls, mint an explicit handle from a tool and have the model pass it back as an argument. We found this works better than session state hidden in the transport - the model can see the handle and thread it between tools."
AWS's architecture team reached the same conclusion for their platform: the sticky sessions and shared session stores a session-based server needed "are no longer necessary" (AWS Architecture Blog).
How do I build a stateless MCP server in TypeScript?#
Install @modelcontextprotocol/server v2, write a factory that builds a fresh McpServer and registers your tools, and pass it to createMcpHandler. The handler serves 2026-07-28 requests and, by default, 2025-era requests too, all per request. On Node, wrap it with toNodeHandler from @modelcontextprotocol/node; on web runtimes, call handler.fetch(request).

Step 1: Install#
Tested on Node.js 24.22.0:
npm i @modelcontextprotocol/server@2.3.1 @modelcontextprotocol/node@2.1.1 @modelcontextprotocol/client@2.3.1 zod@^4The SDK depends on Zod 4 and you import it as zod/v4.
Step 2: Keep state in a store, not a session#
The demo is a tiny cart service. The cart lives in a shared store keyed by a server-minted handle. I used a JSON file so two processes share it; in production this is Postgres or Redis.
// store.ts
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
type Cart = { owner: string; items: { sku: string; qty: number }[]; createdAt: string }
const FILE = new URL('./data/carts.json', import.meta.url)
function load(): Record<string, Cart> {
return existsSync(FILE) ? JSON.parse(readFileSync(FILE, 'utf8')) : {}
}
function save(all: Record<string, Cart>) {
writeFileSync(FILE, JSON.stringify(all, null, 2))
}
export const carts = {
create(owner: string): string {
const all = load()
const id = `cart_${crypto.randomUUID()}`
all[id] = { owner, items: [], createdAt: new Date().toISOString() }
save(all)
return id
},
get(id: string, owner: string): Cart | undefined {
const cart = load()[id]
return cart && cart.owner === owner ? cart : undefined // a handle is not a credential
},
update(id: string, cart: Cart) {
const all = load()
all[id] = cart
save(all)
},
}Note the owner check. A handle such as cart_... travels through the model, so treat it as an identifier, never as proof of access. Always check it against the authenticated user.
Step 3: Write the server#
// server.ts
import { createServer } from 'node:http'
import {
McpServer,
createMcpHandler,
createRequestStateCodec,
inputRequired,
acceptedContent,
} from '@modelcontextprotocol/server'
import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server'
import { toNodeHandler } from '@modelcontextprotocol/node'
import * as z from 'zod/v4'
import { carts } from './store.ts'
const PORT = Number(process.env.PORT ?? 3101)
const INSTANCE = process.env.INSTANCE ?? `instance-${PORT}`
// Shared by every instance, so state minted on one instance verifies on another.
const stateCodec = createRequestStateCodec<{ step: 'confirm-clear'; cartId: string }>({
key: new TextEncoder().encode(process.env.MCP_STATE_KEY ?? 'dev-only-key-change-me-32-bytes!!'),
ttlSeconds: 300,
})
function buildServer(): McpServer {
const server = new McpServer(
{ name: 'shop-mcp', version: '1.0.0' },
{
requestState: { verify: stateCodec.verify },
cacheHints: { 'tools/list': { ttlMs: 60_000, cacheScope: 'public' } },
},
)
// Demo principal. In a real server this comes from the verified bearer token (authInfo).
const owner = 'demo-user'
server.registerTool(
'create_cart',
{ description: 'Create a shopping cart and return its cart_id handle. Pass cart_id to the other cart tools.' },
async (): Promise<CallToolResult> => {
const cartId = carts.create(owner)
return {
content: [{ type: 'text', text: `Created ${cartId} on ${INSTANCE}` }],
structuredContent: { cart_id: cartId },
}
},
)
server.registerTool(
'add_item',
{
description: 'Add a product to a cart created with create_cart',
inputSchema: z.object({ cart_id: z.string(), sku: z.string(), qty: z.number().int().min(1) }),
},
async ({ cart_id, sku, qty }): Promise<CallToolResult> => {
const cart = carts.get(cart_id, owner)
if (!cart) return { content: [{ type: 'text', text: `Unknown cart_id ${cart_id}` }], isError: true }
cart.items.push({ sku, qty })
carts.update(cart_id, cart)
return { content: [{ type: 'text', text: `${cart.items.length} line(s) in ${cart_id}, served by ${INSTANCE}` }] }
},
)
const confirmSchema = z.object({ confirm: z.boolean() })
server.registerTool(
'clear_cart',
{
description: 'Remove every item from a cart after the user confirms',
inputSchema: z.object({ cart_id: z.string() }),
},
async ({ cart_id }, ctx): Promise<CallToolResult | InputRequiredResult> => {
const cart = carts.get(cart_id, owner)
if (!cart) return { content: [{ type: 'text', text: `Unknown cart_id ${cart_id}` }], isError: true }
const state = ctx.mcpReq.requestState<{ step: 'confirm-clear'; cartId: string }>()
const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmSchema)
if (state?.cartId !== cart_id || answer?.confirm !== true) {
// Multi Round-Trip Request: ask the client, then expect a retry of this same call.
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Remove all ${cart.items.length} line(s) from ${cart_id}?`,
requestedSchema: confirmSchema,
}),
},
requestState: await stateCodec.mint({ step: 'confirm-clear', cartId: cart_id }),
})
}
cart.items = []
carts.update(cart_id, cart)
return { content: [{ type: 'text', text: `Cleared ${cart_id} on ${INSTANCE}` }] }
},
)
return server
}
// Fresh McpServer per request; no sessions to store or route.
const handler = createMcpHandler(buildServer, { responseMode: 'json' })
createServer(toNodeHandler(handler)).listen(PORT, '127.0.0.1', () => {
console.log(`${INSTANCE} listening on http://127.0.0.1:${PORT}/mcp`)
})The file type-checks with TypeScript 7.0.2 in strict mode and runs directly with node server.ts on Node 24, which strips types natively. I started two instances to stand in for two containers behind a load balancer:
PORT=3101 INSTANCE=A node server.ts &
PORT=3102 INSTANCE=B node server.ts &On a Next.js App Router project the same handler goes in app/api/mcp/route.ts as export const POST = (req: Request) => handler.fetch(req). My guide on building an MCP server in Next.js covers that route with Origin checks and bearer auth. To teach coding agents when and how to call your tools, pair the server with an Agent Skill.
What does a stateless MCP request look like?#
A stateless MCP request is a single HTTP POST with three headers, MCP-Protocol-Version, Mcp-Method and (for tool calls) Mcp-Name, and a JSON-RPC body whose params._meta repeats the protocol version and declares the client's capabilities. There is no earlier handshake: the first request a client sends can be the tool call itself.
This is the exact request my curl helper sends for create_cart:
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: create_cart
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_cart","arguments":{},
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"curl","version":"8"},
"io.modelcontextprotocol/clientCapabilities":{}}}}server/discover#
The SDK implements server/discover for you. Real response from instance A:
{"result":{"supportedVersions":["2026-07-28"],"capabilities":{"tools":{"listChanged":true}},
"resultType":"complete","ttlMs":0,"cacheScope":"private",
"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"shop-mcp","version":"1.0.0"}}},
"jsonrpc":"2.0","id":1}tools/list with cache hints#
Because I set cacheHints, the list result tells clients they may cache it for 60 seconds:
{"resultType":"complete","ttlMs":60000,"cacheScope":"public","tools":["create_cart","add_item","clear_cart"]}Without a hint the SDK sends ttlMs: 0 and cacheScope: "private", the most conservative policy. If your tool list differs per user, keep cacheScope at "private". The spec says "public" allows shared intermediaries to cache the response.
One cart, two instances#
I created a cart on instance A, then added items through B and then A. Every call landed on a different process and nothing broke, because the state is in the store, not the connection:
create_cart on A → Created cart_d892bc05-… on A
add_item on B → 1 line(s) in cart_d892bc05-…, served by B
add_item on A → 2 line(s) in cart_d892bc05-…, served by AHow do I replace session state when migrating?#
Replace session state in three ways. Data that outlives one call goes in your database under a handle a tool returns, such as cart_id. Data needed only between the rounds of one interaction goes in requestState, signed with HMAC. Per-user data comes from the auth token on each request, never from a session ID.
The TypeScript SDK's 2026-07-28 migration notes state that requestState "round-trips through the client and is therefore untrusted input" and tell you to integrity-protect it. createRequestStateCodec does that with HMAC-SHA256, and the notes add that it is "signed, not encrypted", so never put secrets in it.
How do elicitation and confirmations work without sessions?#
Without sessions the server can no longer push elicitation/create in the middle of a call. Instead it returns a result with resultType: "input_required", the questions in inputRequests, and an optional requestState. The client asks the user, then retries the same call with inputResponses and the requestState echoed back. This pattern is called Multi Round-Trip Requests (MRTR).

Round 1, sent to instance B with clientCapabilities: {"elicitation":{"form":{}}}:
{"result":{"resultType":"input_required",
"inputRequests":{"confirm":{"method":"elicitation/create","params":{
"message":"Remove all 2 line(s) from cart_d892bc05-…?",
"requestedSchema":{"type":"object","properties":{"confirm":{"type":"boolean"}},"required":["confirm"]},
"mode":"form"}}},
"requestState":"v1.eyJwIjp7InN0ZXAiOiJjb25maXJtLWNsZWFyIiwi…"}}Round 2, the retry, sent to instance A with "inputResponses":{"confirm":{"action":"accept","content":{"confirm":true}}} and the same requestState:
{"result":{"content":[{"type":"text","text":"Cleared cart_d892bc05-… on A"}],"resultType":"complete"}}Instance A verified state minted by instance B because both share MCP_STATE_KEY. When I changed one character of requestState, the server rejected it:
{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"Invalid or expired requestState","data":{"reason":"invalid_request_state"}}}With the SDK client you don't write the retry yourself. It answers input_required through your elicitation/create handler and retries automatically:
// client.ts
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
const client = new Client(
{ name: 'shop-client', version: '1.0.0' },
{
versionNegotiation: { mode: 'auto' }, // the v2 client defaults to the 2025 handshake
capabilities: { elicitation: { form: {} } },
},
)
client.setRequestHandler('elicitation/create', async (request) => {
console.log('server asks:', request.params.message)
return { action: 'accept', content: { confirm: true } }
})
await client.connect(new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3101/mcp')))
console.log('era:', client.getProtocolEra())Output:
era: modern
server asks: Remove all 1 line(s) from cart_3ab350d7-…?
[ { type: 'text', text: 'Cleared cart_3ab350d7-… on A' } ]What errors does a stateless MCP server return?#
A 2026-07-28 server returns HTTP 400 with JSON-RPC code -32020 when headers are missing or disagree with the body, -32022 for an unsupported protocol version, and -32021 when a tool needs a client capability the request did not declare. It returns 404 with -32601 for unknown or removed methods such as ping, and 405 for GET.
All of these are real responses from my server:
The header check exists for security. The Streamable HTTP spec says servers must reject mismatches because otherwise "a load balancer routing on the header value while the MCP server executes based on the body value" could be tricked.
Will old MCP clients still work with my stateless server?#
Mostly yes, with one important exception. createMcpHandler defaults to legacy: 'stateless', which answers 2025-era initialize and later requests per request without issuing a session ID. Ordinary tools work. Tools that need user input fail for old clients, because a per-request legacy connection cannot receive server-to-client requests.
The legacy initialize returned 200, content-type: text/event-stream and no Mcp-Session-Id header:
data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"shop-mcp","version":"1.0.0"}},"jsonrpc":"2.0","id":1}Then I ran the same client with versionNegotiation: { mode: 'legacy' }. create_cart and add_item worked, but clear_cart returned:
Cannot request input 'confirm' (elicitation/create): the client on this 2025-era connection did not declare
the required capability (no client capabilities are available on this connection — per-request legacy
serving cannot receive server-to-client requests)So if old clients need confirmations, keep your existing sessionful handler for them. The SDK's migration notes show how: put isLegacyRequest(request) in front of a createMcpHandler(factory, { legacy: 'reject' }) entry and route legacy traffic to your old handler.
Two client-side facts worth knowing:
- The v2
Clientdefaults to the 2025 handshake. You must opt in withversionNegotiation: { mode: 'auto' }(probe withserver/discover, fall back) or{ pin: '2026-07-28' }. - The versioning page has a full compatibility matrix. A legacy client talking to a modern-only server fails, because, in the spec's words, "Legacy clients have no fall-forward mechanism."
Migration checklist: stateful to stateless MCP#
To migrate, upgrade to an SDK that implements 2026-07-28, switch to a per-request handler, move session data into handles, requestState or auth, and rewrite pushed elicitation as input_required. Then remove session infrastructure, set cache hints, and test with both modern and legacy clients before you drop sticky routing.
- Upgrade the SDK. TypeScript: move from
@modelcontextprotocol/sdk1.x to@modelcontextprotocol/server2.x (createMcpHandler). Python, Go and C# also have updated Tier 1 SDKs, per the release post. - Find every use of the session ID (
extra.sessionId,ctx.sessionId, maps keyed byMcp-Session-Id) and move that data to a handle,requestStateor the auth principal. - Return handles from tools (
cart_id,job_id,doc_id) and name them in tool descriptions so the model passes them back. - Authorize every handle against the authenticated user.
- Rewrite
elicitInput()/requestSampling()asreturn inputRequired({...})and readctx.mcpReq.inputResponseson re-entry. - Seal
requestStatewithcreateRequestStateCodecand share the key across instances. - Make tools safe to retry. Streams are no longer resumable, so a dropped connection means the client re-issues the request.
- Set
cacheHintsfortools/listand resources,privateif results vary per user. - Delete
pinghandlers and per-session log level code; log to stderr or OpenTelemetry instead. - Change resource-not-found errors from
-32002to-32602. - Test both eras: a modern client (
mode: 'auto') and a legacy client, including tools that ask for input. - Only then remove sticky sessions, the session store and the GET route.
Common stateless MCP mistakes#
The most common mistakes are trusting a handle as authorization, putting secrets in requestState, forgetting the routing headers in a custom client, marking per-user lists as public, and assuming every old client can still answer elicitation. Each of these either fails with a 400 you can see or leaks data silently, so test them on purpose.
Key takeaways#
- The MCP 2026-07-28 spec made the protocol stateless: no
initialize, noMcp-Session-Id, every request self-describing. - Servers must implement
server/discoverand reject header/body mismatches with-32020. - Keep application state in your database behind handles that tools return; authorize every handle.
- Elicitation is now Multi Round-Trip Requests: return
input_required, the client retries withinputResponsesandrequestState. - Sign
requestStatewithcreateRequestStateCodecand share the key so any instance can verify it. createMcpHandleralso serves 2025-era clients per request, but they cannot answer elicitation on that path.- The v2 TypeScript client still defaults to the 2025 handshake; opt in with
versionNegotiation: { mode: 'auto' }.
Questions
Share


