Migrate Your MCP Server to the Stateless MCP 2026-07-28 Spec
The Model Context Protocol's 2026-07-28 specification is the largest revision since the protocol launched, and its release candidate is live now: the final spec publishes July 28, 2026.

Introduction
The Model Context Protocol's 2026-07-28 specification is the largest revision since the protocol launched, and its release candidate is live now: the final spec publishes July 28, 2026. The headline change is a stateless core: the initialize/initialized handshake and the Mcp-Session-Id header are gone, and with them the sticky sessions, session stores, and gateway body-inspection production deployments grew around. Below, you migrate a session-bound MCP server to the stateless core and prove it by running two instances behind a plain round-robin load balancer.
What the old session actually did
Under the 2025-11-25 spec, every client connection began with an initialize handshake. The server minted a session id, the client echoed it in an Mcp-Session-Id header on every request, and the server kept per-session state in memory. Operationally, that one design choice cascaded: a load balancer had to route a session's every request to the same instance (sticky routing), scaling out meant a shared session store, and gateways had to parse JSON bodies just to learn which method was being called.
POST /mcp HTTP/1.1
Mcp-Session-Id: 1868a90c-3a3f-4f5b
{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"search","arguments":{"q":"otters"}}}One header, three infrastructure obligations.
What the 2026-07-28 core replaces it with
Statelessness in the new core rests on four mechanisms. Client metadata now travels in _meta on every request instead of living server-side. A new server/discover method returns capabilities without establishing anything. The Streamable HTTP transport requires Mcp-Method and Mcp-Name headers, so infrastructure can route on headers without reading bodies (servers must reject requests where header and body disagree). And multi-round-trip interactions return an InputRequiredResult carrying an opaque requestState the client echoes back, so the retry can land on any instance.
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"search","arguments":{"q":"otters"},
"_meta":{"io.modelcontextprotocol/clientInfo":{}}}}The state did not disappear. It moved into the request. That distinction drives every step below.
Build the stateless endpoint
The official Tier 1 SDKs are shipping 2026-07-28 support during the ten-week validation window, so this build implements the wire protocol directly with FastAPI. Fifty lines of transport gets you a server you fully understand, and swapping in the SDK later changes nothing architecturally.
pip install fastapi uvicornfrom fastapi import FastAPI, Header, Request
from fastapi.responses import JSONResponse
app = FastAPI()
PROTOCOL = "2026-07-28"
TOOLS = [{
"name": "search_docs",
"description": "Search the product documentation",
"inputSchema": {
"type": "object",
"properties": {"q": {"type": "string"}},
"required": ["q"],
},
}]
def rpc_result(req_id, result):
return JSONResponse({"jsonrpc": "2.0", "id": req_id, "result": result})
def rpc_error(req_id, code, message):
return JSONResponse({"jsonrpc": "2.0", "id": req_id,
"error": {"code": code, "message": message}})
@app.post("/mcp")
async def mcp(request: Request,
mcp_method: str = Header(None),
mcp_name: str = Header(None)):
body = await request.json()
if mcp_method != body.get("method"):
return rpc_error(body.get("id"), -32600,
"Mcp-Method header disagrees with body")
if mcp_method == "server/discover":
return rpc_result(body["id"], {
"protocolVersion": PROTOCOL,
"serverInfo": {"name": "docs-server", "version": "2.0.0"},
"capabilities": {"tools": {}},
})
if mcp_method == "tools/list":
return rpc_result(body["id"], {
"tools": TOOLS,
"ttlMs": 300_000,
"cacheScope": "public",
})
if mcp_method == "tools/call":
return await handle_call(body, mcp_name)
return rpc_error(body.get("id"), -32601, f"unknown method {mcp_method}")Three things to notice. There is no handshake branch because there is no handshake. The header-vs-body check is mandatory, not defensive politeness. And tools/list now advertises ttlMs and cacheScope, modeled on HTTP Cache-Control, so clients can cache the tool catalog for five minutes and share it across users instead of re-fetching on every connection.
Make every tool call a pure function
The handler takes a request and returns a response. Nothing survives between calls:
DOCS = {
"auth": "Authentication uses OAuth 2.1 with PKCE.",
"rate-limits": "Default rate limit is 100 requests per minute.",
"webhooks": "Webhooks retry with exponential backoff for 24 hours.",
}
async def handle_call(body, tool_name):
args = body["params"]["arguments"]
if tool_name == "search_docs":
q = args["q"].lower()
hits = [{"topic": k, "text": v} for k, v in DOCS.items() if q in k or q in v.lower()]
return rpc_result(body["id"], {
"content": [{"type": "text", "text": str(hits)}],
})
return rpc_error(body["id"], -32602, f"unknown tool {tool_name}")If your current server keeps per-session context (a working directory, an auth scope, a conversation memory), that context now arrives in _meta or in tool arguments on each request, or lives in an external store keyed by something the request carries. The refactor is mechanical once you accept the rule: the instance handling request N knows nothing about request N-1.
Handle confirmations with requestState, not sessions
The old flow held a Server-Sent Events stream open to ask the user "are you sure?" mid-call. The new flow returns an InputRequiredResult and dies. The client re-issues the call with the answer and the echoed requestState, and any instance can finish the job. The state is client-held, so sign it (the pack_state/unpack_state HMAC helpers appear in the full example):
async def handle_delete(body):
params = body["params"]
files = params["arguments"].get("paths", [])
responses = params.get("inputResponses")
if responses is None:
return rpc_result(body["id"], {
"resultType": "inputRequired",
"inputRequests": {"confirm": {
"type": "elicitation",
"message": f"Delete {len(files)} files?",
"schema": {"type": "boolean"},
}},
"requestState": pack_state({"step": 1, "files": files}),
})
state = unpack_state(params["requestState"])
if responses["confirm"] is True:
return rpc_result(body["id"], {
"content": [{"type": "text", "text": f"deleted {len(state['files'])} files"}],
})
return rpc_result(body["id"], {"content": [{"type": "text", "text": "cancelled"}]})The server that asked the question and the server that executes the answer can be different machines. That sentence is the whole migration.
Move long-running work to the Tasks extension
Tasks left the core spec and became an extension, negotiated through the extensions capability map with reverse-DNS ids. The lifecycle is server-directed: the server decides when a tools/call should return a task handle instead of a result, and the client polls with tasks/get. Shared state lives in a store every instance can reach, which here is SQLite and in production is Postgres or Redis:
import sqlite3
import threading
import uuid
db = sqlite3.connect("tasks.db", check_same_thread=False)
db.execute("CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, status TEXT, result TEXT)")
db.commit()
def start_reindex(body):
task_id = str(uuid.uuid4())
db.execute("INSERT INTO tasks VALUES (?, 'working', NULL)", (task_id,))
db.commit()
threading.Thread(target=run_reindex, args=(task_id,), daemon=True).start()
return rpc_result(body["id"], {"task": {"taskId": task_id, "status": "working"}})
def run_reindex(task_id):
import time
time.sleep(30) # stand-in for the real slow job
db.execute("UPDATE tasks SET status='completed', result='reindexed 4,812 documents' WHERE id=?",
(task_id,))
db.commit()Note what is missing: tasks/list. The RC removed it because a list cannot be scoped safely without sessions. If you deployed the experimental 2025-11-25 Tasks API, this lifecycle is a breaking change, not a rename.
Prove it with round-robin
Statelessness is a claim until a load balancer tests it. Run two instances and a four-line nginx config:
uvicorn server:app --port 8001 &
uvicorn server:app --port 8002 &upstream mcp_pool {
server 127.0.0.1:8001;
server 127.0.0.1:8002;
}
server {
listen 8080;
location /mcp { proxy_pass http://mcp_pool; }
}for i in 1 2 3 4; do
curl -s http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' -H 'Mcp-Name: search_docs' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"search_docs","arguments":{"q":"auth"}}}'
echo
doneFour requests, alternating instances, identical answers. Under the 2025-11-25 spec this exact setup was a bug factory. Now it is the reference deployment.
2025-11-25 vs 2026-07-28
| | 2025-11-25 (stateful) | 2026-07-28 (stateless) | |---|---|---| | Connection setup | initialize/initialized handshake | None; server/discover on demand | | Identity | Mcp-Session-Id header | _meta client info per request | | Routing | Sticky sessions required | Any instance, Mcp-Method/Mcp-Name headers | | Gateway visibility | Parse JSON bodies | Route on headers | | Caching | None defined | ttlMs + cacheScope on lists and reads | | Confirmations | Held SSE stream | InputRequiredResult + echoed requestState | | Long-running work | Experimental core Tasks | Tasks extension, server-directed, no tasks/list | | Roots, sampling, logging | Core features | Deprecated, functional for 12+ months |
Limitations
This is a wire-level demonstration, not an SDK replacement: auth (now aligned with OAuth 2.1 and OpenID Connect), MCP Apps, and Trace Context propagation in _meta are real parts of the RC that this build skips. The spec can still shift before July 28. And statelessness relocates state rather than abolishing it: the tasks table, the signed requestState, and whatever context your tools genuinely need all still exist, just no longer pinned to one process. Anyone who tells you their system has no state has simply stopped looking for it.
The full working example
# server.py: stateless MCP server, 2026-07-28 RC shape
# run: MCP_STATE_SECRET=change-me uvicorn server:app --port 8001
import base64
import hashlib
import hmac
import json
import os
import sqlite3
import threading
import time
import uuid
from fastapi import FastAPI, Header, Request
from fastapi.responses import JSONResponse
app = FastAPI()
PROTOCOL = "2026-07-28"
SECRET = os.environ.get("MCP_STATE_SECRET", "change-me").encode()
db = sqlite3.connect("tasks.db", check_same_thread=False)
db.execute("CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, status TEXT, result TEXT)")
db.commit()
DOCS = {
"auth": "Authentication uses OAuth 2.1 with PKCE.",
"rate-limits": "Default rate limit is 100 requests per minute.",
"webhooks": "Webhooks retry with exponential backoff for 24 hours.",
}
TOOLS = [
{"name": "search_docs", "description": "Search the product documentation",
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}, "required": ["q"]}},
{"name": "delete_files", "description": "Delete files after confirmation",
"inputSchema": {"type": "object", "properties": {"paths": {"type": "array", "items": {"type": "string"}}
{"name": "reindex", "description": "Rebuild the search index (long-running)",
"inputSchema": {"type": "object", "properties": {}}},
]
def rpc_result(req_id, result):
return JSONResponse({"jsonrpc": "2.0", "id": req_id, "result": result})
def rpc_error(req_id, code, message):
return JSONResponse({"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}})
def pack_state(state):
raw = json.dumps(state).encode()
sig = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
return base64.b64encode(json.dumps({"raw": raw.decode(), "sig": sig}).encode()).decode()
def unpack_state(packed):
outer = json.loads(base64.b64decode(packed))
expected = hmac.new(SECRET, outer["raw"].encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, outer["sig"]):
raise ValueError("tampered requestState")
return json.loads(outer["raw"])
def run_reindex(task_id):
time.sleep(30)
db.execute("UPDATE tasks SET status='completed', result='reindexed 4,812 documents' WHERE id=?", (task_id
db.commit()
@app.post("/mcp")
async def mcp(request: Request,
mcp_method: str = Header(None),
mcp_name: str = Header(None)):
body = await request.json()
req_id = body.get("id")
if mcp_method != body.get("method"):
return rpc_error(req_id, -32600, "Mcp-Method header disagrees with body")
if mcp_method == "server/discover":
return rpc_result(req_id, {"protocolVersion": PROTOCOL,
"serverInfo": {"name": "docs-server", "version": "2.0.0"},
"capabilities": {"tools": {}, "extensions": {"io.modelcontextprotocol/task
if mcp_method == "tools/list":
return rpc_result(req_id, {"tools": TOOLS, "ttlMs": 300_000, "cacheScope": "public"})
if mcp_method == "tasks/get":
row = db.execute("SELECT status, result FROM tasks WHERE id=?",
(body["params"]["taskId"],)).fetchone()
if row is None:
return rpc_error(req_id, -32602, "unknown task")
return rpc_result(req_id, {"task": {"taskId": body["params"]["taskId"],
"status": row[0], "result": row[1]}})
if mcp_method != "tools/call":
return rpc_error(req_id, -32601, f"unknown method {mcp_method}")
params = body["params"]
args = params.get("arguments", {})
if mcp_name != params.get("name"):
return rpc_error(req_id, -32600, "Mcp-Name header disagrees with body")
if mcp_name == "search_docs":
q = args["q"].lower()
hits = [{"topic": k, "text": v} for k, v in DOCS.items() if q in k or q in v.lower()]
return rpc_result(req_id, {"content": [{"type": "text", "text": str(hits)}]})
if mcp_name == "delete_files":
files = args.get("paths", [])
responses = params.get("inputResponses")
if responses is None:
return rpc_result(req_id, {
"resultType": "inputRequired",
"inputRequests": {"confirm": {"type": "elicitation",
"message": f"Delete {len(files)} files?",
"schema": {"type": "boolean"}}},
"requestState": pack_state({"step": 1, "files": files}),
})
state = unpack_state(params["requestState"])
if responses.get("confirm") is True:
return rpc_result(req_id, {"content": [{"type": "text",
"text": f"deleted {len(state['files'])} files"}]})
return rpc_result(req_id, {"content": [{"type": "text", "text": "cancelled"}]})
if mcp_name == "reindex":
task_id = str(uuid.uuid4())
db.execute("INSERT INTO tasks VALUES (?, 'working', NULL)", (task_id,))
db.commit()
threading.Thread(target=run_reindex, args=(task_id,), daemon=True).start()
return rpc_result(req_id, {"task": {"taskId": task_id, "status": "working"}})
return rpc_error(req_id, -32602, f"unknown tool {mcp_name}")When to migrate
If your MCP server runs as a local stdio process for one user, the stateless core buys you little and the deprecation windows give you a year of calm. Migrate now if your server is remote, multi-user, or about to be: the 2026-07-28 shape is what lets an MCP deployment scale like any other HTTP service, on commodity load balancers, with cacheable catalogs and no session store on the invoice. The ten-week RC window exists precisely so implementers can validate against real workloads before July 28. Be one of them, and the day the spec goes final your server already speaks it.
You might also like
Keep reading from the journal.
July 20, 2026Engineering
Stop switching models to fix your scanner
Quality gates at capture set the accuracy ceiling before any model runs
July 13, 2026DataEngineering
The bestseller your website cannot find
A canonical attribute dictionary, enforced at ingestion, keeps inventory findable
June 30, 2026Coding
Encode the playbook, not just the contract
Put the firm's positions where a system can apply them