[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:
parent
9236824b6d
commit
c8ed07bba2
1 changed files with 858 additions and 58 deletions
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue