[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:
parent
fc456125e2
commit
eb20453f49
3 changed files with 824 additions and 26 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue