AI Engineering

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.

Stateless MCP cover: a POST /mcp diff removing initialize and Mcp-Session-Id, adding MCP-Protocol-Version 2026-07-28, Mcp-Method and Mcp-Name, served by any instance
On this page
  1. What is a stateless MCP server?
  2. What changed in the MCP 2026-07-28 spec?
  3. Should an MCP server be stateless?
  4. How do I build a stateless MCP server in TypeScript?
  5. Step 1: Install
  6. Step 2: Keep state in a store, not a session
  7. Step 3: Write the server
  8. What does a stateless MCP request look like?
  9. server/discover
  10. tools/list with cache hints
  11. One cart, two instances
  12. How do I replace session state when migrating?
  13. How do elicitation and confirmations work without sessions?
  14. What errors does a stateless MCP server return?
  15. Will old MCP clients still work with my stateless server?
  16. Migration checklist: stateful to stateless MCP
  17. Common stateless MCP mistakes
  18. Key takeaways

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

TermMeaning in 2026-07-28
Modern / legacyModern = per-request metadata (2026-07-28+). Legacy = initialize handshake (2025-11-25 and earlier)
_meta envelopeio.modelcontextprotocol/protocolVersion, .../clientCapabilities, .../clientInfo on every request
server/discoverNew RPC every server must implement: supported versions, capabilities, identity
MRTRMulti Round-Trip Requests: server returns input_required, client retries the same call with answers
requestStateOpaque string the server returns with input_required and the client echoes on retry
Dual-eraA 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:

ChangeBefore (2025-11-25)Now (2026-07-28)What to do
SessionsMcp-Session-Id header, DELETE to endRemovedDelete session stores and sticky routing; use explicit handles
Handshakeinitialize + notifications/initializedRemoved; version and capabilities in every request's _metaLet the SDK attach _meta; read capabilities per request
DiscoveryCapabilities from initializeserver/discover (servers must implement it)SDK v2 implements it for you
Server-to-client requestselicitation/create, sampling/createMessage, roots/list pushed by the serverServer returns resultType: "input_required"; client retriesRewrite elicitInput() calls as inputRequired(...)
Notifications streamHTTP GET stream, resources/subscribesubscriptions/listen (one long-lived POST stream)Answer GET with 405
ResumabilityLast-Event-ID redeliveryRemoved; broken stream = re-issue requestMake tools safe to retry
HeadersMCP-Protocol-VersionPlus required Mcp-Method and Mcp-NameServers must reject header/body mismatches
ResultsNo type fieldRequired resultType: "complete" or "input_required"SDK sets it
CachingNoneRequired ttlMs and cacheScope on list and read resultsSet real cache hints
Removed methodsping, logging/setLevelRemoved; log level per request in _metaDelete ping handlers
ErrorsResource not found -32002-32602; new codes -32020 to -32022Update 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 requestinitialize, then notifications/initializedThe real call (tools/call)
Load balancingSticky sessions or a shared session storePlain round-robin
Serverless (Vercel, Lambda)Awkward: session must survive cold startsNatural fit
Where state livesHidden in the transport sessionYour database, keyed by a handle the model can see
Asking the user for inputServer pushes elicitation/create mid-callServer returns input_required; client retries
Broken streamResume with Last-Event-IDRe-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:

bash
npm i @modelcontextprotocol/server@2.3.1 @modelcontextprotocol/node@2.1.1 @modelcontextprotocol/client@2.3.1 zod@^4

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.

ts
// 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#

ts
// 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:

bash
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:

http
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:

json
{"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:

json
{"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:

text
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

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 sessionWhere it goes now
Shopping cart, open document, browser tabDatabase row, addressed by a handle returned from a tool
"User already confirmed step 1"requestState, minted with createRequestStateCodec
Who the user isBearer token / OAuth on every request (authInfo)
Client capabilities_meta of the current request
Log levelio.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":{}}}:

json
{"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:

json
{"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:

json
{"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:

ts
// 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:

text
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:

RequestHTTPJSON-RPC error
Mcp-Name: add_item but body name: create_cart400-32020 "the request headers and body disagree"
tools/call without Mcp-Name400-32020 "the required Mcp-Name header is absent"
MCP-Protocol-Version: 2027-01-01400-32022 "Unsupported protocol version", data.supported: ["2026-07-28"]
clear_cart without the elicitation capability400-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:

text
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:

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

MistakeWhat happensFix
Treating cart_id as proof of accessAny user who sees an ID can use itCheck owner against the token on every call
Secrets in requestStateClient can base64-decode it (signed, not encrypted)Store an ID, keep the secret server-side
Different MCP_STATE_KEY per instanceRetries on another instance fail -32602Share one key across the fleet
Custom client skips Mcp-Method / Mcp-Name400, -32020Send both headers, matching the body
cacheScope: 'public' on per-user listsShared caches may serve one user's list to anotherUse private
v2 client left on defaultsStill speaks the 2025 handshakeversionNegotiation: { mode: 'auto' }
Elicitation tools behind legacy: 'stateless'Old clients get "cannot receive server-to-client requests"Keep a sessionful legacy route until they upgrade

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' }.

Questions