Introduce Capability Registry & Discovery

This commit is contained in:
Christopher Hertel 2025-08-09 00:36:43 +02:00
commit da0d5a87a0
No known key found for this signature in database
GPG key ID: 111F6BFF7B6B5AA3
31 changed files with 7063 additions and 8 deletions

View file

@ -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\Capability\Attribute;
use Mcp\Capability\Prompt\Completion\ProviderInterface;
class CompletionProviderFixture implements ProviderInterface
{
/**
* @var string[]
*/
public static array $completions = ['alpha', 'beta', 'gamma'];
public static string $lastCurrentValue = '';
public function getCompletions(string $currentValue): array
{
self::$lastCurrentValue = $currentValue;
return array_filter(self::$completions, fn ($item) => str_starts_with($item, $currentValue));
}
}

View file

@ -0,0 +1,85 @@
<?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\Capability\Attribute;
use Mcp\Capability\Attribute\CompletionProvider;
use Mcp\Tests\Fixtures\Enum\StatusEnum;
use PHPUnit\Framework\TestCase;
class CompletionProviderTest extends TestCase
{
public function testCanBeConstructedWithProviderClass(): void
{
$attribute = new CompletionProvider(provider: CompletionProviderFixture::class);
$this->assertSame(CompletionProviderFixture::class, $attribute->provider);
$this->assertNull($attribute->values);
$this->assertNull($attribute->enum);
}
public function testCanBeConstructedWithProviderInstance(): void
{
$instance = new CompletionProviderFixture();
$attribute = new CompletionProvider(provider: $instance);
$this->assertSame($instance, $attribute->provider);
$this->assertNull($attribute->values);
$this->assertNull($attribute->enum);
}
public function testCanBeConstructedWithValuesArray(): void
{
$values = ['draft', 'published', 'archived'];
$attribute = new CompletionProvider(values: $values);
$this->assertNull($attribute->provider);
$this->assertSame($values, $attribute->values);
$this->assertNull($attribute->enum);
}
public function testCanBeConstructedWithEnumClass(): void
{
$attribute = new CompletionProvider(enum: StatusEnum::class);
$this->assertNull($attribute->provider);
$this->assertNull($attribute->values);
$this->assertSame(StatusEnum::class, $attribute->enum);
}
public function testThrowsExceptionWhenNoParametersProvided(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only one of provider, values, or enum can be set');
new CompletionProvider();
}
public function testThrowsExceptionWhenMultipleParametersProvided(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only one of provider, values, or enum can be set');
new CompletionProvider(
provider: CompletionProviderFixture::class,
values: ['test']
);
}
public function testThrowsExceptionWhenAllParametersProvided(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only one of provider, values, or enum can be set');
new CompletionProvider(
provider: CompletionProviderFixture::class,
values: ['test'],
enum: StatusEnum::class
);
}
}

View file

@ -0,0 +1,52 @@
<?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\Capability\Attribute;
use Mcp\Capability\Attribute\McpPrompt;
use PHPUnit\Framework\TestCase;
class McpPromptTest extends TestCase
{
public function testInstantiatesWithNameAndDescription(): void
{
// Arrange
$name = 'test-prompt-name';
$description = 'This is a test prompt description.';
// Act
$attribute = new McpPrompt(name: $name, description: $description);
// Assert
$this->assertSame($name, $attribute->name);
$this->assertSame($description, $attribute->description);
}
public function testInstantiatesWithNullValuesForNameAndDescription(): void
{
// Arrange & Act
$attribute = new McpPrompt(name: null, description: null);
// Assert
$this->assertNull($attribute->name);
$this->assertNull($attribute->description);
}
public function testInstantiatesWithMissingOptionalArguments(): void
{
// Arrange & Act
$attribute = new McpPrompt(); // Use default constructor values
// Assert
$this->assertNull($attribute->name);
$this->assertNull($attribute->description);
}
}

View file

