[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.
This commit is contained in:
Christopher Hertel 2026-08-19 00:08:02 +02:00 • committed by GitHub
commit daac88d626
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 907 additions and 13 deletions

View file

@ -59,6 +59,10 @@ use Mcp\Server\Session\SessionStoreInterface;
use Mcp\Server\Stateless\RequestStateCodec;
use Mcp\Server\Stateless\StandardHeaderValidator;
use Mcp\Server\Stateless\StatelessProtocol;
use Mcp\Server\Subscription\InMemoryNotificationBus;
use Mcp\Server\Subscription\NotificationBusInterface;
use Mcp\Server\Subscription\Psr16NotificationBus;
use Mcp\Server\Subscription\PublishingEventDispatcher;
use Mcp\Server\Wire\CachePolicy;
use Psr\Container\ContainerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
@ -110,6 +114,10 @@ final class Builder
private ?CachePolicy $cachePolicy = null;
private ?NotificationBusInterface $notificationBus = null;
private float $subscriptionLifetime = 30.0;
private ?string $requestStateKey = null;
private int $requestStateTtl = 600;
@ -258,6 +266,45 @@ final class Builder
return $this;
}
/**
* Sets the bus carrying server-initiated notifications to open
* `subscriptions/listen` streams (SEP-2575).
*
* Without one, a listen stream acknowledges and then carries nothing: there
* is no safe default, because the right implementation depends on whether
* the publisher and the stream share a process.
* {@see InMemoryNotificationBus} is correct for stdio and persistent
* runtimes; under PHP-FPM, where they are different workers, use
* {@see Psr16NotificationBus} or an implementation over your own broker.
*
* Registry changes are published automatically when an event dispatcher is
* configured; anything else — `notifications/resources/updated` above all —
* is published by the application calling
* {@see NotificationBusInterface::publish()}.
*/
public function setNotificationBus(NotificationBusInterface $bus): self
{
$this->notificationBus = $bus;
return $this;
}
/**
* Sets how long a `subscriptions/listen` stream is held open before the
* server closes it gracefully.
*
* The real ceiling is the runtime's: under PHP-FPM a stream cannot outlive
* `max_execution_time`, and a value above it buys a killed worker instead
* of a longer subscription. Pass `0` for "until the client or the runtime
* ends it", which is what a persistent runtime wants.
*/
public function setSubscriptionLifetime(float $seconds): self
{
$this->subscriptionLifetime = max(0.0, $seconds);
return $this;
}
/**
* Sets how long, and to whom, this server's answers may be cached (SEP-2549).
*
@ -757,7 +804,7 @@ final class Builder
messageFactory: $parts['messageFactory'],
sessionManager: $parts['sessionManager'],
logger: $parts['logger'],
eventDispatcher: $this->eventDispatcher,
eventDispatcher: $parts['eventDispatcher'],
);
return new Server($protocol, $parts['logger']);
@ -782,11 +829,13 @@ final class Builder
configuration: $parts['configuration'],
supportedVersions: $supportedVersions,
logger: $parts['logger'],
subscriptionLifetime: $this->subscriptionLifetime,
headerValidator: $this->headerValidation ? new StandardHeaderValidator($parts['registry']) : null,
requestStateCodec: null !== $this->requestStateKey
? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl)
: null,
cachePolicy: $this->cachePolicy,
notificationBus: $this->notificationBus,
);
}
@ -795,6 +844,7 @@ final class Builder
*
* @return array{
* logger: LoggerInterface,
* eventDispatcher: ?EventDispatcherInterface,
* configuration: Configuration,
* messageFactory: MessageFactory,
* sessionManager: SessionManagerInterface,
@ -807,6 +857,12 @@ final class Builder
{
$logger = $this->logger ?? new NullLogger();
$container = $this->container ?? new Container();
// A configured bus needs the registry's change events, and PSR-14 hands
// the SDK a dispatcher it cannot register listeners on — so it wraps.
$eventDispatcher = null !== $this->notificationBus
? new PublishingEventDispatcher($this->notificationBus, $this->eventDispatcher)
: $this->eventDispatcher;
$subscriptionManager = $this->subscriptionManager ?? new SessionSubscriptionManager($logger);
$sessionManager = $this->sessionManager ?? new SessionManager(
$this->sessionStore ?? new InMemorySessionStore(),
@ -845,7 +901,7 @@ final class Builder
$chainLoader->load($registry);
$eagerlyLoaded = true;
} else {
$registry = new Registry($this->eventDispatcher, $logger, loader: $chainLoader);
$registry = new Registry($eventDispatcher, $logger, loader: $chainLoader);
if (!$this->lazyLoading) {
$registry->load();
}
@ -854,7 +910,7 @@ final class Builder
$messageFactory = MessageFactory::make(additional: $this->extensionMessages);
$capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded);
$capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded, $eventDispatcher);
// Extensions enabled via enableExtension() are folded into caller-supplied
// capabilities too, so setCapabilities() does not silently drop them.
@ -895,6 +951,7 @@ final class Builder
return [
'logger' => $logger,
'eventDispatcher' => $eventDispatcher,
'registry' => $registry,
'configuration' => $configuration,
'messageFactory' => $messageFactory,
@ -909,9 +966,11 @@ final class Builder
* 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): ServerCapabilities
private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded, ?EventDispatcherInterface $eventDispatcher): ServerCapabilities
{
$listChanged = $this->eventDispatcher instanceof EventDispatcherInterface;
// Without a dispatcher the registry announces nothing, so there is no
// list-changed notification to advertise.
$listChanged = $eventDispatcher instanceof EventDispatcherInterface;
if ($eagerlyLoaded) {
$hasResources = $registry->hasResources() || $registry->hasResourceTemplates();

View file

@ -11,6 +11,11 @@
namespace Mcp\Server\Stateless;
use Mcp\Schema\JsonRpc\Notification;
use Mcp\Schema\Notification\PromptListChangedNotification;
use Mcp\Schema\Notification\ResourceListChangedNotification;
use Mcp\Schema\Notification\ResourceUpdatedNotification;
use Mcp\Schema\Notification\ToolListChangedNotification;
use Mcp\Schema\ServerCapabilities;
/**
@ -65,6 +70,24 @@ final class NotificationFilter
);
}
/**
* Whether this filter admits $notification onto the stream.
*
* An allow-list decision, so an unrecognized notification is declined: the
* server MUST NOT send a type the client did not ask for, and "did not ask
* for" includes types it has never heard of.
*/
public function carries(Notification $notification): bool
{
return match (true) {
$notification instanceof ToolListChangedNotification => $this->toolsListChanged,
$notification instanceof PromptListChangedNotification => $this->promptsListChanged,
$notification instanceof ResourceListChangedNotification => $this->resourcesListChanged,
$notification instanceof ResourceUpdatedNotification => \in_array($notification->uri, $this->resourceSubscriptions, true),
default => false,
};
}
/**
* Agreed types only; declined ones are omitted rather than sent as `false`.
*

View file

@ -31,6 +31,7 @@ use Mcp\Server\Handler\Request\RequestHandlerInterface;
use Mcp\Server\Protocol;
use Mcp\Server\Session\InMemorySessionStore;
use Mcp\Server\Session\Session;
use Mcp\Server\Subscription\NotificationBusInterface;
use Mcp\Server\Wire\CachePolicy;
use Mcp\Server\Wire\Rev2026Codec;
use Mcp\Server\Wire\WireCodecInterface;
@ -100,6 +101,7 @@ final class StatelessProtocol
private readonly ?StandardHeaderValidator $headerValidator = null,
private readonly ?RequestStateCodec $requestStateCodec = null,
?CachePolicy $cachePolicy = null,
private readonly ?NotificationBusInterface $notificationBus = null,
) {
$this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy);
@ -295,8 +297,12 @@ final class StatelessProtocol
$agreed = NotificationFilter::fromParams($notifications)->intersect($this->configuration->capabilities);
$lifetime = $this->subscriptionLifetime;
$bus = $this->notificationBus;
$codec = $this->codec;
return StatelessResult::stream(static function () use ($agreed, $id, $lifetime): \Generator {
return StatelessResult::stream(static function () use ($agreed, $id, $lifetime, $bus, $codec): \Generator {
// MUST be the first message carrying this subscription's id, and
// MUST precede any notification on it.
yield [
'jsonrpc' => '2.0',
'method' => self::ACKNOWLEDGED_NOTIFICATION,
@ -306,12 +312,27 @@ final class StatelessProtocol
],
];
// Cross-request notifications need a channel this SDK does not
// define yet, so the stream only waits. The tick is not optional:
// PHP spots a dropped peer by writing, and a sleeping loop would
// pin an FPM worker for the full lifetime.
$deadline = microtime(true) + $lifetime;
// From now, not from the beginning: a subscriber wants what happens
// next, not a replay of the server's history.
$cursor = $bus?->cursor() ?? 0;
// The tick is not optional: PHP spots a dropped peer by writing,
// and a sleeping loop would pin an FPM worker for the full lifetime.
$deadline = 0.0 >= $lifetime ? \INF : microtime(true) + $lifetime;
while (microtime(true) < $deadline) {
if (null !== $bus) {
[$notifications, $cursor] = $bus->since($cursor);
foreach ($notifications as $notification) {
if (!$agreed->carries($notification)) {
continue;
}
yield self::tagWithSubscription($notification, $id);
}
}
yield null;
if (connection_aborted()) {
@ -326,14 +347,36 @@ final class StatelessProtocol
yield [
'jsonrpc' => '2.0',
'id' => $id,
'result' => [
'result' => $codec->encodeResult(self::LISTEN_METHOD, [
'resultType' => 'complete',
'_meta' => [RequestMeta::SUBSCRIPTION_ID => $id],
],
], false),
];
});
}
/**
* Every message on a listen stream carries the id of the subscription it
* belongs to, which is how a client demultiplexes them on stdio — where
* they all share one channel.
*
* @return array<string, mixed>
*/
private static function tagWithSubscription(Notification $notification, string|int $id): array
{
/** @var array<string, mixed> $frame */
$frame = $notification->jsonSerialize();
$params = \is_array($frame['params'] ?? null) ? $frame['params'] : [];
$meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : [];
$meta[RequestMeta::SUBSCRIPTION_ID] = $id;
$params['_meta'] = $meta;
$frame['params'] = $params;
return $frame;
}
private function discover(): DiscoverResult
{
return new DiscoverResult(

View file

@ -0,0 +1,74 @@
<?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\Server\Subscription;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\JsonRpc\Notification;
/**
* A notification bus held in one process's memory.
*
* Correct for stdio and for persistent runtimes where the whole server lives in
* one process (Swoole, FrankenPHP worker mode, ReactPHP). Under PHP-FPM it is
* not: the worker that publishes and the worker holding the stream open are
* different processes, and a notification published in one is invisible to the
* other. {@see Psr16NotificationBus} is the answer there.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class InMemoryNotificationBus implements NotificationBusInterface
{
/** @var array<int, Notification> */
private array $entries = [];
private int $next = 0;
/**
* @param int $backlog how many notifications to keep before dropping the oldest
*/
public function __construct(
private readonly int $backlog = 256,
) {
if ($this->backlog < 1) {
throw new InvalidArgumentException(\sprintf('The notification backlog must be at least one entry, got %d.', $this->backlog));
}
}
public function publish(Notification $notification): void
{
$this->entries[$this->next] = $notification;
++$this->next;
// A stream that went away must not make this grow forever.
if (\count($this->entries) > $this->backlog) {
$this->entries = \array_slice($this->entries, -$this->backlog, preserve_keys: true);
}
}
public function cursor(): int
{
return $this->next;
}
public function since(int $cursor): array
{
$found = [];
foreach ($this->entries as $sequence => $notification) {
if ($sequence >= $cursor) {
$found[] = $notification;
}
}
return [$found, $this->next];
}
}

View file

@ -0,0 +1,53 @@
<?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\Server\Subscription;
use Mcp\Schema\JsonRpc\Notification;
/**
* Carries server-initiated notifications to whichever `subscriptions/listen`
* streams are open.
*
* A bus and not a registry of listeners, because the two ends are rarely in the
* same process: under PHP-FPM the request that changes the tool list and the
* request holding a listen stream open are different workers, so the only thing
* they can share is storage. The read side is a cursor rather than a
* subscription: a stream that opens at cursor `c` reads forward from `c`, and
* nothing has to know a stream exists before it does.
*
* Implementations are expected to drop old entries — a listen stream that goes
* away must not make the backlog grow without bound.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface NotificationBusInterface
{
/**
* Publishes a notification to every stream currently reading forward.
*/
public function publish(Notification $notification): void;
/**
* The cursor a stream opening now should start from.
*
* Deliberately "now" and not "the beginning": a client that subscribes
* wants what happens next, not a replay of everything the server has done.
*/
public function cursor(): int;
/**
* Notifications published after $cursor, and the cursor to read from next.
*
* @return array{list<Notification>, int}
*/
public function since(int $cursor): array;
}

