Removing state from the protocol does not make your state disappear. It just moves it out of the protocol's hands and into yours.
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.
It used to be "ask once on connect." Now it is "carry it every time" — and therefore any request can land on any instance.
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.
// 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
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.A gateway should not have to parse JSON-RPC to rate-limit. Now it doesn't.
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).
# 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.
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.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.
"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.
// 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);
}
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.This revision hands you two new footguns: a mislabelled cache scope, and an authorization server that will make HTTP requests on your behalf.
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.
// 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
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.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.
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.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.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.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._meta identity, server/discover, required headers, MRTR, caching, and deprecationscreateMcpHandler, versionNegotiation, cacheHints, signed requestState