* fix: harden JSON-RPC input parsing against malformed input and DoS
MessageFactory::create() detected batches via $input[0] === '{', which
mishandled valid-but-non-object JSON: a scalar ("5") or leading-whitespace
object silently returned [] (with a PHP warning) or could reach a foreach
over a non-array. It also had no bound on batch size, letting one small
request expand into arbitrarily many operations (amplification).
- Detect single-vs-batch from the decoded JSON type (object vs list array)
instead of the raw first byte; reject scalars and empty payloads as
InvalidInputMessageException rather than crashing or no-oping.
- Cap batch size (maxBatchSize, default 100) and reject oversized batches.
- Reject non-object batch elements as InvalidInputMessageException instead
of letting them hit a TypeError.
- Bound the StreamableHttpTransport POST body (maxBodyBytes, default 4 MiB),
returning 413, with an incremental read so unknown-size/chunked bodies
cannot exhaust memory.
* docs: document input-hardening limits in CHANGELOG and transports guide
Add CHANGELOG entries and docs/transports.md sections for the JSON-RPC
batch size cap (maxBatchSize) and the StreamableHttpTransport request
body size cap (maxBodyBytes), plus the type-based single-vs-batch
message detection.
485 lines
17 KiB
PHP
485 lines
17 KiB
PHP
<?php
|
|
|
|
/*
|
|
* This file is part of the official PHP MCP SDK.
|
|
*
|
|
* A collaboration between Symfony and the PHP Foundation.
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
namespace Mcp\Tests\Unit\JsonRpc;
|
|
|
|
use Mcp\Exception\InvalidArgumentException;
|
|
use Mcp\Exception\InvalidInputMessageException;
|
|
use Mcp\JsonRpc\MessageFactory;
|
|
use Mcp\Schema\JsonRpc\Error;
|
|
use Mcp\Schema\JsonRpc\Response;
|
|
use Mcp\Schema\Notification\CancelledNotification;
|
|
use Mcp\Schema\Notification\InitializedNotification;
|
|
use Mcp\Schema\Request\GetPromptRequest;
|
|
use Mcp\Schema\Request\PingRequest;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class MessageFactoryTest extends TestCase
|
|
{
|
|
private MessageFactory $factory;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->factory = new MessageFactory([
|
|
CancelledNotification::class,
|
|
InitializedNotification::class,
|
|
GetPromptRequest::class,
|
|
PingRequest::class,
|
|
]);
|
|
}
|
|
|
|
public function testCreateRequestWithIntegerId(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "create_story"}, "id": 123}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
/** @var GetPromptRequest $result */
|
|
$result = $results[0];
|
|
$this->assertInstanceOf(GetPromptRequest::class, $result);
|
|
$this->assertSame('prompts/get', $result::getMethod());
|
|
$this->assertSame('create_story', $result->name);
|
|
$this->assertSame(123, $result->getId());
|
|
}
|
|
|
|
public function testCreateRequestWithStringId(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "method": "ping", "id": "abc-123"}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
/** @var PingRequest $result */
|
|
$result = $results[0];
|
|
$this->assertInstanceOf(PingRequest::class, $result);
|
|
$this->assertSame('ping', $result::getMethod());
|
|
$this->assertSame('abc-123', $result->getId());
|
|
}
|
|
|
|
public function testCreateNotification(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 12345}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
/** @var CancelledNotification $result */
|
|
$result = $results[0];
|
|
$this->assertInstanceOf(CancelledNotification::class, $result);
|
|
$this->assertSame('notifications/cancelled', $result::getMethod());
|
|
$this->assertSame(12345, $result->requestId);
|
|
}
|
|
|
|
public function testCreateNotificationWithoutParams(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "method": "notifications/initialized"}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
/** @var InitializedNotification $result */
|
|
$result = $results[0];
|
|
$this->assertInstanceOf(InitializedNotification::class, $result);
|
|
$this->assertSame('notifications/initialized', $result::getMethod());
|
|
}
|
|
|
|
public function testCreateResponseWithIntegerId(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "id": 456, "result": {"content": [{"type": "text", "text": "Hello"}]}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
/** @var Response<array<string, mixed>> $result */
|
|
$result = $results[0];
|
|
$this->assertInstanceOf(Response::class, $result);
|
|
$this->assertSame(456, $result->getId());
|
|
$this->assertIsArray($result->result);
|
|
$this->assertArrayHasKey('content', $result->result);
|
|
}
|
|
|
|
public function testCreateResponseWithStringId(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "id": "response-1", "result": {"status": "ok"}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
/** @var Response<array<string, mixed>> $result */
|
|
$result = $results[0];
|
|
$this->assertInstanceOf(Response::class, $result);
|
|
$this->assertSame('response-1', $result->getId());
|
|
$this->assertEquals(['status' => 'ok'], $result->result);
|
|
}
|
|
|
|
public function testCreateErrorWithIntegerId(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "id": 789, "error": {"code": -32601, "message": "Method not found"}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
/** @var Error $result */
|
|
$result = $results[0];
|
|
$this->assertInstanceOf(Error::class, $result);
|
|
$this->assertSame(789, $result->getId());
|
|
$this->assertSame(-32601, $result->code);
|
|
$this->assertSame('Method not found', $result->message);
|
|
$this->assertNull($result->data);
|
|
}
|
|
|
|
public function testCreateErrorWithStringId(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "id": "err-1", "error": {"code": -32600, "message": "Invalid request"}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
/** @var Error $result */
|
|
$result = $results[0];
|
|
$this->assertInstanceOf(Error::class, $result);
|
|
$this->assertSame('err-1', $result->getId());
|
|
$this->assertSame(-32600, $result->code);
|
|
$this->assertSame('Invalid request', $result->message);
|
|
}
|
|
|
|
public function testCreateErrorWithData(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": "Server error", "data": {"details": "Something went wrong"}}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
/** @var Error $result */
|
|
$result = $results[0];
|
|
$this->assertInstanceOf(Error::class, $result);
|
|
$this->assertEquals(['details' => 'Something went wrong'], $result->data);
|
|
}
|
|
|
|
public function testBatchRequests(): void
|
|
{
|
|
$json = '[
|
|
{"jsonrpc": "2.0", "method": "ping", "id": 1},
|
|
{"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "test"}, "id": 2},
|
|
{"jsonrpc": "2.0", "method": "notifications/initialized"}
|
|
]';
|
|
|
|
$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]);
|
|
}
|
|
|
|
public function testBatchWithMixedMessages(): void
|
|
{
|
|
$json = '[
|
|
{"jsonrpc": "2.0", "method": "ping", "id": 1},
|
|
{"jsonrpc": "2.0", "id": 2, "result": {"status": "ok"}},
|
|
{"jsonrpc": "2.0", "id": 3, "error": {"code": -32600, "message": "Invalid"}},
|
|
{"jsonrpc": "2.0", "method": "notifications/initialized"}
|
|
]';
|
|
|
|
$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]);
|
|
}
|
|
|
|
public function testInvalidJson(): void
|
|
{
|
|
$this->expectException(\JsonException::class);
|
|
|
|
$this->factory->create('invalid json');
|
|
}
|
|
|
|
public function testMissingJsonRpcVersion(): void
|
|
{
|
|
$json = '{"method": "ping", "id": 1}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
$this->assertStringContainsString('jsonrpc', $results[0]->getMessage());
|
|
}
|
|
|
|
public function testInvalidJsonRpcVersion(): void
|
|
{
|
|
$json = '{"jsonrpc": "1.0", "method": "ping", "id": 1}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
$this->assertStringContainsString('jsonrpc', $results[0]->getMessage());
|
|
}
|
|
|
|
public function testMissingAllIdentifyingFields(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "params": {}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
$this->assertStringContainsString('missing', $results[0]->getMessage());
|
|
}
|
|
|
|
public function testUnknownMethod(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "method": "unknown/method", "id": 1}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
$this->assertStringContainsString('Unknown method', $results[0]->getMessage());
|
|
}
|
|
|
|
public function testUnknownNotificationMethod(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "method": "notifications/unknown"}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
$this->assertStringContainsString('Unknown method', $results[0]->getMessage());
|
|
}
|
|
|
|
public function testNotificationMethodUsedAsRequest(): void
|
|
{
|
|
// When a notification method is used with an id, it should still create the notification
|
|
// The fromArray validation will handle any issues
|
|
$json = '{"jsonrpc": "2.0", "method": "notifications/initialized", "id": 1}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
// The notification class will reject the id in fromArray validation
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
}
|
|
|
|
public function testErrorMissingId(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid"}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
$this->assertStringContainsString('id', $results[0]->getMessage());
|
|
}
|
|
|
|
public function testErrorMissingCode(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "id": 1, "error": {"message": "Invalid"}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
$this->assertStringContainsString('code', $results[0]->getMessage());
|
|
}
|
|
|
|
public function testErrorMissingMessage(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32600}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
$this->assertStringContainsString('message', $results[0]->getMessage());
|
|
}
|
|
|
|
public function testBatchWithErrors(): void
|
|
{
|
|
$json = '[
|
|
{"jsonrpc": "2.0", "method": "ping", "id": 1},
|
|
{"jsonrpc": "2.0", "params": {}, "id": 2},
|
|
{"jsonrpc": "2.0", "method": "unknown/method", "id": 3},
|
|
{"jsonrpc": "2.0", "method": "notifications/initialized"}
|
|
]';
|
|
|
|
$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]);
|
|
}
|
|
|
|
public function testMakeFactoryWithDefaultMessages(): void
|
|
{
|
|
$factory = MessageFactory::make();
|
|
$json = '{"jsonrpc": "2.0", "method": "ping", "id": 1}';
|
|
|
|
$results = $factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(PingRequest::class, $results[0]);
|
|
}
|
|
|
|
public function testResponseWithInvalidIdType(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "id": true, "result": {"status": "ok"}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
$this->assertStringContainsString('id', $results[0]->getMessage());
|
|
}
|
|
|
|
public function testErrorWithInvalidIdType(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Invalid"}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
$this->assertStringContainsString('id', $results[0]->getMessage());
|
|
}
|
|
|
|
public function testResponseWithNonArrayResult(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "id": 1, "result": "not an array"}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
$this->assertStringContainsString('result', $results[0]->getMessage());
|
|
}
|
|
|
|
public function testErrorWithNonArrayErrorField(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "id": 1, "error": "not an object"}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
$this->assertStringContainsString('error', $results[0]->getMessage());
|
|
}
|
|
|
|
public function testErrorWithInvalidCodeType(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": "not-a-number", "message": "Invalid"}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
|
$this->assertStringContainsString('code', $results[0]->getMessage());
|
|
}
|
|
|
|
public function testErrorWithInvalidMessageType(): void
|
|
{
|
|
$json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32600, "message": 123}}';
|
|
|
|
$results = $this->factory->create($json);
|
|
|
|
$this->assertCount(1, $results);
|
|
$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);
|
|
}
|
|
}
|