@ -0,0 +1,71 @@
<?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\Capability\Attribute;
use Mcp\Capability\Attribute\McpResourceTemplate;
use PHPUnit\Framework\TestCase;
class McpResourceTemplateTest extends TestCase
{
public function testInstantiatesWithCorrectProperties(): void
{
// Arrange
$uriTemplate = 'file:///{path}/data';
$name = 'test-template-name';
$description = 'This is a test template description.';
$mimeType = 'application/json';
// Act
$attribute = new McpResourceTemplate(
uriTemplate: $uriTemplate,
name: $name,
description: $description,
mimeType: $mimeType,
);
// Assert
$this->assertSame($uriTemplate, $attribute->uriTemplate);
$this->assertSame($name, $attribute->name);
$this->assertSame($description, $attribute->description);
$this->assertSame($mimeType, $attribute->mimeType);
}
public function testInstantiatesWithNullValuesForNameAndDescription(): void
{
// Arrange & Act
$attribute = new McpResourceTemplate(
uriTemplate: 'test://{id}', // uriTemplate is required
name: null,
description: null,
mimeType: null,
);
// Assert
$this->assertSame('test://{id}', $attribute->uriTemplate);
$this->assertNull($attribute->name);
$this->assertNull($attribute->description);
$this->assertNull($attribute->mimeType);
}
public function testInstantiatesWithMissingOptionalArguments(): void
{
// Arrange & Act
$uriTemplate = 'tmpl://{key}';
$attribute = new McpResourceTemplate(uriTemplate: $uriTemplate);
// Assert
$this->assertSame($uriTemplate, $attribute->uriTemplate);
$this->assertNull($attribute->name);
$this->assertNull($attribute->description);
$this->assertNull($attribute->mimeType);
}
}

View file

@ -0,0 +1,77 @@
<?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\Capability\Attribute;
use Mcp\Capability\Attribute\McpResource;
use PHPUnit\Framework\TestCase;
class McpResourceTest extends TestCase
{
public function testInstantiatesWithCorrectProperties(): void
{
// Arrange
$uri = 'file:///test/resource';
$name = 'test-resource-name';
$description = 'This is a test resource description.';
$mimeType = 'text/plain';
$size = 1024;
// Act
$attribute = new McpResource(
uri: $uri,
name: $name,
description: $description,
mimeType: $mimeType,
size: $size,
);
// Assert
$this->assertSame($uri, $attribute->uri);
$this->assertSame($name, $attribute->name);
$this->assertSame($description, $attribute->description);
$this->assertSame($mimeType, $attribute->mimeType);
$this->assertSame($size, $attribute->size);
}
public function testInstantiatesWithNullValuesForNameAndDescription(): void
{
// Arrange & Act
$attribute = new McpResource(
uri: 'file:///test', // URI is required
name: null,
description: null,
mimeType: null,
size: null,
);
// Assert
$this->assertSame('file:///test', $attribute->uri);
$this->assertNull($attribute->name);
$this->assertNull($attribute->description);
$this->assertNull($attribute->mimeType);
$this->assertNull($attribute->size);
}
public function testInstantiatesWithMissingOptionalArguments(): void
{
// Arrange & Act
$uri = 'file:///only-uri';
$attribute = new McpResource(uri: $uri);
// Assert
$this->assertSame($uri, $attribute->uri);
$this->assertNull($attribute->name);
$this->assertNull($attribute->description);
$this->assertNull($attribute->mimeType);
$this->assertNull($attribute->size);
}
}

View file

@ -0,0 +1,52 @@
<?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\Capability\Attribute;
use Mcp\Capability\Attribute\McpTool;
use PHPUnit\Framework\TestCase;
class McpToolTest extends TestCase
{
public function testInstantiatesWithCorrectProperties(): void
{
// Arrange
$name = 'test-tool-name';
$description = 'This is a test description.';
// Act
$attribute = new McpTool(name: $name, description: $description);
// Assert
$this->assertSame($name, $attribute->name);
$this->assertSame($description, $attribute->description);
}
public function testInstantiatesWithNullValuesForNameAndDescription(): void
{
// Arrange & Act
$attribute = new McpTool(name: null, description: null);
// Assert
$this->assertNull($attribute->name);
$this->assertNull($attribute->description);
}
public function testInstantiatesWithMissingOptionalArguments(): void
{
// Arrange & Act
$attribute = new McpTool(); // Use default constructor values
// Assert
$this->assertNull($attribute->name);
$this->assertNull($attribute->description);
}
}

