Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/Capability/Registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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);
}

Expand Down
92 changes: 77 additions & 15 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ final class Builder
*/
private array $loaders = [];

private bool $hasCustomRegistry = false;

private bool $lazyLoading = true;

/**
* Sets the server's identity. Required.
*
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading