[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:
Antoine Bluchet 2026-07-14 23:55:31 +02:00 • committed by GitHub
commit ff91ca6d60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -11,7 +11,9 @@
namespace Mcp\Tests\Unit\Server;
use Mcp\Capability\Registry;
use Mcp\Capability\Registry\ElementReference;
use Mcp\Capability\Registry\Loader\LoaderInterface;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Exception\LogicException;
use Mcp\Schema\Content\TextContent;
@ -19,6 +21,7 @@ use Mcp\Schema\Extension\Apps\McpApps;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\ServerCapabilities;
use Mcp\Schema\Tool;
use Mcp\Server;
use Mcp\Server\Handler\Request\CallToolHandler;
use Mcp\Server\Handler\Request\InitializeHandler;
@ -141,6 +144,68 @@ final class BuilderTest extends TestCase
$this->assertArrayHasKey(McpApps::EXTENSION_ID, $capabilities->extensions);
}
#[TestDox('build() advertises tools capability for a pre-populated registry set via setRegistry()')]
public function testBuildAdvertisesToolsForPreloadedCustomRegistry(): void
{
$registry = new Registry();
$registry->registerTool(
new Tool(name: 'test_tool', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: 'A test tool', annotations: null),
static fn (): string => 'result',
);
$server = Server::builder()
->setServerInfo('test', '1.0.0')
->setRegistry($registry)
->build();
$capabilities = $this->extractServerCapabilities($server);
$this->assertTrue($capabilities->tools);
}
#[TestDox('setLazyLoading() returns the builder for fluent chaining')]
public function testSetLazyLoadingReturnsSelf(): void
{
$builder = Server::builder();
$this->assertSame($builder, $builder->setLazyLoading(false));
}
#[TestDox('Lazy loading (default) advertises tools from configured sources without running loaders')]
public function testLazyLoadingAdvertisesFromConfiguredSourcesWithoutLoading(): void
{
$loader = $this->createMock(LoaderInterface::class);
$loader->expects($this->never())->method('load');
$server = Server::builder()
->setServerInfo('test', '1.0.0')
->addLoader($loader)
->build();
$capabilities = $this->extractServerCapabilities($server);
// A custom loader is opaque, so its presence advertises tools even though it never ran.
$this->assertTrue($capabilities->tools);
}
#[TestDox('Eager loading runs the loaders at build time and advertises from the loaded registry')]
public function testEagerLoadingAdvertisesFromLoadedRegistry(): void
{
$loader = $this->createMock(LoaderInterface::class);
$loader->expects($this->once())->method('load');
$server = Server::builder()
->setServerInfo('test', '1.0.0')
->setLazyLoading(false)
->addLoader($loader)
->build();
$capabilities = $this->extractServerCapabilities($server);
// The loader ran but registered nothing, so the loaded registry advertises no tools.
$this->assertFalse($capabilities->tools);
}
private function extractServerCapabilities(Server $server): ServerCapabilities
{
$protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server);