From 6232c69c9f2dfdb72f232d7cb37b7ee8c63077d7 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 18 Oct 2026 15:33:46 +0200 Subject: [PATCH] [Server] Announce `listChanged` for a custom registry wired to the notification bus --- CHANGELOG.md | 1 + docs/run/subscriptions.md | 17 +++++++++++++- src/Capability/Registry.php | 6 +++++ src/Capability/RegistryInterface.php | 7 ++++++ src/Server/Builder.php | 9 +++---- tests/Unit/Server/BuilderTest.php | 35 +++++++++++++++++++++++++++- 6 files changed, 69 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ebccf240..d0baa0993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `DnsRebindingProtectionMiddleware` always validates `Host` (a missing one is rejected) and checks `Origin` separately against the new `allowedOrigins`, including scheme and port and empty by default. Same-origin requests are always allowed and both lists accept `['*']`. `allowedOrigins` is the new second constructor argument; pass the factories by name. * Fix the client throwing a native `\JsonException` for request params it cannot encode (e.g. invalid UTF-8): it now throws `InvalidArgumentException`, and a request handler result it cannot encode is answered with an internal error instead of no answer. +* [BC Break] Add `RegistryInterface::publishesListChanges()`: a registry passed to `setRegistry()` and wired to the notification bus with a `PublishingEventDispatcher` gets `listChanged` announced on `server/discover`. A decorating registry forwards the answer of the one it wraps. * Fix servers announcing `listChanged` where no list changes are delivered: the `initialize` result never carries it, and `server/discover` only with a notification bus and the built-in registry. A server with only an event dispatcher no longer announces it. * Fix concurrent requests of one session overwriting each other's session changes: `Session::save()` now writes only the keys it changed, onto what the store holds at that point. * Fix `ClientGateway::progress()` sending another call's progress token when calls of one session run in parallel: the token now comes from the gateway's own request. diff --git a/docs/run/subscriptions.md b/docs/run/subscriptions.md index 5e9f6508c..620fdb286 100644 --- a/docs/run/subscriptions.md +++ b/docs/run/subscriptions.md @@ -28,7 +28,22 @@ use Mcp\Server\Subscription\Psr16NotificationBus; ``` Registry changes (`registerTool()`, `unregisterPrompt()`, …) are published automatically, with or -without an event dispatcher, unless you pass your own registry with `setRegistry()`. +without an event dispatcher, unless you pass your own registry with `setRegistry()`. Then you wire +it to the bus yourself, and the builder announces `listChanged` for it: + +```php +use Mcp\Capability\Registry; +use Mcp\Server\Subscription\PublishingEventDispatcher; + +$bus = new InMemoryNotificationBus(); +$registry = new Registry(new PublishingEventDispatcher($bus)); + +$server = Server::builder() + ->setRegistry($registry) + ->setNotificationBus($bus) + ->build(); +``` + Anything else — `notifications/resources/updated` above all — is published by the application: diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 7d043aaed..d3d9badb6 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -30,6 +30,7 @@ use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; +use Mcp\Server\Subscription\PublishingEventDispatcher; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -238,6 +239,11 @@ public function hasPrompt(string $name): bool return isset($this->prompts[$name]); } + public function publishesListChanges(): bool + { + return $this->eventDispatcher instanceof PublishingEventDispatcher; + } + public function hasTools(): bool { $this->load(); diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php index 94b0db23d..538fe45e4 100644 --- a/src/Capability/RegistryInterface.php +++ b/src/Capability/RegistryInterface.php @@ -24,6 +24,7 @@ use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; +use Mcp\Server\Subscription\PublishingEventDispatcher; /** * @phpstan-import-type Handler from ElementReference @@ -97,6 +98,12 @@ public function unregisterResourceTemplate(string $uriTemplate): void; */ public function unregisterPrompt(string $name): void; + /** + * Whether list changes reach a notification bus, e.g. through a {@see PublishingEventDispatcher}. + * A decorator forwards the answer of the registry it wraps. + */ + public function publishesListChanges(): bool; + public function hasTool(string $name): bool; public function hasResource(string $uri): bool; diff --git a/src/Server/Builder.php b/src/Server/Builder.php index d16ec8b64..13f1eb594 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -315,7 +315,9 @@ public function setServerInfo( * * With the built-in registry, registry changes are published automatically, * with or without an event dispatcher, and `listChanged` is announced. A - * registry passed with setRegistry() is neither published nor announced. + * registry passed with setRegistry() is announced only if + * {@see RegistryInterface::publishesListChanges()} says so, as {@see Registry} + * does with a {@see PublishingEventDispatcher}. * Anything else, `notifications/resources/updated` for example, is * published by the application calling {@see NotificationBusInterface::publish()}. */ @@ -1156,9 +1158,8 @@ private function resolve(): array */ private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded): ServerCapabilities { - // Only the bus delivers list changes, and only the built-in registry is - // wired to it; a custom registry's events never reach a client here. - $listChanged = null !== $this->notificationBus && !$this->hasCustomRegistry; + // Only the bus delivers list changes, and only if the registry publishes to it. + $listChanged = null !== $this->notificationBus && $registry->publishesListChanges(); if ($eagerlyLoaded) { $hasResources = $registry->hasResources() || $registry->hasResourceTemplates(); diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 117ee531a..53c793f0d 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -272,7 +272,7 @@ public function testListChangedIsNotAnnouncedWithoutANotificationBus(): void $this->assertFalse($capabilities->promptsListChanged); } - #[TestDox('listChanged is not announced for a custom registry, the builder cannot wire it to the bus')] + #[TestDox('listChanged is not announced for a custom registry that does not publish its changes')] public function testListChangedIsNotAnnouncedForACustomRegistry(): void { $server = Server::builder() @@ -288,6 +288,39 @@ public function testListChangedIsNotAnnouncedForACustomRegistry(): void $this->assertFalse($capabilities->promptsListChanged); } + #[TestDox('listChanged is announced for a custom registry that publishes its changes to the bus')] + public function testListChangedIsAnnouncedForACustomRegistryPublishingChanges(): void + { + $bus = new InMemoryNotificationBus(); + + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setRegistry(new Registry(new PublishingEventDispatcher($bus))) + ->setNotificationBus($bus) + ->build(); + + $capabilities = $this->extractServerCapabilities($server); + + $this->assertTrue($capabilities->toolsListChanged); + $this->assertTrue($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->promptsListChanged); + } + + #[TestDox('listChanged is not announced for a custom registry publishing its changes without a notification bus')] + public function testListChangedIsNotAnnouncedForACustomRegistryPublishingChangesWithoutABus(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setRegistry(new Registry(new PublishingEventDispatcher(new InMemoryNotificationBus()))) + ->build(); + + $capabilities = $this->extractServerCapabilities($server); + + $this->assertFalse($capabilities->toolsListChanged); + $this->assertFalse($capabilities->resourcesListChanged); + $this->assertFalse($capabilities->promptsListChanged); + } + #[TestDox('setLazyLoading() returns the builder for fluent chaining')] public function testSetLazyLoadingReturnsSelf(): void {