From 3d9d5597a2507260116aedb446f49a2408dd76d0 Mon Sep 17 00:00:00 2001 From: Edouard Courty Date: Sun, 30 Nov 2025 21:26:10 +0100 Subject: [PATCH] feat(server): add server lifecycle events --- README.md | 1 + docs/events.md | 94 ++++++++++ src/Event/InitializeRequestEvent.php | 32 ++++ src/Event/PingRequestEvent.php | 32 ++++ src/Event/Prompt/AbstractGetPromptEvent.php | 32 ++++ src/Event/Prompt/GetPromptExceptionEvent.php | 34 ++++ src/Event/Prompt/GetPromptRequestEvent.php | 21 +++ src/Event/Prompt/GetPromptResultEvent.php | 35 ++++ .../Resource/AbstractReadResourceEvent.php | 32 ++++ .../Resource/ReadResourceExceptionEvent.php | 34 ++++ .../Resource/ReadResourceRequestEvent.php | 21 +++ .../Resource/ReadResourceResultEvent.php | 35 ++++ src/Event/Tool/AbstractCallToolEvent.php | 32 ++++ src/Event/Tool/CallToolExceptionEvent.php | 34 ++++ src/Event/Tool/CallToolRequestEvent.php | 21 +++ src/Event/Tool/CallToolResultEvent.php | 35 ++++ src/Server/Builder.php | 10 +- .../Handler/Request/CallToolHandler.php | 14 ++ .../Handler/Request/GetPromptHandler.php | 17 +- .../Handler/Request/InitializeHandler.php | 5 + src/Server/Handler/Request/PingHandler.php | 9 + .../Handler/Request/ReadResourceHandler.php | 18 +- .../Handler/Request/CallToolHandlerTest.php | 154 +++++++++++++++- .../Handler/Request/GetPromptHandlerTest.php | 172 +++++++++++++++++- .../Handler/Request/InitializeHandlerTest.php | 71 ++++++++ .../Handler/Request/PingHandlerTest.php | 18 ++ .../Request/ReadResourceHandlerTest.php | 97 +++++++++- 27 files changed, 1098 insertions(+), 12 deletions(-) create mode 100644 docs/events.md create mode 100644 src/Event/InitializeRequestEvent.php create mode 100644 src/Event/PingRequestEvent.php create mode 100644 src/Event/Prompt/AbstractGetPromptEvent.php create mode 100644 src/Event/Prompt/GetPromptExceptionEvent.php create mode 100644 src/Event/Prompt/GetPromptRequestEvent.php create mode 100644 src/Event/Prompt/GetPromptResultEvent.php create mode 100644 src/Event/Resource/AbstractReadResourceEvent.php create mode 100644 src/Event/Resource/ReadResourceExceptionEvent.php create mode 100644 src/Event/Resource/ReadResourceRequestEvent.php create mode 100644 src/Event/Resource/ReadResourceResultEvent.php create mode 100644 src/Event/Tool/AbstractCallToolEvent.php create mode 100644 src/Event/Tool/CallToolExceptionEvent.php create mode 100644 src/Event/Tool/CallToolRequestEvent.php create mode 100644 src/Event/Tool/CallToolResultEvent.php diff --git a/README.md b/README.md index f58a4b90..ed436fad 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,7 @@ $server = Server::builder() - [Transports](docs/transports.md) - STDIO and HTTP transport setup and usage - [MCP Elements](docs/mcp-elements.md) - Creating tools, resources, and prompts - [Client Communication](docs/client-communication.md) - Communicating back to the client from server-side +- [Events](docs/events.md) - Hooking into server lifecycle with events **Learning:** - [Examples](docs/examples.md) - Comprehensive example walkthroughs diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 00000000..f73261c6 --- /dev/null +++ b/docs/events.md @@ -0,0 +1,94 @@ +# Events + +The MCP SDK provides a PSR-14 compatible event system that allows you to hook into the server's lifecycle. Events enable logging, monitoring, validation, caching, and other custom behaviors. + +## Table of Contents + +- [Setup](#setup) +- [List Change Events](#list-change-events) +- [Lifecycle Events](#lifecycle-events) + - [Tool Events](#tool-events) + - [Prompt Events](#prompt-events) + - [Resource Events](#resource-events) +- [Server Events](#server-events) +- [Use Cases](#use-cases) + +## Setup + +Configure an event dispatcher when building your server: + +```php +use Mcp\Server; +use Symfony\Component\EventDispatcher\EventDispatcher; + +$dispatcher = new EventDispatcher(); + +// Register your listeners +$dispatcher->addListener(CallToolResultEvent::class, function (CallToolResultEvent $event) { + // Handle event +}); + +$server = Server::builder() + ->setEventDispatcher($dispatcher) + ->build(); +``` + +## List Change Events + +These events are dispatched when the lists of available capabilities change: + +| Event | Description | +|-------|-------------| +| `ToolListChangedEvent` | Dispatched when the list of available tools changes | +| `ResourceListChangedEvent` | Dispatched when the list of available resources changes | +| `ResourceTemplateListChangedEvent` | Dispatched when the list of available resource templates changes | +| `PromptListChangedEvent` | Dispatched when the list of available prompts changes | + +These events carry no data and are used to notify clients that they should refresh their capability lists. + +```php +use Mcp\Event\ToolListChangedEvent; + +$dispatcher->addListener(ToolListChangedEvent::class, function (ToolListChangedEvent $event) { + $logger->info('Tool list has changed, clients should refresh'); +}); +``` + +## Lifecycle Events + +### Tool Events + +Events for the tool call lifecycle: + +| Event | Timing | Data | +|-------|----------------------------|------| +| `CallToolRequestEvent` | Before tool execution | `request` | +| `CallToolResultEvent` | After successful execution | `request`, `result` | +| `CallToolExceptionEvent` | On uncaught exception | `request`, `throwable` | + +### Prompt Events + +Events for the prompt retrieval lifecycle: + +| Event | Timing | Data | +|-------|----------------------------|------| +| `GetPromptRequestEvent` | Before prompt execution | `request` | +| `GetPromptResultEvent` | After successful execution | `request`, `result` | +| `GetPromptExceptionEvent` | On uncaught exception | `request`, `throwable` | + +### Resource Events + +Events for the resource read lifecycle: + +| Event | Timing | Data | +|-------|-----------------------|------| +| `ReadResourceRequestEvent` | Before resource read | `request` | +| `ReadResourceResultEvent` | After successful read | `request`, `result` | +| `ReadResourceExceptionEvent` | On uncaught exception | `request`, `throwable` | + +## Server Events + +| Event | Timing | Data | +|-------|--------|------| +| `InitializeRequestEvent` | When client sends initialize request | `request` (InitializeRequest) | +| `PingRequestEvent` | When client sends ping request | `request` (PingRequest) | diff --git a/src/Event/InitializeRequestEvent.php b/src/Event/InitializeRequestEvent.php new file mode 100644 index 00000000..780f6172 --- /dev/null +++ b/src/Event/InitializeRequestEvent.php @@ -0,0 +1,32 @@ + + */ +class InitializeRequestEvent +{ + public function __construct( + private readonly InitializeRequest $request, + ) { + } + + public function getRequest(): InitializeRequest + { + return $this->request; + } +} diff --git a/src/Event/PingRequestEvent.php b/src/Event/PingRequestEvent.php new file mode 100644 index 00000000..a37f4cd1 --- /dev/null +++ b/src/Event/PingRequestEvent.php @@ -0,0 +1,32 @@ + + */ +class PingRequestEvent +{ + public function __construct( + private readonly PingRequest $request, + ) { + } + + public function getRequest(): PingRequest + { + return $this->request; + } +} diff --git a/src/Event/Prompt/AbstractGetPromptEvent.php b/src/Event/Prompt/AbstractGetPromptEvent.php new file mode 100644 index 00000000..0a64e584 --- /dev/null +++ b/src/Event/Prompt/AbstractGetPromptEvent.php @@ -0,0 +1,32 @@ + + */ +abstract class AbstractGetPromptEvent +{ + public function __construct( + private readonly GetPromptRequest $request, + ) { + } + + public function getRequest(): GetPromptRequest + { + return $this->request; + } +} diff --git a/src/Event/Prompt/GetPromptExceptionEvent.php b/src/Event/Prompt/GetPromptExceptionEvent.php new file mode 100644 index 00000000..94fee8ee --- /dev/null +++ b/src/Event/Prompt/GetPromptExceptionEvent.php @@ -0,0 +1,34 @@ + + */ +class GetPromptExceptionEvent extends AbstractGetPromptEvent +{ + public function __construct( + GetPromptRequest $request, + private readonly \Throwable $throwable, + ) { + parent::__construct($request); + } + + public function getThrowable(): \Throwable + { + return $this->throwable; + } +} diff --git a/src/Event/Prompt/GetPromptRequestEvent.php b/src/Event/Prompt/GetPromptRequestEvent.php new file mode 100644 index 00000000..399ccd67 --- /dev/null +++ b/src/Event/Prompt/GetPromptRequestEvent.php @@ -0,0 +1,21 @@ + + */ +class GetPromptRequestEvent extends AbstractGetPromptEvent +{ +} diff --git a/src/Event/Prompt/GetPromptResultEvent.php b/src/Event/Prompt/GetPromptResultEvent.php new file mode 100644 index 00000000..af8d08b3 --- /dev/null +++ b/src/Event/Prompt/GetPromptResultEvent.php @@ -0,0 +1,35 @@ + + */ +class GetPromptResultEvent extends AbstractGetPromptEvent +{ + public function __construct( + GetPromptRequest $request, + private readonly GetPromptResult $result, + ) { + parent::__construct($request); + } + + public function getResult(): GetPromptResult + { + return $this->result; + } +} diff --git a/src/Event/Resource/AbstractReadResourceEvent.php b/src/Event/Resource/AbstractReadResourceEvent.php new file mode 100644 index 00000000..e6d9a32a --- /dev/null +++ b/src/Event/Resource/AbstractReadResourceEvent.php @@ -0,0 +1,32 @@ + + */ +abstract class AbstractReadResourceEvent +{ + public function __construct( + private readonly ReadResourceRequest $request, + ) { + } + + public function getRequest(): ReadResourceRequest + { + return $this->request; + } +} diff --git a/src/Event/Resource/ReadResourceExceptionEvent.php b/src/Event/Resource/ReadResourceExceptionEvent.php new file mode 100644 index 00000000..d8c335db --- /dev/null +++ b/src/Event/Resource/ReadResourceExceptionEvent.php @@ -0,0 +1,34 @@ + + */ +class ReadResourceExceptionEvent extends AbstractReadResourceEvent +{ + public function __construct( + ReadResourceRequest $request, + private readonly \Throwable $throwable, + ) { + parent::__construct($request); + } + + public function getThrowable(): \Throwable + { + return $this->throwable; + } +} diff --git a/src/Event/Resource/ReadResourceRequestEvent.php b/src/Event/Resource/ReadResourceRequestEvent.php new file mode 100644 index 00000000..843f2942 --- /dev/null +++ b/src/Event/Resource/ReadResourceRequestEvent.php @@ -0,0 +1,21 @@ + + */ +class ReadResourceRequestEvent extends AbstractReadResourceEvent +{ +} diff --git a/src/Event/Resource/ReadResourceResultEvent.php b/src/Event/Resource/ReadResourceResultEvent.php new file mode 100644 index 00000000..810629e1 --- /dev/null +++ b/src/Event/Resource/ReadResourceResultEvent.php @@ -0,0 +1,35 @@ + + */ +class ReadResourceResultEvent extends AbstractReadResourceEvent +{ + public function __construct( + ReadResourceRequest $request, + private readonly ReadResourceResult $result, + ) { + parent::__construct($request); + } + + public function getResult(): ReadResourceResult + { + return $this->result; + } +} diff --git a/src/Event/Tool/AbstractCallToolEvent.php b/src/Event/Tool/AbstractCallToolEvent.php new file mode 100644 index 00000000..a2c16ae9 --- /dev/null +++ b/src/Event/Tool/AbstractCallToolEvent.php @@ -0,0 +1,32 @@ + + */ +abstract class AbstractCallToolEvent +{ + public function __construct( + private readonly CallToolRequest $request, + ) { + } + + public function getRequest(): CallToolRequest + { + return $this->request; + } +} diff --git a/src/Event/Tool/CallToolExceptionEvent.php b/src/Event/Tool/CallToolExceptionEvent.php new file mode 100644 index 00000000..35fc9d0c --- /dev/null +++ b/src/Event/Tool/CallToolExceptionEvent.php @@ -0,0 +1,34 @@ + + */ +class CallToolExceptionEvent extends AbstractCallToolEvent +{ + public function __construct( + CallToolRequest $request, + private readonly \Throwable $throwable, + ) { + parent::__construct($request); + } + + public function getThrowable(): \Throwable + { + return $this->throwable; + } +} diff --git a/src/Event/Tool/CallToolRequestEvent.php b/src/Event/Tool/CallToolRequestEvent.php new file mode 100644 index 00000000..378da975 --- /dev/null +++ b/src/Event/Tool/CallToolRequestEvent.php @@ -0,0 +1,21 @@ + + */ +class CallToolRequestEvent extends AbstractCallToolEvent +{ +} diff --git a/src/Event/Tool/CallToolResultEvent.php b/src/Event/Tool/CallToolResultEvent.php new file mode 100644 index 00000000..31394a03 --- /dev/null +++ b/src/Event/Tool/CallToolResultEvent.php @@ -0,0 +1,35 @@ + + */ +class CallToolResultEvent extends AbstractCallToolEvent +{ + public function __construct( + CallToolRequest $request, + private readonly CallToolResult $result, + ) { + parent::__construct($request); + } + + public function getResult(): CallToolResult + { + return $this->result; + } +} diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 4142b97a..57350a10 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -487,16 +487,16 @@ public function build(): Server $referenceHandler = new ReferenceHandler($container); $requestHandlers = array_merge($this->requestHandlers, [ - new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger), + new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger, $this->eventDispatcher), new Handler\Request\CompletionCompleteHandler($registry, $container), - new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), - new Handler\Request\InitializeHandler($configuration), + new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger, $this->eventDispatcher), + new Handler\Request\InitializeHandler($configuration, $this->eventDispatcher), new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit), new Handler\Request\ListResourcesHandler($registry, $this->paginationLimit), new Handler\Request\ListResourceTemplatesHandler($registry, $this->paginationLimit), new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), - new Handler\Request\PingHandler(), - new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger), + new Handler\Request\PingHandler($this->eventDispatcher), + new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger, $this->eventDispatcher), ]); $notificationHandlers = array_merge($this->notificationHandlers, [ diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index e0430802..c0b70229 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -13,6 +13,9 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; +use Mcp\Event\Tool\CallToolExceptionEvent; +use Mcp\Event\Tool\CallToolRequestEvent; +use Mcp\Event\Tool\CallToolResultEvent; use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\TextContent; @@ -22,6 +25,7 @@ use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; use Mcp\Server\Session\SessionInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -37,6 +41,7 @@ public function __construct( private readonly RegistryInterface $registry, private readonly ReferenceHandlerInterface $referenceHandler, private readonly LoggerInterface $logger = new NullLogger(), + private readonly ?EventDispatcherInterface $eventDispatcher = null, ) { } @@ -57,6 +62,9 @@ public function handle(Request $request, SessionInterface $session): Response|Er $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); + $toolCallRequestEvent = new CallToolRequestEvent($request); + $this->eventDispatcher?->dispatch($toolCallRequestEvent); + try { $reference = $this->registry->getTool($toolName); @@ -73,6 +81,9 @@ public function handle(Request $request, SessionInterface $session): Response|Er 'result_type' => \gettype($result), ]); + $toolCallResultEvent = new CallToolResultEvent($request, $result); + $this->eventDispatcher?->dispatch($toolCallResultEvent); + return new Response($request->getId(), $result); } catch (ToolCallException $e) { $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ @@ -93,6 +104,9 @@ public function handle(Request $request, SessionInterface $session): Response|Er 'exception' => $e->getMessage(), ]); + $toolCallExceptionEvent = new CallToolExceptionEvent($request, $e); + $this->eventDispatcher?->dispatch($toolCallExceptionEvent); + return Error::forInternalError('Error while executing tool', $request->getId()); } } diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index 274b8422..34193a9b 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -13,6 +13,9 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; +use Mcp\Event\Prompt\GetPromptExceptionEvent; +use Mcp\Event\Prompt\GetPromptRequestEvent; +use Mcp\Event\Prompt\GetPromptResultEvent; use Mcp\Exception\PromptGetException; use Mcp\Exception\PromptNotFoundException; use Mcp\Schema\JsonRpc\Error; @@ -21,6 +24,7 @@ use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; use Mcp\Server\Session\SessionInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -35,6 +39,7 @@ public function __construct( private readonly RegistryInterface $registry, private readonly ReferenceHandlerInterface $referenceHandler, private readonly LoggerInterface $logger = new NullLogger(), + private readonly ?EventDispatcherInterface $eventDispatcher = null, ) { } @@ -53,6 +58,9 @@ public function handle(Request $request, SessionInterface $session): Response|Er $promptName = $request->name; $arguments = $request->arguments ?? []; + $getPromptRequestEvent = new GetPromptRequestEvent($request); + $this->eventDispatcher?->dispatch($getPromptRequestEvent); + try { $reference = $this->registry->getPrompt($promptName); @@ -61,8 +69,12 @@ public function handle(Request $request, SessionInterface $session): Response|Er $result = $this->referenceHandler->handle($reference, $arguments); $formatted = $reference->formatResult($result); + $result = new GetPromptResult($formatted); + + $getPromptResultEvent = new GetPromptResultEvent($request, $result); + $this->eventDispatcher?->dispatch($getPromptResultEvent); - return new Response($request->getId(), new GetPromptResult($formatted)); + return new Response($request->getId(), $result); } catch (PromptGetException $e) { $this->logger->error(\sprintf('Error while handling prompt "%s": "%s".', $promptName, $e->getMessage())); @@ -74,6 +86,9 @@ public function handle(Request $request, SessionInterface $session): Response|Er } catch (\Throwable $e) { $this->logger->error(\sprintf('Unexpected error while handling prompt "%s": "%s".', $promptName, $e->getMessage())); + $getPromptExceptionEvent = new GetPromptExceptionEvent($request, $e); + $this->eventDispatcher?->dispatch($getPromptExceptionEvent); + return Error::forInternalError('Error while handling prompt', $request->getId()); } } diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index d814d9dd..f8e490d7 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Handler\Request; +use Mcp\Event\InitializeRequestEvent; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -19,6 +20,7 @@ use Mcp\Schema\ServerCapabilities; use Mcp\Server\Configuration; use Mcp\Server\Session\SessionInterface; +use Psr\EventDispatcher\EventDispatcherInterface; /** * @implements RequestHandlerInterface @@ -29,6 +31,7 @@ final class InitializeHandler implements RequestHandlerInterface { public function __construct( public readonly ?Configuration $configuration = null, + private readonly ?EventDispatcherInterface $eventDispatcher = null, ) { } @@ -46,6 +49,8 @@ public function handle(Request $request, SessionInterface $session): Response $session->set('client_info', $request->clientInfo->jsonSerialize()); + $this->eventDispatcher?->dispatch(new InitializeRequestEvent($request)); + return new Response( $request->getId(), new InitializeResult( diff --git a/src/Server/Handler/Request/PingHandler.php b/src/Server/Handler/Request/PingHandler.php index 507680fa..01de9589 100644 --- a/src/Server/Handler/Request/PingHandler.php +++ b/src/Server/Handler/Request/PingHandler.php @@ -11,11 +11,13 @@ namespace Mcp\Server\Handler\Request; +use Mcp\Event\PingRequestEvent; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\PingRequest; use Mcp\Schema\Result\EmptyResult; use Mcp\Server\Session\SessionInterface; +use Psr\EventDispatcher\EventDispatcherInterface; /** * @implements RequestHandlerInterface @@ -24,6 +26,11 @@ */ final class PingHandler implements RequestHandlerInterface { + public function __construct( + private readonly ?EventDispatcherInterface $eventDispatcher = null, + ) { + } + public function supports(Request $request): bool { return $request instanceof PingRequest; @@ -36,6 +43,8 @@ public function handle(Request $request, SessionInterface $session): Response { \assert($request instanceof PingRequest); + $this->eventDispatcher?->dispatch(new PingRequestEvent($request)); + return new Response($request->getId(), new EmptyResult()); } } diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index f955f4b1..495842c0 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -14,6 +14,9 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\RegistryInterface; +use Mcp\Event\Resource\ReadResourceExceptionEvent; +use Mcp\Event\Resource\ReadResourceRequestEvent; +use Mcp\Event\Resource\ReadResourceResultEvent; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ResourceReadException; use Mcp\Schema\JsonRpc\Error; @@ -22,6 +25,7 @@ use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\Session\SessionInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -36,6 +40,7 @@ public function __construct( private readonly RegistryInterface $referenceProvider, private readonly ReferenceHandlerInterface $referenceHandler, private readonly LoggerInterface $logger = new NullLogger(), + private readonly ?EventDispatcherInterface $eventDispatcher = null, ) { } @@ -55,6 +60,9 @@ public function handle(Request $request, SessionInterface $session): Response|Er $this->logger->debug('Reading resource', ['uri' => $uri]); + $resourceReadRequestEvent = new ReadResourceRequestEvent($request); + $this->eventDispatcher?->dispatch($resourceReadRequestEvent); + try { $reference = $this->referenceProvider->getResource($uri); @@ -74,7 +82,12 @@ public function handle(Request $request, SessionInterface $session): Response|Er $formatted = $reference->formatResult($result, $uri, $reference->schema->mimeType); } - return new Response($request->getId(), new ReadResourceResult($formatted)); + $result = new ReadResourceResult($formatted); + + $readResourceResultEvent = new ReadResourceResultEvent($request, $result); + $this->eventDispatcher?->dispatch($readResourceResultEvent); + + return new Response($request->getId(), $result); } catch (ResourceReadException $e) { $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage())); @@ -86,6 +99,9 @@ public function handle(Request $request, SessionInterface $session): Response|Er } catch (\Throwable $e) { $this->logger->error(\sprintf('Unexpected error while reading resource "%s": "%s".', $uri, $e->getMessage())); + $readResourceExceptionEvent = new ReadResourceExceptionEvent($request, $e); + $this->eventDispatcher?->dispatch($readResourceExceptionEvent); + return Error::forInternalError('Error while reading resource', $request->getId()); } } diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 5b03f2bb..810447cf 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -14,6 +14,10 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ToolReference; use Mcp\Capability\RegistryInterface; +use Mcp\Event\Tool\AbstractCallToolEvent; +use Mcp\Event\Tool\CallToolExceptionEvent; +use Mcp\Event\Tool\CallToolRequestEvent; +use Mcp\Event\Tool\CallToolResultEvent; use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\TextContent; @@ -25,6 +29,7 @@ use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; class CallToolHandlerTest extends TestCase @@ -34,6 +39,10 @@ class CallToolHandlerTest extends TestCase private ReferenceHandlerInterface&MockObject $referenceHandler; private LoggerInterface&MockObject $logger; private SessionInterface&MockObject $session; + private EventDispatcherInterface&MockObject $eventDispatcher; + + /** @var AbstractCallToolEvent[] */ + private array $dispatchedEvents = []; protected function setUp(): void { @@ -41,11 +50,22 @@ protected function setUp(): void $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->session = $this->createMock(SessionInterface::class); + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); + + // Store all dispatched events for further assertion + $this->eventDispatcher + ->method('dispatch') + ->with($this->callback(function (AbstractCallToolEvent $event) { + $this->dispatchedEvents[] = $event; + + return true; + })); $this->handler = new CallToolHandler( $this->registry, $this->referenceHandler, $this->logger, + $this->eventDispatcher, ); } @@ -74,11 +94,13 @@ public function testHandleSuccessfulToolCall(): void ->with($toolReference, ['name' => 'John', '_session' => $this->session]) ->willReturn('Hello, John!'); + $resultContent = [new TextContent('Hello, John!')]; + $toolReference ->expects($this->once()) ->method('formatResult') ->with('Hello, John!') - ->willReturn([new TextContent('Hello, John!')]); + ->willReturn($resultContent); // Logger may be called for debugging, so we don't assert never() @@ -87,6 +109,17 @@ public function testHandleSuccessfulToolCall(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $callToolRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(CallToolRequestEvent::class, $callToolRequestEvent); + $this->assertSame($request, $callToolRequestEvent->getRequest()); + + $callToolResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(CallToolResultEvent::class, $callToolResultEvent); + $this->assertSame($request, $callToolResultEvent->getRequest()); + $this->assertSame($resultContent, $callToolResultEvent->getResult()->content); } public function testHandleToolCallWithEmptyArguments(): void @@ -117,6 +150,17 @@ public function testHandleToolCallWithEmptyArguments(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $callToolRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(CallToolRequestEvent::class, $callToolRequestEvent); + $this->assertSame($request, $callToolRequestEvent->getRequest()); + + $callToolResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(CallToolResultEvent::class, $callToolResultEvent); + $this->assertSame($request, $callToolResultEvent->getRequest()); + $this->assertEquals($expectedResult, $callToolResultEvent->getResult()); } public function testHandleToolCallWithComplexArguments(): void @@ -154,6 +198,17 @@ public function testHandleToolCallWithComplexArguments(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $callToolRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(CallToolRequestEvent::class, $callToolRequestEvent); + $this->assertSame($request, $callToolRequestEvent->getRequest()); + + $callToolResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(CallToolResultEvent::class, $callToolResultEvent); + $this->assertSame($request, $callToolResultEvent->getRequest()); + $this->assertEquals($expectedResult, $callToolResultEvent->getResult()); } public function testHandleToolNotFoundExceptionReturnsError(): void @@ -175,6 +230,12 @@ public function testHandleToolNotFoundExceptionReturnsError(): void $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); + + $this->assertCount(1, $this->dispatchedEvents); + + $callToolRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(CallToolRequestEvent::class, $callToolRequestEvent); + $this->assertSame($request, $callToolRequestEvent->getRequest()); } public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): void @@ -210,6 +271,12 @@ public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): voi $this->assertCount(1, $result->content); $this->assertInstanceOf(TextContent::class, $result->content[0]); $this->assertEquals('Tool execution failed', $result->content[0]->text); + + $this->assertCount(1, $this->dispatchedEvents); + + $callToolRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(CallToolRequestEvent::class, $callToolRequestEvent); + $this->assertSame($request, $callToolRequestEvent->getRequest()); } public function testHandleWithNullResult(): void @@ -240,11 +307,29 @@ public function testHandleWithNullResult(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $callToolRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(CallToolRequestEvent::class, $callToolRequestEvent); + $this->assertSame($request, $callToolRequestEvent->getRequest()); + + $callToolResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(CallToolResultEvent::class, $callToolResultEvent); + $this->assertSame($request, $callToolResultEvent->getRequest()); + $this->assertSame([], $callToolResultEvent->getResult()->content); } public function testConstructorWithDefaultLogger(): void { - $handler = new CallToolHandler($this->registry, $this->referenceHandler); + $handler = new CallToolHandler($this->registry, $this->referenceHandler, eventDispatcher: $this->createMock(EventDispatcherInterface::class)); + + $this->assertInstanceOf(CallToolHandler::class, $handler); + } + + public function testConstructorWithoutEventDispatched(): void + { + $handler = new CallToolHandler($this->registry, $this->referenceHandler, eventDispatcher: null); $this->assertInstanceOf(CallToolHandler::class, $handler); } @@ -290,6 +375,12 @@ public function testHandleLogsErrorWithCorrectParameters(): void $this->assertCount(1, $result->content); $this->assertInstanceOf(TextContent::class, $result->content[0]); $this->assertEquals('Custom error message', $result->content[0]->text); + + $this->assertCount(1, $this->dispatchedEvents); + + $callToolRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(CallToolRequestEvent::class, $callToolRequestEvent); + $this->assertSame($request, $callToolRequestEvent->getRequest()); } public function testHandleGenericExceptionReturnsError(): void @@ -317,6 +408,17 @@ public function testHandleGenericExceptionReturnsError(): void $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::INTERNAL_ERROR, $response->code); $this->assertEquals('Error while executing tool', $response->message); + + $this->assertCount(2, $this->dispatchedEvents); + + $callToolRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(CallToolRequestEvent::class, $callToolRequestEvent); + $this->assertSame($request, $callToolRequestEvent->getRequest()); + + $callToolResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(CallToolExceptionEvent::class, $callToolResultEvent); + $this->assertSame($request, $callToolResultEvent->getRequest()); + $this->assertSame($exception, $callToolResultEvent->getThrowable()); } public function testHandleWithSpecialCharactersInToolName(): void @@ -337,16 +439,29 @@ public function testHandleWithSpecialCharactersInToolName(): void ->with($toolReference, ['_session' => $this->session]) ->willReturn('Special tool result'); + $content = [new TextContent('Special tool result')]; + $toolReference ->expects($this->once()) ->method('formatResult') ->with('Special tool result') - ->willReturn([new TextContent('Special tool result')]); + ->willReturn($content); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $callToolRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(CallToolRequestEvent::class, $callToolRequestEvent); + $this->assertSame($request, $callToolRequestEvent->getRequest()); + + $callToolResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(CallToolResultEvent::class, $callToolResultEvent); + $this->assertSame($request, $callToolResultEvent->getRequest()); + $this->assertSame($content, $callToolResultEvent->getResult()->content); } public function testHandleWithSpecialCharactersInArguments(): void @@ -382,6 +497,17 @@ public function testHandleWithSpecialCharactersInArguments(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $callToolRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(CallToolRequestEvent::class, $callToolRequestEvent); + $this->assertSame($request, $callToolRequestEvent->getRequest()); + + $callToolResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(CallToolResultEvent::class, $callToolResultEvent); + $this->assertSame($request, $callToolResultEvent->getRequest()); + $this->assertEquals($expectedResult, $callToolResultEvent->getResult()); } public function testHandleReturnsStructuredContentResult(): void @@ -411,6 +537,17 @@ public function testHandleReturnsStructuredContentResult(): void $this->assertInstanceOf(Response::class, $response); $this->assertSame($structuredResult, $response->result); $this->assertEquals(['result' => 'Rendered results'], $response->result->jsonSerialize()['structuredContent'] ?? []); + + $this->assertCount(2, $this->dispatchedEvents); + + $callToolRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(CallToolRequestEvent::class, $callToolRequestEvent); + $this->assertSame($request, $callToolRequestEvent->getRequest()); + + $callToolResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(CallToolResultEvent::class, $callToolResultEvent); + $this->assertSame($request, $callToolResultEvent->getRequest()); + $this->assertEquals($structuredResult, $callToolResultEvent->getResult()); } public function testHandleReturnsCallToolResult(): void @@ -440,6 +577,17 @@ public function testHandleReturnsCallToolResult(): void $this->assertInstanceOf(Response::class, $response); $this->assertSame($callToolResult, $response->result); $this->assertArrayNotHasKey('structuredContent', $response->result->jsonSerialize()); + + $this->assertCount(2, $this->dispatchedEvents); + + $callToolRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(CallToolRequestEvent::class, $callToolRequestEvent); + $this->assertSame($request, $callToolRequestEvent->getRequest()); + + $callToolResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(CallToolResultEvent::class, $callToolResultEvent); + $this->assertSame($request, $callToolResultEvent->getRequest()); + $this->assertEquals($callToolResult, $callToolResultEvent->getResult()); } /** diff --git a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php index 95b2e5c1..9d1b2f6a 100644 --- a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -14,6 +14,10 @@ use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; +use Mcp\Event\Prompt\AbstractGetPromptEvent; +use Mcp\Event\Prompt\GetPromptExceptionEvent; +use Mcp\Event\Prompt\GetPromptRequestEvent; +use Mcp\Event\Prompt\GetPromptResultEvent; use Mcp\Exception\PromptGetException; use Mcp\Exception\PromptNotFoundException; use Mcp\Schema\Content\PromptMessage; @@ -27,6 +31,7 @@ use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; class GetPromptHandlerTest extends TestCase { @@ -34,14 +39,31 @@ class GetPromptHandlerTest extends TestCase private RegistryInterface&MockObject $referenceProvider; private ReferenceHandlerInterface&MockObject $referenceHandler; private SessionInterface&MockObject $session; + private EventDispatcherInterface&MockObject $eventDispatcher; + + /** @var AbstractGetPromptEvent[] */ + private array $dispatchedEvents = []; protected function setUp(): void { $this->referenceProvider = $this->createMock(RegistryInterface::class); $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->session = $this->createMock(SessionInterface::class); + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); + + $this->eventDispatcher + ->method('dispatch') + ->with($this->callback(function (AbstractGetPromptEvent $event) { + $this->dispatchedEvents[] = $event; - $this->handler = new GetPromptHandler($this->referenceProvider, $this->referenceHandler); + return true; + })); + + $this->handler = new GetPromptHandler( + $this->referenceProvider, + $this->referenceHandler, + eventDispatcher: $this->eventDispatcher, + ); } public function testSupportsGetPromptRequest(): void @@ -84,6 +106,17 @@ public function testHandleSuccessfulPromptGet(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $getPromptRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(GetPromptRequestEvent::class, $getPromptRequestEvent); + $this->assertSame($request, $getPromptRequestEvent->getRequest()); + + $getPromptResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(GetPromptResultEvent::class, $getPromptResultEvent); + $this->assertSame($request, $getPromptResultEvent->getRequest()); + $this->assertEquals($expectedResult, $getPromptResultEvent->getResult()); } public function testHandlePromptGetWithArguments(): void @@ -125,6 +158,17 @@ public function testHandlePromptGetWithArguments(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $getPromptRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(GetPromptRequestEvent::class, $getPromptRequestEvent); + $this->assertSame($request, $getPromptRequestEvent->getRequest()); + + $getPromptResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(GetPromptResultEvent::class, $getPromptResultEvent); + $this->assertSame($request, $getPromptResultEvent->getRequest()); + $this->assertEquals($expectedResult, $getPromptResultEvent->getResult()); } public function testHandlePromptGetWithNullArguments(): void @@ -158,6 +202,17 @@ public function testHandlePromptGetWithNullArguments(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $getPromptRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(GetPromptRequestEvent::class, $getPromptRequestEvent); + $this->assertSame($request, $getPromptRequestEvent->getRequest()); + + $getPromptResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(GetPromptResultEvent::class, $getPromptResultEvent); + $this->assertSame($request, $getPromptResultEvent->getRequest()); + $this->assertEquals($expectedResult, $getPromptResultEvent->getResult()); } public function testHandlePromptGetWithEmptyArguments(): void @@ -191,6 +246,17 @@ public function testHandlePromptGetWithEmptyArguments(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $getPromptRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(GetPromptRequestEvent::class, $getPromptRequestEvent); + $this->assertSame($request, $getPromptRequestEvent->getRequest()); + + $getPromptResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(GetPromptResultEvent::class, $getPromptResultEvent); + $this->assertSame($request, $getPromptResultEvent->getRequest()); + $this->assertEquals($expectedResult, $getPromptResultEvent->getResult()); } public function testHandlePromptGetWithMultipleMessages(): void @@ -226,6 +292,17 @@ public function testHandlePromptGetWithMultipleMessages(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $getPromptRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(GetPromptRequestEvent::class, $getPromptRequestEvent); + $this->assertSame($request, $getPromptRequestEvent->getRequest()); + + $getPromptResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(GetPromptResultEvent::class, $getPromptResultEvent); + $this->assertSame($request, $getPromptResultEvent->getRequest()); + $this->assertEquals($expectedResult, $getPromptResultEvent->getResult()); } public function testHandlePromptNotFoundExceptionReturnsError(): void @@ -245,6 +322,12 @@ public function testHandlePromptNotFoundExceptionReturnsError(): void $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); $this->assertEquals('Prompt not found: "nonexistent_prompt".', $response->message); + + $this->assertCount(1, $this->dispatchedEvents); + + $getPromptRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(GetPromptRequestEvent::class, $getPromptRequestEvent); + $this->assertSame($request, $getPromptRequestEvent->getRequest()); } public function testHandlePromptGetExceptionReturnsError(): void @@ -264,6 +347,12 @@ public function testHandlePromptGetExceptionReturnsError(): void $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::INTERNAL_ERROR, $response->code); $this->assertEquals('Failed to get prompt', $response->message); + + $this->assertCount(1, $this->dispatchedEvents); + + $getPromptRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(GetPromptRequestEvent::class, $getPromptRequestEvent); + $this->assertSame($request, $getPromptRequestEvent->getRequest()); } public function testHandlePromptGetWithComplexArguments(): void @@ -312,6 +401,17 @@ public function testHandlePromptGetWithComplexArguments(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $getPromptRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(GetPromptRequestEvent::class, $getPromptRequestEvent); + $this->assertSame($request, $getPromptRequestEvent->getRequest()); + + $getPromptResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(GetPromptResultEvent::class, $getPromptResultEvent); + $this->assertSame($request, $getPromptResultEvent->getRequest()); + $this->assertEquals($expectedResult, $getPromptResultEvent->getResult()); } public function testHandlePromptGetWithSpecialCharacters(): void @@ -350,6 +450,17 @@ public function testHandlePromptGetWithSpecialCharacters(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $getPromptRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(GetPromptRequestEvent::class, $getPromptRequestEvent); + $this->assertSame($request, $getPromptRequestEvent->getRequest()); + + $getPromptResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(GetPromptResultEvent::class, $getPromptResultEvent); + $this->assertSame($request, $getPromptResultEvent->getRequest()); + $this->assertEquals($expectedResult, $getPromptResultEvent->getResult()); } public function testHandlePromptGetReturnsEmptyMessages(): void @@ -380,6 +491,17 @@ public function testHandlePromptGetReturnsEmptyMessages(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $getPromptRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(GetPromptRequestEvent::class, $getPromptRequestEvent); + $this->assertSame($request, $getPromptRequestEvent->getRequest()); + + $getPromptResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(GetPromptResultEvent::class, $getPromptResultEvent); + $this->assertSame($request, $getPromptResultEvent->getRequest()); + $this->assertEquals($expectedResult, $getPromptResultEvent->getResult()); } public function testHandlePromptGetWithLargeNumberOfArguments(): void @@ -418,6 +540,54 @@ public function testHandlePromptGetWithLargeNumberOfArguments(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $getPromptRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(GetPromptRequestEvent::class, $getPromptRequestEvent); + $this->assertSame($request, $getPromptRequestEvent->getRequest()); + + $getPromptResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(GetPromptResultEvent::class, $getPromptResultEvent); + $this->assertSame($request, $getPromptResultEvent->getRequest()); + $this->assertEquals($expectedResult, $getPromptResultEvent->getResult()); + } + + public function testHandleGenericExceptionReturnsError(): void + { + $request = $this->createGetPromptRequest('failing_prompt'); + $exception = new \RuntimeException('Internal database connection failed'); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('failing_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, ['_session' => $this->session]) + ->willThrowException($exception); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while handling prompt', $response->message); + + $this->assertCount(2, $this->dispatchedEvents); + + $getPromptRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(GetPromptRequestEvent::class, $getPromptRequestEvent); + $this->assertSame($request, $getPromptRequestEvent->getRequest()); + + $getPromptExceptionEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(GetPromptExceptionEvent::class, $getPromptExceptionEvent); + $this->assertSame($request, $getPromptExceptionEvent->getRequest()); + $this->assertSame($exception, $getPromptExceptionEvent->getThrowable()); } /** diff --git a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php index 36c36f14..6b018e54 100644 --- a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; +use Mcp\Event\InitializeRequestEvent; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\MessageInterface; @@ -22,6 +23,7 @@ use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; class InitializeHandlerTest extends TestCase { @@ -73,4 +75,73 @@ public function testHandleUsesConfigurationProtocolVersion(): void $result->jsonSerialize()['protocolVersion'] ); } + + #[TestDox('dispatches InitializeRequestEvent when event dispatcher is provided')] + public function testDispatchesInitializeRequestEvent(): void + { + $configuration = new Configuration( + serverInfo: new Implementation('server', '1.0.0'), + capabilities: new ServerCapabilities(), + ); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $handler = new InitializeHandler($configuration, $eventDispatcher); + + $session = $this->createMock(SessionInterface::class); + $session->method('set'); + + $request = InitializeRequest::fromArray([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => 'request-1', + 'method' => InitializeRequest::getMethod(), + 'params' => [ + 'protocolVersion' => ProtocolVersion::V2024_11_05->value, + 'capabilities' => [], + 'clientInfo' => [ + 'name' => 'test-client', + 'version' => '1.0.0', + ], + ], + ]); + + $eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (InitializeRequestEvent $event) use ($request) { + return $event->getRequest() === $request; + })); + + $handler->handle($request, $session); + } + + #[TestDox('does not fail when no event dispatcher is provided')] + public function testHandlesWithoutEventDispatcher(): void + { + $configuration = new Configuration( + serverInfo: new Implementation('server', '1.0.0'), + capabilities: new ServerCapabilities(), + ); + + $handler = new InitializeHandler($configuration, null); + + $session = $this->createMock(SessionInterface::class); + $session->method('set'); + + $request = InitializeRequest::fromArray([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => 'request-1', + 'method' => InitializeRequest::getMethod(), + 'params' => [ + 'protocolVersion' => ProtocolVersion::V2024_11_05->value, + 'capabilities' => [], + 'clientInfo' => [ + 'name' => 'test-client', + 'version' => '1.0.0', + ], + ], + ]); + + $response = $handler->handle($request, $session); + + $this->assertInstanceOf(InitializeResult::class, $response->result); + } } diff --git a/tests/Unit/Server/Handler/Request/PingHandlerTest.php b/tests/Unit/Server/Handler/Request/PingHandlerTest.php index 2a33ecd1..fbf239e3 100644 --- a/tests/Unit/Server/Handler/Request/PingHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/PingHandlerTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; +use Mcp\Event\PingRequestEvent; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\PingRequest; @@ -18,6 +19,7 @@ use Mcp\Server\Handler\Request\PingHandler; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; class PingHandlerTest extends TestCase { @@ -139,6 +141,22 @@ public function testHandlerCanBeReused(): void } } + public function testDispatchesPingRequestEvent(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $handler = new PingHandler($eventDispatcher); + + $request = $this->createPingRequest(); + + $eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (PingRequestEvent $event) use ($request) { + return $event->getRequest() === $request; + })); + + $handler->handle($request, $this->session); + } + private function createPingRequest(): Request { return PingRequest::fromArray([ diff --git a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php index a4ed0b17..df7bbf0d 100644 --- a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -14,6 +14,10 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\RegistryInterface; +use Mcp\Event\Resource\AbstractReadResourceEvent; +use Mcp\Event\Resource\ReadResourceExceptionEvent; +use Mcp\Event\Resource\ReadResourceRequestEvent; +use Mcp\Event\Resource\ReadResourceResultEvent; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ResourceReadException; use Mcp\Schema\Content\BlobResourceContents; @@ -27,6 +31,7 @@ use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; class ReadResourceHandlerTest extends TestCase { @@ -34,14 +39,31 @@ class ReadResourceHandlerTest extends TestCase private RegistryInterface&MockObject $registry; private ReferenceHandlerInterface&MockObject $referenceHandler; private SessionInterface&MockObject $session; + private EventDispatcherInterface&MockObject $eventDispatcher; + + /** @var AbstractReadResourceEvent[] */ + private array $dispatchedEvents = []; protected function setUp(): void { $this->registry = $this->createMock(RegistryInterface::class); $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->session = $this->createMock(SessionInterface::class); + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); + + $this->eventDispatcher + ->method('dispatch') + ->with($this->callback(function (AbstractReadResourceEvent $event) { + $this->dispatchedEvents[] = $event; - $this->handler = new ReadResourceHandler($this->registry, $this->referenceHandler); + return true; + })); + + $this->handler = new ReadResourceHandler( + $this->registry, + $this->referenceHandler, + eventDispatcher: $this->eventDispatcher, + ); } public function testSupportsReadResourceRequest(): void @@ -89,6 +111,17 @@ public function testHandleSuccessfulResourceRead(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $readResourceRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(ReadResourceRequestEvent::class, $readResourceRequestEvent); + $this->assertSame($request, $readResourceRequestEvent->getRequest()); + + $readResourceResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(ReadResourceResultEvent::class, $readResourceResultEvent); + $this->assertSame($request, $readResourceResultEvent->getRequest()); + $this->assertEquals($expectedResult, $readResourceResultEvent->getResult()); } public function testHandleResourceReadWithBlobContent(): void @@ -128,6 +161,17 @@ public function testHandleResourceReadWithBlobContent(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $readResourceRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(ReadResourceRequestEvent::class, $readResourceRequestEvent); + $this->assertSame($request, $readResourceRequestEvent->getRequest()); + + $readResourceResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(ReadResourceResultEvent::class, $readResourceResultEvent); + $this->assertSame($request, $readResourceResultEvent->getRequest()); + $this->assertEquals($expectedResult, $readResourceResultEvent->getResult()); } public function testHandleResourceReadWithMultipleContents(): void @@ -172,6 +216,17 @@ public function testHandleResourceReadWithMultipleContents(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $readResourceRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(ReadResourceRequestEvent::class, $readResourceRequestEvent); + $this->assertSame($request, $readResourceRequestEvent->getRequest()); + + $readResourceResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(ReadResourceResultEvent::class, $readResourceResultEvent); + $this->assertSame($request, $readResourceResultEvent->getRequest()); + $this->assertEquals($expectedResult, $readResourceResultEvent->getResult()); } public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void @@ -192,6 +247,12 @@ public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); + + $this->assertCount(1, $this->dispatchedEvents); + + $readResourceRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(ReadResourceRequestEvent::class, $readResourceRequestEvent); + $this->assertSame($request, $readResourceRequestEvent->getRequest()); } public function testHandleResourceReadExceptionReturnsActualErrorMessage(): void @@ -212,6 +273,12 @@ public function testHandleResourceReadExceptionReturnsActualErrorMessage(): void $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::INTERNAL_ERROR, $response->code); $this->assertEquals('Failed to read resource: corrupted data', $response->message); + + $this->assertCount(1, $this->dispatchedEvents); + + $readResourceRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(ReadResourceRequestEvent::class, $readResourceRequestEvent); + $this->assertSame($request, $readResourceRequestEvent->getRequest()); } public function testHandleGenericExceptionReturnsGenericError(): void @@ -232,6 +299,17 @@ public function testHandleGenericExceptionReturnsGenericError(): void $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::INTERNAL_ERROR, $response->code); $this->assertEquals('Error while reading resource', $response->message); + + $this->assertCount(2, $this->dispatchedEvents); + + $readResourceRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(ReadResourceRequestEvent::class, $readResourceRequestEvent); + $this->assertSame($request, $readResourceRequestEvent->getRequest()); + + $readResourceExceptionEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(ReadResourceExceptionEvent::class, $readResourceExceptionEvent); + $this->assertSame($request, $readResourceExceptionEvent->getRequest()); + $this->assertSame($exception, $readResourceExceptionEvent->getThrowable()); } public function testHandleResourceReadWithDifferentUriSchemes(): void @@ -325,6 +403,17 @@ public function testHandleResourceReadWithEmptyContent(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($expectedResult, $response->result); + + $this->assertCount(2, $this->dispatchedEvents); + + $readResourceRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(ReadResourceRequestEvent::class, $readResourceRequestEvent); + $this->assertSame($request, $readResourceRequestEvent->getRequest()); + + $readResourceResultEvent = $this->dispatchedEvents[1]; + $this->assertInstanceOf(ReadResourceResultEvent::class, $readResourceResultEvent); + $this->assertSame($request, $readResourceResultEvent->getRequest()); + $this->assertEquals($expectedResult, $readResourceResultEvent->getResult()); } public function testHandleResourceReadWithDifferentMimeTypes(): void @@ -412,6 +501,12 @@ public function testHandleResourceNotFoundWithCustomMessage(): void $this->assertInstanceOf(Error::class, $response); $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); + + $this->assertCount(1, $this->dispatchedEvents); + + $readResourceRequestEvent = $this->dispatchedEvents[0]; + $this->assertInstanceOf(ReadResourceRequestEvent::class, $readResourceRequestEvent); + $this->assertSame($request, $readResourceRequestEvent->getRequest()); } private function createReadResourceRequest(string $uri): ReadResourceRequest