feat(server): add server lifecycle events (#175)

Signed-off-by: Edouard Courty <edouard.courty2@gmail.com>
This commit is contained in:
Edouard Courty 2026-02-23 22:40:43 +01:00 • committed by GitHub
commit 158e64627f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1008 additions and 0 deletions

View file

@ -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

103
docs/events.md Normal file
View file

@ -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');
});
```

61
src/Event/ErrorEvent.php Normal file
View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Event;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Server\Session\SessionInterface;
/**
* Event dispatched when an error occurs during request processing.
*
* Listeners can modify the error before it's sent to the client.
*
* @author Edouard Courty <edouard.courty2@gmail.com>
*/
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;
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Event;
use Mcp\Schema\JsonRpc\Notification;
use Mcp\Server\Session\SessionInterface;
/**
* Event dispatched when any notification is received from the client.
*
* Listeners can modify the notification before it's processed by handlers.
*
* @author Edouard Courty <edouard.courty2@gmail.com>
*/
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();
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Event;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Server\Session\SessionInterface;
/**
* Event dispatched when any request is received from the client.
*
* Listeners can modify the request before it's processed by handlers.
*
* @author Edouard Courty <edouard.courty2@gmail.com>
*/
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();
}
}

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Event;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Server\Session\SessionInterface;
/**
* Event dispatched when a successful response is ready to be sent to the client.
*
* Listeners can modify the response before it's sent.
*
* @author Edouard Courty <edouard.courty2@gmail.com>
*/
final class ResponseEvent
{
/**
* @param Response<mixed> $response
*/
public function __construct(
private Response $response,
private readonly Request $request,
private readonly SessionInterface $session,
) {
}
/**
* @return Response<mixed>
*/
public function getResponse(): Response
{
return $this->response;
}
/**
* @param Response<mixed> $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();
}
}

View file

@ -564,6 +564,7 @@ final class Builder
sessionFactory: $sessionFactory,
sessionStore: $sessionStore,
logger: $logger,
eventDispatcher: $this->eventDispatcher,
);
return new Server($protocol, $logger);

View file

@ -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\SessionFactoryInterface;
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 @@ class Protocol
private readonly SessionFactoryInterface $sessionFactory,
private readonly SessionStoreInterface $sessionStore,
private readonly LoggerInterface $logger = new NullLogger(),
private readonly ?EventDispatcherInterface $eventDispatcher = null,
) {
}
@ -151,6 +157,20 @@ class Protocol
$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 @@ class Protocol
$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 @@ class Protocol
}
$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 @@ class Protocol
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 @@ class Protocol
{
$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;

View file

@ -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 Mcp\Server\Transport\TransportInterface;
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 @@ final class ProtocolTest extends TestCase
$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
);
}
}