You did everything the guides told you to do.
You stood up a Model Context Protocol (MCP) server. You handled the initialize handshake. You issued an Mcp-Session-Id, wired sticky sessions on your load balancer so every follow-up request landed on the instance that held the connection, and you built a session store so a pod restart would not drop a live client. That is the reference architecture. It is in every "production MCP server" post written in 2025, including the one on this blog.
On 2026-07-28 the MCP specification deletes the session. No handshake. No Mcp-Session-Id. Any request can hit any instance. MCP is now stateless.
Here is the part that makes this dangerous instead of merely annoying: nothing crashes. Your running server keeps serving. The protocol is versioned, old clients keep negotiating the old revision, and the maintainers have said so in plain language. So there is no outage to force your hand. The gap just widens quietly: every time someone copies the 2025 template, another server ships built the old way.
The thesis: MCP went stateless, but the session problem did not
Removing protocol-level sessions did not remove the session problem. It relocated it - out of the wire protocol and into your application. That relocation is the actual work, and almost none of the existing MCP guides describe it, because when they were written the protocol did the work for you.
So the claim of this article is narrow and testable: nothing breaks at runtime on July 28, but the reference architecture is now wrong, and the thing that replaces it is a set of responsibilities you now own. State continuity, version negotiation, delivery guarantees, and per-request authorization used to live below your code. They now live inside it. Unless you go looking, no code owns them.
That is the trap. An unowned responsibility is not a neutral gap you can schedule for later. It is a bug that has not happened yet: the cross-tenant read, the double charge, the request that runs on a version your handler never checked. The stateful protocol hid these behind the session. Remove the session and they surface anyway, one incident at a time, in the systems of teams who thought "stateless" meant "less work."
If you build a new MCP server from a 2025 template after this date, you are not shipping a working system with a deprecation warning. You are shipping the old mental model into a protocol that no longer holds up its half of it.
What the protocol used to do for you: handshake, session, and Mcp-Session-Id (through 2025-11-25)
The revision this replaces is 2025-11-25. Under it, an MCP session was a real protocol object:
- A handshake. The client sent
initialize, the server replied with its capabilities and protocol version, the client sentnotifications/initialized. Version and capability negotiation happened once, at the top of the connection. - A session id. The server minted an
Mcp-Session-Id. Every later request carried it. That header is why teams reached for sticky routing and a shared session store: the session was connection-bound state the protocol expected to persist. - Resumable streams. Server-Sent Events (SSE) responses were resumable. If a stream dropped, the client replayed
Last-Event-IDand the server redelivered the missed messages. The protocol promised to resume a broken response and redeliver what you missed. - Server-initiated calls. The server could call back into the client mid-request for sampling, elicitation, or roots.
This was convenient and it scaled badly. Sessions fought load balancers. Horizontal scaling meant either sticky routing or a shared session store that every instance contended on. The connection lifecycle leaked into your harness, so your infrastructure had to care which pod a client was "attached" to. The statelessness in the new revision is the direct answer to that pain.
What actually changed on 2026-07-28
The 2026-07-28 revision is a Release Candidate (RC), published in May 2026 and finalizing on 2026-07-28. Treat the date as real and the text as near-final. The changes below are from the official changelog and the Specification Enhancement Proposals (SEPs) behind them.
- Sessions removed (SEP-2567). The
Mcp-Session-Idheader and protocol-level sessions are gone from the Streamable HTTP transport. List endpoints no longer vary per connection. In the spec's own words, servers that need cross-call state now use "explicit, server-minted handles passed as ordinary tool arguments." - Handshake removed (SEP-2575). No more
initialize/notifications/initialized. Every request carries its protocol version and client capabilities in_meta, under the keysio.modelcontextprotocol/protocolVersionandio.modelcontextprotocol/clientCapabilities. A mismatch returnsUnsupportedProtocolVersionError. A newserver/discoverremote procedure call (RPC) lets a client ask what a server supports. - Stream resumability removed (SEP-2575).
Last-Event-IDand SSE event ids are gone. "A broken response stream loses the in-flight request; clients MUST re-issue it as a new request with a new request id." That single sentence changes your delivery guarantee from "resumed once" to "retried as new." - Server callbacks reworked into Multi Round-Trip Requests (SEP-2322). Instead of the server calling back into the client, a result can come back with
resultType: "input_required", anInputRequiredResultcarryinginputRequestsand an opaquerequestState. The client answers and retries the original request withinputResponses. Any instance can process the retry, because the state to resume is in the payload, not in a pinned connection. - Routable headers (SEP-2243). Streamable HTTP POSTs now carry
Mcp-MethodandMcp-Name, so a gateway can route without parsing the JSON body. - Auth hardened. MCP servers are now formally OAuth 2.1 resource servers. Clients validate the
iss(issuer) parameter before redeeming an auth code. Dynamic Client Registration is deprecated in favor of Client ID Metadata Documents (CIMD), with backward compatibility retained. - A real deprecation policy (SEP-2596). Features now have a lifecycle with a 12-month deprecation window, and revisions are negotiated. This is the clause that makes "nothing breaks" true.
Does your running server die on July 28? No. The official position is explicit: for existing clients and servers, nothing breaks then either. A server that speaks 2025-11-25 keeps speaking it, because the protocol version is negotiated per request and old revisions stay supported through the deprecation window. What expires is not your uptime. It is the correctness of the design you would copy for the next server.
The named pattern: Protocol-to-Application State Migration
I call this shift Protocol-to-Application State Migration: a capability the wire protocol used to guarantee is removed, its difficulty is unchanged, and it lands in your application harness with no default owner. The protocol never solved these problems. It kept them out of your sight. Statelessness is the protocol handing them back.
There are exactly four unowned responsibilities. Each was the protocol's job on 2025-11-25 and is your job on 2026-07-28. Name them, assign them an owner in your code, and the migration is done. Skip one, and it becomes the incident you debug in three months.
flowchart LR
subgraph PROTO["Protocol owned it through 2025-11-25"]
A1["initialize handshake"]
A2["Mcp-Session-Id sessions"]
A3["SSE Last-Event-ID resumability"]
A4["session-bound auth"]
end
subgraph APP["Your harness owns it from 2026-07-28"]
B1["version and capabilities in _meta per request"]
B2["server-minted handles passed as tool args"]
B3["app-layer idempotency keys"]
B4["per-request OAuth 2.1 validation"]
end
A1 -->|"unowned: silent version mishandling"| B1
A2 -->|"unowned: forged handle, cross-tenant read"| B2
A3 -->|"unowned: double execution"| B3
A4 -->|"unowned: replayed token"| B4
style A1 fill:#E74C3C,color:#FFFFFF
style A2 fill:#E74C3C,color:#FFFFFF
style A3 fill:#E74C3C,color:#FFFFFF
style A4 fill:#E74C3C,color:#FFFFFF
style B1 fill:#6BCF7F,color:#2C2C2A
style B2 fill:#6BCF7F,color:#2C2C2A
style B3 fill:#6BCF7F,color:#2C2C2A
style B4 fill:#6BCF7F,color:#2C2C2A
Four responsibilities the protocol used to own, and where each one lands in your harness now.
1. State continuity becomes Handle-Passing State
The sanctioned replacement for a session is a server-minted handle passed as an ordinary tool argument. Your server creates an opaque token that stands for some server-side state, returns it, and the client sends it back on the next call. Any instance can resolve it, because the handle points at shared storage, not at a pinned connection.
The wrong way is to treat the handle like the old session id and trust it:
# WRONG: the handle is a raw pointer the client can forge.# Any client can pass any workflow_id and read another tenant's state.def resume_workflow(workflow_id: str) -> dict: return WORKFLOW_STORE[workflow_id] # no ownership check, no integrityThe right way is to make the handle tamper-evident and bind it to the caller:
import binascii, hmac, hashlib, json, base64SIGNING_KEY = load_secret("mcp_handle_key").encode() # bytes, from your secret managerDIGEST_LEN = 32 # HMAC-SHA256 is always 32 bytes; split by length, not a delimiterdef mint_handle(tenant_id: str, state_ref: str) -> str: body = json.dumps({"t": tenant_id, "r": state_ref}, separators=(",", ":")) raw = body.encode() sig = hmac.new(SIGNING_KEY, raw, hashlib.sha256).digest() return base64.urlsafe_b64encode(raw + sig).decode()def resolve_handle(handle: str, caller_tenant: str) -> str: try: decoded = base64.urlsafe_b64decode(handle.encode()) except (binascii.Error, ValueError): raise PermissionError("malformed handle") # attacker input -> 403, not a 500 raw, sig = decoded[:-DIGEST_LEN], decoded[-DIGEST_LEN:] expected = hmac.new(SIGNING_KEY, raw, hashlib.sha256).digest() if not hmac.compare_digest(sig, expected): raise PermissionError("forged or tampered handle") claims = json.loads(raw) if claims["t"] != caller_tenant: raise PermissionError("handle does not belong to caller") return claims["r"]This is not ceremony. Security researchers at Akamai have already warned that moving state to client-held tokens opens workflow hijacking and cross-tenant access when those tokens are predictable or unverified. A signed, caller-bound handle closes exactly that gap. Treat every handle as attacker-controlled input, because it is.
One caveat the pairing invites: this handle is signed, not encrypted. The client can base64-decode it and read tenant_id and state_ref in cleartext, so keep secrets out of the payload and reach for authenticated encryption if the contents are sensitive. Give it a short time-to-live and rotate the signing key, too. A handle that never expires is a credential that never dies.
2. Version negotiation moves into _meta
There is no handshake to read the protocol version from once. Every request carries it. The wrong way is to keep the old assumption - that a connected client already negotiated your revision - and never look:
# WRONG: pins one revision and assumes every request speaks it.CURRENT = "2026-07-28"def handle(request: dict) -> dict: return dispatch(request, version=CURRENT) # never reads _meta; older clients mishandledThe right way is to read the version on every request and fail closed on a mismatch:
SUPPORTED = {"2026-07-28", "2025-11-25"}def negotiate(meta: dict) -> str: version = meta.get("io.modelcontextprotocol/protocolVersion") if version not in SUPPORTED: raise UnsupportedProtocolVersionError(version) # spec-defined error return versionImplement server/discover so clients can ask what you support instead of probing blindly. The versioning you used to get for free at connection setup is now a per-request check you write.
3. Delivery becomes At-Least-Once Tool Execution
This is the change teams will miss, because it is subtractive. With resumability removed, a dropped response stream is not resumed. The client re-issues the call as a brand new request. Your tool can therefore run more than once for what the user experienced as one action. That is at-least-once tool execution, and it is the default now whether you designed for it or not.
If a tool has side effects - a payment, an email, a row insert - "runs twice" is a correctness bug. The retry that causes it usually arrives while the first call is still in flight, so a naive check-then-act - read the key, see nothing, run - races and charges twice. You have to reserve the key atomically before you execute:
def run_once(store, key: str, op): # Atomic reserve: only the first caller wins the insert. if not store.reserve(key): # e.g. INSERT ... ON CONFLICT DO NOTHING return store.await_result(key) # this call is a retry: replay the result result = op() store.complete(key, result) # turn the reservation into the recorded result return resultdef charge_card(args: dict) -> dict: key = args.get("idempotency_key") if key is None: raise ValueError("side-effecting tool requires an idempotency_key") # fail closed # Pass the same key down so the gateway (system of record) dedupes too. return run_once(IDEM_STORE, key, lambda: gateway.charge(args, idempotency_key=key))Two things make this correct, and both are on you, not the protocol. First, the reserve must be atomic - a unique-constraint insert or a compare-and-set - or the in-flight retry slips through and you are back to a double charge. Second, the client must send the same idempotency_key on the retried request, which now carries a new request id, because the spec re-issues the call rather than resuming it. That sameness is a client and SDK contract you have to specify and validate; the server cannot guarantee it alone. So reject any side-effecting call that arrives without a key.
The spec does not mandate idempotency keys. This is the engineering consequence of removing resumability, and owning it correctly means atomic reservation plus a client contract, not a lambda wrapped around your gateway call. A stateful protocol let you pretend delivery was exactly-once. A stateless one makes the retry visible, which is honest and more work than it looks.
4. Authorization becomes per-request
With no session, there is no authenticated connection to ride on. Every request is authenticated on its own at the transport boundary. MCP servers are OAuth 2.1 resource servers now: validate the token, validate the audience so a token minted for another server cannot be replayed at yours, and validate iss on the client side of the flow. Authorization stops being a thing you check once at initialize and becomes a thing you check every time.
How to migrate your MCP server to the stateless spec
You have a deprecation window, not a deadline. Migrate deliberately.
- Pin and negotiate. Read the protocol version from
_metaon every request, keep a smallSUPPORTEDset, and implementserver/discover. Fail closed on unknown versions. - Replace the session store with signed handles. Anywhere you relied on
Mcp-Session-Idfor cross-call state, mint a signed, caller-bound handle and pass it as a tool argument. Never trust a raw identifier from the client. Keep secrets out of the payload and give the handle a short time-to-live. - Add idempotency to every side-effecting tool. Reserve a client-supplied idempotency key atomically before executing, replay the recorded result on retry, and reject side-effecting calls that arrive without a key. Assume at-least-once.
- Move auth to per-request validation. Validate token, audience (RFC 8707 resource indicators), and issuer. Plan the Dynamic Client Registration to CIMD move; you can keep DCR during the compatibility window.
- Let the gateway route on headers. Use
Mcp-MethodandMcp-Nameso load balancers stop parsing bodies, drop the sticky-session rules you no longer need, and treat routing as a control-plane concern. - Do not rewrite in a panic. Versioned negotiation plus a 12-month window means your
2025-11-25server is fine while you do this. The risk is silent architectural drift, not a hard cutover.
What to watch
- The RC finalizing on 2026-07-28. Confirm the changelog did not shift between RC and final before you cite specific error names in your own docs.
- The beta SDKs. Python
mcp[cli]==2.0.0b1, TypeScript v2 beta, Go1.7.0-pre.1, C#2.0.0-preview.1. The v1 lines keep getting security fixes for at least six months, so you are not forced onto a beta to stay supported. - Whether the warned attack surfaces become real incidents. The client-held-state and
_metainjection risks are, so far, researcher warnings, not published exploits. Handle forgery is the new session fixation. If you sign and bind handles now, you are ahead of it.
The protocol got simpler. Your harness did not. That is the whole story of a stateless MCP: the hard part is still here. It just changed owners, and the new owner is you.
Step back one level and Protocol-to-Application State Migration stops being about MCP at all. Every protocol simplification is a responsibility transfer. When a layer below you drops a guarantee - a session, a delivery promise, an ordering constraint - the work does not leave the system. It moves up to the nearest layer that still cares, and that layer is usually yours. Kafka did it when it made you own consumer offsets instead of the broker tracking them. Microservices did it when they dropped the single database transaction a monolith gave you for free and handed you sagas and compensating actions. MCP is doing it now. The lesson is durable even after this specific spec is old news: when something below you announces it got simpler, read it as a bill, and check whose name is on it. On 2026-07-28, the name is yours.
References
- Model Context Protocol (2026). The 2026-07-28 MCP Specification Release Candidate. Official MCP Blog. https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/
- Model Context Protocol (2026). Key Changes (Draft Changelog). modelcontextprotocol.io. https://modelcontextprotocol.io/specification/draft/changelog
- Model Context Protocol (2026, Jun 29). Beta SDKs for the 2026-07-28 MCP Spec Release Candidate Are Here. https://blog.modelcontextprotocol.io/posts/sdk-betas-2026-07-28/
- SEP-2567 - Remove protocol-level sessions and the
Mcp-Session-Idheader. https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567 - SEP-2575 - Remove the
initializehandshake, make MCP stateless, addserver/discover. https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 - SEP-2322 - Multi Round-Trip Requests. https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
- SEP-2243 -
Mcp-MethodandMcp-Nameroutable headers. https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243 - SEP-2596 - Feature lifecycle and deprecation policy. https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2596
- WorkOS (2026, Jun 18). The biggest MCP spec update ships July 28: What changes for AI agent authentication. https://workos.com/blog/mcp-2026-spec-agent-authentication
- SecurityWeek (2026, Jun 26). New Enterprise-Ready MCP Specification Brings New Security Challenges. https://www.securityweek.com/new-enterprise-ready-mcp-specification-brings-new-security-challenges/
- MCP Transports (2025-03-26). Streamable HTTP transport introduction. https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
Related Articles
- Agent Skills Are Not Prompts. They Are Production Knowledge Infrastructure.
- Claude Code Guide: Build Agentic Workflows with Commands, MCP, and Subagents
- Which Claude Code Layer Solves Your Problem? A Diagnostic Guide for AI Engineers