Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Two revisions at once

MCP is a dated specification. 2026-07-28 is the current revision, and 2025-11-25 stays in wide use while implementations migrate. 2026-07-28 removes the initialize handshake outright (SEP-2575), makes every request carry its own _meta envelope, and adds caching hints, multi-round-trip requests, subscriptions, and a discovery probe. That is not a version bump anyone should take on faith.

So this toolkit judges both, and can judge one trace against both in a single pass. A migration stops being an inventory of changes somebody read in a changelog and becomes a measurement.

What “both” means here

2025-11-252026-07-28
Registry entries348501
Judged by a named check102181
Carrying a documented exclusion246320
Shipped in every buildyesyes

The two registries are extracted per revision rather than sharing entries: each clause is quoted from its own revision’s published text, and a clause restated with different words gets its own ID. 2025-11-25’s BASE-003 forbids reusing a request ID within a session; 2026-07-28’s BASE-045 forbids reusing one that is still in flight, so reuse after a response is now legal. Those are two clauses, not one clause with a footnote — and pointing the new one at the old one’s check would have reported conforming traces as violations.

The cost of that method is that no clause is in force at both revisions, so a side-by-side report never shows one clause passing at one revision and failing at the other. What it shows instead is presence, which is what a migration actually consists of.

A trace judged against both

This is a conforming 2025-11-25 handshake — the client offers a version, the server answers, the client confirms:

{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"corpus-client","version":"0.1.0"}}}}
{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"corpus-server","version":"0.1.0"}}}}
{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}

Judged against both revisions at once:

$ mcp-trace-validator validate --revision 2025-11-25 --revision 2026-07-28 --all handshake.jsonl

(--all lists every clause; without it the report lists only the failing, warning and unsupported ones.) Four rows out of the report say the whole thing:

  LIFE-001   (MUST)  2025-11-25=pass  2026-07-28=absent  *differs
  DISC-001   (MUST)  2025-11-25=absent  2026-07-28=not-observed  *differs
  BASE-030   (MUST)  2025-11-25=absent  2026-07-28=fail  *differs
  BASE-045   (MUST NOT)  2025-11-25=absent  2026-07-28=pass  *differs
  • LIFE-001 — “the client MUST initiate the lifecycle” — is a clause the newer revision removes. It reads pass then absent.
  • DISC-001 — the server/discover probe that replaces the handshake — is a clause the newer revision adds, and this session carried nothing it binds to, so it reads not-observed rather than counting as a pass.
  • BASE-030 — every request must carry an io.modelcontextprotocol/clientCapabilities _meta member — is a clause the newer revision adds that this session breaks. It reads fail, and it is the migration work this trace has actually discovered.
  • BASE-045 — the narrowed request-ID rule — reads pass, because reusing an ID after its response is exactly what the new text permits.

And the verdict splits:

per revision:
  2025-11-25: 17 pass, 0 fail, 0 warn, 246 excluded, 0 unsupported, 14 not applicable, 71 not observed — verdict pass
  2025-11-25: wire schema pass (3 messages valid)
  2026-07-28: 15 pass, 8 fail, 0 warn, 320 excluded, 0 unsupported, 0 not applicable, 158 not observed — verdict fail
  2026-07-28: wire schema fail (1 of 3 messages invalid)
overall verdict: fail

A conforming session today, and eight concrete failures tomorrow — nine, with the 2026-07-28 schema’s own finding: the initialize result carries no resultType, which every result at that revision must. Both summary lines account for every clause in their revision — the counts add up to 348 and 501 — because a line that quietly omits an outcome is a line that overstates what was measured.

The example above is executed by a test (book_examples.rs) against the real validator on every cargo test, so this page cannot drift from what the tool actually prints.

Which revision a trace is judged against

