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
206 changes: 206 additions & 0 deletions src/Capability/LazyRegistry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Capability;

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\Schema\Page;
use Mcp\Schema\Prompt;
use Mcp\Schema\ResourceDefinition;
use Mcp\Schema\ResourceTemplate;
use Mcp\Schema\Tool;

/**
* Decorates a registry so its loader runs on first read instead of eagerly at build time.
*
* Under a persistent runtime (e.g. FrankenPHP worker mode) the server is built once, so eager
* loading would freeze the registry to a data source not yet ready at build (cold cache) for the
* whole process. Deferring to the first read runs the load once, at request time. Writes are
* delegated without loading, so registrations made before the first read survive it.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
final class LazyRegistry implements RegistryInterface
{
private bool $loaded = false;

public function __construct(
private readonly RegistryInterface $registry,
private readonly LoaderInterface $loader,
) {
}

public function registerTool(Tool $tool, callable|array|string $handler): ToolReference
{
return $this->registry->registerTool($tool, $handler);
}

public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference
{
return $this->registry->registerResource($resource, $handler);
}

public function registerResourceTemplate(ResourceTemplate $template, callable|array|string $handler, array $completionProviders = []): ResourceTemplateReference
{
return $this->registry->registerResourceTemplate($template, $handler, $completionProviders);
}

public function registerPrompt(Prompt $prompt, callable|array|string $handler, array $completionProviders = []): PromptReference
{
return $this->registry->registerPrompt($prompt, $handler, $completionProviders);
}

public function unregisterTool(string $name): void
{
$this->registry->unregisterTool($name);
}

public function unregisterResource(string $uri): void
{
$this->registry->unregisterResource($uri);
}

public function unregisterResourceTemplate(string $uriTemplate): void
{
$this->registry->unregisterResourceTemplate($uriTemplate);
}

public function unregisterPrompt(string $name): void
{
$this->registry->unregisterPrompt($name);
}

public function hasTool(string $name): bool
{
$this->load();

return $this->registry->hasTool($name);
}

public function hasResource(string $uri): bool
{
$this->load();

return $this->registry->hasResource($uri);
}

public function hasResourceTemplate(string $uriTemplate): bool
{
$this->load();

return $this->registry->hasResourceTemplate($uriTemplate);
}

public function hasPrompt(string $name): bool
{
$this->load();

return $this->registry->hasPrompt($name);
}

public function hasTools(): bool
{
$this->load();

return $this->registry->hasTools();
}

public function getTools(?int $limit = null, ?string $cursor = null): Page
{
$this->load();

return $this->registry->getTools($limit, $cursor);
}

public function getTool(string $name): ToolReference
{
$this->load();

return $this->registry->getTool($name);
}

public function hasResources(): bool
{
$this->load();

return $this->registry->hasResources();
}

public function getResources(?int $limit = null, ?string $cursor = null): Page
{
$this->load();

return $this->registry->getResources($limit, $cursor);
}

public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference
{
$this->load();

return $this->registry->getResource($uri, $includeTemplates);
}

public function hasResourceTemplates(): bool
{
$this->load();

return $this->registry->hasResourceTemplates();
}

public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page
{
$this->load();

return $this->registry->getResourceTemplates($limit, $cursor);
}

public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference
{
$this->load();

return $this->registry->getResourceTemplate($uriTemplate);
}

public function hasPrompts(): bool
{
$this->load();

return $this->registry->hasPrompts();
}

public function getPrompts(?int $limit = null, ?string $cursor = null): Page
{
$this->load();

return $this->registry->getPrompts($limit, $cursor);
}

public function getPrompt(string $name): PromptReference
{
$this->load();

return $this->registry->getPrompt($name);
}

private function load(): void
{
if ($this->loaded) {
return;
}

$this->loader->load($this->registry);
$this->loaded = true;
}
}
20 changes: 14 additions & 6 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Mcp\Capability\Discovery\Discoverer;
use Mcp\Capability\Discovery\DiscovererInterface;
use Mcp\Capability\Discovery\SchemaGeneratorInterface;
use Mcp\Capability\LazyRegistry;
use Mcp\Capability\Registry;
use Mcp\Capability\Registry\Container;
use Mcp\Capability\Registry\ElementReference;
Expand Down Expand Up @@ -219,6 +220,8 @@ final class Builder
*/
private array $loaders = [];

private bool $hasCustomRegistry = false;

