[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
8 changed files with 1003 additions and 338 deletions
164
tests/Unit/Capability/Discovery/DiscoveryStateTest.php
Normal file
164
tests/Unit/Capability/Discovery/DiscoveryStateTest.php
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<?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\Discovery;
|
||||
|
||||
use Mcp\Capability\Discovery\DiscoveryState;
|
||||
use Mcp\Capability\Registry\PromptReference;
|
||||
use Mcp\Capability\Registry\ResourceReference;
|
||||
use Mcp\Capability\Registry\ResourceTemplateReference;
|
||||
use Mcp\Capability\Registry\ToolReference;
|
||||
use Mcp\Schema\Prompt;
|
||||
use Mcp\Schema\Resource;
|
||||
use Mcp\Schema\ResourceTemplate;
|
||||
use Mcp\Schema\Tool;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DiscoveryStateTest extends TestCase
|
||||
{
|
||||
public function testObsoletedByReturnsEntriesAbsentFromNext(): void
|
||||
{
|
||||
$t1 = $this->tool('t1');
|
||||
$t2 = $this->tool('t2');
|
||||
|
||||
$owned = new DiscoveryState(
|
||||
tools: ['t1' => $t1, 't2' => $t2],
|
||||
);
|
||||
$next = new DiscoveryState(
|
||||
tools: ['t2' => $this->tool('t2'), 't3' => $this->tool('t3')],
|
||||
);
|
||||
|
||||
$obsolete = $owned->obsoletedBy($next);
|
||||
|
||||
$this->assertSame(['t1' => $t1], $obsolete->getTools());
|
||||
}
|
||||
|
||||
public function testObsoletedByIsAsymmetricAndIgnoresValuesOnSharedKeys(): void
|
||||
{
|
||||
// Same key, different reference instance — must NOT be reported as obsolete.
|
||||
$owned = new DiscoveryState(
|
||||
tools: ['t' => $this->tool('t')],
|
||||
);
|
||||
$next = new DiscoveryState(
|
||||
tools: ['t' => $this->tool('t')],
|
||||
);
|
||||
|
||||
$this->assertTrue($owned->obsoletedBy($next)->isEmpty());
|
||||
}
|
||||
|
||||
public function testObsoletedByOnEmptyNextReturnsAllOwned(): void
|
||||
{
|
||||
$owned = new DiscoveryState(
|
||||
tools: ['t' => $this->tool('t')],
|
||||
resources: ['r://x' => $this->resource('r://x')],
|
||||
prompts: ['p' => $this->prompt('p')],
|
||||
resourceTemplates: ['x://{id}' => $this->template('x://{id}')],
|
||||
);
|
||||
|
||||
$obsolete = $owned->obsoletedBy(new DiscoveryState());
|
||||
|
||||
$this->assertSame(['t'], array_keys($obsolete->getTools()));
|
||||
$this->assertSame(['r://x'], array_keys($obsolete->getResources()));
|
||||
$this->assertSame(['p'], array_keys($obsolete->getPrompts()));
|
||||
$this->assertSame(['x://{id}'], array_keys($obsolete->getResourceTemplates()));
|
||||
}
|
||||
|
||||
public function testObsoletedByOnEmptyOwnedReturnsEmpty(): void
|
||||
{
|
||||
$next = new DiscoveryState(
|
||||
tools: ['t' => $this->tool('t')],
|
||||
);
|
||||
|
||||
$this->assertTrue((new DiscoveryState())->obsoletedBy($next)->isEmpty());
|
||||
}
|
||||
|
||||
public function testObsoletedByKeepsKindsIndependent(): void
|
||||
{
|
||||
// A key shared across different kinds must not cancel out.
|
||||
$owned = new DiscoveryState(
|
||||
tools: ['shared' => $this->tool('shared')],
|
||||
prompts: ['shared' => $this->prompt('shared')],
|
||||
);
|
||||
$next = new DiscoveryState(
|
||||
tools: ['shared' => $this->tool('shared')],
|
||||
// 'shared' prompt is gone.
|
||||
);
|
||||
|
||||
$obsolete = $owned->obsoletedBy($next);
|
||||
|
||||
$this->assertSame([], $obsolete->getTools());
|
||||
$this->assertSame(['shared'], array_keys($obsolete->getPrompts()));
|
||||
}
|
||||
|
||||
public function testObsoletedByAcrossAllKindsAtOnce(): void
|
||||
{
|
||||
$owned = new DiscoveryState(
|
||||
tools: ['keep_t' => $this->tool('keep_t'), 'drop_t' => $this->tool('drop_t')],
|
||||
resources: ['r://keep' => $this->resource('r://keep'), 'r://drop' => $this->resource('r://drop')],
|
||||
prompts: ['keep_p' => $this->prompt('keep_p'), 'drop_p' => $this->prompt('drop_p')],
|
||||
resourceTemplates: ['keep://{id}' => $this->template('keep://{id}'), 'drop://{id}' => $this->template('drop://{id}')],
|
||||
);
|
||||
$next = new DiscoveryState(
|
||||
tools: ['keep_t' => $this->tool('keep_t')],
|
||||
resources: ['r://keep' => $this->resource('r://keep')],
|
||||
prompts: ['keep_p' => $this->prompt('keep_p')],
|
||||
resourceTemplates: ['keep://{id}' => $this->template('keep://{id}')],
|
||||
);
|
||||
|
||||
$obsolete = $owned->obsoletedBy($next);
|
||||
|
||||
$this->assertSame(['drop_t'], array_keys($obsolete->getTools()));
|
||||
$this->assertSame(['r://drop'], array_keys($obsolete->getResources()));
|
||||
$this->assertSame(['drop_p'], array_keys($obsolete->getPrompts()));
|
||||
$this->assertSame(['drop://{id}'], array_keys($obsolete->getResourceTemplates()));
|
||||
}
|
||||
|
||||
private function tool(string $name): ToolReference
|
||||
{
|
||||
return new ToolReference(
|
||||
new Tool(
|
||||
name: $name,
|
||||
title: null,
|
||||
inputSchema: ['type' => 'object', 'properties' => [], 'required' => null],
|
||||
description: null,
|
||||
annotations: null,
|
||||
icons: null,
|
||||
meta: null,
|
||||
outputSchema: null,
|
||||
),
|
||||
static fn () => null,
|
||||
);
|
||||
}
|
||||
|
||||
private function resource(string $uri): ResourceReference
|
||||
{
|
||||
return new ResourceReference(
|
||||
new Resource(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'),
|
||||
static fn () => null,
|
||||
);
|
||||
}
|
||||
|
||||
private function prompt(string $name): PromptReference
|
||||
{
|
||||
return new PromptReference(
|
||||
new Prompt(name: $name, description: null, arguments: []),
|
||||
static fn () => [],
|
||||
);
|
||||
}
|
||||
|
||||
private function template(string $uriTemplate): ResourceTemplateReference
|
||||
{
|
||||
return new ResourceTemplateReference(
|
||||
new ResourceTemplate(uriTemplate: $uriTemplate, name: 'tpl', description: null, mimeType: 'text/plain'),
|
||||
static fn () => null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,6 @@ class DiscoveryTest extends TestCase
|
|||
$this->assertCount(5, $tools);
|
||||
|
||||
$this->assertArrayHasKey('greet_user', $tools);
|
||||
$this->assertFalse($tools['greet_user']->isManual);
|
||||
$this->assertEquals('greet_user', $tools['greet_user']->tool->name);
|
||||
$this->assertEquals('Greets a user by name.', $tools['greet_user']->tool->description);
|
||||
$this->assertEquals([DiscoverableToolHandler::class, 'greet'], $tools['greet_user']->handler);
|
||||
|
|
@ -54,7 +53,6 @@ class DiscoveryTest extends TestCase
|
|||
|
||||
$this->assertArrayHasKey('InvokableCalculator', $tools);
|
||||
$this->assertInstanceOf(ToolReference::class, $tools['InvokableCalculator']);
|
||||
$this->assertFalse($tools['InvokableCalculator']->isManual);
|
||||
$this->assertEquals([InvocableToolFixture::class, '__invoke'], $tools['InvokableCalculator']->handler);
|
||||
|
||||
$this->assertArrayHasKey('inc_file_name_tool', $tools);
|
||||
|
|
@ -68,44 +66,36 @@ class DiscoveryTest extends TestCase
|
|||
$this->assertCount(3, $resources);
|
||||
|
||||
$this->assertArrayHasKey('app://info/version', $resources);
|
||||
$this->assertFalse($resources['app://info/version']->isManual);
|
||||
$this->assertEquals('app_version', $resources['app://info/version']->resource->name);
|
||||
$this->assertEquals('text/plain', $resources['app://info/version']->resource->mimeType);
|
||||
|
||||
$this->assertArrayHasKey('invokable://config/status', $resources);
|
||||
$this->assertFalse($resources['invokable://config/status']->isManual);
|
||||
$this->assertEquals([InvocableResourceFixture::class, '__invoke'], $resources['invokable://config/status']->handler);
|
||||
|
||||
$prompts = $discovery->getPrompts();
|
||||
$this->assertCount(4, $prompts);
|
||||
|
||||
$this->assertArrayHasKey('creative_story_prompt', $prompts);
|
||||
$this->assertFalse($prompts['creative_story_prompt']->isManual);
|
||||
$this->assertCount(2, $prompts['creative_story_prompt']->prompt->arguments);
|
||||
$this->assertEquals(CompletionProviderFixture::class, $prompts['creative_story_prompt']->completionProviders['genre']);
|
||||
|
||||
$this->assertArrayHasKey('simpleQuestionPrompt', $prompts);
|
||||
$this->assertFalse($prompts['simpleQuestionPrompt']->isManual);
|
||||
|
||||
$this->assertArrayHasKey('InvokableGreeterPrompt', $prompts);
|
||||
$this->assertFalse($prompts['InvokableGreeterPrompt']->isManual);
|
||||
$this->assertEquals([InvocablePromptFixture::class, '__invoke'], $prompts['InvokableGreeterPrompt']->handler);
|
||||
|
||||
$this->assertArrayHasKey('content_creator', $prompts);
|
||||
$this->assertFalse($prompts['content_creator']->isManual);
|
||||
$this->assertCount(3, $prompts['content_creator']->completionProviders);
|
||||
|
||||
$templates = $discovery->getResourceTemplates();
|
||||
$this->assertCount(4, $templates);
|
||||
|
||||
$this->assertArrayHasKey('product://{region}/details/{productId}', $templates);
|
||||
$this->assertFalse($templates['product://{region}/details/{productId}']->isManual);
|
||||
$this->assertEquals('product_details_template', $templates['product://{region}/details/{productId}']->resourceTemplate->name);
|
||||
$this->assertEquals(CompletionProviderFixture::class, $templates['product://{region}/details/{productId}']->completionProviders['region']);
|
||||
$this->assertEqualsCanonicalizing(['region', 'productId'], $templates['product://{region}/details/{productId}']->getVariableNames());
|
||||
|
||||
$this->assertArrayHasKey('invokable://user-profile/{userId}', $templates);
|
||||
$this->assertFalse($templates['invokable://user-profile/{userId}']->isManual);
|
||||
$this->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $templates['invokable://user-profile/{userId}']->handler);
|
||||
}
|
||||
|
||||
|
|
|
|||
57
tests/Unit/Capability/Registry/Loader/ChainLoaderTest.php
Normal file
57
tests/Unit/Capability/Registry/Loader/ChainLoaderTest.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?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\Registry;
|
||||
use Mcp\Capability\Registry\Loader\ChainLoader;
|
||||
use Mcp\Tests\Unit\Capability\Registry\Loader\Stub\RecordingLoader;
|
||||
use Mcp\Tests\Unit\Capability\Registry\Loader\Stub\ToolWriterLoader;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ChainLoaderTest extends TestCase
|
||||
{
|
||||
public function testInvokesChildLoadersInOrder(): void
|
||||
{
|
||||
$calls = new \ArrayObject();
|
||||
$a = new RecordingLoader('A', $calls);
|
||||
$b = new RecordingLoader('B', $calls);
|
||||
$c = new RecordingLoader('C', $calls);
|
||||
|
||||
(new ChainLoader([$a, $b, $c]))->load(new Registry());
|
||||
|
||||
$this->assertSame(['A', 'B', 'C'], $calls->getArrayCopy());
|
||||
}
|
||||
|
||||
public function testLastWriterWinsForConflictingKeys(): void
|
||||
{
|
||||
$registry = new Registry();
|
||||
|
||||
$first = new ToolWriterLoader('shared', static fn () => 'first');
|
||||
$second = new ToolWriterLoader('shared', static fn () => 'second');
|
||||
|
||||
(new ChainLoader([$first, $second]))->load($registry);
|
||||
|
||||
$this->assertSame('second', ($registry->getTool('shared')->handler)());
|
||||
}
|
||||
|
||||
public function testEmptyChainIsNoop(): void
|
||||
{
|
||||
$registry = new Registry();
|
||||
|
||||
(new ChainLoader([]))->load($registry);
|
||||
|
||||
$this->assertFalse($registry->hasTools());
|
||||
$this->assertFalse($registry->hasResources());
|
||||
$this->assertFalse($registry->hasResourceTemplates());
|
||||
$this->assertFalse($registry->hasPrompts());
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?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\Stub;
|
||||
|
||||
use Mcp\Capability\Discovery\DiscovererInterface;
|
||||
use Mcp\Capability\Discovery\DiscoveryState;
|
||||
|
||||
final class MutableDiscoverer implements DiscovererInterface
|
||||
{
|
||||
public function __construct(public DiscoveryState $state)
|
||||
{
|
||||
}
|
||||
|
||||
public function discover(string $basePath, array $directories, array $excludeDirs = [], array $namePatterns = self::DEFAULT_NAME_PATERNS): DiscoveryState
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?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\Stub;
|
||||
|
||||
use Mcp\Capability\Registry\Loader\LoaderInterface;
|
||||
use Mcp\Capability\RegistryInterface;
|
||||
|
||||
final class RecordingLoader implements LoaderInterface
|
||||
{
|
||||
/**
|
||||
* @param \ArrayObject<int, string> $calls
|
||||
*/
|
||||
public function __construct(private string $name, private \ArrayObject $calls)
|
||||
{
|
||||
}
|
||||
|
||||
public function load(RegistryInterface $registry): void
|
||||
{
|
||||
$this->calls->append($this->name);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?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\Stub;
|
||||
|
||||
use Mcp\Capability\Registry\Loader\LoaderInterface;
|
||||
use Mcp\Capability\RegistryInterface;
|
||||
use Mcp\Schema\Tool;
|
||||
|
||||
final class ToolWriterLoader implements LoaderInterface
|
||||
{
|
||||
public function __construct(private string $toolName, private \Closure $handler)
|
||||
{
|
||||
}
|
||||
|
||||
public function load(RegistryInterface $registry): void
|
||||
{
|
||||
$registry->registerTool(new Tool(
|
||||
name: $this->toolName,
|
||||
title: null,
|
||||
inputSchema: ['type' => 'object', 'properties' => [], 'required' => null],
|
||||
description: null,
|
||||
annotations: null,
|
||||
icons: null,
|
||||
meta: null,
|
||||
outputSchema: null,
|
||||
), $this->handler);
|
||||
}
|
||||
}
|
||||
|
|
@ -82,48 +82,18 @@ class RegistryTest extends TestCase
|
|||
$this->assertInstanceOf(ToolReference::class, $toolRef);
|
||||
$this->assertEquals($tool->name, $toolRef->tool->name);
|
||||
$this->assertEquals($handler, $toolRef->handler);
|
||||
$this->assertFalse($toolRef->isManual);
|
||||
}
|
||||
|
||||
public function testRegisterToolWithManualFlag(): void
|
||||
public function testRegisterToolOverwritesPriorRegistration(): void
|
||||
{
|
||||
$tool = $this->createValidTool('test_tool');
|
||||
$handler = static fn () => 'result';
|
||||
$first = $this->createValidTool('test_tool');
|
||||
$second = $this->createValidTool('test_tool');
|
||||
|
||||
$this->registry->registerTool($tool, $handler, true);
|
||||
$this->registry->registerTool($first, static fn () => 'first');
|
||||
$this->registry->registerTool($second, static fn () => 'second');
|
||||
|
||||
$toolRef = $this->registry->getTool('test_tool');
|
||||
$this->assertTrue($toolRef->isManual);
|
||||
}
|
||||
|
||||
public function testRegisterToolIgnoresDiscoveredWhenManualExists(): void
|
||||
{
|
||||
$manualTool = $this->createValidTool('test_tool');
|
||||
$discoveredTool = $this->createValidTool('test_tool');
|
||||
|
||||
$this->registry->registerTool($manualTool, static fn () => 'manual', true);
|
||||
|
||||
$this->logger
|
||||
->expects($this->once())
|
||||
->method('debug')
|
||||
->with('Ignoring discovered tool "test_tool" as it conflicts with a manually registered one.');
|
||||
|
||||
$this->registry->registerTool($discoveredTool, static fn () => 'discovered');
|
||||
|
||||
$toolRef = $this->registry->getTool('test_tool');
|
||||
$this->assertTrue($toolRef->isManual);
|
||||
}
|
||||
|
||||
public function testRegisterToolOverridesDiscoveredWithManual(): void
|
||||
{
|
||||
$discoveredTool = $this->createValidTool('test_tool');
|
||||
$manualTool = $this->createValidTool('test_tool');
|
||||
|
||||
$this->registry->registerTool($discoveredTool, static fn () => 'discovered');
|
||||
$this->registry->registerTool($manualTool, static fn () => 'manual', true);
|
||||
|
||||
$toolRef = $this->registry->getTool('test_tool');
|
||||
$this->assertTrue($toolRef->isManual);
|
||||
$this->assertEquals('second', ($toolRef->handler)());
|
||||
}
|
||||
|
||||
public function testGetToolThrowsExceptionForUnregisteredTool(): void
|
||||
|
|
@ -169,36 +139,18 @@ class RegistryTest extends TestCase
|
|||
$this->assertInstanceOf(ResourceReference::class, $resourceRef);
|
||||
$this->assertEquals($resource->uri, $resourceRef->resource->uri);
|
||||
$this->assertEquals($handler, $resourceRef->handler);
|
||||
$this->assertFalse($resourceRef->isManual);
|
||||
}
|
||||
|
||||
public function testRegisterResourceWithManualFlag(): void
|
||||
public function testRegisterResourceOverwritesPriorRegistration(): void
|
||||
{
|
||||
$resource = $this->createValidResource('test://resource');
|
||||
$handler = static fn () => 'content';
|
||||
$first = $this->createValidResource('test://resource');
|
||||
$second = $this->createValidResource('test://resource');
|
||||
|
||||
$this->registry->registerResource($resource, $handler, true);
|
||||
$this->registry->registerResource($first, static fn () => 'first');
|
||||
$this->registry->registerResource($second, static fn () => 'second');
|
||||
|
||||
$resourceRef = $this->registry->getResource('test://resource');
|
||||
$this->assertTrue($resourceRef->isManual);
|
||||
}
|
||||
|
||||
public function testRegisterResourceIgnoresDiscoveredWhenManualExists(): void
|
||||
{
|
||||
$manualResource = $this->createValidResource('test://resource');
|
||||
$discoveredResource = $this->createValidResource('test://resource');
|
||||
|
||||
$this->registry->registerResource($manualResource, static fn () => 'manual', true);
|
||||
|
||||
$this->logger
|
||||
->expects($this->once())
|
||||
->method('debug')
|
||||
->with('Ignoring discovered resource "test://resource" as it conflicts with a manually registered one.');
|
||||
|
||||
$this->registry->registerResource($discoveredResource, static fn () => 'discovered');
|
||||
|
||||
$resourceRef = $this->registry->getResource('test://resource');
|
||||
$this->assertTrue($resourceRef->isManual);
|
||||
$this->assertEquals('second', ($resourceRef->handler)());
|
||||
}
|
||||
|
||||
public function testGetResourceThrowsExceptionForUnregisteredResource(): void
|
||||
|
|
@ -244,7 +196,6 @@ class RegistryTest extends TestCase
|
|||
$this->assertInstanceOf(ResourceTemplateReference::class, $templateRef);
|
||||
$this->assertEquals($template->uriTemplate, $templateRef->resourceTemplate->uriTemplate);
|
||||
$this->assertEquals($handler, $templateRef->handler);
|
||||
$this->assertFalse($templateRef->isManual);
|
||||
}
|
||||
|
||||
public function testGetResourcePrefersDirectResourceOverTemplate(): void
|
||||
|
|
@ -300,22 +251,16 @@ class RegistryTest extends TestCase
|
|||
$this->assertEquals($completionProviders, $templateRef->completionProviders);
|
||||
}
|
||||
|
||||
public function testRegisterResourceTemplateIgnoresDiscoveredWhenManualExists(): void
|
||||
public function testRegisterResourceTemplateOverwritesPriorRegistration(): void
|
||||
{
|
||||
$manualTemplate = $this->createValidResourceTemplate('test://{id}');
|
||||
$discoveredTemplate = $this->createValidResourceTemplate('test://{id}');
|
||||
$first = $this->createValidResourceTemplate('test://{id}');
|
||||
$second = $this->createValidResourceTemplate('test://{id}');
|
||||
|
||||
$this->registry->registerResourceTemplate($manualTemplate, static fn () => 'manual', [], true);
|
||||
|
||||
$this->logger
|
||||
->expects($this->once())
|
||||
->method('debug')
|
||||
->with('Ignoring discovered template "test://{id}" as it conflicts with a manually registered one.');
|
||||
|
||||
$this->registry->registerResourceTemplate($discoveredTemplate, static fn () => 'discovered');
|
||||
$this->registry->registerResourceTemplate($first, static fn () => 'first');
|
||||
$this->registry->registerResourceTemplate($second, static fn () => 'second');
|
||||
|
||||
$templateRef = $this->registry->getResourceTemplate('test://{id}');
|
||||
$this->assertTrue($templateRef->isManual);
|
||||
$this->assertEquals('second', ($templateRef->handler)());
|
||||
}
|
||||
|
||||
public function testResourceTemplateMatchingPrefersMoreSpecificMatches(): void
|
||||
|
|
@ -375,7 +320,6 @@ class RegistryTest extends TestCase
|
|||
$this->assertInstanceOf(PromptReference::class, $promptRef);
|
||||
$this->assertEquals($prompt->name, $promptRef->prompt->name);
|
||||
$this->assertEquals($handler, $promptRef->handler);
|
||||
$this->assertFalse($promptRef->isManual);
|
||||
}
|
||||
|
||||
public function testRegisterPromptWithCompletionProviders(): void
|
||||
|
|
@ -389,22 +333,16 @@ class RegistryTest extends TestCase
|
|||
$this->assertEquals($completionProviders, $promptRef->completionProviders);
|
||||
}
|
||||
|
||||
public function testRegisterPromptIgnoresDiscoveredWhenManualExists(): void
|
||||
public function testRegisterPromptOverwritesPriorRegistration(): void
|
||||
{
|
||||
$manualPrompt = $this->createValidPrompt('test_prompt');
|
||||
$discoveredPrompt = $this->createValidPrompt('test_prompt');
|
||||
$first = $this->createValidPrompt('test_prompt');
|
||||
$second = $this->createValidPrompt('test_prompt');
|
||||
|
||||
$this->registry->registerPrompt($manualPrompt, static fn () => 'manual', [], true);
|
||||
|
||||
$this->logger
|
||||
->expects($this->once())
|
||||
->method('debug')
|
||||
->with('Ignoring discovered prompt "test_prompt" as it conflicts with a manually registered one.');
|
||||
|
||||
$this->registry->registerPrompt($discoveredPrompt, static fn () => 'discovered');
|
||||
$this->registry->registerPrompt($first, static fn () => 'first');
|
||||
$this->registry->registerPrompt($second, static fn () => 'second');
|
||||
|
||||
$promptRef = $this->registry->getPrompt('test_prompt');
|
||||
$this->assertTrue($promptRef->isManual);
|
||||
$this->assertEquals('second', ($promptRef->handler)());
|
||||
}
|
||||
|
||||
public function testGetPromptThrowsExceptionForUnregisteredPrompt(): void
|
||||
|
|
@ -415,70 +353,55 @@ class RegistryTest extends TestCase
|
|||
$this->registry->getPrompt('non_existent_prompt');
|
||||
}
|
||||
|
||||
public function testClearRemovesOnlyDiscoveredElements(): void
|
||||
public function testUnregisterToolRemovesRegisteredTool(): void
|
||||
{
|
||||
$manualTool = $this->createValidTool('manual_tool');
|
||||
$discoveredTool = $this->createValidTool('discovered_tool');
|
||||
$manualResource = $this->createValidResource('test://manual');
|
||||
$discoveredResource = $this->createValidResource('test://discovered');
|
||||
$manualPrompt = $this->createValidPrompt('manual_prompt');
|
||||
$discoveredPrompt = $this->createValidPrompt('discovered_prompt');
|
||||
$manualTemplate = $this->createValidResourceTemplate('manual://{id}');
|
||||
$discoveredTemplate = $this->createValidResourceTemplate('discovered://{id}');
|
||||
$tool = $this->createValidTool('test_tool');
|
||||
$this->registry->registerTool($tool, static fn () => 'result');
|
||||
|
||||
$this->registry->registerTool($manualTool, static fn () => 'manual', true);
|
||||
$this->registry->registerTool($discoveredTool, static fn () => 'discovered');
|
||||
$this->registry->registerResource($manualResource, static fn () => 'manual', true);
|
||||
$this->registry->registerResource($discoveredResource, static fn () => 'discovered');
|
||||
$this->registry->registerPrompt($manualPrompt, static fn () => [], [], true);
|
||||
$this->registry->registerPrompt($discoveredPrompt, static fn () => []);
|
||||
$this->registry->registerResourceTemplate($manualTemplate, static fn () => 'manual', [], true);
|
||||
$this->registry->registerResourceTemplate($discoveredTemplate, static fn () => 'discovered');
|
||||
$this->registry->unregisterTool('test_tool');
|
||||
|
||||
// Test that all elements exist
|
||||
$this->registry->getTool('manual_tool');
|
||||
$this->registry->getResource('test://manual');
|
||||
$this->registry->getPrompt('manual_prompt');
|
||||
$this->registry->getResourceTemplate('manual://{id}');
|
||||
$this->registry->getTool('discovered_tool');
|
||||
$this->registry->getResource('test://discovered');
|
||||
$this->registry->getPrompt('discovered_prompt');
|
||||
$this->registry->getResourceTemplate('discovered://{id}');
|
||||
|
||||
$this->registry->clear();
|
||||
|
||||
// Manual elements should still exist
|
||||
$this->registry->getTool('manual_tool');
|
||||
$this->registry->getResource('test://manual');
|
||||
$this->registry->getPrompt('manual_prompt');
|
||||
$this->registry->getResourceTemplate('manual://{id}');
|
||||
|
||||
// Test that all discovered elements throw exceptions
|
||||
$this->expectException(ToolNotFoundException::class);
|
||||
$this->registry->getTool('discovered_tool');
|
||||
|
||||
$this->expectException(ResourceNotFoundException::class);
|
||||
$this->registry->getResource('test://discovered');
|
||||
|
||||
$this->expectException(PromptNotFoundException::class);
|
||||
$this->registry->getPrompt('discovered_prompt');
|
||||
|
||||
$this->expectException(ResourceNotFoundException::class);
|
||||
$this->registry->getResourceTemplate('discovered://{id}');
|
||||
$this->registry->getTool('test_tool');
|
||||
}
|
||||
|
||||
public function testClearLogsNothingWhenNoDiscoveredElements(): void
|
||||
public function testUnregisterToolIsIdempotentForAbsentName(): void
|
||||
{
|
||||
$manualTool = $this->createValidTool('manual_tool');
|
||||
$this->registry->registerTool($manualTool, static fn () => 'manual', true);
|
||||
$this->registry->unregisterTool('never_registered');
|
||||
|
||||
$this->logger
|
||||
->expects($this->never())
|
||||
->method('debug');
|
||||
$this->assertFalse($this->registry->hasTools());
|
||||
}
|
||||
|
||||
$this->registry->clear();
|
||||
public function testUnregisterResourceRemovesRegisteredResource(): void
|
||||
{
|
||||
$resource = $this->createValidResource('test://resource');
|
||||
$this->registry->registerResource($resource, static fn () => 'content');
|
||||
|
||||
$this->registry->getTool('manual_tool');
|
||||
$this->registry->unregisterResource('test://resource');
|
||||
|
||||
$this->expectException(ResourceNotFoundException::class);
|
||||
$this->registry->getResource('test://resource', false);
|
||||
}
|
||||
|
||||
public function testUnregisterResourceTemplateRemovesRegisteredTemplate(): void
|
||||
{
|
||||
$template = $this->createValidResourceTemplate('test://{id}');
|
||||
$this->registry->registerResourceTemplate($template, static fn () => 'content');
|
||||
|
||||
$this->registry->unregisterResourceTemplate('test://{id}');
|
||||
|
||||
$this->expectException(ResourceNotFoundException::class);
|
||||
$this->registry->getResourceTemplate('test://{id}');
|
||||
}
|
||||
|
||||
public function testUnregisterPromptRemovesRegisteredPrompt(): void
|
||||
{
|
||||
$prompt = $this->createValidPrompt('test_prompt');
|
||||
$this->registry->registerPrompt($prompt, static fn () => []);
|
||||
|
||||
$this->registry->unregisterPrompt('test_prompt');
|
||||
|
||||
$this->expectException(PromptNotFoundException::class);
|
||||
$this->registry->getPrompt('test_prompt');
|
||||
}
|
||||
|
||||
public function testRegisterToolHandlesStringHandler(): void
|
||||
|
|
|
|||
Loading…
Reference in a new issue