Chapter 3 of 7 · 14 min read
Capability and version negotiation
On this page
Two objects are exchanged during the MCP handshake and they decide everything a session is
allowed to do. The initialize params carry the client's capabilities; the response
carries the server's. Reading them is a different act from recognising the name in
clientInfo, and the difference matters because the name is a much stronger signal than it
is a reliable one: a host with a model visibly behind it, a chat window open and a
subscription paid for can still declare {}, because the host decides per server, per
workspace or per user policy what it is willing to offer, and the declaration is the only
channel through which that decision reaches you.
The same applies in the other direction and one layer down, to the protocol version. It is negotiated in the same exchange, it is answered rather than echoed, and like the capability objects it is settled once and cannot be reopened while the connection is open.
Two declarations, pointing in opposite directions
The two capability objects are not two halves of one list. Each side declares what it is prepared to serve to the other, which means the client's object is about requests the server may originate and the server's object is about requests the client may originate.
That inversion catches people every time, and the way to stop being caught is to derive it
rather than memorise it. A capability declaration exists so the other side knows what it is
allowed to ask for. Only the party that will answer a request knows whether it can answer
it — the asker has no way to find out except by being told. So the declaration has to be
made by the answering side, which means each object describes methods that arrive at its
author rather than methods its author sends. Reading the table with that in hand, tools is
in the server's object because tools/call is a request the server answers, and sampling
is in the client's object because sampling/createMessage is a request the client answers.
Neither object is a wish list. Each is a promise about incoming traffic, which is why the
useful question when reading one is never "what does this side want to do" but "what is
this side agreeing to be asked".
| Declared by | Key | What it unlocks for the other side |
|---|---|---|
| Server | tools | tools/list, tools/call |
| Server | resources | resources/list, resources/read, resources/templates/list |
| Server | prompts | prompts/list, prompts/get |
| Server | logging | logging/setLevel, and log notifications flowing out |
| Server | completions | completion/complete for argument autocomplete |
| Client | sampling | server-initiated sampling/createMessage |
| Client | roots | roots/list, which returns the workspace directories the host has granted this server |
| Client | elicitation | server-initiated elicitation/create to ask the user for input |
Each value is an object rather than a boolean, and the keys inside it are
sub-capabilities that refine what the top-level key promised.
resources: { subscribe: true, listChanged: true } says the server will accept
subscriptions and will announce list changes; resources: {} says neither of
those, while still meaning that reads and lists are available. The empty object
is a real declaration, not an absence, and the distinction between
resources: {} and no resources key at all is the difference between "this
server serves resources and nothing more elaborate" and "this server has no
resources".
Three states rather than two is the part that has to be held deliberately, because most
code that reads configuration is written against two. Absent means the feature does not
exist in this session; present-and-empty means the feature exists at its plainest; present
with sub-keys means the feature exists with refinements. The hazard is that in several
languages an empty mapping is falsy, so a check written as "if the capabilities have
resources" reads resources: {} as a no — and a server offering perfectly good read-only
resources is treated by the client as having none, on the strength of a test that looks
correct in review. The test that preserves all three states asks whether the key is present,
which is a different question from whether its value is non-empty, and then asks separately
about whichever sub-key the feature actually needs.
That structure is what makes a mismatch legible at the moment the handshake completes
rather than at the moment a feature fails. A client that declared {} has made a complete
statement: no sampling, no roots, no elicitation. Nothing about the host's product, the
model it runs, the subscription tier of the user or the fact that a chat window is visibly
open changes that object. Some hosts ship sampling off by default and turn it on when the
user grants a specific server permission, which means the same product, same version, same
user can present different capability objects to two servers on the same machine.
The version in the response is the server's answer, not an echo
Protocol versions in MCP are dates: 2024-11-05, 2025-03-26, 2025-06-18. The client
puts the latest version it supports in the initialize params, and the server replies with
the same version if it supports it, or with the latest version it supports if it does
not. The reply is a decision about the session, and the client's obligations differ by case.
| Client sends | Server responds | What the session speaks | Client's move |
|---|---|---|---|
2025-06-18 | 2025-06-18 | 2025-06-18 | proceed |
2025-06-18 | 2024-11-05 | 2024-11-05 | proceed in the older shape, or disconnect |
2025-06-18 | 2019-01-01 | nothing | disconnect: unsupported |
2024-11-05 | 2025-06-18 | irregular | treat as an error and disconnect |
The second row is the one that produces silent damage, because a client that ignores the
counter-offer and carries on sending 2025-06-18 message shapes does not fail immediately.
Most messages are identical across adjacent versions, so discovery works, most tool calls
work, and the divergence surfaces only where the shapes actually differ. Between
2024-11-05 and 2025-06-18 that includes tool results carrying structured content, the
audio content type, and elicitation existing at all — so a client speaking the newer
shape at an older server gets a validation error on the one call that used the new field,
weeks after the connection pattern was established, and the error names a field rather than
a version.
Choosing between the two legal responses to a downgrade is a product decision rather than a
protocol one. Speaking the older version is right when the features you actually use exist
there, which for most hosts is true, since tool discovery and invocation have been stable
across every version so far. Disconnecting is right when the older version is missing
something the feature depends on — a host built around elicitation has nothing to offer
against a 2024-11-05 server and should say so at connect time rather than at the first
prompt. What is not available is the middle path of connecting, keeping the newer shapes,
and hoping, because the failure lands on the user during a task rather than on the developer
during setup.
Date-based versioning is a deliberate departure from semver, and the reason is worth knowing because it changes how you read a mismatch. Semver invites a client to reason about compatibility — "the server is 1.4, I am 1.7, minor versions are compatible, so I will assume the features I need are there" — and that reasoning is exactly what capability declarations replace. A date has no ordering semantics beyond "later", carries no promise of backward compatibility, and offers nothing to infer from, which pushes every question about what is available back onto the capability objects where it can be answered exactly.
Over streamable HTTP there is a second place the negotiated version lives. From
2025-06-18, the client sends an MCP-Protocol-Version header on every subsequent HTTP
request, carrying the version that was agreed:
POST /mcp HTTP/1.1
Host: docs.internal
Content-Type: application/json
MCP-Protocol-Version: 2024-11-05
Mcp-Session-Id: 5f2c9a1e-7b40-4c1a-9a2f-0d1c3e8a6b77
{"jsonrpc":"2.0","id":4,"method":"tools/list"}The Mcp-Session-Id beside it is the identifier the server minted during initialize and
expects back on everything that belongs to this session, which is how a stateless-looking
sequence of POSTs is recognised as one conversation. The version header exists because HTTP
requests can be routed independently — the POST carrying tools/list may reach a different
process than the one that handled initialize — and that process needs to know which
version's rules apply without holding session state.
The contrast with stdio is what makes those two headers look inevitable rather than
bureaucratic. Over a pipe there is one process at the far end for the whole life of the
connection, so anything it learned during initialize it still knows when the next message
arrives, and no message needs to carry any of it. Take that guarantee away and every fact
established during the handshake has to travel on each request that depends on it, because
the receiver may be meeting this session for the first time. Two facts qualify: which
session this is, and which version's rules to apply. Those are exactly the two headers, and
there are no others because those are the only two things the handshake settled that a later
request cannot do without.
A client that negotiated down to
2024-11-05 and then sends MCP-Protocol-Version: 2025-06-18 in the header is announcing
the mismatch on every single request, and a strict server is entitled to reject with
400 Bad Request.
Once, and only for this session
Negotiation happens exactly once, at initialize, and there is no message for redoing it.
Nothing in the protocol lets a server declare halfway through a session that it now supports
subscriptions, and nothing lets a client upgrade to a newer protocol version without tearing
the connection down and opening a new one. A capability set is fixed for the lifetime of the
connection.
What can change is the contents behind a capability, which is what the listChanged
sub-capabilities are for. A server that declared tools: { listChanged: true } may send
notifications/tools/list_changed at any point, and the client re-fetches tools/list and
gets a different array. The tools moved; the capability did not. Reading a list_changed
notification as a signal that the session's terms have shifted is a category error that
leads to clients re-issuing initialize on a live connection, which a conforming server
rejects as an invalid request because it is already initialized.
The practical consequence lands on hosts that connect at startup and stay connected for a working day. If a user changes a setting that ought to grant a server sampling access, the running session cannot learn about it; the host has to close that client and open a new one, which means the capability change is a reconnect, with everything a reconnect implies for in-flight work. That is a genuine cost of one-shot negotiation and it is the price of not having a renegotiation state machine in a protocol whose whole appeal is that the handshake is three messages long.
Authorisation, inventory and guarantees
Four adjacent things get read into a capability object that are not in it, and each substitution produces a different wrong conclusion. What they have in common is worth saying before the list: a capability declaration is a statement about which methods this session will dispatch, and nothing else. Every one of the four errors below is the same move — reading a statement about dispatch as a statement about something further along the path the request takes.
A capability is not authorisation. tools: {} in the response means the tools methods exist
in this session; it says nothing about whether the credentials behind the connection are
allowed to run any particular one. A tools/call can be dispatched, reach the handler, and
come back with a permission error from the underlying API, and that is a successful protocol
interaction reporting a failed operation.
A capability is not an inventory. Declaring tools says nothing about which tools, how
many, or what they are named; that is what tools/list is for. A client that treats the
presence of the tools key as evidence that a specific tool exists has skipped the
discovery step, and gets -32602 for unknown tool names rather than the -32601 it would
get for an undeclared method.
Those two codes are worth keeping straight, because which one comes back tells you which
mistake was made. -32601 is "method not found", and it is the answer when the method
itself is not something this session dispatches — the capability was never declared, so
there is no handler to reach. -32602 is "invalid params", and it is the answer when the
method exists and was dispatched, and the handler looked at the arguments and refused them;
an unknown tool name is an invalid parameter to a tools/call that was itself perfectly
legal. So the pair localises the fault without any further evidence: one says the client
asked for something the handshake ruled out, the other says the client asked correctly and
supplied something wrong. They are as far apart as a missing capability and a typo, and
telling them apart takes reading two digits. Both are avoidable from the client's side, and
by different means: the first by reading the declaration before emitting the method, the
second by listing before calling.
A capability is not a guarantee of success. resources: { subscribe: true } means the
server will accept a resources/subscribe request; it does not promise that a notification
will ever arrive on it, which is a gap wide enough to hang a feature on and the subject of
the chapter after next.
And a capability is not a version. Two servers can both declare
tools: { listChanged: true } while negotiating different protocol versions, so
the shape of the results they return is not the same. Both facts have to be
read.
Checking a client against a captured fixture
The cheapest way to keep a client honest is to capture the initialize exchange from each server you integrate with and turn it into a test fixture. The check is a set-difference: every method the client is capable of emitting, mapped to the capability it requires, tested against what the fixture declared.
const REQUIRES: Record<string, string> = {
"tools/list": "tools",
"tools/call": "tools",
"resources/read": "resources",
"resources/subscribe": "resources.subscribe",
"prompts/get": "prompts",
"completion/complete": "completions",
};
function unmetMethods(declared: Record<string, unknown>, methods: string[]): string[] {
const has = (path: string) =>
path.split(".").reduce<unknown>(
(node, key) => (node && typeof node === "object"
? (node as Record<string, unknown>)[key]
: undefined),
declared,
) !== undefined;
return methods.filter((m) => !has(REQUIRES[m]));
}
// Fixture captured from the partner's server, checked into the repo.
const serverCaps = { tools: { listChanged: true }, resources: {} };
test("every method the docs panel emits is declared", () => {
const emitted = ["tools/list", "tools/call", "resources/read", "resources/subscribe"];
expect(unmetMethods(serverCaps, emitted)).toEqual([]);
});Run against that fixture, the test fails on resources/subscribe and passes on everything
else, which is a compile-time-ish answer to a question that would otherwise be discovered by
a user watching a panel that never updates. The fixtures are cheap to produce — they are the
JSON the server already sent — and they are the artefact to ask a partner for when their
server is not something you can run locally.
Read the declaration, not the logo. That is the whole of it, and it holds in both directions: a server reading a client's capabilities to decide whether it may ask for a model completion, and a client reading a server's to decide whether a feature can be offered at all. The two objects are exchanged before any feature code runs, they are authoritative for the entire session, and they are the only statement either side ever makes about what it will answer.
The habit generalises past testing. When a session misbehaves, the first two questions are what the capability objects said and what the client attempted, and both are answerable from a captured trace without reading any implementation — which is the point at which a disagreement between two teams becomes a matter of citing a message rather than describing a symptom.
Architecture — read the current version at https://daviddominguez.dev/learn/en/architecture/capability-and-version-negotiation