[Server] Untangle origin tracking from Registry (#302)
Drop the isManual flag from ElementReference and the discovery-state methods from RegistryInterface. The Registry becomes a flat last-write-wins map; DiscoveryLoader owns its own owned-set bookkeeping so re-running discovery only removes elements it itself contributed. Adds ChainLoader for explicit composition. Manual-over-discovered precedence is preserved via loader ordering, not a flag on the reference.
This commit is contained in:
parent
e00450a218
commit
967c5cda4d
1 changed files with 1003 additions and 338 deletions
284
tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php
Normal file
284
tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
<?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\Registry\Loader;
|
||||
|
||||
use Mcp\Capability\Completion\EnumCompletionProvider;
|
||||
use Mcp\Capability\Completion\ListCompletionProvider;
|
||||
use Mcp\Capability\Discovery\DiscoveryState;
|
||||
use Mcp\Capability\Registry;
|
||||
use Mcp\Capability\Registry\Loader\DiscoveryLoader;
|
||||
use Mcp\Capability\Registry\PromptReference;
|
||||
use Mcp\Capability\Registry\ResourceReference;
|
||||
use Mcp\Capability\Registry\ResourceTemplateReference;
|
||||
use Mcp\Capability\Registry\ToolReference;
|
||||
use Mcp\Exception\PromptNotFoundException;
|
||||
use Mcp\Exception\ResourceNotFoundException;
|
||||
use Mcp\Exception\ToolNotFoundException;
|
||||
use Mcp\Schema\Prompt;
|
||||
use Mcp\Schema\Resource;
|
||||
use Mcp\Schema\ResourceTemplate;
|
||||
use Mcp\Schema\Tool;
|
||||
use Mcp\Tests\Unit\Capability\Registry\Loader\Stub\MutableDiscoverer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DiscoveryLoaderTest extends TestCase
|
||||
{
|
||||
private Registry $registry;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->registry = new Registry();
|
||||
}
|
||||
|
||||
public function testLoadRegistersAllDiscoveredElements(): void
|
||||
{
|
||||
$loader = new DiscoveryLoader('/base', [], [], new MutableDiscoverer(new DiscoveryState(
|
||||
tools: ['t1' => new ToolReference($this->makeTool('t1'), static fn () => 't1')],
|
||||
resources: ['r://1' => new ResourceReference($this->makeResource('r://1'), static fn () => 'r1')],
|
||||
prompts: ['p1' => new PromptReference($this->makePrompt('p1'), static fn () => [])],
|
||||
resourceTemplates: ['t://{id}' => new ResourceTemplateReference($this->makeTemplate('t://{id}'), static fn () => 'tpl')],
|
||||
)));
|
||||
|
||||
$loader->load($this->registry);
|
||||
|
||||
$this->assertInstanceOf(ToolReference::class, $this->registry->getTool('t1'));
|
||||
$this->assertInstanceOf(ResourceReference::class, $this->registry->getResource('r://1', false));
|
||||
$this->assertInstanceOf(PromptReference::class, $this->registry->getPrompt('p1'));
|
||||
$this->assertInstanceOf(ResourceTemplateReference::class, $this->registry->getResourceTemplate('t://{id}'));
|
||||
}
|
||||
|
||||
public function testLoadTwiceUnregistersStaleAndKeepsNew(): void
|
||||
{
|
||||
$discoverer = new MutableDiscoverer(new DiscoveryState(
|
||||
tools: ['t1' => new ToolReference($this->makeTool('t1'), static fn () => 't1')],
|
||||
resources: ['r://1' => new ResourceReference($this->makeResource('r://1'), static fn () => 'r1')],
|
||||
));
|
||||
$loader = new DiscoveryLoader('/base', [], [], $discoverer);
|
||||
|
||||
$loader->load($this->registry);
|
||||
|
||||
// Second discovery: t1 is gone, t2 appears; resource r://1 still present, new r://2 added.
|
||||
$discoverer->state = new DiscoveryState(
|
||||
tools: ['t2' => new ToolReference($this->makeTool('t2'), static fn () => 't2')],
|
||||
resources: [
|
||||
'r://1' => new ResourceReference($this->makeResource('r://1'), static fn () => 'r1-updated'),
|
||||
'r://2' => new ResourceReference($this->makeResource('r://2'), static fn () => 'r2'),
|
||||
],
|
||||
);
|
||||
$loader->load($this->registry);
|
||||
|
||||
$this->assertInstanceOf(ToolReference::class, $this->registry->getTool('t2'));
|
||||
$this->assertInstanceOf(ResourceReference::class, $this->registry->getResource('r://2', false));
|
||||
|
||||
$updatedResource = $this->registry->getResource('r://1', false);
|
||||
$this->assertInstanceOf(ResourceReference::class, $updatedResource);
|
||||
$this->assertSame('r1-updated', ($updatedResource->handler)());
|
||||
|
||||
$this->expectException(ToolNotFoundException::class);
|
||||
$this->registry->getTool('t1');
|
||||
}
|
||||
|
||||
public function testLoadTwiceUnregistersStalePromptsAndTemplates(): void
|
||||
{
|
||||
$discoverer = new MutableDiscoverer(new DiscoveryState(
|
||||
prompts: [
|
||||
'p1' => new PromptReference($this->makePrompt('p1'), static fn () => []),
|
||||
'p2' => new PromptReference($this->makePrompt('p2'), static fn () => []),
|
||||
],
|
||||
resourceTemplates: [
|
||||
't1://{id}' => new ResourceTemplateReference($this->makeTemplate('t1://{id}'), static fn () => 'tpl1'),
|
||||
't2://{id}' => new ResourceTemplateReference($this->makeTemplate('t2://{id}'), static fn () => 'tpl2'),
|
||||
],
|
||||
));
|
||||
$loader = new DiscoveryLoader('/base', [], [], $discoverer);
|
||||
$loader->load($this->registry);
|
||||
|
||||
// Second discovery: drop p1 and t1, keep p2 and t2, add p3 and t3.
|
||||
$discoverer->state = new DiscoveryState(
|
||||
prompts: [
|
||||
'p2' => new PromptReference($this->makePrompt('p2'), static fn () => []),
|
||||
'p3' => new PromptReference($this->makePrompt('p3'), static fn () => []),
|
||||
],
|
||||
resourceTemplates: [
|
||||
't2://{id}' => new ResourceTemplateReference($this->makeTemplate('t2://{id}'), static fn () => 'tpl2'),
|
||||
't3://{id}' => new ResourceTemplateReference($this->makeTemplate('t3://{id}'), static fn () => 'tpl3'),
|
||||
],
|
||||
);
|
||||
$loader->load($this->registry);
|
||||
|
||||
$this->assertInstanceOf(PromptReference::class, $this->registry->getPrompt('p2'));
|
||||
$this->assertInstanceOf(PromptReference::class, $this->registry->getPrompt('p3'));
|
||||
$this->assertInstanceOf(ResourceTemplateReference::class, $this->registry->getResourceTemplate('t2://{id}'));
|
||||
$this->assertInstanceOf(ResourceTemplateReference::class, $this->registry->getResourceTemplate('t3://{id}'));
|
||||
|
||||
$missing = 0;
|
||||
try {
|
||||
$this->registry->getPrompt('p1');
|
||||
} catch (PromptNotFoundException) {
|
||||
++$missing;
|
||||
}
|
||||
try {
|
||||
$this->registry->getResourceTemplate('t1://{id}');
|
||||
} catch (ResourceNotFoundException) {
|
||||
++$missing;
|
||||
}
|
||||
$this->assertSame(2, $missing);
|
||||
}
|
||||
|
||||
public function testLoadOverwritesPreviousRegistrationOnSameKey(): void
|
||||
{
|
||||
$discoverer = new MutableDiscoverer(new DiscoveryState(
|
||||
tools: ['t' => new ToolReference($this->makeTool('t'), static fn () => 'v1')],
|
||||
prompts: ['p' => new PromptReference($this->makePrompt('p'), static fn () => [], ['arg' => EnumCompletionProvider::class])],
|
||||
));
|
||||
$loader = new DiscoveryLoader('/base', [], [], $discoverer);
|
||||
$loader->load($this->registry);
|
||||
|
||||
// Same names, different handlers / completion providers.
|
||||
$discoverer->state = new DiscoveryState(
|
||||
tools: ['t' => new ToolReference($this->makeTool('t'), static fn () => 'v2')],
|
||||
prompts: ['p' => new PromptReference($this->makePrompt('p'), static fn () => [], ['arg' => ListCompletionProvider::class])],
|
||||
);
|
||||
$loader->load($this->registry);
|
||||
|
||||
$this->assertSame('v2', ($this->registry->getTool('t')->handler)());
|
||||
$this->assertSame(['arg' => ListCompletionProvider::class], $this->registry->getPrompt('p')->completionProviders);
|
||||
}
|
||||
|
||||
public function testLoadPreservesConflictingRuntimeRegistration(): void
|
||||
{
|
||||
// Application registers a tool directly.
|
||||
$this->registry->registerTool($this->makeTool('shared'), static fn () => 'runtime');
|
||||
|
||||
// Discovery later finds the same name. The manual registration wins —
|
||||
// discovery does not clobber entries it doesn't own.
|
||||
$discoverer = new MutableDiscoverer(new DiscoveryState(
|
||||
tools: ['shared' => new ToolReference($this->makeTool('shared'), static fn () => 'discovered')],
|
||||
));
|
||||
(new DiscoveryLoader('/base', [], [], $discoverer))->load($this->registry);
|
||||
|
||||
$this->assertSame('runtime', ($this->registry->getTool('shared')->handler)());
|
||||
}
|
||||
|
||||
public function testLoadPreservesRuntimeOverrideOfPreviouslyOwnedEntry(): void
|
||||
{
|
||||
// First discovery owns 'shared'.
|
||||
$discoverer = new MutableDiscoverer(new DiscoveryState(
|
||||
tools: ['shared' => new ToolReference($this->makeTool('shared'), static fn () => 'discovered-v1')],
|
||||
));
|
||||
$loader = new DiscoveryLoader('/base', [], [], $discoverer);
|
||||
$loader->load($this->registry);
|
||||
|
||||
// Developer overrides at runtime.
|
||||
$this->registry->registerTool($this->makeTool('shared'), static fn () => 'runtime');
|
||||
|
||||
// Rediscovery still finds 'shared'; loader sees the registry no longer holds its instance and steps aside.
|
||||
$discoverer->state = new DiscoveryState(
|
||||
tools: ['shared' => new ToolReference($this->makeTool('shared'), static fn () => 'discovered-v2')],
|
||||
);
|
||||
$loader->load($this->registry);
|
||||
|
||||
$this->assertSame('runtime', ($this->registry->getTool('shared')->handler)());
|
||||
}
|
||||
|
||||
public function testLoadDoesNotUnregisterRuntimeAdditions(): void
|
||||
{
|
||||
$discoverer = new MutableDiscoverer(new DiscoveryState(
|
||||
tools: ['discovered_tool' => new ToolReference($this->makeTool('discovered_tool'), static fn () => 'discovered')],
|
||||
));
|
||||
$loader = new DiscoveryLoader('/base', [], [], $discoverer);
|
||||
|
||||
$loader->load($this->registry);
|
||||
|
||||
// Application registers a tool directly between two discovery runs.
|
||||
$this->registry->registerTool($this->makeTool('runtime_tool'), static fn () => 'runtime');
|
||||
|
||||
// Second discovery run with a different state. The runtime tool must survive.
|
||||
$discoverer->state = new DiscoveryState(
|
||||
tools: ['discovered_tool_v2' => new ToolReference($this->makeTool('discovered_tool_v2'), static fn () => 'v2')],
|
||||
);
|
||||
$loader->load($this->registry);
|
||||
|
||||
$this->assertInstanceOf(ToolReference::class, $this->registry->getTool('runtime_tool'));
|
||||
$this->assertInstanceOf(ToolReference::class, $this->registry->getTool('discovered_tool_v2'));
|
||||
|
||||
$this->expectException(ToolNotFoundException::class);
|
||||
$this->registry->getTool('discovered_tool');
|
||||
}
|
||||
|
||||
public function testEmptySecondLoadUnregistersAllPreviouslyDiscovered(): void
|
||||
{
|
||||
$discoverer = new MutableDiscoverer(new DiscoveryState(
|
||||
tools: ['t' => new ToolReference($this->makeTool('t'), static fn () => 't')],
|
||||
resources: ['r://x' => new ResourceReference($this->makeResource('r://x'), static fn () => 'rx')],
|
||||
prompts: ['p' => new PromptReference($this->makePrompt('p'), static fn () => [])],
|
||||
resourceTemplates: ['x://{id}' => new ResourceTemplateReference($this->makeTemplate('x://{id}'), static fn () => 'tpl')],
|
||||
));
|
||||
$loader = new DiscoveryLoader('/base', [], [], $discoverer);
|
||||
$loader->load($this->registry);
|
||||
|
||||
$discoverer->state = new DiscoveryState();
|
||||
$loader->load($this->registry);
|
||||
|
||||
$exceptions = 0;
|
||||
try {
|
||||
$this->registry->getTool('t');
|
||||
} catch (ToolNotFoundException) {
|
||||
++$exceptions;
|
||||
}
|
||||
try {
|
||||
$this->registry->getResource('r://x', false);
|
||||
} catch (ResourceNotFoundException) {
|
||||
++$exceptions;
|
||||
}
|
||||
try {
|
||||
$this->registry->getPrompt('p');
|
||||
} catch (PromptNotFoundException) {
|
||||
++$exceptions;
|
||||
}
|
||||
try {
|
||||
$this->registry->getResourceTemplate('x://{id}');
|
||||
} catch (ResourceNotFoundException) {
|
||||
++$exceptions;
|
||||
}
|
||||
$this->assertSame(4, $exceptions);
|
||||
}
|
||||
|
||||
private function makeTool(string $name): Tool
|
||||
{
|
||||
return new Tool(
|
||||
name: $name,
|
||||
title: null,
|
||||
inputSchema: ['type' => 'object', 'properties' => [], 'required' => null],
|
||||
description: null,
|
||||
annotations: null,
|
||||
icons: null,
|
||||
meta: null,
|
||||
outputSchema: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function makeResource(string $uri): Resource
|
||||
{
|
||||
return new Resource(uri: $uri, name: 'r', description: null, mimeType: 'text/plain');
|
||||
}
|
||||
|
||||
private function makePrompt(string $name): Prompt
|
||||
{
|
||||
return new Prompt(name: $name, description: null, arguments: []);
|
||||
}
|
||||
|
||||
private function makeTemplate(string $uriTemplate): ResourceTemplate
|
||||
{
|
||||
return new ResourceTemplate(uriTemplate: $uriTemplate, name: 'tpl', description: null, mimeType: 'text/plain');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue