[Schema][Server] Preserve request id in JSON-RPC error responses instead of fabricating id:"" (#379)

* Preserve request id in JSON-RPC error responses instead of fabricating id:""

When the server received input it could not turn into a valid message, the
error response carried a fabricated empty-string id that was never in the
request, breaking JSON-RPC client correlation.

The id default for the error factories was '' and that default reached the
wire unchanged: unrecoverable parse errors emitted id:"", and for invalid-
but-parseable messages MessageFactory discarded the decoded id so it also
fell back to "".

Now unrecoverable parse errors (-32700) return id: null per JSON-RPC 2.0,
and invalid-but-parseable requests (-32600) preserve the original request
id. Error::$id / getId() and the $id parameter of forParseError() /
forInvalidRequest() are widened to string|int|null, and the recoverable id
is threaded through InvalidInputMessageException.

Fixes #333

* Preserve nullable request id consistently across Error factories and fromArray

- Widen the remaining for*() factories (forMethodNotFound, forInvalidParams,
  forInternalError, forServerError, forResourceNotFound) to string|int|null
  defaulting to null, so no error path can fabricate id:"" again. The only
  defaulting call site (ProtocolVersionMiddleware) now emits id:null instead
  of id:"" for a pre-parse rejection, which is the spec-correct value.
- Accept a null id in Error::fromArray(): require the id key to be present
  (JSON-RPC responses must carry it) but allow its value to be null, so a
  spec-compliant id:null error response round-trips and incoming error
  responses with id:null are accepted instead of rejected.
- Widen the @phpstan-type ErrorData id to string|int|null to match the ctor,
  getId(), and jsonSerialize() shape.

* Document intentional id recovery guard in MessageFactory

* Cover id: 0 and string ids in recoverable-id preservation test

* Fixed client handling of null-id error responses
This commit is contained in:
Valeriu Gutu 2026-08-18 16:47:27 +03:00 • committed by GitHub
commit 1961414432
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 130 additions and 2 deletions

View file

@ -16,4 +16,17 @@ namespace Mcp\Exception;
*/
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;
}
}

View file

@ -148,6 +148,11 @@ final class MessageFactory
$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']))) {
$e->setRequestId($message['id']);
}
$messages[] = $e;
}
}

View file

@ -20,7 +20,7 @@ use Mcp\Schema\Enum\ProtocolVersion;
*
* @phpstan-type ErrorData array{
* jsonrpc: string,
* id: string|int,
* id: string|int|null,
* code: int,
* message: string,
* data?: mixed,

View file

@ -228,7 +228,7 @@ class Protocol
{
$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);
}