View file

@ -0,0 +1,182 @@
<?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\Capability\Discovery;
use Mcp\Capability\Discovery\Discoverer;
use Mcp\Capability\Prompt\Completion\EnumCompletionProvider;
use Mcp\Capability\Prompt\Completion\ListCompletionProvider;
use Mcp\Capability\Registry;
use Mcp\Capability\Registry\RegisteredPrompt;
use Mcp\Capability\Registry\RegisteredResource;
use Mcp\Capability\Registry\RegisteredResourceTemplate;
use Mcp\Capability\Registry\RegisteredTool;
use Mcp\Tests\Capability\Attribute\CompletionProviderFixture;
use Mcp\Tests\Capability\Discovery\Fixtures\DiscoverableToolHandler;
use Mcp\Tests\Capability\Discovery\Fixtures\InvocablePromptFixture;
use Mcp\Tests\Capability\Discovery\Fixtures\InvocableResourceFixture;
use Mcp\Tests\Capability\Discovery\Fixtures\InvocableResourceTemplateFixture;
use Mcp\Tests\Capability\Discovery\Fixtures\InvocableToolFixture;
use PHPUnit\Framework\TestCase;
class DiscoveryTest extends TestCase
{
private Registry $registry;
private Discoverer $discoverer;
protected function setUp(): void
{
$this->registry = new Registry();
$this->discoverer = new Discoverer($this->registry);
}
public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles()
{
$this->discoverer->discover(__DIR__, ['Fixtures']);
$tools = $this->registry->getTools();
$this->assertCount(4, $tools);
$greetUserTool = $this->registry->getTool('greet_user');
$this->assertInstanceOf(RegisteredTool::class, $greetUserTool);
$this->assertFalse($greetUserTool->isManual);
$this->assertEquals('greet_user', $greetUserTool->tool->name);
$this->assertEquals('Greets a user by name.', $greetUserTool->tool->description);
$this->assertEquals([DiscoverableToolHandler::class, 'greet'], $greetUserTool->handler);
$this->assertArrayHasKey('name', $greetUserTool->tool->inputSchema['properties'] ?? []);
$repeatActionTool = $this->registry->getTool('repeatAction');
$this->assertInstanceOf(RegisteredTool::class, $repeatActionTool);
$this->assertEquals('A tool with more complex parameters and inferred name/description.', $repeatActionTool->tool->description);
$this->assertTrue($repeatActionTool->tool->annotations->readOnlyHint);
$this->assertEquals(['count', 'loudly', 'mode'], array_keys($repeatActionTool->tool->inputSchema['properties'] ?? []));
$invokableCalcTool = $this->registry->getTool('InvokableCalculator');
$this->assertInstanceOf(RegisteredTool::class, $invokableCalcTool);
$this->assertFalse($invokableCalcTool->isManual);
$this->assertEquals([InvocableToolFixture::class, '__invoke'], $invokableCalcTool->handler);
$this->assertNull($this->registry->getTool('private_tool_should_be_ignored'));
$this->assertNull($this->registry->getTool('protected_tool_should_be_ignored'));
$this->assertNull($this->registry->getTool('static_tool_should_be_ignored'));
$resources = $this->registry->getResources();
$this->assertCount(3, $resources);
$appVersionRes = $this->registry->getResource('app://info/version');
$this->assertInstanceOf(RegisteredResource::class, $appVersionRes);
$this->assertFalse($appVersionRes->isManual);
$this->assertEquals('app_version', $appVersionRes->schema->name);
$this->assertEquals('text/plain', $appVersionRes->schema->mimeType);
$invokableStatusRes = $this->registry->getResource('invokable://config/status');
$this->assertInstanceOf(RegisteredResource::class, $invokableStatusRes);
$this->assertFalse($invokableStatusRes->isManual);
$this->assertEquals([InvocableResourceFixture::class, '__invoke'], $invokableStatusRes->handler);
$prompts = $this->registry->getPrompts();
$this->assertCount(4, $prompts);
$storyPrompt = $this->registry->getPrompt('creative_story_prompt');
$this->assertInstanceOf(RegisteredPrompt::class, $storyPrompt);
$this->assertFalse($storyPrompt->isManual);
$this->assertCount(2, $storyPrompt->prompt->arguments);
$this->assertEquals(CompletionProviderFixture::class, $storyPrompt->completionProviders['genre']);
$simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt');
$this->assertInstanceOf(RegisteredPrompt::class, $simplePrompt);
$this->assertFalse($simplePrompt->isManual);
$invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt');
$this->assertInstanceOf(RegisteredPrompt::class, $invokableGreeter);
$this->assertFalse($invokableGreeter->isManual);
$this->assertEquals([InvocablePromptFixture::class, '__invoke'], $invokableGreeter->handler);
$contentCreatorPrompt = $this->registry->getPrompt('content_creator');
$this->assertInstanceOf(RegisteredPrompt::class, $contentCreatorPrompt);
$this->assertFalse($contentCreatorPrompt->isManual);
$this->assertCount(3, $contentCreatorPrompt->completionProviders);
$templates = $this->registry->getResourceTemplates();
$this->assertCount(4, $templates);
$productTemplate = $this->registry->getResourceTemplate('product://{region}/details/{productId}');
$this->assertInstanceOf(RegisteredResourceTemplate::class, $productTemplate);
$this->assertFalse($productTemplate->isManual);
$this->assertEquals('product_details_template', $productTemplate->resourceTemplate->name);
$this->assertEquals(CompletionProviderFixture::class, $productTemplate->completionProviders['region']);
$this->assertEqualsCanonicalizing(['region', 'productId'], $productTemplate->getVariableNames());
$invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}');
$this->assertInstanceOf(RegisteredResourceTemplate::class, $invokableUserTemplate);
$this->assertFalse($invokableUserTemplate->isManual);
$this->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $invokableUserTemplate->handler);
}
public function testDoesNotDiscoverElementsFromExcludedDirectories()
{
$this->discoverer->discover(__DIR__, ['Fixtures']);
$this->assertInstanceOf(RegisteredTool::class, $this->registry->getTool('hidden_subdir_tool'));
$this->registry->clear();
$this->discoverer->discover(__DIR__, ['Fixtures'], ['SubDir']);
$this->assertNull($this->registry->getTool('hidden_subdir_tool'));
}
public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles()
{
$this->discoverer->discover(__DIR__, ['EmptyDir']);
$this->assertEmpty($this->registry->getTools());
}
public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute()
{
$this->discoverer->discover(__DIR__, ['Fixtures']);
$repeatActionTool = $this->registry->getTool('repeatAction');
$this->assertEquals('repeatAction', $repeatActionTool->tool->name);
$this->assertEquals('A tool with more complex parameters and inferred name/description.', $repeatActionTool->tool->description);
$simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt');
$this->assertEquals('simpleQuestionPrompt', $simplePrompt->prompt->name);
$this->assertNull($simplePrompt->prompt->description);
$invokableCalc = $this->registry->getTool('InvokableCalculator');
$this->assertEquals('InvokableCalculator', $invokableCalc->tool->name);
$this->assertEquals('An invokable calculator tool.', $invokableCalc->tool->description);
}
public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttributes()
{
$this->discoverer->discover(__DIR__, ['Fixtures']);
$contentPrompt = $this->registry->getPrompt('content_creator');
$this->assertInstanceOf(RegisteredPrompt::class, $contentPrompt);
$this->assertCount(3, $contentPrompt->completionProviders);
$typeProvider = $contentPrompt->completionProviders['type'];
$this->assertInstanceOf(ListCompletionProvider::class, $typeProvider);
$statusProvider = $contentPrompt->completionProviders['status'];
$this->assertInstanceOf(EnumCompletionProvider::class, $statusProvider);
$priorityProvider = $contentPrompt->completionProviders['priority'];
$this->assertInstanceOf(EnumCompletionProvider::class, $priorityProvider);
$contentTemplate = $this->registry->getResourceTemplate('content://{category}/{slug}');
$this->assertInstanceOf(RegisteredResourceTemplate::class, $contentTemplate);
$this->assertCount(1, $contentTemplate->completionProviders);
$categoryProvider = $contentTemplate->completionProviders['category'];
$this->assertInstanceOf(ListCompletionProvider::class, $categoryProvider);
}
}

