Skip to content

[Client][Schema][Server] Implement protocol version negotiation - #403

Open
chr-hertel wants to merge 1 commit into
modelcontextprotocol:mainfrom
chr-hertel:feature/protocol-version-negotiation
Open

[Client][Schema][Server] Implement protocol version negotiation#403
chr-hertel wants to merge 1 commit into
modelcontextprotocol:mainfrom
chr-hertel:feature/protocol-version-negotiation

Conversation

@chr-hertel

Copy link
Copy Markdown
Member

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

InitializeHandler never 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

ProtocolVersion becomes the registry the other SDKs converged on — typescript-sdk's shared/protocolEras.ts and python-sdk's mcp_types/version.py:

ProtocolVersion::latest();             // newest, any era
ProtocolVersion::latestHandshake();    // newest reachable via `initialize`
ProtocolVersion::latestModern();
ProtocolVersion::oldest();
ProtocolVersion::handshakeVersions();  // ordered subsets
ProtocolVersion::modernVersions();
$version->isModern();
$version->isAtLeast($minimum);
$version->isOlderThan($other);

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-28 is added as a known revision, and the set splits in two:

  • handshake2025-11-25 and earlier: version negotiated through the initialize round-trip.
  • modern2026-07-28+: no initialize at all; every request carries its version in _meta and servers advertise through server/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 initialize exchange — 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 under protocol_version so downstream version-gated serialization has something to read:

Client requests Server answers
a supported handshake version that same version
unknown / malformed newest handshake version
a modern version newest handshake version (never a modern one)

Client (Client\Protocol::initialize()) — validates the counter-offer instead of silently ignoring it. InitializeResult::fromArray() uses tryFrom, so an unknown version previously became null and was dropped on the floor. A counter-offer the SDK cannot speak now fails the handshake (surfacing as ConnectionException through both transports) rather than continuing on a revision neither side agreed on. The negotiated version is recorded on ClientState. A client configured with a modern revision still offers the newest handshake version, mirroring the server-side invariant.

TransportProtocolVersionMiddleware defaults to handshakeVersions() rather than every declared case, so it stops accepting a modern version this server cannot yet serve. The header-absent fallback is now the named ProtocolVersion::DEFAULT_NEGOTIATED_VERSION instead of an inline V2025_03_26.

Points for review

  1. Configuration::$protocolVersion shifts 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 in InitializeHandler::supportedVersions().
  2. ProtocolVersionMiddleware's default set narrowed. Anyone relying on the default to accept everything in the enum will now see 2026-07-28 rejected 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.
  3. ProtocolVersion::cases() and handshakeVersions() now differ, so every call site has to pick one deliberately. Both current call sites were reviewed.
  4. MessageInterface::PROTOCOL_VERSION left alone (still V2025_11_25, currently equal to latestHandshake()). It is a const so it cannot call a method; retiring it belongs with the modern-era work.

Testing

make cs and make phpstan clean; 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 on InitializeHandlerTest.

Follow-ups this unblocks

Version-gated serialization is the next prerequisite: resultType on 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

@chr-hertel
chr-hertel force-pushed the feature/protocol-version-negotiation branch from 88c4b7f to a90be81 Compare July 27, 2026 11:02
@chr-hertel
chr-hertel requested a review from Copilot July 27, 2026 11:03
@chr-hertel chr-hertel changed the title Implement protocol version negotiation [Client][Schema][Server] Implement protocol version negotiation Jul 27, 2026
@chr-hertel chr-hertel added Server Issues & PRs related to the Server component Client Issues & PRs related to the Client component Schema Issues & PRs related to the Schema component improves spec compliance Improves consistency with other SDKs such as TyepScript labels Jul 27, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ProtocolVersion registry with ordered subsets (handshakeVersions() / modernVersions()), comparisons by declaration order, and a new known revision 2026-07-28.
  • Fix server-side initialize negotiation 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.

Comment thread src/Server/Handler/Request/InitializeHandler.php
Comment thread tests/Unit/Schema/Enum/ProtocolVersionTest.php Outdated
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
chr-hertel force-pushed the feature/protocol-version-negotiation branch from 1a6023e to 6042d3d Compare July 27, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Client Issues & PRs related to the Client component improves spec compliance Improves consistency with other SDKs such as TyepScript Schema Issues & PRs related to the Schema component Server Issues & PRs related to the Server component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants