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.
176 lines
9 KiB
PHP
176 lines
9 KiB
PHP
<?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\Completion\EnumCompletionProvider;
|
|
use Mcp\Capability\Completion\ListCompletionProvider;
|
|
use Mcp\Capability\Discovery\Discoverer;
|
|
use Mcp\Capability\Registry\ToolReference;
|
|
use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture;
|
|
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\AlternativeFileNameToolHandler;
|
|
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\DiscoverableToolHandler;
|
|
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocablePromptFixture;
|
|
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocableResourceFixture;
|
|
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocableResourceTemplateFixture;
|
|
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocableToolFixture;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class DiscoveryTest extends TestCase
|
|
{
|
|
private Discoverer $discoverer;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->discoverer = new Discoverer();
|
|
}
|
|
|
|
public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void
|
|
{
|
|
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']);
|
|
|
|
$tools = $discovery->getTools();
|
|
$this->assertCount(5, $tools);
|
|
|
|
$this->assertArrayHasKey('greet_user', $tools);
|
|
$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);
|
|
$this->assertArrayHasKey('name', $tools['greet_user']->tool->inputSchema['properties'] ?? []);
|
|
|
|
$this->assertArrayHasKey('repeatAction', $tools);
|
|
$this->assertEquals('A tool with more complex parameters and inferred name/description.', $tools['repeatAction']->tool->description);
|
|
$this->assertTrue($tools['repeatAction']->tool->annotations->readOnlyHint);
|
|
$this->assertEquals(['count', 'loudly', 'mode'], array_keys($tools['repeatAction']->tool->inputSchema['properties'] ?? []));
|
|
|
|
$this->assertArrayHasKey('InvokableCalculator', $tools);
|
|
$this->assertInstanceOf(ToolReference::class, $tools['InvokableCalculator']);
|
|
$this->assertEquals([InvocableToolFixture::class, '__invoke'], $tools['InvokableCalculator']->handler);
|
|
|
|
$this->assertArrayHasKey('inc_file_name_tool', $tools);
|
|
$this->assertEquals([AlternativeFileNameToolHandler::class, 'run'], $tools['inc_file_name_tool']->handler);
|
|
|
|
$this->assertArrayNotHasKey('private_tool_should_be_ignored', $tools);
|
|
$this->assertArrayNotHasKey('protected_tool_should_be_ignored', $tools);
|
|
$this->assertArrayNotHasKey('static_tool_should_be_ignored', $tools);
|
|
|
|
$resources = $discovery->getResources();
|
|
$this->assertCount(3, $resources);
|
|
|
|
$this->assertArrayHasKey('app://info/version', $resources);
|
|
$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->assertEquals([InvocableResourceFixture::class, '__invoke'], $resources['invokable://config/status']->handler);
|
|
|
|
$prompts = $discovery->getPrompts();
|
|
$this->assertCount(4, $prompts);
|
|
|
|
$this->assertArrayHasKey('creative_story_prompt', $prompts);
|
|
$this->assertCount(2, $prompts['creative_story_prompt']->prompt->arguments);
|
|
$this->assertEquals(CompletionProviderFixture::class, $prompts['creative_story_prompt']->completionProviders['genre']);
|
|
|
|
$this->assertArrayHasKey('simpleQuestionPrompt', $prompts);
|
|
|
|
$this->assertArrayHasKey('InvokableGreeterPrompt', $prompts);
|
|
$this->assertEquals([InvocablePromptFixture::class, '__invoke'], $prompts['InvokableGreeterPrompt']->handler);
|
|
|
|
$this->assertArrayHasKey('content_creator', $prompts);
|
|
$this->assertCount(3, $prompts['content_creator']->completionProviders);
|
|
|
|
$templates = $discovery->getResourceTemplates();
|
|
$this->assertCount(4, $templates);
|
|
|
|
$this->assertArrayHasKey('product://{region}/details/{productId}', $templates);
|
|
$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->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $templates['invokable://user-profile/{userId}']->handler);
|
|
}
|
|
|
|
public function testDoesNotDiscoverElementsFromExcludedDirectories(): void
|
|
{
|
|
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures']);
|
|
$this->assertArrayHasKey('hidden_subdir_tool', $discovery->getTools());
|
|
|
|
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], ['SubDir']);
|
|
$this->assertArrayNotHasKey('hidden_subdir_tool', $discovery->getTools());
|
|
}
|
|
|
|
public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles(): void
|
|
{
|
|
$discovery = $this->discoverer->discover(__DIR__, ['EmptyDir']);
|
|
|
|
$this->assertTrue($discovery->isEmpty());
|
|
}
|
|
|
|
public function testHandlesDefaultAndOverriddenFileNamePatterns(): void
|
|
{
|
|
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures']);
|
|
$this->assertArrayHasKey('greet_user', $discovery->getTools());
|
|
$this->assertArrayNotHasKey('inc_file_name_tool', $discovery->getTools());
|
|
|
|
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], []);
|
|
$this->assertArrayHasKey('greet_user', $discovery->getTools());
|
|
$this->assertArrayNotHasKey('inc_file_name_tool', $discovery->getTools());
|
|
|
|
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']);
|
|
$this->assertArrayHasKey('greet_user', $discovery->getTools());
|
|
$this->assertArrayHasKey('inc_file_name_tool', $discovery->getTools());
|
|
|
|
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.inc']);
|
|
$this->assertArrayNotHasKey('greet_user', $discovery->getTools());
|
|
$this->assertArrayHasKey('inc_file_name_tool', $discovery->getTools());
|
|
}
|
|
|
|
public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute(): void
|
|
{
|
|
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures']);
|
|
|
|
$this->assertArrayHasKey('repeatAction', $tools = $discovery->getTools());
|
|
$this->assertEquals('repeatAction', $tools['repeatAction']->tool->name);
|
|
$this->assertEquals('A tool with more complex parameters and inferred name/description.', $tools['repeatAction']->tool->description);
|
|
|
|
$this->assertArrayHasKey('simpleQuestionPrompt', $prompts = $discovery->getPrompts());
|
|
$this->assertEquals('simpleQuestionPrompt', $prompts['simpleQuestionPrompt']->prompt->name);
|
|
$this->assertNull($prompts['simpleQuestionPrompt']->prompt->description);
|
|
|
|
$this->assertArrayHasKey('InvokableCalculator', $tools);
|
|
$this->assertEquals('InvokableCalculator', $tools['InvokableCalculator']->tool->name);
|
|
$this->assertEquals('An invokable calculator tool.', $tools['InvokableCalculator']->tool->description);
|
|
}
|
|
|
|
public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttributes(): void
|
|
{
|
|
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures']);
|
|
|
|
$this->assertArrayHasKey('content_creator', $prompts = $discovery->getPrompts());
|
|
$this->assertCount(3, $prompts['content_creator']->completionProviders);
|
|
|
|
$typeProvider = $prompts['content_creator']->completionProviders['type'];
|
|
$this->assertInstanceOf(ListCompletionProvider::class, $typeProvider);
|
|
|
|
$statusProvider = $prompts['content_creator']->completionProviders['status'];
|
|
$this->assertInstanceOf(EnumCompletionProvider::class, $statusProvider);
|
|
|
|
$priorityProvider = $prompts['content_creator']->completionProviders['priority'];
|
|
$this->assertInstanceOf(EnumCompletionProvider::class, $priorityProvider);
|
|
|
|
$this->assertArrayHasKey('content://{category}/{slug}', $templates = $discovery->getResourceTemplates());
|
|
$this->assertCount(1, $templates['content://{category}/{slug}']->completionProviders);
|
|
|
|
$categoryProvider = $templates['content://{category}/{slug}']->completionProviders['category'];
|
|
$this->assertInstanceOf(ListCompletionProvider::class, $categoryProvider);
|
|
}
|
|
}
|