# MCP Went Stateless: What the 2026-07-28 Spec Actually Changes

> Source: <https://dev.to/krlz/mcp-went-stateless-what-the-2026-07-28-spec-actually-changes-273k>
> Published: 2026-08-09 14:01:44+00:00

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.

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](https://www.rfc-editor.org/rfc/rfc9207) 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.

"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](https://ics.uci.edu/~fielding/pubs/dissertation/top.htm), Ch. 5. MCP just paid that tuition in public.

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]"
python
# 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:

``` python
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.

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](https://modelcontextprotocol.io/specification/2026-07-28) (and every earlier revision) and to fix long-standing architectural issues. Coming from v1? See [What's new in v2](https://py.sdk.modelcontextprotocol.io/whats-new/) for the tour of what changed and the [migration guide](https://py.sdk.modelcontextprotocol.io/migration/) for every breaking change.

**Not ready to migrate?** v1.x lives on the [ v1.x branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x), continues to receive critical bug fixes and security patches, and is documented at

`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](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) or find us in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX).

**The documentation lives at https://py.sdk.modelcontextprotocol.io/.**

It has a [Get](https://py.sdk.modelcontextprotocol.io/get-started/)…

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:

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 MRTR`ttlMs`

/ `cacheScope`

to list responses`iss`

(RFC 9207) if you're a client; plan the DCR → CIMD move if you're a server`structured_content`

, `next_cursor`

, `input_schema`

)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.*
