Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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 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.
* [BC Break] `SchemaValidator` takes an optional `Opis\JsonSchema\Validator` as its first constructor argument, moving `$logger` to second. Pass `logger:` by name.
* [BC Break] `SchemaValidator::validateAgainstJsonSchema()` no longer validates an empty array as an object. Pass `new \stdClass()` for an empty object.
Expand Down
23 changes: 23 additions & 0 deletions src/Schema/ServerCapabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
chr-hertel marked this conversation as resolved.
false,
$this->logging,
$this->completions,
$this->experimental,
$this->extensions,
$this->tasks,
);
}

/**
* @return array{
* logging?: \stdClass,
Expand Down
22 changes: 13 additions & 9 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -310,10 +310,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
{
Expand Down Expand Up @@ -405,6 +406,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
{
Expand Down Expand Up @@ -1076,7 +1080,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.
Expand Down Expand Up @@ -1132,11 +1136,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();
Expand Down
6 changes: 5 additions & 1 deletion src/Server/Handler/Request/InitializeHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions tests/Unit/Schema/ServerCapabilitiesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
47 changes: 47 additions & 0 deletions tests/Unit/Server/BuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
use Mcp\Tests\Unit\Server\Extension\ThingListRequest;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Psr\EventDispatcher\EventDispatcherInterface;

final class BuilderTest extends TestCase
{
Expand Down Expand Up @@ -192,6 +193,52 @@ public function testBuildAdvertisesToolsForPreloadedCustomRegistry(): void
$this->assertTrue($capabilities->tools);
}

#[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
{
Expand Down
26 changes: 26 additions & 0 deletions tests/Unit/Server/Handler/Request/InitializeHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions tests/Unit/Server/Stateless/StatelessProtocolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading