Skip to content

Commit 0da4ccf

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. Advertise capabilities from the configured element sources instead of the loaded registry, so the initialize handshake does not force an eager load.
1 parent 30e520a commit 0da4ccf

3 files changed

Lines changed: 322 additions & 6 deletions

File tree

src/Capability/LazyRegistry.php

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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+
* Running loaders in {@see \Mcp\Server\Builder::build()} freezes the registry to whatever the
29+
* loaders can see at build time. Under a persistent runtime (e.g. FrankenPHP worker mode) the
30+
* server is built once, so if a loader's data source is not ready at that moment — a cold cache,
31+
* a not-yet-warmed metadata layer — the registry captures an empty state and never recovers.
32+
*
33+
* Deferring the load to the first read moves it to request time, when the application is fully
34+
* initialized. The load runs once per process; subsequent reads hit the populated inner registry.
35+
* Registrations are delegated straight through, so runtime registrations made before the first
36+
* read are preserved (the loader's registrations are additive/idempotent by name).
37+
*
38+
* @author Antoine Bluchet <soyuka@gmail.com>
39+
*/
40+
final class LazyRegistry implements RegistryInterface
41+
{
42+
private bool $loaded = false;
43+
44+
public function __construct(
45+
private readonly RegistryInterface $registry,
46+
private readonly LoaderInterface $loader,
47+
) {
48+
}
49+
50+
public function registerTool(Tool $tool, callable|array|string $handler): ToolReference
51+
{
52+
return $this->registry->registerTool($tool, $handler);
53+
}
54+
55+
public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference
56+
{
57+
return $this->registry->registerResource($resource, $handler);
58+
}
59+
60+
public function registerResourceTemplate(ResourceTemplate $template, callable|array|string $handler, array $completionProviders = []): ResourceTemplateReference
61+
{
62+
return $this->registry->registerResourceTemplate($template, $handler, $completionProviders);
63+
}
64+
65+
public function registerPrompt(Prompt $prompt, callable|array|string $handler, array $completionProviders = []): PromptReference
66+
{
67+
return $this->registry->registerPrompt($prompt, $handler, $completionProviders);
68+
}
69+
70+
public function unregisterTool(string $name): void
71+
{
72+
$this->registry->unregisterTool($name);
73+
}
74+
75+
public function unregisterResource(string $uri): void
76+
{
77+
$this->registry->unregisterResource($uri);
78+
}
79+
80+
public function unregisterResourceTemplate(string $uriTemplate): void
81+
{
82+
$this->registry->unregisterResourceTemplate($uriTemplate);
83+
}
84+
85+
public function unregisterPrompt(string $name): void
86+
{
87+
$this->registry->unregisterPrompt($name);
88+
}
89+
90+
public function hasTool(string $name): bool
91+
{
92+
$this->load();
93+
94+
return $this->registry->hasTool($name);
95+
}
96+
97+
public function hasResource(string $uri): bool
98+
{
99+
$this->load();
100+
101+
return $this->registry->hasResource($uri);
102+
}
103+
104+
public function hasResourceTemplate(string $uriTemplate): bool
105+
{
106+
$this->load();
107+
108+
return $this->registry->hasResourceTemplate($uriTemplate);
109+
}
110+
111+
public function hasPrompt(string $name): bool
112+
{
113+
$this->load();
114+
115+
return $this->registry->hasPrompt($name);
116+
}
117+
118+
public function hasTools(): bool
119+
{
120+
$this->load();
121+
122+
return $this->registry->hasTools();
123+
}
124+
125+
public function getTools(?int $limit = null, ?string $cursor = null): Page
126+
{
127+
$this->load();
128+
129+
return $this->registry->getTools($limit, $cursor);
130+
}
131+
132+
public function getTool(string $name): ToolReference
133+
{
134+
$this->load();
135+
136+
return $this->registry->getTool($name);
137+
}
138+
139+
public function hasResources(): bool
140+
{
141+
$this->load();
142+
143+
return $this->registry->hasResources();
144+
}
145+
146+
public function getResources(?int $limit = null, ?string $cursor = null): Page
147+
{
148+
$this->load();
149+
150+
return $this->registry->getResources($limit, $cursor);
151+
}
152+
153+
public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference
154+
{
155+
$this->load();
156+
157+
return $this->registry->getResource($uri, $includeTemplates);
158+
}
159+
160+
public function hasResourceTemplates(): bool
161+
{
162+
$this->load();
163+
164+
return $this->registry->hasResourceTemplates();
165+
}
166+
167+
public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page
168+
{
169+
$this->load();
170+
171+
return $this->registry->getResourceTemplates($limit, $cursor);
172+
}
173+
174+
public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference
175+
{
176+
$this->load();
177+
178+
return $this->registry->getResourceTemplate($uriTemplate);
179+
}
180+
181+
public function hasPrompts(): bool
182+
{
183+
$this->load();
184+
185+
return $this->registry->hasPrompts();
186+
}
187+
188+
public function getPrompts(?int $limit = null, ?string $cursor = null): Page
189+
{
190+
$this->load();
191+
192+
return $this->registry->getPrompts($limit, $cursor);
193+
}
194+
195+
public function getPrompt(string $name): PromptReference
196+
{
197+
$this->load();
198+
199+
return $this->registry->getPrompt($name);
200+
}
201+
202+
private function load(): void
203+
{
204+
if ($this->loaded) {
205+
return;
206+
}
207+
208+
$this->loaded = true;
209+
$this->loader->load($this->registry);
210+
}
211+
}

src/Server/Builder.php

Lines changed: 18 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;
@@ -674,18 +675,29 @@ public function build(): Server
674675
}
675676
}
676677

