[Client][Schema][Server] Implement protocol version negotiation (#403)

* [Client][Docs][Schema][Server] Implement protocol version negotiation

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.

* [Client][Docs][Schema][Server] Warn when the configured protocol version cannot be negotiated

* Log a warning instead of silently falling back when a modern revision is
  configured on the client or pinned on the server builder.
* Document that setProtocolVersion() does not restrict the MCP-Protocol-Version
  header check, which validates against a fixed set.
* Pin every revision's era in a test, so a new case declared past the modern
  boundary fails instead of dropping out of the handshake set.
* Add CHANGELOG entry.

* [Client][Docs][Schema][Server] Rename version fallback constant and trim comments
This commit is contained in:
Christopher Hertel 2026-08-15 01:41:19 +02:00 • committed by GitHub
commit eb20453f49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 824 additions and 26 deletions

View file

@ -77,7 +77,7 @@ $client = Client::builder()
### Protocol Version
Specify the MCP protocol version (defaults to latest):
Specify the MCP protocol version to offer during the handshake (defaults to the latest):
```php
use Mcp\Schema\Enum\ProtocolVersion;
@ -87,6 +87,19 @@ $client = Client::builder()
->build();
```
This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as
described in the specification's
[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation)
section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK
cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed
on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated.
Modern revisions such as `2026-07-28` replaced `initialize` with per-request metadata, so they cannot be offered here.
Configuring one still opens the handshake with `ProtocolVersion::latestHandshake()`, and the client logs a warning
saying so.
See [Protocol Version Negotiation](server-builder.md#protocol-version-negotiation) for the server side of the exchange.
### Capabilities
Declare client capabilities to enable server features:

View file

@ -8,6 +8,7 @@ various aspects of the server behavior.
- [Basic Usage](#basic-usage)
- [Server Configuration](#server-configuration)
- [Protocol Version Negotiation](#protocol-version-negotiation)
- [Discovery Configuration](#discovery-configuration)
- [Session Management](#session-management)
- [Manual Capability Registration](#manual-capability-registration)
@ -90,6 +91,73 @@ $server = Server::builder()
->setInstructions('This calculator supports basic arithmetic operations. Use the calculate tool for math operations and check the config resource for current settings.');
```
### Protocol Version
By default the server negotiates the protocol revision with each client during the `initialize` handshake, and you do
not need to configure anything. See [Protocol Version Negotiation](#protocol-version-negotiation) below for how that
negotiation resolves, and for what `setProtocolVersion()` changes:
```php
use Mcp\Schema\Enum\ProtocolVersion;
$server = Server::builder()
->setProtocolVersion(ProtocolVersion::V2025_06_18);
```
## Protocol Version Negotiation
MCP revisions are identified by a date string such as `2025-11-25`. The client names the revision it wants to speak in
its `initialize` request, and the server answers with the revision the connection will actually use. Both sides
disconnect if they cannot agree. This follows the
[protocol version negotiation](https://modelcontextprotocol.io/specification/draft/basic/versioning#protocol-version-negotiation)
section of the specification.
The SDK's known revisions live in `Mcp\Schema\Enum\ProtocolVersion`, declared oldest to newest:
```php
use Mcp\Schema\Enum\ProtocolVersion;
ProtocolVersion::latestHandshake(); // newest revision reachable via `initialize`
ProtocolVersion::handshakeVersions(); // every revision the server will negotiate, oldest first
ProtocolVersion::V2025_11_25->isAtLeast(ProtocolVersion::V2025_06_18); // true
```
Comparisons go through declaration order rather than string collation. The identifiers happen to be ISO dates today,
but they are an enumerated set rather than an ordered scalar, so nothing should assume they sort chronologically.
### How the server answers
| Client requests | Server responds with |
| --- | --- |
| A revision the server supports | That same revision |
| An unknown or malformed revision | `ProtocolVersion::latestHandshake()` as a counter-offer |
| A modern revision such as `2026-07-28` | `ProtocolVersion::latestHandshake()` as a counter-offer |
A counter-offer is not an error: the client decides whether it can continue on the offered revision or must close the
connection. The negotiated revision is stored on the session under `protocol_version`.
The last row is not a rejection of an unknown revision — the SDK knows `2026-07-28`, it just cannot be reached through
this handshake. The modern era replaced `initialize` with per-request metadata, so answering with one of its revisions
would leave a connection neither side could use. Serving that era is separate work; today the server only knows not to
mis-negotiate it.
This table is mirrored by the `provideNegotiationTable()` data provider in
`tests/Unit/Server/Handler/Request/InitializeHandlerTest.php`, which drives its supported-revision rows off the enum so
a newly declared revision is covered automatically.
### Pinning a revision
`setProtocolVersion()` pins the handshake to exactly one revision instead of negotiating across the supported set. The
pin wins over the client's request, so a client asking for anything else receives the pinned revision as a
counter-offer and has to decide whether to continue. Leave it unset unless you have a reason to refuse other revisions.
> [!NOTE]
> On the Streamable HTTP transport, every request after the handshake also carries an `MCP-Protocol-Version` header,
> which is validated separately by `ProtocolVersionMiddleware`. The pin does not reach that check: the transport
> builds the middleware without access to the server configuration, so the header keeps being accepted for every
> revision in `ProtocolVersion::handshakeVersions()`. To narrow it too, construct the middleware yourself with the
> same revision — see [Protocol Version Validation](transports.md#protocol-version-validation).
## Discovery Configuration
**Required when using MCP attributes.** If you're using PHP attributes (`#[McpTool]`, `#[McpResource]`, `#[McpResourceTemplate]`, `#[McpPrompt]`) to define your MCP elements, you **MUST** configure discovery to tell the server where to look for these attributes.
@ -661,6 +729,7 @@ $server = Server::builder()
| `setServerInfo()` | name, version, description? | Set server identity |
| `setPaginationLimit()` | limit | Set max items per page |
| `setInstructions()` | instructions | Set usage instructions |
| `setProtocolVersion()` | protocolVersion | Pin the handshake to one protocol revision |
| `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery |
| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management |
| `setLogger()` | logger | Set PSR-3 logger |

View file

@ -231,6 +231,17 @@ use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]);
```
The default set is `ProtocolVersion::handshakeVersions()` — every revision the server can actually negotiate over
`initialize`, rather than every revision the enum declares. A request without the header is treated as
`ProtocolVersion::DEFAULT_HEADER_VERSION` (`2025-03-26`), the revision that introduced both Streamable HTTP and the
header itself, so a header-less request cannot be newer than that.
This header check is separate from, and happens after, the handshake itself. See
[Protocol Version Negotiation](server-builder.md#protocol-version-negotiation) for how the revision is agreed in the
first place. Being separate also means it is unaffected by `setProtocolVersion()`: the middleware validates against
the set it was constructed with, not against the revision a given session negotiated, so a server that pins the
handshake has to pass that revision here as well.
### Request Body Size Limit
`StreamableHttpTransport` caps the POST body it reads to guard against memory exhaustion from an oversized or