diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db034bd..5ecccb16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `ResourceDefinition::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. * [BC Break] `ResourceTemplate::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. * [BC Break] `McpResource` and `McpResourceTemplate` attribute signatures changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments. +* Harden JSON-RPC input parsing: single-message vs batch is now decided from the decoded JSON type (object → single, list array → batch) instead of the raw first byte. Scalars, empty payloads, and non-object batch elements are surfaced as `InvalidInputMessageException` entries instead of triggering warnings or a `TypeError`. +* Add `maxBatchSize` (default `100`) to `MessageFactory` — oversized JSON-RPC batches are rejected before any message is constructed, guarding against amplification. +* Add `maxBodyBytes` (default 4 MiB) to `StreamableHttpTransport` — POST bodies exceeding the cap are rejected with `413`. Unknown-size/chunked bodies are read incrementally and stopped at the cap so they cannot exhaust memory. 0.5.0 ----- diff --git a/docs/transports.md b/docs/transports.md index 3708e350..049ca2d6 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -112,6 +112,7 @@ $transport = new StreamableHttpTransport( - **`streamFactory`** (optional): `StreamFactoryInterface` - PSR-17 factory for creating response body streams. Auto-discovered if not provided. - **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. - **`middleware`** (optional): `iterable|null` - PSR-15 middleware chain. `null` (omitted) installs the [default stack](#default-middleware). `[]` disables all defaults — useful when the surrounding application already handles CORS, host validation, etc. +- **`maxBodyBytes`** (optional): `int` - Upper bound on the POST request body read, in bytes. Defaults to 4 MiB (`StreamableHttpTransport::DEFAULT_MAX_BODY_BYTES`). See [Request Body Size Limit](#request-body-size-limit). ### PSR-17 Auto-Discovery @@ -230,6 +231,40 @@ use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]); ``` +### Request Body Size Limit + +`StreamableHttpTransport` caps the POST body it reads to guard against memory exhaustion from an oversized or +unbounded (chunked) payload. The default cap is 4 MiB. A body over the cap is rejected with `413` and never reaches +message parsing. + +```php +use Mcp\Server\Transport\StreamableHttpTransport; + +// Raise the cap to 16 MiB +$transport = new StreamableHttpTransport($request, maxBodyBytes: 16 * 1024 * 1024); +``` + +When the request stream advertises a size, the transport rejects it up-front. Otherwise (e.g. chunked transfer with +unknown size) the body is read incrementally and aborted as soon as it crosses the cap, so an unbounded stream cannot +exhaust memory. A value below `1` throws `InvalidArgumentException`. + +### JSON-RPC Batch Size Limit + +A JSON-RPC batch (top-level array) is capped at 100 messages by default. Oversized batches are rejected before any +message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap lives on +`MessageFactory`: + +```php +use Mcp\JsonRpc\MessageFactory; + +$factory = MessageFactory::make(maxBatchSize: 50); +``` + +Single-message vs batch is determined from the decoded JSON type — a JSON object is a single message, a JSON array +is a batch. Scalars, empty payloads, and non-object batch elements are returned as `InvalidInputMessageException` +entries (the existing per-message error contract), not parse errors or crashes. A `maxBatchSize` below `1` throws +`InvalidArgumentException`. + ### Custom PSR-15 Middleware `StreamableHttpTransport` accepts any PSR-15 middleware chain. To extend the defaults, spread them and append diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index 1bb82db8..bb2f2f00 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -67,12 +67,24 @@ final class MessageFactory Schema\Request\SetLogLevelRequest::class, ]; + /** + * Upper bound on the number of messages accepted in a single batch, guarding + * against amplification where one small request expands into many operations. + */ + public const DEFAULT_MAX_BATCH_SIZE = 100; + /** * @param list|class-string> $registeredMessages + * @param int $maxBatchSize Maximum number of messages accepted in a single JSON-RPC batch */ public function __construct( private readonly array $registeredMessages, + private readonly int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE, ) { + if ($this->maxBatchSize < 1) { + throw new InvalidArgumentException('maxBatchSize must be at least 1.'); + } + foreach ($this->registeredMessages as $messageClass) { if (!is_subclass_of($messageClass, Request::class) && !is_subclass_of($messageClass, Notification::class)) { throw new InvalidArgumentException(\sprintf('Message classes must extend %s or %s.', Request::class, Notification::class)); @@ -83,9 +95,9 @@ public function __construct( /** * Creates a new Factory instance with all the protocol's default messages. */ - public static function make(): self + public static function make(int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE): self { - return new self(self::REGISTERED_MESSAGES); + return new self(self::REGISTERED_MESSAGES, $maxBatchSize); } /** @@ -102,13 +114,37 @@ public function create(string $input): array { $data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR); - if ('{' === $input[0]) { - $data = [$data]; + // A JSON-RPC payload is a single message (JSON object) or a batch (JSON + // array). Anything else (scalar, null) is invalid input rather than a + // parse error, and must not reach the per-message loop below. + if (!\is_array($data)) { + return [new InvalidInputMessageException('A JSON-RPC message must be a JSON object or a batch array.')]; + } + + // json_decode(assoc: true) maps both objects and arrays to PHP arrays. A + // list is a batch; a non-list (string keys) is a single message. An empty + // array is ambiguous ({} vs []) and invalid as either, so reject it. + if ([] === $data) { + return [new InvalidInputMessageException('A JSON-RPC message must not be empty.')]; + } + + if (array_is_list($data)) { + if (\count($data) > $this->maxBatchSize) { + return [new InvalidInputMessageException(\sprintf('JSON-RPC batch size %d exceeds the maximum allowed batch size of %d.', \count($data), $this->maxBatchSize))]; + } + + $batch = $data; + } else { + $batch = [$data]; } $messages = []; - foreach ($data as $message) { + foreach ($batch as $message) { try { + if (!\is_array($message)) { + throw new InvalidInputMessageException('A JSON-RPC message must be a JSON object.'); + } + $messages[] = $this->createMessage($message); } catch (InvalidInputMessageException $e) { $messages[] = $e; diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index ab84b092..a5f757c0 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -22,6 +22,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\StreamInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Uid\Uuid; @@ -36,6 +37,12 @@ class StreamableHttpTransport extends BaseTransport public const SESSION_HEADER = 'Mcp-Session-Id'; public const PROTOCOL_VERSION_HEADER = 'Mcp-Protocol-Version'; + /** + * Upper bound on the request body read for a POST, guarding against memory + * exhaustion from an oversized (or unbounded chunked) payload. + */ + public const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024; + private ResponseFactoryInterface $responseFactory; private StreamFactoryInterface $streamFactory; @@ -54,9 +61,14 @@ public function __construct( ?StreamFactoryInterface $streamFactory = null, ?LoggerInterface $logger = null, ?iterable $middleware = null, + private readonly int $maxBodyBytes = self::DEFAULT_MAX_BODY_BYTES, ) { parent::__construct($logger); + if ($this->maxBodyBytes < 1) { + throw new InvalidArgumentException('maxBodyBytes must be at least 1.'); + } + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); @@ -107,7 +119,13 @@ protected function handleOptionsRequest(): ResponseInterface protected function handlePostRequest(): ResponseInterface { - $body = $this->request->getBody()->getContents(); + $body = $this->readBody($this->request->getBody()); + if (null === $body) { + $this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]); + + return $this->createErrorResponse(Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), 413); + } + $this->handleMessage($body, $this->sessionId); if (null !== $this->immediateResponse) { @@ -273,6 +291,37 @@ protected function createErrorResponse(Error $jsonRpcError, int $statusCode): Re return $response; } + /** + * Reads the request body, bounded by {@see self::$maxBodyBytes}. + * + * Returns the body contents, or `null` when the payload exceeds the cap. When + * the stream advertises a size we reject up-front; otherwise (e.g. chunked + * transfer with unknown size) we read incrementally and stop at the cap so an + * unbounded stream cannot exhaust memory. + */ + private function readBody(StreamInterface $body): ?string + { + $size = $body->getSize(); + if (null !== $size && $size > $this->maxBodyBytes) { + return null; + } + + $contents = ''; + while (!$body->eof()) { + $chunk = $body->read(8192); + if ('' === $chunk) { + break; + } + + $contents .= $chunk; + if (\strlen($contents) > $this->maxBodyBytes) { + return null; + } + } + + return $contents; + } + /** * @param iterable $middleware * diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index d38aabeb..4a07baba 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\JsonRpc; +use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\InvalidInputMessageException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Error; @@ -400,4 +401,85 @@ public function testErrorWithInvalidMessageType(): void $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); $this->assertStringContainsString('message', $results[0]->getMessage()); } + + public function testScalarJsonIsRejected(): void + { + $results = $this->factory->create('5'); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + } + + public function testStringJsonIsRejected(): void + { + $results = $this->factory->create('"hello"'); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + } + + public function testEmptyBatchIsRejected(): void + { + $results = $this->factory->create('[]'); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + } + + public function testBatchElementMustBeObject(): void + { + $results = $this->factory->create('[1, 2]'); + + $this->assertCount(2, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); + } + + public function testLeadingWhitespaceObjectIsParsedAsSingleMessage(): void + { + $json = " \n {\"jsonrpc\": \"2.0\", \"method\": \"ping\", \"id\": 1}"; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + } + + public function testBatchSizeExceedingMaxIsRejected(): void + { + $factory = new MessageFactory([PingRequest::class], maxBatchSize: 2); + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "method": "ping", "id": 2}, + {"jsonrpc": "2.0", "method": "ping", "id": 3} + ]'; + + $results = $factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('batch', $results[0]->getMessage()); + } + + public function testBatchSizeWithinMaxIsAccepted(): void + { + $factory = new MessageFactory([PingRequest::class], maxBatchSize: 2); + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "method": "ping", "id": 2} + ]'; + + $results = $factory->create($json); + + $this->assertCount(2, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(PingRequest::class, $results[1]); + } + + public function testNonPositiveMaxBatchSizeThrows(): void + { + $this->expectException(InvalidArgumentException::class); + + new MessageFactory([PingRequest::class], maxBatchSize: 0); + } } diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 0f949604..32479a08 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -261,6 +261,42 @@ public function testInvalidMiddlewareEntryThrows(): void ); } + public function testPostBodyExceedingMaxBytesReturns413(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withBody($this->factory->createStream(str_repeat('a', 64))); + + // Empty middleware bypasses the default security stack to isolate body-size handling. + $transport = new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 16); + + $response = $transport->listen(); + + $this->assertSame(413, $response->getStatusCode()); + } + + public function testPostBodyWithinMaxBytesIsNotRejected(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withBody($this->factory->createStream('{}')); + + $transport = new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 1024); + + $response = $transport->listen(); + + $this->assertNotSame(413, $response->getStatusCode()); + } + + public function testNonPositiveMaxBodyBytesThrows(): void + { + $request = $this->factory->createServerRequest('POST', 'http://localhost/'); + + $this->expectException(InvalidArgumentException::class); + + new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 0); + } + private function stubAuth401(): MiddlewareInterface { return new class($this->factory) implements MiddlewareInterface {