1:1 mentoring with Big Tech AI engineers
Back to blog
By AgenticPrep Team14 min read

MCP Went Stateless: What Spec 2026-07-28 Changes and How to Migrate

No handshake, no session, no server-to-client requests. What changed on the wire, who speaks which era, and the order to migrate in.

mcpmigrationprotocolpython
Key idea

MCP 2026-07-28 deletes the handshake and the session: every request carries its own version and capabilities, and a server that needs input returns a question instead of calling the client. Your servers keep working for old clients only if they serve both eras, and they scale only if the state you used to hide in the session now lives in the message or in a store every replica can reach.

The session pinned a client to one instance; a 2026-07-28 request carries its own context
UP TO 2025-11-25: HANDSHAKE, THEN SESSION 2026-07-28: ONE SELF-DESCRIBING POST Client legacy Instance A holds the session 1 · initialize 2 · initialized 3 · tools/call + Mcp-Session-Id Client modern 1 · tools/call + _meta or this one or this one Instance A Instance B Instance C another instance: 404, session unknown any instance answers; routers read headers The protocol forgets between requests, so state now rides in the message

Three POSTs before the first tool result become one. On a phone, swipe the figure sideways.

What broke: seven things the handshake era relied on

ThingLegacyModern
Setupinitialize + notifications/initializednone; server/discover is optional
Version, capabilitiesagreed once per sessionin _meta on every request
SessionMcp-Session-Id headergone; handles and your own store
Server asks the clientserver sends a request mid-callinput_required result, client retries
HTTPPOST + GET stream, Last-Event-ID resumePOST only; GET and DELETE are 405
Livenesspinggone; health-check the HTTP endpoint
Change feedresources/subscribesubscriptions/listen (one long POST)

From the spec changelog. These are removed, not deprecated; a legacy peer still uses all of them, so the eras must be told apart (step 04).

The version string names the last date a breaking change landed. 2026-07-28 is the current revision (release candidate 2026-05-21, final 2026-07-28); the four before it, 2024-11-05 through 2025-11-25, all start with initialize. That is the line between the two eras. The MCP overview walks the five revisions; MCP Architecture explains why sessions had to go.

On the wire: three POSTs become one

Legacy 2025-11-25
POST {"method": "initialize",
  "params": {"protocolVersion":
             "2025-11-25", ...}}
200  Mcp-Session-Id: 71fdb84c...
POST {"method":
      "notifications/initialized"}
     Mcp-Session-Id: 71fdb84c...
202
POST {"method": "tools/call", ...}
     Mcp-Session-Id: 71fdb84c...
200  {"content": [{"type": "text",
       "text": "A-91: shipped"}]}
  • Three POSTs before the first result
  • The session id pins the client to the instance that issued it
Modern 2026-07-28
POST /mcp
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_order

{"method": "tools/call",
 "params": {"name": "get_order",
  "arguments": {"order_id": "A-91"},
  "_meta": {version, capabilities,
            clientInfo}}}
  • One POST, no session
  • Headers repeat the method and tool name so a router can read them without parsing the body

Rule of thumb: if a request cannot be understood on its own, it is not a 2026-07-28 request.

Both captured on 2026-10-21 against the same mcp 2.3.0 server, which served each era on one endpoint. Bodies are trimmed; the full modern request is below.

The modern call and its result, then four ways to get it wrong
app
POST /mcp
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_order

{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
 "params": {
  "name": "get_order",
  "arguments": {"order_id": "A-91"},
  "_meta": {
   "io.modelcontextprotocol/protocolVersion": "2026-07-28",
   "io.modelcontextprotocol/clientCapabilities": {},
   "io.modelcontextprotocol/clientInfo": {"name": "new", "version": "1"}}}}
_meta replaces what the handshake agreed
tool
{"jsonrpc": "2.0", "id": 1, "result": {
 "content": [{"type": "text", "text": "A-91: shipped"}],
 "isError": false,
 "resultType": "complete",
 "_meta": {"io.modelcontextprotocol/serverInfo":
            {"name": "orders", "version": ""}}}}
every result now says what kind it is: resultType complete, input_required or task
error

Same POST, no _meta → 400, -32602: params._meta must carry protocolVersion and clientCapabilities

error

Mcp-Name: delete_order, body says get_order → 400, -32020: mcp-name header does not match the body

error

Version 2027-01-01 → 400, -32022, data.supported: ["2026-07-28"]

error

GET /mcp as a modern client → 405: the modern era is POST only

The body is the source of truth and the headers must agree with it; a mismatch is -32020, which stops a proxy from routing on one tool name while the server runs another. clientInfo and serverInfo are self-reported, so never authorize on them. Header rules and the stream that closes with each request are in MCP Transport.

Servers stop calling you: multi round-trip requests

One confirmation across two replicas
app
POST tools/call id 7, refund_order A-91  → replica A
tool
resultType: input_required
inputRequests: elicitation/create "Refund order A-91?"
requestState: v1.95RLH0UWe9Xv2mF8…
the call ends; replica A keeps nothing
app
POST tools/call id 8, same arguments
inputResponses: accept, confirm true
requestState: the same  → replica B
replica B completes: Refunded A-91

Interactive when scripts load: scrub all six messages. The static view shows the three HTTP exchanges.

Elicitation, sampling and roots requests no longer travel from server to client. A tool that needs an answer returns input_required with inputRequests and an opaque requestState; the client asks, then repeats the call with inputResponses, the same state and a new JSON-RPC id. In Python 2.x the question lives in a resolver, so the tool body runs only once the answer is in:

import os
from typing import Annotated
from pydantic import BaseModel
from mcp.server.mcpserver import (Elicit, ElicitationResult,
    MCPServer, RequestStateSecurity, Resolve)

ring = RequestStateSecurity(keys=[os.environ["REQUEST_STATE_KEY"]])
mcp = MCPServer("orders", request_state_security=ring)

class Confirm(BaseModel):
    confirm: bool
def ask(order_id: str) -> Elicit[Confirm]:
    return Elicit(f"Refund order {order_id}?", Confirm)
@mcp.tool()
def refund_order(order_id: str, answer: Annotated[
        ElicitationResult[Confirm], Resolve(ask)]) -> str:
    """Refund one order after the user confirms."""
    if answer.action == "accept" and answer.data.confirm:
        return f"Refunded {order_id}"
    return "Not refunded: not confirmed."

Awaiting ctx.elicit() inside the body is the old shape: a modern request has no back-channel, so it fails. The same resolver-based tool also served a legacy client in our run, as an old-style elicitation/create. The full pattern, with handles and tasks, is in State Without Sessions.

Two eras coexist, and SDK support is opt-in

PackageModern?Watch for
Python mcp 2.3.0yes, both sidesclient probes by default
Python mcp 1.xnopin mcp<2 only for old code
TS v2 server and client 2.3.1yesclient defaults to legacy
TS sdk 1.32.1 (npm latest)no: stops at 2025-11-25move to the v2 packages
Go, C#, Rust (Sept 2026)yescheck your version
Java, Swift, Kotlin (Sept 2026)no evidence yetplan on legacy

TS v2 is @modelcontextprotocol/server and /client; TS sdk is @modelcontextprotocol/sdk. Versions read from PyPI and npm on 2026-10-21; the TS default from the 2.3.1 type definitions. The last two rows come from the SDK READMEs as of 2026-09-28.

The trap is the npm package everyone already has. @modelcontextprotocol/sdk is still the latest tag and its newest release still names 2025-11-25 as its top version. Even the v2 TypeScript client stays on legacy until you set versionNegotiation. Python is the other way round: Client probes by default. That is the client we ran, unchanged, against both servers:

import asyncio, sys
from mcp import Client

async def main(url: str):
    for mode in ("auto", "legacy"):
        async with Client(url, mode=mode) as c:
            r = await c.call_tool("get_order", {"order_id": "A-91"})
            print(mode, c.protocol_version, r.content[0].text)

asyncio.run(main(sys.argv[1]))
ServerModeRevision
v2 server (mcp 2.3.0)auto2026-07-28
v2 server (mcp 2.3.0)legacy2025-11-25
v1 server (mcp 1.30.0)auto2025-11-25 (fell back)
ClientServerOutcome
Modern onlyLegacy onlyFails
Dual-era (auto)Legacy onlyWorks: falls back to initialize
LegacyDual-eraWorks: server answers the handshake
LegacyModern onlyFails: no fall-forward

