From ffae0f7d3b364987598cee50256bf3ef3a49481e Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 1 Jul 2026 08:09:28 +0200 Subject: [PATCH] [Server] Defer element loading to first registry read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builder::build() runs all loaders eagerly and snapshots capabilities from the resulting registry. Both are computed once, when the server is built. Under a persistent runtime (e.g. FrankenPHP worker mode) the server is built a single time, so a loader whose data source is not yet ready at that moment (cold cache, un-warmed metadata) leaves the registry empty for the whole process — tools/list stays empty while tools/call still works. Make deferred loading the default behavior of Registry itself: it takes a loader and runs it on the first read (has*/get*), moving loading to request time when the application is initialized. The load runs once; the loaded flag is set only after a successful load, so a transient failure is retried on the next read. A re-entrancy guard lets loaders that read the registry during their own run (e.g. discovery's identity check) observe the partial state instead of recursing. Registrations made before the first read survive it. Expose the switch on Builder via setLazyLoading() (default on) so callers can opt back into eager loading at build time. In lazy mode capabilities are advertised from the configured sources so the initialize handshake does not force a load; in eager mode (or for a registry supplied via setRegistry(), which is always loaded eagerly) they are read from the loaded registry. --- src/Capability/Registry.php | 62 ++++++++++ src/Server/Builder.php | 92 ++++++++++++--- .../Loader/ExplicitElementLoaderTest.php | 6 +- tests/Unit/Capability/RegistryTest.php | 109 ++++++++++++++++++ tests/Unit/Server/BuilderTest.php | 65 +++++++++++ 5 files changed, 315 insertions(+), 19 deletions(-) diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 23167b68..f90a4ca1 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -11,6 +11,7 @@ namespace Mcp\Capability; +use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; @@ -60,13 +61,42 @@ final class Registry implements RegistryInterface */ private array $resourceTemplates = []; + private bool $loaded = false; + + private bool $loading = false; + public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), private readonly NameValidator $nameValidator = new NameValidator(), + private readonly ?LoaderInterface $loader = null, ) { } + /** + * Runs the configured loader once, on demand. Reads trigger this automatically, so element + * loading is deferred to the first read (request time) rather than eager at build time — under a + * persistent runtime a source not yet ready at build no longer freezes the registry empty. + * + * `loaded` is set only after success, so a transient failure is retried on the next read. The + * `loading` guard lets a loader read the registry during its own run (e.g. discovery's identity + * check) without re-entering the load. + */ + public function load(): void + { + if ($this->loaded || $this->loading || null === $this->loader) { + return; + } + + $this->loading = true; + try { + $this->loader->load($this); + $this->loaded = true; + } finally { + $this->loading = false; + } + } + public function registerTool(Tool $tool, callable|array|string $handler): ToolReference { if (!$this->nameValidator->isValid($tool->name)) { @@ -165,31 +195,43 @@ public function unregisterPrompt(string $name): void public function hasTool(string $name): bool { + $this->load(); + return isset($this->tools[$name]); } public function hasResource(string $uri): bool { + $this->load(); + return isset($this->resources[$uri]); } public function hasResourceTemplate(string $uriTemplate): bool { + $this->load(); + return isset($this->resourceTemplates[$uriTemplate]); } public function hasPrompt(string $name): bool { + $this->load(); + return isset($this->prompts[$name]); } public function hasTools(): bool { + $this->load(); + return [] !== $this->tools; } public function getTools(?int $limit = null, ?string $cursor = null): Page { + $this->load(); + $tools = []; foreach ($this->tools as $toolReference) { $tools[$toolReference->tool->name] = $toolReference->tool; @@ -212,16 +254,22 @@ public function getTools(?int $limit = null, ?string $cursor = null): Page public function getTool(string $name): ToolReference { + $this->load(); + return $this->tools[$name] ?? throw new ToolNotFoundException($name); } public function hasResources(): bool { + $this->load(); + return [] !== $this->resources; } public function getResources(?int $limit = null, ?string $cursor = null): Page { + $this->load(); + $resources = []; foreach ($this->resources as $resourceReference) { $resources[$resourceReference->resource->uri] = $resourceReference->resource; @@ -246,6 +294,8 @@ public function getResource( string $uri, bool $includeTemplates = true, ): ResourceReference|ResourceTemplateReference { + $this->load(); + $registration = $this->resources[$uri] ?? null; if ($registration) { return $registration; @@ -266,11 +316,15 @@ public function getResource( public function hasResourceTemplates(): bool { + $this->load(); + return [] !== $this->resourceTemplates; } public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page { + $this->load(); + $templates = []; foreach ($this->resourceTemplates as $templateReference) { $templates[$templateReference->resourceTemplate->uriTemplate] = $templateReference->resourceTemplate; @@ -293,16 +347,22 @@ public function getResourceTemplates(?int $limit = null, ?string $cursor = null) public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference { + $this->load(); + return $this->resourceTemplates[$uriTemplate] ?? throw new ResourceNotFoundException($uriTemplate); } public function hasPrompts(): bool { + $this->load(); + return [] !== $this->prompts; } public function getPrompts(?int $limit = null, ?string $cursor = null): Page { + $this->load(); + $prompts = []; foreach ($this->prompts as $promptReference) { $prompts[$promptReference->prompt->name] = $promptReference->prompt; @@ -325,6 +385,8 @@ public function getPrompts(?int $limit = null, ?string $cursor = null): Page public function getPrompt(string $name): PromptReference { + $this->load(); + return $this->prompts[$name] ?? throw new PromptNotFoundException($name); } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 198a3497..c53fa620 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -219,6 +219,10 @@ final class Builder */ private array $loaders = []; + private bool $hasCustomRegistry = false; + + private bool $lazyLoading = true; + /** * Sets the server's identity. Required. * @@ -344,6 +348,21 @@ public function addNotificationHandlers(iterable $handlers): self public function setRegistry(RegistryInterface $registry): self { $this->registry = $registry; + $this->hasCustomRegistry = true; + + return $this; + } + + /** + * Controls when configured loaders (manual elements, discovery, custom loaders) run. + * + * 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. + */ + public function setLazyLoading(bool $lazyLoading = true): self + { + $this->lazyLoading = $lazyLoading; return $this; } @@ -643,7 +662,6 @@ public function build(): Server { $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); - $registry = $this->registry ?? new Registry($this->eventDispatcher, $logger); $subscriptionManager = $this->subscriptionManager ?? new SessionSubscriptionManager($logger); $sessionManager = $this->sessionManager ?? new SessionManager( $this->sessionStore ?? new InMemorySessionStore(), @@ -674,23 +692,24 @@ public function build(): Server } } - $loader = new ChainLoader($loaders); - $loader->load($registry); + $chainLoader = new ChainLoader($loaders); + + if ($this->hasCustomRegistry) { + // Builder can't inject the loader into an already-constructed instance, so load it eagerly. + $registry = $this->registry; + $chainLoader->load($registry); + $eagerlyLoaded = true; + } else { + $registry = new Registry($this->eventDispatcher, $logger, loader: $chainLoader); + if (!$this->lazyLoading) { + $registry->load(); + } + $eagerlyLoaded = !$this->lazyLoading; + } $messageFactory = MessageFactory::make(); - $capabilities = $this->serverCapabilities ?? new ServerCapabilities( - tools: $registry->hasTools(), - toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, - resources: $registry->hasResources() || $registry->hasResourceTemplates(), - resourcesSubscribe: $registry->hasResources() || $registry->hasResourceTemplates(), - resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, - prompts: $registry->hasPrompts(), - promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, - logging: true, - completions: true, - extensions: $this->extensions ?: null, - ); + $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. @@ -734,6 +753,49 @@ public function build(): Server return new Server($protocol, $logger); } + /** + * 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 + { + $listChanged = $this->eventDispatcher instanceof EventDispatcherInterface; + + 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/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php index 0066a318..dad2de64 100644 --- a/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php +++ b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php @@ -373,11 +373,9 @@ public function execute(array $arguments, ClientGateway $gateway): mixed */ private function buildAndGetRegistry(callable $configure): RegistryInterface { + // A caller-supplied registry is loaded eagerly at build, so it is populated once build() returns. $registry = new Registry(); - $builder = Server::builder() - ->setServerInfo('test', '1.0.0') - ->setRegistry($registry); - $configure($builder)->build(); + $configure(Server::builder()->setServerInfo('test', '1.0.0')->setRegistry($registry))->build(); return $registry; } diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index a4d27801..c8aa5a7f 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -13,10 +13,12 @@ use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Capability\Registry; +use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; +use Mcp\Capability\RegistryInterface; use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; @@ -534,6 +536,113 @@ public function testExtractStructuredContentReturnsArrayDirectlyForArrayOutputSc ], $structuredContent); } + public function testConfiguredLoaderIsNotRunUntilFirstRead(): void + { + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->never())->method('load'); + + // Constructing (and registering) must not trigger the loader. + $registry = new Registry(null, $this->logger, loader: $loader); + $registry->registerTool($this->createValidTool('manual'), 'handler'); + } + + public function testConfiguredLoaderRunsOnFirstReadAndPopulatesTheRegistry(): void + { + $loader = $this->toolLoader($this->createValidTool('loaded')); + $registry = new Registry(null, $this->logger, loader: $loader); + + $this->assertTrue($registry->hasTools()); + $this->assertArrayHasKey('loaded', $registry->getTools()->references); + } + + public function testConfiguredLoaderRunsExactlyOnceAcrossManyReads(): void + { + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->once())->method('load'); + + $registry = new Registry(null, $this->logger, loader: $loader); + $registry->hasTools(); + $registry->getTools(); + $registry->hasResources(); + $registry->getPrompts(); + } + + public function testRuntimeRegistrationsSurviveTheDeferredLoad(): void + { + $loader = $this->toolLoader($this->createValidTool('loaded')); + $registry = new Registry(null, $this->logger, loader: $loader); + // Registered before the first read; the deferred load must be additive, not replacing. + $registry->registerTool($this->createValidTool('runtime'), 'handler'); + + $tools = $registry->getTools()->references; + $this->assertArrayHasKey('runtime', $tools); + $this->assertArrayHasKey('loaded', $tools); + } + + public function testConfiguredLoaderRetriesAfterAFailedLoad(): void + { + $tool = $this->createValidTool('loaded'); + $loader = new class($tool) implements LoaderInterface { + private int $calls = 0; + + public function __construct(private readonly Tool $tool) + { + } + + public function load(RegistryInterface $registry): void + { + ++$this->calls; + if (1 === $this->calls) { + throw new \RuntimeException('data source not ready'); + } + + $registry->registerTool($this->tool, 'handler'); + } + }; + + $registry = new Registry(null, $this->logger, loader: $loader); + + try { + $registry->hasTools(); + $this->fail('Expected the first load to throw.'); + } catch (\RuntimeException $e) { + $this->assertSame('data source not ready', $e->getMessage()); + } + + $this->assertArrayHasKey('loaded', $registry->getTools()->references); + } + + public function testLoadRunsTheConfiguredLoaderEagerly(): void + { + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->once())->method('load'); + + $registry = new Registry(null, $this->logger, loader: $loader); + $registry->load(); + } + + public function testLoadIsANoopWithoutAConfiguredLoader(): void + { + $registry = new Registry(null, $this->logger); + $registry->load(); + + $this->assertFalse($registry->hasTools()); + } + + private function toolLoader(Tool $tool): LoaderInterface + { + return new class($tool) implements LoaderInterface { + public function __construct(private readonly Tool $tool) + { + } + + public function load(RegistryInterface $registry): void + { + $registry->registerTool($this->tool, 'handler'); + } + }; + } + private function createValidTool(string $name, ?array $outputSchema = null): Tool { return new Tool( diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 4435639c..53ff61f2 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -11,7 +11,9 @@ namespace Mcp\Tests\Unit\Server; +use Mcp\Capability\Registry; use Mcp\Capability\Registry\ElementReference; +use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Exception\LogicException; use Mcp\Schema\Content\TextContent; @@ -19,6 +21,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\ServerCapabilities; +use Mcp\Schema\Tool; use Mcp\Server; use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Handler\Request\InitializeHandler; @@ -122,6 +125,68 @@ public function testEnableExtensionMergesIntoCustomCapabilities(): void $this->assertArrayHasKey(McpApps::EXTENSION_ID, $capabilities->extensions); } + #[TestDox('build() advertises tools capability for a pre-populated registry set via setRegistry()')] + public function testBuildAdvertisesToolsForPreloadedCustomRegistry(): void + { + $registry = new Registry(); + $registry->registerTool( + new Tool(name: 'test_tool', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: 'A test tool', annotations: null), + static fn (): string => 'result', + ); + + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setRegistry($registry) + ->build(); + + $capabilities = $this->extractServerCapabilities($server); + + $this->assertTrue($capabilities->tools); + } + + #[TestDox('setLazyLoading() returns the builder for fluent chaining')] + public function testSetLazyLoadingReturnsSelf(): void + { + $builder = Server::builder(); + + $this->assertSame($builder, $builder->setLazyLoading(false)); + } + + #[TestDox('Lazy loading (default) advertises tools from configured sources without running loaders')] + public function testLazyLoadingAdvertisesFromConfiguredSourcesWithoutLoading(): void + { + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->never())->method('load'); + + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addLoader($loader) + ->build(); + + $capabilities = $this->extractServerCapabilities($server); + + // A custom loader is opaque, so its presence advertises tools even though it never ran. + $this->assertTrue($capabilities->tools); + } + + #[TestDox('Eager loading runs the loaders at build time and advertises from the loaded registry')] + public function testEagerLoadingAdvertisesFromLoadedRegistry(): void + { + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->once())->method('load'); + + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setLazyLoading(false) + ->addLoader($loader) + ->build(); + + $capabilities = $this->extractServerCapabilities($server); + + // The loader ran but registered nothing, so the loaded registry advertises no tools. + $this->assertFalse($capabilities->tools); + } + private function extractServerCapabilities(Server $server): ServerCapabilities { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server);