The short version: The 2026-07-28 MCP spec is stateless, and the payoff the primary source spells out is that a remote server "can now run behind a plain round-robin load balancer" — no sticky sessions, no shared session store. But **the protocol going stateless does not make your server stateless. A single in-memory map keyed by a connection, an auth token cached per instance, a subscription list held in RAM — any of these silently re-pins traffic and turns your first cross-replica request into a 404. The one-line test: run two replicas behind round-robin, mint a handle on one, use it on the other, and assert it round-trips.** Below is that test, plus the load and chaos checks that catch the residue before you delete your sticky-session config.

If you have not done the protocol migration yet, do that first — the client migration and the server checklist cover the header and handshake changes. This piece is what you run after the code compiles and before you trust it in production.

1. Stand up two replicas behind round-robin#

You cannot test for session affinity with one instance — a single server answers every request no matter how stateful it is. You need at least two identical replicas and a balancer that spreads requests without pinning. Round-robin is the whole point: it deliberately sends consecutive requests to different backends.

# docker-compose.yml — two identical replicas, nginx round-robin in front
services:
  mcp-a:
    build: .
    environment: [INSTANCE=a]
  mcp-b:
    build: .
    environment: [INSTANCE=b]
  lb:
    image: nginx:1.27
    ports: ["8080:80"]
    volumes: ["./nginx.conf:/etc/nginx/nginx.conf:ro"]
    depends_on: [mcp-a, mcp-b]
# nginx.conf — no ip_hash, no sticky cookie. Round-robin is the default.
events {}
http {
  upstream mcp {
    server mcp-a:3000;   # round-robin: request 1 → a, request 2 → b, ...
    server mcp-b:3000;
  }
  server {
    listen 80;
    location / {
      proxy_pass http://mcp;
      add_header X-Upstream $upstream_addr always;   # which replica answered
    }
  }
}

The X-Upstream header is your instrument — it tells you which replica served each request. If you ever feel tempted to add ip_hash or a sticky cookie to make a test pass, stop: that is the affinity you are supposed to be proving you no longer need.

2. The cross-replica handle test — the one that matters#

This is the single test that proves statelessness. Mint a handle with one tool call, then use it on the very next call. Round-robin guarantees the two requests hit different replicas, so if the handle still resolves, no instance is holding private state.

import httpx

SPEC = "2026-07-28"
URL = "http://localhost:8080/mcp"

def headers(method, name=None):
    h = {"Content-Type": "application/json",
         "MCP-Protocol-Version": SPEC, "Mcp-Method": method}
    if name: h["Mcp-Name"] = name
    return h

def call(client, method, params, name=None):
    body = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
    r = client.post(URL, headers=headers(method, name), json=body)
    r.raise_for_status()
    return r, r.headers.get("X-Upstream", "?")

with httpx.Client() as c:
    # Request 1 lands on replica A and mints a handle.
    r1, node1 = call(c, "tools/call",
                     {"name": "create_basket", "arguments": {}}, "create_basket")
    basket_id = r1.json()["result"]["structuredContent"]["basket_id"]

    # Request 2 lands on replica B and USES that handle.
    r2, node2 = call(c, "tools/call",
                     {"name": "add_item",
                      "arguments": {"basket_id": basket_id, "sku": "A-42"}},
                     "add_item")

    assert node1 != node2, f"round-robin didn't split: both hit {node1}"
    assert r2.status_code == 200, "handle didn't round-trip — you have per-instance state"
    print(f"PASS: minted on {node1}, used on {node2}, no affinity needed")

Gotcha: the failure is almost never in the protocol layer — it is in your tool code. The usual culprit is a tool that stashed the basket in self._baskets[basket_id] on the instance that created it. Replica B has never seen that dict. The fix is the one the spec assumes: carry state in the explicit handle, persisted to a shared store (Postgres, Redis, the object store), not in process memory.

3. Load-test with no session pinning#

Now confirm it holds under concurrency and that the balancer actually spreads the load. Send plain requests with no cookie and no session id — you are the stateless client the spec describes.

# 2000 requests, 50 concurrent, straight at the balancer, no session pinning.
hey -n 2000 -c 50 -m POST \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: server/discover" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}' \
  http://localhost:8080/mcp

# Then read the split from each replica's access log:
docker compose logs mcp-a | grep -c POST
docker compose logs mcp-b | grep -c POST   # should be within a few % of mcp-a

Two assertions: the non-2xx rate is zero, and the two counts are roughly equal. A skew toward one replica means something upstream is pinning (a stray keep-alive reuse counts here — force new connections in the test if you see it). A cluster of 4xx under load usually means a header the balancer strips or rewrites — route on Mcp-Method at the edge, not on the JSON-RPC body.

4. Kill a replica mid-run#

The strongest proof is a chaos test: losing a node should be a non-event. Drive steady traffic, kill a replica, and watch the error count.

( hey -z 20s -c 20 -m POST \
    -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: server/discover" \
    -H "Content-Type: application/json" \
    -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}' \
    http://localhost:8080/mcp > result.txt ) &
sleep 5
docker compose kill mcp-a      # yank a node while traffic flows
wait; grep -E "Status code|responses" result.txt

A stateless deployment absorbs this: the balancer stops sending to the dead node and every subsequent request completes on mcp-b. If instead you see a burst of failures beyond the handful of connections that were mid-flight on mcp-a, some client work depended on state that only lived on that instance — which is the affinity the whole exercise is meant to rule out.

5. Diff server/discover across replicas#

Statelessness includes your capability set. server/discover replaces the old handshake and is meant to be cacheable — which is only safe if every replica returns the same thing. Fetch it from each instance directly (bypass the balancer) and diff:

diff <(curl -s mcp-a:3000/mcp -H "Mcp-Method: server/discover" \
        -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}') \
     <(curl -s mcp-b:3000/mcp -H "Mcp-Method: server/discover" \
        -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}') \
  && echo "PASS: capabilities identical across replicas"

A difference means a capability depends on per-node state — a feature flag read at boot, an env var set on one host but not the other — and a cache in front will serve one replica's answer to clients hitting another. Make the capability set a pure function of your deployed build, then set a real Cache-Control on the response and let a CDN hold it.

Run these five before you delete the sticky-session config, not after. The spec's promise of boring, cheap, round-robin infrastructure is real — but it is a promise about the protocol, and only these tests tell you whether your code kept its side of the bargain. When they're green, the stateless core is finally paying you back the sticky-session tax you used to pay every month.