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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
17 changes: 16 additions & 1 deletion src/Server/Transport/StdioTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ protected function processInput(): void
{
$line = fgets($this->input, $this->maxLineBytes);
if (false === $line) {
usleep(50000); // 50ms
$this->waitForInput();

return;
}
Expand Down Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions tests/Unit/Server/Transport/StdioTransportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
{
Expand Down
Loading