From b16b6e3ed650707cf42df663e19314dd61327c38 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 12 Oct 2026 22:23:43 +0200 Subject: [PATCH] [Client] Send `MCP-Protocol-Version` on every request after the handshake Co-authored-by: meraklbz --- CHANGELOG.md | 1 + src/Client/Transport/HttpTransport.php | 47 ++++++++++---- .../Client/Transport/HttpTransportTest.php | 64 +++++++++++++++++++ 3 files changed, 99 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f87bd0..ea68819d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `StreamableHttpTransport::handleFiberTermination()` takes the terminated `\Fiber` as its first argument; a subclass overriding it has to accept it too. * [BC Break] Reject a `Tool` input schema whose `properties` is not an object or whose `required` is neither a list nor `null`, instead of silently replacing the member. Reject a `completion/complete` whose `argument` is missing `name` or `value`, instead of completing against an empty prefix. * Add `HttpTransport::getSessionId()` to read the server-minted `Mcp-Session-Id`: a request-scoped caller can persist it and pass it back through the constructor's `$headers` on a later transport. Always `null` on `2026-07-28`, which removed protocol-level sessions. +* Fix `HttpTransport` omitting the `MCP-Protocol-Version` header on requests after the `initialize` handshake, including the closing `DELETE`. * Fix OIDC discovery rejecting issuers with a trailing slash (e.g. Authentik, Auth0). * Fix stateless SSE streams holding back frames until close when PHP output buffering is enabled. * Reject a recognized `Mcp-Param-*` header whose mirrored argument is absent from the body with `-32020`, instead of accepting the request (SEP-2243). diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 883a503f..d35fe339 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -19,6 +19,7 @@ use Mcp\Exception\TimeoutException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Wire\McpHeader; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; @@ -163,8 +164,8 @@ public function send(string $data): void ->withHeader('Accept', 'application/json, text/event-stream') ->withBody($this->streamFactory->createStream($data)); - if (null !== $this->sessionId) { - $request = $request->withHeader('Mcp-Session-Id', $this->sessionId); + foreach ($this->sessionHeaders() as $name => $value) { + $request = $request->withHeader($name, $value); } // Protocol-derived first, so an explicitly configured header still wins: @@ -315,10 +316,9 @@ public function close(): void { if (null !== $this->sessionId) { try { - $request = $this->requestFactory->createRequest('DELETE', $this->endpoint) - ->withHeader('Mcp-Session-Id', $this->sessionId); + $request = $this->requestFactory->createRequest('DELETE', $this->endpoint); - foreach ($this->headers as $name => $value) { + foreach ([...$this->sessionHeaders(), ...$this->headers] as $name => $value) { $request = $request->withHeader($name, $value); } @@ -335,6 +335,34 @@ public function close(): void $this->handleClose('Transport closed'); } + /** + * Headers a handshake-era connection carries on every request after `initialize`. + * + * 2026-07-28 derives its version header from each message instead, see + * {@see protocolHeaders()}. + * + * @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header + * + * @return array + */ + private function sessionHeaders(): array + { + $headers = []; + + if (null !== $this->sessionId) { + $headers['Mcp-Session-Id'] = $this->sessionId; + } + + // Not on `initialize` itself, nor on the one that replaces an expired + // session: the version is only settled by its answer. + $version = $this->state?->getProtocolVersion(); + if (null !== $version && !$version->isModern() && $this->state?->isInitialized()) { + $headers[McpHeader::PROTOCOL_VERSION] = $version->value; + } + + return $headers; + } + /** * @return array */ @@ -374,14 +402,7 @@ private function openListenStream(): void $request = $this->requestFactory->createRequest('GET', $this->endpoint) ->withHeader('Accept', 'text/event-stream'); - if (null !== $this->sessionId) { - $request = $request->withHeader('Mcp-Session-Id', $this->sessionId); - } - if (null !== $version) { - $request = $request->withHeader('MCP-Protocol-Version', $version->value); - } - - foreach ($this->headers as $name => $value) { + foreach ([...$this->sessionHeaders(), ...$this->headers] as $name => $value) { $request = $request->withHeader($name, $value); } diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index d273918f..546f985a 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -711,6 +711,61 @@ public function isCancellationRequested(): bool $client->disconnect(); } + #[TestDox('a handshake-era connection names its revision on every request after "initialize", until the session expires')] + public function testHandshakeEraRequestsCarryTheProtocolVersion(): void + { + $expired = false; + $httpClient = new RecordingHttpClient(static function (array $message) use (&$expired): ?ResponseInterface { + if ('initialize' === ($message['method'] ?? null)) { + return new Response(200, ['Content-Type' => 'application/json', 'Mcp-Session-Id' => 'session'], json_encode([ + 'jsonrpc' => '2.0', + 'id' => $message['id'], + 'result' => [ + 'protocolVersion' => ProtocolVersion::V2025_06_18->value, + 'capabilities' => ['tools' => []], + 'serverInfo' => ['name' => 'test-server', 'version' => '1'], + ], + ], \JSON_THROW_ON_ERROR)); + } + if ('gone' === ($message['params']['name'] ?? null)) { + $expired = true; + + return new Response(404); + } + + return null; + }); + $transport = new HttpTransport('http://localhost/mcp', [], $httpClient, $this->factory, $this->factory); + $client = Client::builder()->setClientInfo('test', '1')->setProtocolVersion(ProtocolVersion::V2025_11_25)->build(); + + $client->connect($transport); + try { + $client->callTool('gone'); + $this->fail('An expired session must fail the call.'); + } catch (ConnectionException) { + } + $this->assertTrue($expired); + $client->connect($transport); + $client->callTool('fast'); + $client->disconnect(); + + $sent = array_map(static function (RequestInterface $request): string { + $method = json_decode((string) $request->getBody(), true)['method'] ?? $request->getMethod(); + + return $method.' '.($request->getHeaderLine('MCP-Protocol-Version') ?: '-'); + }, $httpClient->requests); + + $this->assertSame([ + 'initialize -', + 'notifications/initialized 2025-06-18', + 'tools/call 2025-06-18', + 'initialize -', + 'notifications/initialized 2025-06-18', + 'tools/call 2025-06-18', + 'DELETE 2025-06-18', + ], $sent); + } + private function createTransport(int $maxSseBufferBytes = 8 * 1024 * 1024): HttpTransport { return new HttpTransport( @@ -747,6 +802,9 @@ final class RecordingHttpClient implements ClientInterface /** @var list> */ public array $messages = []; + /** @var list */ + public array $requests = []; + /** * The hook sees every request first and may return its own answer; a null * return falls through to the default one. @@ -759,6 +817,12 @@ public function __construct(private readonly ?\Closure $intercept = null) public function sendRequest(RequestInterface $request): ResponseInterface { + $this->requests[] = $request; + + if ('POST' !== $request->getMethod()) { + return new Response(202); + } + /** @var array $message */ $message = json_decode((string) $request->getBody(), true, flags: \JSON_THROW_ON_ERROR); $this->messages[] = $message;