A trace is not silent about which revision it belongs to — the handshake states it, a stateless session states it in every request’s _meta, and the HTTP transport states it in a header. So by default the validator judges a trace against the revision it declares, and says so in the report’s first line: revision 2026-07-28 (declared by the trace). Where there was a handshake, the server’s initialize result is the authority: the session ran under the revision it names — that is what negotiation means — so it is judged under that one alone, and a note on stderr says when the client had proposed another (--revision judges the proposal instead). A negotiated revision this build cannot judge is refused (exit 2) rather than approximated by the proposal, and so is a trace declaring only revisions this build cannot judge. A trace declaring nothing is judged against the newest supported revision, with a note on stderr.

Judging against the wrong rules is easy to do on purpose, with --revision. Force 2025-11-25 onto a 2026-07-28 recording and every clause the two revisions disagree about becomes a finding: the session opens with tools/list rather than initialize, so LIFE-001 fails; it reuses request ids after their responses, so BASE-003 fails. Both are correct answers to the question that was asked, and neither is a defect in the implementation. This session says 2026-07-28 twice over:

{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"http","method":"POST","headers":{"accept":"application/json, text/event-stream","mcp-protocol-version":"2026-07-28"}}
{"seq":1,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}}
{"seq":2,"direction":"server-to-client","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"tools":[],"resultType":"complete"}}}

so validating it with --revision 2025-11-25 says so, above the rows and again under the verdict:

  NOTE  this session declares protocol revision 2026-07-28, not 2025-11-25.
        Every outcome here judges it against rules it was not playing by;
        re-run with `--revision 2026-07-28` to judge it against its own.

It is deliberately quiet. A session that proposed one revision and negotiated another is judged under the negotiated one by default, but the client did state the proposal, so asking for that revision with --revision draws no note; a version a server refused states nothing, because the session never ran under it; and a recording that declares no revision at all gets no note, since there is nothing to disagree with.

The four ways a clause is not a pass

A side-by-side report is where these stop being pedantry, because all four appear at once and they mean different things:

ReadsMeans
absentThe clause does not exist at that revision.
excludedIt exists, but no recorded trace can judge it — the reason is stated per entry.
not-applicableIt exists and is judgeable, but it is gated on a capability this session never negotiated.
not-observedIt exists, is judgeable, is not gated — and the session simply carried nothing it binds to.

None of them is a pass, and the report never quietly promotes one. That rule is the whole reason the numbers here are smaller than a conformance tool’s numbers usually are.

What is measured at 2026-07-28 today

  • The reference server serves the stateless surface with --protocol-version 2026-07-28, and the official suite’s 2026-07-28 scenarios score 42 passing / 0 failing against it.
  • Eight committed captures — a conforming session over each transport, a probe session of deliberately malformed requests, the official runner’s two, and three of the official SDKs’ own conformance servers (the cross-SDK matrix) — evidence 148 of the 181 judgeable clauses between them. What no capture reaches is named, one clause at a time, in the corpus chapter.
  • The project’s CI re-runs that measurement weekly and ratchets it against a committed baseline: any change in either direction fails the build, so a change in the score is recorded in the commit that caused it rather than estimated.

That ratchet has already paid for itself in the direction nobody plans for. Through suite 0.2.0-alpha.9 the runner scored the reference server identically whichever revision it served — its scenarios exercise features, not revisions, so it could not tell the two apart. This project’s registry could, and flagged the 2025-11-25 server for returning cacheable results without the cache hints 2026-07-28 requires (CACH-001). Six weeks later alpha.11 added a schema check and found the same clause, independently, from the other side. The server’s SDK then fixed it: since rmcp 3.5.1 both legs score 42 passing / 0 failing.

The next revision

The specification’s unreleased next revision (draft) is watched, never judged. No build can validate against it — --revision draft is refused like any revision the build does not ship — but the project reads it weekly and records what it has changed against 2026-07-28: pages added or dropped, keyword counts that moved, and each 2026-07-28 clause whose sentence the draft rewords or removes. So far that is one wording edit: the draft bounds a header-mirrored integer “for integers represented using IEEE754 double-precision floating point numbers” where the released transport page says “for JavaScript”. The interval is the same, so the 2026-07-28 verdict is the same. When the draft ships as a dated revision, its registry starts from that record rather than from scratch.

Full detail is in the corpus chapter, the ecosystem register and the conformance strategy.