View file

@ -0,0 +1,143 @@
<?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\Capability\Discovery;
use Mcp\Capability\Discovery\DocBlockParser;
use phpDocumentor\Reflection\DocBlock;
use phpDocumentor\Reflection\DocBlock\Tags\Deprecated;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
use phpDocumentor\Reflection\DocBlock\Tags\See;
use phpDocumentor\Reflection\DocBlock\Tags\Throws;
use PHPUnit\Framework\TestCase;
class DocBlockParserTest extends TestCase
{
private DocBlockParser $parser;
protected function setUp(): void
{
$this->parser = new DocBlockParser();
}
public function testGetSummaryReturnsCorrectSummary()
{
$method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly');
$docComment = $method->getDocComment() ?: null;
$docBlock = $this->parser->parseDocBlock($docComment);
$this->assertEquals('Simple summary line.', $this->parser->getSummary($docBlock));
$method2 = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription');
$docComment2 = $method2->getDocComment() ?: null;
$docBlock2 = $this->parser->parseDocBlock($docComment2);
$this->assertEquals('Summary line here.', $this->parser->getSummary($docBlock2));
}
public function testGetDescriptionReturnsCorrectDescription()
{
$method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription');
$docComment = $method->getDocComment() ?: null;
$docBlock = $this->parser->parseDocBlock($docComment);
$expectedDesc = "Summary line here.\n\nThis is a longer description spanning\nmultiple lines.\nIt might contain *markdown* or `code`.";
$this->assertEquals($expectedDesc, $this->parser->getDescription($docBlock));
$method2 = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly');
$docComment2 = $method2->getDocComment() ?: null;
$docBlock2 = $this->parser->parseDocBlock($docComment2);
$this->assertEquals('Simple summary line.', $this->parser->getDescription($docBlock2));
}
public function testGetParamTagsReturnsStructuredParamInfo()
{
$method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithParams');
$docComment = $method->getDocComment() ?: null;
$docBlock = $this->parser->parseDocBlock($docComment);
$params = $this->parser->getParamTags($docBlock);
$this->assertCount(6, $params);
$this->assertArrayHasKey('$param1', $params);
$this->assertArrayHasKey('$param2', $params);
$this->assertArrayHasKey('$param3', $params);
$this->assertArrayHasKey('$param4', $params);
$this->assertArrayHasKey('$param5', $params);
$this->assertArrayHasKey('$param6', $params);
$this->assertInstanceOf(Param::class, $params['$param1']);
$this->assertEquals('param1', $params['$param1']->getVariableName());
$this->assertEquals('string', $this->parser->getParamTypeString($params['$param1']));
$this->assertEquals('description for string param', $this->parser->getParamDescription($params['$param1']));
$this->assertInstanceOf(Param::class, $params['$param2']);
$this->assertEquals('param2', $params['$param2']->getVariableName());
$this->assertEquals('int|null', $this->parser->getParamTypeString($params['$param2']));
$this->assertEquals('description for nullable int param', $this->parser->getParamDescription($params['$param2']));
$this->assertInstanceOf(Param::class, $params['$param3']);
$this->assertEquals('param3', $params['$param3']->getVariableName());
$this->assertEquals('bool', $this->parser->getParamTypeString($params['$param3']));
$this->assertEquals('nothing to say', $this->parser->getParamDescription($params['$param3']));
$this->assertInstanceOf(Param::class, $params['$param4']);
$this->assertEquals('param4', $params['$param4']->getVariableName());
$this->assertEquals('mixed', $this->parser->getParamTypeString($params['$param4']));
$this->assertEquals('Missing type', $this->parser->getParamDescription($params['$param4']));
$this->assertInstanceOf(Param::class, $params['$param5']);
$this->assertEquals('param5', $params['$param5']->getVariableName());
$this->assertEquals('array<string,mixed>', $this->parser->getParamTypeString($params['$param5']));
$this->assertEquals('array description', $this->parser->getParamDescription($params['$param5']));
$this->assertInstanceOf(Param::class, $params['$param6']);
$this->assertEquals('param6', $params['$param6']->getVariableName());
$this->assertEquals('stdClass', $this->parser->getParamTypeString($params['$param6']));
$this->assertEquals('object param', $this->parser->getParamDescription($params['$param6']));
}
public function testGetTagsByNameReturnsSpecificTags()
{
$method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithMultipleTags');
$docComment = $method->getDocComment() ?: null;
$docBlock = $this->parser->parseDocBlock($docComment);
$this->assertInstanceOf(DocBlock::class, $docBlock);
$throwsTags = $docBlock->getTagsByName('throws');
$this->assertCount(1, $throwsTags);
$this->assertInstanceOf(Throws::class, $throwsTags[0]);
$this->assertEquals('\\RuntimeException', (string) $throwsTags[0]->getType());
$this->assertEquals('if processing fails', $throwsTags[0]->getDescription()->render());
$deprecatedTags = $docBlock->getTagsByName('deprecated');
$this->assertCount(1, $deprecatedTags);
$this->assertInstanceOf(Deprecated::class, $deprecatedTags[0]);
$this->assertEquals('use newMethod() instead', $deprecatedTags[0]->getDescription()->render());
$seeTags = $docBlock->getTagsByName('see');
$this->assertCount(1, $seeTags);
$this->assertInstanceOf(See::class, $seeTags[0]);
$this->assertStringContainsString('DocBlockTestFixture::newMethod()', (string) $seeTags[0]->getReference());
$nonExistentTags = $docBlock->getTagsByName('nosuchtag');
$this->assertEmpty($nonExistentTags);
}
public function testHandlesMethodWithNoDocblockGracefully()
{
$method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithNoDocBlock');
$docComment = $method->getDocComment() ?: null;
$docBlock = $this->parser->parseDocBlock($docComment);
$this->assertNull($docBlock);
$this->assertNull($this->parser->getSummary($docBlock));
$this->assertNull($this->parser->getDescription($docBlock));
$this->assertEmpty($this->parser->getParamTags($docBlock));
}
}

