diff --git a/composer.json b/composer.json index 4d94c1a..9de2d0b 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,6 @@ "opis/json-schema": "^2.4", "phpdocumentor/reflection-docblock": "^5.6", "psr/container": "^2.0", - "psr/event-dispatcher": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/finder": "^6.4 || ^7.3", "symfony/uid": "^6.4 || ^7.3" diff --git a/examples/09-standalone-cli/index.php b/examples/09-standalone-cli/index.php index f7a6742..a2b4dd4 100644 --- a/examples/09-standalone-cli/index.php +++ b/examples/09-standalone-cli/index.php @@ -27,8 +27,10 @@ $logger ); +$notificationPublisher = new Mcp\Server\NotificationPublisher(); + // Set up the server -$sever = new Mcp\Server($jsonRpcHandler, $logger); +$sever = new Mcp\Server($jsonRpcHandler, $notificationPublisher, $logger); // Create the transport layer using Stdio $transport = new Mcp\Server\Transport\StdioTransport(logger: $logger); diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index f9e6582..44a1aa6 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -17,16 +17,15 @@ use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; -use Mcp\Event\PromptListChangedEvent; -use Mcp\Event\ResourceListChangedEvent; -use Mcp\Event\ResourceTemplateListChangedEvent; -use Mcp\Event\ToolListChangedEvent; +use Mcp\Schema\Notification\PromptListChangedNotification; +use Mcp\Schema\Notification\ResourceListChangedNotification; +use Mcp\Schema\Notification\ToolListChangedNotification; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; -use Psr\EventDispatcher\EventDispatcherInterface; +use Mcp\Server\NotificationPublisher; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -61,7 +60,7 @@ final class Registry implements ReferenceProviderInterface, ReferenceRegistryInt private array $resourceTemplates = []; public function __construct( - private readonly ?EventDispatcherInterface $eventDispatcher = null, + private readonly NotificationPublisher $notificationPublisher = new NotificationPublisher(), private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -74,12 +73,12 @@ public function getCapabilities(): ServerCapabilities return new ServerCapabilities( tools: [] !== $this->tools, - toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, + toolsListChanged: true, resources: [] !== $this->resources || [] !== $this->resourceTemplates, resourcesSubscribe: false, - resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, + resourcesListChanged: true, prompts: [] !== $this->prompts, - promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, + promptsListChanged: true, logging: false, completions: true, ); @@ -100,7 +99,7 @@ public function registerTool(Tool $tool, callable|array|string $handler, bool $i $this->tools[$toolName] = new ToolReference($tool, $handler, $isManual); - $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); + $this->notificationPublisher->enqueue(new ToolListChangedNotification()); } public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void @@ -118,7 +117,7 @@ public function registerResource(Resource $resource, callable|array|string $hand $this->resources[$uri] = new ResourceReference($resource, $handler, $isManual); - $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); + $this->notificationPublisher->enqueue(new ResourceListChangedNotification()); } public function registerResourceTemplate( @@ -145,7 +144,8 @@ public function registerResourceTemplate( $completionProviders, ); - $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); + // TODO: Create ResourceTemplateListChangedNotification. + // $this->notificationPublisher->enqueue(ResourceTemplateListChangedNotification::class); } public function registerPrompt( @@ -167,7 +167,7 @@ public function registerPrompt( $this->prompts[$promptName] = new PromptReference($prompt, $handler, $isManual, $completionProviders); - $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); + $this->notificationPublisher->enqueue(new PromptListChangedNotification()); } public function clear(): void diff --git a/src/Event/PromptListChangedEvent.php b/src/Event/PromptListChangedEvent.php deleted file mode 100644 index 2e86918..0000000 --- a/src/Event/PromptListChangedEvent.php +++ /dev/null @@ -1,19 +0,0 @@ - - */ -final class PromptListChangedEvent -{ -} diff --git a/src/Event/ResourceListChangedEvent.php b/src/Event/ResourceListChangedEvent.php deleted file mode 100644 index 83120d6..0000000 --- a/src/Event/ResourceListChangedEvent.php +++ /dev/null @@ -1,19 +0,0 @@ - - */ -final class ResourceListChangedEvent -{ -} diff --git a/src/Event/ResourceTemplateListChangedEvent.php b/src/Event/ResourceTemplateListChangedEvent.php deleted file mode 100644 index 0c13f65..0000000 --- a/src/Event/ResourceTemplateListChangedEvent.php +++ /dev/null @@ -1,19 +0,0 @@ - - */ -final class ResourceTemplateListChangedEvent -{ -} diff --git a/src/Event/ToolListChangedEvent.php b/src/Event/ToolListChangedEvent.php deleted file mode 100644 index 84d175a..0000000 --- a/src/Event/ToolListChangedEvent.php +++ /dev/null @@ -1,19 +0,0 @@ - - */ -final class ToolListChangedEvent -{ -} diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index b6e34cc..4f351d4 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -18,6 +18,10 @@ use Mcp\Schema\Request; /** + * @phpstan-type RequestData array{ + * params?: array, + * } + * * @author Christopher Hertel */ final class MessageFactory diff --git a/src/Server.php b/src/Server.php index fc81382..7e6b67d 100644 --- a/src/Server.php +++ b/src/Server.php @@ -12,6 +12,7 @@ namespace Mcp; use Mcp\JsonRpc\Handler; +use Mcp\Server\NotificationPublisher; use Mcp\Server\ServerBuilder; use Mcp\Server\TransportInterface; use Psr\Log\LoggerInterface; @@ -24,6 +25,7 @@ final class Server { public function __construct( private readonly Handler $jsonRpcHandler, + private readonly NotificationPublisher $notificationPublisher, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -63,6 +65,18 @@ public function connect(TransportInterface $transport): void } } + foreach ($this->notificationPublisher->flush() as $notification) { + try { + $transport->send(json_encode($notification, \JSON_THROW_ON_ERROR)); + } catch (\JsonException $e) { + $this->logger->error('Failed to encode notification to JSON.', [ + 'notification' => $notification::class, + 'exception' => $e, + ]); + continue; + } + } + usleep(1000); } diff --git a/src/Server/NotificationPublisher.php b/src/Server/NotificationPublisher.php new file mode 100644 index 0000000..b81afd3 --- /dev/null +++ b/src/Server/NotificationPublisher.php @@ -0,0 +1,40 @@ + + */ +class NotificationPublisher +{ + /** @var list */ + private array $queue = []; + + public function enqueue(Notification $notification): void + { + $this->queue[] = $notification; + } + + /** + * @return \Generator + */ + public function flush(): iterable + { + yield from $this->queue; + + $this->queue = []; + } +} diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index 4075cdb..52cfca0 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -39,7 +39,6 @@ use Mcp\Schema\ToolAnnotations; use Mcp\Server; use Psr\Container\ContainerInterface; -use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Psr\SimpleCache\CacheInterface; @@ -61,8 +60,6 @@ final class ServerBuilder private ?PromptGetterInterface $promptGetter = null; - private ?EventDispatcherInterface $eventDispatcher = null; - private ?ContainerInterface $container = null; private ?int $paginationLimit = 50; @@ -154,13 +151,6 @@ public function setLogger(LoggerInterface $logger): self return $this; } - public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): self - { - $this->eventDispatcher = $eventDispatcher; - - return $this; - } - public function setToolCaller(ToolCallerInterface $toolCaller): self { $this->toolCaller = $toolCaller; @@ -276,9 +266,9 @@ public function addPrompt(callable|array|string $handler, ?string $name = null, public function build(): Server { $logger = $this->logger ?? new NullLogger(); - + $notificationPublisher = new NotificationPublisher(); $container = $this->container ?? new Container(); - $registry = new Registry($this->eventDispatcher, $logger); + $registry = new Registry($notificationPublisher, $logger); $referenceHandler = new ReferenceHandler($container); $toolCaller = $this->toolCaller ??= new ToolCaller($registry, $referenceHandler, $logger); @@ -302,6 +292,7 @@ public function build(): Server promptGetter: $promptGetter, logger: $logger, ), + notificationPublisher: $notificationPublisher, logger: $logger, ); } diff --git a/tests/Capability/Registry/RegistryTest.php b/tests/Capability/Registry/RegistryTest.php index 14548b6..a2cb329 100644 --- a/tests/Capability/Registry/RegistryTest.php +++ b/tests/Capability/Registry/RegistryTest.php @@ -13,11 +13,15 @@ use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Registry; +use Mcp\Schema\Notification\PromptListChangedNotification; +use Mcp\Schema\Notification\ResourceListChangedNotification; +use Mcp\Schema\Notification\ToolListChangedNotification; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; +use Mcp\Server\NotificationPublisher; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -30,7 +34,7 @@ class RegistryTest extends TestCase protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); - $this->registry = new Registry(null, $this->logger); + $this->registry = new Registry(new NotificationPublisher(), $this->logger); } public function testConstructorWithDefaults(): void @@ -39,9 +43,6 @@ public function testConstructorWithDefaults(): void $capabilities = $registry->getCapabilities(); $this->assertInstanceOf(ServerCapabilities::class, $capabilities); - $this->assertFalse($capabilities->toolsListChanged); - $this->assertFalse($capabilities->resourcesListChanged); - $this->assertFalse($capabilities->promptsListChanged); } public function testGetCapabilitiesWhenEmpty(): void @@ -307,6 +308,57 @@ public function testMultipleRegistrationsOfSameElementWithSameType(): void $this->assertEquals('second', ($toolRef->handler)()); } + public function testToolRegistrationTriggersNotification(): void + { + $tool = $this->createValidTool('the-best-tool-name-ever'); + + $expected = [$tool->name => $tool]; + + $notificationPublisher = $this->createMock(NotificationPublisher::class); + $notificationPublisher->expects($this->once()) + ->method('enqueue') + ->with(new ToolListChangedNotification()); + + $registry = new Registry($notificationPublisher); + $registry->registerTool($tool, fn () => null); + + $this->assertSame($expected, $registry->getTools()); + } + + public function testResourceRegistrationTriggersNotification(): void + { + $resource = $this->createValidResource('config://the-best-resource-uri-ever'); + + $expected = [$resource->uri => $resource]; + + $notificationPublisher = $this->createMock(NotificationPublisher::class); + $notificationPublisher->expects($this->once()) + ->method('enqueue') + ->with(new ResourceListChangedNotification()); + + $registry = new Registry($notificationPublisher); + $registry->registerResource($resource, fn () => null); + + $this->assertSame($expected, $registry->getResources()); + } + + public function testPromptRegistrationTriggersNotification(): void + { + $prompt = $this->createValidPrompt('the-best-prompt-ever'); + + $expected = [$prompt->name => $prompt]; + + $notificationPublisher = $this->createMock(NotificationPublisher::class); + $notificationPublisher->expects($this->once()) + ->method('enqueue') + ->with(new PromptListChangedNotification()); + + $registry = new Registry($notificationPublisher); + $registry->registerPrompt($prompt, fn () => null); + + $this->assertSame($expected, $registry->getPrompts()); + } + private function createValidTool(string $name): Tool { return new Tool( diff --git a/tests/Server/NotificationPublisherTest.php b/tests/Server/NotificationPublisherTest.php new file mode 100644 index 0000000..bedb1fd --- /dev/null +++ b/tests/Server/NotificationPublisherTest.php @@ -0,0 +1,50 @@ + + */ +class NotificationPublisherTest extends TestCase +{ + public function testEnqueue(): void + { + $expectedNotifications = [ + new ToolListChangedNotification(), + new ResourceListChangedNotification(), + new PromptListChangedNotification(), + ]; + $notificationPublisher = new NotificationPublisher(); + + foreach ($expectedNotifications as $notificationType) { + $notificationPublisher->enqueue($notificationType); + } + + $flushedNotifications = iterator_to_array($notificationPublisher->flush()); + + $this->assertCount(\count($expectedNotifications), $flushedNotifications); + + foreach ($flushedNotifications as $index => $notification) { + $this->assertSame($expectedNotifications[$index], $notification); + } + + $this->assertEmpty(iterator_to_array($notificationPublisher->flush())); + } +} diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 1917711..d2e3483 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -13,6 +13,7 @@ use Mcp\JsonRpc\Handler; use Mcp\Server; +use Mcp\Server\NotificationPublisher; use Mcp\Server\Transport\InMemoryTransport; use PHPUnit\Framework\MockObject\Stub\Exception; use PHPUnit\Framework\TestCase; @@ -35,13 +36,16 @@ public function testJsonExceptions() $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls(new Exception(new \JsonException('foobar')), ['success']); + $notificationPublisher = $this->createMock(NotificationPublisher::class); + $notificationPublisher->expects($this->once())->method('flush'); + $transport = $this->getMockBuilder(InMemoryTransport::class) ->setConstructorArgs([['foo', 'bar']]) ->onlyMethods(['send']) ->getMock(); $transport->expects($this->once())->method('send')->with('success'); - $server = new Server($handler, $logger); + $server = new Server($handler, $notificationPublisher, $logger); $server->connect($transport); } }