* [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
205 lines
8.3 KiB
PHP
205 lines
8.3 KiB
PHP
<?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\Server\Handler\Request;
|
|
|
|
use Mcp\Schema\Enum\ProtocolVersion;
|
|
use Mcp\Schema\Implementation;
|
|
use Mcp\Schema\JsonRpc\MessageInterface;
|
|
use Mcp\Schema\Request\InitializeRequest;
|
|
use Mcp\Schema\Result\InitializeResult;
|
|
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;
|
|
|
|
class InitializeHandlerTest extends TestCase
|
|
{
|
|
#[TestDox('uses configuration protocol version when provided')]
|
|
public function testHandleUsesConfigurationProtocolVersion(): void
|
|
{
|
|
$customProtocolVersion = ProtocolVersion::V2024_11_05;
|
|
|
|
$configuration = new Configuration(
|
|
serverInfo: new Implementation('server', '1.2.3'),
|
|
capabilities: new ServerCapabilities(),
|
|
protocolVersion: $customProtocolVersion,
|
|
);
|
|
|
|
$handler = new InitializeHandler($configuration);
|
|
|
|
$session = $this->createMock(SessionInterface::class);
|
|
$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 = $this->createInitializeRequest(ProtocolVersion::V2024_11_05->value);
|
|
|
|
$response = $handler->handle($request, $session);
|
|
|
|
$this->assertInstanceOf(InitializeResult::class, $response->result);
|
|
|
|
/** @var InitializeResult $result */
|
|
$result = $response->result;
|
|
|
|
$this->assertSame($customProtocolVersion, $result->protocolVersion);
|
|
$this->assertSame(
|
|
$customProtocolVersion->value,
|
|
$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',
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
}
|