[Server] Defer element loading to first registry read (#389)

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.
This commit is contained in:
Antoine Bluchet 2026-07-14 23:55:31 +02:00 • committed by GitHub
commit ff91ca6d60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 315 additions and 19 deletions

View file

@ -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 @@ final class Registry implements RegistryInterface
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 @@ final class Registry implements RegistryInterface
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 @@ final class Registry implements RegistryInterface
string $uri,
bool $includeTemplates = true,
): ResourceReference|ResourceTemplateReference {
$this->load();
$registration = $this->resources[$uri] ?? null;
if ($registration) {
return $registration;
@ -266,11 +316,15 @@ final class Registry implements RegistryInterface
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 @@ final class Registry implements RegistryInterface
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 @@ final class Registry implements RegistryInterface
public function getPrompt(string $name): PromptReference
{
$this->load();
return $this->prompts[$name] ?? throw new PromptNotFoundException($name);
}

View file

@ -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 @@ final class Builder
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 @@ final class Builder
{
$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 @@ final class Builder
}
}
$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 @@ final class Builder
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);

View file

@ -373,11 +373,9 @@ final class ExplicitElementLoaderTest extends TestCase
*/
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;
}

View file

@ -13,10 +13,12 @@ namespace Mcp\Tests\Unit\Capability;
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 @@ class RegistryTest extends TestCase
], $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(

View file

@ -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\Extension\Apps\McpApps;
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;
@ -141,6 +144,68 @@ final class BuilderTest extends TestCase
$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);