For eighteen months, running a remote MCP server meant fighting your own infrastructure. You had a bidirectional, stateful protocol sitting on top of HTTP, which meant sticky sessions, a shared Redis for session state, or a gateway doing packet inspection to route requests to the one box that held the connection. Every horizontal scaling story started with an apology.
On July 28, 2026, that ended. The 2026-07-28 specification makes MCP stateless at the protocol layer — the largest revision since launch, and the first one that makes MCP behave like the rest of the web.
What actually changed
Six SEPs (Specification Enhancement Proposals) work together here. The short version:
The handshake is gone. initialize/initialized and the Mcp-Session-Id header have been removed (SEP-2575, SEP-2567). Every request is now self-describing: protocol version, client identity, and client capabilities ride inline in _meta on each call. There's an optional server/discover RPC if a client wants capabilities up front, but nothing requires it.
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"search","arguments":{"q":"otters"},
"_meta":{"io.modelcontextprotocol/clientInfo":{"name":"my-app","version":"1.0"}}}}
Any request can land on any instance behind a plain round-robin load balancer. No shared storage, no ARR affinity, no sticky routing.
Routing moved into headers. Mcp-Method and Mcp-Name are now required on Streamable HTTP requests (SEP-2243). Your gateway, WAF, or rate limiter can route and meter without parsing JSON bodies. If you've ever written a Lua script to peek inside an MCP payload at the edge, you can delete it.
List results are cacheable. tools/list, prompts/list, resources/list, and resources/read now carry ttlMs and cacheScope (SEP-2549), with deterministic ordering. That last part matters more than it looks: a stable tool catalog keeps upstream prompt caches stable across reconnects, which is a real token-cost line item.
Server→client calls became round trips. This is the clever bit. Elicitation and sampling used to require a held-open stream, which is exactly what a stateless protocol can't offer. Multi Round-Trip Requests (SEP-2322) invert it: the server returns resultType: "input_required" plus an opaque request-state token, the client gathers the answers, then calls the same tool again with inputResponses attached. Every leg is an ordinary client-to-server request.
Auth got hardened. Authorization servers should return iss per RFC 9207 and clients must validate it before redeeming a code (SEP-2468) — that closes an AS mix-up hole. Client credentials are now bound to the issuer that minted them (SEP-2352), and Dynamic Client Registration is formally deprecated in favour of Client ID Metadata Documents.
Deprecations. Roots, Sampling, and Logging are deprecated (SEP-2577), as is the legacy HTTP+SSE transport (SEP-2596). Both get a twelve-month minimum offramp under the new deprecation policy — which is itself the quiet good news here. This is a protocol that now plans its breakage.
The part people will get wrong
"Stateless protocol" does not mean "stateless application."
If your server needs to carry state across calls, you mint an explicit handle from a tool and let the model pass it back as an ordinary argument. The maintainers are direct that this works better than state hidden in the transport, and I think they're right for a reason that isn't primarily architectural: the model can see the handle. It becomes a thing the agent reasons about and threads between tools, instead of an invisible coupling that breaks the moment a load balancer does its job.
This is the same lesson REST landed on twenty-five years ago. Roy Fielding's dissertation argued the statelessness constraint buys you visibility, reliability, and scalability at the cost of repeated per-request data — Architectural Styles and the Design of Network-based Software Architectures, Ch. 5. MCP just paid that tuition in public.
A stateless server in Python
The v2 line of the Python SDK renamed the in-SDK FastMCP to MCPServer and moved transport options off the constructor onto run(). If you're still importing mcp.server.fastmcp, you're on v1.x and speaking the old handshake.
uv init mcp-units && cd mcp-units
uv add "mcp[cli]"
# server.py
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("units")
@mcp.tool()
def to_celsius(fahrenheit: float) -> float:
"""Convert Fahrenheit to Celsius."""
return round((fahrenheit - 32) * 5 / 9, 2)
if __name__ == "__main__":
# stateless_http + json_response = plain HTTP request/response
mcp.run(transport="streamable-http", stateless_http=True, json_response=True)
That's it. No session manager, no event store, no affinity. Run it and point the inspector at http://localhost:8000/mcp:
uv run server.py
npx -y @modelcontextprotocol/inspector
Now the interesting version — state without a session:
import uuid
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("reports")
# In production this is Redis/Postgres, not a dict — but note that it's
# *your* store, keyed by a handle the model holds, not transport state.
JOBS: dict[str, dict] = {}
@mcp.tool()
def start_export(dataset: str) -> str:
"""Begin an export. Returns a job handle to pass to check_export."""
job_id = f"job_{uuid.uuid4().hex[:8]}"
JOBS[job_id] = {"dataset": dataset, "status": "running"}
return job_id
@mcp.tool()
def check_export(job_id: str) -> dict[str, str]:
"""Check an export by its handle."""
return JOBS.get(job_id, {"status": "unknown"})
Two calls, potentially two different machines, zero coordination. The handle is in the model's context, not in the transport.
One gotcha worth internalising before you migrate: because MRTR removed the back-channel, ctx.elicit() and ctx.session.create_message() raise NoBackChannelError on a modern connection. If your server asks the user mid-call, that code needs rewriting around the input-required round trip — it's the single most likely thing to break.
modelcontextprotocol
/
python-sdk
The official Python SDK for Model Context Protocol servers and clients
MCP Python SDK
Note
This is v2 of the MCP Python SDK, the current stable release line. It is a major rework of the SDK, both to support the 2026-07-28 MCP specification (and every earlier revision) and to fix long-standing architectural issues. Coming from v1? See What's new in v2 for the tour of what changed and the migration guide for every breaking change.
Not ready to migrate? v1.x lives on the v1.x branch, continues to receive critical bug fixes and security patches, and is documented at https://py.sdk.modelcontextprotocol.io/v1/. Since pip install mcp now installs 2.x, keep a <2 upper bound on your requirement (for example mcp>=1.28,<2) until you've migrated.
Something rough, confusing, or broken? Open an issue or find us in #python-sdk-dev on the MCP Contributors Discord.
Documentation
The documentation lives at https://py.sdk.modelcontextprotocol.io/.
It has a Get…
Why this matters beyond the ops win
The scaling story is the headline, but I think the deeper shift is that MCP stopped being a transport-flavoured protocol and became an HTTP-flavoured one. That means the boring, battle-tested layer of the web now applies to agent tooling: CDNs, edge workers, standard load balancers, cache-control semantics, header-based authorization at the gateway.
It also narrows some real attack surface. The research on MCP security has been fairly damning, and a lot of it clusters around trust boundaries between independently operated components:
- Hou, Zhao, Wang & Wang (2025) — Model Context Protocol (MCP): Landscape, Security Threats, and Future Research Directions — the canonical starting point. Builds a threat taxonomy across the server lifecycle and four attacker classes. The tool-description manipulation section (embedding "prefer this tool" directives to hijack tool selection) is worth the read on its own.
- Radosevich & Halloran (2025) — MCP Safety Audit — demonstrates concrete exploits against real MCP deployments.
- Yang, Wu & Chen (2025) — MCPSecBench — a systematic security benchmark and playground, if you want something to actually run against your server.
- Wang et al. (2025) — MCP-Bench — benchmarks tool-using agents on complex real-world tasks via MCP servers. Useful for the "is my tool catalog actually usable by a model" question, which is separate from whether it's correct.
Read those with a date-stamp in mind: they all predate the stateless core, so the session-layer threats they describe are partly answered by a protocol that no longer has a session layer. The prompt-level and supply-chain threats are entirely untouched. Stateless MCP is a scalability fix, not a security fix — treat any vendor claiming otherwise with suspicion.
Migration checklist
2026-07-28 (v2 for Python; pin exactly)mcp.server.fastmcp.FastMCP → mcp.server.mcpserver.MCPServer
stateless_http / json_response from the constructor to run() / streamable_http_app()
ctx.elicit() or create_message() around MRTRttlMs / cacheScope to list responsesiss (RFC 9207) if you're a client; plan the DCR → CIMD move if you're a serverstructured_content, next_cursor, input_schema)
Where to go next
- The 2026-07-28 specification and its full changelog
- The maintainers' release post, which is unusually readable for a spec announcement
- Simon Willison's take, if you want the "I'd lost interest in MCP and this brought it back" perspective
If you've already migrated a production server, I'd like to hear what broke. My guess is it was the elicitation rewrite and not the sessions.
Building AI integrations at Codiva. Comments and corrections welcome.
Top comments (0)