DAY 60 · PROTOCOL

MCP Goes Stateless, and How to Migrate

The 2026-07-28 spec deleted the handshake and the session — which lines of your server have to change

2026-10-03 · BigCat

Removing state from the protocol does not make your state disappear. It just moves it out of the protocol's hands and into yours.

Prerequisite → Day 18 MCP (three primitives / transports / the context tax — written against the old stateful protocol)

// WHY THIS MATTERS

The 2026-07-28 revision is the largest breaking rewrite since MCP launched: the initialize handshake is gone, Mcp-Session-Id is gone, and the protocol no longer has a concept of a "session" at all. This is not syntactic sugar — the mental model from Day 18, "connect, then negotiate capabilities in a handshake," is simply no longer valid. What is worth your time is not the sentence "it went stateless" but three concrete engineering consequences: a remote server can now run bare behind a round-robin load balancer (no sticky routing, no shared session store), routing and rate-limiting information has been lifted into HTTP headers (your gateway can meter per tool without parsing the JSON-RPC body), and flows that need mid-call user input now use a retry instead of a long-lived connection. The costs are equally concrete: statelessness pushes a batch of state-management problems from the protocol onto you, and one mislabelled cache scope is a cross-user leak. This issue walks the mechanisms, gives a migration checklist, and names the failure mode each new mechanism brings with it.

// POINT 01

The handshake is gone: identity and capabilities drop from connection scope to request scope

It used to be "ask once on connect." Now it is "carry it every time" — and therefore any request can land on any instance.

// BACKGROUND

The old lifecycle was: connect → initialize to exchange protocol version and capabilities → initialized → subsequent requests identify back into the same session via Mcp-Session-Id. That design let a server assume "I have seen what came before for this session," at the price that horizontal scaling required stickiness: either the load balancer pinned sessions to instances, or session state went into a shared store like Redis.

The new spec (SEP-2575 / SEP-2567) deletes that layer wholesale. Client identity now travels in _meta on every request, under the fixed reverse-DNS key io.modelcontextprotocol/clientInfo. Capability discovery becomes an optional server/discover method — clients call it when they want to know up front, not as a prerequisite for anything. Alongside it, W3C Trace Context (SEP-414) also rides in _meta under the fixed keys traceparent / tracestate / baggage, which for the first time lets MCP calls join your existing OTel traces directly.

// IN PRACTICE

// New protocol: a self-contained tools/call, no prior handshake
{
  "jsonrpc": "2.0", "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": { "q": "mcp stateless" },
    "_meta": {
      "io.modelcontextprotocol/clientInfo": { "name": "my-app", "version": "1.0" }
    }
  }
}
// TypeScript SDK v2: a factory builds one server instance per request
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';

const handler = createMcpHandler(() => {
  const server = new McpServer(
    { name: 'my-server', version: '1.0.0' },
    { capabilities: { tools: {} } }
  );
  // register tools / resources / prompts here
  return server;
});
export default handler;  // web-standard runtimes export it directly
OLD (2025, stateful) NEW (2026-07-28, stateless) client ──initialize──┐ client ──tools/call──┐ └─Session-Id──┤ (identity in _meta) │ ▼ ▼ ┌─ sticky LB ─┐ ┌─ round-robin ─┐ ▼ ▼ ▼ ▼ [inst A] [inst B] [inst A] [inst B] └── shared session store (no shared state) (Redis / sticky routing)
Failure mode: the most common misreading is taking "the protocol is stateless" to mean "I don't have to manage state." The protocol merely stopped identifying sessions for you — business state that belonged in external storage still belongs there: a server accumulating per-client caches or authorization scope in process memory works fine on one instance and drifts randomly once you scale to two. The second trap is on the client side: server/discover is optional, so if you skip it and hard-code capabilities, a server that lacks a primitive will not fail at handshake time — it will blow up on the first real call instead.
// POINT 02

Mcp-Method / Mcp-Name: lifting routing information into HTTP headers

A gateway should not have to parse JSON-RPC to rate-limit. Now it doesn't.

// BACKGROUND

Under the old protocol all MCP traffic looked identical at the HTTP layer: the same POST /mcp, with the method name and tool name buried in the body. Rate-limiting per tool, authorizing per method, or blocking a dangerous operation at the WAF all required making the gateway parse a JSON body — an ugly and error-prone job at the infrastructure layer.

