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
17 changes: 8 additions & 9 deletions docs/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,22 +259,21 @@ When the request stream advertises a size, the transport rejects it up-front. Ot
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
### JSON-RPC Batch Requests

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`:
The MCP protocol does not support JSON-RPC batch requests: a POST body must be a single JSON-RPC message. A
top-level JSON array is rejected by `MessageFactory` as invalid input, so no element of a batch is ever
hydrated or processed:

```php
use Mcp\JsonRpc\MessageFactory;

$factory = MessageFactory::make(maxBatchSize: 50);
$results = MessageFactory::make()->create('[{...}, {...}]'); // [InvalidInputMessageException]
```

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`.
Single-message vs batch is determined from the decoded JSON type — a JSON object is a single message, a JSON
array is a batch and is rejected wholesale with an `InvalidInputMessageException` entry. Scalars and empty
payloads are rejected the same way.

### Custom PSR-15 Middleware

Expand Down
63 changes: 19 additions & 44 deletions src/JsonRpc/MessageFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,24 +68,12 @@ 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<Request>|class-string<Notification>> $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));
Expand All @@ -96,16 +84,18 @@ public function __construct(
/**
* Creates a new Factory instance with all the protocol's default messages.
*/
public static function make(int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE): self
public static function make(): self
{
return new self(self::REGISTERED_MESSAGES, $maxBatchSize);
return new self(self::REGISTERED_MESSAGES);
}

/**
* Creates message objects from JSON input.
* Creates a message object from JSON input.
*
* Supports both single messages and batch requests. Returns an array containing
* MessageInterface objects or InvalidInputMessageException instances for invalid messages.
* Accepts a single JSON-RPC message only; the MCP protocol no longer supports
* JSON-RPC batch requests, so a top-level array is rejected as invalid input.
* Returns an array containing a MessageInterface object or an
* InvalidInputMessageException instance for invalid messages.
*
* @return array<MessageInterface|InvalidInputMessageException>
*
Expand All @@ -115,44 +105,29 @@ public function create(string $input): array
{
$data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR);

// 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.
// A JSON-RPC payload is a single message (JSON object). Anything else
// (scalar, null) is invalid input rather than a parse error.
if (!\is_array($data)) {
return [new InvalidInputMessageException('A JSON-RPC message must be a JSON object or a batch array.')];
return [new InvalidInputMessageException('A JSON-RPC message must be a JSON object.')];
}

// 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.
// json_decode(assoc: true) maps both objects and arrays to PHP arrays. 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.')];
}

// A list is a batch array. MCP removed JSON-RPC batches from the
// protocol, so the whole payload is invalid rather than a set of messages.
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];
return [new InvalidInputMessageException('JSON-RPC batch requests are not supported; send a single JSON-RPC message.')];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return [new InvalidInputMessageException('JSON-RPC batch requests are not supported; send a single JSON-RPC message.')];
return [new InvalidInputMessageException('JSON-RPC batch requests are not supported anymore since specification release 2025-06-18; send a single JSON-RPC message.')];

}

$messages = [];
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;
}
try {
return [$this->createMessage($data)];
} catch (InvalidInputMessageException $e) {
return [$e];
}

return $messages;
}

/**
Expand Down
10 changes: 5 additions & 5 deletions tests/Unit/JsonRpc/MalformedInputTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,15 @@ public function testMalformedPayloadIsReportedAsInvalidInput(string $payload): v
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
}

#[TestDox('A malformed message in a batch does not discard the valid ones')]
public function testMalformedMessageInBatchDoesNotDiscardValidMessages(): void
#[TestDox('A batch payload is rejected as a whole, without hydrating any entry')]
public function testBatchPayloadIsRejectedAsAWhole(): void
{
$payload = '[{"jsonrpc":"2.0","id":1,"method":"tools/list"},{"jsonrpc":"2.0","id":2,"method":{}}]';

$results = MessageFactory::make()->create($payload);

$this->assertCount(2, $results);
$this->assertInstanceOf(\Mcp\Schema\Request\ListToolsRequest::class, $results[0]);
$this->assertInstanceOf(InvalidInputMessageException::class, $results[1]);
$this->assertCount(1, $results);
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
$this->assertStringContainsString('batch', $results[0]->getMessage());
}
}
88 changes: 15 additions & 73 deletions tests/Unit/JsonRpc/MessageFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

namespace Mcp\Tests\Unit\JsonRpc;

use Mcp\Exception\InvalidArgumentException;
use Mcp\Exception\InvalidInputMessageException;
use Mcp\JsonRpc\MessageFactory;
use Mcp\Schema\JsonRpc\Error;
Expand Down Expand Up @@ -167,7 +166,7 @@ public function testCreateErrorWithData(): void
$this->assertEquals(['details' => 'Something went wrong'], $result->data);
}

public function testBatchRequests(): void
public function testBatchRequestsAreRejected(): void
{
$json = '[
{"jsonrpc": "2.0", "method": "ping", "id": 1},
Expand All @@ -177,13 +176,12 @@ public function testBatchRequests(): void

$results = $this->factory->create($json);

$this->assertCount(3, $results);
$this->assertInstanceOf(PingRequest::class, $results[0]);
$this->assertInstanceOf(GetPromptRequest::class, $results[1]);
$this->assertInstanceOf(InitializedNotification::class, $results[2]);
$this->assertCount(1, $results);
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
$this->assertStringContainsString('batch', $results[0]->getMessage());
}

public function testBatchWithMixedMessages(): void
public function testBatchWithMixedMessagesIsRejected(): void
{
$json = '[
{"jsonrpc": "2.0", "method": "ping", "id": 1},
Expand All @@ -194,11 +192,9 @@ public function testBatchWithMixedMessages(): void

$results = $this->factory->create($json);

$this->assertCount(4, $results);
$this->assertInstanceOf(PingRequest::class, $results[0]);
$this->assertInstanceOf(Response::class, $results[1]);
$this->assertInstanceOf(Error::class, $results[2]);
$this->assertInstanceOf(InitializedNotification::class, $results[3]);
$this->assertCount(1, $results);
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
$this->assertStringContainsString('batch', $results[0]->getMessage());
}

public function testInvalidJson(): void
Expand Down Expand Up @@ -309,7 +305,7 @@ public function testErrorMissingMessage(): void
$this->assertStringContainsString('message', $results[0]->getMessage());
}

public function testBatchWithErrors(): void
public function testBatchWithErrorsIsRejected(): void
{
$json = '[
{"jsonrpc": "2.0", "method": "ping", "id": 1},
Expand All @@ -320,11 +316,9 @@ public function testBatchWithErrors(): void

$results = $this->factory->create($json);

$this->assertCount(4, $results);
$this->assertInstanceOf(PingRequest::class, $results[0]);
$this->assertInstanceOf(InvalidInputMessageException::class, $results[1]);
$this->assertInstanceOf(InvalidInputMessageException::class, $results[2]);
$this->assertInstanceOf(InitializedNotification::class, $results[3]);
$this->assertCount(1, $results);
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
$this->assertStringContainsString('batch', $results[0]->getMessage());
}

public function testMakeFactoryWithDefaultMessages(): void
Expand Down Expand Up @@ -444,13 +438,13 @@ public function testEmptyBatchIsRejected(): void
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
}

public function testBatchElementMustBeObject(): void
public function testBatchWithNonObjectElementsIsRejected(): void
{
$results = $this->factory->create('[1, 2]');

$this->assertCount(2, $results);
$this->assertCount(1, $results);
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
$this->assertInstanceOf(InvalidInputMessageException::class, $results[1]);
$this->assertStringContainsString('batch', $results[0]->getMessage());
}

/**
Expand All @@ -474,20 +468,6 @@ public function testNonStringMethodIsRejected(string $method): void
$this->assertStringContainsString('"method" must be a string', $results[0]->getMessage());
}

public function testBatchWithNonStringMethodStillYieldsTheValidMessages(): void
{
$json = '[
{"jsonrpc": "2.0", "method": "ping", "id": 1},
{"jsonrpc": "2.0", "method": {}, "id": 2}
]';

$results = $this->factory->create($json);

$this->assertCount(2, $results);
$this->assertInstanceOf(PingRequest::class, $results[0]);
$this->assertInstanceOf(InvalidInputMessageException::class, $results[1]);
}

public function testLeadingWhitespaceObjectIsParsedAsSingleMessage(): void
{
$json = " \n {\"jsonrpc\": \"2.0\", \"method\": \"ping\", \"id\": 1}";
Expand All @@ -497,42 +477,4 @@ public function testLeadingWhitespaceObjectIsParsedAsSingleMessage(): void
$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);
}
}
Loading