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#

  1. No handshake required. A tools/call with no prior initialize must succeed. There is no handshake in the spec anymore; if your server still demands one, it's not stateless.
  2. Requests share nothing. Two independent requests, no common session, both succeed.
  3. Instance-independence. The same request against a freshly restarted process returns the same result — proof the request carried everything it needed.
  4. Self-describing + hardened auth. Responses carry Mcp-Method/Mcp-Name routing headers, and a token with the wrong iss is 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.