677-
$loader = new ChainLoader($loaders);
678-
$loader->load($registry);
678+
// Load lazily, on the first registry read, instead of eagerly here. Eager loading freezes
679+
// the registry to what the loaders see at build time; under a persistent runtime (e.g.
680+
// FrankenPHP worker mode) the server is built once, so a loader whose data source is not
681+
// yet ready at build (cold cache, un-warmed metadata) would leave the registry empty for
682+
// the whole process. Deferring to request time avoids that. @see LazyRegistry
683+
$registry = new LazyRegistry($registry, new ChainLoader($loaders));
679684

680685
$messageFactory = MessageFactory::make();
681686

687+
// Capabilities are advertised from the configured element sources rather than from the
688+
// (now lazily loaded) registry, so the initialize handshake does not force an eager load.
689+
// Custom loaders and discovery are opaque about which element kinds they yield, so their
690+
// presence advertises tools, resources and prompts; over-advertising is harmless (a client
691+
// simply lists and gets an empty result).
692+
$hasOpaqueSources = [] !== $this->loaders || null !== $this->discoveryBasePath;
693+
682694
$capabilities = $this->serverCapabilities ?? new ServerCapabilities(
683-
tools: $registry->hasTools(),
695+
tools: [] !== $this->tools || [] !== $this->explicitTools || $hasOpaqueSources,
684696
toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
685-
resources: $registry->hasResources() || $registry->hasResourceTemplates(),
686-
resourcesSubscribe: $registry->hasResources() || $registry->hasResourceTemplates(),
697+
resources: [] !== $this->resources || [] !== $this->explicitResources || [] !== $this->resourceTemplates || [] !== $this->explicitResourceTemplates || $hasOpaqueSources,
698+
resourcesSubscribe: [] !== $this->resources || [] !== $this->explicitResources || [] !== $this->resourceTemplates || [] !== $this->explicitResourceTemplates || $hasOpaqueSources,
687699
resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
688-
prompts: $registry->hasPrompts(),
700+
prompts: [] !== $this->prompts || [] !== $this->explicitPrompts || $hasOpaqueSources,
689701
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
690702
logging: true,
691703
completions: true,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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+
private function tool(string $name): Tool
90+
{
91+
return new Tool($name, null, ['type' => 'object', 'properties' => [], 'required' => null], null, null);
92+
}
93+
}

0 commit comments

Comments
 (0)