DEV Community

Cover image for Stateless MCP Servers: What the 2026-07-28 Spec Changed and How to Migrate
Waseem Anjum
Waseem Anjum

Posted on Originally published at fabwaseem.com

Stateless MCP Servers: What the 2026-07-28 Spec Changed and How to Migrate

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."

Term Meaning in 2026-07-28
Modern / legacy Modern = per-request metadata (2026-07-28+). Legacy = initialize handshake (2025-11-25 and earlier)
_meta envelope io.modelcontextprotocol/protocolVersion, .../clientCapabilities, .../clientInfo on every request
server/discover New RPC every server must implement: supported versions, capabilities, identity
MRTR Multi Round-Trip Requests: server returns input_required, client retries the same call with answers
requestState Opaque string the server returns with input_required and the client echoes on retry
Dual-era A server or client that speaks both modern and legacy versions

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:

Change Before (2025-11-25) Now (2026-07-28) What to do
Sessions Mcp-Session-Id header, DELETE to end Removed Delete session stores and sticky routing; use explicit handles
Handshake initialize + notifications/initialized Removed; version and capabilities in every request's _meta Let the SDK attach _meta; read capabilities per request
Discovery Capabilities from initialize server/discover (servers must implement it) SDK v2 implements it for you
Server-to-client requests elicitation/create, sampling/createMessage, roots/list pushed by the server Server returns resultType: "input_required"; client retries Rewrite elicitInput() calls as inputRequired(...)
Notifications stream HTTP GET stream, resources/subscribe subscriptions/listen (one long-lived POST stream) Answer GET with 405
Resumability Last-Event-ID redelivery Removed; broken stream = re-issue request Make tools safe to retry
Headers MCP-Protocol-Version Plus required Mcp-Method and Mcp-Name Servers must reject header/body mismatches
Results No type field Required resultType: "complete" or "input_required" SDK sets it
Caching None Required ttlMs and cacheScope on list and read results Set real cache hints
Removed methods ping, logging/setLevel Removed; log level per request in _meta Delete ping handlers
Errors Resource not found -32002 -32602; new codes -32020 to -32022 Update error mapping

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."

Stateful MCP (2025-era sessions) Stateless MCP (2026-07-28)
First request initialize, then notifications/initialized The real call (tools/call)
Load balancing Sticky sessions or a shared session store Plain round-robin
Serverless (Vercel, Lambda) Awkward: session must survive cold starts Natural fit
Where state lives Hidden in the transport session Your database, keyed by a handle the model can see
Asking the user for input Server pushes elicitation/create mid-call Server returns input_required; client retries
Broken stream Resume with Last-Event-ID Re-issue the request

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).

Stateless MCP request flow: an MCP client sends self-describing POST requests through a round-robin load balancer to any of several server instances, each building a fresh McpServer per request and sharing state through a database keyed by cart_id handles

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@^4
Enter fullscreen mode Exit fullscreen mode

The 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)
  },
}
Enter fullscreen mode Exit fullscreen mode

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`)
})
Enter fullscreen mode Exit fullscreen mode

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 &
Enter fullscreen mode Exit fullscreen mode

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":{}}}}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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"]}
Enter fullscreen mode Exit fullscreen mode

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 A
Enter fullscreen mode Exit fullscreen mode

How 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.

What you kept in the session Where it goes now
Shopping cart, open document, browser tab Database row, addressed by a handle returned from a tool
"User already confirmed step 1" requestState, minted with createRequestStateCodec
Who the user is Bearer token / OAuth on every request (authInfo)
Client capabilities _meta of the current request
Log level io.modelcontextprotocol/logLevel in the request's _meta

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).

Multi Round-Trip Request sequence: the client calls clear_cart on instance B, B returns input_required with inputRequests and a signed requestState, the client asks the user, then retries the same call with inputResponses and requestState on instance A, which verifies the HMAC and returns complete

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…"}}
Enter fullscreen mode Exit fullscreen mode

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"}}
Enter fullscreen mode Exit fullscreen mode

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"}}}
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

Output:

era: modern
server asks: Remove all 1 line(s) from cart_3ab350d7-…?
[ { type: 'text', text: 'Cleared cart_3ab350d7-… on A' } ]
Enter fullscreen mode Exit fullscreen mode

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:

Request HTTP JSON-RPC error
Mcp-Name: add_item but body name: create_cart 400 -32020 "the request headers and body disagree"
tools/call without Mcp-Name 400 -32020 "the required Mcp-Name header is absent"
MCP-Protocol-Version: 2027-01-01 400 -32022 "Unsupported protocol version", data.supported: ["2026-07-28"]
clear_cart without the elicitation capability 400 -32021 "the request's client capabilities do not declare the required capability"
ping (removed in 2026-07-28) 404 -32601 "Method not found"
GET /mcp (old notification stream) 405 "Method not allowed."

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}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 Client defaults to the 2025 handshake. You must opt in with versionNegotiation: { mode: 'auto' } (probe with server/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.

  1. Upgrade the SDK. TypeScript: move from @modelcontextprotocol/sdk 1.x to @modelcontextprotocol/server 2.x (createMcpHandler). Python, Go and C# also have updated Tier 1 SDKs, per the release post.
  2. Find every use of the session ID (extra.sessionId, ctx.sessionId, maps keyed by Mcp-Session-Id) and move that data to a handle, requestState or the auth principal.
  3. Return handles from tools (cart_id, job_id, doc_id) and name them in tool descriptions so the model passes them back.
  4. Authorize every handle against the authenticated user.
  5. Rewrite elicitInput() / requestSampling() as return inputRequired({...}) and read ctx.mcpReq.inputResponses on re-entry.
  6. Seal requestState with createRequestStateCodec and share the key across instances.
  7. Make tools safe to retry. Streams are no longer resumable, so a dropped connection means the client re-issues the request.
  8. Set cacheHints for tools/list and resources, private if results vary per user.
  9. Delete ping handlers and per-session log level code; log to stderr or OpenTelemetry instead.
  10. Change resource-not-found errors from -32002 to -32602.
  11. Test both eras: a modern client (mode: 'auto') and a legacy client, including tools that ask for input.
  12. 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.

Mistake What happens Fix
Treating cart_id as proof of access Any user who sees an ID can use it Check owner against the token on every call
Secrets in requestState Client can base64-decode it (signed, not encrypted) Store an ID, keep the secret server-side
Different MCP_STATE_KEY per instance Retries on another instance fail -32602 Share one key across the fleet
Custom client skips Mcp-Method / Mcp-Name 400, -32020 Send both headers, matching the body
cacheScope: 'public' on per-user lists Shared caches may serve one user's list to another Use private
v2 client left on defaults Still speaks the 2025 handshake versionNegotiation: { mode: 'auto' }
Elicitation tools behind legacy: 'stateless' Old clients get "cannot receive server-to-client requests" Keep a sessionful legacy route until they upgrade

Related: stateless or not, every MCP call costs more than a REST call. I measured how much, in latency, throughput and tokens, in MCP vs API: when you need which.

Key takeaways

  • The MCP 2026-07-28 spec made the protocol stateless: no initialize, no Mcp-Session-Id, every request self-describing.
  • Servers must implement server/discover and 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 with inputResponses and requestState.
  • Sign requestState with createRequestStateCodec and share the key so any instance can verify it.
  • createMcpHandler also 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' }.

Originally published on fabwaseem.com.

Top comments (0)