diff --git a/README.md b/README.md index 52b3ebec..f9ae1896 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..ebd70ed2 --- /dev/null +++ b/docs/events.md @@ -0,0 +1,103 @@ +# Events + +The MCP SDK provides a PSR-14 compatible event system that allows you to hook into the server's lifecycle. Events enable request/response modification, and other user-defined behaviors. + +## Table of Contents + +- [Setup](#setup) +- [Protocol Events](#protocol-events) + - [RequestEvent](#requestevent) + - [ResponseEvent](#responseevent) + - [ErrorEvent](#errorevent) + - [NotificationEvent](#notificationevent) +- [List Change Events](#list-change-events) + +## 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(RequestEvent::class, function (RequestEvent $event) { + // Handle any incoming request + if ($event->getMethod() === 'tools/call') { + // Handle tool call requests specifically + } +}); + +$server = Server::builder() + ->setEventDispatcher($dispatcher) + ->build(); +``` + +## Protocol Events + +The SDK dispatches 4 broad event types at the protocol level, allowing you to observe and modify all server operations: + +### RequestEvent + +**Dispatched**: When any request is received from the client, before it's processed by handlers. + +**Properties**: +- `getRequest(): Request` - The incoming request +- `setRequest(Request $request): void` - Modify the request before processing +- `getSession(): SessionInterface` - The current session +- `getMethod(): string` - Convenience method to get the request method + +### ResponseEvent + +**Dispatched**: When a successful response is ready to be sent to the client, after handler execution. + +**Properties**: +- `getResponse(): Response` - The response being sent +- `setResponse(Response $response): void` - Modify the response before sending +- `getRequest(): Request` - The original request +- `getSession(): SessionInterface` - The current session +- `getMethod(): string` - Convenience method to get the request method + +### ErrorEvent + +**Dispatched**: When an error occurs during request processing. + +**Properties**: +- `getError(): Error` - The error being sent +- `setError(Error $error): void` - Modify the error before sending +- `getRequest(): Request` - The original request (null for parse errors) +- `getThrowable(): ?\Throwable` - The exception that caused the error (if any) +- `getSession(): SessionInterface` - The current session + +### NotificationEvent + +**Dispatched**: When a notification is received from the client, before it's processed by handlers. + +**Properties**: +- `getNotification(): Notification` - The incoming notification +- `setNotification(Notification $notification): void` - Modify the notification before processing +- `getSession(): SessionInterface` - The current session +- `getMethod(): string` - Convenience method to get the notification method + +## 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'); +}); +``` diff --git a/src/Event/ErrorEvent.php b/src/Event/ErrorEvent.php new file mode 100644 index 00000000..a272d802 --- /dev/null +++ b/src/Event/ErrorEvent.php @@ -0,0 +1,61 @@ + + */ +final class ErrorEvent +{ + public function __construct( + private Error $error, + private readonly Request $request, + private readonly SessionInterface $session, + private readonly ?\Throwable $throwable, + ) { + } + + public function getError(): Error + { + return $this->error; + } + + public function setError(Error $error): void + { + $this->error = $error; + } + + public function getRequest(): Request + { + return $this->request; + } + + public function getThrowable(): ?\Throwable + { + return $this->throwable; + } + + public function getSession(): SessionInterface + { + return $this->session; + } +} diff --git a/src/Event/NotificationEvent.php b/src/Event/NotificationEvent.php new file mode 100644 index 00000000..f2dbe298 --- /dev/null +++ b/src/Event/NotificationEvent.php @@ -0,0 +1,53 @@ + + */ +final class NotificationEvent +{ + public function __construct( + private Notification $notification, + private readonly SessionInterface $session, + ) { + } + + public function getNotification(): Notification + { + return $this->notification; + } + + public function setNotification(Notification $notification): void + { + $this->notification = $notification; + } + + public function getSession(): SessionInterface + { + return $this->session; + } + + public function getMethod(): string + { + return $this->notification::getMethod(); + } +} diff --git a/src/Event/RequestEvent.php b/src/Event/RequestEvent.php new file mode 100644 index 00000000..6ffbb99f --- /dev/null +++ b/src/Event/RequestEvent.php @@ -0,0 +1,53 @@ + + */ +final class RequestEvent +{ + public function __construct( + private Request $request, + private readonly SessionInterface $session, + ) { + } + + public function getRequest(): Request + { + return $this->request; + } + + public function setRequest(Request $request): void + { + $this->request = $request; + } + + public function getSession(): SessionInterface + { + return $this->session; + } + + public function getMethod(): string + { + return $this->request::getMethod(); + } +} diff --git a/src/Event/ResponseEvent.php b/src/Event/ResponseEvent.php new file mode 100644 index 00000000..c5aac45a --- /dev/null +++ b/src/Event/ResponseEvent.php @@ -0,0 +1,69 @@ + + */ +final class ResponseEvent +{ + /** + * @param Response $response + */ + public function __construct( + private Response $response, + private readonly Request $request, + private readonly SessionInterface $session, + ) { + } + + /** + * @return Response + */ + public function getResponse(): Response + { + return $this->response; + } + + /** + * @param Response $response + */ + public function setResponse(Response $response): void + { + $this->response = $response; + } + + public function getRequest(): Request + { + return $this->request; + } + + public function getSession(): SessionInterface + { + return $this->session; + } + + public function getMethod(): string + { + return $this->request::getMethod(); + } +} diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 9e9b6b2f..d103d0df 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -550,6 +550,7 @@ public function build(): Server sessionFactory: $sessionFactory, sessionStore: $sessionStore, logger: $logger, + eventDispatcher: $this->eventDispatcher, ); return new Server($protocol, $logger); diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index feedae3b..3ebe469c 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -11,6 +11,10 @@ namespace Mcp\Server; +use Mcp\Event\ErrorEvent; +use Mcp\Event\NotificationEvent; +use Mcp\Event\RequestEvent; +use Mcp\Event\ResponseEvent; use Mcp\Exception\InvalidInputMessageException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Error; @@ -25,6 +29,7 @@ use Mcp\Server\Session\SessionInterface; use Mcp\Server\Session\SessionStoreInterface; use Mcp\Server\Transport\TransportInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\Uid\Uuid; @@ -68,6 +73,7 @@ public function __construct( private readonly SessionFactoryInterface $sessionFactory, private readonly SessionStoreInterface $sessionStore, private readonly LoggerInterface $logger = new NullLogger(), + private readonly ?EventDispatcherInterface $eventDispatcher = null, ) { } @@ -151,6 +157,20 @@ private function handleInvalidMessage(TransportInterface $transport, InvalidInpu $this->sendResponse($transport, $error, $session); } + /** + * Dispatches an event through the event dispatcher if available. + * + * @template T of object + * + * @param T $event + * + * @return T + */ + private function dispatchEvent(object $event): object + { + return $this->eventDispatcher?->dispatch($event) ?? $event; + } + /** * Handle a request from the transport. * @@ -162,6 +182,9 @@ private function handleRequest(TransportInterface $transport, Request $request, $session->set(self::SESSION_ACTIVE_REQUEST_META, $request->getMeta()); + $event = $this->dispatchEvent(new RequestEvent($request, $session)); + $request = $event->getRequest(); + $handlerFound = false; foreach ($this->requestHandlers as $handler) { @@ -195,16 +218,30 @@ private function handleRequest(TransportInterface $transport, Request $request, } $finalResult = $fiber->getReturn(); + if ($finalResult instanceof Response) { + $responseEvent = $this->dispatchEvent(new ResponseEvent($finalResult, $request, $session)); + $finalResult = $responseEvent->getResponse(); + } elseif ($finalResult instanceof Error) { + $errorEvent = $this->dispatchEvent(new ErrorEvent($finalResult, $request, $session, null)); + $finalResult = $errorEvent->getError(); + } + $this->sendResponse($transport, $finalResult, $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 (\Throwable $e) { $this->logger->error(\sprintf('Uncaught exception: %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); } @@ -213,6 +250,9 @@ private function handleRequest(TransportInterface $transport, Request $request, if (!$handlerFound) { $error = Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $request::getMethod()), $request->getId()); + $errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, null)); + $error = $errorEvent->getError(); + $this->sendResponse($transport, $error, $session); } } @@ -238,6 +278,9 @@ private function handleNotification(Notification $notification, SessionInterface { $this->logger->info('Handling notification.', ['notification' => $notification]); + $event = $this->dispatchEvent(new NotificationEvent($notification, $session)); + $notification = $event->getNotification(); + foreach ($this->notificationHandlers as $handler) { if (!$handler->supports($notification)) { continue; diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index c73307bc..e15ce054 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -11,9 +11,16 @@ namespace Mcp\Tests\Unit\Server; +use Mcp\Event\ErrorEvent; +use Mcp\Event\NotificationEvent; +use Mcp\Event\RequestEvent; +use Mcp\Event\ResponseEvent; use Mcp\JsonRpc\MessageFactory; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Notification\LoggingMessageNotification; +use Mcp\Schema\Request\CallToolRequest; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Protocol; @@ -24,6 +31,7 @@ use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Uid\Uuid; final class ProtocolTest extends TestCase @@ -719,4 +727,620 @@ public function testDestroySessionRemovesSession(): void $protocol->destroySession($sessionId); } + + #[TestDox('RequestEvent is dispatched when a request is received')] + public function testRequestEventIsDispatched(): void + { + $capturedEvents = []; + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher + ->method('dispatch') + ->willReturnCallback(static function ($event) use (&$capturedEvents) { + $capturedEvents[] = $event; + + return $event; + }); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willReturn(new Response(1, ['result' => 'success'])); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $session->method('get')->willReturn([]); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + eventDispatcher: $eventDispatcher, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "ping", "id": 1}', + $sessionId + ); + + // Should have RequestEvent (and ResponseEvent) + $this->assertGreaterThanOrEqual(1, \count($capturedEvents)); + $this->assertInstanceOf(RequestEvent::class, $capturedEvents[0]); + $this->assertSame($session, $capturedEvents[0]->getSession()); + $this->assertSame('ping', $capturedEvents[0]->getMethod()); + } + + #[TestDox('RequestEvent modification is used by handler')] + public function testRequestEventModificationIsUsed(): void + { + $handlerReceivedRequest = null; + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher + ->method('dispatch') + ->willReturnCallback(static function ($event) { + if ($event instanceof RequestEvent) { + // Simulate a listener modifying the request + $originalRequest = $event->getRequest(); + + // Create a modified CallToolRequest with different name but same ID + $modifiedRequest = CallToolRequest::fromArray([ + 'jsonrpc' => '2.0', + 'id' => $originalRequest->getId(), + 'method' => 'tools/call', + 'params' => [ + 'name' => 'modified_tool', + 'arguments' => ['modified' => true], + ], + ]); + + $event->setRequest($modifiedRequest); + } + + return $event; + }); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler + ->method('handle') + ->willReturnCallback(static function ($request) use (&$handlerReceivedRequest) { + $handlerReceivedRequest = $request; + + return new Response(1, ['result' => 'success']); + }); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $session->method('get')->willReturn([]); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + eventDispatcher: $eventDispatcher, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "tools/call", "id": 1, "params": {"name": "original_tool", "arguments": {}}}', + $sessionId + ); + + // Verify the handler received the modified request + $this->assertInstanceOf(CallToolRequest::class, $handlerReceivedRequest); + + $this->assertSame('modified_tool', $handlerReceivedRequest->name); + $this->assertSame(['modified' => true], $handlerReceivedRequest->arguments); + } + + #[TestDox('RequestEvent works with null EventDispatcher')] + public function testRequestEventWithNullDispatcher(): void + { + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willReturn(new Response(1, ['result' => 'success'])); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $session->method('get')->willReturn([]); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + eventDispatcher: null, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "ping", "id": 1}', + $sessionId + ); + + // Should not crash - success + $this->expectNotToPerformAssertions(); + } + + #[TestDox('ResponseEvent is dispatched when handler returns Response')] + public function testResponseEventIsDispatched(): void + { + $capturedEvents = []; + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher + ->method('dispatch') + ->willReturnCallback(static function ($event) use (&$capturedEvents) { + $capturedEvents[] = $event; + + return $event; + }); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willReturn(new Response(1, ['result' => 'success'])); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $session->method('get')->willReturn([]); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + eventDispatcher: $eventDispatcher, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "ping", "id": 1}', + $sessionId + ); + + // Should have RequestEvent and ResponseEvent + $this->assertCount(2, $capturedEvents); + $this->assertInstanceOf(RequestEvent::class, $capturedEvents[0]); + $this->assertInstanceOf(ResponseEvent::class, $capturedEvents[1]); + + /** @var ResponseEvent $responseEvent */ + $responseEvent = $capturedEvents[1]; + $this->assertSame($session, $responseEvent->getSession()); + $this->assertSame('ping', $responseEvent->getMethod()); + $this->assertInstanceOf(Response::class, $responseEvent->getResponse()); + } + + #[TestDox('ResponseEvent modification is used when sending')] + public function testResponseEventModificationIsUsed(): void + { + $outgoingQueue = []; + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher + ->method('dispatch') + ->willReturnCallback(static function ($event) { + if ($event instanceof ResponseEvent) { + // Simulate a listener modifying the response + $modifiedResponse = new Response( + $event->getResponse()->getId(), + ['result' => 'modified', 'original' => false] + ); + $event->setResponse($modifiedResponse); + } + + return $event; + }); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willReturn(new Response(1, ['result' => 'original'])); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $session->method('get')->willReturn([]); + $session + ->method('set') + ->willReturnCallback(static function ($key, $value) use (&$outgoingQueue) { + if ('_mcp.outgoing_queue' === $key) { + $outgoingQueue = $value; + } + }); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + eventDispatcher: $eventDispatcher, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "ping", "id": 1}', + $sessionId + ); + + // Verify the MODIFIED response was queued + $this->assertNotEmpty($outgoingQueue); + $lastQueued = end($outgoingQueue); + $this->assertIsArray($lastQueued); + $this->assertArrayHasKey('message', $lastQueued); + + $decoded = json_decode($lastQueued['message'], true); + $this->assertSame('modified', $decoded['result']['result']); + $this->assertFalse($decoded['result']['original']); + } + + #[TestDox('ErrorEvent is dispatched when handler returns Error')] + public function testErrorEventIsDispatchedForErrorResult(): void + { + $capturedEvents = []; + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher + ->method('dispatch') + ->willReturnCallback(static function ($event) use (&$capturedEvents) { + $capturedEvents[] = $event; + + return $event; + }); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willReturn(Error::forInternalError('test error', 1)); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $session->method('get')->willReturn([]); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + eventDispatcher: $eventDispatcher, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "ping", "id": 1}', + $sessionId + ); + + // Should have RequestEvent and ErrorEvent + $this->assertCount(2, $capturedEvents); + $this->assertInstanceOf(RequestEvent::class, $capturedEvents[0]); + $this->assertInstanceOf(ErrorEvent::class, $capturedEvents[1]); + + /** @var ErrorEvent $errorEvent */ + $errorEvent = $capturedEvents[1]; + $this->assertSame($session, $errorEvent->getSession()); + $this->assertNull($errorEvent->getThrowable()); + $this->assertInstanceOf(Error::class, $errorEvent->getError()); + } + + #[TestDox('ErrorEvent is dispatched on InvalidArgumentException')] + public function testErrorEventIsDispatchedForInvalidArgument(): void + { + $capturedEvents = []; + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher + ->method('dispatch') + ->willReturnCallback(static function ($event) use (&$capturedEvents) { + $capturedEvents[] = $event; + + return $event; + }); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willThrowException(new \InvalidArgumentException('Invalid param')); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $session->method('get')->willReturn([]); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + eventDispatcher: $eventDispatcher, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "ping", "id": 1}', + $sessionId + ); + + // Should have RequestEvent and ErrorEvent + $this->assertCount(2, $capturedEvents); + $this->assertInstanceOf(RequestEvent::class, $capturedEvents[0]); + $this->assertInstanceOf(ErrorEvent::class, $capturedEvents[1]); + + /** @var ErrorEvent $errorEvent */ + $errorEvent = $capturedEvents[1]; + $this->assertInstanceOf(\InvalidArgumentException::class, $errorEvent->getThrowable()); + $this->assertSame('Invalid param', $errorEvent->getThrowable()->getMessage()); + } + + #[TestDox('ErrorEvent is dispatched on generic Throwable')] + public function testErrorEventIsDispatchedForGenericException(): void + { + $capturedEvents = []; + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher + ->method('dispatch') + ->willReturnCallback(static function ($event) use (&$capturedEvents) { + $capturedEvents[] = $event; + + return $event; + }); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willThrowException(new \RuntimeException('Runtime error')); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $session->method('get')->willReturn([]); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + eventDispatcher: $eventDispatcher, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "ping", "id": 1}', + $sessionId + ); + + // Should have RequestEvent and ErrorEvent + $this->assertCount(2, $capturedEvents); + $this->assertInstanceOf(RequestEvent::class, $capturedEvents[0]); + $this->assertInstanceOf(ErrorEvent::class, $capturedEvents[1]); + + /** @var ErrorEvent $errorEvent */ + $errorEvent = $capturedEvents[1]; + $this->assertInstanceOf(\RuntimeException::class, $errorEvent->getThrowable()); + $this->assertSame('Runtime error', $errorEvent->getThrowable()->getMessage()); + } + + #[TestDox('ErrorEvent is dispatched when no handler found')] + public function testErrorEventIsDispatchedForMethodNotFound(): void + { + $capturedEvents = []; + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher + ->method('dispatch') + ->willReturnCallback(static function ($event) use (&$capturedEvents) { + $capturedEvents[] = $event; + + return $event; + }); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $session->method('get')->willReturn([]); + $session->expects($this->once())->method('save'); + $session->expects($this->atLeastOnce())->method('set'); + + $this->sessionFactory->method('create')->willReturn($session); // create() for initialize + $this->sessionStore->method('exists')->willReturn(false); // No existing session + + $protocol = new Protocol( + requestHandlers: [], // No handlers + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + eventDispatcher: $eventDispatcher, + ); + + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}}', + null // Initialize must not have sessionId + ); + + // Should have RequestEvent and ErrorEvent + $this->assertCount(2, $capturedEvents); + $this->assertInstanceOf(RequestEvent::class, $capturedEvents[0]); + $this->assertInstanceOf(ErrorEvent::class, $capturedEvents[1]); + + /** @var ErrorEvent $errorEvent */ + $errorEvent = $capturedEvents[1]; + $this->assertNull($errorEvent->getThrowable()); + $this->assertInstanceOf(Error::class, $errorEvent->getError()); + } + + #[TestDox('NotificationEvent is dispatched when notification received')] + public function testNotificationEventIsDispatched(): void + { + $capturedEvent = null; + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher + ->expects($this->once()) + ->method('dispatch') + ->with($this->callback(static function ($event) use (&$capturedEvent) { + $capturedEvent = $event; + + return $event instanceof NotificationEvent; + })) + ->willReturnArgument(0); + + $handler = $this->createMock(NotificationHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->expects($this->once())->method('handle'); + + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [$handler], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + eventDispatcher: $eventDispatcher, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + $sessionId + ); + + $this->assertNotNull($capturedEvent); + $this->assertInstanceOf(NotificationEvent::class, $capturedEvent); + $this->assertSame($session, $capturedEvent->getSession()); + $this->assertSame('notifications/initialized', $capturedEvent->getMethod()); + } + + #[TestDox('NotificationEvent modification is used by handlers')] + public function testNotificationEventModificationIsUsed(): void + { + $handlerReceivedNotification = null; + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher + ->method('dispatch') + ->willReturnCallback(static function ($event) { + if ($event instanceof NotificationEvent) { + // Simulate a listener modifying the notification + $modifiedNotification = LoggingMessageNotification::fromArray([ + 'jsonrpc' => '2.0', + 'method' => 'notifications/message', + 'params' => [ + 'level' => 'error', + 'data' => 'modified message', + 'logger' => 'modified_logger', + ], + ]); + + $event->setNotification($modifiedNotification); + } + + return $event; + }); + + $handler = $this->createMock(NotificationHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler + ->method('handle') + ->willReturnCallback(static function ($notification) use (&$handlerReceivedNotification) { + $handlerReceivedNotification = $notification; + }); + + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [$handler], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + eventDispatcher: $eventDispatcher, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "notifications/message", "params": {"level": "info", "data": "original message"}}', + $sessionId + ); + + // Verify the handler received the MODIFIED notification + $this->assertInstanceOf(LoggingMessageNotification::class, $handlerReceivedNotification); + $this->assertSame(LoggingLevel::Error, $handlerReceivedNotification->level); + $this->assertSame('modified message', $handlerReceivedNotification->data); + $this->assertSame('modified_logger', $handlerReceivedNotification->logger); + } + + #[TestDox('NotificationEvent works with null EventDispatcher')] + public function testNotificationEventWithNullDispatcher(): void + { + $handler = $this->createMock(NotificationHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->expects($this->once())->method('handle'); + + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [$handler], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + eventDispatcher: null, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + $sessionId + ); + } }