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
@chr-hertel chr-hertel added the needs review PR needs code review by maintainer label Aug 10, 2026
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 needs review PR needs code review by maintainer 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