View file

@ -0,0 +1,96 @@
<?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\Capability\Discovery;
/**
* A stub class for testing DocBlock parsing.
*/
class DocBlockTestFixture
{
/**
* Simple summary line.
*/
public function methodWithSummaryOnly(): void
{
}
/**
* Summary line here.
*
* This is a longer description spanning
* multiple lines.
* It might contain *markdown* or `code`.
*
* @since 1.0
*/
public function methodWithSummaryAndDescription(): void
{
}
/**
* Method with various parameter tags.
*
* @param string $param1 description for string param
* @param int|null $param2 description for nullable int param
* @param bool $param3 nothing to say
* @param $param4 Missing type
* @param array<string, mixed> $param5 array description
* @param \stdClass $param6 object param
*/
/* @phpstan-ignore-next-line missingType.parameter */
public function methodWithParams(string $param1, ?int $param2, bool $param3, $param4, array $param5, \stdClass $param6): void
{
}
/**
* Method with return tag.
*
* @return string the result of the operation
*/
public function methodWithReturn(): string
{
return '';
}
/**
* Method with multiple tags.
*
* @param float $value the value to process
*
* @return bool status of the operation
*
* @throws \RuntimeException if processing fails
*
* @deprecated use newMethod() instead
* @see DocBlockTestFixture::newMethod()
*/
public function methodWithMultipleTags(float $value): bool /* @phpstan-ignore throws.unusedType */
{
return true;
}
/**
* Malformed docblock - missing closing.
*/
public function methodWithMalformedDocBlock(): void
{
}
public function methodWithNoDocBlock(): void
{
}
// Some other method needed for a @see tag perhaps
public function newMethod(): void
{
}
}

