[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:
parent
7dd72d82de
commit
daac88d626
8 changed files with 907 additions and 13 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
74
src/Server/Subscription/InMemoryNotificationBus.php
Normal file
74
src/Server/Subscription/InMemoryNotificationBus.php
Normal 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];
|
||||
}
|
||||
}
|
||||
53
src/Server/Subscription/NotificationBusInterface.php
Normal file
53
src/Server/Subscription/NotificationBusInterface.php
Normal 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;
|
||||
}
|
||||
113
src/Server/Subscription/Psr16NotificationBus.php
Normal file
113
src/Server/Subscription/Psr16NotificationBus.php
Normal 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;
|
||||
}
|
||||
}
|
||||
53
src/Server/Subscription/PublishingEventDispatcher.php
Normal file
53
src/Server/Subscription/PublishingEventDispatcher.php
Normal 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;
|
||||
}
|
||||
}
|
||||
81
src/Server/Subscription/RegistryChangePublisher.php
Normal file
81
src/Server/Subscription/RegistryChangePublisher.php
Normal 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(...),
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue