diff --git a/CHANGELOG.md b/CHANGELOG.md index e34852a9..c2f492e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * On a `2026-07-28` connection, `Client::setLoggingLevel()` stamps the level on every following request, `Client::ping()` sends `server/discover` and `Client::sendRootsListChanged()` sends nothing. * Fail a client request at once when the HTTP server refuses it with an error status or the stdio server process exits, instead of waiting out the timeout. * [BC Break] Bump `MessageInterface::PROTOCOL_VERSION` to `2026-07-28`. Use `ProtocolVersion::latestHandshake()` where a handshake revision is needed, e.g. in an `initialize` answer. +* Fix the server's `StdioTransport` answering a request on an idle connection only after ~50ms: it now waits on the input with `stream_select()` instead of sleeping. 0.8.0 ----- diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 4f25d5e9..08202708 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -149,7 +149,7 @@ protected function processInput(): void { $line = fgets($this->input, $this->maxLineBytes); if (false === $line) { - usleep(50000); // 50ms + $this->waitForInput(); return; } @@ -183,6 +183,21 @@ protected function processInput(): void } } + /** + * Idles until input arrives, for at most 50ms, so the run loop still gets + * to suspended fibers, open streams and queued messages in between. + */ + private function waitForInput(): void + { + $read = [$this->input]; + $write = $except = null; + + // Streams that cannot be selected, like php://memory, fall back to sleeping. + if (false === @stream_select($read, $write, $except, 0, 50000)) { + usleep(50000); + } + } + /** * Hands one message to the era it belongs to, settling the connection's * era on the first request. diff --git a/tests/Unit/Server/Transport/StdioTransportTest.php b/tests/Unit/Server/Transport/StdioTransportTest.php index 094b714a..dbcf5ab9 100644 --- a/tests/Unit/Server/Transport/StdioTransportTest.php +++ b/tests/Unit/Server/Transport/StdioTransportTest.php @@ -13,6 +13,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Transport\TransportInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -51,6 +52,39 @@ public function testNormalLineIsDispatched(): void $this->assertSame(['{"jsonrpc":"2.0","id":1}'], $messages); } + #[TestDox('a line arriving on an idle input is read as soon as it arrives')] + public function testIdleInputWakesOnArrival(): void + { + // The child sends a request, waits for the answer, idles for 10ms, then sends another and exits. + $process = proc_open( + [\PHP_BINARY, '-r', 'echo "{\"first\":1}\n"; fgets(STDIN); usleep(10000); echo "{\"second\":1}\n";'], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w']], + $pipes, + ); + $this->assertIsResource($process); + + $answeredAt = null; + $arrivedAt = null; + $transport = new StdioTransport(input: $pipes[1], output: $pipes[0]); + $transport->onMessage(static function (TransportInterface $transport) use (&$answeredAt, &$arrivedAt): void { + if (null === $answeredAt) { + $transport->send('{"answer":1}', []); + $answeredAt = hrtime(true); + + return; + } + + $arrivedAt = hrtime(true); + }); + + $transport->listen(); + proc_close($process); + + $this->assertNotNull($answeredAt); + $this->assertNotNull($arrivedAt); + $this->assertLessThan(45, ($arrivedAt - $answeredAt) / 1e6, 'the idle wait must end when input arrives'); + } + #[TestDox('the line byte cap must be a positive number of bytes')] public function testRejectsNonPositiveCap(): void {