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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ 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.
* Fix JSON-RPC error responses fabricating an empty-string `id`. Unrecoverable parse errors (`-32700`) now return `id: null` per JSON-RPC 2.0, and invalid-but-parseable requests (`-32600`) preserve the original request `id`. `Error::$id` / `Error::getId()` and the `$id` parameter of `Error::forParseError()` / `Error::forInvalidRequest()` are widened to `string|int|null`; `InvalidInputMessageException` now carries the recoverable request id via `getRequestId()`/`setRequestId()`.

0.5.0
-----
Expand Down
13 changes: 13 additions & 0 deletions src/Exception/InvalidInputMessageException.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,17 @@
*/
class InvalidInputMessageException extends \InvalidArgumentException implements ExceptionInterface
{
private string|int|null $requestId = null;

public function getRequestId(): string|int|null
{
return $this->requestId;
}

public function setRequestId(string|int|null $requestId): self
{
$this->requestId = $requestId;

return $this;
}
}
5 changes: 5 additions & 0 deletions src/JsonRpc/MessageFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ public function create(string $input): array
try {
$messages[] = $this->createMessage($message);
} catch (InvalidInputMessageException $e) {
// Recover the id only when it's a valid JSON-RPC scalar;
// a null or malformed id is left at the exception's null default.
if (\is_array($message) && isset($message['id']) && (\is_string($message['id']) || \is_int($message['id']))) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

❓ JSON-RPC 2.0 allows id: null in requests. Here isset() drops it, so a null-id invalid request falls back to the null default in forInvalidRequest() — same outcome, fine. Intentional? If so, worth a one-line comment so the next reader doesn't "fix" it by switching to array_key_exists.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Intentional - isset + the type guard. We only recover valid string|int ids; a null or non-scalar id stays at the exception's null default. array_key_exists would converge to the same via the type guard. Leaving a comment in the code.

$e->setRequestId($message['id']);
}
$messages[] = $e;
}
}
Expand Down
29 changes: 16 additions & 13 deletions src/Schema/JsonRpc/Error.php
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 for*() factories left behind (L89–L111). forMethodNotFound, forInvalidParams, forInternalError, forServerError, and forResourceNotFound still default $id = ''. Any caller that forgets to pass the id reintroduces the exact bug this PR fixes. Suggest widening them to string|int|null with null default for consistency, even if no current call site hits that path.

🟡 fromArray() no longer round-trips its own output (L60–L65). The ctor and jsonSerialize() now accept/emit id: null, but fromArray() still throws on null (isset($data['id']) is false for null, then the type guard rejects it). Encoding a parse-error response and decoding it back via fromArray() blows up. Either allow null here or document the asymmetry.

🔵 @phpstan-type ErrorData is stale (L19–L25). Still declares id: string|int; widen to string|int|null to match the ctor / getId() / jsonSerialize() shape.

Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*
* @phpstan-type ErrorData array{
* jsonrpc: string,
* id: string|int,
* id: string|int|null,
* code: int,
* message: string,
* data?: mixed,
Expand All @@ -42,7 +42,7 @@ class Error implements MessageInterface
* @param mixed|null $data additional information about the error
*/
public function __construct(
public readonly string|int $id,
public readonly string|int|null $id,
public readonly int $code,
public readonly string $message,
public readonly mixed $data = null,
Expand All @@ -57,10 +57,13 @@ final public static function fromArray(array $data): self
if (!isset($data['jsonrpc']) || MessageInterface::JSONRPC_VERSION !== $data['jsonrpc']) {
throw new InvalidArgumentException('Invalid or missing "jsonrpc" in Error data.');
}
if (!isset($data['id'])) {
// JSON-RPC requires the "id" key on a response, but its value may be null
// (parse error / invalid request where the id could not be determined), so the
// key must be present yet allowed to hold null alongside string|int.
if (!\array_key_exists('id', $data)) {
throw new InvalidArgumentException('Invalid or missing "id" in Error data.');
}
if (!\is_string($data['id']) && !\is_int($data['id'])) {
if (null !== $data['id'] && !\is_string($data['id']) && !\is_int($data['id'])) {
throw new InvalidArgumentException('Invalid "id" type in Error data.');
}
if (!isset($data['error']) || !\is_array($data['error'])) {
Expand All @@ -76,50 +79,50 @@ final public static function fromArray(array $data): self
return new self($data['id'], $data['error']['code'], $data['error']['message'], $data['error']['data'] ?? null);
}

final public static function forParseError(string $message, string|int $id = ''): self
final public static function forParseError(string $message, string|int|null $id = null): self
{
return new self($id, self::PARSE_ERROR, $message);
}

final public static function forInvalidRequest(string $message, string|int $id = ''): self
final public static function forInvalidRequest(string $message, string|int|null $id = null): self
{
return new self($id, self::INVALID_REQUEST, $message);
}

final public static function forMethodNotFound(string $message, string|int $id = ''): self
final public static function forMethodNotFound(string $message, string|int|null $id = null): self
{
return new self($id, self::METHOD_NOT_FOUND, $message);
}

final public static function forInvalidParams(string $message, string|int $id = '', mixed $data = null): self
final public static function forInvalidParams(string $message, string|int|null $id = null, mixed $data = null): self
{
return new self($id, self::INVALID_PARAMS, $message, $data);
}

final public static function forInternalError(string $message, string|int $id = ''): self
final public static function forInternalError(string $message, string|int|null $id = null): self
{
return new self($id, self::INTERNAL_ERROR, $message);
}

final public static function forServerError(string $message, string|int $id = ''): self
final public static function forServerError(string $message, string|int|null $id = null): self
{
return new self($id, self::SERVER_ERROR, $message);
}

final public static function forResourceNotFound(string $message, string|int $id = ''): self
final public static function forResourceNotFound(string $message, string|int|null $id = null): self
{
return new self($id, self::RESOURCE_NOT_FOUND, $message);
}

public function getId(): string|int
public function getId(): string|int|null
{
return $this->id;
}

/**
* @return array{
* jsonrpc: string,
* id: string|int,
* id: string|int|null,
* error: array{
* code: int,
* message: string,
Expand Down
2 changes: 1 addition & 1 deletion src/Server/Protocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ private function handleInvalidMessage(TransportInterface $transport, InvalidInpu
{
$this->logger->warning('Failed to create message.', ['exception' => $exception]);

$error = Error::forInvalidRequest($exception->getMessage());
$error = Error::forInvalidRequest($exception->getMessage(), $exception->getRequestId());
$this->sendResponse($transport, $error, $session);
}

Expand Down
16 changes: 15 additions & 1 deletion tests/Unit/JsonRpc/MessageFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -346,9 +346,23 @@ public function testResponseWithInvalidIdType(): void
$this->assertStringContainsString('id', $results[0]->getMessage());
}

public function testErrorWithNullIdIsAccepted(): void
{
// JSON-RPC 2.0 mandates id: null on an error response when the request id
// could not be determined (parse error / invalid request), so an incoming
// error with a null id must deserialize, not be rejected.
$json = '{"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "Parse error"}}';

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

$this->assertCount(1, $results);
$this->assertInstanceOf(Error::class, $results[0]);
$this->assertNull($results[0]->getId());
}

public function testErrorWithInvalidIdType(): void
{
$json = '{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Invalid"}}';
$json = '{"jsonrpc": "2.0", "id": true, "error": {"code": -32600, "message": "Invalid"}}';

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

Expand Down
96 changes: 96 additions & 0 deletions tests/Unit/Server/ProtocolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use Mcp\Server\Session\SessionInterface;
use Mcp\Server\Session\SessionManagerInterface;
use Mcp\Server\Transport\TransportInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -297,6 +298,41 @@ public function testInvalidJsonReturnsParseError(): void
);
}

#[TestDox('Unrecoverable parse error does not fabricate an empty-string id')]
public function testParseErrorDoesNotFabricateEmptyStringId(): void
{
$sentPayload = null;
$this->transport->expects($this->once())
->method('send')
->willReturnCallback(static function ($data) use (&$sentPayload) {
$sentPayload = $data;
});

$protocol = new Protocol(
requestHandlers: [],
notificationHandlers: [],
messageFactory: MessageFactory::make(),
sessionManager: $this->sessionManager,
);

// Well-formed JSON nested past PHP's json_decode() depth limit (512), mirroring
// issue #333: json_decode() throws "Maximum stack depth exceeded" so the request
// carries a real numeric id (900512) that cannot be recovered once decoding fails.
$deeplyNested = str_repeat('[', 600).str_repeat(']', 600);
$input = '{"jsonrpc":"2.0","id":900512,"method":"initialize","params":'.$deeplyNested.'}';

$protocol->processInput($this->transport, $input, null);

$this->assertNotNull($sentPayload);
$decoded = json_decode($sentPayload, true);
$this->assertSame(Error::PARSE_ERROR, $decoded['error']['code']);
// The original id is genuinely unrecoverable after a parse failure: per JSON-RPC 2.0
// the response must emit id: null, never a fabricated empty string and never a dropped
// key (clients that strictly validate the JSON-RPC shape rely on the key being present).
$this->assertArrayHasKey('id', $decoded);
$this->assertNull($decoded['id'], 'Unrecoverable parse error must emit id: null');
}

#[TestDox('Invalid message structure returns error')]
public function testInvalidMessageStructureReturnsError(): void
{
Expand Down Expand Up @@ -349,6 +385,66 @@ public function testInvalidMessageStructureReturnsError(): void
$this->assertEquals(Error::INVALID_REQUEST, $message['error']['code']);
}

/**
* @return iterable<string, array{string, string|int}>
*/
public static function recoverableIdProvider(): iterable
{
yield 'positive int' => ['{"jsonrpc": "2.0", "id": 42, "params": {}}', 42];
yield 'zero int (truthiness trap)' => ['{"jsonrpc": "2.0", "id": 0, "params": {}}', 0];
yield 'string id' => ['{"jsonrpc": "2.0", "id": "req-1", "params": {}}', 'req-1'];
}

#[DataProvider('recoverableIdProvider')]
#[TestDox('Invalid but parseable message preserves its recoverable id')]
public function testInvalidMessagePreservesRecoverableId(string $input, string|int $expectedId): void
{
$session = $this->createMock(SessionInterface::class);

$this->sessionManager->method('createWithId')->willReturn($session);
$this->sessionManager->method('exists')->willReturn(true);

// Configure session mock for queue operations (mirrors testInvalidMessageStructureReturnsError).
$queue = [];
$session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) {
if ('_mcp.outgoing_queue' === $key) {
return $queue;
}

return $default;
});

$session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) {
if ('_mcp.outgoing_queue' === $key) {
$queue = $value;
}
});

$protocol = new Protocol(
requestHandlers: [],
notificationHandlers: [],
messageFactory: MessageFactory::make(),
sessionManager: $this->sessionManager,
);

$sessionId = Uuid::v4();
// Valid JSON carrying a real id but missing method/result/error: the message is
// structurally invalid, yet its id IS recoverable from the decoded payload.
$protocol->processInput(
$this->transport,
$input,
$sessionId
);

$outgoing = $protocol->consumeOutgoingMessages($sessionId);
$this->assertCount(1, $outgoing);

$message = json_decode($outgoing[0]['message'], true);
$this->assertArrayHasKey('error', $message);
$this->assertEquals(Error::INVALID_REQUEST, $message['error']['code']);
$this->assertSame($expectedId, $message['id'], 'Invalid-but-parseable message must preserve its recoverable id, not return ""');
}

#[TestDox('Request without handler returns method not found error')]
public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void
{
Expand Down