php-sdk/tests/Unit/JsonRpc/MessageFactoryTest.php
Christopher Hertel c8ed07bba2
[Server] Handle malformed JSON-RPC input graceful (#412)
* Never let a throwable escape Protocol::processInput()

* Validate payload types in the schema hydration methods

* Stop leaking handler exception messages to the client
2026-08-14 22:34:01 +02:00

538 lines
19 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\ElicitRequest;
use Mcp\Schema\Request\GetPromptRequest;
use Mcp\Schema\Request\PingRequest;
use PHPUnit\Framework\Attributes\DataProvider;
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 testMakeFactoryParsesElicitationCreate(): void
{
$factory = MessageFactory::make();
$json = '{"jsonrpc": "2.0", "method": "elicitation/create", "id": 1, "params": {"message": "Your name?", "requestedSchema": {"type": "object", "properties": {"name": {"type": "string", "title": "Name"}}, "required": ["name"]}}}';
$results = $factory->create($json);
$this->assertCount(1, $results);
/** @var ElicitRequest $result */
$result = $results[0];
$this->assertInstanceOf(ElicitRequest::class, $result);
$this->assertSame('elicitation/create', $result::getMethod());
$this->assertSame('Your name?', $result->message);
$this->assertSame(1, $result->getId());
}
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]);
}
/**
* @return iterable<string, array{string}>
*/
public static function provideNonStringMethods(): iterable
{
yield 'object' => ['{"evil": true}'];
yield 'array' => ['[1, 2, 3]'];
yield 'int' => ['5'];
yield 'bool' => ['true'];
}
#[DataProvider('provideNonStringMethods')]
public function testNonStringMethodIsRejected(string $method): void
{
$results = $this->factory->create(\sprintf('{"jsonrpc": "2.0", "id": 1, "method": %s}', $method));
$this->assertCount(1, $results);
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
$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}";
$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);
}
}