View file

@ -0,0 +1,47 @@
<?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\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\CompletionProvider;
use Mcp\Capability\Attribute\McpPrompt;
use Mcp\Tests\Capability\Attribute\CompletionProviderFixture;
class DiscoverablePromptHandler
{
/**
* Generates a creative story prompt.
*
* @param string $genre the genre of the story
* @param int $lengthWords approximate length in words
*
* @return array the prompt messages
*/
#[McpPrompt(name: 'creative_story_prompt')]
public function generateStoryPrompt(
#[CompletionProvider(provider: CompletionProviderFixture::class)]
string $genre,
int $lengthWords = 200,
): array {
return [
['role' => 'user', 'content' => "Write a {$genre} story about a lost robot, approximately {$lengthWords} words long."],
];
}
#[McpPrompt]
public function simpleQuestionPrompt(string $question): array
{
return [
['role' => 'user', 'content' => $question],
['role' => 'assistant', 'content' => 'I will try to answer that.'],
];
}
}

View file

@ -0,0 +1,50 @@
<?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\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\McpResource;
use Mcp\Schema\Annotations;
class DiscoverableResourceHandler
{
/**
* Provides the application's current version.
*
* @return string the version string
*/
#[McpResource(
uri: 'app://info/version',
name: 'app_version',
description: 'The current version of the application.',
mimeType: 'text/plain',
size: 10
)]
public function getAppVersion(): string
{
return '1.2.3-discovered';
}
#[McpResource(
uri: 'config://settings/ui',
name: 'ui_settings_discovered',
mimeType: 'application/json',
annotations: new Annotations(priority: 0.5)
)]
public function getUiSettings(): array
{
return ['theme' => 'dark', 'fontSize' => 14];
}
public function someOtherMethod(): void
{
}
}