View file

@ -0,0 +1,113 @@
<?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\Server\Subscription;
use Mcp\Exception\InvalidArgumentException;
use Mcp\JsonRpc\MessageFactory;
use Mcp\Schema\JsonRpc\Notification;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Psr\SimpleCache\CacheInterface;
/**
* A notification bus over any PSR-16 cache, so publishers and listen streams in
* different processes can see each other.
*
* The sequence counter and the entries are separate keys: a publisher bumps the
* counter and writes its entry under the number it got. That is not atomic
* across processes — two publishers can take the same number and one entry is
* lost — which is the trade every PSR-16-only design makes, and acceptable
* here: a lost `tools/list_changed` costs a client one stale list until the TTL
* lapses, not correctness. A backend with atomic increments (Redis `INCR`)
* should implement {@see NotificationBusInterface} directly.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class Psr16NotificationBus implements NotificationBusInterface
{
private const CURSOR_KEY = 'cursor';
private readonly MessageFactory $messageFactory;
/**
* @param string $prefix namespace for this bus's keys, so one cache can carry several
* @param int $ttl how long an entry stays readable, in seconds
* @param int $backlog how many entries a reader will look back over
*/
public function __construct(
private readonly CacheInterface $cache,
private readonly string $prefix = 'mcp.notifications.',
private readonly int $ttl = 120,
private readonly int $backlog = 256,
private readonly LoggerInterface $logger = new NullLogger(),
?MessageFactory $messageFactory = null,
) {
if ($this->backlog < 1) {
throw new InvalidArgumentException(\sprintf('The notification backlog must be at least one entry, got %d.', $this->backlog));
}
$this->messageFactory = $messageFactory ?? MessageFactory::make();
}
public function publish(Notification $notification): void
{
$sequence = $this->cursor();
$this->cache->set($this->key((string) $sequence), json_encode($notification, \JSON_THROW_ON_ERROR), $this->ttl);
$this->cache->set($this->key(self::CURSOR_KEY), $sequence + 1, $this->ttl);
}
public function cursor(): int
{
$cursor = $this->cache->get($this->key(self::CURSOR_KEY), 0);
return \is_int($cursor) ? $cursor : 0;
}
public function since(int $cursor): array
{
$head = $this->cursor();
// A reader that fell far behind reads what is still there, not
// everything it missed.
$from = max($cursor, $head - $this->backlog);
$found = [];
for ($sequence = $from; $sequence < $head; ++$sequence) {
$raw = $this->cache->get($this->key((string) $sequence));
if (!\is_string($raw)) {
// Expired, or lost to a concurrent publisher taking the same
// number. Neither is worth failing the stream over.
continue;
}
try {
foreach ($this->messageFactory->create($raw) as $message) {
if ($message instanceof Notification) {
$found[] = $message;
}
}
} catch (\Throwable $e) {
$this->logger->warning('Dropped an unreadable notification from the bus.', ['sequence' => $sequence, 'exception' => $e]);
}
}
return [$found, $head];
}
private function key(string $suffix): string
{
return $this->prefix.$suffix;
}
}

View file

@ -0,0 +1,53 @@
<?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\Server\Subscription;
use Psr\EventDispatcher\EventDispatcherInterface;
/**
* Publishes registry change events onto a notification bus, then passes them on.
*
* PSR-14 splits dispatching from listener registration, and the SDK is only
* handed the dispatcher half — so it cannot register a listener on an
* application's own dispatcher. This decorator is the way round that: configure
* a bus and the SDK wraps whatever dispatcher it was given (or stands in for
* one), so a runtime `registerTool()` reaches a listening client without the
* caller wiring anything.
*
* An application with its own listener provider can skip this and register
* {@see RegistryChangePublisher::listeners()} itself.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class PublishingEventDispatcher implements EventDispatcherInterface
{
/** @var array<class-string, callable(object): void> */
private readonly array $listeners;
public function __construct(
NotificationBusInterface $bus,
private readonly ?EventDispatcherInterface $inner = null,
) {
$this->listeners = (new RegistryChangePublisher($bus))->listeners();
}
public function dispatch(object $event): object
{
$listener = $this->listeners[$event::class] ?? null;
if (null !== $listener) {
$listener($event);
}
return $this->inner?->dispatch($event) ?? $event;
}
}

View file

@ -0,0 +1,81 @@
<?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\Server\Subscription;
use Mcp\Event\PromptListChangedEvent;
use Mcp\Event\ResourceListChangedEvent;
use Mcp\Event\ResourceTemplateListChangedEvent;
use Mcp\Event\ToolListChangedEvent;
use Mcp\Schema\Notification\PromptListChangedNotification;
use Mcp\Schema\Notification\ResourceListChangedNotification;
use Mcp\Schema\Notification\ToolListChangedNotification;
/**
* Turns the registry's own change events into the notifications a
* `subscriptions/listen` stream carries.
*
* The registry already announces every mutation to the PSR-14 dispatcher; this
* is the adapter that puts them where a stream can read them, so a runtime
* `registerTool()` reaches a listening client without the caller knowing a bus
* exists.
*
* There is no template-list notification in the protocol, so a changed
* resource-template list is announced as a changed resource list — which is
* what a client would refetch anyway.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class RegistryChangePublisher
{
public function __construct(
private readonly NotificationBusInterface $bus,
) {
}
public function onToolListChanged(ToolListChangedEvent $event): void
{
$this->bus->publish(new ToolListChangedNotification());
}
public function onPromptListChanged(PromptListChangedEvent $event): void
{
$this->bus->publish(new PromptListChangedNotification());
}
public function onResourceListChanged(ResourceListChangedEvent $event): void
{
$this->bus->publish(new ResourceListChangedNotification());
}
public function onResourceTemplateListChanged(ResourceTemplateListChangedEvent $event): void
{
$this->bus->publish(new ResourceListChangedNotification());
}
/**
* Every event this publisher handles, keyed by event class.
*
* Shaped for a dispatcher that wants a map; a framework's own subscriber
* conventions can read it too rather than restating the list.
*
* @return array<class-string, callable(object): void>
*/
public function listeners(): array
{
return [
ToolListChangedEvent::class => $this->onToolListChanged(...),
PromptListChangedEvent::class => $this->onPromptListChanged(...),
ResourceListChangedEvent::class => $this->onResourceListChanged(...),
ResourceTemplateListChangedEvent::class => $this->onResourceTemplateListChanged(...),
];
}
}