Migrating your MCP server off the handshake for the 2026-07-28 spec is a finite diff: delete the initialize dance, drop the Mcp-Session-Id header, read _meta on every request. The hard part is the thing the diff doesn't show you — a hidden session dependency you left behind. This is a test that fails loudly if you did, so you can gate it in CI before the lock on July 28.
The one-sentence version: statelessness isn't code you wrote, it's behavior you have to prove — so send requests that share nothing, send them to a fresh process, and assert they all succeed identically.
Why a passing migration lies to you#
Here's the trap. You remove the handshake, your unit tests go green, you ship. Every test ran against one process over one connection, so an in-memory cache keyed by connection, a per-socket auth token, or a counter that assumes ordered requests all worked — right up until a round-robin load balancer sends request two to a different pod and it 400s with "not initialized" or silently returns stale state. The 2026-07-28 spec exists so any request can land on any instance; a conformance test reproduces that split on purpose, on your laptop, before production does it for you.
The four checks that matter#
- No handshake required. A
tools/callwith no priorinitializemust succeed. There is no handshake in the spec anymore; if your server still demands one, it's not stateless. - Requests share nothing. Two independent requests, no common session, both succeed.
- Instance-independence. The same request against a freshly restarted process returns the same result — proof the request carried everything it needed.
- Self-describing + hardened auth. Responses carry
Mcp-Method/Mcp-Namerouting headers, and a token with the wrongissis rejected per RFC 9207 — validated per request, because there's no handshake to validate it once.
The test#
This is a pytest suite against a Streamable HTTP MCP endpoint. It assumes a helper that builds a self-describing request with the _meta block the spec now requires on every call. Swap BASE_URL and the tool name for yours.
import subprocess, time, httpx, pytest
BASE_URL = "http://127.0.0.1:8080/mcp"
def req(method, params=None, iss="https://auth.example.com"):
"""A self-describing 2026-07-28 request: no session, everything in _meta."""
return {
"jsonrpc": "2.0", "id": 1, "method": method,
"params": params or {},
"_meta": {
"protocolVersion": "2026-07-28",
"clientInfo": {"name": "conformance", "version": "1.0"},
"capabilities": {},
"auth": {"iss": iss},
},
}
def call(payload):
return httpx.post(BASE_URL, json=payload, timeout=10)
# 1. A tools/call must work with NO prior initialize.
def test_no_handshake_required():
r = call(req("tools/call", {"name": "ping", "arguments": {}}))
assert r.status_code == 200
assert "error" not in r.json() or r.json()["error"]["code"] != -32002 # not "uninitialized"
# 2. Two requests that share nothing both succeed.
def test_requests_share_nothing():
a = call(req("tools/list"))
b = call(req("tools/call", {"name": "ping", "arguments": {}}))
assert a.status_code == 200 and b.status_code == 200
# 3. Same request against a freshly restarted process → same result.
def test_instance_independence():
first = call(req("tools/list")).json()["result"]
proc = subprocess.Popen(["python", "-m", "your_server"]) # fresh process
time.sleep(1.5)
try:
second = call(req("tools/list")).json()["result"]
finally:
proc.terminate()
assert first == second # request two depended on nothing request one created
# 4. Routing headers present; wrong issuer rejected.
def test_routing_headers_and_issuer():
r = call(req("tools/list"))
assert "Mcp-Method" in r.headers and "Mcp-Name" in r.headers
bad = call(req("tools/list", iss="https://evil.example.com"))
assert bad.status_code in (401, 403)
Fifty lines, no framework beyond pytest and httpx. Check 3 is the one that earns its keep: restarting the process is the cheapest honest simulation of "a different pod answered." If you'd rather not spawn a subprocess in CI, run two copies of the server on different ports and alternate requests between them — same guarantee, tests the round-robin path directly.
Wire it into CI before July 28#
The reason this belongs in CI and not a one-off script is that statelessness is easy to reintroduce. Six weeks from now a teammate adds a per-connection cache to shave latency, every unit test stays green, and the property you migrated for is quietly gone. A conformance job that runs check 3 on every PR is the thing that catches it — the same way a lint rule catches the style regression a reviewer would miss.
Run it against the 2026-07-28 beta SDKs now, fix whatever it turns up, and walk into the July 28 lock knowing your server behaves the way the spec says it does — not just that the diff looked right. For the broader server-testing picture beyond statelessness, our MCP server testing guide and the authorization changes breakdown cover the rest of the surface.



