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.
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
| Thing | Legacy | Modern |
|---|---|---|
| Setup | initialize + notifications/initialized | none; server/discover is optional |
| Version, capabilities | agreed once per session | in _meta on every request |
| Session | Mcp-Session-Id header | gone; handles and your own store |
| Server asks the client | server sends a request mid-call | input_required result, client retries |
| HTTP | POST + GET stream, Last-Event-ID resume | POST only; GET and DELETE are 405 |
| Liveness | ping | gone; health-check the HTTP endpoint |
| Change feed | resources/subscribe | subscriptions/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
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
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.
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{"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 taskSame POST, no _meta → 400, -32602: params._meta must carry protocolVersion and clientCapabilities
Mcp-Name: delete_order, body says get_order → 400, -32020: mcp-name header does not match the body
Version 2027-01-01 → 400, -32022, data.supported: ["2026-07-28"]
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
POST tools/call id 7, refund_order A-91 → replica AresultType: input_required
inputRequests: elicitation/create "Refund order A-91?"
requestState: v1.95RLH0UWe9Xv2mF8…the call ends; replica A keeps nothingPOST tools/call id 8, same arguments
inputResponses: accept, confirm true
requestState: the same → replica Breplica B completes: Refunded A-91Interactive 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
| Package | Modern? | Watch for |
|---|---|---|
| Python mcp 2.3.0 | yes, both sides | client probes by default |
| Python mcp 1.x | no | pin mcp<2 only for old code |
| TS v2 server and client 2.3.1 | yes | client defaults to legacy |
| TS sdk 1.32.1 (npm latest) | no: stops at 2025-11-25 | move to the v2 packages |
| Go, C#, Rust (Sept 2026) | yes | check your version |
| Java, Swift, Kotlin (Sept 2026) | no evidence yet | plan 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]))
| Server | Mode | Revision |
|---|---|---|
| v2 server (mcp 2.3.0) | auto | 2026-07-28 |
| v2 server (mcp 2.3.0) | legacy | 2025-11-25 |
| v1 server (mcp 1.30.0) | auto | 2025-11-25 (fell back) |
| Client | Server | Outcome |
|---|---|---|
| Modern only | Legacy only | Fails |
Dual-era (auto) | Legacy only | Works: falls back to initialize |
| Legacy | Dual-era | Works: server answers the handshake |
| Legacy | Modern only | Fails: 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
# 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()# 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.
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
| Setup | What happened |
|---|---|
Both replicas share REQUEST_STATE_KEY | A asks, B completes: Refunded A-91 |
| Each replica has its own key | B answers 400, -32602 Invalid or expired requestState |
| Legacy client, same tool, SSE responses | server sends elicitation/create the old way; Refunded A-91 |
Legacy client, json_response=True | fails: 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 pollstasks/get. - Shared caches
ttlMsandcacheScopeon lists;publicmay 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
| Feature | Since | Removable |
|---|---|---|
| Roots | 2026-07-28 | first revision on or after 2027-07-28 |
| Sampling | 2026-07-28 | first revision on or after 2027-07-28 |
| Logging | 2026-07-28 | first revision on or after 2027-07-28 |
| Dynamic Client Registration | 2026-07-28 | first revision on or after 2027-07-28 |
| HTTP+SSE transport | 2025-03-26 | three 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
isswith the recorded issuer before redeeming the code.
The full flow is in MCP Authorization; the threats in MCP Security.
The migration checklist
- InventoryList every client and server you run, its SDK and version, and the era it speaks. Done when: A table with no blanks.
- Upgrade the SDKPython
mcp>=2.3(FastMCPis nowMCPServer); TypeScript: the v2 server package. Done when:server/discoveranswers. - Serve both erasKeep one endpoint that answers
initializeand modern POSTs; the v2 SDKs do both. Done when: Inspector passes in legacy and modern. - Stop calling the clientReplace
ctx.elicit(), sampling and roots calls in tool bodies with resolvers that returninput_required. Done when: The tool returnsinput_required. - Share the state keyGive every replica one
requestStatekey ring from your secret store. Done when: A retry on another replica completes. - Move session stateAnything keyed by
Mcp-Session-Idbecomes a handle argument or a row in your store. Done when: Kill a replica mid-task; nothing is lost. - Route and cacheDrop sticky sessions; route on
Mcp-MethodandMcp-Name; setttlMsandcacheScopeon lists. Done when: Round-robin works. - 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.
- 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.
- Flip the clientsTurn on the TS client's version negotiation (auto); Python is
autoalready; 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.
- 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: trueso 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.
“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.
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.
- 2026-07-28 removes
initialize,Mcp-Session-Id,pingand server-to-client requests; every request carries_meta. - Two eras coexist; npm's
@modelcontextprotocol/sdkand 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.