[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:
parent
e4a8a224ff
commit
ff91ca6d60
1 changed files with 315 additions and 19 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue