diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c2fa309..59f515e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to `mcp/sdk` will be documented in this file. * [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`. +* Add `ElementSourceInterface`, `ElementCollection` and `Builder::addSource()`. A source returns its elements as a value. +* [BC Break] An exception from an element loader or discovery no longer aborts loading: `ChainLoader` logs it as an error, runs the remaining loaders and throws a `PartialLoadException`. The registry serves what loaded and retries on the next read, the server advertises all kinds until then, and `Builder::build()` no longer throws for such a failure. A `ConfigurationException`, e.g. from a manual element with a missing handler class, still aborts loading. +* Deprecate `DiscoveryLoader`, use `SourceLoader` over a `DiscoverySource` with `onlyMissing: true` instead. * 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 06c3f324..77b5a172 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -508,6 +508,7 @@ $server = Server::builder() | `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, 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 | +| `addSource()` | source | Add an element source, see [Element sources](../servers/registration.md#element-sources) | | `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/docs/servers/registration.md b/docs/servers/registration.md index 445114c3..b0cb236e 100644 --- a/docs/servers/registration.md +++ b/docs/servers/registration.md @@ -256,6 +256,62 @@ Use `add()` when the metadata cannot be inferred from a handler class via reflec prefer `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from the handler's signature and docblock. +## Element sources + +Your tools, resources or prompts may come from a database or another service. An element source reads them and +returns them as a collection: + +```php +use Mcp\Server\Capability\Registry\Source\ElementCollection; +use Mcp\Server\Capability\Registry\Source\ElementSourceInterface; +use Mcp\Server\Capability\Registry\ToolReference; +use Mcp\Schema\Tool; + +final class ProductToolSource implements ElementSourceInterface +{ + public function __construct(private readonly ProductRepository $products) + { + } + + public function provide(): ElementCollection + { + $tools = []; + foreach ($this->products->findPublished() as $product) { + $tool = new Tool('product_'.$product->id, $product->title, $product->inputSchema, $product->description, null); + $tools[] = new ToolReference($tool, [ProductTools::class, 'run']); + } + + return new ElementCollection(tools: $tools); + } +} +``` + +Register the source on the builder: + +```php +$server = Server::builder() + ->setServerInfo('Product Server', '1.0.0') + ->addSource(new ProductToolSource($products)) + ->build(); +``` + +The server calls `provide()` when it loads the elements. + +An element from a source replaces one with the same identifier from a custom loader. Elements registered on the +builder replace those of a source, and discovery never replaces an existing element. + +If a source throws an exception, the server logs it as an error and serves the other elements. The server calls the +source again on each read until it succeeds. Until then, it doesn't call the sources that already succeeded. The registry +doesn't announce elements that arrive on a later attempt, so clients see them the next time they list. + +While a source fails, the server advertises tools, resources and prompts, because it can't know what the source provides. + +A custom loader can write elements before it fails. While a custom loader fails, the server also runs everything that +comes after it on each read, including the sources. + +A `ConfigurationException` stops the load instead, because it reports a mistake in your setup, e.g. a +handler class that doesn't exist. The request that triggered the load fails with `-32603`. + ## Hybrid Approach Combine discovery with manual registration for maximum flexibility: diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 06346c52..d33f0ebe 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -26,7 +26,6 @@ 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; @@ -37,12 +36,14 @@ 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; use Mcp\Server\Capability\Registry\Loader\LoaderInterface; use Mcp\Server\Capability\Registry\Loader\ReflectedElementLoader; +use Mcp\Server\Capability\Registry\Loader\SourceLoader; use Mcp\Server\Capability\Registry\ReferenceHandler; use Mcp\Server\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Server\Capability\Registry\Source\DiscoverySource; +use Mcp\Server\Capability\Registry\Source\ElementSourceInterface; use Mcp\Server\Capability\RegistryInterface; use Mcp\Server\Exception\InvalidArgumentException; use Mcp\Server\Exception\LogicException; @@ -276,6 +277,11 @@ final class Builder */ private array $loaders = []; + /** + * @var ElementSourceInterface[] + */ + private array $sources = []; + private bool $hasCustomRegistry = false; private ?bool $lazyLoading = null; @@ -938,6 +944,17 @@ public function addLoaders(iterable $loaders): self return $this; } + /** + * Register an element source. Its elements win over those of custom loaders and discovery, + * and lose to elements registered on the builder. A failing source is logged and skipped. + */ + public function addSource(ElementSourceInterface $source): self + { + $this->sources[] = $source; + + return $this; + } + /** * Stop serving multi round-trip handlers to handshake-era clients. * @@ -1143,10 +1160,9 @@ private function resolve(): array $this->gcDivisor, ); - // ExplicitElementLoader and ReflectedElementLoader run before DiscoveryLoader so manual entries are seen first; - // DiscoveryLoader's identity check then preserves them against same-name discovered entries. $loaders = [ ...$this->loaders, + ...array_map(static fn (ElementSourceInterface $source): SourceLoader => new SourceLoader($source), $this->sources), new ExplicitElementLoader( $this->explicitTools, $this->explicitResources, @@ -1158,25 +1174,22 @@ private function resolve(): array if (null !== $this->discoveryBasePath) { $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); - $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer, $this->discoveryNamePatterns, $logger); + $loaders[] = new SourceLoader( + new DiscoverySource($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer, $this->discoveryNamePatterns), + true, + $logger, + ); } - $chainLoader = new ChainLoader($loaders); + $chainLoader = new ChainLoader($loaders, $logger); - if ($this->hasCustomRegistry && true === $this->lazyLoading) { - $registry = new LazyRegistry($this->registry, $chainLoader); - } elseif ($this->hasCustomRegistry) { - $registry = $this->registry; - if ($registry instanceof DeferredRegistryInterface) { - $registry->loadFrom($chainLoader); - } else { - $chainLoader->load($registry); - } - } else { - $registry = new Registry($eventDispatcher, $logger, loader: $chainLoader); - if (false === $this->lazyLoading) { - $registry->load(); - } + $registry = $this->hasCustomRegistry + ? new LazyRegistry($this->registry, $chainLoader) + : new Registry($eventDispatcher, $logger, loader: $chainLoader); + + // A supplied registry is read directly by its owner, so it only waits when that is asked for. + if ($this->hasCustomRegistry ? true !== $this->lazyLoading : false === $this->lazyLoading) { + $registry->load(); } $messageFactory = MessageFactory::make(additional: $this->extensionMessages); diff --git a/src/Server/Capability/DeferredRegistryInterface.php b/src/Server/Capability/DeferredRegistryInterface.php index c01bb2b1..0cba4455 100644 --- a/src/Server/Capability/DeferredRegistryInterface.php +++ b/src/Server/Capability/DeferredRegistryInterface.php @@ -21,7 +21,7 @@ interface DeferredRegistryInterface { /** - * Runs a pending load, if there is one. A failed load stays pending. + * Runs a pending load, if there is one. A load that fails or is incomplete stays pending. */ public function load(): void; diff --git a/src/Server/Capability/Registry.php b/src/Server/Capability/Registry.php index ef6660c5..ae860e87 100644 --- a/src/Server/Capability/Registry.php +++ b/src/Server/Capability/Registry.php @@ -27,6 +27,7 @@ use Mcp\Server\Event\ResourceTemplateListChangedEvent; use Mcp\Server\Event\ToolListChangedEvent; use Mcp\Server\Exception\InvalidCursorException; +use Mcp\Server\Exception\PartialLoadException; use Mcp\Server\Exception\PromptNotFoundException; use Mcp\Server\Exception\ResourceNotFoundException; use Mcp\Server\Exception\ToolNotFoundException; @@ -90,7 +91,11 @@ public function load(): void return; } - $this->loadFrom($this->loader); + try { + $this->loadFrom($this->loader); + } catch (PartialLoadException) { + return; + } $this->loaded = true; } diff --git a/src/Server/Capability/Registry/LazyRegistry.php b/src/Server/Capability/Registry/LazyRegistry.php index 17681953..cb05c27b 100644 --- a/src/Server/Capability/Registry/LazyRegistry.php +++ b/src/Server/Capability/Registry/LazyRegistry.php @@ -19,6 +19,7 @@ use Mcp\Server\Capability\DeferredRegistryInterface; use Mcp\Server\Capability\Registry\Loader\LoaderInterface; use Mcp\Server\Capability\RegistryInterface; +use Mcp\Server\Exception\PartialLoadException; /** * Defers a loader over any registry to the first read, leaving the registry itself unaware of it. @@ -49,6 +50,8 @@ public function load(): void try { $this->loadFrom($this->loader); $this->loaded = true; + } catch (PartialLoadException) { + // what loaded is served, and the load stays pending } finally { $this->loading = false; } diff --git a/src/Server/Capability/Registry/Loader/ChainLoader.php b/src/Server/Capability/Registry/Loader/ChainLoader.php index cb37a486..e7de6c25 100644 --- a/src/Server/Capability/Registry/Loader/ChainLoader.php +++ b/src/Server/Capability/Registry/Loader/ChainLoader.php @@ -12,27 +12,75 @@ namespace Mcp\Server\Capability\Registry\Loader; use Mcp\Server\Capability\RegistryInterface; +use Mcp\Server\Exception\ConfigurationException; +use Mcp\Server\Exception\ElementSourceException; +use Mcp\Server\Exception\PartialLoadException; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * Composes multiple loaders into a single one. Child loaders run in the order they were given; - * for conflicting keys, the loader that runs later wins (last-write-wins). + * for conflicting keys, the loader that runs later wins (last-write-wins). A failing loader is logged + * and skipped, and the chain reports the incomplete load once the others ran. A + * {@see ConfigurationException} is a mistake in the server's own setup that no retry fixes, so it + * ends the load instead. + * + * The next load of that registry only retries what failed. A loader that recovers, or that may have + * written before it failed, is followed by all loaders after it, so that last-write-wins gives the + * result of an uninterrupted run. An {@see ElementSourceException} means that nothing was written. * * @author Christopher Hertel */ final class ChainLoader implements LoaderInterface { + /** + * @var \WeakMap> + */ + private \WeakMap $failed; + /** * @param LoaderInterface[] $loaders */ public function __construct( private readonly array $loaders, + private readonly LoggerInterface $logger = new NullLogger(), ) { + $this->failed = new \WeakMap(); } public function load(RegistryInterface $registry): void { - foreach ($this->loaders as $loader) { - $loader->load($registry); + $retry = $this->failed[$registry] ?? null; + $runRemaining = null === $retry; + $failed = []; + + foreach (array_values($this->loaders) as $position => $loader) { + if (!$runRemaining && !isset($retry[$position])) { + continue; + } + + try { + $loader->load($registry); + $runRemaining = true; + } catch (ConfigurationException $e) { + unset($this->failed[$registry]); + + throw $e; + } catch (\Throwable $e) { // loading runs inside request handling, so nothing a source throws may end it + $failed[$position] = true; + $runRemaining = $runRemaining || !$e instanceof ElementSourceException; + $this->logger->error(\sprintf('Loading elements from "%s" failed: %s', $loader::class, $e->getMessage()), ['exception' => $e]); + } + } + + if ([] === $failed) { + unset($this->failed[$registry]); + + return; } + + $this->failed[$registry] = $failed; + + throw new PartialLoadException('Some element loaders failed.'); } } diff --git a/src/Server/Capability/Registry/Loader/DiscoveryLoader.php b/src/Server/Capability/Registry/Loader/DiscoveryLoader.php index bec36720..c186e0ff 100644 --- a/src/Server/Capability/Registry/Loader/DiscoveryLoader.php +++ b/src/Server/Capability/Registry/Loader/DiscoveryLoader.php @@ -13,6 +13,7 @@ use Mcp\Server\Capability\Discovery\DiscovererInterface; use Mcp\Server\Capability\Discovery\DiscoveryState; +use Mcp\Server\Capability\Registry\Source\DiscoverySource; use Mcp\Server\Capability\RegistryInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -20,6 +21,8 @@ /** * @internal * + * @deprecated use {@see SourceLoader} over a {@see DiscoverySource} with `onlyMissing: true` + * * @author Antoine Bluchet */ final class DiscoveryLoader implements LoaderInterface diff --git a/src/Server/Capability/Registry/Loader/SourceLoader.php b/src/Server/Capability/Registry/Loader/SourceLoader.php new file mode 100644 index 00000000..429bbac3 --- /dev/null +++ b/src/Server/Capability/Registry/Loader/SourceLoader.php @@ -0,0 +1,83 @@ + + */ +final class SourceLoader implements LoaderInterface +{ + /** + * @param bool $onlyMissing keep what the registry already holds under the same name or URI + */ + public function __construct( + private readonly ElementSourceInterface $source, + private readonly bool $onlyMissing = false, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function load(RegistryInterface $registry): void + { + try { + $collection = $this->source->provide(); + } catch (\Throwable $e) { + throw new ElementSourceException(\sprintf('Element source "%s" failed: %s', $this->source::class, $e->getMessage()), previous: $e); + } + + foreach ($collection->getTools() as $reference) { + if ($this->canWrite('tool', $reference->tool->name, $registry->hasTool(...))) { + $registry->registerTool($reference->tool, $reference->handler); + } + } + + foreach ($collection->getResources() as $reference) { + if ($this->canWrite('resource', $reference->resource->uri, $registry->hasResource(...))) { + $registry->registerResource($reference->resource, $reference->handler); + } + } + + foreach ($collection->getResourceTemplates() as $reference) { + if ($this->canWrite('resource template', $reference->resourceTemplate->uriTemplate, $registry->hasResourceTemplate(...))) { + $registry->registerResourceTemplate($reference->resourceTemplate, $reference->handler, $reference->completionProviders); + } + } + + foreach ($collection->getPrompts() as $reference) { + if ($this->canWrite('prompt', $reference->prompt->name, $registry->hasPrompt(...))) { + $registry->registerPrompt($reference->prompt, $reference->handler, $reference->completionProviders); + } + } + } + + /** + * @param callable(string): bool $exists + */ + private function canWrite(string $kind, string $key, callable $exists): bool + { + if (!$this->onlyMissing || !$exists($key)) { + return true; + } + + $this->logger->debug(\sprintf('Ignoring %s "%s" from an element source: the registry already holds it.', $kind, $key)); + + return false; + } +} diff --git a/src/Server/Capability/Registry/Source/DiscoverySource.php b/src/Server/Capability/Registry/Source/DiscoverySource.php new file mode 100644 index 00000000..421c119c --- /dev/null +++ b/src/Server/Capability/Registry/Source/DiscoverySource.php @@ -0,0 +1,43 @@ + + */ +final class DiscoverySource implements ElementSourceInterface +{ + /** + * @param string[] $scanDirs + * @param string[] $excludeDirs + * @param string[] $namePatterns + */ + public function __construct( + private readonly string $basePath, + private readonly array $scanDirs, + private readonly array $excludeDirs, + private readonly DiscovererInterface $discoverer, + private readonly array $namePatterns = DiscovererInterface::DEFAULT_NAME_PATERNS, + ) { + } + + public function provide(): ElementCollection + { + $state = $this->discoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs, $this->namePatterns); + + return new ElementCollection($state->getTools(), $state->getResources(), $state->getResourceTemplates(), $state->getPrompts()); + } +} diff --git a/src/Server/Capability/Registry/Source/ElementCollection.php b/src/Server/Capability/Registry/Source/ElementCollection.php new file mode 100644 index 00000000..d2a31d0b --- /dev/null +++ b/src/Server/Capability/Registry/Source/ElementCollection.php @@ -0,0 +1,71 @@ + + */ +final class ElementCollection +{ + /** + * @param array $tools + * @param array $resources + * @param array $resourceTemplates + * @param array $prompts + */ + public function __construct( + private readonly array $tools = [], + private readonly array $resources = [], + private readonly array $resourceTemplates = [], + private readonly array $prompts = [], + ) { + } + + /** + * @return array + */ + public function getTools(): array + { + return $this->tools; + } + + /** + * @return array + */ + public function getResources(): array + { + return $this->resources; + } + + /** + * @return array + */ + public function getResourceTemplates(): array + { + return $this->resourceTemplates; + } + + /** + * @return array + */ + public function getPrompts(): array + { + return $this->prompts; + } +} diff --git a/src/Server/Capability/Registry/Source/ElementSourceInterface.php b/src/Server/Capability/Registry/Source/ElementSourceInterface.php new file mode 100644 index 00000000..1d475143 --- /dev/null +++ b/src/Server/Capability/Registry/Source/ElementSourceInterface.php @@ -0,0 +1,22 @@ + + */ +interface ElementSourceInterface +{ + public function provide(): ElementCollection; +} diff --git a/src/Server/Exception/ElementSourceException.php b/src/Server/Exception/ElementSourceException.php new file mode 100644 index 00000000..32d2d24d --- /dev/null +++ b/src/Server/Exception/ElementSourceException.php @@ -0,0 +1,21 @@ + + */ +final class ElementSourceException extends RuntimeException +{ +} diff --git a/src/Server/Exception/PartialLoadException.php b/src/Server/Exception/PartialLoadException.php new file mode 100644 index 00000000..65df4a49 --- /dev/null +++ b/src/Server/Exception/PartialLoadException.php @@ -0,0 +1,22 @@ + + */ +final class PartialLoadException extends RuntimeException +{ +} diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index ea0cbe20..07ece511 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -23,10 +23,14 @@ use Mcp\Schema\Tool; use Mcp\Server\Builder; use Mcp\Server\Capability\Discovery\DiscovererInterface; +use Mcp\Server\Capability\Discovery\DiscoveryState; use Mcp\Server\Capability\Registry; use Mcp\Server\Capability\Registry\ElementReference; use Mcp\Server\Capability\Registry\Loader\LoaderInterface; use Mcp\Server\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Server\Capability\Registry\Source\ElementCollection; +use Mcp\Server\Capability\Registry\Source\ElementSourceInterface; +use Mcp\Server\Capability\Registry\ToolReference; use Mcp\Server\Capability\RegistryInterface; use Mcp\Server\Exception\InvalidArgumentException as ServerInvalidArgumentException; use Mcp\Server\Exception\LogicException; @@ -694,6 +698,66 @@ public function testADeferredThirdPartyRegistryAdvertisesWhatItHolds(): void $this->assertFalse($capabilities->tools); $this->assertFalse($capabilities->resources); } + + public function testASourceThatFailsLeavesTheOtherElementsServable(): void + { + $registry = new Registry(); + $source = $this->createStub(ElementSourceInterface::class); + $source->method('provide')->willThrowException(new RuntimeException('database down')); + + $server = Server::builder() + ->setRegistry($registry) + ->addSource($source) + ->addTool(static fn (): string => 'ok', 'alpha') + ->build(); + + $this->assertTrue($registry->hasTool('alpha')); + $this->assertTrue($this->extractServerCapabilities($server)->tools); + } + + public function testASourceAdvertisesOnlyWhatItProvides(): void + { + $tool = new Tool(name: 'alpha', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: null, annotations: null); + $source = $this->createStub(ElementSourceInterface::class); + $source->method('provide')->willReturn(new ElementCollection(tools: [new ToolReference($tool, 'handler')])); + + $capabilities = $this->extractServerCapabilities(Server::builder()->addSource($source)->build()); + + $this->assertTrue($capabilities->tools); + $this->assertFalse($capabilities->resources); + $this->assertFalse($capabilities->prompts); + } + + public function testAFailingSourceAdvertisesAllKindsAndIsCalledOncePerRequest(): void + { + $source = $this->createMock(ElementSourceInterface::class); + $source->expects($this->once())->method('provide')->willThrowException(new RuntimeException('database down')); + + $capabilities = $this->extractServerCapabilities(Server::builder()->addSource($source)->build()); + + $this->assertTrue($capabilities->tools); + $this->assertTrue($capabilities->resources); + $this->assertTrue($capabilities->prompts); + } + + public function testDiscoveredElementsDoNotOverwriteThoseOfASource(): void + { + $registry = new Registry(); + $discoverer = $this->createStub(DiscovererInterface::class); + $tool = new Tool(name: 'alpha', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: null, annotations: null); + $discoverer->method('discover')->willReturn(new DiscoveryState(tools: ['alpha' => new ToolReference($tool, 'discovered')])); + $source = $this->createStub(ElementSourceInterface::class); + $source->method('provide')->willReturn(new ElementCollection(tools: [new ToolReference($tool, 'provided')])); + + Server::builder() + ->setRegistry($registry) + ->setDiscovery(__DIR__) + ->setDiscoverer($discoverer) + ->addSource($source) + ->build(); + + $this->assertSame('provided', $registry->getTool('alpha')->handler); + } } /** diff --git a/tests/Unit/Server/Capability/Registry/LazyRegistryTest.php b/tests/Unit/Server/Capability/Registry/LazyRegistryTest.php index 8bb9df57..5e221cc4 100644 --- a/tests/Unit/Server/Capability/Registry/LazyRegistryTest.php +++ b/tests/Unit/Server/Capability/Registry/LazyRegistryTest.php @@ -16,6 +16,7 @@ use Mcp\Server\Capability\Registry\LazyRegistry; use Mcp\Server\Capability\Registry\Loader\LoaderInterface; use Mcp\Server\Capability\RegistryInterface; +use Mcp\Server\Exception\PartialLoadException; use Mcp\Server\Exception\RuntimeException; use Mcp\Server\Subscription\InMemoryNotificationBus; use Mcp\Server\Subscription\PublishingEventDispatcher; @@ -186,6 +187,20 @@ public function testLoadsAThirdPartyRegistryThroughThePlainLoader(): void $this->assertTrue($registry->hasTools()); } + public function testIncompleteLoadStaysPendingWithoutFailingTheReader(): void + { + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->exactly(2)) + ->method('load') + ->willThrowException(new PartialLoadException()); + + $registry = new LazyRegistry(new Registry(), $loader); + + $this->assertSame([], $registry->getTools()->references); + $this->assertFalse($registry->isLoaded()); + $this->assertSame([], $registry->getTools()->references); + } + private function tool(string $name): Tool { return new Tool(name: $name, title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: null, annotations: null); diff --git a/tests/Unit/Server/Capability/Registry/Loader/ChainLoaderTest.php b/tests/Unit/Server/Capability/Registry/Loader/ChainLoaderTest.php index 8aebbb67..9c076749 100644 --- a/tests/Unit/Server/Capability/Registry/Loader/ChainLoaderTest.php +++ b/tests/Unit/Server/Capability/Registry/Loader/ChainLoaderTest.php @@ -11,11 +11,22 @@ namespace Mcp\Tests\Unit\Server\Capability\Registry\Loader; +use Mcp\Schema\Tool; use Mcp\Server\Capability\Registry; use Mcp\Server\Capability\Registry\Loader\ChainLoader; +use Mcp\Server\Capability\Registry\Loader\LoaderInterface; +use Mcp\Server\Capability\Registry\Loader\SourceLoader; +use Mcp\Server\Capability\Registry\Source\ElementCollection; +use Mcp\Server\Capability\Registry\Source\ElementSourceInterface; +use Mcp\Server\Capability\Registry\ToolReference; +use Mcp\Server\Capability\RegistryInterface; +use Mcp\Server\Exception\ConfigurationException; +use Mcp\Server\Exception\PartialLoadException; +use Mcp\Server\Exception\RuntimeException; use Mcp\Tests\Unit\Server\Capability\Registry\Loader\Stub\RecordingLoader; use Mcp\Tests\Unit\Server\Capability\Registry\Loader\Stub\ToolWriterLoader; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; class ChainLoaderTest extends TestCase { @@ -56,4 +67,159 @@ public function testEmptyChainIsNoop(): void $this->assertFalse($registry->hasResourceTemplates()); $this->assertFalse($registry->hasPrompts()); } + + public function testAFailingLoaderDoesNotStopTheOthersAndIsReported(): void + { + $calls = new \ArrayObject(); + $failing = $this->createStub(LoaderInterface::class); + $failing->method('load')->willThrowException(new RuntimeException('source down')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('error'); + + $chain = new ChainLoader([$failing, new RecordingLoader('after', $calls)], $logger); + + try { + $chain->load(new Registry()); + $this->fail('The chain must report that it is incomplete.'); + } catch (PartialLoadException) { + } + + $this->assertSame(['after'], $calls->getArrayCopy()); + } + + public function testAConfigurationErrorEndsTheLoad(): void + { + $calls = new \ArrayObject(); + $misconfigured = $this->createStub(LoaderInterface::class); + $misconfigured->method('load')->willThrowException(new ConfigurationException('handler class not found')); + + $registry = new Registry(); + $chain = new ChainLoader([new RecordingLoader('before', $calls), $misconfigured, new RecordingLoader('after', $calls)]); + + foreach ([1, 2] as $attempt) { + try { + $chain->load($registry); + $this->fail('A configuration error must reach the caller.'); + } catch (ConfigurationException) { + } + } + + $this->assertSame(['before', 'before'], $calls->getArrayCopy()); + } + + public function testARetryRunsOnlyTheFailedSourceWhileItStaysDown(): void + { + $calls = new \ArrayObject(); + $source = $this->createStub(ElementSourceInterface::class); + $source->method('provide')->willThrowException(new RuntimeException('source down')); + + $registry = new Registry(); + $chain = new ChainLoader([new RecordingLoader('before', $calls), new SourceLoader($source), new RecordingLoader('after', $calls)]); + + for ($i = 0; $i < 3; ++$i) { + try { + $chain->load($registry); + $this->fail('The chain must report that it is incomplete.'); + } catch (PartialLoadException) { + } + } + + $this->assertSame(['before', 'after'], $calls->getArrayCopy()); + } + + public function testARecoveredLoaderDoesNotOverwriteLaterLoaders(): void + { + $recovering = new class implements LoaderInterface { + public bool $down = true; + + public function load(RegistryInterface $registry): void + { + if ($this->down) { + throw new RuntimeException('source down'); + } + + (new ToolWriterLoader('shared', static fn () => 'recovered'))->load($registry); + } + }; + + $registry = new Registry(); + $chain = new ChainLoader([$recovering, new ToolWriterLoader('shared', static fn () => 'later')]); + + try { + $chain->load($registry); + $this->fail('The chain must report that it is incomplete.'); + } catch (PartialLoadException) { + } + + $recovering->down = false; + $chain->load($registry); + + $handler = $registry->getTool('shared')->handler; + $this->assertInstanceOf(\Closure::class, $handler); + $this->assertSame('later', $handler()); + } + + public function testAFailingLoaderThatWroteDoesNotOverwriteLaterLoadersOnRetry(): void + { + $failing = new class implements LoaderInterface { + public function load(RegistryInterface $registry): void + { + (new ToolWriterLoader('shared', static fn () => 'failing'))->load($registry); + + throw new RuntimeException('source down'); + } + }; + + $registry = new Registry(); + $chain = new ChainLoader([$failing, new ToolWriterLoader('shared', static fn () => 'later')]); + + for ($i = 0; $i < 2; ++$i) { + try { + $chain->load($registry); + $this->fail('The chain must report that it is incomplete.'); + } catch (PartialLoadException) { + } + } + + $handler = $registry->getTool('shared')->handler; + $this->assertInstanceOf(\Closure::class, $handler); + $this->assertSame('later', $handler()); + } + + public function testASourceFailingDuringRegistrationIsFollowedByLaterLoadersOnRetry(): void + { + $calls = new \ArrayObject(); + $source = $this->createStub(ElementSourceInterface::class); + $source->method('provide')->willReturn(new ElementCollection(tools: [ + new ToolReference(new Tool(name: 'shared', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: null, annotations: null), 'handler'), + ])); + + $registry = $this->createStub(RegistryInterface::class); + $registry->method('registerTool')->willThrowException(new RuntimeException('registry down')); + + $chain = new ChainLoader([new SourceLoader($source), new RecordingLoader('after', $calls)]); + + for ($i = 0; $i < 2; ++$i) { + try { + $chain->load($registry); + $this->fail('The chain must report that it is incomplete.'); + } catch (PartialLoadException) { + } + } + + $this->assertSame(['after', 'after'], $calls->getArrayCopy()); + } + + public function testACompleteChainRunsAllLoadersOnTheNextLoad(): void + { + $calls = new \ArrayObject(); + $registry = new Registry(); + $chain = new ChainLoader([new RecordingLoader('A', $calls), new RecordingLoader('B', $calls)]); + + $chain->load($registry); + $chain->load($registry); + + $this->assertSame(['A', 'B', 'A', 'B'], $calls->getArrayCopy()); + } } diff --git a/tests/Unit/Server/Capability/Registry/Source/ElementSourceTest.php b/tests/Unit/Server/Capability/Registry/Source/ElementSourceTest.php new file mode 100644 index 00000000..8299dc0c --- /dev/null +++ b/tests/Unit/Server/Capability/Registry/Source/ElementSourceTest.php @@ -0,0 +1,124 @@ +registerTool($this->tool('alpha'), 'existing'); + + (new SourceLoader($this->source(new ElementCollection(tools: [new ToolReference($this->tool('alpha'), 'provided')]))))->load($registry); + + $this->assertSame('provided', $registry->getTool('alpha')->handler); + } + + public function testSourceLoaderOnlyFillsGapsWhenAskedTo(): void + { + $registry = new Registry(); + $registry->registerTool($this->tool('alpha'), 'existing'); + + (new SourceLoader($this->source(new ElementCollection(tools: [ + new ToolReference($this->tool('alpha'), 'provided'), + new ToolReference($this->tool('beta'), 'provided'), + ])), onlyMissing: true))->load($registry); + + $this->assertSame('existing', $registry->getTool('alpha')->handler); + $this->assertSame('provided', $registry->getTool('beta')->handler); + } + + public function testAFailedSourceIsRetriedOnTheNextReadWhileOthersStayServable(): void + { + $healthy = new SourceLoader($this->source(new ElementCollection(tools: [new ToolReference($this->tool('alpha'), 'handler')]))); + $source = new class($this->tool('beta')) implements ElementSourceInterface { + public bool $available = false; + + public function __construct(private Tool $tool) + { + } + + public function provide(): ElementCollection + { + if (!$this->available) { + throw new RuntimeException('database down'); + } + + return new ElementCollection(tools: [new ToolReference($this->tool, 'handler')]); + } + }; + + $registry = new Registry(loader: new ChainLoader([$healthy, new SourceLoader($source)])); + + $this->assertTrue($registry->hasTool('alpha')); + $this->assertFalse($registry->hasTool('beta')); + $this->assertFalse($registry->isLoaded()); + + $source->available = true; + + $this->assertTrue($registry->hasTool('beta')); + $this->assertTrue($registry->isLoaded()); + } + + public function testCollectionOfNamedHandlersSurvivesSerialization(): void + { + $collection = new ElementCollection(tools: [new ToolReference($this->tool('alpha'), 'handler')]); + + $this->assertEquals($collection, unserialize(serialize($collection))); + } + + public function testDiscoverySourceProvidesWhatTheDiscovererFound(): void + { + $reference = new ToolReference($this->tool('alpha'), 'handler'); + $discoverer = $this->createMock(DiscovererInterface::class); + $discoverer->expects($this->once()) + ->method('discover') + ->with('/base', ['src'], ['vendor'], ['*.php']) + ->willReturn(new DiscoveryState(tools: ['alpha' => $reference])); + + $source = new DiscoverySource('/base', ['src'], ['vendor'], $discoverer, ['*.php']); + + $this->assertSame(['alpha' => $reference], $source->provide()->getTools()); + } + + 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 source(ElementCollection $collection): ElementSourceInterface + { + return new class($collection) implements ElementSourceInterface { + public function __construct(private ElementCollection $collection) + { + } + + public function provide(): ElementCollection + { + return $this->collection; + } + }; + } +} diff --git a/tests/Unit/Server/RegistryCapabilitiesTest.php b/tests/Unit/Server/RegistryCapabilitiesTest.php index 7784a7cb..cfe5d61c 100644 --- a/tests/Unit/Server/RegistryCapabilitiesTest.php +++ b/tests/Unit/Server/RegistryCapabilitiesTest.php @@ -16,6 +16,7 @@ use Mcp\Server\Capability\Registry\LazyRegistry; use Mcp\Server\Capability\Registry\Loader\LoaderInterface; use Mcp\Server\Capability\RegistryInterface; +use Mcp\Server\Exception\PartialLoadException; use Mcp\Server\RegistryCapabilities; use PHPUnit\Framework\TestCase; @@ -77,6 +78,24 @@ public function testAdvertisesAllKindsWhileAnotherFiberIsLoading(): void $this->assertTrue($capabilities->prompts); } + public function testAdvertisesAllKindsWhileTheSuppliedRegistryIsIncomplete(): void + { + $unavailable = $this->createStub(LoaderInterface::class); + $unavailable->method('load')->willThrowException(new PartialLoadException()); + $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'); + }); + + $registry = new LazyRegistry(new Registry(loader: $unavailable), $loader); + $capabilities = (new RegistryCapabilities($registry, false))->detect(); + + $this->assertFalse($registry->isLoaded()); + $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);