[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
16 changed files with 824 additions and 26 deletions
|
|
@ -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
|
||||
-----
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
202
tests/Unit/Client/ProtocolTest.php
Normal file
202
tests/Unit/Client/ProtocolTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
134
tests/Unit/Schema/Enum/ProtocolVersionTest.php
Normal file
134
tests/Unit/Schema/Enum/ProtocolVersionTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue