The short version: In the 2026-07-28 MCP spec the protocol is stateless — the Mcp-Session-Id header and the initialize/initialized handshake are gone, so your client can no longer open a session and lean on it. The one-line mental model for the fix: stop establishing a connection, and start stamping three headers on every request. Send MCP-Protocol-Version plus Mcp-Method (and Mcp-Name on targeted calls) on each call, replace initialize with a stateless server/discover, and carry any continuity in an explicit handle the model passes back as a tool argument. This is the client-side companion to our server migration checklist; where that piece is a server to-do list, this one is code-first for the client.

If you want the background on why the session died, we covered the stateless core and the deprecation of Sampling, Roots, and Logging as they landed. Below is the migration itself.

1. Put the three routing headers on every request#

The Streamable HTTP transport now requires headers so load balancers and gateways can route on the operation without parsing the JSON-RPC body. On every request send MCP-Protocol-Version and Mcp-Method. Add Mcp-Name whenever the call targets a specific object — tools/call, resources/read, prompts/get — to name the tool, resource, or prompt.

import httpx

SPEC = "2026-07-28"

def mcp_headers(method: str, name: str | None = None) -> dict[str, str]:
    headers = {
        "Content-Type": "application/json",
        "MCP-Protocol-Version": SPEC,   # required on EVERY request
        "Mcp-Method": method,           # e.g. "tools/call"
    }
    # Mcp-Name is required for tools/call, resources/read, prompts/get.
    # It is NOT required for server/discover (no target object).
    if name is not None:
        headers["Mcp-Name"] = name
    return headers

async def call(client: httpx.AsyncClient, url: str, method: str,
               params: dict, name: str | None = None):
    body = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
    resp = await client.post(url, headers=mcp_headers(method, name), json=body)
    resp.raise_for_status()
    return resp.json()

Gotcha: servers reject requests where the headers and the body disagree. If Mcp-Method says tools/call but the body invokes something else, you get a hard error — derive the headers from the same values you serialize into the body, never hand-write them.

2. Replace initialize with server/discover#

There is no handshake to complete before your first real call. Delete the initialize/initialized exchange. If you need the server's capabilities up front, call server/discover — it is stateless and cacheable, so any instance can answer it and you can safely reuse the result across requests.

# BEFORE (removed in 2026-07-28) — do not do this:
# await call(client, url, "initialize", {"capabilities": {...},
#            "clientInfo": {...}, "protocolVersion": "2025-11-25"})
# session_id = resp.headers["Mcp-Session-Id"]   # header is GONE

# AFTER — fetch capabilities statelessly, cache them, reuse anywhere:
disco = await call(client, url, "server/discover", params={})  # no Mcp-Name
capabilities = disco["result"]["capabilities"]

The protocol version, client info, and client capabilities that used to be negotiated once at connection time now travel in _meta on every request. Attach them to your params rather than to a one-time handshake:

def with_meta(params: dict) -> dict:
    return {**params, "_meta": {
        "mcp/protocolVersion": SPEC,
        "mcp/clientInfo": {"name": "my-agent", "version": "1.0.0"},
        "mcp/capabilities": {"elicitation": {}},
    }}

Gotcha: stop persisting a session id. Any code path that read Mcp-Session-Id from a response, stored it, and replayed it on later calls is now dead — remove it, or you will pin (and eventually fail) against instances that no longer honor it.

3. Carry state in an explicit handle, not the session#

State that used to ride the session now moves into the open: a tool mints an id and returns it, and the model passes that id back as an ordinary argument on the next call — exactly how HTTP APIs have always worked. Your client's job is simply to let the handle round-trip through the conversation.

# First call: the server mints a handle and returns it in the tool result.
r1 = await call(client, url, "tools/call",
                params=with_meta({"name": "create_basket", "arguments": {}}),
                name="create_basket")
basket_id = r1["result"]["structuredContent"]["basket_id"]

# Later call: the MODEL supplies basket_id as a normal argument.
# There is no session tying these two requests together — and they may
# land on completely different server instances.
r2 = await call(client, url, "tools/call",
                params=with_meta({"name": "add_item",
                                  "arguments": {"basket_id": basket_id,
                                                "sku": "A-42"}}),
                name="add_item")

Gotcha: never rebuild a pseudo-session on the client. Don't hash the handle into a sticky-routing key or hide it from the model — the id is meant to travel as a visible argument. If your handles are long-lived, see our note on not orphaning a client handle store.

4. Point auth discovery at RFC 9728 metadata#

Servers are now OAuth 2.1 resource servers and MUST expose RFC 9728 Protected Resource Metadata, so your client discovers the authorization server instead of guessing. On a 401, read the WWW-Authenticate challenge if present, but fall back to the well-known Protected Resource Metadata URI — the challenge header is now optional.

async def discover_auth_server(client, resource_url: str) -> str:
    meta = await client.get(
        resource_url.rstrip("/") + "/.well-known/oauth-protected-resource")
    meta.raise_for_status()
    return meta.json()["authorization_servers"][0]

From there you run a standard OAuth 2.1 flow and attach the bearer token to the same header dict from Step 1. For the full auth rewrite, WorkOS has a good rundown.

When to migrate, and the SDK timeline#

The release candidate locked 2026-05-21 and the final spec ships 2026-07-28. SDK maintainers get a ten-week window, and Tier-1 SDKs are expected to ship spec support within it — so pin the exact compliant release before cutting over in production rather than hand-rolling headers indefinitely. Two related extensions land alongside the core: the Tasks extension (SEP-2663) replaces blocking with a poll-driven lifecycle where tools/call can return a task handle you drive with tasks/get, and MCP Apps (SEP-1865) delivers sandboxed, server-rendered UI. Do the three-header change first — it is the smallest edit that makes your client legal on day one.