View file

@ -0,0 +1,51 @@
<?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\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\CompletionProvider;
use Mcp\Capability\Attribute\McpResourceTemplate;
use Mcp\Tests\Capability\Attribute\CompletionProviderFixture;
class DiscoverableTemplateHandler
{
/**
* Retrieves product details based on ID and region.
*
* @param string $productId the ID of the product
* @param string $region the sales region
*
* @return array product details
*/
#[McpResourceTemplate(
uriTemplate: 'product://{region}/details/{productId}',
name: 'product_details_template',
mimeType: 'application/json'
)]
public function getProductDetails(
string $productId,
#[CompletionProvider(provider: CompletionProviderFixture::class)]
string $region,
): array {
return [
'id' => $productId,
'name' => 'Product '.$productId,
'region' => $region,
'price' => ('EU' === $region ? '€' : '$').(hexdec(substr(md5($productId), 0, 4)) / 100),
];
}
#[McpResourceTemplate(uriTemplate: 'file://{path}/{filename}.{extension}')]
public function getFileContent(string $path, string $filename, string $extension): string
{
return "Content of {$path}/{$filename}.{$extension}";
}
}

View file

@ -0,0 +1,68 @@
<?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\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Schema\ToolAnnotations;
use Mcp\Tests\Fixtures\Enum\BackedStringEnum;
class DiscoverableToolHandler
{
/**
* A basic discoverable tool.
*
* @param string $name the name to greet
*
* @return string the greeting
*/
#[McpTool(name: 'greet_user', description: 'Greets a user by name.')]
public function greet(string $name): string
{
return "Hello, {$name}!";
}
/**
* A tool with more complex parameters and inferred name/description.
*
* @param int $count the number of times to repeat
* @param bool $loudly Should it be loud?
* @param BackedStringEnum $mode the mode of operation
*
* @return array an array with results
*/
#[McpTool(annotations: new ToolAnnotations(readOnlyHint: true))]
public function repeatAction(int $count, bool $loudly = false, BackedStringEnum $mode = BackedStringEnum::OptionA): array
{
return ['count' => $count, 'loudly' => $loudly, 'mode' => $mode->value, 'message' => 'Action repeated.'];
}
// This method should NOT be discovered as a tool
public function internalHelperMethod(int $value): int
{
return $value * 2;
}
#[McpTool(name: 'private_tool_should_be_ignored')] // On private method
private function aPrivateTool(): void
{
}
#[McpTool(name: 'protected_tool_should_be_ignored')] // On protected method
protected function aProtectedTool(): void
{
}
#[McpTool(name: 'static_tool_should_be_ignored')] // On static method
public static function aStaticTool(): void
{
}
}

