diff --git a/CHANGELOG.md b/CHANGELOG.md index 17c2c2a7..666c7db2 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. +* 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. * [BC Break] `SchemaValidator` takes an optional `Opis\JsonSchema\Validator` as its first constructor argument, moving `$logger` to second. Pass `logger:` by name. diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index f398a458..b6ab1c45 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -144,6 +144,29 @@ public function withExtensions(array $extensions): self ); } + /** + * Returns a copy that announces no list-changed notifications. + * + * A kind announced only through its `listChanged` flag stays announced. + */ + public function withoutListChanged(): self + { + return new self( + $this->tools || $this->toolsListChanged, + false, + $this->resources || $this->resourcesListChanged, + $this->resourcesSubscribe, + false, + $this->prompts || $this->promptsListChanged, + false, + $this->logging, + $this->completions, + $this->experimental, + $this->extensions, + $this->tasks, + ); + } + /** * @return array{ * logging?: \stdClass, diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 6eadb805..d16ec8b6 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -313,10 +313,11 @@ public function setServerInfo( * runtimes; under PHP-FPM, where they are different workers, use * {@see Psr16NotificationBus} or an implementation over your own broker. * - * Registry changes are published automatically, with or without an event - * dispatcher, unless you pass your own registry with setRegistry(). Anything - * else, `notifications/resources/updated` for example, is published by the - * application calling {@see NotificationBusInterface::publish()}. + * 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. + * Anything else, `notifications/resources/updated` for example, is + * published by the application calling {@see NotificationBusInterface::publish()}. */ public function setNotificationBus(NotificationBusInterface $bus): self { @@ -408,6 +409,9 @@ public function setInstructions(?string $instructions): self /** * Explicitly set server capabilities. If set, this overrides automatic detection. + * + * A `listChanged` flag set here is announced on `server/discover` only; the + * `initialize` handshake has no way to deliver list changes. */ public function setCapabilities(ServerCapabilities $serverCapabilities): self { @@ -1094,7 +1098,7 @@ private function resolve(): array $messageFactory = MessageFactory::make(additional: $this->extensionMessages); - $capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded, $eventDispatcher); + $capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded); // Extensions enabled via enableExtension() are folded into caller-supplied // capabilities too, so setCapabilities() does not silently drop them. @@ -1150,11 +1154,11 @@ private function resolve(): array * the load, so they are advertised from the configured sources instead — opaque sources (custom * loaders, discovery) advertise all kinds, and over-advertising is harmless per MCP semantics. */ - private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded, ?EventDispatcherInterface $eventDispatcher): ServerCapabilities + private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded): ServerCapabilities { - // Without a dispatcher the registry announces nothing, so there is no - // list-changed notification to advertise. - $listChanged = $eventDispatcher instanceof EventDispatcherInterface; + // 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; if ($eagerlyLoaded) { $hasResources = $registry->hasResources() || $registry->hasResourceTemplates(); diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index af883cd3..6f6b5de0 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -51,10 +51,14 @@ public function handle(Request $request, SessionInterface $session): Response $negotiated = $this->negotiate($request->protocolVersion); $session->set('protocol_version', $negotiated->value); + // List changes reach clients only through `subscriptions/listen`, which + // the handshake era does not have. + $capabilities = ($this->configuration->capabilities ?? new ServerCapabilities())->withoutListChanged(); + return new Response( $request->getId(), new InitializeResult( - $this->configuration->capabilities ?? new ServerCapabilities(), + $capabilities, $this->configuration->serverInfo ?? new Implementation(), $this->configuration?->instructions, null, diff --git a/tests/Unit/Schema/ServerCapabilitiesTest.php b/tests/Unit/Schema/ServerCapabilitiesTest.php index bb4e40ca..2105e3e3 100644 --- a/tests/Unit/Schema/ServerCapabilitiesTest.php +++ b/tests/Unit/Schema/ServerCapabilitiesTest.php @@ -429,4 +429,28 @@ public function testWithExtensionsKeepsTasks(): void $this->assertSame(['list' => []], $capabilities->tasks); } + + public function testWithoutListChangedKeepsKindsAnnouncedOnlyThroughListChanged(): void + { + $capabilities = (new ServerCapabilities( + tools: false, + toolsListChanged: true, + resources: false, + resourcesListChanged: true, + prompts: false, + promptsListChanged: true, + ))->withoutListChanged(); + + $this->assertTrue($capabilities->tools); + $this->assertFalse($capabilities->toolsListChanged); + $this->assertTrue($capabilities->resources); + $this->assertFalse($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->prompts); + $this->assertFalse($capabilities->promptsListChanged); + $this->assertEquals([ + 'prompts' => new \stdClass(), + 'resources' => new \stdClass(), + 'tools' => new \stdClass(), + ], $capabilities->jsonSerialize()); + } } diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index fa101c40..117ee531 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -43,6 +43,7 @@ use Mcp\Tests\Unit\Server\Session\Fixture\RecordingSessionStore; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; final class BuilderTest extends TestCase { @@ -241,6 +242,52 @@ public function testSetSessionLockIsWiredIntoTheProtocol(): void ], $log); } + #[TestDox('listChanged is announced when a notification bus delivers the built-in registry\'s changes')] + public function testListChangedIsAnnouncedWithANotificationBus(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setNotificationBus(new InMemoryNotificationBus()) + ->build(); + + $capabilities = $this->extractServerCapabilities($server); + + $this->assertTrue($capabilities->toolsListChanged); + $this->assertTrue($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->promptsListChanged); + } + + #[TestDox('listChanged is not announced with an event dispatcher alone, nothing delivers the changes')] + public function testListChangedIsNotAnnouncedWithoutANotificationBus(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setEventDispatcher($this->createStub(EventDispatcherInterface::class)) + ->build(); + + $capabilities = $this->extractServerCapabilities($server); + + $this->assertFalse($capabilities->toolsListChanged); + $this->assertFalse($capabilities->resourcesListChanged); + $this->assertFalse($capabilities->promptsListChanged); + } + + #[TestDox('listChanged is not announced for a custom registry, the builder cannot wire it to the bus')] + public function testListChangedIsNotAnnouncedForACustomRegistry(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setRegistry(new Registry()) + ->setNotificationBus(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 { diff --git a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php index 25df23b0..7efb77ae 100644 --- a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php @@ -177,6 +177,32 @@ public function testHandlesMissingConfiguration(): void $this->assertSame(ProtocolVersion::V2025_06_18, $response->result->protocolVersion); } + #[TestDox('listChanged is not announced, the handshake era never delivers list changes')] + public function testListChangedIsStrippedFromTheHandshake(): void + { + $handler = new InitializeHandler(new Configuration( + serverInfo: new Implementation('server', '1.2.3'), + capabilities: new ServerCapabilities( + toolsListChanged: true, + resourcesSubscribe: true, + resourcesListChanged: true, + promptsListChanged: true, + ), + )); + + $response = $handler->handle( + $this->createInitializeRequest(ProtocolVersion::V2025_06_18->value), + $this->createStub(SessionInterface::class), + ); + + \assert($response->result instanceof InitializeResult); + $this->assertEquals([ + 'prompts' => new \stdClass(), + 'resources' => (object) ['subscribe' => true], + 'tools' => new \stdClass(), + ], $response->result->capabilities->jsonSerialize()); + } + private function createConfiguration(?ProtocolVersion $protocolVersion = null): Configuration { return new Configuration( diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 1d3deaaf..3e2c9d26 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -1236,6 +1236,20 @@ public function testExtensionIsAdvertised(): void $this->assertSame(['flavour' => 'vanilla'], (array) $answer['body']['result']['capabilities']['extensions']['com.example/things']); } + #[TestDox('listChanged is announced on server/discover when a notification bus delivers it')] + public function testListChangedIsAdvertisedWithANotificationBus(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->setNotificationBus(new InMemoryNotificationBus()) + ->addTool(static fn (): string => 'ok', 'alpha') + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $capabilities = self::call($protocol, 'server/discover')['body']['result']['capabilities']; + + $this->assertTrue($capabilities['tools']['listChanged']); + } + #[TestDox('a method of an extension this server has never heard of stays generic')] public function testUnknownExtensionMethodStaysGeneric(): void {