From 95c37fc5903d964efaa136a7b66306d7873c74c6 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 22 Aug 2026 04:01:20 +0200 Subject: [PATCH 1/3] [Capability] Suppress list-changed events during the initial registry load --- src/Capability/Registry.php | 31 ++++++++++++----- tests/Unit/Capability/RegistryTest.php | 46 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index f90a4ca1..8a97d400 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -108,7 +108,7 @@ public function registerTool(Tool $tool, callable|array|string $handler): ToolRe $reference = new ToolReference($tool, $handler); $this->tools[$tool->name] = $reference; - $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); + $this->dispatch(new ToolListChangedEvent()); return $reference; } @@ -118,7 +118,7 @@ public function registerResource(ResourceDefinition $resource, callable|array|st $reference = new ResourceReference($resource, $handler); $this->resources[$resource->uri] = $reference; - $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); + $this->dispatch(new ResourceListChangedEvent()); return $reference; } @@ -131,7 +131,7 @@ public function registerResourceTemplate( $reference = new ResourceTemplateReference($template, $handler, $completionProviders); $this->resourceTemplates[$template->uriTemplate] = $reference; - $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); + $this->dispatch(new ResourceTemplateListChangedEvent()); return $reference; } @@ -144,7 +144,7 @@ public function registerPrompt( $reference = new PromptReference($prompt, $handler, $completionProviders); $this->prompts[$prompt->name] = $reference; - $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); + $this->dispatch(new PromptListChangedEvent()); return $reference; } @@ -157,7 +157,7 @@ public function unregisterTool(string $name): void unset($this->tools[$name]); - $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); + $this->dispatch(new ToolListChangedEvent()); } public function unregisterResource(string $uri): void @@ -168,7 +168,7 @@ public function unregisterResource(string $uri): void unset($this->resources[$uri]); - $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); + $this->dispatch(new ResourceListChangedEvent()); } public function unregisterResourceTemplate(string $uriTemplate): void @@ -179,7 +179,7 @@ public function unregisterResourceTemplate(string $uriTemplate): void unset($this->resourceTemplates[$uriTemplate]); - $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); + $this->dispatch(new ResourceTemplateListChangedEvent()); } public function unregisterPrompt(string $name): void @@ -190,7 +190,7 @@ public function unregisterPrompt(string $name): void unset($this->prompts[$name]); - $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); + $this->dispatch(new PromptListChangedEvent()); } public function hasTool(string $name): bool @@ -390,6 +390,21 @@ public function getPrompt(string $name): PromptReference return $this->prompts[$name] ?? throw new PromptNotFoundException($name); } + /** + * List-changed events announce a change to a list a client may have already seen. The deferred + * load populates the initial state before any read returns, so nothing observable changes — + * dispatching there would publish one spurious frame per element onto a configured notification + * bus. Suppressed while the loader runs, dispatched as usual for runtime (un)registrations. + */ + private function dispatch(object $event): void + { + if ($this->loading) { + return; + } + + $this->eventDispatcher?->dispatch($event); + } + /** * Calculate next cursor for pagination. * diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index 0b796db6..cb325565 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -19,6 +19,7 @@ use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; use Mcp\Capability\RegistryInterface; +use Mcp\Event\ToolListChangedEvent; use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; @@ -31,6 +32,7 @@ use Mcp\Schema\Tool; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; class RegistryTest extends TestCase @@ -722,6 +724,50 @@ public function load(RegistryInterface $registry): void $this->assertArrayHasKey('loaded', $registry->getTools()->references); } + public function testListChangedEventsAreSuppressedDuringTheDeferredLoad(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects($this->never())->method('dispatch'); + + $loader = new class($this->createValidTool('loaded'), $this->createValidResource('file:///loaded'), $this->createValidResourceTemplate('file:///loaded/{id}'), $this->createValidPrompt('loaded_prompt')) implements LoaderInterface { + public function __construct( + private readonly Tool $tool, + private readonly ResourceDefinition $resource, + private readonly ResourceTemplate $template, + private readonly Prompt $prompt, + ) { + } + + public function load(RegistryInterface $registry): void + { + $registry->registerTool($this->tool, 'handler'); + $registry->registerResource($this->resource, 'handler'); + $registry->registerResourceTemplate($this->template, 'handler'); + $registry->registerPrompt($this->prompt, 'handler'); + } + }; + + $registry = new Registry($eventDispatcher, $this->logger, loader: $loader); + + $this->assertTrue($registry->hasTools()); + $this->assertTrue($registry->hasPrompts()); + } + + public function testListChangedEventsAreStillDispatchedForRuntimeRegistrations(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ToolListChangedEvent::class)) + ->willReturnArgument(0); + + $loader = $this->toolLoader($this->createValidTool('loaded')); + $registry = new Registry($eventDispatcher, $this->logger, loader: $loader); + $registry->load(); + + $registry->registerTool($this->createValidTool('runtime'), 'handler'); + } + public function testLoadRunsTheConfiguredLoaderEagerly(): void { $loader = $this->createMock(LoaderInterface::class); From 59ad41f547c723231c096c53190dd32ac074ffca Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 24 Aug 2026 23:46:23 +0200 Subject: [PATCH 2/3] Shorten dispatch() comment to a one-liner --- src/Capability/Registry.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 8a97d400..a1ec2346 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -390,12 +390,7 @@ public function getPrompt(string $name): PromptReference return $this->prompts[$name] ?? throw new PromptNotFoundException($name); } - /** - * List-changed events announce a change to a list a client may have already seen. The deferred - * load populates the initial state before any read returns, so nothing observable changes — - * dispatching there would publish one spurious frame per element onto a configured notification - * bus. Suppressed while the loader runs, dispatched as usual for runtime (un)registrations. - */ + /** Suppressed while the deferred loader runs, since it only sets up initial state. */ private function dispatch(object $event): void { if ($this->loading) { From f9a667f6a87f400d16aa2da950b7bde11ceb20fb Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 24 Aug 2026 23:46:55 +0200 Subject: [PATCH 3/3] Use multi-line docblock format for dispatch() comment --- src/Capability/Registry.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index a1ec2346..794193f8 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -390,7 +390,9 @@ public function getPrompt(string $name): PromptReference return $this->prompts[$name] ?? throw new PromptNotFoundException($name); } - /** Suppressed while the deferred loader runs, since it only sets up initial state. */ + /** + * Suppressed while the deferred loader runs, since it only sets up initial state. + */ private function dispatch(object $event): void { if ($this->loading) {