Skip to content

Commit d393af6

Browse files
committed
[Server] Defer element loading to first registry read
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. Wrap the registry in a LazyRegistry that runs the loaders on the first read, moving loading to request time when the application is initialized. The load is retried on the next read if it throws, so a transient failure at the first read does not freeze an empty registry for the whole process. Advertise capabilities from the configured element sources instead of the loaded registry, so the initialize handshake does not force an eager load. A registry supplied via setRegistry() counts as an opaque source too, so a pre-populated custom registry still advertises its capabilities.
1 parent 30e520a commit d393af6

5 files changed

Lines changed: 383 additions & 10 deletions

File tree

src/Capability/LazyRegistry.php

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Capability;
13+
14+
use Mcp\Capability\Registry\Loader\LoaderInterface;
15+
use Mcp\Capability\Registry\PromptReference;
16+
use Mcp\Capability\Registry\ResourceReference;
17+
use Mcp\Capability\Registry\ResourceTemplateReference;
18+
use Mcp\Capability\Registry\ToolReference;
19+
use Mcp\Schema\Page;
20+
use Mcp\Schema\Prompt;
21+
use Mcp\Schema\ResourceDefinition;
22+
use Mcp\Schema\ResourceTemplate;
23+
use Mcp\Schema\Tool;
24+
25+
/**
26+
* Decorates a registry so its loader runs on first read instead of eagerly at build time.
27+
*
28+
* Under a persistent runtime (e.g. FrankenPHP worker mode) the server is built once, so eager
29+
* loading would freeze the registry to a data source not yet ready at build (cold cache) for the
30+
* whole process. Deferring to the first read runs the load once, at request time. Writes are
31+
* delegated without loading, so registrations made before the first read survive it.
32+
*
33+
* @author Antoine Bluchet <soyuka@gmail.com>
34+
*/
35+
final class LazyRegistry implements RegistryInterface
36+
{
37+
private bool $loaded = false;
38+
39+
public function __construct(
40+
private readonly RegistryInterface $registry,
41+
private readonly LoaderInterface $loader,
42+
) {
43+
}
44+
45+
public function registerTool(Tool $tool, callable|array|string $handler): ToolReference
46+
{
47+
return $this->registry->registerTool($tool, $handler);
48+
}
49+
50+
public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference
51+
{
52+
return $this->registry->registerResource($resource, $handler);
53+
}
54+
55+
public function registerResourceTemplate(ResourceTemplate $template, callable|array|string $handler, array $completionProviders = []): ResourceTemplateReference
56+
{
57+
return $this->registry->registerResourceTemplate($template, $handler, $completionProviders);
58+
}
59+
60+
public function registerPrompt(Prompt $prompt, callable|array|string $handler, array $completionProviders = []): PromptReference
61+
{
62+
return $this->registry->registerPrompt($prompt, $handler, $completionProviders);
63+
}
64+
65+
public function unregisterTool(string $name): void
66+
{
67+
$this->registry->unregisterTool($name);
68+
}
69+
70+
public function unregisterResource(string $uri): void
71+
{
72+
$this->registry->unregisterResource($uri);
73+
}
74+
75+
public function unregisterResourceTemplate(string $uriTemplate): void
76+
{
77+
$this->registry->unregisterResourceTemplate($uriTemplate);
78+
}
79+
80+
public function unregisterPrompt(string $name): void
81+
{
82+
$this->registry->unregisterPrompt($name);
83+
}
84+
85+
public function hasTool(string $name): bool
86+
{
87+
$this->load();
88+
89+
return $this->registry->hasTool($name);
90+
}
91+
92+
public function hasResource(string $uri): bool
93+
{
94+
$this->load();
95+
96+
return $this->registry->hasResource($uri);
97+
}
98+
99+
public function hasResourceTemplate(string $uriTemplate): bool
100+
{
101+
$this->load();
102+
103+
return $this->registry->hasResourceTemplate($uriTemplate);
104+
}
105+
106+
public function hasPrompt(string $name): bool
107+
{
108+
$this->load();
109+
110+
return $this->registry->hasPrompt($name);
111+
}
112+
113+
public function hasTools(): bool
114+
{
115+
$this->load();
116+
117+
return $this->registry->hasTools();
118+
}
119+
120+
public function getTools(?int $limit = null, ?string $cursor = null): Page
121+
{
122+
$this->load();
123+
124+
return $this->registry->getTools($limit, $cursor);
125+
}
126+
127+
public function getTool(string $name): ToolReference
128+
{
129+
$this->load();
130+
131+
return $this->registry->getTool($name);
132+
}
133+
134+
public function hasResources(): bool
135+
{
136+
$this->load();
137+
138+
return $this->registry->hasResources();
139+
}
140+
141+
public function getResources(?int $limit = null, ?string $cursor = null): Page
142+
{
143+
$this->load();
144+
145+
return $this->registry->getResources($limit, $cursor);
146+
}
147+
148+
public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference
149+
{
150+
$this->load();
151+
152+
return $this->registry->getResource($uri, $includeTemplates);
153+
}
154+
155+
public function hasResourceTemplates(): bool
156+
{
157+
$this->load();
158+
159+
return $this->registry->hasResourceTemplates();
160+
}
161+
162+
public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page
163+
{
164+
$this->load();
165+
166+
return $this->registry->getResourceTemplates($limit, $cursor);
167+
}
168+
169+
public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference
170+
{
171+
$this->load();
172+
173+
return $this->registry->getResourceTemplate($uriTemplate);
174+
}
175+
176+
public function hasPrompts(): bool
177+
{
178+
$this->load();
179+
180+
return $this->registry->hasPrompts();
181+
}
182+
183+
public function getPrompts(?int $limit = null, ?string $cursor = null): Page
184+
{
185+
$this->load();
186+
187+
return $this->registry->getPrompts($limit, $cursor);
188+
}
189+
190+
public function getPrompt(string $name): PromptReference
191+
{
192+
$this->load();
193+
194+
return $this->registry->getPrompt($name);
195+
}
196+
197+
private function load(): void
198+
{
199+
if ($this->loaded) {
200+
return;
201+
}
202+
203+
$this->loader->load($this->registry);
204+
$this->loaded = true;
205+
}
206+
}

