How a Function Call Became MCP

The five MCP specs.

What got fixed, what didn't, and what got heavier.

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.

How MCP progressed:
local → remote → safe → durable → deployable

The five releases, and what moved the needle

# Version Needle movers What it unlocked
1 2024-11-05 Primitives and discovery One contract instead of N integrations — local only
2 2025-03-26 Streamable HTTP and OAuth Servers could leave the laptop
3 2025-06-18 Structured output and elicitation Results a program can parse, and a human in the loop
4 2025-11-25 Tasks and extension conventions Work longer than a request, and growth without core bloat
5 2026-07-28 Stateless core, stateless elicitation, debuggability, and governed extensions Serverless and edge deployment, mid-call questions any instance can resume, and one trace across the vendor line

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

Part I — Before MCP

Three rungs with nothing to do with AI. Every idea in MCP starts here.

1 — Fixed arity

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.

2 — Bag or object of arguments

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.

3 — The name moves inside

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.

4 — The call becomes data

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.

Part II — MCP with a session

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.

5 — Fixed dispatch, name demoted into params

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}}}

  • The protocol's method set is now closed and finite. The tool set is open.
  • Why: the caller is a model that did not exist when the server was written.

The same four operations, carried up the ladder

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 Rung 5
operation method: "tools/call"
op params.name
everything else params.arguments

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?

6 — Discovery returns callable descriptions

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.

  • One response, and the caller knows the surface: what exists, what each tool takes, what is required, what is optional, what values are legal.
  • 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.

7 — The session

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.

What broke

Symptom Root cause
Sticky routing required The session ID pins a client to whichever instance issued it
Shared session store required Any other instance has no idea who the caller is
Gateways must parse bodies The operation name is buried in params.name — nothing routable is visible
No cache guidance A client cannot tell how long a tools/list response stays fresh
Long-lived SSE streams The only way to learn a list changed, or to deliver a mid-call prompt
Tasks needed redesign The experimental Tasks API did not survive production use

Every one of these is an operations problem, not a semantics problem. The protocol worked. It did not deploy.

Part III — MCP 2026-07-28

The largest revision since launch

8 — The self-contained request

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"}}}}
  • No handshake. initialize and initialized are gone. Protocol version, client info, and capabilities ride in _meta on every request.
  • No session. Mcp-Session-Id is gone. Any request lands on any instance, behind an ordinary round-robin load balancer.
  • The name appears twice — in a header for routing, in the body for dispatch. Servers reject requests where the two disagree.
  • server/discover fetches server capabilities when a client wants them, rather than at connect time.
How a function call became stateless MCP

The supporting pieces

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 punchline

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.

If you run an MCP server

  • Drop initialize handling. Read protocol version, client info, and capabilities from _meta on each request.
  • Stop issuing and validating Mcp-Session-Id. Any cross-call state becomes an explicit handle returned to the model.
  • Send and route on Mcp-Method and Mcp-Name. Reject requests where the headers and body disagree.
  • Set ttlMs and cacheScope on list and resource-read results, or clients will refetch more than they need to.
  • If you shipped against the experimental Tasks API, migrate to the Tasks extension and its new lifecycle.
  • Nothing switches off on publication. Servers and clients on 2025-11-25 keep working.

Part IV — What got fixed, what didn't, and what got heavier

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.

Legend improved · unchanged · heavier

The objection What 2026-07-28 does Verdict Why
Tool definitions consume context Nothing The definitions still enter the prompt
Token cost per turn ttlMs and cacheScope cache the fetch, not the prompt Saves a network call, not a token
Latency Handshake removed — one fewer round trip at connect Marginal, but real
Control over what enters context server/discover is on demand rather than pushed at connect Slight
Control over transformation, ranking, and security Nothing — still the server's business
Reliability and debuggability W3C Trace Context keys locked in _meta One trace spans host, SDK, server, and downstream
Handoffs input_required plus an echoed requestState A protocol primitive for agent-to-human escalation
Deployment friction Sessions and sticky routing gone Any request lands on any instance
Boxed in by prebuilt wrappers Extensions are governed and versioned independently Partial
Large API surfaces Nothing More tools still means more context, linearly
Schema weight Full JSON Schema 2020-12 — oneOf, anyOf, $ref, conditionals Better accuracy per call, more tokens per definition

The two rows that matter

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 sharper version of the argument

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.

Four things people get wrong

  • MCP did not evolve from REST. It is built on JSON-RPC 2.0. Rung 4 is its real parent. The earlier rungs are conceptual, not lineage.
  • MCP did not invent runtime discovery. WSDL, GraphQL introspection, and gRPC server reflection all predate it. What is new is discovery aimed at a caller who is not deterministic.
  • The ladder is not a timeline. SOAP is 1999, REST is 2000, JSON-RPC 2.0 is roughly 2010, and gRPC and GraphQL both arrive in 2015. They overlap heavily.
  • MCP does not replace REST. Most MCP servers wrap a REST API. It is a layer above, not a successor.

For the record

  • MCP versions are date-stamped, marking the last date backwards-incompatible changes were made. There is no major-number scheme, so there is no "MCP 2.0" — the names are 2025-11-25 and 2026-07-28.
  • The release candidate locked on May 21, 2026. The final specification shipped on July 28, on schedule, after a ten-week validation window.
  • MCP has passed 400 million monthly SDK downloads, roughly a fourfold increase over the year.

Sources and related reading

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.

See AI agents in Action