Skip to content
Chapter 3 of 7 · ArchitectureCapability and version negotiation

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 byKeyWhat it unlocks for the other side
Servertoolstools/list, tools/call
Serverresourcesresources/list, resources/read, resources/templates/list
Serverpromptsprompts/list, prompts/get
Serverlogginglogging/setLevel, and log notifications flowing out
Servercompletionscompletion/complete for argument autocomplete
Clientsamplingserver-initiated sampling/createMessage
Clientrootsroots/list, which returns the workspace directories the host has granted this server
Clientelicitationserver-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 sendsServer respondsWhat the session speaksClient's move
2025-06-182025-06-182025-06-18proceed
2025-06-182024-11-052024-11-05proceed in the older shape, or disconnect
2025-06-182019-01-01nothingdisconnect: unsupported
2024-11-052025-06-18irregulartreat 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:

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

ts
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

Back to top