And why stateless MCP is a big deal.
By Dickey Singh, CEO and Founder, Cast.app
The fifth MCP specification shipped on July 28, 2026. The headline change is that MCP is no longer stateful.
That sounds like plumbing. It isn't. Until yesterday, every MCP connection opened with a handshake, and the server handed back a session ID the client had to carry on every request after that. The ID pinned the client to one server instance. If you wanted to run more than one instance, you needed sticky routing, or a shared session store, or both. Serverless was awkward. Edge was worse.
That is gone. Each request now carries what it needs, so any request can hit any instance behind a plain round-robin load balancer. The same release also gave extensions a real governance model and locked down trace propagation, so a single call can be followed across a vendor boundary.
The rest of this piece is a ladder. Each rung is a shape a call can take on a wire — starting with a plain function invocation, ending with the request format that shipped yesterday. Every rung fixes one problem from the rung beneath it, and creates one new problem for the rung above it. Read in order, MCP looks less like an invention and more like the next obvious step.
Part IV is the audit: what the release fixed, what it left alone, and where it went backwards.
* Extensions appear twice on purpose. Release 4 made them a convention — naming, discovery, negotiation. Release 5 made them governed — reverse-DNS IDs, their own repositories, independent versioning, a formal track from experimental to official.
* Elicitation follows the same pattern. Release 3 introduced the primitive — a server asking the user for structured input mid-call. Release 5 changed how it travels: the server returns instead of holding a stream open.
* The core shrinks while the periphery formalizes. Batching arrived in release 2 and was gone by release 3. Tasks arrived in core in release 4 and moved out in release 5.
Three rungs with nothing to do with AI. Every idea in MCP starts here.
The plainest thing a program can do: call a function with a set number of arguments in a set order.
add(3, 4)
add(3, 4, 5) ← overload
add3Num(3, 4, 5) ← different function
Problem: arity and order are baked in. Adding a parameter means an overload or a new name. Neither scales.
The usual escape is to stop passing arguments by position and pass one object that names them.
add({a: 3, b: 4, c: 5})
Fixed: variable length, and names instead of positions. Arguments can nest, so a single argument can carry a whole structure.
Problem: every new operation still needs a new function. The rung-1 problem is back, one level up.
The way out is one entry point that covers many shapes. Make the operation itself an argument:
operation({op: "add", a: 3, b: 4})
operation({op: "scale", vector: [1, 2, 3], factor: 2})
operation({op: "merge", left: {...}, right: {...}, strategy: "deep"})
operation({op: "query", filter: {status: "open", tags: ["a", "b"]},
page: {size: 50, cursor: "c_82f"}})
Problem: operation is still a symbol in your language. It cannot cross a wire.
JSON-RPC 2.0
To send that call to another process, the whole call has to become text on a wire. Solved problem, and has been since roughly 2010.
{"jsonrpc": "2.0", "id": 1, "method": "add", "params": {"a": 3, "b": 4}}
Fixed: the call is now a value. Serializable, routable, loggable, replayable.
Problem: the caller has to already know that add exists and what it takes.
2024-11-05 through 2025-11-25
MCP is built on rung 4. It adds three things: a fixed dispatch method, runtime discovery, and a session. The first two were right.
Here is the move that defines MCP. The JSON-RPC method becomes a constant. The operation you actually want drops down into the parameters.
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "add", "arguments": {"a": 3, "b": 4}}}
Every operation from rung 3 crosses the wire unchanged in shape. Only the envelope is new.
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "add",
"arguments": {"a": 3, "b": 4}}}
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "scale",
"arguments": {"vector": [1, 2, 3], "factor": 2}}}
{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "merge",
"arguments": {"left": {"a": 1}, "right": {"a": 2, "b": 3},
"strategy": "deep"}}}
{"jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": {"name": "query",
"arguments": {"filter": {"status": "open", "tags": ["a", "b"]},
"page": {"size": 50, "cursor": "c_82f"}}}}
The mapping is one to one:
Rung 3 was this pattern written by hand, inside one process. Rung 5 is the same pattern standardized, over a wire, for a caller who was not there at build time.
Problem: how does the caller learn that add, scale, merge, and query exist — and what each one takes?
MCP answers that with a menu. The client asks what the server can do, and the server replies with a machine-readable description of every tool.
What can you do?
// →
{"jsonrpc": "2.0", "id": 5, "method": "tools/list"}
Here is what I can do:
// ←
{"jsonrpc": "2.0", "id": 5, "result": {
"tools": [
{
"name": "add",
"title": "Add two numbers",
"description": "Return the sum of a and b.",
"inputSchema": {
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "number"}
},
"required": ["a", "b"]
}
},
{
"name": "scale",
"title": "Scale a vector",
"description": "Multiply every element of a vector by a scalar factor.",
"inputSchema": {
"type": "object",
"properties": {
"vector": {"type": "array", "items": {"type": "number"}},
"factor": {"type": "number"}
},
"required": ["vector", "factor"]
}
},
{
"name": "merge",
"title": "Merge two objects",
"description": "Combine two objects. Use strategy 'deep' to merge nested keys, 'shallow' to overwrite top-level keys only.",
"inputSchema": {
"type": "object",
"properties": {
"left": {"type": "object"},
"right": {"type": "object"},
"strategy": {"type": "string", "enum": ["deep", "shallow"], "default": "deep"}
},
"required": ["left", "right"]
}
},
{
"name": "query",
"title": "Query records",
"description": "Search records with an optional filter. Returns a page of results and a cursor for the next page.",
"inputSchema": {
"type": "object",
"properties": {
"filter": {
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["open", "closed", "pending"]},
"tags": {"type": "array", "items": {"type": "string"}}
}
},
"page": {
"type": "object",
"properties": {
"size": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
"cursor": {"type": "string"}
}
}
}
}
}
]
}}
The signature has become a runtime value. That is the whole point of the rung.
description is load-bearing. It is written for the model, not for the docs site. merge has to explain what strategy means, because nothing else will.enum, default, minimum, and maximum shrink the space of calls a model can get wrong.query has no required array at all. Every argument is optional and intent has to be inferred. It is the tool most likely to be called badly, which is why schema expressiveness matters.For this same pattern on a real customer problem — a tool that answers "who should this customer talk to when the agent cannot help?" — see Your First CX MCP Server.
The session is the one that did not hold up.
Stateful means the server remembers you between requests. The client opened with a handshake, the server allocated a record for that conversation, and it handed back an ID:
POST /mcp HTTP/1.1
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-11-25","capabilities":{},
"clientInfo":{"name":"my-app","version":"1.0"}}}
Every later request had to carry that ID:
POST /mcp HTTP/1.1
Mcp-Session-Id: 1868a90c-3a3f-4f5b
{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"search","arguments":{"q":"otters"}}}
Sessions are not unusual. The web runs on them. The difference is where the state lives.
A web session ID is a lookup key. The state sits in Redis or a database, so any server can handle any request. Or the state travels inside the token itself, as with a JWT, and no lookup is needed at all. Either way the instance is interchangeable — that is the whole point.
MCP's session was neither. MCP itself started life as a subprocess over stdio, where the session and the process are the same thing, and the HTTP transport inherited that shape. The record stayed in one instance's memory. No other instance could pick up that client, and dropping the connection lost the record.
There is a second problem. Most of what the session held was not user state at all. It was protocol facts settled during the handshake: version, client info, capabilities. Cheap to restate on every request. Expensive to remember for the life of a connection.
Every one of these is an operations problem, not a semantics problem. The protocol worked. It did not deploy.
2026-07-28The largest revision since launch
The fix: make every request carry what it needs — the same move the web made when it went from server-held sessions to signed tokens.
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: add
Content-Type: application/json
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "add", "arguments": {"a": 3, "b": 4},
"_meta": {"io.modelcontextprotocol/clientInfo": {"name": "my-app", "version": "1.0"}}}}
initialize and initialized are gone. Protocol version, client info, and capabilities ride in _meta on every request.Mcp-Session-Id is gone. Any request lands on any instance, behind an ordinary round-robin load balancer.server/discover fetches server capabilities when a client wants them, rather than at connect time.Handles replace session state. If the protocol will not remember anything for you, state has to become visible. A tool mints an explicit identifier and the model threads it through later calls:
create_basket()
→ {basket_id: "b_7f2"}
add_item(basket_id: "b_7f2", sku: "X")
The model can see this state, compose it across tools, and hand it between steps. Session state could do none of that.
Multi round-trip replaces the held-open stream. Elicitation — a server asking the user for structured input mid-call — arrived back in 2025-06-18. What is new is how it travels. The server used to push the question over a held-open SSE stream; now it returns instead of blocking:
{"resultType": "input_required",
"inputRequests": {
"confirm": {"type": "elicitation",
"message": "Delete 3 files?",
"schema": {"type": "boolean"}}
},
"requestState": "eyJzdGVwIjox..."}
j
The client gathers answers and re-issues the original call with inputResponses and the echoed requestState. Any instance can pick it up, because everything needed is in the payload. This is the protocol-level primitive for the escalation pattern we have called Agent-to-Human transfer — the moment an agent decides a human should take over.
Caching and tracing become explicit. List and resource-read results carry ttlMs and cacheScope, modeled on HTTP Cache-Control. W3C Trace Context key names are locked in _meta, so one trace spans host, client SDK, server, and downstream. This is the debuggability half of the release and it is underrated — nothing in the first four versions gave you observability across a vendor boundary.
Extensions become first-class. Reverse-DNS identifiers, their own repositories with delegated maintainers, independent versioning, and a formal track from experimental to official. MCP Apps — servers shipping interactive HTML that hosts render in a sandboxed iframe — and Tasks both ship as extensions rather than core.
Authorization gets serious. Six proposals bring the auth spec closer to how OAuth 2.0 and OpenID Connect are actually deployed. This is a large share of the release and it rarely gets covered, because it is unglamorous. It is also the part enterprise buyers will ask about first.
Three things are deprecated. Roots, Sampling, and Logging are annotated as deprecated. They still work. Under the new lifecycle policy, anything deprecated stays in the spec for at least twelve months before it can be removed.
One thing that did not change: stdio is still a supported transport. Local servers on a laptop work the way they always did. Everything above is about what happens when a server has to serve more than one person.
The name climbed down — from a symbol, to a field inside an argument object, to method, to params.name — so a runtime caller could reach it.
Then in the last step it climbed back up into a header, so infrastructure could route it without reading it.
initialize handling. Read protocol version, client info, and capabilities from _meta on each request.Mcp-Session-Id. Any cross-call state becomes an explicit handle returned to the model.Mcp-Method and Mcp-Name. Reject requests where the headers and body disagree.ttlMs and cacheScope on list and resource-read results, or clients will refetch more than they need to.2025-11-25 keep working.We have argued that for select agent use cases, direct APIs, direct database access, and context and content injection beat large MCP tool surfaces — on latency, tokens, context discipline, and control. This release answers a good deal of that. It does not answer the central part.
Caching does not reduce tokens. ttlMs lets a client hold a tools/list response instead of refetching it. The definitions still get injected into the prompt on every turn. A network round trip was never the expensive part.
Richer schemas are larger schemas. JSON Schema 2020-12 buys real precision — query from rung 6 can finally express its optional branches. It also makes every definition heavier. If the objection is context weight, that is a step backwards.
The complaint was never that MCP is operationally awkward. This release fixed the operational awkwardness — sessions, sticky routing, held-open streams, missing traces.
Discovery by injection has a token floor that no transport change can lower.
That is the durable form of the claim. It survives this revision and the next one, because it is a property of the pattern rather than of the wire format.
Which is why these remain different jobs. Direct access wins on zero definition tokens, transformation and ranking before anything reaches the model, one fewer network hop, and no negotiation surface to secure. MCP wins oninteroperability across systems you do not control, a shared trace boundary across a vendor line, a standard handoff primitive, and bootstrapping speed.
MCP earns its keep when bringing tools to an agent you do not control, less so when building a tightly controlled agent for a specific task. For legacy systems that will never speak MCP natively, the MCP Proxy Bridge pattern is still how you get there without rebuilding when the vendor changes.
2025-11-25 and 2026-07-28.Primary
From Cast
Dickey Singh is the founder of Cast (cast.app), where the team is building agentic CS infrastructure for enterprise customers including HPE, Pure Storage, Cloudera, and CDK Global. Cast itself runs on agentic engineering — the team uses Cursor, Claude Code, Codex, and GitHub bots daily. Cast breaks the headcount cycle: more accounts no longer means more CSMs.