[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
This commit is contained in:
Christopher Hertel 2026-08-14 22:34:01 +02:00 • committed by GitHub
commit c8ed07bba2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -61,6 +61,12 @@ class Protocol
public const SESSION_LOGGING_LEVEL = '_mcp.logging_level';
/**
* Deliberately generic: unexpected throwables carry internal details such as file paths, class
* names and argument types, which must not be handed to the peer. The full exception is logged.
*/
private const INTERNAL_ERROR_MESSAGE = 'Internal server error.';
/**
* @param array<int, RequestHandlerInterface<ResultInterface|array<string, mixed>>> $requestHandlers
* @param array<int, NotificationHandlerInterface> $notificationHandlers
@ -107,6 +113,66 @@ class Protocol
* @param TransportInterface<mixed> $transport
*/
public function processInput(TransportInterface $transport, string $input, ?Uuid $sessionId): void
{
// Last line of defense: a malformed message must never escape as a PHP error and take the
// server process down.
try {
$this->doProcessInput($transport, $input, $sessionId);
} catch (\Throwable $e) {
$this->logger->error(\sprintf('Uncaught exception while processing input: %s', $e->getMessage()), ['exception' => $e]);
// Only a request may be answered. Replying to a notification would violate JSON-RPC,
// and the failure has already been logged.
if (null === $id = self::findResponseId($input)) {
return;
}
try {
$this->sendResponse($transport, Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), null);
} catch (\Throwable $e) {
$this->logger->error(\sprintf('Failed to send internal error response: %s', $e->getMessage()), ['exception' => $e]);
}
}
}
/**
* Determines the id an unprocessable input has to be answered under.
*
* Returns null when the input carries no request at all, in which case it consists of
* notifications only and JSON-RPC forbids answering it. A batch resolves to the empty id
* because its failure cannot be attributed to one of its requests.
*/
private static function findResponseId(string $input): string|int|null
{
try {
$data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if (!\is_array($data)) {
return null;
}
if (!array_is_list($data)) {
$id = $data['id'] ?? null;
return \is_string($id) || \is_int($id) ? $id : null;
}
foreach ($data as $message) {
if (\is_array($message) && isset($message['id'])) {
return '';
}
}
return null;
}
/**
* @param TransportInterface<mixed> $transport
*/
private function doProcessInput(TransportInterface $transport, string $input, ?Uuid $sessionId): void
{
$this->logger->info('Received message to process.', ['message' => $input]);
@ -128,14 +194,25 @@ class Protocol
}
foreach ($messages as $message) {
if ($message instanceof InvalidInputMessageException) {
$this->handleInvalidMessage($transport, $message, $session);
} elseif ($message instanceof Request) {
$this->handleRequest($transport, $message, $session);
} elseif ($message instanceof Response || $message instanceof Error) {
$this->handleResponse($message, $session);
} elseif ($message instanceof Notification) {
$this->handleNotification($message, $session);
// Guarded per message so one faulty message cannot suppress the rest of a batch.
try {
if ($message instanceof InvalidInputMessageException) {
$this->handleInvalidMessage($transport, $message, $session);
} elseif ($message instanceof Request) {
$this->handleRequest($transport, $message, $session);
} elseif ($message instanceof Response || $message instanceof Error) {
$this->handleResponse($message, $session);
} elseif ($message instanceof Notification) {
$this->handleNotification($message, $session);
}
} catch (\Throwable $e) {
$this->logger->error(\sprintf('Uncaught exception while handling message: %s', $e->getMessage()), ['exception' => $e]);
// Only a request may be answered; a notification or a response must not produce one.
if ($message instanceof Request) {
$error = Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $message->getId());
$this->sendResponse($transport, $error, $session);
}
}
}
@ -236,7 +313,7 @@ class Protocol
} catch (\Throwable $e) {
$this->logger->error(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]);
$error = Error::forInternalError($e->getMessage(), $request->getId());
$error = Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $request->getId());
$errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, $e));
$error = $errorEvent->getError();