DEV Community

Sindhuja Nagaraja Sudhakar
Sindhuja Nagaraja Sudhakar

Posted on AI-assisted

Why MCP Dropped the Handshake: Building a Bare-Metal Stateless Client (SEP-2575)

Part 1 of 2.

Here's something that snuck up on me: as of the 2026-07-28 MCP specification, MCP is stateless by default. The proposal that started it — SEP-2575, "Make MCP Stateless" — has been folded into the spec itself. The spec now calls the old handshake-based design legacy and the new per-request design modern: there is no initialize handshake and no Mcp-Session-Id; every request carries its own protocol version and capabilities in _meta.

So I did two things: built the smallest possible modern (stateless) client to feel how it works, then pointed today's real AI clients at it to see how they negotiate. Spoiler: it's messier than "they've caught up" or "they haven't" — and the spec's own negotiation rules explain exactly why.

The problem: session state creates affinity

In the legacy MCP flow, a client opens a session with initialize, the server may hand back an Mcp-Session-Id, and the client echoes that id on every later request. That one header is what pins you to a box.

Once a request carries a session id, your load balancer has to send every follow-up call back to the same server instance (sticky routing), or you have to replicate that session across instances — usually with a Redis session store. Either way you pay: sticky routing wrecks even load distribution, and shared session state adds a network hop, a new failure mode, and a scaling bottleneck to every single call.

Here's the stateful world SEP-2575 wants to leave behind:

sequenceDiagram
    participant Agent
    participant LB as Load Balancer
    participant S1 as Instance 1
    participant Redis

    Agent->>LB: initialize
    LB->>S1: (creates session abc123)
    S1->>Redis: store session abc123
    S1-->>Agent: Mcp-Session-Id: abc123
    Note over Agent,S1: every later call MUST return to Instance 1<br/>(or read the session back from Redis)
    Agent->>LB: tools/call  (Mcp-Session-Id: abc123)
    LB->>S1: pinned by session id

The question I wanted to answer: can I design the client so no session ever needs to be pinned or synchronized?

The idea: make every request self-describing

