[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
16 changed files with 824 additions and 26 deletions

View file

@ -10,6 +10,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`.
* Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`.
* Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`.
* Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set.
0.7.0
-----

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

View file

@ -19,6 +19,7 @@ use Mcp\Exception\ConnectionException;
use Mcp\Exception\RequestException;
use Mcp\Exception\RuntimeException;
use Mcp\Schema\Enum\LoggingLevel;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Request;
@ -113,6 +114,18 @@ class Client
return $this->protocol->getState()->getInstructions();
}
/**
* Protocol revision negotiated during the handshake.
*
* This is the version the server answered with, which is not necessarily the
* one configured on the builder: a server that cannot speak the requested
* revision counter-offers one it supports. Null until the handshake completed.
*/
public function getProtocolVersion(): ?ProtocolVersion
{
return $this->protocol->getState()->getProtocolVersion();
}
/**
* Send a ping request to the server.
*/

View file

@ -18,6 +18,7 @@ use Mcp\Client\State\ClientState;
use Mcp\Client\State\ClientStateInterface;
use Mcp\Client\Transport\TransportInterface;
use Mcp\JsonRpc\MessageFactory;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Notification;
use Mcp\Schema\JsonRpc\Request;
@ -98,8 +99,20 @@ class Protocol
*/
public function initialize(Configuration $config): Response|Error
{
$offered = $config->protocolVersion;
if ($offered->isModern()) {
// Only handshake era spec versions need the initialize call, so if we
// end up here, we fall back to the latest handshake version.
$offered = ProtocolVersion::latestHandshake();
$this->logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, offering the newest handshake revision instead.', [
'configured' => $config->protocolVersion->value,
'offered' => $offered->value,
]);
}
$request = new InitializeRequest(
$config->protocolVersion->value,
$offered->value,
$config->capabilities,
$config->clientInfo,
);
@ -108,6 +121,26 @@ class Protocol
if ($response instanceof Response) {
$initResult = InitializeResult::fromArray($response->result);
// A counter-offer this SDK cannot speak leaves nothing to fall back to,
// so the handshake fails rather than continuing on a revision neither
// side agrees on.
$negotiated = $initResult->protocolVersion;
if (null === $negotiated || $negotiated->isModern()) {
// fromArray() above already rejected a missing or non-string revision.
$counterOffer = (string) $response->result['protocolVersion'];
return Error::forInvalidParams(\sprintf(
'Server responded with unsupported protocol version "%s". Supported versions: %s.',
$counterOffer,
implode(', ', array_map(
static fn (ProtocolVersion $v): string => $v->value,
ProtocolVersion::handshakeVersions(),
)),
), $response->id);
}
$this->state->setProtocolVersion($negotiated);
$this->state->setServerInfo($initResult->serverInfo);
$this->state->setInstructions($initResult->instructions);
$this->state->setInitialized(true);
@ -116,6 +149,7 @@ class Protocol
$this->logger->info('Initialization complete', [
'server' => $initResult->serverInfo->name,
'protocolVersion' => $negotiated->value,
]);
}

View file

@ -11,6 +11,7 @@
namespace Mcp\Client\State;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Response;
@ -28,6 +29,7 @@ class ClientState implements ClientStateInterface
{
private int $requestIdCounter = 1;
private bool $initialized = false;
private ?ProtocolVersion $protocolVersion = null;
private ?Implementation $serverInfo = null;
private ?string $instructions = null;
@ -96,6 +98,16 @@ class ClientState implements ClientStateInterface
return $this->initialized;
}
public function setProtocolVersion(ProtocolVersion $protocolVersion): void
{
$this->protocolVersion = $protocolVersion;
}
public function getProtocolVersion(): ?ProtocolVersion
{
return $this->protocolVersion;
}
public function setServerInfo(Implementation $serverInfo): void
{
$this->serverInfo = $serverInfo;

View file

@ -11,6 +11,7 @@
namespace Mcp\Client\State;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Response;
@ -76,6 +77,18 @@ interface ClientStateInterface
*/
public function isInitialized(): bool;
/**
* Store the protocol version negotiated during initialization.
*/
public function setProtocolVersion(ProtocolVersion $protocolVersion): void;
/**
* Get the protocol version negotiated during initialization.
*
* Null until the handshake has completed.
*/
public function getProtocolVersion(): ?ProtocolVersion;
/**
* Store the server info from initialization.
*/

View file

@ -11,15 +11,115 @@
namespace Mcp\Schema\Enum;
use Mcp\Exception\LogicException;
/**
* Available protocol versions for MCP.
* Registry of the MCP protocol revisions this SDK knows about.
*
* Cases are declared oldest to newest, and that declaration order — not the
* lexicographic order of the values — is what every comparison here relies on:
* revision identifiers are an enumerated set, not an ordered scalar, and a
* future one is not guaranteed to be date-shaped.
*
* The revisions split into two eras:
*
* - **handshake** (`2025-11-25` and earlier) negotiate a version through the
* `initialize` round-trip and keep session state for the connection;
* - **modern** ({@see self::FIRST_MODERN_VERSION} and later) drop `initialize`
* entirely — every request carries its own version in `_meta`, and servers
* advertise what they speak through `server/discover`.
*
* @see https://modelcontextprotocol.io/specification/draft/basic/versioning
*
* @author Illia Vasylevskyi<ineersa@gmail.com>
*/
enum ProtocolVersion: string
{
// Declaration order is also the era boundary: everything declared from
// FIRST_MODERN_VERSION onwards is modern. A new handshake-era revision has
// to be inserted above that case — appending it here would silently drop it
// out of the handshake negotiation.
case V2024_11_05 = '2024-11-05';
case V2025_03_26 = '2025-03-26';
case V2025_06_18 = '2025-06-18';
case V2025_11_25 = '2025-11-25';
case V2026_07_28 = '2026-07-28';
/**
* First revision of the modern era, in which the `initialize` handshake was
* replaced by per-request metadata.
*/
public const FIRST_MODERN_VERSION = self::V2026_07_28;
/**
* Version a server assumes when a client omits the `MCP-Protocol-Version`
* header on the Streamable HTTP transport.
*
* This is the revision that introduced both Streamable HTTP and the header
* itself, so a request without the header cannot be newer than this.
*/
public const DEFAULT_HEADER_VERSION = self::V2025_03_26;
/**
* Newest revision reachable through the `initialize` handshake.
*
* This is what a server counter-offers when it cannot honour the version a
* client asked for, and what a handshake-era client offers by default.
*/
public static function latestHandshake(): self
{
$versions = self::handshakeVersions();
return $versions[\count($versions) - 1];
}
/**
* Revisions reachable through the `initialize` handshake, oldest to newest.
*
* @return non-empty-list<self>
*/
public static function handshakeVersions(): array
{
return array_values(array_filter(self::cases(), static fn (self $v): bool => !$v->isModern()));
}
/**
* Revisions using the per-request metadata envelope, oldest to newest.
*
* @return non-empty-list<self>
*/
public static function modernVersions(): array
{
return array_values(array_filter(self::cases(), static fn (self $v): bool => $v->isModern()));
}
/**
* Whether this revision belongs to the modern, per-request-metadata era.
*/
public function isModern(): bool
{
return $this->isAtLeast(self::FIRST_MODERN_VERSION);
}
/**
* Whether this revision is at least as new as $minimum.
*/
public function isAtLeast(self $minimum): bool
{
return $this->position() >= $minimum->position();
}
/**
* Index of this revision in the chronological declaration order.
*/
private function position(): int
{
foreach (self::cases() as $index => $case) {
if ($case === $this) {
return $index;
}
}
throw new LogicException(\sprintf('Protocol version "%s" is not a declared case.', $this->value));
}
}

View file

@ -717,6 +717,13 @@ final class Builder
$capabilities = $capabilities->withExtensions($this->extensions);
}
if (null !== $this->protocolVersion && $this->protocolVersion->isModern()) {
$logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, negotiating the handshake revisions instead.', [
'configured' => $this->protocolVersion->value,
'negotiable' => array_map(static fn (ProtocolVersion $v): string => $v->value, ProtocolVersion::handshakeVersions()),
]);
}
$serverInfo = $this->serverInfo ?? new Implementation();
$configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion);
$referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container);

View file

@ -11,6 +11,7 @@
namespace Mcp\Server\Handler\Request;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
@ -47,6 +48,9 @@ final class InitializeHandler implements RequestHandlerInterface
$session->set('client_info', $request->clientInfo->jsonSerialize());
$session->set('client_capabilities', $request->capabilities->jsonSerialize());
$negotiated = $this->negotiate($request->protocolVersion);
$session->set('protocol_version', $negotiated->value);
return new Response(
$request->getId(),
new InitializeResult(
@ -54,8 +58,49 @@ final class InitializeHandler implements RequestHandlerInterface
$this->configuration->serverInfo ?? new Implementation(),
$this->configuration?->instructions,
null,
$this->configuration?->protocolVersion,
$negotiated,
),
);
}
/**
* Picks the protocol version to answer an `initialize` handshake with.
*
* If the client asked for a version this server supports, the spec requires
* responding with that exact version. Otherwise the server counter-offers
* the newest version it does support and leaves it to the client to decide
* whether it can continue on that revision or must disconnect.
*/
private function negotiate(string $requested): ProtocolVersion
{
$supported = $this->supportedVersions();
$version = ProtocolVersion::tryFrom($requested);
if (null !== $version && \in_array($version, $supported, true)) {
return $version;
}
return $supported[\count($supported) - 1];
}
/**
* Versions this server is willing to negotiate over `initialize`.
*
* A version configured on the server pins the handshake to exactly that
* revision. Modern revisions are never offered here: they have no
* `initialize` at all, so a client that reached this handler cannot speak
* one, and answering with it would leave the connection unusable.
*
* @return non-empty-list<ProtocolVersion>
*/
private function supportedVersions(): array
{
$configured = $this->configuration?->protocolVersion;
if (null !== $configured && !$configured->isModern()) {
return [$configured];
}
return ProtocolVersion::handshakeVersions();
}
}

View file

@ -35,6 +35,9 @@ use Psr\Http\Server\RequestHandlerInterface;
* the `initialize` round-trip does not carry the header, and legacy clients
* that omit it are tolerated.
*
* Validation is against a fixed set, not against the version a particular session
* negotiated: PSR-15 middleware runs before the transport resolves the session.
*
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
*
* @author Volodymyr Panivko <sveneld300@gmail.com>
@ -48,7 +51,7 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface
private readonly array $supportedVersions;
/**
* @param list<ProtocolVersion>|null $supportedVersions Versions the server accepts. Defaults to all values of {@see ProtocolVersion}.
* @param list<ProtocolVersion>|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()}; modern revisions are excluded as their per-request negotiation is not served yet.
* @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null)
* @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null)
*/
@ -57,7 +60,7 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface
?ResponseFactoryInterface $responseFactory = null,
?StreamFactoryInterface $streamFactory = null,
) {
$versions = $supportedVersions ?? ProtocolVersion::cases();
$versions = $supportedVersions ?? ProtocolVersion::handshakeVersions();
$this->supportedVersions = array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $versions));
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
@ -70,10 +73,10 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface
// Spec backwards-compat: when the header is absent, the server SHOULD assume
// protocol version 2025-03-26 — the release in which Streamable HTTP and the
// header itself were introduced. This is deliberately lower than the SDK's
// own default (V2025_06_18) so clients predating the header convention still
// get a deterministic protocol version applied. Servers that whitelist only
// newer versions in $supportedVersions will reject such requests with 400.
$version = '' === $headerValue ? ProtocolVersion::V2025_03_26->value : $headerValue;
// own default so clients predating the header convention still get a
// deterministic protocol version applied. Servers that whitelist only newer
// versions in $supportedVersions will reject such requests with 400.
$version = '' === $headerValue ? ProtocolVersion::DEFAULT_HEADER_VERSION->value : $headerValue;
if (\in_array($version, $this->supportedVersions, true)) {
return $handler->handle($request);

View file

@ -0,0 +1,202 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Tests\Unit\Client;
use Mcp\Client\Configuration;
use Mcp\Client\Protocol;
use Mcp\Client\State\ClientStateInterface;
use Mcp\Client\Transport\TransportInterface;
use Mcp\Exception\LogicException;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\MessageInterface;
use Mcp\Schema\JsonRpc\Response;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Psr\Log\AbstractLogger;
use Psr\Log\LogLevel;
final class ProtocolTest extends TestCase
{
#[TestDox('offers the configured protocol version in the handshake')]
public function testOffersConfiguredVersion(): void
{
$transport = new RecordingTransport(ProtocolVersion::V2025_06_18->value);
$protocol = new Protocol();
$protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_06_18));
$protocol->initialize($config);
$this->assertSame(ProtocolVersion::V2025_06_18->value, $transport->offeredVersion);
}
#[TestDox('never offers a modern version over the initialize handshake, and warns about it')]
public function testDoesNotOfferModernVersionOverHandshake(): void
{
$transport = new RecordingTransport(ProtocolVersion::latestHandshake()->value);
$protocol = new Protocol(logger: $logger = new CollectingLogger());
$protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28));
$protocol->initialize($config);
$this->assertSame(ProtocolVersion::latestHandshake()->value, $transport->offeredVersion);
$this->assertSame([[
'configured' => ProtocolVersion::V2026_07_28->value,
'offered' => ProtocolVersion::latestHandshake()->value,
]], $logger->warnings);
}
#[TestDox('accepts a counter-offer the SDK can speak and records it as negotiated')]
public function testAcceptsHandshakeCounterOffer(): void
{
$transport = new RecordingTransport(ProtocolVersion::V2024_11_05->value);
$protocol = new Protocol();
$protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_11_25));
$result = $protocol->initialize($config);
$this->assertInstanceOf(Response::class, $result);
$this->assertSame(ProtocolVersion::V2024_11_05, $protocol->getState()->getProtocolVersion());
$this->assertTrue($protocol->getState()->isInitialized());
}
#[TestDox('fails the handshake when the server answers with a version the SDK cannot speak')]
#[DataProvider('provideUnusableCounterOffers')]
public function testRejectsUnusableCounterOffer(string $counterOffer): void
{
$transport = new RecordingTransport($counterOffer);
$protocol = new Protocol();
$protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2025_11_25));
$result = $protocol->initialize($config);
$this->assertInstanceOf(Error::class, $result);
$this->assertStringContainsString($counterOffer, $result->message);
$this->assertNull($protocol->getState()->getProtocolVersion());
$this->assertFalse($protocol->getState()->isInitialized());
}
/**
* @return iterable<string, array{string}>
*/
public static function provideUnusableCounterOffers(): iterable
{
yield 'unknown revision' => ['2099-01-01'];
// The modern era has no `initialize`, so a server answering the handshake
// with one has produced a connection neither side can actually use.
yield 'modern revision' => [ProtocolVersion::V2026_07_28->value];
}
private function createConfiguration(ProtocolVersion $protocolVersion): Configuration
{
return new Configuration(
clientInfo: new Implementation('client-app', '1.0.0'),
capabilities: new ClientCapabilities(),
protocolVersion: $protocolVersion,
);
}
}
/**
* Transport that answers the `initialize` request inline with a canned
* `protocolVersion`, so the handshake resolves without a Fiber round-trip.
*/
final class RecordingTransport implements TransportInterface
{
public ?string $offeredVersion = null;
private ClientStateInterface $state;
public function __construct(private readonly string $counterOffer)
{
}
public function send(string $data): void
{
/** @var array{id: int|string, method: string, params?: array{protocolVersion?: string}} $message */
$message = json_decode($data, true);
if ('initialize' !== ($message['method'] ?? null)) {
return;
}
$this->offeredVersion = $message['params']['protocolVersion'] ?? null;
$this->state->storeResponse($message['id'], [
'jsonrpc' => MessageInterface::JSONRPC_VERSION,
'id' => $message['id'],
'result' => [
'protocolVersion' => $this->counterOffer,
'capabilities' => [],
'serverInfo' => ['name' => 'server', 'version' => '1.2.3'],
],
]);
}
public function setState(ClientStateInterface $state): void
{
$this->state = $state;
}
public function connect(): void
{
}
public function close(): void
{
}
public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error
{
throw new LogicException('Not used in these tests.');
}
public function onInitialize(callable $callback): void
{
}
public function onMessage(callable $callback): void
{
}
public function onError(callable $callback): void
{
}
public function onClose(callable $callback): void
{
}
}
/**
* Logger that keeps the context of every warning, so a silent fallback can be
* told apart from one the caller was told about.
*/
final class CollectingLogger extends AbstractLogger
{
/** @var list<array<string, mixed>> */
public array $warnings = [];
/**
* @param string|\Stringable $message
* @param array<string, mixed> $context
*/
public function log($level, $message, array $context = []): void
{
if (LogLevel::WARNING === $level) {
$this->warnings[] = $context;
}
}
}

View file

@ -0,0 +1,134 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Tests\Unit\Schema\Enum;
use Mcp\Schema\Enum\ProtocolVersion;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
class ProtocolVersionTest extends TestCase
{
#[TestDox('comparisons follow declaration order, whatever shape the values have')]
public function testDeclarationOrderIsTheOnlyOrdering(): void
{
$cases = ProtocolVersion::cases();
// Deliberately not a string sort: asserting the values collate
// chronologically would bake in the date shape the enum refuses to assume.
foreach ($cases as $index => $version) {
$this->assertTrue(
$version->isAtLeast($version),
\sprintf('%s should be at least itself.', $version->value),
);
foreach (\array_slice($cases, $index + 1) as $newer) {
$this->assertTrue(
$newer->isAtLeast($version),
\sprintf('%s is declared after %s and should compare newer.', $newer->value, $version->value),
);
$this->assertFalse(
$version->isAtLeast($newer),
\sprintf('%s should not compare newer than %s.', $version->value, $newer->value),
);
}
}
}
#[TestDox('splits the known revisions into a handshake and a modern era')]
public function testEraSplitCoversEveryCaseExactlyOnce(): void
{
$handshake = ProtocolVersion::handshakeVersions();
$modern = ProtocolVersion::modernVersions();
// A partition: every revision in exactly one era, in declaration order.
$this->assertSame(ProtocolVersion::cases(), [...$handshake, ...$modern]);
}
#[TestDox('every known revision is assigned to an era on purpose')]
public function testEveryRevisionIsClassifiedExplicitly(): void
{
// Hand-maintained on purpose: a revision appended below FIRST_MODERN_VERSION
// is classified modern by accident, and every other assertion in this file
// stays green while it vanishes from the handshake negotiation.
$eras = [
'2024-11-05' => false,
'2025-03-26' => false,
'2025-06-18' => false,
'2025-11-25' => false,
'2026-07-28' => true,
];
$this->assertSame(
array_keys($eras),
array_map(static fn (ProtocolVersion $version): string => $version->value, ProtocolVersion::cases()),
'A revision was added or removed: list it above with the era it belongs to.',
);
foreach (ProtocolVersion::cases() as $version) {
$this->assertSame(
$eras[$version->value],
$version->isModern(),
\sprintf('%s is in the wrong era; a handshake revision must be declared above %s.', $version->value, ProtocolVersion::FIRST_MODERN_VERSION->value),
);
}
}
#[TestDox('2026-07-28 opens the modern era')]
public function testModernEraStartsAt20260728(): void
{
$this->assertSame(ProtocolVersion::V2026_07_28, ProtocolVersion::FIRST_MODERN_VERSION);
$this->assertFalse(ProtocolVersion::V2025_11_25->isModern());
$this->assertTrue(ProtocolVersion::V2026_07_28->isModern());
}
#[TestDox('latestHandshake() stops short of the modern era')]
public function testLatestHandshakeStopsBeforeTheModernEra(): void
{
$this->assertSame(ProtocolVersion::V2025_11_25, ProtocolVersion::latestHandshake());
$this->assertFalse(ProtocolVersion::latestHandshake()->isModern());
}
#[TestDox('handshake versions never contain a modern revision')]
public function testHandshakeVersionsExcludeModernRevisions(): void
{
foreach (ProtocolVersion::handshakeVersions() as $version) {
$this->assertFalse($version->isModern(), \sprintf('%s leaked into the handshake era.', $version->value));
}
}
#[TestDox('compares by declaration order, not by string value')]
#[DataProvider('provideVersionComparisons')]
public function testIsAtLeast(ProtocolVersion $version, ProtocolVersion $minimum, bool $expected): void
{
$this->assertSame($expected, $version->isAtLeast($minimum));
}
/**
* @return iterable<string, array{ProtocolVersion, ProtocolVersion, bool}>
*/
public static function provideVersionComparisons(): iterable
{
yield 'equal' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18, true];
yield 'newer' => [ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_06_18, true];
yield 'older' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_03_26, false];
yield 'across eras' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2024_11_05, true];
yield 'oldest against newest' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2026_07_28, false];
}
#[TestDox('the header-absent default is the revision that introduced the header')]
public function testDefaultHeaderVersion(): void
{
$this->assertSame(ProtocolVersion::V2025_03_26, ProtocolVersion::DEFAULT_HEADER_VERSION);
}
}

View file

@ -20,6 +20,7 @@ use Mcp\Schema\ServerCapabilities;
use Mcp\Server\Configuration;
use Mcp\Server\Handler\Request\InitializeHandler;
use Mcp\Server\Session\SessionInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
@ -39,29 +40,18 @@ class InitializeHandlerTest extends TestCase
$handler = new InitializeHandler($configuration);
$session = $this->createMock(SessionInterface::class);
$session->expects($this->exactly(2))
$session->expects($this->exactly(3))
->method('set')
->willReturnCallback(function (string $key, mixed $value): void {
match ($key) {
'client_info' => $this->assertSame(['name' => 'client-app', 'version' => '1.0.0'], $value),
'client_capabilities' => $this->assertEquals(new \stdClass(), $value),
'protocol_version' => $this->assertSame(ProtocolVersion::V2024_11_05->value, $value),
default => $this->fail("Unexpected session key: {$key}"),
};
});
$request = InitializeRequest::fromArray([
'jsonrpc' => MessageInterface::JSONRPC_VERSION,
'id' => 'request-1',
'method' => InitializeRequest::getMethod(),
'params' => [
'protocolVersion' => ProtocolVersion::V2024_11_05->value,
'capabilities' => [],
'clientInfo' => [
'name' => 'client-app',
'version' => '1.0.0',
],
],
]);
$request = $this->createInitializeRequest(ProtocolVersion::V2024_11_05->value);
$response = $handler->handle($request, $session);
@ -76,4 +66,140 @@ class InitializeHandlerTest extends TestCase
$result->jsonSerialize()['protocolVersion']
);
}
#[TestDox('answers an unpinned handshake per the negotiation table')]
#[DataProvider('provideNegotiationTable')]
public function testNegotiationTable(string $requested, ProtocolVersion $expected): void
{
$handler = new InitializeHandler($this->createConfiguration());
$response = $handler->handle(
$this->createInitializeRequest($requested),
$this->createStub(SessionInterface::class),
);
\assert($response->result instanceof InitializeResult);
$this->assertNotNull($response->result->protocolVersion);
$this->assertSame($expected, $response->result->protocolVersion);
// No row may resolve to a modern revision, whatever the client sent — that
// era has no `initialize`, so the answer would be unusable for both sides.
$this->assertFalse($response->result->protocolVersion->isModern());
}
/**
* The negotiation table from docs/server-builder.md, one data set per case a
* client can present. Keeping the rows in a single provider is what stops the
* documented table and the tested behaviour from drifting apart.
*
* @return iterable<string, array{string, ProtocolVersion}>
*/
public static function provideNegotiationTable(): iterable
{
$counterOffer = ProtocolVersion::latestHandshake();
// Driven off the enum rather than a literal list, so a new revision is
// covered by the era it is declared in the moment it lands.
foreach (ProtocolVersion::handshakeVersions() as $version) {
yield \sprintf('supported %s -> echoed back', $version->value) => [$version->value, $version];
}
yield 'unknown future revision -> counter-offer' => ['2099-01-01', $counterOffer];
yield 'not a revision at all -> counter-offer' => ['banana', $counterOffer];
yield 'empty -> counter-offer' => ['', $counterOffer];
foreach (ProtocolVersion::modernVersions() as $version) {
yield \sprintf('modern %s -> counter-offer', $version->value) => [$version->value, $counterOffer];
}
}
#[TestDox('a modern version pinned in configuration cannot leak into the handshake')]
public function testModernConfiguredVersionFallsBackToHandshakeSet(): void
{
$handler = new InitializeHandler($this->createConfiguration(ProtocolVersion::V2026_07_28));
$response = $handler->handle(
$this->createInitializeRequest(ProtocolVersion::V2025_06_18->value),
$this->createStub(SessionInterface::class),
);
\assert($response->result instanceof InitializeResult);
$this->assertSame(ProtocolVersion::V2025_06_18, $response->result->protocolVersion);
}
#[TestDox('a pinned version wins over a different version requested by the client')]
public function testPinnedVersionOverridesClientRequest(): void
{
$handler = new InitializeHandler($this->createConfiguration(ProtocolVersion::V2025_03_26));
$response = $handler->handle(
$this->createInitializeRequest(ProtocolVersion::V2025_11_25->value),
$this->createStub(SessionInterface::class),
);
\assert($response->result instanceof InitializeResult);
$this->assertSame(ProtocolVersion::V2025_03_26, $response->result->protocolVersion);
}
#[TestDox('stores the negotiated version on the session')]
public function testStoresNegotiatedVersionOnSession(): void
{
$handler = new InitializeHandler($this->createConfiguration());
$stored = [];
$session = $this->createMock(SessionInterface::class);
$session->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$stored): void {
$stored[$key] = $value;
});
$handler->handle($this->createInitializeRequest(ProtocolVersion::V2025_06_18->value), $session);
$this->assertSame(ProtocolVersion::V2025_06_18->value, $stored['protocol_version'] ?? null);
}
#[TestDox('falls back to empty defaults when constructed without a configuration')]
public function testHandlesMissingConfiguration(): void
{
// The reads in handle() sit on the left of ??, which has isset semantics and
// therefore tolerates the null without a nullsafe operator. This test is what
// proves that, rather than the shape of the accessor.
$handler = new InitializeHandler();
$response = $handler->handle(
$this->createInitializeRequest(ProtocolVersion::V2025_06_18->value),
$this->createStub(SessionInterface::class),
);
\assert($response->result instanceof InitializeResult);
$this->assertEquals(new ServerCapabilities(), $response->result->capabilities);
$this->assertEquals(new Implementation(), $response->result->serverInfo);
$this->assertNull($response->result->instructions);
$this->assertSame(ProtocolVersion::V2025_06_18, $response->result->protocolVersion);
}
private function createConfiguration(?ProtocolVersion $protocolVersion = null): Configuration
{
return new Configuration(
serverInfo: new Implementation('server', '1.2.3'),
capabilities: new ServerCapabilities(),
protocolVersion: $protocolVersion,
);
}
private function createInitializeRequest(string $protocolVersion): InitializeRequest
{
return InitializeRequest::fromArray([
'jsonrpc' => MessageInterface::JSONRPC_VERSION,
'id' => 'request-1',
'method' => InitializeRequest::getMethod(),
'params' => [
'protocolVersion' => $protocolVersion,
'capabilities' => [],
'clientInfo' => [
'name' => 'client-app',
'version' => '1.0.0',
],
],
]);
}
}

View file

@ -44,12 +44,12 @@ final class ProtocolVersionMiddlewareTest extends MiddlewareTestCase
$this->assertSame(400, $response->getStatusCode());
}
#[TestDox('accepts every version declared in the ProtocolVersion enum')]
#[TestDox('accepts every handshake-era version by default')]
public function testAcceptsSupportedVersions(): void
{
$middleware = new ProtocolVersionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory);
foreach (ProtocolVersion::cases() as $version) {
foreach (ProtocolVersion::handshakeVersions() as $version) {
$request = $this->factory->createServerRequest('POST', 'http://localhost/')
->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, $version->value);
@ -59,6 +59,21 @@ final class ProtocolVersionMiddlewareTest extends MiddlewareTestCase
}
}
#[TestDox('rejects modern-era versions by default, since the server cannot serve them yet')]
public function testRejectsModernVersionsByDefault(): void
{
$middleware = new ProtocolVersionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory);
foreach (ProtocolVersion::modernVersions() as $version) {
$request = $this->factory->createServerRequest('POST', 'http://localhost/')
->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, $version->value);
$response = $middleware->process($request, $this->passthroughHandler);
$this->assertSame(400, $response->getStatusCode(), 'Expected '.$version->value.' to be rejected.');
}
}
#[TestDox('rejects unsupported well-formed version with 400')]
public function testRejectsUnsupportedVersion(): void
{