feat(server): add server lifecycle events (#175)
Signed-off-by: Edouard Courty <edouard.courty2@gmail.com>
This commit is contained in:
parent
04a9a16be2
commit
158e64627f
9 changed files with 1008 additions and 0 deletions
|
|
@ -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
103
docs/events.md
Normal 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
61
src/Event/ErrorEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
53
src/Event/NotificationEvent.php
Normal file
53
src/Event/NotificationEvent.php
Normal 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();
|
||||
}
|
||||
}
|
||||
53
src/Event/RequestEvent.php
Normal file
53
src/Event/RequestEvent.php
Normal 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();
|
||||
}
|
||||
}
|
||||
69
src/Event/ResponseEvent.php
Normal file
69
src/Event/ResponseEvent.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -564,6 +564,7 @@ final class Builder
|
|||
sessionFactory: $sessionFactory,
|
||||
sessionStore: $sessionStore,
|
||||
logger: $logger,
|
||||
eventDispatcher: $this->eventDispatcher,
|
||||
);
|
||||
|
||||
return new Server($protocol, $logger);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue