[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:
Christopher Hertel 2026-05-17 14:03:31 +02:00 • committed by GitHub
commit 967c5cda4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 1003 additions and 338 deletions

View 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,
);
}
}

View file

@ -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);
}