From 11a8bbc6941110166cb49da21c18cb1dfd5ef24b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bullorini?= Date: Wed, 11 Nov 2026 11:27:14 -0300 Subject: [PATCH] [Docs][Server] Document and test that `initialize` negotiates from the body, not the version header The MCP-Protocol-Version header sent with `initialize` plays no part in negotiation and is not compared with `params.protocolVersion`. Closes #309 --- docs/run/http.md | 21 +++++--- .../Middleware/ProtocolVersionMiddleware.php | 4 +- .../Transport/StreamableHttpTransportTest.php | 54 +++++++++++++++++++ 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/docs/run/http.md b/docs/run/http.md index 41b7ca446..72838067d 100644 --- a/docs/run/http.md +++ b/docs/run/http.md @@ -168,8 +168,8 @@ A reverse proxy validating `Host` alone does not replace Origin validation. ## Protocol Version Validation `ProtocolVersionMiddleware` rejects requests whose `MCP-Protocol-Version` header is not in the SDK's supported -set with `400 Bad Request`. Requests without the header pass through, since the `initialize` round-trip and some -legacy clients do not send it. +set with `400 Bad Request`. Requests without the header pass through, since a client need not send it on +`initialize` and some legacy clients never send it. The transport applies it to handshake-era traffic only, always with the default set, and also when you replace the default stack. `StreamableHttpTransport::handshakeMiddleware()` returns it. Don't add it to a custom @@ -180,14 +180,21 @@ The default set is `ProtocolVersion::handshakeVersions()` — every revision the `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 versions](../protocol-versions.md#negotiating-in-the-handshake-era) for how the revision is agreed in the -first place. The check is also unaffected by `setProtocolVersion()`, so a server that pins the handshake still -accepts every handshake revision in this header. +On `initialize`, the body is authoritative: the server negotiates from `params.protocolVersion` as described in +[Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era), and a pin set with +`setProtocolVersion()` still wins over it (see [Pinning a revision](../protocol-versions.md#pinning-a-revision)). The +header plays no part in negotiation and is not compared with `params.protocolVersion`. A client re-initializing may +still send the revision it negotiated on an earlier connection in the header while its body proposes another, so the +two are allowed to differ. + +The header check is unaffected by `setProtocolVersion()`, so a server that pins the handshake still accepts every +handshake revision in this header. Modern revisions never reach it. A `2026-07-28` request declares its revision in `params._meta` and repeats it in the `MCP-Protocol-Version` header. The server answers a missing or different header with `400` and error code -`-32020`. See [Serving both eras](protocol-eras.md). +`-32020`. A request whose header names a modern revision but whose body lacks the `params._meta` envelope, such as a +handshake-era `initialize`, is refused by the transport with `400` and error code `-32602`. See +[Serving both eras](protocol-eras.md). ## Request Body Size Limit diff --git a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php index e4ec0159d..c6b08b470 100644 --- a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php @@ -32,8 +32,8 @@ * values with `400 Bad Request`. * * When the header is absent the middleware passes the request through — - * the `initialize` round-trip does not carry the header, and legacy clients - * that omit it are tolerated. + * clients need not send it on `initialize`, 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. diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 978552a0e..59b31cf9e 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Server\Transport; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\Notification\ProgressNotification; use Mcp\Schema\Request\PingRequest; @@ -807,6 +808,43 @@ public function testPostWithoutRequestDoesNotDrainTheOutgoingQueue(string $body) $this->assertSame($queued, $session->get('_mcp.outgoing_queue')); } + /** + * @return iterable + */ + public static function provideInitializeVersionHeaders(): iterable + { + foreach (['without sessions' => false, 'with sessions' => true] as $mode => $withSession) { + yield 'an older header, '.$mode => [ProtocolVersion::V2025_11_25->value, ProtocolVersion::V2025_03_26->value, $withSession]; + yield 'a newer header, '.$mode => [ProtocolVersion::V2025_03_26->value, ProtocolVersion::V2025_11_25->value, $withSession]; + yield 'no header, '.$mode => [ProtocolVersion::V2025_11_25->value, null, $withSession]; + } + } + + #[DataProvider('provideInitializeVersionHeaders')] + #[TestDox('initialize negotiates the body version given $_dataName')] + public function testInitializeNegotiatesTheBodyVersion(string $bodyVersion, ?string $headerVersion, bool $withSession): void + { + $store = new InMemorySessionStore(); + $builder = Server::builder()->setServerInfo('test', '1.0'); + + if ($withSession) { + $builder->setSession($store); + } + + $response = $builder->build()->run(new StreamableHttpTransport($this->initializeRequest($bodyVersion, $headerVersion), $this->factory, $this->factory)); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame($bodyVersion, json_decode((string) $response->getBody(), true)['result']['protocolVersion'] ?? null); + + if ($withSession) { + $sessionId = $response->getHeaderLine(StreamableHttpTransport::SESSION_HEADER); + $this->assertNotSame('', $sessionId); + + $session = new Session($store, Uuid::fromString($sessionId)); + $this->assertSame($bodyVersion, $session->get('protocol_version')); + } + } + /** * Sends one POST to a fresh server sharing $store, like a PHP worker would. */ @@ -882,6 +920,22 @@ private function initialize(Server $server): string return $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory))->getHeaderLine(StreamableHttpTransport::SESSION_HEADER); } + private function initializeRequest(string $bodyVersion, ?string $headerVersion): ServerRequestInterface + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Content-Type', 'application/json') + ->withHeader('Accept', 'application/json, text/event-stream') + ->withBody($this->factory->createStream(\sprintf('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"%s","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}', $bodyVersion))); + + if (null === $headerVersion) { + return $request; + } + + return $request->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, $headerVersion); + } + private function delete(Server $server, string $sessionId): ResponseInterface { $request = $this->factory