[Client][Schema][Server] Implement protocol version negotiation - #403
Open
chr-hertel wants to merge 1 commit into
Open
[Client][Schema][Server] Implement protocol version negotiation#403chr-hertel wants to merge 1 commit into
chr-hertel wants to merge 1 commit into
Conversation
chr-hertel
requested review from
CodeWithKyrian,
Nyholm and
soyuka
as code owners
July 27, 2026 10:58
chr-hertel
force-pushed
the
feature/protocol-version-negotiation
branch
from
July 27, 2026 11:02
88c4b7f to
a90be81
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
This PR makes protocol version negotiation explicit and spec-correct across the SDK’s server handshake path, client handshake validation, and HTTP transport version filtering, while introducing an enum-based registry that partitions “handshake” vs “modern” eras.
Changes:
- Add a
ProtocolVersionregistry with ordered subsets (handshakeVersions()/modernVersions()), comparisons by declaration order, and a new known revision2026-07-28. - Fix server-side
initializenegotiation to echo supported client requests, counter-offer appropriately, and persist the negotiated version on the session. - Tighten client and transport behavior: clients validate/record negotiated versions; HTTP middleware defaults to accepting only handshake-era revisions.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Schema/Enum/ProtocolVersion.php | Adds modern-era revision + version registry helpers and ordering semantics. |
| src/Server/Handler/Request/InitializeHandler.php | Implements server-side negotiation + session persistence of negotiated version. |
| src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php | Defaults supported versions to handshake-era only; uses named default for missing header. |
| src/Client/Protocol.php | Offers handshake-era version when configured modern; validates counter-offers; records negotiated version in state. |
| src/Client/State/ClientStateInterface.php | Adds protocol-version getters/setters to client state. |
| src/Client/State/ClientState.php | Stores negotiated protocol version on the client state. |
| tests/Unit/Schema/Enum/ProtocolVersionTest.php | Adds coverage for era split, accessors, comparisons, and defaults. |
| tests/Unit/Server/Handler/Request/InitializeHandlerTest.php | Adds negotiation and session-storage test coverage for InitializeHandler. |
| tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php | Updates default-acceptance expectations to handshake-era and adds modern-rejection test. |
| tests/Unit/Client/ProtocolTest.php | Adds coverage for offered version, counter-offer acceptance, and counter-offer rejection. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The `initialize` handler never looked at the version the client asked for: it always answered with the server's configured version (or the SDK default), so a client offering a revision the server supports could still be told to speak a different one. The spec requires echoing back a supported request and only counter-offering when the requested version cannot be honoured. Turns ProtocolVersion into the registry the other SDKs converged on (typescript-sdk's protocolEras.ts, python-sdk's mcp_types/version.py): `latestHandshake()`, `handshakeVersions()`, `modernVersions()` and ordered comparison through `isAtLeast()`. Comparison goes through declaration order rather than string collation, so an unrecognized peer string compares conservatively instead of accidentally sorting above a known revision. The one throw the enum can produce is the SDK's own LogicException, so a consumer catching `Mcp\Exception\ExceptionInterface` does not miss it. Adds 2026-07-28 as a known revision and splits the set into two eras: handshake (2025-11-25 and earlier, negotiated via `initialize`) and modern (2026-07-28+, per-request `_meta`). The lists are kept apart so a modern revision can never leak into an `initialize` exchange, which has no `initialize` at all. Serving the modern era is follow-up work; this only teaches the SDK that the revision exists and must not be mis-negotiated. Deliberately absent is any "newest revision in any era" accessor, which would hand back the one revision the handshake must never offer. Server: negotiates and stores the result on the session. A version pinned in Configuration still pins the handshake to exactly that revision. Client: validates the counter-offer and fails the handshake instead of continuing on a revision it cannot speak; records the negotiated version on the client state, readable through the new Client::getProtocolVersion(). The HTTP middleware now defaults to the handshake versions rather than every declared case, so it stops accepting a modern version the server cannot yet serve. When the header is absent it falls back to ProtocolVersion::DEFAULT_NEGOTIATED_VERSION (2025-03-26), the revision that introduced both Streamable HTTP and the header itself. The negotiation behaviour is one provideNegotiationTable() data provider whose rows are the documented table -- supported revision echoed back, unknown or malformed counter-offered, modern counter-offered -- so the docs and the suite cannot drift apart. The supported-revision rows iterate handshakeVersions() rather than a literal list, so declaring a new handshake revision extends the table automatically, and the "never answer with a modern revision" assertion runs on every row. Documented in server-builder.md (the enum registry, how the server answers a handshake, what pinning changes), transports.md (the header check) and client.md (the client's side), all linking the spec's versioning section.
chr-hertel
force-pushed
the
feature/protocol-version-negotiation
branch
from
July 27, 2026 15:18
1a6023e to
6042d3d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Groundwork for the 2026-07-28 spec release: makes the SDK's protocol version handling explicit and correct before the stateless/modern-era work lands on top of it.
The bug
InitializeHandlernever read$request->protocolVersion. It answered every handshake with$configuration->protocolVersion(or the SDK default), so negotiation effectively did not exist — a client offering a revision the server supports could still be told to speak a different one.The spec requires the opposite: if the server supports the requested version it MUST respond with that same version, and only counter-offer when it cannot honour the request.
Version registry
ProtocolVersionbecomes the registry the other SDKs converged on —typescript-sdk'sshared/protocolEras.tsandpython-sdk'smcp_types/version.py:Ordering goes through declaration order, not string collation. Revision identifiers happen to be ISO dates today, but they are an enumerated set rather than an ordered scalar — future identifiers are not guaranteed to be date-shaped, and an unrecognized peer string must compare conservatively instead of accidentally sorting above a known revision (
"zzz" > "2025-11-25"is true under string comparison).Two eras
2026-07-28is added as a known revision, and the set splits in two:2025-11-25and earlier: version negotiated through theinitializeround-trip.2026-07-28+: noinitializeat all; every request carries its version in_metaand servers advertise throughserver/discover.The two lists are deliberately kept apart, mirroring the TypeScript SDK's reasoning, so that adding a revision to one era can never leak a version string into the other era's negotiation. A modern revision must never appear in an
initializeexchange — that method does not exist in that era, and answering with one leaves a connection neither side can use.Serving the modern era is follow-up work. This PR only teaches the SDK that the revision exists and must not be mis-negotiated.
Behaviour
Server (
InitializeHandler) — negotiates, and stores the result on the session underprotocol_versionso downstream version-gated serialization has something to read:Client (
Client\Protocol::initialize()) — validates the counter-offer instead of silently ignoring it.InitializeResult::fromArray()usestryFrom, so an unknown version previously becamenulland was dropped on the floor. A counter-offer the SDK cannot speak now fails the handshake (surfacing asConnectionExceptionthrough both transports) rather than continuing on a revision neither side agreed on. The negotiated version is recorded onClientState. A client configured with a modern revision still offers the newest handshake version, mirroring the server-side invariant.Transport —
ProtocolVersionMiddlewaredefaults tohandshakeVersions()rather than every declared case, so it stops accepting a modern version this server cannot yet serve. The header-absent fallback is now the namedProtocolVersion::DEFAULT_NEGOTIATED_VERSIONinstead of an inlineV2025_03_26.Points for review
Configuration::$protocolVersionshifts meaning — from "the version the server announces" to "pin the handshake to exactly this revision". A pin still wins over the client's request, so an explicit pin stays meaningful. If spec-echo should always win instead, that is a one-line change inInitializeHandler::supportedVersions().ProtocolVersionMiddleware's default set narrowed. Anyone relying on the default to accept everything in the enum will now see2026-07-28rejected with 400. That is the intent — the alternative is advertising support that does not exist — but it is a visible default change. Its test was updated and one added asserting modern versions are rejected by default.ProtocolVersion::cases()andhandshakeVersions()now differ, so every call site has to pick one deliberately. Both current call sites were reviewed.MessageInterface::PROTOCOL_VERSIONleft alone (stillV2025_11_25, currently equal tolatestHandshake()). It is aconstso it cannot call a method; retiring it belongs with the modern-era work.Testing
make csandmake phpstanclean; 957 tests pass (7 pre-existing skips).New coverage:
ProtocolVersionTest(era partition, ordering, the invariant that no modern revision leaks into the handshake list),Client\ProtocolTest(offer, counter-offer acceptance, rejection of unusable counter-offers), plus negotiation cases onInitializeHandlerTest.Follow-ups this unblocks
Version-gated serialization is the next prerequisite:
resultTypeon every result,ttlMs/cacheScope(#354), and the resource-not-found code flip (#359) all need to emit different shapes per negotiated version, which the DTOs cannot express today —jsonSerialize()takes no arguments. This PR provides the version to gate on; it does not build the gate.🤖 Generated with Claude Code