/**
* Sets the server's identity. Required.
*
Expand Down Expand Up @@ -344,6 +347,7 @@ public function addNotificationHandlers(iterable $handlers): self
public function setRegistry(RegistryInterface $registry): self
{
$this->registry = $registry;
$this->hasCustomRegistry = true;

return $this;
}
Expand Down Expand Up @@ -674,18 +678,22 @@ public function build(): Server
}
}

$loader = new ChainLoader($loaders);
$loader->load($registry);
// Defer loading to the first registry read (request time) instead of eagerly here. @see LazyRegistry
$registry = new LazyRegistry($registry, new ChainLoader($loaders));

$messageFactory = MessageFactory::make();

// Advertise from configured sources, not the now-lazy registry, so initialize does not force a load.
// Opaque sources (custom loaders, discovery, a caller registry) advertise all kinds; over-advertising is harmless.
$hasOpaqueSources = [] !== $this->loaders || null !== $this->discoveryBasePath || $this->hasCustomRegistry;

$capabilities = $this->serverCapabilities ?? new ServerCapabilities(
tools: $registry->hasTools(),
tools: [] !== $this->tools || [] !== $this->explicitTools || $hasOpaqueSources,
toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
resources: $registry->hasResources() || $registry->hasResourceTemplates(),
resourcesSubscribe: $registry->hasResources() || $registry->hasResourceTemplates(),
resources: [] !== $this->resources || [] !== $this->explicitResources || [] !== $this->resourceTemplates || [] !== $this->explicitResourceTemplates || $hasOpaqueSources,
resourcesSubscribe: [] !== $this->resources || [] !== $this->explicitResources || [] !== $this->resourceTemplates || [] !== $this->explicitResourceTemplates || $hasOpaqueSources,
resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
prompts: $registry->hasPrompts(),
prompts: [] !== $this->prompts || [] !== $this->explicitPrompts || $hasOpaqueSources,
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
logging: true,
completions: true,
Expand Down
127 changes: 127 additions & 0 deletions tests/Unit/Capability/LazyRegistryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Tests\Unit\Capability;

use Mcp\Capability\LazyRegistry;
use Mcp\Capability\Registry;
use Mcp\Capability\Registry\Loader\LoaderInterface;
use Mcp\Capability\RegistryInterface;
use Mcp\Schema\Tool;
use PHPUnit\Framework\TestCase;

class LazyRegistryTest extends TestCase
{
public function testLoaderIsNotRunUntilFirstRead(): void
{
$loader = $this->createMock(LoaderInterface::class);
$loader->expects($this->never())->method('load');

// Constructing (and registering) must not trigger the loader.
$registry = new LazyRegistry(new Registry(), $loader);
$registry->registerTool($this->tool('manual'), 'handler');
}

public function testLoaderRunsOnFirstReadAndPopulatesTheRegistry(): void
{
$inner = new Registry();
$loader = new class($this->tool('loaded')) implements LoaderInterface {
public function __construct(private readonly Tool $tool)
{
}

public function load(RegistryInterface $registry): void
{
$registry->registerTool($this->tool, 'handler');
}
};

$registry = new LazyRegistry($inner, $loader);

$this->assertTrue($registry->hasTools());
$tools = $registry->getTools()->references;
$this->assertArrayHasKey('loaded', $tools);
}

public function testLoaderRunsExactlyOnceAcrossManyReads(): void
{
$loader = $this->createMock(LoaderInterface::class);
$loader->expects($this->once())->method('load');

$registry = new LazyRegistry(new Registry(), $loader);
$registry->hasTools();
$registry->getTools();
$registry->hasResources();
$registry->getPrompts();
}

public function testRuntimeRegistrationsSurviveTheDeferredLoad(): void
{
$inner = new Registry();
$loader = new class($this->tool('loaded')) implements LoaderInterface {
public function __construct(private readonly Tool $tool)
{
}

public function load(RegistryInterface $registry): void
{
$registry->registerTool($this->tool, 'handler');
}
};

$registry = new LazyRegistry($inner, $loader);
// Registered before the first read; the deferred load must be additive, not replacing.
$registry->registerTool($this->tool('runtime'), 'handler');

$tools = $registry->getTools()->references;
$this->assertArrayHasKey('runtime', $tools);
$this->assertArrayHasKey('loaded', $tools);
}

public function testLoaderRetriesAfterAFailedLoad(): void
{
$inner = new Registry();
$loader = new class($this->tool('loaded')) 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 LazyRegistry($inner, $loader);

try {
$registry->hasTools();
$this->fail('Expected the first load to throw.');
} catch (\RuntimeException $e) {
$this->assertSame('data source not ready', $e->getMessage());
}

$tools = $registry->getTools()->references;
$this->assertArrayHasKey('loaded', $tools);
}

private function tool(string $name): Tool
{
return new Tool($name, null, ['type' => 'object', 'properties' => [], 'required' => null], null, null);
}
}
Loading