From 912779d8c27e3447fa4fd4cb9ee845b20dabf346 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 14 Oct 2026 01:34:44 +0200 Subject: [PATCH] [Server] Accept a POST without requests with 202 and keep the queued messages --- CHANGELOG.md | 1 + .../Transport/StreamableHttpTransport.php | 11 +++--- .../Transport/StreamableHttpTransportTest.php | 34 +++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 838bc3ae..f952089c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Fix `Client::connect()` rejecting an empty `serverInfo.version`, as the Python SDK sends, and leaving the transport open when initialization fails on a malformed result: the transport is now closed and the failure wrapped in a `ConnectionException`. * Fix parallel elicitations on one session getting each other's answers: requests to the client get UUID ids instead of a session counter, and a request or notification a handler sends goes out on the stream of its own call. * [BC Break] Ids of requests to the client are UUIDs: `Protocol::sendRequest()`, `Protocol::checkResponse()` and `Protocol::handleFiberYield()` take and return a `Uuid` instead of an `int`, as does the response finder of a transport. On the wire and in the session they are RFC 4122 strings. +* Fix `StreamableHttpTransport` answering a POST of only responses or notifications with `200` and the session's queued messages: it is now accepted with `202` and no body, and the queued messages stay. * Fix stateless SSE streams holding back frames until close when PHP output buffering is enabled. * Fix a session deleted while one of its requests still ran coming back when that request saved it. * 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/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 79086372..867b5534 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -249,14 +249,15 @@ protected function handleDeleteRequest(): ResponseInterface protected function createJsonResponse(): ResponseInterface { + // A POST without requests (only responses or notifications) is accepted + // without a body, so the session's queued messages stay for a later answer. + if ([] === $this->responses) { + return $this->responseFactory->createResponse(202); + } + $messages = [...array_column($this->getOutgoingMessages($this->sessionId), 'message'), ...$this->responses]; $this->responses = []; - if ([] === $messages) { - return $this->responseFactory->createResponse(202) - ->withHeader('Content-Type', 'application/json'); - } - $responseBody = 1 === \count($messages) ? $messages[0] : '['.implode(',', $messages).']'; $response = $this->responseFactory->createResponse(200) diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 06a529ce..e113c645 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -720,6 +720,40 @@ public function testMessageThatCannotBeStoredFailsInTheHandler(): void $this->assertStringNotContainsString('"error"', $output); } + /** + * @return iterable + */ + public static function providePostsWithoutRequest(): iterable + { + yield 'client response' => ['{"jsonrpc":"2.0","id":"7","result":{}}']; + yield 'notification' => ['{"jsonrpc":"2.0","method":"notifications/roots/list_changed"}']; + } + + #[DataProvider('providePostsWithoutRequest')] + #[TestDox('a POST without a request is accepted with 202 and no body, and leaves the queued messages in place')] + public function testPostWithoutRequestDoesNotDrainTheOutgoingQueue(string $body): void + { + $store = new InterleavingSessionStore(); + $sessionId = $this->post($store, '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}') + ->getHeaderLine(StreamableHttpTransport::SESSION_HEADER); + + $queued = [[ + 'message' => '{"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"file:///a"}}', + 'context' => ['type' => 'notification'], + ]]; + $session = new Session($store, Uuid::fromString($sessionId)); + $session->set('_mcp.outgoing_queue', $queued); + $session->save(); + + $response = $this->post($store, $body, $sessionId); + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('', (string) $response->getBody()); + + $session = new Session($store, Uuid::fromString($sessionId)); + $this->assertSame($queued, $session->get('_mcp.outgoing_queue')); + } + /** * Sends one POST to a fresh server sharing $store, like a PHP worker would. */