SEP-2243 standardizes this: Streamable HTTP requests must carry three headers — MCP-Protocol-Version, Mcp-Method, and Mcp-Name. Mcp-Method is the JSON-RPC method name; Mcp-Name mirrors params.name or params.uri, so calls that name a target (tools/call / resources/read / prompts/get) expose "which thing is being touched" in the headers. Crucially this is not a friendly hint: the server must validate that headers and body agree, and reject them when they don't — the TS SDK implements this as HTTP 400 with JSON-RPC error code -32020 (HeaderMismatch). A tool can additionally mark a parameter with x-mcp-header in its inputSchema, and the SDK serializes it into an Mcp-Param-{Name} header (Base64-encoded when necessary).

// IN PRACTICE

# Hand-rolled request: missing one of the three headers is a protocol error
curl -X POST https://example.com/mcp \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: search' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"search","arguments":{"q":"x"}}}'

# Only now can you write this at the gateway (Envoy-style, per-tool buckets)
# descriptor: request_headers -> Mcp-Name -> rate_limit
# Expensive tools (running SQL / calling external APIs) get a tighter bucket;
# cheap tools/list rides a wide one — without touching a single line of body.
Failure mode: first, intermediaries stripping headers. Plenty of reverse proxies, CDNs, and corporate egress proxies drop or rewrite custom headers they don't recognize, and the moment Mcp-Method gets eaten every request uniformly 400s — because it fails wholesale rather than intermittently, it is easy to misdiagnose as the server being down. curl through the real path before you ship to confirm the headers survive. Second, x-mcp-header lifts parameter values into your infrastructure logs: gateways, WAFs, and CDNs log request headers by default, so do not mark parameters that carry user identifiers, query content, or secrets.
Further reading: MCP Blog · The 2026-07-28 Release Candidate (SEP-by-SEP walkthrough of the six statelessness SEPs, 2026-05)
// POINT 03

MRTR: a retry instead of a long-lived connection

Asking the user something mid-execution no longer needs a live server-side connection — sign the state and hand it to the client to bring back.

// BACKGROUND

"The tool is halfway done and needs a confirmation or an extra argument" used to be implemented by the server issuing a request on an SSE stream. That was the last hard dependency on sticky sessions: the server had to stay alive on that connection, a dropped connection killed the flow, and it therefore could not cross instances.

SEP-2322 introduces MRTR (Multi-Round-Trip Requests) and inverts it: the server does not push, it returns a result that says "I still need input" — resultType: 'input_required', carrying inputRequests (what it needs answered) and requestState (an opaque string holding the context needed to continue). Once the client has the answers it retries the original call, putting them in inputResponses and echoing requestState back verbatim. Flow state therefore parks with the client between the two hops, the server needs to remember nothing, and the next hop can resume on a different instance. The companion SEP-2260 tightens what is left of server-initiated requests: they may only be issued while the server is actively processing a client request.

// IN PRACTICE

// TS SDK v2: requestState must be signed (HMAC-SHA256)
import { inputRequired, acceptedContent,
         createRequestStateCodec } from '@modelcontextprotocol/server';

const stateCodec = createRequestStateCodec<FlowState>({ key: SECRET });

async (args, ctx) => {
  const state = ctx.mcpReq.requestState<FlowState>();
  if (!state) {                      // first hop: ask
    return inputRequired({
      inputRequests: { count: inputRequired.elicit({ /* schema */ }) },
      requestState: await stateCodec.mint({ phase: 'awaiting-count' })
    });
  }
  // second hop: take the answers and continue (possibly on another instance)
  const response = acceptedContent(ctx.mcpReq.inputResponses, 'count', SCHEMA);
}
client server (any instance) │── tools/call ──────────────────────▶ A │◀─ input_required + inputRequests ───┤ │ + requestState (signed) │ │ │ │ (collect input from the user; │ │ no connection held meanwhile) │ │ │ │── retry tools/call ────────────────▶ B ← resumes on a new instance │ + inputResponses + requestState │ │◀─ final result ─────────────────────┤
Failure mode: requestState is data that has made a round trip through the client's hands. Hand-rolling the protocol layer, signing is the easiest thing to omit, and the field then degenerates into one the client can tamper with freely — stuffing {"userId":...,"approved":true} into it is a privilege-escalation bug. The SDK's HMAC signing exists precisely for this; do not route around it. The second trap is idempotency: externalized state means the same requestState can be replayed or submitted concurrently, so side-effecting tools (placing orders, sending mail, writing to a database) must carry their own idempotency key — the same problem as the exactly-once requirement in Day 58's piece on durable execution. Third, don't stuff large payloads into requestState: it crosses the network on every hop.
Further reading: SEP-2322 · Multi-Round-Trip Requests · Day 58 Durable Agent Execution (idempotency keys and exactly-once side effects)
// POINT 04

Cache scope, CIMD, and the twelve-month window: a migration checklist

This revision hands you two new footguns: a mislabelled cache scope, and an authorization server that will make HTTP requests on your behalf.

// BACKGROUND

Once sessions are gone, the cost of clients re-fetching tools/list surfaces, so SEP-2549 adds ttlMs and cacheScope to list and resource results, modeled on HTTP Cache-Control. The TS SDK defaults to the most conservative ttlMs: 0 / cacheScope: 'private' — that default is correct, and before changing it to 'shared' you should be able to prove the result is independent of who is calling.

Two authorization changes need deliberate work. First, Dynamic Client Registration (DCR) is formally deprecated in favour of CIMD (Client ID Metadata Documents): a client's client_id becomes a URL, and the authorization server fetches that URL on first use to retrieve a metadata document. The motivation is that DCR's open registration was used to register phishing clients, with publicly documented one-click account-takeover incidents in 2025; CIMD binds client identity to a domain the client must actually control. Second, SEP-2468 requires validating the iss parameter in authorization responses per RFC 9207.

The deprecation cadence is set by SEP-2577: an Active / Deprecated / Removed lifecycle with at least twelve months between deprecation and the earliest possible removal. Marked deprecated in this revision are Roots, Sampling, and Logging plus the legacy HTTP+SSE transport — their methods, types, and capability flags keep working in this release and in every specification version published within a year. On the SDK side, all four Tier 1 SDKs (TypeScript, Python, Go, C#) speak 2026-07-28, and Rust supports it in beta.

// IN PRACTICE

// Cache hints: before switching to 'shared', ask "is this list user-filtered?"
new McpServer({ name: 'my-server', version: '1.0.0' }, {
  capabilities: { tools: {} },
  cacheHints: { tools: { ttlMs: 3600000, cacheScope: 'shared' } }
  // ↑ only valid when the tool list is identical for every caller
});
# Migration checklist for a self-hosted server
1. Upgrade to an SDK that speaks 2026-07-28 (TS/Python/Go/C# GA, Rust beta)
2. Swap the entry point for a stateless handler (TS: createMcpHandler);
   delete the initialize branch
3. Audit in-process state: per-session caches / accumulated scope
   -> externalize or remove
4. Verify Mcp-Method / Mcp-Name survive your reverse proxy and CDN
5. Rewrite server-initiated requests as MRTR; requestState must be signed
6. Set ttlMs / cacheScope explicitly on every list; leave the default private
7. Authorization: add RFC 9207 iss validation; plan the DCR -> CIMD move
8. Inventory dependencies on Roots / Sampling / Logging (~one-year window)
9. Old clients: the SDK's legacy 'stateless' mode serves 2025 traffic
   on the same endpoint
Failure mode: the most dangerous is cacheScope: 'shared' on a user-filtered list — multi-tenant servers slip into this easily ("isn't the tool list static?"), and user A ends up caching the tool set visible to user B. If anything in a list result has ever varied by caller, it can only be private. Next, CIMD works by having the authorization server fetch a client-supplied URL, which is inherently an SSRF surface: a self-hosted AS must constrain that fetch (block private ranges and metadata endpoints, cap timeouts and response size, don't follow redirects). Last is misjudging the cadence — reading "twelve months left" as "not my problem this time." The replacement paths for Roots and Sampling are not settled yet, so binding your design tightly to them now makes next year's migration a reactive one.
Further reading: MCP Spec 2026-07-28 · Client Registration (the normative text on CIMD and the DCR deprecation) · MCP Blog · Release Candidate (the six authorization SEPs)

// WHERE THIS IS HEADED

The official roadmap of 2026-08-22 is blunt about direction: transports keep converging on HTTP — even local servers are to be unified onto "Streamable HTTP over stdio" rather than maintaining two approaches; authorization moves toward workload identity, finalizing DPoP and replacing long-lived API keys with the ID-JAG grant behind Enterprise-Managed Authorization plus standard token exchange; server-initiated events come back as webhooks and channels so clients aren't left polling for results — which does not contradict this revision's tightening of server-initiated requests, it moves them out of an in-protocol connection and onto an out-of-band channel; and progressive tool discovery, where a server offers a small entry point and reveals more of its catalog as the conversation narrows — the protocol-level answer to the tool-definition context tax from Day 18. If you run your own server, the first two mean: stop binding your design to stdio-only paths and static API keys now.

// GOING DEEPER

Does "stateless protocol" eliminate complexity, or just relocate it?
It relocates it. The "session" abstraction used to carry three things for both sides: identity memory, a capability contract, and mid-flow state. With it gone, identity becomes overhead retransmitted on every request, the capability contract becomes either an extra server/discover round trip or the risk of a late failure, and flow state becomes a signed requestState the client ferries back and forth. The gain is real — horizontal scaling no longer needs sticky routing, which is worth a lot operationally — but the protocol's surface area grew (three required headers, a caching semantics, a state string that must be signed) and the new correctness burden lands on implementers. So the gain is unevenly distributed: a clear win for anyone running remote servers at scale, close to pure migration cost for someone running one local stdio server.
Is there a new security cost to exposing method and tool names in HTTP headers?
Yes, and it is a trade-off rather than a pure win. Three costs: observability is leakage — headers land in gateway, CDN, and WAF logs, so "who called which tool when" is recorded all along the path, and x-mcp-header drags parameter values in too; a metadata side channel — even with a redacted body, the call pattern itself can reveal business intent; and a new inconsistency surface — header/body agreement validation is itself logic that must be implemented correctly, and a lax implementation offers a "pass on the header, execute on the body" bypass. The conclusion is not to avoid it but to design headers as public data.
MRTR parks flow state with the client. Which classic distributed-systems problem is that isomorphic to?
To pushing session state down into a cookie or JWT. Three old lessons apply directly: state held by the client must be signed, or it is an escalation path; anything that round-trips can be replayed, so side-effecting resumption must be idempotent and requestState should bind an idempotency key rather than merely encoding "what's next"; and without server-side bookkeeping there is no expiry, so you must put an issue time in the payload and validate it. The deeper difference: a durable-execution framework's journal lives on the server, while MRTR's state lives on the client — which makes MRTR right for short, few-hop, replayable interactions and wrong as a way to implement long-running workflows.
How do you engineer against a "safe by default but easy to break" setting like cache scope?
The incident never happens at the default; it happens the moment someone flips it to shared to save latency — and that class of bug will not show up in tests, because in a single-user environment the two behave identically. So lean on structural constraints rather than defaults: make "does this result vary by caller" a fact at the type level, or add a CI assertion that requests the same tools/list under two different identities and treats any difference as proof that shared is misconfigured. This generalizes: any setting that "still runs when wrong, and is only wrong under multi-tenancy" deserves a two-identity comparison test rather than a warning in the docs.
What general lesson does a breaking rewrite like Day 18 → Day 60 teach about adopting new protocols early?
One usable dividing line: isolate the protocol as a dependency instead of internalizing it as architecture. What this revision broke were exactly the implementations that had absorbed protocol semantics into their design — whoever's business logic genuinely relied on "I have seen what came before for this session" has the most painful migration, while whoever treated MCP as a thin request/response adapter changes a few lines. So the point is not early versus late, but leaving a layer when you adopt: tool implementations that are unaware of transport and session semantics, with protocol detail concentrated in one adapter. A corollary: the spec now writes a minimum twelve-month deprecation window into the rules, which makes future breaking changes more predictable — and makes "wait for it to stabilize" a worse bargain than it used to be.

// FURTHER READING