Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions docs/run/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions tests/Unit/Server/Transport/StreamableHttpTransportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -807,6 +808,43 @@ public function testPostWithoutRequestDoesNotDrainTheOutgoingQueue(string $body)
$this->assertSame($queued, $session->get('_mcp.outgoing_queue'));
}

/**
* @return iterable<string, array{string, ?string, bool}>
*/
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.
*/
Expand Down Expand Up @@ -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
Expand Down