php-sdk/tests/Unit/Server/Subscription/NotificationBusTest.php
Christopher Hertel daac88d626
[Server] Make subscriptions/listen actually deliver (#451)
The stream acknowledged, slept thirty seconds and closed, so tools and
resource changes never reached a client — and with resources/subscribe
gone, that left the revision with no server-push at all. A notification bus
carries them: in-memory where publisher and stream share a process, PSR-16
where php-fpm puts them in different ones. The conformance server proves
the latter — a tools/call in one worker reaches a stream held in another.
2026-08-19 00:08:02 +02:00

217 lines
6.8 KiB
PHP

<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Tests\Unit\Server\Subscription;
use Mcp\Event\ToolListChangedEvent;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Notification\PromptListChangedNotification;
use Mcp\Schema\Notification\ResourceUpdatedNotification;
use Mcp\Schema\Notification\ToolListChangedNotification;
use Mcp\Server\Subscription\InMemoryNotificationBus;
use Mcp\Server\Subscription\NotificationBusInterface;
use Mcp\Server\Subscription\Psr16NotificationBus;
use Mcp\Server\Subscription\PublishingEventDispatcher;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Psr\SimpleCache\CacheInterface;
class NotificationBusTest extends TestCase
{
/**
* @return iterable<string, array{NotificationBusInterface}>
*/
public static function buses(): iterable
{
yield 'in memory' => [new InMemoryNotificationBus()];
yield 'psr-16' => [new Psr16NotificationBus(self::arrayCache())];
}
#[DataProvider('buses')]
#[TestDox('a subscriber reads what is published after it opened')]
public function testReadsForwardFromItsCursor(NotificationBusInterface $bus): void
{
$bus->publish(new ToolListChangedNotification());
// Opened after the first notification, so it must not see it.
$cursor = $bus->cursor();
$bus->publish(new PromptListChangedNotification());
$bus->publish(new ResourceUpdatedNotification('file:///a'));
[$found, $next] = $bus->since($cursor);
$this->assertCount(2, $found);
$this->assertInstanceOf(PromptListChangedNotification::class, $found[0]);
$this->assertInstanceOf(ResourceUpdatedNotification::class, $found[1]);
$this->assertSame('file:///a', $found[1]->uri);
// Reading again from the returned cursor yields nothing new.
[$again] = $bus->since($next);
$this->assertSame([], $again);
}
#[DataProvider('buses')]
#[TestDox('a quiet bus hands back nothing')]
public function testQuietBusIsEmpty(NotificationBusInterface $bus): void
{
[$found] = $bus->since($bus->cursor());
$this->assertSame([], $found);
}
#[DataProvider('buses')]
#[TestDox('two subscribers at different cursors each get their own view')]
public function testCursorsAreIndependent(NotificationBusInterface $bus): void
{
$early = $bus->cursor();
$bus->publish(new ToolListChangedNotification());
$late = $bus->cursor();
$bus->publish(new PromptListChangedNotification());
$this->assertCount(2, $bus->since($early)[0]);
$this->assertCount(1, $bus->since($late)[0]);
}
#[TestDox('the backlog is bounded, so a stream that went away cannot grow it forever')]
public function testBacklogIsBounded(): void
{
$bus = new InMemoryNotificationBus(backlog: 3);
$cursor = $bus->cursor();
for ($i = 0; $i < 10; ++$i) {
$bus->publish(new ToolListChangedNotification());
}
$this->assertCount(3, $bus->since($cursor)[0]);
}
#[TestDox('a backlog below one entry is refused')]
public function testZeroBacklogIsRefused(): void
{
$this->expectException(InvalidArgumentException::class);
new InMemoryNotificationBus(backlog: 0);
}
#[TestDox('a registry change event becomes a notification on the bus')]
public function testRegistryEventsArePublished(): void
{
$bus = new InMemoryNotificationBus();
$cursor = $bus->cursor();
(new PublishingEventDispatcher($bus))->dispatch(new ToolListChangedEvent());
[$found] = $bus->since($cursor);
$this->assertCount(1, $found);
$this->assertInstanceOf(ToolListChangedNotification::class, $found[0]);
}
#[TestDox('an event the publisher does not know is passed on untouched')]
public function testUnknownEventIsPassedThrough(): void
{
$bus = new InMemoryNotificationBus();
$cursor = $bus->cursor();
$event = new \stdClass();
$this->assertSame($event, (new PublishingEventDispatcher($bus))->dispatch($event));
$this->assertSame([], $bus->since($cursor)[0]);
}
#[TestDox('an inner dispatcher still sees every event')]
public function testInnerDispatcherStillRuns(): void
{
$seen = [];
$inner = new class($seen) implements \Psr\EventDispatcher\EventDispatcherInterface {
/** @param array<int, object> $seen */
public function __construct(public array &$seen)
{
}
public function dispatch(object $event): object
{
$this->seen[] = $event;
return $event;
}
};
(new PublishingEventDispatcher(new InMemoryNotificationBus(), $inner))->dispatch(new ToolListChangedEvent());
$this->assertCount(1, $inner->seen);
}
private static function arrayCache(): CacheInterface
{
return new class implements CacheInterface {
/** @var array<string, mixed> */
private array $values = [];
public function get(string $key, mixed $default = null): mixed
{
return $this->values[$key] ?? $default;
}
public function set(string $key, mixed $value, int|\DateInterval|null $ttl = null): bool
{
$this->values[$key] = $value;
return true;
}
public function delete(string $key): bool
{
unset($this->values[$key]);
return true;
}
public function clear(): bool
{
$this->values = [];
return true;
}
public function getMultiple(iterable $keys, mixed $default = null): iterable
{
foreach ($keys as $key) {
yield $key => $this->get($key, $default);
}
}
public function setMultiple(iterable $values, int|\DateInterval|null $ttl = null): bool
{
foreach ($values as $key => $value) {
$this->set((string) $key, $value, $ttl);
}
return true;
}
public function deleteMultiple(iterable $keys): bool
{
foreach ($keys as $key) {
$this->delete($key);
}
return true;
}
public function has(string $key): bool
{
return \array_key_exists($key, $this->values);
}
};
}
}