The modern move is to stop negotiating context once and remembering it, and instead put that context into every request. Each call carries its protocol version and capabilities inline in _meta. Nothing is remembered between requests, so nothing has to be routed to a particular instance. (The spec uses namespaced keys like _meta.io.modelcontextprotocol/* for this — more on the exact schema below.)

sequenceDiagram
    participant Agent
    participant LB as Load Balancer
    participant Pool as Any Instance

    Agent->>LB: tools/call  (_meta: version + capabilities)
    LB->>Pool: route to ANY node
    Pool-->>Agent: result (echoes clientId)
    Note over Agent,Pool: no session id, nothing to pin,<br/>no Redis in the path

The code: a handshake-free JSON-RPC client

There's deliberately no initialize step. Every request is a plain JSON-RPC 2.0 object with the protocol metadata riding along in params._meta:

PROTOCOL_VERSION = "2026-07-28"

CLIENT_CAPABILITIES = {
    "tools": {"listChanged": False},
    "stateless": True,
}

def build_meta(client_id=None, capabilities=None):
    return {
        "protocolVersion": PROTOCOL_VERSION,
        "clientCapabilities": capabilities or CLIENT_CAPABILITIES,
        "clientId": client_id or str(uuid.uuid4()),
    }

def build_jsonrpc_request(method, params=None, client_id=None, capabilities=None):
    params = dict(params or {})
    params["_meta"] = build_meta(client_id, capabilities)   # inline, every call
    return {"jsonrpc": "2.0", "id": next(_id_counter),
            "method": method, "params": params}
Enter fullscreen mode Exit fullscreen mode

⚠️ Schema note: I flattened the metadata (protocolVersion,
clientCapabilities, clientId) to isolate the statelessness property and keep the snippet readable. The real wire format namespaces these under
_meta.io.modelcontextprotocol/* (e.g. io.modelcontextprotocol/protocolVersion). Treat my _meta as illustrative, not the literal spec schema.

The transport is just an HTTP POST — no session object, no connection state. I also surface the protocol version as a header so a gateway can inspect it without parsing the body:

def _post(self, payload):
    resp = requests.post(self.base_url, json=payload, headers={
        "Content-Type": "application/json",
        "MCP-Protocol-Version": PROTOCOL_VERSION,
    }, timeout=self.timeout)
    resp.raise_for_status()
    data = resp.json()
    if data.get("error"):
        raise MCPError(f"{data['error'].get('code')}: {data['error'].get('message')}")
    return data.get("result", {})
Enter fullscreen mode Exit fullscreen mode

Because list_tools() and call_tool() both go straight through _post, any instance can serve any request. There's nothing sticky to preserve.

Experiment: does statelessness remove affinity?

Hypothesis: if every request carries its own protocol context, the same logical client should be able to hit different replicas on consecutive calls with no shared session state.

To test that, I wrote a test that fires 50 concurrent tool calls, each carrying its own randomized _meta, through a deliberately awful transport (35% dropped packets, latency jitter, random instance selection) at a pool of stateless replicas that share no session state:

NUM_REQUESTS = 50
POOL_SIZE = 4
DROP_RATE = 0.35

async def chaotic_send(request, pool):
    for _ in range(MAX_RETRIES):
        await asyncio.sleep(random.uniform(0, 0.005))   # jitter
        if random.random() < DROP_RATE:
            continue                                    # dropped -> retry
        instance = random.choice(pool)                 # no affinity
        return await instance.handle(request)
    raise ConnectionError("all packets dropped after retries")
Enter fullscreen mode Exit fullscreen mode

Each replica answers using only the per-request _meta — never remembered state:

meta = params["_meta"]          # the ONLY context the server relies on
return {..., "_meta": {"serverInstance": self.instance_id,
                       "echoClientId": meta["clientId"]}}
Enter fullscreen mode Exit fullscreen mode

The suite asserts all 50 requests completed, that reused client ids landed on different instances (proving no stickiness snuck in), and that every response echoed the exact protocol version its request supplied. Here's the actual run:

  Stateless MCP concurrency test  (50 concurrent calls)
protocol version      : 2026-07-28
simulated drop rate   : 35% (with up to 10 retries)
server pool           : ['srv-0', 'srv-1', 'srv-2', 'srv-3']
requests completed    : 50/50
client -> instances (proves no sticky routing):
    client-0: ['srv-0', 'srv-1', 'srv-2', 'srv-3']  <-- moved between instances
    client-1: ['srv-0', 'srv-1', 'srv-2', 'srv-3']  <-- moved between instances
    client-2: ['srv-0', 'srv-1', 'srv-2', 'srv-3']  <-- moved between instances
PASS: every instance served any request independently - no session locks.
Enter fullscreen mode Exit fullscreen mode

Every client id was served by all four instances. The property we were after: request routing becomes independent of client identity — same logical client, different servers, no shared protocol session. This doesn't prove a production load balancer will never add affinity; it shows the protocol doesn't require any.

The catch: negotiation is a two-sided handshake

Then I pointed two real MCP clients at my server — GitHub Copilot CLI and Cursor. My first cut failed hard:

-32601: method not found: 'initialize'
Enter fullscreen mode Exit fullscreen mode

The logs told a subtler story than "clients are behind." The spec gives a modern server two jobs: it MUST implement a server/discover RPC (so clients can learn its versions and capabilities without a handshake), and it serves requests carrying per-request _meta. My first server implemented neither server/discover nor the old initialize — so there was simply no way in.

The clients then revealed their eras in the request stream:

  • Copilot CLI is dual-era. It tried the modern server/discover first, and only when my server answered -32601 did it fall back to the legacy initialize (which also -32601'd) before giving up.
  • Cursor went straight to initialize — the legacy path, no probe.

So the fix had two valid shapes. The spec-correct one: implement server/discover and be a proper modern server. The quick one I took for this experiment: add a legacy initialize shim so both clients connect via the handshake path — but crucially, my shim stores nothing and never mints an Mcp-Session-Id. Even on the legacy path, with no session id handed back the client has nothing sticky to resend, so any instance can still serve any later call. Backward compatibility and load-balancer freedom.

That's the subtle bit I want you to take away: protocol-level stickiness is server-minted. The server issues the id; the client just echoes it. Decline to mint it, and the client naturally sends nothing to pin with — no client adoption required. Statelessness at the protocol layer is something the server unlocks on its own. (That's the exact opposite of caching, which — as I'll show in Part 2 — only pays off when the consumer cooperates.)

💡 Key takeaway: Protocol-level stickiness is server-minted. As long as your server answers initialize without returning an Mcp-Session-Id, the client has nothing to echo — so any instance can serve any request, and you stay backward-compatible with today's legacy clients. Apps that need cross-call state use explicit state handles (server-minted IDs like basket_id, threaded through tool arguments) — and because any replica can resolve a handle from shared storage, they keep application state without bringing stickiness back.

Stateful vs. stateless, at a glance

Attribute Legacy MCP (2025-11-25 and earlier) Modern MCP (2026-07-28)
Session tracking Mcp-Session-Id header required Protocol metadata inline via _meta
Load balancing Sticky routing required Any instance / round-robin
Shared protocol-session state Redis / session store None required
Instance rotation Restarts can break active sessions Instances rotate freely

What statelessness does not solve

Protocol statelessness only removes implicit session state — it does not make your application stateless. You still own auth, authorization, rate limiting, idempotency, long-running tasks, and real conversation memory. In the modern model, cross-call state travels through explicit state handles — server-minted IDs (a basket_id, a connection_id) that the model threads through later tool calls — rather than an implicit session. That's the pattern SEP-2567 ("Sessionless MCP via Explicit State Handles") formalizes; notably it's a tool-design convention, not a new wire construct.

Stateless protocol ≠ stateless application. Statelessness buys free horizontal routing, not a free pass on application state.

flowchart TD
    MCP[MCP request] --> P[Protocol state]
    MCP --> A[Application state]
    P --> P1["Stateless: version + capabilities<br/>in _meta, no session"]
    A --> A1["Explicit state handle<br/>(basket_id, connection_id)"]
    P1 --> R[Any replica serves it]
    A1 --> ST[(Shared storage)]
    R --> App[Your application]
    ST --> App

What surprised me

I expected the hard part to be removing the session. It wasn't — that was a few lines. The harder problem was negotiation and compatibility: a server can be perfectly capable of handling stateless requests and still be unreachable by a client that expects a different protocol path. The real lesson wasn't "statelessness is easy" — it was that protocol design is about how two independently-evolving implementations discover what the other supports.

Takeaways

  • Protocol-level statelessness removes implicit session affinity — sticky routing and Redis session sync disappear because there's no session id to pin to.
  • Application state still exists, but through explicit state handles, not a stateful protocol. Stateless protocol ≠ stateless application.
  • The real production problem isn't just statelessness — it's compatibility (Copilot probes server/discover, Cursor wants initialize), plus caching, retries, and the sheer volume of tool calls an agent generates.

Part 1 was about routing. Part 2 is about agent behaviour — caching, and why advertising ttlMs / cacheScope isn't enough to stop AI planning loops from thrashing your database.

References

  1. MCP Specification (2026-07-28) — Basic / Overview. https://modelcontextprotocol.io/specification/2026-07-28/basic
  2. MCP Specification (2026-07-28) — Versioning and Compatibility (Modern / Legacy / Dual-era, per-request _meta, compatibility matrix). https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning
  3. MCP Specification (2026-07-28) — Transports / Overview (_meta.io.modelcontextprotocol/*, MCP-Protocol-Version). https://modelcontextprotocol.io/specification/2026-07-28/basic/transports
  4. SEP-2575 — Make MCP Stateless (Final; incorporated into the 2026-07-28 spec). https://modelcontextprotocol.io/seps/2575-stateless-mcp
  5. The 2026-07-28 MCP Specification Release Candidate — MCP Blog. https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/
  6. SEP-2567 — Sessionless MCP via Explicit State Handles (cited in-text). https://modelcontextprotocol.io/seps/2567-sessionless-mcp
  7. JSON-RPC 2.0 Specification. https://www.jsonrpc.org/specification

Top comments (0)