Index Ai Academy
Phase 9 · Model Context Protocol · Protocol foundations

Versions and stateless requests

Revisions by date, the legacy handshake, per-request _meta and version negotiation.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

A protocol used by thousands of apps can't change all at once. MCP names each revision by date and lets clients and servers agree on one. In 2026 it also made a big design change: it dropped the opening handshake, so every request now stands on its own. That makes servers far easier to scale, but much existing software still speaks the older style. This lesson covers the versions, the old handshake, the new per-request metadata, and how a client settles on a version. The next lesson covers how a server describes itself, and how the two eras live side by side.

What you'll be able to do

  • Explain how MCP changed from a handshake protocol to a stateless one in the 2026-07-28 revision
  • Write requests with the per-request _meta fields: protocol version, client capabilities and identity
  • Handle version negotiation with UnsupportedProtocolVersion and pick a version both sides speak
  • Keep state across calls with an explicit handle instead of a session

What it covers

  1. Revisions by date
  2. What state costs
  3. The modern way: every request stands alone
  4. Two copies of a server
  5. Negotiating a version
  6. Choosing a version from the server's list
  7. When something must be remembered
  8. A basket that any copy can find

Revisions by date

MCP versions are dates, like the editions of an atlas: 2024-11-05 (the first public release), 2025-03-26, 2025-06-18, 2025-11-25 and 2026-07-28. A date sorts naturally and says when it was published. There's no guessing whether version 3 came before version 2.1. The spec calls everything up to 2025-11-25 legacy, and 2026-07-28 onward modern.

The legacy way: a handshake

Think of a phone call. You say hello, agree on a language, and from then on you both remember what was said.

In legacy MCP, a client opens a connection and first sends initialize with its preferred version, its capabilities and its name. The server answers with the version it chose and its own capabilities. The client then sends a notifications/initialized notification, and only then do normal requests begin.

Everything agreed in the handshake is state, which means information remembered between messages. It lasts for the life of the connection, or of a session identified by an Mcp-Session-Id header over HTTP. That's the flow you used in Phase 1, and the academy's practice server still answers it.

Try it now

Three messages before any real work, and every later message relies on them. That memory is the state the next section is about.

What state costs

State has costs. Picture a bank where only one teller holds your file. Every time you come in, you have to wait for that one teller, even when nine others are free.

A busy server runs on several machines behind a load balancer, a machine that shares requests out. If sessions live in memory, every request in a session must reach the machine that holds that session's state. Load balancers can do that, with sticky sessions, but it's extra work and it spreads the load unevenly. If that machine restarts, the session is lost and the client has to start over. Change notifications needed a long-lived stream tied to the session, too.

In plain words: a handshake makes the server remember you, and remembering is expensive once there's more than one server.

Quick check
A legacy MCP session was set up on machine 3 of ten. What must the load balancer do with the rest of that session's requests?
  1. Send them all to machine 3
  2. Send each one to the least busy machine
  3. Send them to all ten machines at once
Show the answer

Send them all to machine 3

The handshake's results live on machine 3, so the others wouldn't know the session. That's what sticky sessions means, and if machine 3 restarts, the session is gone.

The modern way: every request stands alone

A letter is different from a phone call. It has to say everything the reader needs, because the reader remembers nothing. The 2026-07-28 revision made MCP stateless, the word for keeping nothing between messages. There's no handshake. Instead, every request carries what the server needs in params._meta:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {},
      "io.modelcontextprotocol/clientInfo": {"name": "shop-assistant", "version": "0.3.0"}
    }
  }
}
  • protocolVersion (required): the revision this request uses.
  • clientCapabilities (required): the optional features this client supports, such as elicitation. An empty object means "only the basics".
  • clientInfo (recommended): the client's name and version, for logs and display only.

The long names start with io.modelcontextprotocol/. That prefix marks them as the protocol's own, so other tools can add their own keys to _meta without clashing. Two of those fields are required in every modern request: io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities. A request missing either is malformed. The server answers with error -32602 (Invalid params), and HTTP 400 over HTTP.

Results answer in kind. Each has resultType ("complete" for ordinary results), and a _meta holding io.modelcontextprotocol/serverInfo, the server's own name and version.

In plain words: the server no longer has to remember you, because each request introduces itself.

Worked example

Two copies of a server

Popular servers run several copies behind a load balancer, and each request may land on a different one. See what that does to a server that keeps sessions in memory.

class LegacyServer:
    def __init__(self):
        self.sessions = {}
    def handle(self, session, method):
        if method == "initialize":
            self.sessions[session] = "2025-03-26"
            return "ok"
        if session not in self.sessions:
            return "error: unknown session"
        return f"{method} answered"

class ModernServer:
    def handle(self, meta, method):
        if "protocolVersion" not in meta:
            return "error: missing _meta"
        return f"{method} answered"

copies = [LegacyServer(), LegacyServer()]
print(copies[0].handle("s1", "initialize"))
print(copies[1].handle("s1", "tools/list"))   # the next request lands on the other copy

copies = [ModernServer(), ModernServer()]
meta = {"protocolVersion": "2026-07-28"}
print(copies[0].handle(meta, "tools/list"))
print(copies[1].handle(meta, "tools/list"))
It runs right here in your browser, with nothing to install.

The legacy server fails as soon as a request reaches a copy that didn't see the handshake. Fixing that needs sticky routing or shared storage. A modern request carries everything needed, so any copy can answer it.

Quick check
In the 2026-07-28 revision, where does a server learn which protocol version a request uses?
  1. From the Mcp-Session-Id header
  2. From the initialize handshake at the start of the connection
  3. It guesses from the method name
  4. From params._meta["io.modelcontextprotocol/protocolVersion"] in that same request
Show the answer

From params._meta["io.modelcontextprotocol/protocolVersion"] in that same request

Modern MCP is stateless: each request carries its version and capabilities. Initialize and session ids belong to legacy revisions.

Negotiating a version

There's no up-front agreement, so negotiation happens per request. Picture asking for something in French abroad, and the clerk answering "sorry, I speak English and German". You pick one of those and ask again.

If the server doesn't support the version a request names, it answers with UnsupportedProtocolVersion, error code -32022, and lists what it does support:

{"jsonrpc": "2.0", "id": 1, "error": {"code": -32022, "message": "Unsupported protocol version",
  "data": {"supported": ["2026-07-28", "2025-11-25"], "requested": "2027-01-15"}}}

The client picks a version both sides support, preferring the newest it knows, and retries. If there's none in common, it reports a clear error to the user, with both lists, instead of failing mysteriously. A client remembers the answer for that server, so it doesn't pay for the extra round trip on every call.

In plain words: ask in your newest language. If the server says no, pick the newest one you both speak.

Worked example

Choosing a version from the server's list

A client prefers the newest version it knows. Given the server's supported list from an error, pick the best version both sides speak.

CLIENT_VERSIONS = ["2026-07-28", "2025-11-25", "2025-06-18"]     # newest first

def choose_version(server_supported):
    for v in CLIENT_VERSIONS:
        if v in server_supported:
            return v
    return None

print(choose_version(["2025-11-25", "2025-06-18"]))
print(choose_version(["2026-07-28"]))
print(choose_version(["2024-11-05"]))
In the lesson you can change this and run it.

The client walks its own list from newest to oldest and takes the first one the server also lists. When there's nothing in common, it returns None, and a well-behaved client shows the user a clear message (with both lists) instead of failing mysteriously.

Quick check
A client picks the best version both sides support, in its own order of preference. What does this print?
ours = ["2026-07-28", "2025-11-25", "2025-06-18"]
theirs = ["2025-11-25", "2025-06-18"]
print(next((v for v in ours if v in theirs), None))
  1. None
  2. 2025-06-18
  3. 2026-07-28
  4. 2025-11-25
Show the answer

2025-11-25

2026-07-28 isn't in theirs, so next moves on, and 2025-11-25 is the first that both lists have. The newest version the client knows that the server also supports wins. None would mean there's no common version.

When something must be remembered

Some things really do have to last across calls. A shopping basket is the classic case: add an item now, pay for it three calls later. In stateless MCP the server still can't keep that in connection memory, because the next call may reach a different copy.

The answer is a handle: the server stores the basket somewhere all its copies can read, such as a database, and returns a short id like "bsk_0001". The client sends that id back as an ordinary tool argument on later calls. It works like a coat check. You hand over the coat, you get a ticket, and any attendant can fetch the coat from the ticket.

The server should also check that the handle belongs to the caller, so one customer can't open another's basket by guessing an id.

In plain words: state that must last gets a ticket, and the ticket travels with every request.

Worked example

A basket that any copy can find

Two copies of a modern server share one store. The first call opens a basket and gets a handle; later calls pass the handle back, whichever copy they reach.

STORE = {}                      # stands in for a database every copy can reach

class ModernServer:
    def open_basket(self):
        handle = f"bsk_{len(STORE) + 1:04d}"
        STORE[handle] = []
        return handle
    def add_item(self, handle, item):
        if handle not in STORE:
            return "error: unknown basket"
        STORE[handle].append(item)
        return f"{len(STORE[handle])} item(s) in {handle}"

copies = [ModernServer(), ModernServer()]
handle = copies[0].open_basket()             # the first call lands on copy 0
print(copies[1].add_item(handle, "socks"))   # the next lands on copy 1
print(copies[0].add_item(handle, "hat"))
print(copies[1].add_item("bsk_9999", "scarf"))
In the lesson you can change this and run it.

The handle is the only thing the client holds. Both copies read the same store, so it doesn't matter which one gets the call: socks lands as item 1 and the hat as item 2 in the same basket. A made-up handle is refused.

Practice what you just read

  • 3 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Which two _meta fields must every modern MCP request include?

  1. io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities
  2. io.modelcontextprotocol/clientInfo and io.modelcontextprotocol/serverInfo
  3. Mcp-Session-Id and io.modelcontextprotocol/protocolVersion
  4. io.modelcontextprotocol/progressToken and io.modelcontextprotocol/logLevel

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson