[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
1 changed files with 824 additions and 26 deletions
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue