From 02cb8e7039c10475970700bb2c92ae5fe75a8af5 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 22 Aug 2026 04:07:23 +0200 Subject: [PATCH] [Server] Align Protocol's exception-to-error mapping with StatelessProtocol --- src/Server/Protocol.php | 24 ++++++++ tests/Unit/Server/ProtocolTest.php | 98 ++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index 4d6e5f5c..21080e25 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -16,6 +16,8 @@ use Mcp\Event\RequestEvent; use Mcp\Event\ResponseEvent; use Mcp\Exception\InvalidInputMessageException; +use Mcp\Exception\LogicException; +use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Notification; @@ -324,6 +326,17 @@ private function handleRequest(TransportInterface $transport, Request $request, } $this->sendResponse($transport, $finalResult, $session); + } catch (MissingRequiredClientCapabilityException $e) { + // Same rendering as StatelessProtocol::toErrorResult(): the + // shared handlers rethrow this expecting -32021, whichever era + // answers. + $this->logger->warning(\sprintf('Missing required client capability: %s', $e->getMessage()), ['exception' => $e]); + + $error = Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $request->getId()); + $errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, $e)); + $error = $errorEvent->getError(); + + $this->sendResponse($transport, $error, $session); } catch (\InvalidArgumentException $e) { $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); @@ -331,6 +344,17 @@ private function handleRequest(TransportInterface $transport, Request $request, $errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, $e)); $error = $errorEvent->getError(); + $this->sendResponse($transport, $error, $session); + } catch (LogicException $e) { + // Guidance for the tool author, not a detail leaked from their + // code or a dependency's — safe to echo back verbatim, as the + // modern era already does. + $this->logger->error(\sprintf('Logic error: %s', $e->getMessage()), ['exception' => $e]); + + $error = Error::forInternalError($e->getMessage(), $request->getId()); + $errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, $e)); + $error = $errorEvent->getError(); + $this->sendResponse($transport, $error, $session); } catch (\Throwable $e) { $this->logger->error(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index 997c39d0..16522ce7 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -15,7 +15,10 @@ use Mcp\Event\NotificationEvent; use Mcp\Event\RequestEvent; use Mcp\Event\ResponseEvent; +use Mcp\Exception\LogicException; +use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\JsonRpc\MessageFactory; +use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; @@ -793,6 +796,101 @@ public function testHandlerUnexpectedExceptionReturnsInternalError(): void $this->assertStringNotContainsString('Unexpected error', $message['error']['message']); } + #[TestDox('Handler throwing MissingRequiredClientCapabilityException returns -32021, as the modern era does')] + public function testHandlerMissingClientCapabilityReturnsCapabilityError(): void + { + $required = new ClientCapabilities(roots: false, sampling: true); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willThrowException(new MissingRequiredClientCapabilityException($required, 'needs sampling')); + + $message = $this->dispatchToHandler($handler); + + $this->assertArrayHasKey('error', $message); + $this->assertEquals(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $message['error']['code']); + $this->assertSame('needs sampling', $message['error']['message']); + $this->assertSame(['sampling' => []], $message['error']['data']['requiredCapabilities']); + } + + #[TestDox('Handler throwing the SDK LogicException echoes its guidance, as the modern era does')] + public function testHandlerSdkLogicExceptionEchoesMessage(): void + { + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willThrowException(new LogicException('Call Builder::setRequestState() first.')); + + $message = $this->dispatchToHandler($handler); + + $this->assertArrayHasKey('error', $message); + $this->assertEquals(Error::INTERNAL_ERROR, $message['error']['code']); + $this->assertSame('Call Builder::setRequestState() first.', $message['error']['message']); + } + + #[TestDox('A plain \LogicException stays a generic internal error')] + public function testHandlerPlainLogicExceptionStaysGeneric(): void + { + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willThrowException(new \LogicException('a dependency detail')); + + $message = $this->dispatchToHandler($handler); + + $this->assertArrayHasKey('error', $message); + $this->assertEquals(Error::INTERNAL_ERROR, $message['error']['code']); + $this->assertSame('Internal server error.', $message['error']['message']); + } + + /** + * Runs one tools/call through a protocol with the given handler and + * returns the decoded message it queued. + * + * @param RequestHandlerInterface<\Mcp\Schema\JsonRpc\ResultInterface> $handler + * + * @return array + */ + private function dispatchToHandler(RequestHandlerInterface $handler): array + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); + + $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: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "test"}}', + $sessionId + ); + + $outgoing = $protocol->consumeOutgoingMessages($sessionId); + $this->assertCount(1, $outgoing); + + return json_decode($outgoing[0]['message'], true); + } + #[TestDox('Notification handler exceptions are caught and logged')] public function testNotificationHandlerExceptionsAreCaught(): void {