Merge commit from fork

processInput() appended every 8 KiB stdout chunk to $inputBuffer and only
drained it on a "\n". A spawned server that streams stdout without ever
emitting a newline grew the buffer without bound, exhausting client
memory (GHSA-6vrr-g4hw-f3hj).

Cap the buffer (default 4 MiB, configurable via the constructor). When
appending the next chunk would exceed the cap before a newline is seen,
abort the read and resolve the in-flight request with an error
immediately, so the caller fails fast instead of spinning until its
request timeout.

Based on localden's suggested fix in the advisory: makes the cap
configurable, but resolves the pending request with an error (keeping
runRequest's Response|Error contract) instead of throwing a
TransportException out of the read loop, and leaves connection teardown
to the caller. Adds regression tests.

Co-authored-by: localden <53200638+localden@users.noreply.github.com>
This commit is contained in:
Antoine Bluchet 2026-07-27 11:16:14 +02:00 • committed by GitHub
commit 529099f463
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -12,6 +12,7 @@
namespace Mcp\Client\Transport;
use Mcp\Exception\ConnectionException;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Response;
use Psr\Log\LoggerInterface;
@ -45,6 +46,11 @@ class StdioTransport extends BaseTransport
private string $inputBuffer = '';
/**
* Default cap on the bytes buffered while waiting for a complete line.
*/
public const DEFAULT_MAX_BUFFER_SIZE = 4 * 1024 * 1024;
/** @var McpFiber|null */
private ?\Fiber $activeFiber = null;
@ -52,10 +58,16 @@ class StdioTransport extends BaseTransport
private $activeProgressCallback;
/**
* @param string $command The command to run
* @param array<int, string> $args Command arguments
* @param string|null $cwd Working directory
* @param array<string, string>|null $env Environment variables
* @param string $command The command to run
* @param array<int, string> $args Command arguments
* @param string|null $cwd Working directory
* @param array<string, string>|null $env Environment variables
* @param int $maxBufferSize Maximum bytes buffered while waiting for a complete line. The
* buffer is only drained on a "\n"; a spawned server that streams
* stdout without ever emitting a newline would otherwise grow it
* without bound and exhaust client memory. Reaching the cap aborts
* the read instead. Raise it for servers that emit single frames
* larger than the default.
*/
public function __construct(
private readonly string $command,
@ -63,8 +75,13 @@ class StdioTransport extends BaseTransport
private readonly ?string $cwd = null,
private readonly ?array $env = null,
?LoggerInterface $logger = null,
private readonly int $maxBufferSize = self::DEFAULT_MAX_BUFFER_SIZE,
) {
parent::__construct($logger);
if ($maxBufferSize < 1) {
throw new InvalidArgumentException(\sprintf('The maximum buffer size must be a positive number of bytes, got %d.', $maxBufferSize));
}
}
public function connect(): void
@ -223,6 +240,12 @@ class StdioTransport extends BaseTransport
$data = fread($this->stdout, 8192);
if (false !== $data && '' !== $data) {
if (\strlen($this->inputBuffer) + \strlen($data) > $this->maxBufferSize) {
$this->abortInput(\sprintf('buffered %d bytes without a newline, exceeding the %d byte limit', \strlen($this->inputBuffer) + \strlen($data), $this->maxBufferSize));
return;
}
$this->inputBuffer .= $data;
}
@ -237,6 +260,33 @@ class StdioTransport extends BaseTransport
}
}
/**
* Discard the input buffer and fail any in-flight request.
*
* The waiting fiber is resolved with an error immediately so the caller
* fails fast, rather than spinning until the request timeout elapses.
*/
private function abortInput(string $reason): void
{
$bufferedBytes = \strlen($this->inputBuffer);
$this->inputBuffer = '';
$this->logger->warning('Aborting stdio input: '.$reason, [
'buffered_bytes' => $bufferedBytes,
'max_buffer_size' => $this->maxBufferSize,
]);
if (null === $this->state) {
return;
}
foreach ($this->state->getPendingRequests() as $pending) {
$requestId = $pending['request_id'];
$error = Error::forInternalError('stdio input aborted: '.$reason, $requestId);
$this->state->storeResponse($requestId, $error->jsonSerialize());
}
}
private function processFiber(): void
{
if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) {