[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:
parent
e4a8a224ff
commit
ff91ca6d60
2 changed files with 315 additions and 19 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue