Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/Server/Protocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -324,13 +326,35 @@ 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]);

$error = Error::forInvalidParams($e->getMessage(), $request->getId());
$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]);
Expand Down
98 changes: 98 additions & 0 deletions tests/Unit/Server/ProtocolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, mixed>
*/
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
{
Expand Down