A dual-era client probes with server/discover; a legacy server does not know the method, so the client falls back to initialize and remembers the answer. Nothing falls forward, so a modern-only server strands every legacy client. Serve both until your inventory says no legacy client is left. The HTTP probe order is in MCP Transport; per-client defaults, including Claude Code, are in MCP with Claude.

Migrating a Python server from v1 to v2

Before mcp 1.30.0, legacy only
# mcp 1.x (pip install "mcp<2")
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("orders")

@mcp.tool()
def get_order(order_id: str) -> str:
    """Look up one order by id."""
    return f"{order_id}: shipped"

app = mcp.streamable_http_app()
After mcp 2.3.0, both eras
# mcp 2.x (pip install "mcp>=2.3")
from mcp.server import MCPServer

mcp = MCPServer("orders")

@mcp.tool()
def get_order(order_id: str) -> str:
    """Look up one order by id."""
    return f"{order_id}: shipped"

app = mcp.streamable_http_app()

Rule of thumb: the rename is two lines; the work is in tools that call the client, and in state.

pip install mcp now installs 2.x, and the v1 import fails with a ModuleNotFoundError that names the fix: FastMCP was renamed to MCPServer, or pin mcp<2. Both servers above ran, and the v2 one answered the legacy handshake and the modern POST on the same /mcp path. Tool design is unchanged, so Build an MCP Server in Python and Building MCP Servers still apply.

One flag to watch

streamable_http_app(json_response=True) answers with plain JSON, so a legacy client has no stream on which to receive elicitation/create. In our run that call failed with no back-channel, while the default SSE responses worked in both eras.

A stateless protocol is not a stateless application

SetupWhat happened
Both replicas share REQUEST_STATE_KEYA asks, B completes: Refunded A-91
Each replica has its own keyB answers 400, -32602 Invalid or expired requestState
Legacy client, same tool, SSE responsesserver sends elicitation/create the old way; Refunded A-91
Legacy client, json_response=Truefails: no back-channel for server-initiated requests

Two replicas of the step 03 tool: first POST to one, retry to the other.

The protocol stopped holding state; your application did not. In Python the default requestState key is random per process, so a retry that lands on another replica cannot open the state. With random balancing that is one retry in two on two replicas and nine in ten on ten. The fix is one key ring on every replica, as in the code above, and rotation that adds the new key before it signs with it.

Question mid-call
Sealed requestState; every replica needs the key. It is replayable, so record a spent state or use an idempotency key for writes.
Record across calls
A handle the tool returns and the model passes back, looked up per call and checked against the caller.
Work longer than a request
The Tasks extension: return resultType: task, the client polls tasks/get.
Shared caches
ttlMs and cacheScope on lists; public may be shared across users, so it is never access control.

What you get in return is ordinary HTTP scaling: round-robin works, health checks hit the endpoint, and a gateway can route and rate-limit on headers. That shape is in MCP in Production and Many Servers.

Deprecations, their clocks, and the auth changes

FeatureSinceRemovable
Roots2026-07-28first revision on or after 2027-07-28
Sampling2026-07-28first revision on or after 2027-07-28
Logging2026-07-28first revision on or after 2027-07-28
Dynamic Client Registration2026-07-28first revision on or after 2027-07-28
HTTP+SSE transport2025-03-26three months after SEP-2596 went Final: the next revision (undated)

From the spec's deprecated-features registry (read on GitHub, 2026-10-21). Removal is a maintainer decision at release time, never automatic, and nothing has been removed under the policy yet.

Deprecated means it still works: a legacy server can still send sampling/createMessage, and a modern one can still carry sampling inside inputRequests. So keep client handlers until your servers move, but write no new code against them. HTTP+SSE is the urgent one: deprecated since 2025-03-26, so it can go in the next revision.

Audience
The server is an OAuth resource server: it accepts only tokens issued for it (RFC 8707) and answers 401 otherwise.
No passthrough
Never forward the inbound token to an upstream API; get a token of your own.
Registration
Pre-registered, then Client ID Metadata Documents, then DCR (deprecated); DCR clients must set application_type.
Flow checks
PKCE S256 is required; compare a returned iss with the recorded issuer before redeeming the code.

The full flow is in MCP Authorization; the threats in MCP Security.

The migration checklist

  1. InventoryList every client and server you run, its SDK and version, and the era it speaks. Done when: A table with no blanks.
  2. Upgrade the SDKPython mcp>=2.3 (FastMCP is now MCPServer); TypeScript: the v2 server package. Done when: server/discover answers.
  3. Serve both erasKeep one endpoint that answers initialize and modern POSTs; the v2 SDKs do both. Done when: Inspector passes in legacy and modern.
  4. Stop calling the clientReplace ctx.elicit(), sampling and roots calls in tool bodies with resolvers that return input_required. Done when: The tool returns input_required.
  5. Share the state keyGive every replica one requestState key ring from your secret store. Done when: A retry on another replica completes.
  6. Move session stateAnything keyed by Mcp-Session-Id becomes a handle argument or a row in your store. Done when: Kill a replica mid-task; nothing is lost.
  7. Route and cacheDrop sticky sessions; route on Mcp-Method and Mcp-Name; set ttlMs and cacheScope on lists. Done when: Round-robin works.
  8. Retire deprecated partsLogging to stderr or OpenTelemetry, sampling to your LLM API, roots to tool parameters, HTTP+SSE to Streamable HTTP. Done when: No new code uses them.
  9. Harden authValidate the token audience, never forward it upstream, prefer Client ID Metadata Documents over DCR. Done when: A token for another server gets 401.
  10. Flip the clientsTurn on the TS client's version negotiation (auto); Python is auto already; check Claude Code's runtime. Done when: Each client logs 2026-07-28.

Steps 1 to 3 keep both eras working on day one; 4 to 7 make the modern era scale; flip clients last.

Common mistakes
  • Upgrading only the client to modern-only: every legacy server it talks to fails. Probe instead.
  • Keeping sticky sessions "to be safe": it hides the per-process key bug until a replica restarts.
  • Returning upstream API failures as JSON-RPC errors: they belong in isError: true so the model can recover.
  • Reading "SSE is deprecated" as "no text/event-stream": Streamable HTTP still streams each response; only the two-endpoint transport is going.
Interview angle

“Our MCP server runs on three replicas with sticky sessions. The spec went stateless. What changes?” The protocol stops needing affinity: each POST carries version and capabilities, so drop the stickiness and route on Mcp-Method. Then the application: anything keyed by the session id moves to handles or a shared store, the requestState key becomes one ring, and writes get idempotency keys because retries replay. Finish with the eras: serve both until no legacy client is left.

Check yourself
A Python mcp 2.3 client connects to a server built on mcp 1.x. Which revision do they use, and how did they agree?

2025-11-25. The client's default auto mode sent server/discover; the 1.x server did not recognise it, so the client fell back to initialize. We ran exactly this.

Confirmations work on staging (one replica) and fail with -32602 "Invalid or expired requestState" in production. What is wrong?

Each production replica seals state with its own random key, so a retry landing elsewhere cannot open it. Give every replica the same key ring.

Can you delete your client's sampling handler today?

Not yet. Sampling is deprecated, not removed: legacy servers still send it, and modern ones can still carry it in inputRequests. The earliest removal is the first revision on or after 2027-07-28.

Recap
  • 2026-07-28 removes initialize, Mcp-Session-Id, ping and server-to-client requests; every request carries _meta.
  • Two eras coexist; npm's @modelcontextprotocol/sdk and the TS v2 client default to legacy, while Python 2.x probes.
  • Serve both eras on one endpoint, and let clients probe rather than guess.
  • Tools that ask the user return input_required; the state rides in the message, so every replica needs one key.
  • Roots, Sampling, Logging and DCR can go from 2027-07-28; HTTP+SSE can go in the next revision.

Next: State Without Sessions — handles, sealed state and tasks, the three places your session state goes now.

Enjoyed this post? The full curriculum has 149 sections, system design problems, and AI-reviewed practice runs.

See the full guide