diff --git a/docs/client.md b/docs/client.md index 494edf1f..1c740849 100644 --- a/docs/client.md +++ b/docs/client.md @@ -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,15 @@ $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. + +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: diff --git a/docs/server-builder.md b/docs/server-builder.md index 5016526a..7403ef30 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -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,71 @@ $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`. 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 +727,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 | diff --git a/docs/transports.md b/docs/transports.md index 049ca2d6..0cf51835 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -231,6 +231,15 @@ 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_NEGOTIATED_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. + ### Request Body Size Limit `StreamableHttpTransport` caps the POST body it reads to guard against memory exhaustion from an oversized or diff --git a/src/Client.php b/src/Client.php index dd290698..988a9743 100644 --- a/src/Client.php +++ b/src/Client.php @@ -18,6 +18,7 @@ use Mcp\Exception\ConnectionException; use Mcp\Exception\RequestException; use Mcp\Schema\Enum\LoggingLevel; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; @@ -111,6 +112,18 @@ public function getInstructions(): ?string 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. */ diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 75648456..622e2f19 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -18,6 +18,7 @@ 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,15 @@ public function connect(TransportInterface $transport, Configuration $config): v */ public function initialize(Configuration $config): Response|Error { + // `initialize` only exists in the handshake era, so a client configured with + // a modern revision still opens with the newest handshake one. Reaching a + // modern revision is a separate negotiation, not this handshake. + $offered = $config->protocolVersion->isModern() + ? ProtocolVersion::latestHandshake() + : $config->protocolVersion; + $request = new InitializeRequest( - $config->protocolVersion->value, + $offered->value, $config->capabilities, $config->clientInfo, ); @@ -108,6 +116,26 @@ public function initialize(Configuration $config): Response|Error if ($response instanceof Response) { $initResult = InitializeResult::fromArray($response->result); + + // The server either echoes the requested version or counter-offers one + // of its own. A counter-offer this SDK cannot speak leaves nothing to + // fall back to, so the handshake fails instead of continuing on a + // revision neither side agrees on. + $negotiated = $initResult->protocolVersion; + if (null === $negotiated || $negotiated->isModern()) { + $counterOffer = $response->result['protocolVersion'] ?? null; + + return Error::forInvalidParams(\sprintf( + 'Server responded with unsupported protocol version "%s". Supported versions: %s.', + \is_string($counterOffer) ? $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 +144,7 @@ public function initialize(Configuration $config): Response|Error $this->logger->info('Initialization complete', [ 'server' => $initResult->serverInfo->name, + 'protocolVersion' => $negotiated->value, ]); } diff --git a/src/Client/State/ClientState.php b/src/Client/State/ClientState.php index b3e6ba8f..a1e1f896 100644 --- a/src/Client/State/ClientState.php +++ b/src/Client/State/ClientState.php @@ -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 @@ public function isInitialized(): bool 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; diff --git a/src/Client/State/ClientStateInterface.php b/src/Client/State/ClientStateInterface.php index 5be054dc..a5de42d9 100644 --- a/src/Client/State/ClientStateInterface.php +++ b/src/Client/State/ClientStateInterface.php @@ -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 @@ public function setInitialized(bool $initialized): void; */ 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. */ diff --git a/src/Schema/Enum/ProtocolVersion.php b/src/Schema/Enum/ProtocolVersion.php index b580e709..fe5d560f 100644 --- a/src/Schema/Enum/ProtocolVersion.php +++ b/src/Schema/Enum/ProtocolVersion.php @@ -11,8 +11,30 @@ 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 happen to be ISO dates today, but they are an enumerated + * set rather than an ordered scalar: future identifiers are not guaranteed to be + * date-shaped, and an unrecognized peer string must compare conservatively + * instead of accidentally sorting above a known revision. + * + * 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`. + * + * The two lists are deliberately kept apart so that adding a revision to one era + * can never leak a version string into the other era's negotiation. + * + * @see https://modelcontextprotocol.io/specification/draft/basic/versioning * * @author Illia Vasylevskyi */ @@ -22,4 +44,83 @@ enum ProtocolVersion: string 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_NEGOTIATED_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 + */ + 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 + */ + 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)); + } } diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index f7c7eac6..4461ae41 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -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 @@ public function handle(Request $request, SessionInterface $session): Response $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 @@ public function handle(Request $request, SessionInterface $session): Response $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 + */ + private function supportedVersions(): array + { + $configured = $this->configuration?->protocolVersion; + + if (null !== $configured && !$configured->isModern()) { + return [$configured]; + } + + return ProtocolVersion::handshakeVersions(); + } } diff --git a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php index 6d60e1ab..45688dad 100644 --- a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php @@ -48,7 +48,7 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface private readonly array $supportedVersions; /** - * @param list|null $supportedVersions Versions the server accepts. Defaults to all values of {@see ProtocolVersion}. + * @param list|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()} — the modern revisions are excluded because this server cannot serve their per-request negotiation 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 +57,7 @@ public function __construct( ?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 +70,10 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface // 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_NEGOTIATED_VERSION->value : $headerValue; if (\in_array($version, $this->supportedVersions, true)) { return $handler->handle($request); diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php new file mode 100644 index 00000000..26bc6af3 --- /dev/null +++ b/tests/Unit/Client/ProtocolTest.php @@ -0,0 +1,175 @@ +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')] + public function testDoesNotOfferModernVersionOverHandshake(): void + { + $transport = new RecordingTransport(ProtocolVersion::latestHandshake()->value); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $protocol->initialize($config); + + $this->assertSame(ProtocolVersion::latestHandshake()->value, $transport->offeredVersion); + } + + #[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 + */ + 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 + { + } +} diff --git a/tests/Unit/Schema/Enum/ProtocolVersionTest.php b/tests/Unit/Schema/Enum/ProtocolVersionTest.php new file mode 100644 index 00000000..7adebc88 --- /dev/null +++ b/tests/Unit/Schema/Enum/ProtocolVersionTest.php @@ -0,0 +1,111 @@ + $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(); + + // Concatenating the two eras back into the full case list proves the split + // is a partition: every revision appears, in order, and none appears twice. + $this->assertSame(ProtocolVersion::cases(), [...$handshake, ...$modern]); + } + + #[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 + { + // The counter-offer a server makes when it cannot honour the requested + // version, so this must never drift up into a revision the handshake + // cannot serve. + $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 + */ + 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 testDefaultNegotiatedVersion(): void + { + $this->assertSame(ProtocolVersion::V2025_03_26, ProtocolVersion::DEFAULT_NEGOTIATED_VERSION); + } +} diff --git a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php index c56eac4d..06e4b666 100644 --- a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php @@ -20,6 +20,7 @@ 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 @@ public function testHandleUsesConfigurationProtocolVersion(): void $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,147 @@ public function testHandleUsesConfigurationProtocolVersion(): void $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 ever resolve to a modern revision, whatever the client sent: + // that era has no `initialize` at all, so answering with one would leave + // the connection 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 + */ + public static function provideNegotiationTable(): iterable + { + $counterOffer = ProtocolVersion::latestHandshake(); + + // A revision the server supports is echoed back unchanged. Driven off the + // enum rather than a literal list, so a new handshake revision is covered + // the moment it is declared. + foreach (ProtocolVersion::handshakeVersions() as $version) { + yield \sprintf('supported %s -> echoed back', $version->value) => [$version->value, $version]; + } + + // An unknown or malformed revision draws a counter-offer instead of an error. + 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]; + + // A modern revision is known to the SDK but unreachable through `initialize`, + // so it counter-offers rather than echoing what the client asked for. + 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 + { + // $configuration is nullable and defaults to null, but nothing else in the + // suite builds the handler that way, so the fallbacks were never exercised. + // 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', + ], + ], + ]); + } } diff --git a/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php index ad216d56..143b4320 100644 --- a/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php +++ b/tests/Unit/Server/Transport/Http/Middleware/ProtocolVersionMiddlewareTest.php @@ -44,12 +44,12 @@ public function testMissingHeaderRejectedByStrictServer(): void $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 @@ public function testAcceptsSupportedVersions(): void } } + #[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 {