[Server] Announce `listChanged` only where list changes are delivered… · modelcontextprotocol/php-sdk@3e91489 · GitHub
Skip to content

Commit 3e91489

Browse files
authored
[Server] Announce listChanged only where list changes are delivered (#593)
* [Server] Announce listChanged only where list changes are delivered * [Server] Address review: cover listChanged-only kinds, clarify bus docblock
1 parent 0008c00 commit 3e91489

8 files changed

Lines changed: 153 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions

‎src/Schema/ServerCapabilities.php‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,29 @@ public function withExtensions(array $extensions): self
144144
);
145145
}
146146

147+
/**
148+
* Returns a copy that announces no list-changed notifications.
149+
*
150+
* A kind announced only through its `listChanged` flag stays announced.
151+
*/
152+
public function withoutListChanged(): self
153+
{
154+
return new self(
155+
$this->tools || $this->toolsListChanged,
156+
false,
157+
$this->resources || $this->resourcesListChanged,
158+
$this->resourcesSubscribe,
159+
false,
160+
$this->prompts || $this->promptsListChanged,
161+
false,
162+
$this->logging,
163+
$this->completions,
164+
$this->experimental,
165+
$this->extensions,
166+
$this->tasks,
167+
);
168+
}
169+
147170
/**
148171
* @return array{
149172
* logging?: \stdClass,

‎src/Server/Builder.php‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -313,10 +313,11 @@ public function setServerInfo(
313313
* runtimes; under PHP-FPM, where they are different workers, use
314314
* {@see Psr16NotificationBus} or an implementation over your own broker.
315315
*
316-
* Registry changes are published automatically, with or without an event
317-
* dispatcher, unless you pass your own registry with setRegistry(). Anything
318-
* else, `notifications/resources/updated` for example, is published by the
319-
* application calling {@see NotificationBusInterface::publish()}.
316+
* With the built-in registry, registry changes are published automatically,
317+
* with or without an event dispatcher, and `listChanged` is announced. A
318+
* registry passed with setRegistry() is neither published nor announced.
319+
* Anything else, `notifications/resources/updated` for example, is
320+
* published by the application calling {@see NotificationBusInterface::publish()}.
320321
*/
321322
public function setNotificationBus(NotificationBusInterface $bus): self
322323
{
@@ -408,6 +409,9 @@ public function setInstructions(?string $instructions): self
408409

409410
/**
410411
* Explicitly set server capabilities. If set, this overrides automatic detection.
412+
*
413+
* A `listChanged` flag set here is announced on `server/discover` only; the
414+
* `initialize` handshake has no way to deliver list changes.
411415
*/
412416
public function setCapabilities(ServerCapabilities $serverCapabilities): self
413417
{
@@ -1094,7 +1098,7 @@ private function resolve(): array
10941098

10951099
$messageFactory = MessageFactory::make(additional: $this->extensionMessages);
10961100

1097-
$capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded, $eventDispatcher);
1101+
$capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded);
10981102

10991103
// Extensions enabled via enableExtension() are folded into caller-supplied
11001104
// capabilities too, so setCapabilities() does not silently drop them.
@@ -1150,11 +1154,11 @@ private function resolve(): array
11501154
* the load, so they are advertised from the configured sources instead — opaque sources (custom
11511155
* loaders, discovery) advertise all kinds, and over-advertising is harmless per MCP semantics.
11521156
*/
1153-
private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded, ?EventDispatcherInterface $eventDispatcher): ServerCapabilities
1157+
private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded): ServerCapabilities
11541158
{
1155-
// Without a dispatcher the registry announces nothing, so there is no
1156-
// list-changed notification to advertise.
1157-
$listChanged = $eventDispatcher instanceof EventDispatcherInterface;
1159+
// Only the bus delivers list changes, and only the built-in registry is
1160+
// wired to it; a custom registry's events never reach a client here.
1161+
$listChanged = null !== $this->notificationBus && !$this->hasCustomRegistry;
11581162

11591163
if ($eagerlyLoaded) {
11601164
$hasResources = $registry->hasResources() || $registry->hasResourceTemplates();

‎src/Server/Handler/Request/InitializeHandler.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,14 @@ public function handle(Request $request, SessionInterface $session): Response
5151
$negotiated = $this->negotiate($request->protocolVersion);
5252
$session->set('protocol_version', $negotiated->value);
5353

54+
// List changes reach clients only through `subscriptions/listen`, which
55+
// the handshake era does not have.
56+
$capabilities = ($this->configuration->capabilities ?? new ServerCapabilities())->withoutListChanged();
57+
5458
return new Response(
5559
$request->getId(),
5660
new InitializeResult(
57-
$this->configuration->capabilities ?? new ServerCapabilities(),
61+
$capabilities,
5862
$this->configuration->serverInfo ?? new Implementation(),
5963
$this->configuration?->instructions,
6064
null,

‎tests/Unit/Schema/ServerCapabilitiesTest.php‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,4 +429,28 @@ public function testWithExtensionsKeepsTasks(): void
429429

430430
$this->assertSame(['list' => []], $capabilities->tasks);
431431
}
432+
433+
public function testWithoutListChangedKeepsKindsAnnouncedOnlyThroughListChanged(): void
434+
{
435+
$capabilities = (new ServerCapabilities(
436+
tools: false,
437+
toolsListChanged: true,
438+
resources: false,
439+
resourcesListChanged: true,
440+
prompts: false,
441+
promptsListChanged: true,
442+
))->withoutListChanged();
443+
444+
$this->assertTrue($capabilities->tools);
445+
$this->assertFalse($capabilities->toolsListChanged);
446+
$this->assertTrue($capabilities->resources);
447+
$this->assertFalse($capabilities->resourcesListChanged);
448+
$this->assertTrue($capabilities->prompts);
449+
$this->assertFalse($capabilities->promptsListChanged);
450+
$this->assertEquals([
451+
'prompts' => new \stdClass(),
452+
'resources' => new \stdClass(),
453+
'tools' => new \stdClass(),
454+
], $capabilities->jsonSerialize());
455+
}
432456
}

‎tests/Unit/Server/BuilderTest.php‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
use Mcp\Tests\Unit\Server\Session\Fixture\RecordingSessionStore;
4444
use PHPUnit\Framework\Attributes\TestDox;
4545
use PHPUnit\Framework\TestCase;
46+
use Psr\EventDispatcher\EventDispatcherInterface;
4647

4748
final class BuilderTest extends TestCase
4849
{
@@ -241,6 +242,52 @@ public function testSetSessionLockIsWiredIntoTheProtocol(): void
241242
], $log);
242243
}
243244

245+
#[TestDox('listChanged is announced when a notification bus delivers the built-in registry\'s changes')]
246+
public function testListChangedIsAnnouncedWithANotificationBus(): void
247+
{
248+
$server = Server::builder()
249+
->setServerInfo('test', '1.0.0')
250+
->setNotificationBus(new InMemoryNotificationBus())
251+
->build();
252+
253+
$capabilities = $this->extractServerCapabilities($server);
254+
255+
$this->assertTrue($capabilities->toolsListChanged);
256+
$this->assertTrue($capabilities->resourcesListChanged);
257+
$this->assertTrue($capabilities->promptsListChanged);
258+
}
259+
260+
#[TestDox('listChanged is not announced with an event dispatcher alone, nothing delivers the changes')]
261+
public function testListChangedIsNotAnnouncedWithoutANotificationBus(): void
262+
{
263+
$server = Server::builder()
264+
->setServerInfo('test', '1.0.0')
265+
->setEventDispatcher($this->createStub(EventDispatcherInterface::class))
266+
->build();
267+
268+
$capabilities = $this->extractServerCapabilities($server);
269+
270+
$this->assertFalse($capabilities->toolsListChanged);
271+
$this->assertFalse($capabilities->resourcesListChanged);
272+
$this->assertFalse($capabilities->promptsListChanged);
273+
}
274+
275+
#[TestDox('listChanged is not announced for a custom registry, the builder cannot wire it to the bus')]
276+
public function testListChangedIsNotAnnouncedForACustomRegistry(): void
277+
{
278+
$server = Server::builder()
279+
->setServerInfo('test', '1.0.0')
280+
->setRegistry(new Registry())
281+
->setNotificationBus(new InMemoryNotificationBus())
282+
->build();
283+
284+
$capabilities = $this->extractServerCapabilities($server);
285+
286+
$this->assertFalse($capabilities->toolsListChanged);
287+
$this->assertFalse($capabilities->resourcesListChanged);
288+
$this->assertFalse($capabilities->promptsListChanged);
289+
}
290+
244291
#[TestDox('setLazyLoading() returns the builder for fluent chaining')]
245292
public function testSetLazyLoadingReturnsSelf(): void
246293
{

‎tests/Unit/Server/Handler/Request/InitializeHandlerTest.php‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,32 @@ public function testHandlesMissingConfiguration(): void
177177
$this->assertSame(ProtocolVersion::V2025_06_18, $response->result->protocolVersion);
178178
}
179179

180+
#[TestDox('listChanged is not announced, the handshake era never delivers list changes')]
181+
public function testListChangedIsStrippedFromTheHandshake(): void
182+
{
183+
$handler = new InitializeHandler(new Configuration(
184+
serverInfo: new Implementation('server', '1.2.3'),
185+
capabilities: new ServerCapabilities(
186+
toolsListChanged: true,
187+
resourcesSubscribe: true,
188+
resourcesListChanged: true,
189+
promptsListChanged: true,
190+
),
191+
));
192+
193+
$response = $handler->handle(
194+
$this->createInitializeRequest(ProtocolVersion::V2025_06_18->value),
195+
$this->createStub(SessionInterface::class),
196+
);
197+
198+
\assert($response->result instanceof InitializeResult);
199+
$this->assertEquals([
200+
'prompts' => new \stdClass(),
201+
'resources' => (object) ['subscribe' => true],
202+
'tools' => new \stdClass(),
203+
], $response->result->capabilities->jsonSerialize());
204+
}
205+
180206
private function createConfiguration(?ProtocolVersion $protocolVersion = null): Configuration
181207
{
182208
return new Configuration(

‎tests/Unit/Server/Stateless/StatelessProtocolTest.php‎

Lines changed: 14 additions & 0 deletions

0 commit comments

Comments
 (0)