MCP explained, and what changed when it went stateless
Companion code: 05-mcp-server
TL;DR
- MCP (Model Context Protocol) is a standard way for an AI application to connect to tools and data. You write a server once, and any MCP-capable app can use it.
- Its July 2026 revision (2026-07-28) removed the connection handshake and sessions. Every request now carries everything the server needs, so a remote MCP server runs like an ordinary web service behind an ordinary load balancer.
- The design lesson goes beyond MCP: state that spans requests belongs behind explicit handles that the caller passes back, not in the connection.
The problem MCP solves
Post #4 was about designing good tools. MCP is about delivering them. Before it, every AI application had its own way of defining tools. An integration for one assistant had to be rewritten for the next, and each tool provider maintained one adapter per app. With M applications and N tools, that’s M×N integrations.
Anthropic introduced MCP in November 2024 as an open protocol that turns this into M+N. Tool providers write an MCP server; applications include an MCP client; the two talk a common language. In December 2025 the protocol moved to the Linux Foundation’s new Agentic AI Foundation, so no single vendor controls it.
The vocabulary:
- Host: the application the user runs, such as a chat app, an IDE or your own agent.
- Client: the part of the host that talks to one server.
- Server: a program that exposes capabilities, usually by wrapping a real system: a code host, a database, a file system, a SaaS API.
Servers offer three kinds of things:
| Primitive | Who decides to use it | Example |
|---|---|---|
| Tools | the model | create_issue, run_query |
| Resources | the application | a file, a database schema, a document |
| Prompts | the user | a “summarize this PR” template |
MCP is about connecting an agent to its tools and data. Connecting agents to each other is a different problem, covered by a separate protocol, A2A, which is also hosted by the Linux Foundation.
What goes over the wire
MCP messages are JSON-RPC 2.0: a request has a method, params and an id, and a response carries a result or an error. There are two standard transports. stdio is for local servers, which the host starts as a subprocess and talks to over standard input and output. Streamable HTTP is for remote servers, where every message is an HTTP POST to a single endpoint.
The core of a tool integration is three methods:
server/discover: what versions and capabilities do you support?tools/list: which tools do you offer? (names, descriptions, JSON Schemas)tools/call: run this tool with these arguments.
A host typically lists the tools, hands their descriptions to the model, and turns each tool call the model makes into a tools/call request. The companion code’s agent.py does exactly that, and the translation is small because MCP’s tool format and the Claude API’s tool format are nearly identical:
tools = [{"name": t["name"], "description": t["description"], "input_schema": t["inputSchema"]}
for t in mcp.request("tools/list")["tools"]]
Why sessions were a problem
Until the 2025-11-25 revision, an MCP conversation began with an initialize handshake. Client and server exchanged versions and capabilities once, and the server gave the client a session ID (the Mcp-Session-Id HTTP header). Everything after that was interpreted within the session. Servers could also push their own requests to the client over a long-lived stream, for example asking the client to show the user a question.
That works well when one server process serves one client, which is the local stdio case MCP started with. It fits badly with how remote services are actually run:
- Sticky routing. If session state lives in instance 2, every request in that session must reach instance 2. Load balancers need session affinity, and losing an instance loses its sessions.
- Shared session stores. The alternative is to put session state in Redis or similar, which is extra infrastructure just to remember a handshake.
- Long-lived streams. Holding a stream open per client so the server can push requests ties up connections and complicates proxies.
- Opaque traffic. Gateways and firewalls had to parse JSON bodies to know what a request was even doing.
Cloudflare, which runs many remote MCP servers, put it this way in its write-up on the change (The next generation of MCP, August 2026): the old design meant managing sticky sessions, open streams and message replay, which was “more overhead and complexity than a traditional web server”. By then it had already been running its own large MCP server in an unofficial stateless mode for months.
What 2026-07-28 changed
The release candidate was locked on May 21, 2026, and the final specification shipped on July 28 after a ten-week validation window. The headline change is a stateless core: all the information needed to process a request is contained in the request itself.
No handshake, no sessions. Each request carries its protocol version, client identity and client capabilities in a _meta field:
{
"jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": {
"name": "add_item",
"arguments": {"list_id": "lst_3f9a1c", "item": "headlamp"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "agentic-systems-demo", "version": "0.1.0"},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
If the server doesn’t speak that version, it says so and lists the versions it does support (error -32022), and the client retries. server/discover lets a client ask up front, but it’s optional.
Headers mirror the body. Over HTTP, the request also carries MCP-Protocol-Version, Mcp-Method and, for tool calls, Mcp-Name headers. Load balancers, rate limiters and firewalls can now route or block “calls to run_query” without reading JSON. To stop the two from disagreeing, which is a security hole if the proxy trusts one and the server the other, servers must reject any mismatch (error -32020).
Caching hints. List results now carry ttlMs and cacheScope (public or private), modelled on HTTP’s Cache-Control. A tool list that’s the same for everyone can be cached by clients and gateways, so it isn’t re-fetched on every request.
No server-initiated requests. When a server needs something from the client mid-call, such as user input, it no longer sends its own request down a stream. It returns an input_required result listing what it needs, plus an opaque requestState; the client gathers the answers and retries the call with them. The specification calls this a multi-round-trip request, and it keeps everything request/response. Clients that want change notifications open an explicit subscriptions/listen request whose response is a stream.
Extensions and a deprecation policy. Optional features now live in named extensions: MCP Apps (server-rendered interfaces) and Tasks (long-running work you start now and check on later). Older features such as roots, sampling and protocol-level logging are deprecated, with a formal twelve-month window before removal. Authorization also moved closer to standard OAuth and OpenID Connect practice.
Where the state goes
If the protocol has no sessions, what happens to tools that really are stateful: a shopping cart, a browser tab, a database transaction? The specification’s guidance is simple: return a handle and take it back as an argument.
The companion server does this with shopping lists:
if name == "create_list":
list_id = "lst_" + secrets.token_hex(3)
lists[list_id] = {"title": args["title"], "items": []}
_save(lists)
return _text(f"Created list {list_id} ({args['title']!r})", {"list_id": list_id})
The model receives lst_3f9a1c and passes it to add_item and get_list. The list itself lives in a store that every instance shares; in the demo it’s a JSON file, in production a database. That’s what lets the client spread one task across instances:
2. A multi-step task, with each step on whichever instance is next
tools/call -> 127.0.0.1:8001
tools/call -> 127.0.0.1:8002
tools/call -> 127.0.0.1:8001
tools/call -> 127.0.0.1:8002
tools/call -> 127.0.0.1:8001
Weekend trip: tent, headlamp, trail mix
That output is from an actual run of client.py against two server processes. The list was created on one instance, filled in through both, and read back from the first, with no session anywhere.
Three details make handles work well, all of them from the specification’s advice:
- Say how long they live. “Lists expire after 24 hours” belongs in the tool description, where the model can see it.
- Fail helpfully. An unknown or expired handle should come back as a tool error the model can act on (“create a new list”), not a protocol error. That’s rule 4 from post #4 again.
- Check authorization on every call. A handle is a name, not a permission. The server must verify that this caller may use this list.
Try it
cd examples/05-mcp-server
python server.py --port 8001 # terminal 1
python server.py --port 8002 # terminal 2
python client.py # terminal 3: the protocol, step by step
python agent.py "Make a packing list for a weekend hiking trip with 5 essentials"
client.py needs no API key. It shows discovery, cached tool lists, a multi-step task spread across both instances, a tool error, and version negotiation. The README has curl commands for breaking the protocol on purpose: mismatched headers, missing metadata, and the GET endpoint that no longer exists.
The server is a teaching implementation of the core request path, about 150 lines of standard library. For real servers, use an official MCP SDK.
Trade-offs
- It’s a breaking change. Clients and servers written for 2025-11-25 don’t interoperate with 2026-07-28-only peers. The specification defines how “dual-era” implementations detect which kind of peer they’re talking to. Expect a transition period where SDKs speak both.
- State doesn’t disappear; it moves. The protocol no longer holds it, so your application does, in a database, behind handles, with expiry and authorization. That’s more explicit work for stateful tools, and much less work for everything else.
- Every request is a little bigger. Repeating version and capabilities on each call costs a few hundred bytes. In exchange, you can remove sticky routing, session stores and stream bookkeeping.
Next in the series: Tool calls or code? When agents should write programs.
Sources & further reading
- Model Context Protocol, specification revision 2026-07-28: see in particular Versioning and Compatibility, Streamable HTTP, Tools and Caching.
- MCP blog, 2026-07-28 release candidate (May 2026): the full list of changes.
- Cloudflare, The next generation of MCP (Aug 2026): running stateless MCP at scale.
- Agentic AI Foundation, The age of stateless MCP (Aug 2026).
- Anthropic, Introducing the Model Context Protocol (Nov 2024).