php-sdk/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php
Christopher Hertel eb20453f49
[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
2026-08-15 01:41:19 +02:00

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',
],
],
]);
}
}