What real clients actually do, and where the protocol revisions disagree with each other. Measured against this server unless stated otherwise. The README's Protocol conformance section covers what this server implements; this covers the ground around it, which is where the surprises are.
Every measurement names a client version, because none of this is guaranteed to stay true.
The draft revision 2026-07-28 is a protocol-era change, not an increment. The spec now calls
2025-11-25 and earlier "legacy" and the new model "modern":
- No
initializehandshake. Every request carries_meta['io.modelcontextprotocol/protocolVersion']andio.modelcontextprotocol/clientCapabilities, both required; missing →-32602and HTTP 400. - Protocol-level sessions are removed. A modern-only server must ignore
Mcp-Session-Id, never mint or echo one, and answer GET/DELETE on the MCP endpoint with 405. The standalone GET SSE stream is gone, replaced bysubscriptions/listen;Last-Event-IDresumption is gone. server/discoveris must-implement, answeringsupportedVersions/capabilities/_meta.serverInfo/instructionsplus caching hintsttlMsandcacheScope.- Every result must carry
resultType. New reserved error codes:-32020HeaderMismatch,-32021MissingRequiredClientCapability,-32022UnsupportedProtocolVersion. - Mandatory mirrored headers
MCP-Protocol-Version/Mcp-Method/Mcp-Name, with server-side header↔body validation (including=?base64?…?=decode) →-32020and 400. - Unknown method → HTTP 404 with
-32601. The status is load-bearing for era detection. - Dual-era on one endpoint is explicitly allowed: modern
_metapresent → modern;initialize→ legacy.
Why this matters here. Statelessness is normative in the draft — "state spanning requests MUST be
referenced by an explicit identifier the client passes on each request" — and this server's
per-client worker gems are exactly connection-inferred state keyed on Mcp-Session-Id. Isolation
would have to become a client-supplied workspace id, or collapse to per-principal, before the modern
path could be complete. That decision gates the work, which is why the draft sits under Future work
rather than in progress.
Note that the offline_access "SHOULD NOT" and the scope-hierarchy MUST are draft-only, and so
are not gaps in either supported revision.
Fetched from modelcontextprotocol.io, 2026-08-31, because this is the kind of rule that gets misremembered as one rule.
2025-11-25 (Streamable HTTP → Sending Messages to the Server) — the revision this server
implements:
Disconnection MAY occur at any time (e.g., due to network conditions). Therefore: Disconnection SHOULD NOT be interpreted as the client cancelling its request. To cancel, the client SHOULD explicitly send an MCP
CancelledNotification.
Draft 2026-07-28 (Streamable HTTP → Cancellation) — the exact reverse:
Closing the SSE response stream MUST be treated by the server as cancellation of that request. … The server SHOULD stop work on the cancelled request as soon as practical and MUST NOT send any further messages for it.
and the draft makes notifications/cancelled stdio-only. So under today's revision a
stream-close cancel is a deliberate deviation (gate it behind config, default off); under the draft
it is mandatory.
Two more draft facts from the same page: X-Accel-Buffering: no should be sent on any SSE
response, and the mirrored Mcp-Method / Mcp-Name headers are required, with -32020 on a
mismatch.
Cancellation is by notification, and it keeps the stream open. Driving a 90-second
execute_code and pressing Esc, with MCP_TRACE=1:
- 14 seconds after the call went out:
{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":2,"reason":"AbortError: remote-cancel"}}—requestIdmatching the request's own id. - The stream stayed open. The streamed answer's trace at the call's natural end, 76 seconds after
the cancel, read
client-gone=false delivered=true: the client held the stream for the whole cancelled call and took delivery of an answer it had abandoned. That is exactly2025-11-25conformant, and the mirror of that revision telling servers a disconnect should not be read as cancellation.
So notifications/cancelled is the trigger worth building against; stream-close cancellation is
era-proofing, and against this client it is dead code.
A cancel POST deadlocks behind the call it cancels, unless the front end intercepts it. Measured:
a notifications/cancelled sent 3 seconds into a 20-second call was answered 202 after
17 seconds — it queued on the session's worker mutex in forward:. Any server with per-session
workers has to intercept cancellation in the front end, before routing, the way a client's
JSON-RPC response already is.
It is already draft-aware. It probes server/discover at protocol 2026-07-28 first, then falls
back to initialize at 2025-11-25. So the fallback is a deliberate choice of mechanism, not
ignorance of the draft.
There is no 60-second client timeout, at least not here. The widely repeated assumption — that
clients give up on a tool call after about 60 seconds, and therefore that a server-side deadline must
sit under that, and that progress notifications are valuable partly because some clients reset the
timer on each one — did not hold. A tool call that ran 150 seconds and emitted no progress
notifications completed normally; so did a second 150-second call that did emit progress and
returned a 1 MB result. Two consequences: defaulting requestTimeoutSeconds to none was right, and
for a stronger reason than the one given at the time (with no client-side deadline, the server's old
45-second default was the only thing killing long calls); and a deadline-refresh-on-progress design
has no problem left to solve — do not build one without re-measuring. This measures one client, one
version, one transport, on localhost; a different client, or a proxy in the path, may still impose
one.
It sends a progressToken and then renders nothing. It sends one on every tools/call, and this
server answers with well-formed notifications/progress on the request's own stream (verified on the
wire). The client shows no UI element and writes nothing about it to its own MCP debug log
(~/Library/Caches/claude-cli-nodejs/<cwd>/mcp-logs-<server>/*.jsonl). It parses the frames without
error and does nothing observable. Worth remembering before treating "the client asks for X" as "the
client wants X": the token is emitted by the SDK layer, not because the host has somewhere to show
it.
Inspector does render progress — the same frames, unchanged, showed the tool stepping. That settles "are our frames wrong or is the host choosing not to render them": the frames are correct and consumable, and the blank UI elsewhere is a host choice.
Inspector also advertises considerably more than Claude Code does — sampling, elicitation (both
form and url), roots, and tasks (list, cancel, requests). It is the fuller client, and the
better one to test anything client-facing against.
Two traps when demonstrating progress with list_failing_tests, both self-inflicted by this
project's own suites: never include McpProgressTest (its tests remove #McpProgress from
SessionTemps and re-point #McpFrontEndSession at the worker, destroying the live reporter of the
call running them — measured 0 frames with it, 3 of 4 without), and never include McpToolTest
(it calls toolset methods directly, so its ticks leak onto the caller's stream with the wrong
totals).