src/Server/Builder.php

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Mcp\Capability\Discovery\Discoverer;
1717
use Mcp\Capability\Discovery\DiscovererInterface;
1818
use Mcp\Capability\Discovery\SchemaGeneratorInterface;
19+
use Mcp\Capability\LazyRegistry;
1920
use Mcp\Capability\Registry;
2021
use Mcp\Capability\Registry\Container;
2122
use Mcp\Capability\Registry\ElementReference;
@@ -219,6 +220,8 @@ final class Builder
219220
*/
220221
private array $loaders = [];
221222

223+
private bool $hasCustomRegistry = false;
224+
222225
/**
223226
* Sets the server's identity. Required.
224227
*
@@ -344,6 +347,7 @@ public function addNotificationHandlers(iterable $handlers): self
344347
public function setRegistry(RegistryInterface $registry): self
345348
{
346349
$this->registry = $registry;
350+
$this->hasCustomRegistry = true;
347351

348352
return $this;
349353
}
@@ -674,18 +678,22 @@ public function build(): Server
674678
}
675679
}
676680

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

680684
$messageFactory = MessageFactory::make();
681685

686+
// Advertise from configured sources, not the now-lazy registry, so initialize does not force a load.
687+
// Opaque sources (custom loaders, discovery, a caller registry) advertise all kinds; over-advertising is harmless.
688+
$hasOpaqueSources = [] !== $this->loaders || null !== $this->discoveryBasePath || $this->hasCustomRegistry;
689+
682690
$capabilities = $this->serverCapabilities ?? new ServerCapabilities(
683-
tools: $registry->hasTools(),
691+
tools: [] !== $this->tools || [] !== $this->explicitTools || $hasOpaqueSources,
684692
toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
685-
resources: $registry->hasResources() || $registry->hasResourceTemplates(),
686-
resourcesSubscribe: $registry->hasResources() || $registry->hasResourceTemplates(),
693+
resources: [] !== $this->resources || [] !== $this->explicitResources || [] !== $this->resourceTemplates || [] !== $this->explicitResourceTemplates || $hasOpaqueSources,
694+
resourcesSubscribe: [] !== $this->resources || [] !== $this->explicitResources || [] !== $this->resourceTemplates || [] !== $this->explicitResourceTemplates || $hasOpaqueSources,
687695
resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
688-
prompts: $registry->hasPrompts(),
696+
prompts: [] !== $this->prompts || [] !== $this->explicitPrompts || $hasOpaqueSources,
689697
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
690698
logging: true,
691699
completions: true,
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Tests\Unit\Capability;
13+
14+
use Mcp\Capability\LazyRegistry;
15+
use Mcp\Capability\Registry;
16+
use Mcp\Capability\Registry\Loader\LoaderInterface;
17+
use Mcp\Capability\RegistryInterface;
18+
use Mcp\Schema\Tool;
19+
use PHPUnit\Framework\TestCase;
20+
21+
class LazyRegistryTest extends TestCase
22+
{
23+
public function testLoaderIsNotRunUntilFirstRead(): void
24+
{
25+
$loader = $this->createMock(LoaderInterface::class);
26+
$loader->expects($this->never())->method('load');
27+
28+
// Constructing (and registering) must not trigger the loader.
29+
$registry = new LazyRegistry(new Registry(), $loader);
30+
$registry->registerTool($this->tool('manual'), 'handler');
31+
}
32+
33+
public function testLoaderRunsOnFirstReadAndPopulatesTheRegistry(): void
34+
{
35+
$inner = new Registry();
36+
$loader = new class($this->tool('loaded')) implements LoaderInterface {
37+
public function __construct(private readonly Tool $tool)
38+
{
39+
}
40+
41+
public function load(RegistryInterface $registry): void
42+
{
43+
$registry->registerTool($this->tool, 'handler');
44+
}
45+
};
46+
47+
$registry = new LazyRegistry($inner, $loader);
48+
49+
$this->assertTrue($registry->hasTools());
50+
$tools = $registry->getTools()->references;
51+
$this->assertArrayHasKey('loaded', $tools);
52+
}
53+
54+
public function testLoaderRunsExactlyOnceAcrossManyReads(): void
55+
{
56+
$loader = $this->createMock(LoaderInterface::class);
57+
$loader->expects($this->once())->method('load');
58+
59+
$registry = new LazyRegistry(new Registry(), $loader);
60+
$registry->hasTools();
61+
$registry->getTools();
62+
$registry->hasResources();
63+
$registry->getPrompts();
64+
}
65+
66+
public function testRuntimeRegistrationsSurviveTheDeferredLoad(): void
67+
{
68+
$inner = new Registry();
69+
$loader = new class($this->tool('loaded')) implements LoaderInterface {
70+
public function __construct(private readonly Tool $tool)
71+
{
72+
}
73+
74+
public function load(RegistryInterface $registry): void
75+
{
76+
$registry->registerTool($this->tool, 'handler');
77+
}
78+
};
79+
80+
$registry = new LazyRegistry($inner, $loader);
81+
// Registered before the first read; the deferred load must be additive, not replacing.
82+
$registry->registerTool($this->tool('runtime'), 'handler');
83+
84+
$tools = $registry->getTools()->references;
85+
$this->assertArrayHasKey('runtime', $tools);
86+
$this->assertArrayHasKey('loaded', $tools);
87+
}
88+
89+
public function testLoaderRetriesAfterAFailedLoad(): void
90+
{
91+
$inner = new Registry();
92+
$loader = new class($this->tool('loaded')) implements LoaderInterface {
93+
private int $calls = 0;
94+
95+
public function __construct(private readonly Tool $tool)
96+
{
97+
}
98+
99+
public function load(RegistryInterface $registry): void
100+
{
101+
++$this->calls;
102+
if (1 === $this->calls) {
103+
throw new \RuntimeException('data source not ready');
104+
}
105+
106+
$registry->registerTool($this->tool, 'handler');
107+
}
108+
};
109+
110+
$registry = new LazyRegistry($inner, $loader);
111+
112+
try {
113+
$registry->hasTools();
114+
$this->fail('Expected the first load to throw.');
115+
} catch (\RuntimeException $e) {
116+
$this->assertSame('data source not ready', $e->getMessage());
117+
}
118+
119+
$tools = $registry->getTools()->references;
120+
$this->assertArrayHasKey('loaded', $tools);
121+
}
122+
123+
private function tool(string $name): Tool
124+
{
125+
return new Tool($name, null, ['type' => 'object', 'properties' => [], 'required' => null], null, null);
126+
}
127+
}

0 commit comments

Comments
 (0)