Part 3 of 6 · 8 min read
Two eras of the protocol
Covers: Two eras of the protocol; State in a protocol that has none; What the host must do.
Two eras of the protocol
This is the part that changes fastest, so check it against the current specification.
The older form (specification 2025-11-25): a session
A connection opens with a handshake. The client sends initialize with the protocol version it supports, its capabilities (for example sampling, roots, elicitation) and information about itself. The server answers with a version (the same, or another it supports), its capabilities (tools, resources, prompts, logging, ...) and serverInfo, and optional instructions for the model. Then the client sends a notifications/initialized notification. Only after that does normal work start. Both sides must use only capabilities that were negotiated. There is no shutdown message: a connection ends when its transport ends.
The server remembers the session. A request before initialize is out of order; in the example it is refused.
The newer form (specification 2026-07-28): no session
The key changes of this revision include, in the words of its change list:
- Stateless. The
initialize/notifications/initializedhandshake is removed. Every request carries its protocol version and the client's capabilities in_meta; clients should also identify themselves, and servers identify themselves in each result. - No protocol-level sessions. The session header of the HTTP transport is gone, and list endpoints (
tools/list,resources/list,prompts/list) no longer vary per connection. A server that needs state across calls uses explicit handles passed as ordinary tool arguments. server/discover: servers must implement it; clients may call it first to learn supported versions, capabilities and identity, but they need not.- Version mismatch is answered per request with
UnsupportedProtocolVersionError, listing what the server supports; the client retries with a version both sides know. - Every result carries
resultType(complete, orinput_requiredfor the multi-round-trip pattern that replaces server-initiated requests; module 4). - List results are cacheable:
ttlMsandcacheScopesay how long a client may reuse them. Servers should list tools in a deterministic order, which helps client caches and model prompt caches. - Several things were removed or moved:
ping,logging/setLeveland the roots-changed notification are gone; the experimental tasks became an official extension; SSE stream resumability was removed from the HTTP transport. Roots, sampling and logging are deprecated, and so is the older HTTP+SSE transport.
Know which era you are reading about
A tutorial that tells you to "send initialize, then notifications/initialized" describes the older form. It is still what many servers and SDK versions speak, and a new client has to handle it. A tutorial that says "put the version in _meta" describes the newer form. When you write a client, support both; when you write a server, decide which you serve (a server may serve both at once, choosing from how the client opens).
In the example, the same library server can be built to support the modern era, the legacy era, or both, and the client can be forced to one era or left to probe:
The probe is the one the versioning page describes: a client that supports both tries server/discover first. If the server answers, it is modern. If it answers with a recognised modern error (the unsupported-version error), the client picks a version from the list. If it answers with anything else, it is a legacy server and the client does the handshake. All nine combinations come out as the specification's compatibility table says: a modern-only client with a legacy-only server fails (clearly), a legacy-only client with a modern-only server fails (it has no way forward), a dual-era client works with all three.
What the new form costs and saves
Measured on the same server and the same call, in this toy:
| Setup | Round trips before the first real call | Bytes per call (100 calls) |
|---|---|---|
| legacy handshake | 1 (the initialize reply; the initialized notification has no reply) |
441 |
| modern, first request is the real one | 0 | 738 (about 662 with minimal metadata) |
So the modern form saves a round trip when a connection starts and spends more bytes on every call, because each request repeats the version and capabilities and each result names the server. The exact numbers belong to my JSON; the shape (fixed cost against per-call cost) does not. The per-call overhead is part of why the specification marks client and server identification as "should" rather than "must", and the trade is a good one when servers sit behind load balancers, since any instance can answer any request.
State in a protocol that has none
If the protocol has no session, how does a server keep a shopping basket, a reading list, an open transaction? The specification's answer is a design rule: return an explicit handle from a creation tool, and take it as an argument afterwards. The model carries the handle.
The example's library has create_reading_list (returns a list_id), add_to_list(list_id, isbn) and get_list(list_id). A list made on one connection is readable from another connection, because the state is keyed by the handle and lives in the server, not in the connection. A test confirms it. And an unknown or expired handle is a tool error that says what to do: Unknown or expired list_id 'lst_999'. Create a new one with create_reading_list.
Things to decide when you design a handle (they follow the specification's guidance, and they are the least-privilege questions of module 1 again):
- A handle is a name, not a permission. For an authenticated server, check that this caller may use this handle on every call. For an unauthenticated server, the handle is a bearer secret: make it unguessable and give it a limited lifetime.
- Keep it opaque. Handles that encode structure invite parsing and guessing.
- State the lifetime in the description of the tool that creates it ("lists expire after 24 hours"), so the model can see it.
- Expiry is a normal error with a recovery step.
What the host must do
The protocol gives the host a pipe and a vocabulary. It does not give the host good judgement. Four jobs stay with you, and the example has a small test for each.
Prefix names. Tool names are unique within a server, not across servers. The example has two servers that both offer search: seven tools in all, six unique names without a prefix. The host gives the model library__search and calendar__search and routes by the prefix. (The specification suggests a prefix scheme; the separator is my choice, and many model providers restrict the characters allowed in function names, which is general knowledge, so check yours.)
Show the model ordinary function schemas. This is the part that surprises people: the model never sees "MCP". The host converts each tool definition into the function-calling shape from Level 2 (name, description, input_schema) and the model picks from that list as it always did. MCP is how the host found and executes the tools.
Ask for consent, and distrust claims. Tool definitions can carry annotations that describe behaviour, such as a hint that a tool only reads. The specification says clients must treat annotations as untrusted unless they come from a trusted server, and that there should always be a human able to deny tool calls. In the example, a server called tidy offers tidy_up marked as read-only that in fact clears the calendar:
| Host policy | Prompts shown | Calendar events after |
|---|---|---|
| believes every server's read-only hint | 0 | 0 of 3 left |
| believes hints only from servers it trusts | 1 (about tidy__tidy_up) |
3 of 3 left (an attentive person declined) |
The only difference is whose claims the host believes. This is the first glimpse of tool poisoning, which module 5 treats properly: a description or annotation is text written by the server's author, and the host must treat it as data.
Keep the conversation. Servers get the arguments of the calls addressed to them, nothing else. The host is the one place that sees everything, which is also why the host is the place to enforce the gateway from module 1: scopes, rows, caps, quotas, approvals and the audit log apply to MCP tools exactly as to your own.