diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0b7062..9c2fa309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.11.0 +------ + +* [BC Break] `Builder::setLazyLoading(true)` now also defers loading into a registry passed to `setRegistry()`, which any `RegistryInterface` supports. A registry you read directly stays empty until the server first reads it; without the call it is still loaded at build. +* [BC Break] Detect the server capabilities when a request asks for them, so `initialize`, `server/discover` and `subscriptions/listen` run a pending load. The server advertises what the registry holds, and no longer every kind for a custom loader or discovery that was not loaded yet. While a load is still running, it advertises all kinds. `Configuration::$capabilities` is replaced by `Configuration::capabilities()`, and the constructor also takes a closure. +* Add `DeferredRegistryInterface`, so the builder loads a custom registry without announcing the load as list changes, and no longer depends on the concrete `Registry`. +* Split `RegistryInterface` by role into `ElementCatalogInterface`, `ElementResolverInterface` and `ElementWriterInterface`, which it extends. Request handlers depend on the role they use, so a handler can run against a catalog or resolver alone. + 0.10.0 ------ diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index 56cfbdc3..06c3f324 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -506,8 +506,8 @@ $server = Server::builder() | `setSchemaGenerator()` | schemaGenerator | Replace the generator that builds input schemas from PHP types, see [Schema generation](../servers/schemas.md) | | `setSchemaValidator()` | schemaValidator | Replace the validator for tool arguments and structured output, see [Schema generation](../servers/schemas.md) | | `setReferenceHandler()` | referenceHandler | Replace the class that calls the handlers of tools, resources and prompts | -| `setRegistry()` | registry | Use your own registry. It is loaded when the server is built | -| `setLazyLoading()` | lazyLoading? | Load elements on the first registry read (default) or, with `false`, when the server is built | +| `setRegistry()` | registry | Use your own registry. It is loaded when the server is built, unless you call `setLazyLoading()` | +| `setLazyLoading()` | lazyLoading? | Load elements on the first registry read, which is at the latest when a client asks for the capabilities (default without `setRegistry()`), or, with `false`, when the server is built. Your own registry stays empty until the server first reads it | | `addLoader()` | loader | Add a custom loader that registers elements | | `addLoaders()` | loaders | Add several custom loaders | | `addRequestHandler()` | handler | Prepend a single custom request handler | diff --git a/src/Server/Builder.php b/src/Server/Builder.php index aa8a850d..06346c52 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -26,6 +26,7 @@ use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server\Capability\Completion\ProviderInterface; +use Mcp\Server\Capability\DeferredRegistryInterface; use Mcp\Server\Capability\Discovery\CachedDiscoverer; use Mcp\Server\Capability\Discovery\Discoverer; use Mcp\Server\Capability\Discovery\DiscovererInterface; @@ -34,6 +35,7 @@ use Mcp\Server\Capability\Registry; use Mcp\Server\Capability\Registry\Container; use Mcp\Server\Capability\Registry\ElementReference; +use Mcp\Server\Capability\Registry\LazyRegistry; use Mcp\Server\Capability\Registry\Loader\ChainLoader; use Mcp\Server\Capability\Registry\Loader\DiscoveryLoader; use Mcp\Server\Capability\Registry\Loader\ExplicitElementLoader; @@ -276,7 +278,7 @@ final class Builder private bool $hasCustomRegistry = false; - private bool $lazyLoading = true; + private ?bool $lazyLoading = null; private bool $headerValidation = true; @@ -596,8 +598,8 @@ public function setRegistry(RegistryInterface $registry): self * * Lazy (the default) defers loading to the first registry read so a persistent runtime does not * freeze the registry to a source not yet ready at build time. Disable to load eagerly at build. - * A registry supplied via setRegistry() is always loaded eagerly; its own constructor loader, - * if it has one, still runs on the first read. + * A registry supplied via setRegistry() is loaded at build unless lazy loading is asked for explicitly. + * Then it stays unloaded until the server first reads it, also for code holding the same instance. */ public function setLazyLoading(bool $lazyLoading = true): self { @@ -1079,7 +1081,8 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202 $discovery = $parts['configuration']; // Without a bus, `subscriptions/listen` delivers nothing; `resources/subscribe` only exists on the handshake. if (null === $this->notificationBus && null === $this->serverCapabilities) { - $discovery = new Configuration($discovery->serverInfo, $discovery->capabilities->withoutResourcesSubscribe(), $discovery->paginationLimit, $discovery->instructions, $discovery->protocolVersion); + $configuration = $discovery; + $discovery = new Configuration($discovery->serverInfo, static fn (): ServerCapabilities => $configuration->capabilities()->withoutResourcesSubscribe(), $discovery->paginationLimit, $discovery->instructions, $discovery->protocolVersion); } return new StatelessProtocol( @@ -1160,35 +1163,31 @@ private function resolve(): array $chainLoader = new ChainLoader($loaders); - if ($this->hasCustomRegistry) { - // Builder can't inject the loader into an already-constructed instance, so load it eagerly. - // Via loadFrom(), which suppresses the change events the load would otherwise dispatch. + if ($this->hasCustomRegistry && true === $this->lazyLoading) { + $registry = new LazyRegistry($this->registry, $chainLoader); + } elseif ($this->hasCustomRegistry) { $registry = $this->registry; - if ($registry instanceof Registry) { + if ($registry instanceof DeferredRegistryInterface) { $registry->loadFrom($chainLoader); } else { $chainLoader->load($registry); } - $eagerlyLoaded = true; } else { $registry = new Registry($eventDispatcher, $logger, loader: $chainLoader); - if (!$this->lazyLoading) { + if (false === $this->lazyLoading) { $registry->load(); } - $eagerlyLoaded = !$this->lazyLoading; } $messageFactory = MessageFactory::make(additional: $this->extensionMessages); // `server/discover` exists in the modern era only. $modernMessageFactory = MessageFactory::make(additional: [...$this->extensionMessages, DiscoverRequest::class]); - $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. - if (null !== $this->serverCapabilities && [] !== $this->extensions) { - $capabilities = $capabilities->withExtensions($this->extensions); - } + $capabilities = null === $this->serverCapabilities + ? (new RegistryCapabilities($registry, null !== $this->notificationBus, $this->extensions))->detect(...) + : ([] !== $this->extensions ? $this->serverCapabilities->withExtensions($this->extensions) : $this->serverCapabilities); if (null !== $this->protocolVersion && $this->protocolVersion->isModern()) { $logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, negotiating the handshake revisions instead.', [ @@ -1234,50 +1233,6 @@ private function resolve(): array ]; } - /** - * When loaded, capabilities are read from the registry. When deferred, reading it would force - * 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 - { - // Only the bus delivers list changes, and only if the registry publishes to it. - $listChanged = null !== $this->notificationBus && $registry->publishesListChanges(); - - if ($eagerlyLoaded) { - $hasResources = $registry->hasResources() || $registry->hasResourceTemplates(); - - return new ServerCapabilities( - tools: $registry->hasTools(), - toolsListChanged: $listChanged, - resources: $hasResources, - resourcesSubscribe: $hasResources, - resourcesListChanged: $listChanged, - prompts: $registry->hasPrompts(), - promptsListChanged: $listChanged, - logging: true, - completions: true, - extensions: $this->extensions ?: null, - ); - } - - $hasOpaqueSources = [] !== $this->loaders || null !== $this->discoveryBasePath; - $hasResources = [] !== $this->resources || [] !== $this->explicitResources || [] !== $this->resourceTemplates || [] !== $this->explicitResourceTemplates || $hasOpaqueSources; - - return new ServerCapabilities( - tools: [] !== $this->tools || [] !== $this->explicitTools || $hasOpaqueSources, - toolsListChanged: $listChanged, - resources: $hasResources, - resourcesSubscribe: $hasResources, - resourcesListChanged: $listChanged, - prompts: [] !== $this->prompts || [] !== $this->explicitPrompts || $hasOpaqueSources, - promptsListChanged: $listChanged, - logging: true, - completions: true, - extensions: $this->extensions ?: null, - ); - } - private function createDiscoverer(LoggerInterface $logger): DiscovererInterface { $discoverer = new Discoverer($logger, null, $this->schemaGenerator); diff --git a/src/Server/Capability/DeferredRegistryInterface.php b/src/Server/Capability/DeferredRegistryInterface.php new file mode 100644 index 00000000..c01bb2b1 --- /dev/null +++ b/src/Server/Capability/DeferredRegistryInterface.php @@ -0,0 +1,35 @@ + + */ +interface DeferredRegistryInterface +{ + /** + * Runs a pending load, if there is one. A failed load stays pending. + */ + public function load(): void; + + public function isLoaded(): bool; + + /** + * Runs $loader against this registry without announcing its writes as list changes, since they + * describe the registry filling up rather than changing. + */ + public function loadFrom(LoaderInterface $loader): void; +} diff --git a/src/Server/Capability/ElementCatalogInterface.php b/src/Server/Capability/ElementCatalogInterface.php new file mode 100644 index 00000000..94bfa9cf --- /dev/null +++ b/src/Server/Capability/ElementCatalogInterface.php @@ -0,0 +1,54 @@ + + */ +interface ElementCatalogInterface +{ + /** + * Gets all registered tools. + * + * @return Page + */ + public function getTools(?int $limit = null, ?string $cursor = null): Page; + + /** + * Gets all registered resources. + * + * @return Page + */ + public function getResources(?int $limit = null, ?string $cursor = null): Page; + + /** + * Gets all registered resource templates. + * + * @return Page + */ + public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page; + + /** + * Gets all registered prompts. + * + * @return Page + */ + public function getPrompts(?int $limit = null, ?string $cursor = null): Page; +} diff --git a/src/Server/Capability/ElementResolverInterface.php b/src/Server/Capability/ElementResolverInterface.php new file mode 100644 index 00000000..fa68f59f --- /dev/null +++ b/src/Server/Capability/ElementResolverInterface.php @@ -0,0 +1,51 @@ + + */ +interface ElementResolverInterface +{ + /** + * Gets a tool reference by name. + * + * @throws ToolNotFoundException + * @throws ConfigurationException when the elements fail to load + */ + public function getTool(string $name): ToolReference; + + /** + * Gets a resource reference by URI (includes template matching if enabled). + * + * @throws ResourceNotFoundException + */ + public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference; + + /** + * Gets a prompt reference by name. + * + * @throws PromptNotFoundException + */ + public function getPrompt(string $name): PromptReference; +} diff --git a/src/Server/Capability/ElementWriterInterface.php b/src/Server/Capability/ElementWriterInterface.php new file mode 100644 index 00000000..6f88322b --- /dev/null +++ b/src/Server/Capability/ElementWriterInterface.php @@ -0,0 +1,96 @@ + + */ +interface ElementWriterInterface +{ + /** + * Registers a tool with its handler. Overwrites any prior registration of the same name. + * Returns the stored reference, whose identity callers may track to detect later overwrites. + * + * @param Handler $handler + */ + public function registerTool(Tool $tool, callable|array|string $handler): ToolReference; + + /** + * Registers a resource with its handler. Overwrites any prior registration of the same URI. + * Returns the stored reference, whose identity callers may track to detect later overwrites. + * + * @param Handler $handler + */ + public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference; + + /** + * Registers a resource template with its handler and completion providers. + * Overwrites any prior registration of the same URI template. + * Returns the stored reference, whose identity callers may track to detect later overwrites. + * + * @param Handler $handler + * @param array $completionProviders + */ + public function registerResourceTemplate( + ResourceTemplate $template, + callable|array|string $handler, + array $completionProviders = [], + ): ResourceTemplateReference; + + /** + * Registers a prompt with its handler and completion providers. + * Overwrites any prior registration of the same name. + * Returns the stored reference, whose identity callers may track to detect later overwrites. + * + * @param Handler $handler + * @param array $completionProviders + */ + public function registerPrompt( + Prompt $prompt, + callable|array|string $handler, + array $completionProviders = [], + ): PromptReference; + + /** + * Removes a tool by name. No-op if absent. + */ + public function unregisterTool(string $name): void; + + /** + * Removes a resource by URI. No-op if absent. + */ + public function unregisterResource(string $uri): void; + + /** + * Removes a resource template by URI template. No-op if absent. + */ + public function unregisterResourceTemplate(string $uriTemplate): void; + + /** + * Removes a prompt by name. No-op if absent. + */ + public function unregisterPrompt(string $name): void; +} diff --git a/src/Server/Capability/Registry.php b/src/Server/Capability/Registry.php index f518d29b..ef6660c5 100644 --- a/src/Server/Capability/Registry.php +++ b/src/Server/Capability/Registry.php @@ -40,7 +40,7 @@ * * @author Kyrian Obikwelu */ -final class Registry implements RegistryInterface +final class Registry implements RegistryInterface, DeferredRegistryInterface { /** * @var array @@ -64,7 +64,8 @@ final class Registry implements RegistryInterface private bool $loaded = false; - private bool $loading = false; + /** Loads running right now, counted because loads from other fibers may interleave */ + private int $loading = 0; public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, @@ -85,7 +86,7 @@ public function __construct( */ public function load(): void { - if ($this->loaded || $this->loading || null === $this->loader) { + if ($this->loaded || 0 !== $this->loading || null === $this->loader) { return; } @@ -94,24 +95,26 @@ public function load(): void $this->loaded = true; } + public function isLoaded(): bool + { + return $this->loaded || null === $this->loader; + } + /** * Runs $loader with the change events its registrations would dispatch suppressed, since they * describe the registry filling up rather than changing. * - * Re-entrant-safe, and failure propagates. Does not mark the registry loaded: $loader is the - * caller's, and the one the constructor took is still owed its run. + * Always runs $loader, even while another load is running, and failure propagates. Does not + * mark the registry loaded: $loader is the caller's, and the one the constructor took is still + * owed its run. */ public function loadFrom(LoaderInterface $loader): void { - if ($this->loading) { - return; - } - - $this->loading = true; + ++$this->loading; try { $loader->load($this); } finally { - $this->loading = false; + --$this->loading; } } @@ -418,7 +421,7 @@ public function getPrompt(string $name): PromptReference */ private function dispatch(object $event): void { - if ($this->loading) { + if (0 !== $this->loading) { return; } diff --git a/src/Server/Capability/Registry/LazyRegistry.php b/src/Server/Capability/Registry/LazyRegistry.php new file mode 100644 index 00000000..17681953 --- /dev/null +++ b/src/Server/Capability/Registry/LazyRegistry.php @@ -0,0 +1,234 @@ + + */ +final class LazyRegistry implements RegistryInterface, DeferredRegistryInterface +{ + private bool $loaded = false; + private bool $loading = false; + + public function __construct( + private readonly RegistryInterface $registry, + private readonly LoaderInterface $loader, + ) { + } + + public function load(): void + { + if ($this->loading) { + return; + } + + if (!$this->loaded) { + $this->loading = true; + try { + $this->loadFrom($this->loader); + $this->loaded = true; + } finally { + $this->loading = false; + } + } + + if ($this->registry instanceof DeferredRegistryInterface) { + $this->registry->load(); + } + } + + public function isLoaded(): bool + { + return $this->loaded && (!$this->registry instanceof DeferredRegistryInterface || $this->registry->isLoaded()); + } + + public function loadFrom(LoaderInterface $loader): void + { + if ($this->registry instanceof DeferredRegistryInterface) { + $this->registry->loadFrom($loader); + + return; + } + + $loader->load($this->registry); + } + + public function publishesListChanges(): bool + { + return $this->registry->publishesListChanges(); + } + + public function registerTool(Tool $tool, callable|array|string $handler): ToolReference + { + return $this->registry->registerTool($tool, $handler); + } + + public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference + { + return $this->registry->registerResource($resource, $handler); + } + + public function registerResourceTemplate(ResourceTemplate $template, callable|array|string $handler, array $completionProviders = []): ResourceTemplateReference + { + return $this->registry->registerResourceTemplate($template, $handler, $completionProviders); + } + + public function registerPrompt(Prompt $prompt, callable|array|string $handler, array $completionProviders = []): PromptReference + { + return $this->registry->registerPrompt($prompt, $handler, $completionProviders); + } + + public function unregisterTool(string $name): void + { + $this->registry->unregisterTool($name); + } + + public function unregisterResource(string $uri): void + { + $this->registry->unregisterResource($uri); + } + + public function unregisterResourceTemplate(string $uriTemplate): void + { + $this->registry->unregisterResourceTemplate($uriTemplate); + } + + public function unregisterPrompt(string $name): void + { + $this->registry->unregisterPrompt($name); + } + + public function hasTool(string $name): bool + { + $this->load(); + + return $this->registry->hasTool($name); + } + + public function hasResource(string $uri): bool + { + $this->load(); + + return $this->registry->hasResource($uri); + } + + public function hasResourceTemplate(string $uriTemplate): bool + { + $this->load(); + + return $this->registry->hasResourceTemplate($uriTemplate); + } + + public function hasPrompt(string $name): bool + { + $this->load(); + + return $this->registry->hasPrompt($name); + } + + public function hasTools(): bool + { + $this->load(); + + return $this->registry->hasTools(); + } + + public function hasResources(): bool + { + $this->load(); + + return $this->registry->hasResources(); + } + + public function hasResourceTemplates(): bool + { + $this->load(); + + return $this->registry->hasResourceTemplates(); + } + + public function hasPrompts(): bool + { + $this->load(); + + return $this->registry->hasPrompts(); + } + + public function getTools(?int $limit = null, ?string $cursor = null): Page + { + $this->load(); + + return $this->registry->getTools($limit, $cursor); + } + + public function getResources(?int $limit = null, ?string $cursor = null): Page + { + $this->load(); + + return $this->registry->getResources($limit, $cursor); + } + + public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page + { + $this->load(); + + return $this->registry->getResourceTemplates($limit, $cursor); + } + + public function getPrompts(?int $limit = null, ?string $cursor = null): Page + { + $this->load(); + + return $this->registry->getPrompts($limit, $cursor); + } + + public function getTool(string $name): ToolReference + { + $this->load(); + + return $this->registry->getTool($name); + } + + public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference + { + $this->load(); + + return $this->registry->getResource($uri, $includeTemplates); + } + + public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference + { + $this->load(); + + return $this->registry->getResourceTemplate($uriTemplate); + } + + public function getPrompt(string $name): PromptReference + { + $this->load(); + + return $this->registry->getPrompt($name); + } +} diff --git a/src/Server/Capability/RegistryInterface.php b/src/Server/Capability/RegistryInterface.php index af34b431..7a6c0ca2 100644 --- a/src/Server/Capability/RegistryInterface.php +++ b/src/Server/Capability/RegistryInterface.php @@ -11,94 +11,16 @@ namespace Mcp\Server\Capability; -use Mcp\Schema\Page; -use Mcp\Schema\Prompt; -use Mcp\Schema\ResourceDefinition; -use Mcp\Schema\ResourceTemplate; -use Mcp\Schema\Tool; -use Mcp\Server\Capability\Registry\ElementReference; -use Mcp\Server\Capability\Registry\PromptReference; -use Mcp\Server\Capability\Registry\ResourceReference; use Mcp\Server\Capability\Registry\ResourceTemplateReference; -use Mcp\Server\Capability\Registry\ToolReference; -use Mcp\Server\Exception\ConfigurationException; -use Mcp\Server\Exception\PromptNotFoundException; use Mcp\Server\Exception\ResourceNotFoundException; -use Mcp\Server\Exception\ToolNotFoundException; use Mcp\Server\Subscription\PublishingEventDispatcher; /** - * @phpstan-import-type Handler from ElementReference - * * @author Kyrian Obikwelu * @author Christopher Hertel */ -interface RegistryInterface +interface RegistryInterface extends ElementCatalogInterface, ElementResolverInterface, ElementWriterInterface { - /** - * Registers a tool with its handler. Overwrites any prior registration of the same name. - * Returns the stored reference, whose identity callers may track to detect later overwrites. - * - * @param Handler $handler - */ - public function registerTool(Tool $tool, callable|array|string $handler): ToolReference; - - /** - * Registers a resource with its handler. Overwrites any prior registration of the same URI. - * Returns the stored reference, whose identity callers may track to detect later overwrites. - * - * @param Handler $handler - */ - public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference; - - /** - * Registers a resource template with its handler and completion providers. - * Overwrites any prior registration of the same URI template. - * Returns the stored reference, whose identity callers may track to detect later overwrites. - * - * @param Handler $handler - * @param array $completionProviders - */ - public function registerResourceTemplate( - ResourceTemplate $template, - callable|array|string $handler, - array $completionProviders = [], - ): ResourceTemplateReference; - - /** - * Registers a prompt with its handler and completion providers. - * Overwrites any prior registration of the same name. - * Returns the stored reference, whose identity callers may track to detect later overwrites. - * - * @param Handler $handler - * @param array $completionProviders - */ - public function registerPrompt( - Prompt $prompt, - callable|array|string $handler, - array $completionProviders = [], - ): PromptReference; - - /** - * Removes a tool by name. No-op if absent. - */ - public function unregisterTool(string $name): void; - - /** - * Removes a resource by URI. No-op if absent. - */ - public function unregisterResource(string $uri): void; - - /** - * Removes a resource template by URI template. No-op if absent. - */ - public function unregisterResourceTemplate(string $uriTemplate): void; - - /** - * Removes a prompt by name. No-op if absent. - */ - public function unregisterPrompt(string $name): void; - /** * Whether list changes reach a notification bus, e.g. through a {@see PublishingEventDispatcher}. * A decorator forwards the answer of the registry it wraps. @@ -118,51 +40,20 @@ public function hasPrompt(string $name): bool; */ public function hasTools(): bool; - /** - * Gets all registered tools. - * - * @return Page - */ - public function getTools(?int $limit = null, ?string $cursor = null): Page; - - /** - * Gets a tool reference by name. - * - * @throws ToolNotFoundException - * @throws ConfigurationException when the elements fail to load - */ - public function getTool(string $name): ToolReference; - /** * @return bool true if any resources are registered */ public function hasResources(): bool; - /** - * Gets all registered resources. - * - * @return Page - */ - public function getResources(?int $limit = null, ?string $cursor = null): Page; - - /** - * Gets a resource reference by URI (includes template matching if enabled). - * - * @throws ResourceNotFoundException - */ - public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference; - /** * @return bool true if any resource templates are registered */ public function hasResourceTemplates(): bool; /** - * Gets all registered resource templates. - * - * @return Page + * @return bool true if any prompts are registered */ - public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page; + public function hasPrompts(): bool; /** * Gets a resource template reference by URI template. @@ -170,23 +61,4 @@ public function getResourceTemplates(?int $limit = null, ?string $cursor = null) * @throws ResourceNotFoundException */ public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference; - - /** - * @return bool true if any prompts are registered - */ - public function hasPrompts(): bool; - - /** - * Gets all registered prompts. - * - * @return Page - */ - public function getPrompts(?int $limit = null, ?string $cursor = null): Page; - - /** - * Gets a prompt reference by name. - * - * @throws PromptNotFoundException - */ - public function getPrompt(string $name): PromptReference; } diff --git a/src/Server/Configuration.php b/src/Server/Configuration.php index 07edd407..8e03d6cc 100644 --- a/src/Server/Configuration.php +++ b/src/Server/Configuration.php @@ -25,20 +25,25 @@ class Configuration { /** - * @param Implementation $serverInfo info about this MCP server application - * @param ServerCapabilities $capabilities capabilities of this MCP server application - * @param int $paginationLimit maximum number of items to return for list methods - * @param string|null $instructions instructions describing how to use the server and its features + * @param Implementation $serverInfo info about this MCP server application + * @param ServerCapabilities|(\Closure(): ServerCapabilities) $capabilities capabilities of this MCP server application, or what resolves them for each request + * @param int $paginationLimit maximum number of items to return for list methods + * @param string|null $instructions instructions describing how to use the server and its features */ public function __construct( public readonly Implementation $serverInfo, - public readonly ServerCapabilities $capabilities, + private readonly ServerCapabilities|\Closure $capabilities, public readonly int $paginationLimit = 50, public readonly ?string $instructions = null, public readonly ?ProtocolVersion $protocolVersion = null, ) { } + public function capabilities(): ServerCapabilities + { + return $this->capabilities instanceof \Closure ? ($this->capabilities)() : $this->capabilities; + } + /** * Versions this server is willing to negotiate over `initialize`. * diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index e4d4ad5a..ae53791f 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -21,8 +21,8 @@ use Mcp\Schema\Result\InputRequiredResult; use Mcp\Schema\Tool; use Mcp\Server\Capability\Discovery\SchemaValidator; +use Mcp\Server\Capability\ElementResolverInterface; use Mcp\Server\Capability\Registry\ReferenceHandlerInterface; -use Mcp\Server\Capability\RegistryInterface; use Mcp\Server\Exception\MissingRequiredClientCapabilityException; use Mcp\Server\Exception\RegistryException; use Mcp\Server\Exception\SessionRequiredException; @@ -47,7 +47,7 @@ final class CallToolHandler implements RequestHandlerInterface private SchemaValidator $schemaValidator; public function __construct( - private readonly RegistryInterface $registry, + private readonly ElementResolverInterface $registry, private readonly ReferenceHandlerInterface $referenceHandler, private readonly LoggerInterface $logger = new NullLogger(), ?SchemaValidator $schemaValidator = null, diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index aa708703..b0fbaa96 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -19,8 +19,8 @@ use Mcp\Schema\ResourceReference; use Mcp\Schema\Result\CompletionCompleteResult; use Mcp\Server\Capability\Completion\ProviderInterface; +use Mcp\Server\Capability\ElementResolverInterface; use Mcp\Server\Capability\Registry\ResourceTemplateReference; -use Mcp\Server\Capability\RegistryInterface; use Mcp\Server\Exception\PromptNotFoundException; use Mcp\Server\Exception\ResourceNotFoundException; use Mcp\Server\Session\SessionInterface; @@ -38,7 +38,7 @@ final class CompletionCompleteHandler implements RequestHandlerInterface { public function __construct( - private readonly RegistryInterface $registry, + private readonly ElementResolverInterface $registry, private readonly ?ContainerInterface $container = null, private readonly LoggerInterface $logger = new NullLogger(), ) { diff --git a/src/Server/Handler/Request/DiscoverHandler.php b/src/Server/Handler/Request/DiscoverHandler.php index 84acc74d..44c567bb 100644 --- a/src/Server/Handler/Request/DiscoverHandler.php +++ b/src/Server/Handler/Request/DiscoverHandler.php @@ -51,7 +51,7 @@ public function handle(Request $request, SessionInterface $session): Response $request->getId(), new DiscoverResult( $this->supportedVersions, - $this->configuration->capabilities, + $this->configuration->capabilities(), $this->configuration->instructions, ), ); diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index c1cc10aa..9a66ea9f 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -17,8 +17,8 @@ use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; use Mcp\Schema\Result\InputRequiredResult; +use Mcp\Server\Capability\ElementResolverInterface; use Mcp\Server\Capability\Registry\ReferenceHandlerInterface; -use Mcp\Server\Capability\RegistryInterface; use Mcp\Server\Exception\MissingRequiredClientCapabilityException; use Mcp\Server\Exception\PromptGetException; use Mcp\Server\Exception\PromptNotFoundException; @@ -36,7 +36,7 @@ final class GetPromptHandler implements RequestHandlerInterface { public function __construct( - private readonly RegistryInterface $registry, + private readonly ElementResolverInterface $registry, private readonly ReferenceHandlerInterface $referenceHandler, private readonly LoggerInterface $logger = new NullLogger(), ) { diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index 13d77ba3..c6c49fab 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -54,7 +54,7 @@ public function handle(Request $request, SessionInterface $session): Response // List changes reach clients only through `subscriptions/listen`, which // the handshake era does not have. - $capabilities = ($this->configuration->capabilities ?? new ServerCapabilities())->withoutListChanged(); + $capabilities = ($this->configuration?->capabilities() ?? new ServerCapabilities())->withoutListChanged(); // Resource updates reach the client outside a request, and the log level is kept between requests. if ($session instanceof EphemeralSessionInterface) { diff --git a/src/Server/Handler/Request/ListPromptsHandler.php b/src/Server/Handler/Request/ListPromptsHandler.php index 5f45f9f0..c5494023 100644 --- a/src/Server/Handler/Request/ListPromptsHandler.php +++ b/src/Server/Handler/Request/ListPromptsHandler.php @@ -15,7 +15,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListPromptsRequest; use Mcp\Schema\Result\ListPromptsResult; -use Mcp\Server\Capability\RegistryInterface; +use Mcp\Server\Capability\ElementCatalogInterface; use Mcp\Server\Exception\InvalidCursorException; use Mcp\Server\Session\SessionInterface; @@ -27,7 +27,7 @@ final class ListPromptsHandler implements RequestHandlerInterface { public function __construct( - private readonly RegistryInterface $registry, + private readonly ElementCatalogInterface $registry, private readonly int $pageSize = 20, ) { } diff --git a/src/Server/Handler/Request/ListResourceTemplatesHandler.php b/src/Server/Handler/Request/ListResourceTemplatesHandler.php index a211eeed..139b38fe 100644 --- a/src/Server/Handler/Request/ListResourceTemplatesHandler.php +++ b/src/Server/Handler/Request/ListResourceTemplatesHandler.php @@ -15,7 +15,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourceTemplatesRequest; use Mcp\Schema\Result\ListResourceTemplatesResult; -use Mcp\Server\Capability\RegistryInterface; +use Mcp\Server\Capability\ElementCatalogInterface; use Mcp\Server\Exception\InvalidCursorException; use Mcp\Server\Session\SessionInterface; @@ -27,7 +27,7 @@ final class ListResourceTemplatesHandler implements RequestHandlerInterface { public function __construct( - private readonly RegistryInterface $registry, + private readonly ElementCatalogInterface $registry, private readonly int $pageSize = 20, ) { } diff --git a/src/Server/Handler/Request/ListResourcesHandler.php b/src/Server/Handler/Request/ListResourcesHandler.php index e5c3c1b9..19021dec 100644 --- a/src/Server/Handler/Request/ListResourcesHandler.php +++ b/src/Server/Handler/Request/ListResourcesHandler.php @@ -15,7 +15,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourcesRequest; use Mcp\Schema\Result\ListResourcesResult; -use Mcp\Server\Capability\RegistryInterface; +use Mcp\Server\Capability\ElementCatalogInterface; use Mcp\Server\Exception\InvalidCursorException; use Mcp\Server\Session\SessionInterface; @@ -27,7 +27,7 @@ final class ListResourcesHandler implements RequestHandlerInterface { public function __construct( - private readonly RegistryInterface $registry, + private readonly ElementCatalogInterface $registry, private readonly int $pageSize = 20, ) { } diff --git a/src/Server/Handler/Request/ListToolsHandler.php b/src/Server/Handler/Request/ListToolsHandler.php index 775a31ff..efb0d1f9 100644 --- a/src/Server/Handler/Request/ListToolsHandler.php +++ b/src/Server/Handler/Request/ListToolsHandler.php @@ -15,7 +15,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListToolsRequest; use Mcp\Schema\Result\ListToolsResult; -use Mcp\Server\Capability\RegistryInterface; +use Mcp\Server\Capability\ElementCatalogInterface; use Mcp\Server\Exception\InvalidCursorException; use Mcp\Server\Session\SessionInterface; @@ -28,7 +28,7 @@ final class ListToolsHandler implements RequestHandlerInterface { public function __construct( - private readonly RegistryInterface $registry, + private readonly ElementCatalogInterface $registry, private readonly int $pageSize = 20, ) { } diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index def85079..07951989 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -17,9 +17,9 @@ use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\InputRequiredResult; use Mcp\Schema\Result\ReadResourceResult; +use Mcp\Server\Capability\ElementResolverInterface; use Mcp\Server\Capability\Registry\ReferenceHandlerInterface; use Mcp\Server\Capability\Registry\ResourceTemplateReference; -use Mcp\Server\Capability\RegistryInterface; use Mcp\Server\Exception\MissingRequiredClientCapabilityException; use Mcp\Server\Exception\RegistryException; use Mcp\Server\Exception\ResourceNotFoundException; @@ -38,7 +38,7 @@ final class ReadResourceHandler implements RequestHandlerInterface { public function __construct( - private readonly RegistryInterface $referenceProvider, + private readonly ElementResolverInterface $referenceProvider, private readonly ReferenceHandlerInterface $referenceHandler, private readonly LoggerInterface $logger = new NullLogger(), ) { diff --git a/src/Server/Handler/Request/ResourceSubscribeHandler.php b/src/Server/Handler/Request/ResourceSubscribeHandler.php index 96acdbd4..211358ea 100644 --- a/src/Server/Handler/Request/ResourceSubscribeHandler.php +++ b/src/Server/Handler/Request/ResourceSubscribeHandler.php @@ -16,7 +16,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ResourceSubscribeRequest; use Mcp\Schema\Result\EmptyResult; -use Mcp\Server\Capability\RegistryInterface; +use Mcp\Server\Capability\ElementResolverInterface; use Mcp\Server\Exception\ResourceNotFoundException; use Mcp\Server\RequestContext; use Mcp\Server\Resource\SubscriptionManagerInterface; @@ -33,7 +33,7 @@ final class ResourceSubscribeHandler implements RequestHandlerInterface { public function __construct( - private readonly RegistryInterface $registry, + private readonly ElementResolverInterface $registry, private readonly SubscriptionManagerInterface $subscriptionManager, private readonly LoggerInterface $logger = new NullLogger(), ) { diff --git a/src/Server/Handler/Request/ResourceUnsubscribeHandler.php b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php index a0bdfef3..8599ef5f 100644 --- a/src/Server/Handler/Request/ResourceUnsubscribeHandler.php +++ b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php @@ -16,7 +16,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ResourceUnsubscribeRequest; use Mcp\Schema\Result\EmptyResult; -use Mcp\Server\Capability\RegistryInterface; +use Mcp\Server\Capability\ElementResolverInterface; use Mcp\Server\Exception\ResourceNotFoundException; use Mcp\Server\RequestContext; use Mcp\Server\Resource\SubscriptionManagerInterface; @@ -33,7 +33,7 @@ final class ResourceUnsubscribeHandler implements RequestHandlerInterface { public function __construct( - private readonly RegistryInterface $registry, + private readonly ElementResolverInterface $registry, private readonly SubscriptionManagerInterface $subscriptionManager, private readonly LoggerInterface $logger = new NullLogger(), ) { diff --git a/src/Server/RegistryCapabilities.php b/src/Server/RegistryCapabilities.php new file mode 100644 index 00000000..d71138fc --- /dev/null +++ b/src/Server/RegistryCapabilities.php @@ -0,0 +1,66 @@ + + */ +final class RegistryCapabilities +{ + /** + * @param array> $extensions + */ + public function __construct( + private readonly RegistryInterface $registry, + private readonly bool $hasNotificationBus, + private readonly array $extensions = [], + ) { + } + + public function detect(): ServerCapabilities + { + $canGrow = false; + if ($this->registry instanceof DeferredRegistryInterface) { + $this->registry->load(); + // Hiding the kinds of elements that still arrive would hide them from the client for the whole session. + $canGrow = !$this->registry->isLoaded(); + } + + $hasTools = $canGrow || $this->registry->hasTools(); + $hasResources = $canGrow || $this->registry->hasResources() || $this->registry->hasResourceTemplates(); + $hasPrompts = $canGrow || $this->registry->hasPrompts(); + + // Only the bus delivers list changes, and only if the registry publishes to it. + $listChanged = $this->hasNotificationBus && $this->registry->publishesListChanges(); + + return new ServerCapabilities( + tools: $hasTools, + toolsListChanged: $listChanged, + resources: $hasResources, + resourcesSubscribe: $hasResources, + resourcesListChanged: $listChanged, + prompts: $hasPrompts, + promptsListChanged: $listChanged, + logging: true, + completions: true, + extensions: $this->extensions ?: null, + ); + } +} diff --git a/src/Server/Stateless/StandardHeaderValidator.php b/src/Server/Stateless/StandardHeaderValidator.php index e18c84fa..8b1f58a1 100644 --- a/src/Server/Stateless/StandardHeaderValidator.php +++ b/src/Server/Stateless/StandardHeaderValidator.php @@ -13,7 +13,7 @@ use Mcp\Schema\Json; use Mcp\Schema\Wire\McpHeader; -use Mcp\Server\Capability\RegistryInterface; +use Mcp\Server\Capability\ElementResolverInterface; use Mcp\Server\Exception\ConfigurationException; use Mcp\Server\Exception\ToolNotFoundException; @@ -36,7 +36,7 @@ final class StandardHeaderValidator private const MAX_SAFE_INTEGER = 2 ** 53 - 1; public function __construct( - private readonly ?RegistryInterface $registry = null, + private readonly ?ElementResolverInterface $registry = null, ) { } diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 4437e8ac..f430f1a5 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -359,9 +359,19 @@ private function listen(?array $params, string|int $id, bool $bounded): Stateles { $notifications = \is_array($params['notifications'] ?? null) ? $params['notifications'] : null; // Only the bus delivers anything on this stream. - $agreed = null === $this->notificationBus - ? new NotificationFilter() - : NotificationFilter::fromParams($notifications)->intersect($this->configuration->capabilities); + if (null === $this->notificationBus) { + $agreed = new NotificationFilter(); + } else { + try { + $capabilities = $this->configuration->capabilities(); + } catch (\Throwable $e) { + $this->logger->error('Resolving the server capabilities failed on a subscriptions/listen request.', ['exception' => $e]); + + return StatelessResult::error(Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), 500); + } + + $agreed = NotificationFilter::fromParams($notifications)->intersect($capabilities); + } $lifetime = $this->subscriptionLifetime; $bus = $this->notificationBus; diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 32acdf3a..ea0cbe20 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -40,6 +40,7 @@ use Mcp\Server\Subscription\InMemoryNotificationBus; use Mcp\Server\Subscription\PublishingEventDispatcher; use Mcp\Server\Transport\InMemoryTransport; +use Mcp\Tests\Unit\Server\Capability\Registry\Loader\Stub\RecordingLoader; use Mcp\Tests\Unit\Server\Extension\ThingExtension; use Mcp\Tests\Unit\Server\Extension\ThingListHandler; use Mcp\Tests\Unit\Server\Extension\ThingListRequest; @@ -466,21 +467,23 @@ public function testEnableExtensionRegistersItsHandlers(): void $this->assertContainsOnlyInstancesOf(ThingListHandler::class, $handlers); } - #[TestDox('Lazy loading (default) advertises tools from configured sources without running loaders')] - public function testLazyLoadingAdvertisesFromConfiguredSourcesWithoutLoading(): void + #[TestDox('Lazy loading (default) runs the loaders when a request asks for the capabilities, not at build time')] + public function testLazyLoadingDetectsCapabilitiesFromTheLoadedRegistry(): void { - $loader = $this->createMock(LoaderInterface::class); - $loader->expects($this->never())->method('load'); + $calls = new \ArrayObject(); $server = Server::builder() ->setServerInfo('test', '1.0.0') - ->addLoader($loader) + ->addLoader(new RecordingLoader('loader', $calls)) ->build(); + $this->assertSame([], $calls->getArrayCopy()); + $capabilities = $this->extractServerCapabilities($server); - // A custom loader is opaque, so its presence advertises tools even though it never ran. - $this->assertTrue($capabilities->tools); + // The loader ran but registered nothing, so the loaded registry advertises no tools. + $this->assertSame(['loader'], $calls->getArrayCopy()); + $this->assertFalse($capabilities->tools); } #[TestDox('Eager loading runs the loaders at build time and advertises from the loaded registry')] @@ -551,7 +554,7 @@ private function extractServerCapabilities(Server $server): ServerCapabilities if ($handler instanceof InitializeHandler) { $this->assertNotNull($handler->configuration); - return $handler->configuration->capabilities; + return $handler->configuration->capabilities(); } } @@ -623,6 +626,74 @@ public function testAThirdPartyRegistryIsStillLoadedThroughThePlainLoader(): voi ->addTool(static fn (): string => 'ok', 'alpha') ->build(); } + + public function testASuppliedRegistryStaysEagerByDefault(): void + { + $registry = new Registry(); + + Server::builder() + ->setRegistry($registry) + ->addTool(static fn (): string => 'ok', 'alpha') + ->build(); + + $this->assertTrue($registry->hasTool('alpha')); + } + + public function testASuppliedRegistryIsDeferredWhenLazyLoadingIsAskedFor(): void + { + $calls = new \ArrayObject(); + + $server = Server::builder() + ->setRegistry(new Registry()) + ->setLazyLoading() + ->addLoader(new RecordingLoader('loader', $calls)) + ->build(); + + $this->assertSame([], $calls->getArrayCopy()); + + $this->extractServerCapabilities($server); + + $this->assertSame(['loader'], $calls->getArrayCopy()); + } + + public function testASuppliedRegistryWithItsOwnLoaderIsNotLoadedByTheBuild(): void + { + $calls = new \ArrayObject(); + + $server = Server::builder() + ->setRegistry(new Registry(loader: new RecordingLoader('own', $calls))) + ->setLazyLoading() + ->build(); + + $this->assertSame([], $calls->getArrayCopy()); + + $this->extractServerCapabilities($server); + + $this->assertSame(['own'], $calls->getArrayCopy()); + } + + public function testAThirdPartyRegistryIsNotReadByTheBuild(): void + { + $registry = $this->createMock(RegistryInterface::class); + $registry->expects($this->never())->method('hasTools'); + + Server::builder()->setRegistry($registry)->setLazyLoading()->build(); + } + + public function testADeferredThirdPartyRegistryAdvertisesWhatItHolds(): void + { + $registry = $this->createStub(RegistryInterface::class); + $registry->method('hasPrompts')->willReturn(true); + + $capabilities = $this->extractServerCapabilities(Server::builder() + ->setRegistry($registry) + ->setLazyLoading() + ->build()); + + $this->assertTrue($capabilities->prompts); + $this->assertFalse($capabilities->tools); + $this->assertFalse($capabilities->resources); + } } /** diff --git a/tests/Unit/Server/Capability/Registry/LazyRegistryTest.php b/tests/Unit/Server/Capability/Registry/LazyRegistryTest.php new file mode 100644 index 00000000..8bb9df57 --- /dev/null +++ b/tests/Unit/Server/Capability/Registry/LazyRegistryTest.php @@ -0,0 +1,207 @@ +createMock(LoaderInterface::class); + $loader->expects($this->once())->method('load'); + + $registry = new LazyRegistry(new Registry(), $loader); + + $registry->getTools(); + $registry->hasTools(); + } + + public function testWritesAndListChangeSupportDoNotLoad(): void + { + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->never())->method('load'); + + $registry = new LazyRegistry(new Registry(), $loader); + + $registry->registerTool($this->tool('alpha'), static fn (): string => 'ok'); + $registry->unregisterTool('alpha'); + $registry->publishesListChanges(); + } + + public function testAReadDuringARunningLoadLeavesItPending(): void + { + $loader = new class implements LoaderInterface { + public int $calls = 0; + + public function load(RegistryInterface $registry): void + { + if (1 === ++$this->calls) { + \Fiber::suspend(); + + throw new RuntimeException('source not ready'); + } + } + }; + + $registry = new LazyRegistry(new Registry(), $loader); + $fiber = new \Fiber($registry->load(...)); + $fiber->start(); + + $registry->load(); + $this->assertFalse($registry->isLoaded()); + + try { + $fiber->resume(); + $this->fail('The failure must reach the reader that started the load.'); + } catch (RuntimeException) { + } + $this->assertFalse($registry->isLoaded()); + + $registry->load(); + $this->assertSame(2, $loader->calls); + $this->assertTrue($registry->isLoaded()); + } + + public function testFailedLoadIsRetriedOnTheNextRead(): void + { + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->exactly(2)) + ->method('load') + ->willReturnCallback(static function () { + static $calls = 0; + if (1 === ++$calls) { + throw new RuntimeException('source not ready'); + } + }); + + $registry = new LazyRegistry(new Registry(), $loader); + + try { + $registry->getTools(); + $this->fail('The load failure must reach the reader.'); + } catch (RuntimeException) { + } + + $this->assertSame([], $registry->getTools()->references); + } + + public function testStaysPendingUntilTheWrappedRegistryHasLoaded(): void + { + $own = new class implements LoaderInterface { + public int $calls = 0; + + public function load(RegistryInterface $registry): void + { + if (1 === ++$this->calls) { + throw new RuntimeException('source not ready'); + } + } + }; + $builder = $this->createMock(LoaderInterface::class); + $builder->expects($this->once())->method('load'); + + $registry = new LazyRegistry(new Registry(loader: $own), $builder); + + try { + $registry->load(); + $this->fail('The failure of the wrapped registry must reach the caller.'); + } catch (RuntimeException) { + } + $this->assertFalse($registry->isLoaded()); + + $registry->load(); + $this->assertTrue($registry->isLoaded()); + $this->assertSame(2, $own->calls); + } + + public function testRunsItsLoaderWhileTheWrappedRegistryLoadsInAnotherFiber(): void + { + $suspending = new class implements LoaderInterface { + public function load(RegistryInterface $registry): void + { + \Fiber::suspend(); + } + }; + $inner = new Registry(loader: $suspending); + $fiber = new \Fiber($inner->load(...)); + $fiber->start(); + + $registry = new LazyRegistry($inner, $this->writing($this->tool('alpha'))); + $registry->load(); + $this->assertFalse($registry->isLoaded()); + + $fiber->resume(); + + $this->assertTrue($registry->isLoaded()); + $this->assertTrue($registry->hasTool('alpha')); + } + + public function testExposesWhatTheLoaderWroteToTheWrappedRegistry(): void + { + $inner = new Registry(); + $registry = new LazyRegistry($inner, $this->writing($this->tool('alpha'))); + + $this->assertFalse($inner->hasTool('alpha')); + $this->assertSame('alpha', $registry->getTool('alpha')->tool->name); + $this->assertTrue($inner->hasTool('alpha')); + } + + public function testLoadIsNotAnnouncedAsAChangeByADeferredRegistry(): void + { + $bus = new InMemoryNotificationBus(); + $registry = new LazyRegistry(new Registry(new PublishingEventDispatcher($bus)), $this->writing($this->tool('alpha'))); + + $registry->load(); + + $this->assertTrue($registry->hasTool('alpha')); + $this->assertSame(0, $bus->cursor()); + } + + public function testLoadsAThirdPartyRegistryThroughThePlainLoader(): void + { + $inner = $this->createMock(RegistryInterface::class); + $inner->expects($this->once())->method('registerTool'); + $inner->method('hasTools')->willReturn(true); + + $registry = new LazyRegistry($inner, $this->writing($this->tool('alpha'))); + + $this->assertTrue($registry->hasTools()); + } + + private function tool(string $name): Tool + { + return new Tool(name: $name, title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: null, annotations: null); + } + + private function writing(Tool $tool): LoaderInterface + { + return new class($tool) implements LoaderInterface { + public function __construct(private Tool $tool) + { + } + + public function load(RegistryInterface $registry): void + { + $registry->registerTool($this->tool, static fn (): string => 'ok'); + } + }; + } +} diff --git a/tests/Unit/Server/Handler/Request/DiscoverHandlerTest.php b/tests/Unit/Server/Handler/Request/DiscoverHandlerTest.php index 4832b4dd..ee51313b 100644 --- a/tests/Unit/Server/Handler/Request/DiscoverHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/DiscoverHandlerTest.php @@ -45,7 +45,7 @@ public function testAnswersFromConfiguration(): void $this->assertSame('d-1', $response->getId()); $this->assertInstanceOf(DiscoverResult::class, $response->result); $this->assertSame([ProtocolVersion::V2026_07_28], $response->result->supportedVersions); - $this->assertSame($configuration->capabilities, $response->result->capabilities); + $this->assertSame($configuration->capabilities(), $response->result->capabilities); $this->assertSame('Call list_tools first.', $response->result->instructions); } diff --git a/tests/Unit/Server/RegistryCapabilitiesTest.php b/tests/Unit/Server/RegistryCapabilitiesTest.php new file mode 100644 index 00000000..7784a7cb --- /dev/null +++ b/tests/Unit/Server/RegistryCapabilitiesTest.php @@ -0,0 +1,84 @@ +registerTool($this->tool(), static fn (): string => 'ok'); + + $capabilities = (new RegistryCapabilities($registry, false))->detect(); + + $this->assertTrue($capabilities->tools); + $this->assertFalse($capabilities->resources); + $this->assertFalse($capabilities->prompts); + } + + public function testRunsAPendingLoadAndAdvertisesItsResult(): void + { + $tool = $this->tool(); + $loader = $this->createStub(LoaderInterface::class); + $loader->method('load')->willReturnCallback(static function (RegistryInterface $registry) use ($tool): void { + $registry->registerTool($tool, static fn (): string => 'ok'); + }); + + $capabilities = (new RegistryCapabilities(new LazyRegistry(new Registry(), $loader), false))->detect(); + + $this->assertTrue($capabilities->tools); + $this->assertFalse($capabilities->prompts); + } + + public function testFollowsTheRegistryAcrossRequests(): void + { + $registry = new Registry(); + $detector = new RegistryCapabilities($registry, false); + + $this->assertFalse($detector->detect()->tools); + + $registry->registerTool($this->tool(), static fn (): string => 'ok'); + + $this->assertTrue($detector->detect()->tools); + } + + public function testAdvertisesAllKindsWhileAnotherFiberIsLoading(): void + { + $loader = $this->createStub(LoaderInterface::class); + $loader->method('load')->willReturnCallback(static function (): void { + \Fiber::suspend(); + }); + + $registry = new LazyRegistry(new Registry(), $loader); + $fiber = new \Fiber($registry->load(...)); + $fiber->start(); + + $capabilities = (new RegistryCapabilities($registry, false))->detect(); + + $this->assertTrue($capabilities->tools); + $this->assertTrue($capabilities->resources); + $this->assertTrue($capabilities->prompts); + } + + private function tool(): Tool + { + return new Tool(name: 'alpha', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: null, annotations: null); + } +} diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 17f5c084..dbf85eda 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -38,6 +38,9 @@ use Mcp\Schema\Result\ReadResourceResult; use Mcp\Schema\ServerCapabilities; use Mcp\Server\Authorization\AccessToken; +use Mcp\Server\Capability\Registry; +use Mcp\Server\Capability\Registry\Loader\LoaderInterface; +use Mcp\Server\Capability\RegistryInterface; use Mcp\Server\ClientGateway; use Mcp\Server\Configuration; use Mcp\Server\Event\ErrorEvent; @@ -45,6 +48,7 @@ use Mcp\Server\Event\ResponseEvent; use Mcp\Server\Exception\MissingRequiredClientCapabilityException; use Mcp\Server\Exception\ResourceReadException; +use Mcp\Server\Exception\RuntimeException; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\RequestContext; use Mcp\Server\Server; @@ -1481,6 +1485,31 @@ public function testListenStreamDeliversSubscribedNotifications(): void $this->assertSame('complete', $frames[3]['result']['resultType']); } + #[TestDox('a listen request answers with an error when resolving the capabilities fails')] + public function testListenAnswersAFailingLoadWithAnError(): void + { + $loader = new class implements LoaderInterface { + public function load(RegistryInterface $registry): void + { + throw new RuntimeException('source not ready'); + } + }; + + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->setRegistry(new Registry(loader: $loader)) + ->setLazyLoading() + ->setNotificationBus(new InMemoryNotificationBus()) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $result = $protocol->handleInline(self::inlineRequest('subscriptions/listen', ['notifications' => ['toolsListChanged' => true]])); + + $this->assertFalse($result->isStream()); + $this->assertSame(500, $result->httpStatus); + $body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR); + $this->assertSame(Error::INTERNAL_ERROR, $body['error']['code']); + } + /** * A request the way stdio carries it: the metadata inline, no headers. *