Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
11 changes: 6 additions & 5 deletions src/Server/Transport/StreamableHttpTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions tests/Unit/Server/Transport/StreamableHttpTransportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,40 @@ public function testMessageThatCannotBeStoredFailsInTheHandler(): void
$this->assertStringNotContainsString('"error"', $output);
}

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