php-sdk/src/Schema/JsonRpc/Error.php
Christopher Hertel 5041caf77a
[Schema] Omit the id of an error response that never had one (#442)
* [Schema] Omit the id of an error response that never had one

An error response whose id could not be read sent `"id": ""`, which claims
the peer issued a request with an empty-string id - a different statement
from "the id could not be read". JSON-RPC spells the latter as a null id,
and a receiver that cannot read the id is exactly the case the code covers.

[BC Break] Error::$id accepts null and getId() may return it. All the for*()
factories default to null, fromArray() accepts a missing or explicitly-null
id, and the member is omitted from the serialized form when absent.

* Guard null response id in Client/Server Protocol handlers

Also fix Error::jsonSerialize()'s phpdoc: data belongs inside error, not beside it.
2026-08-18 15:24:19 +02:00

212 lines
7.2 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\Schema\JsonRpc;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Enum\ProtocolVersion;
/**
* A response to a request that indicates an error occurred.
*
* @phpstan-type ErrorData array{
* jsonrpc: string,
* id: string|int,
* code: int,
* message: string,
* data?: mixed,
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class Error implements MessageInterface
{
public const PARSE_ERROR = -32700;
public const INVALID_REQUEST = -32600;
public const METHOD_NOT_FOUND = -32601;
public const INVALID_PARAMS = -32602;
public const INTERNAL_ERROR = -32603;
public const SERVER_ERROR = -32000;
public const RESOURCE_NOT_FOUND = -32002;
/**
* Values in the HTTP headers contradict the request body, or a required
* header is missing or malformed. Answered with `400 Bad Request`.
*/
public const HEADER_MISMATCH = -32020;
/**
* Handling the request needs a client capability the client never declared.
* Answered with `400 Bad Request`.
*/
public const MISSING_REQUIRED_CLIENT_CAPABILITY = -32021;
/**
* The request's protocol version is unknown to this server, or is a version
* it has chosen not to implement. Answered with `400 Bad Request`.
*/
public const UNSUPPORTED_PROTOCOL_VERSION = -32022;
/**
* @param string|int|null $id The id of the request this answers. `null` only when it could not be
* read — a malformed body, or a notification that was refused — in which
* case the member is omitted rather than sent as an id nobody issued.
* @param int $code the error type that occurred
* @param string $message a short description of the error
* @param mixed|null $data additional information about the error
*/
public function __construct(
public readonly string|int|null $id,
public readonly int $code,
public readonly string $message,
public readonly mixed $data = null,
) {
}
/**
* @param ErrorData $data
*/
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.');
}
// An error response carrying no id is well-formed: it is what a
// receiver sends when the id could not be read off the request.
if (isset($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'])) {
throw new InvalidArgumentException('Invalid or missing "error" field in Error data.');
}
if (!isset($data['error']['code']) || !\is_int($data['error']['code'])) {
throw new InvalidArgumentException('Invalid or missing "code" in Error data.');
}
if (!isset($data['error']['message']) || !\is_string($data['error']['message'])) {
throw new InvalidArgumentException('Invalid or missing "message" in Error data.');
}
return new self($data['id'] ?? null, $data['error']['code'], $data['error']['message'], $data['error']['data'] ?? null);
}
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|null $id = null): self
{
return new self($id, self::INVALID_REQUEST, $message);
}
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|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|null $id = null): self
{
return new self($id, self::INTERNAL_ERROR, $message);
}
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|null $id = null): self
{
return new self($id, self::RESOURCE_NOT_FOUND, $message);
}
final public static function forHeaderMismatch(string $message, string|int|null $id = null): self
{
return new self($id, self::HEADER_MISMATCH, $message);
}
/**
* @param ClientCapabilities $requiredCapabilities the capabilities the server needs to process the request
*/
final public static function forMissingRequiredClientCapability(
string $message,
ClientCapabilities $requiredCapabilities,
string|int|null $id = null,
): self {
return new self($id, self::MISSING_REQUIRED_CLIENT_CAPABILITY, $message, [
'requiredCapabilities' => $requiredCapabilities,
]);
}
/**
* The client is expected to pick a mutually supported version out of
* $supported and retry, so the list travels with the error rather than
* only in the message.
*
* @param list<ProtocolVersion> $supported versions this server does support
*/
final public static function forUnsupportedProtocolVersion(
string $requested,
array $supported,
string|int|null $id = null,
): self {
return new self($id, self::UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', [
'requested' => $requested,
'supported' => array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $supported)),
]);
}
public function getId(): string|int|null
{
return $this->id;
}
/**
* @return array{
* jsonrpc: string,
* id?: string|int,
* error: array{
* code: int,
* message: string,
* data?: mixed,
* },
* }
*/
public function jsonSerialize(): array
{
$error = [
'code' => $this->code,
'message' => $this->message,
];
if (null !== $this->data) {
$error['data'] = $this->data;
}
$data = ['jsonrpc' => MessageInterface::JSONRPC_VERSION];
// Omitted, not empty: `"id": ""` claims the sender issued a request
// with an empty-string id, which is a different statement from "the
// id could not be read".
if (null !== $this->id) {
$data['id'] = $this->id;
}
$data['error'] = $error;
return $data;
}
}