View 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\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\CompletionProvider;
use Mcp\Capability\Attribute\McpPrompt;
use Mcp\Capability\Attribute\McpResourceTemplate;
use Mcp\Tests\Fixtures\Enum\PriorityEnum;
use Mcp\Tests\Fixtures\Enum\StatusEnum;
class EnhancedCompletionHandler
{
/**
* Create content with list and enum completion providers.
*/
#[McpPrompt(name: 'content_creator')]
public function createContent(
#[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide'])]
string $type,
#[CompletionProvider(enum: StatusEnum::class)]
string $status,
#[CompletionProvider(enum: PriorityEnum::class)]
string $priority,
): array {
return [
['role' => 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority}"],
];
}
/**
* Resource template with list completion for categories.
*/
#[McpResourceTemplate(
uriTemplate: 'content://{category}/{slug}',
name: 'content_template'
)]
public function getContent(
#[CompletionProvider(values: ['news', 'blog', 'docs', 'api'])]
string $category,
string $slug,
): array {
return [
'category' => $category,
'slug' => $slug,
'url' => "https://example.com/{$category}/{$slug}",
];
}
}

View file

@ -0,0 +1,23 @@
<?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\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\McpPrompt;
#[McpPrompt(name: 'InvokableGreeterPrompt')]
class InvocablePromptFixture
{
public function __invoke(string $personName): array
{
return [['role' => 'user', 'content' => "Generate a short greeting for {$personName}."]];
}
}

View file

@ -0,0 +1,23 @@
<?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\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\McpResource;
#[McpResource(uri: 'invokable://config/status', name: 'invokable_app_status')]
class InvocableResourceFixture
{
public function __invoke(): array
{
return ['status' => 'OK', 'load' => rand(1, 100) / 100.0];
}
}

View file

@ -0,0 +1,23 @@
<?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\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\McpResourceTemplate;
#[McpResourceTemplate(uriTemplate: 'invokable://user-profile/{userId}')]
class InvocableResourceTemplateFixture
{
public function __invoke(string $userId): array
{
return ['id' => $userId, 'email' => "user{$userId}@example-invokable.com"];
}
}

View file

@ -0,0 +1,26 @@
<?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\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(name: 'InvokableCalculator', description: 'An invokable calculator tool.')]
class InvocableToolFixture
{
/**
* Adds two numbers.
*/
public function __invoke(int $a, int $b): int
{
return $a + $b;
}
}

View file

@ -0,0 +1,36 @@
<?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\Capability\Discovery\Fixtures;
class NonDiscoverableClass
{
public function someMethod(): string
{
return 'Just a regular method.';
}
}
interface MyDiscoverableInterface
{
}
trait MyDiscoverableTrait
{
public function traitMethod()
{
}
}
enum MyDiscoverableEnum
{
case Alpha;
}

View file

@ -0,0 +1,22 @@
<?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\Capability\Discovery\Fixtures\SubDir;
use Mcp\Capability\Attribute\McpTool;
class HiddenTool
{
#[McpTool(name: 'hidden_subdir_tool')]
public function run()
{
}
}

Some files were not shown because too many files have changed in this diff Show more