Split testsuite for unit and inspector based tests (#82)

This commit is contained in:
Christopher Hertel 2025-09-25 23:27:41 +02:00 • committed by GitHub
commit 3ec4cf612a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 188 additions and 85 deletions

View file

@ -0,0 +1,98 @@
<?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\CachedDiscoverer;
use Mcp\Capability\Discovery\Discoverer;
use Mcp\Capability\Discovery\DiscoveryState;
use Mcp\Capability\Registry;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Psr\SimpleCache\CacheInterface;
class CachedDiscovererTest extends TestCase
{
public function testCachedDiscovererUsesCacheOnSecondCall(): void
{
$registry = new Registry(null, new NullLogger());
$discoverer = new Discoverer($registry, new NullLogger());
$cache = $this->createMock(CacheInterface::class);
$cache->expects($this->once())
->method('get')
->willReturn(null);
$cache->expects($this->once())
->method('set')
->willReturn(true);
$cachedDiscoverer = new CachedDiscoverer(
$discoverer,
$cache,
new NullLogger()
);
$result = $cachedDiscoverer->discover('/test/path', ['.'], []);
$this->assertInstanceOf(DiscoveryState::class, $result);
}
public function testCachedDiscovererReturnsCachedResults(): void
{
$registry = new Registry(null, new NullLogger());
$discoverer = new Discoverer($registry, new NullLogger());
$cache = $this->createMock(CacheInterface::class);
$cachedState = new DiscoveryState();
$cache->expects($this->once())
->method('get')
->willReturn($cachedState);
$cache->expects($this->never())
->method('set');
$cachedDiscoverer = new CachedDiscoverer(
$discoverer,
$cache,
new NullLogger()
);
$result = $cachedDiscoverer->discover('/test/path', ['.'], []);
$this->assertInstanceOf(DiscoveryState::class, $result);
}
public function testCacheKeyGeneration(): void
{
$registry = new Registry(null, new NullLogger());
$discoverer = new Discoverer($registry, new NullLogger());
$cache = $this->createMock(CacheInterface::class);
$cache->expects($this->exactly(2))
->method('get')
->willReturn(null);
$cache->expects($this->exactly(2))
->method('set')
->willReturn(true);
$cachedDiscoverer = new CachedDiscoverer(
$discoverer,
$cache,
new NullLogger()
);
$result1 = $cachedDiscoverer->discover('/path1', ['.'], []);
$result2 = $cachedDiscoverer->discover('/path2', ['.'], []);
$this->assertInstanceOf(DiscoveryState::class, $result1);
$this->assertInstanceOf(DiscoveryState::class, $result2);
}
}

View file

@ -0,0 +1,183 @@
<?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\Discoverer;
use Mcp\Capability\Prompt\Completion\EnumCompletionProvider;
use Mcp\Capability\Prompt\Completion\ListCompletionProvider;
use Mcp\Capability\Registry;
use Mcp\Capability\Registry\PromptReference;
use Mcp\Capability\Registry\ResourceReference;
use Mcp\Capability\Registry\ResourceTemplateReference;
use Mcp\Capability\Registry\ToolReference;
use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture;
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 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(ToolReference::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(ToolReference::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(ToolReference::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(ResourceReference::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(ResourceReference::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(PromptReference::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(PromptReference::class, $simplePrompt);
$this->assertFalse($simplePrompt->isManual);
$invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt');
$this->assertInstanceOf(PromptReference::class, $invokableGreeter);
$this->assertFalse($invokableGreeter->isManual);
$this->assertEquals([InvocablePromptFixture::class, '__invoke'], $invokableGreeter->handler);
$contentCreatorPrompt = $this->registry->getPrompt('content_creator');
$this->assertInstanceOf(PromptReference::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(ResourceTemplateReference::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(ResourceTemplateReference::class, $invokableUserTemplate);
$this->assertFalse($invokableUserTemplate->isManual);
$this->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $invokableUserTemplate->handler);
}
public function testDoesNotDiscoverElementsFromExcludedDirectories()
{
$this->discoverer->discover(__DIR__, ['Fixtures']);
$this->assertInstanceOf(ToolReference::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']);
$tools = $this->registry->getTools();
$this->assertEmpty($tools->references);
}
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(PromptReference::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(ResourceTemplateReference::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\Unit\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,98 @@
<?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\Tests\Capability\Discovery\Missing;
/**
* 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\Unit\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\CompletionProvider;
use Mcp\Capability\Attribute\McpPrompt;
use Mcp\Tests\Unit\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\Unit\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\Unit\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\CompletionProvider;
use Mcp\Capability\Attribute\McpResourceTemplate;
use Mcp\Tests\Unit\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\Unit\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Schema\ToolAnnotations;
use Mcp\Tests\Unit\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\Unit\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\CompletionProvider;
use Mcp\Capability\Attribute\McpPrompt;
use Mcp\Capability\Attribute\McpResourceTemplate;
use Mcp\Tests\Unit\Fixtures\Enum\PriorityEnum;
use Mcp\Tests\Unit\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\Unit\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\Unit\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\Unit\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\Unit\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\Unit\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\Unit\Capability\Discovery\Fixtures\SubDir;
use Mcp\Capability\Attribute\McpTool;
class HiddenTool
{
#[McpTool(name: 'hidden_subdir_tool')]
public function run()
{
}
}

View file

@ -0,0 +1,207 @@
<?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\HandlerResolver;
use Mcp\Exception\InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class HandlerResolverTest extends TestCase
{
public function testResolvesClosuresToReflectionFunction()
{
$closure = function (string $input): string {
return "processed: $input";
};
$resolved = HandlerResolver::resolve($closure);
$this->assertInstanceOf(\ReflectionFunction::class, $resolved);
$this->assertEquals(1, $resolved->getNumberOfParameters());
$this->assertInstanceOf(\ReflectionNamedType::class, $returnType = $resolved->getReturnType());
$this->assertEquals('string', $returnType->getName());
}
public function testResolvesValidArrayHandler()
{
$handler = [ValidHandlerClass::class, 'publicMethod'];
$resolved = HandlerResolver::resolve($handler);
$this->assertInstanceOf(\ReflectionMethod::class, $resolved);
$this->assertEquals('publicMethod', $resolved->getName());
$this->assertEquals(ValidHandlerClass::class, $resolved->getDeclaringClass()->getName());
}
public function testResolvesValidInvokableClassStringHandler()
{
$handler = ValidInvokableClass::class;
$resolved = HandlerResolver::resolve($handler);
$this->assertInstanceOf(\ReflectionMethod::class, $resolved);
$this->assertEquals('__invoke', $resolved->getName());
$this->assertEquals(ValidInvokableClass::class, $resolved->getDeclaringClass()->getName());
}
public function testResolvesStaticMethodsForManualRegistration()
{
$handler = [ValidHandlerClass::class, 'staticMethod'];
$resolved = HandlerResolver::resolve($handler);
$this->assertInstanceOf(\ReflectionMethod::class, $resolved);
$this->assertEquals('staticMethod', $resolved->getName());
$this->assertTrue($resolved->isStatic());
}
public function testThrowsForInvalidArrayHandlerFormatCount()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("Invalid array handler format. Expected [ClassName::class, 'methodName'].");
HandlerResolver::resolve([ValidHandlerClass::class]); /* @phpstan-ignore argument.type */
}
public function testThrowsForInvalidArrayHandlerFormatTypes()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("Invalid array handler format. Expected [ClassName::class, 'methodName'].");
HandlerResolver::resolve([ValidHandlerClass::class, 123]); /* @phpstan-ignore argument.type */
}
public function testThrowsForNonExistentClassInArrayHandler()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Handler class "NonExistentClass" not found');
HandlerResolver::resolve(['NonExistentClass', 'method']);
}
public function testThrowsForNonExistentMethodInArrayHandler()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Handler method "nonExistentMethod" not found in class');
HandlerResolver::resolve([ValidHandlerClass::class, 'nonExistentMethod']);
}
public function testThrowsForNonExistentClassInStringHandler()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.');
HandlerResolver::resolve('NonExistentInvokableClass');
}
public function testThrowsForNonInvokableClassStringHandler()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invokable handler class "Mcp\Tests\Unit\Capability\Discovery\NonInvokableClass" must have a public "__invoke" method.');
HandlerResolver::resolve(NonInvokableClass::class);
}
public function testThrowsForProtectedMethodHandler()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('must be public');
HandlerResolver::resolve([ValidHandlerClass::class, 'protectedMethod']);
}
public function testThrowsForPrivateMethodHandler()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('must be public');
HandlerResolver::resolve([ValidHandlerClass::class, 'privateMethod']);
}
public function testThrowsForConstructorAsHandler()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('cannot be a constructor or destructor');
HandlerResolver::resolve([ValidHandlerClass::class, '__construct']);
}
public function testThrowsForDestructorAsHandler()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('cannot be a constructor or destructor');
HandlerResolver::resolve([ValidHandlerClass::class, '__destruct']);
}
public function testThrowsForAbstractMethodHandler()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Handler method "Mcp\Tests\Unit\Capability\Discovery\AbstractHandlerClass::abstractMethod" must be abstract.');
HandlerResolver::resolve([AbstractHandlerClass::class, 'abstractMethod']);
}
public function testResolvesClosuresWithDifferentSignatures()
{
$noParams = function () {
return 'test';
};
$withParams = function (int $a, string $b = 'default') {
return $a.$b;
};
$variadic = function (...$args) {
return $args;
};
$this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($noParams));
$this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($withParams));
$this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($variadic));
$this->assertEquals(0, HandlerResolver::resolve($noParams)->getNumberOfParameters());
$this->assertEquals(2, HandlerResolver::resolve($withParams)->getNumberOfParameters());
$this->assertTrue(HandlerResolver::resolve($variadic)->isVariadic());
}
public function testDistinguishesBetweenClosuresAndCallableArrays()
{
$closure = function () {
return 'closure';
};
$array = [ValidHandlerClass::class, 'publicMethod'];
$string = ValidInvokableClass::class;
$this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($closure));
$this->assertInstanceOf(\ReflectionMethod::class, HandlerResolver::resolve($array));
$this->assertInstanceOf(\ReflectionMethod::class, HandlerResolver::resolve($string));
}
}
// Helper classes
class ValidHandlerClass
{
public function publicMethod(): void
{
}
protected function protectedMethod(): void
{
}
private function privateMethod(): void /* @phpstan-ignore method.unused */
{
}
public static function staticMethod(): void
{
}
public function __construct()
{
}
public function __destruct()
{
}
}
class ValidInvokableClass
{
public function __invoke(): void
{
}
}
class NonInvokableClass
{
}
abstract class AbstractHandlerClass
{
abstract public function abstractMethod(): void;
}

View file

@ -0,0 +1,415 @@
<?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\Attribute\Schema;
use Mcp\Tests\Unit\Fixtures\Enum\BackedIntEnum;
use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum;
use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum;
/**
* Comprehensive fixture for testing SchemaGenerator with various scenarios.
*/
class SchemaGeneratorFixture
{
// ===== BASIC SCENARIOS =====
public function noParams(): void
{
}
/**
* Type hints only - no Schema attributes.
*/
public function typeHintsOnly(string $name, int $age, bool $active, array $tags, ?\stdClass $config = null): void
{
}
/**
* DocBlock types only - no PHP type hints, no Schema attributes.
*
* @param string $username The username
* @param int $count Number of items
* @param bool $enabled Whether enabled
* @param array $data Some data
*/
public function docBlockOnly($username, $count, $enabled, $data): void
{
}
/**
* Type hints with DocBlock descriptions.
*
* @param string $email User email address
* @param int $score User score
* @param bool $verified Whether user is verified
*/
public function typeHintsWithDocBlock(string $email, int $score, bool $verified): void
{
}
// ===== METHOD-LEVEL SCHEMA SCENARIOS =====
/**
* Method-level Schema with complete definition.
*/
#[Schema(definition: [
'type' => 'object',
'description' => 'Creates a custom filter with complete definition',
'properties' => [
'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']],
'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']],
'value' => ['description' => 'Value to filter by, type depends on field and operator'],
],
'required' => ['field', 'operator', 'value'],
'if' => [
'properties' => ['field' => ['const' => 'date']],
],
'then' => [
'properties' => ['value' => ['type' => 'string', 'format' => 'date']],
],
])]
public function methodLevelCompleteDefinition(string $field, string $operator, mixed $value): array
{
return compact('field', 'operator', 'value');
}
/**
* Method-level Schema defining properties.
*/
#[Schema(
description: 'Creates a new user with detailed information.',
properties: [
'username' => ['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'],
'email' => ['type' => 'string', 'format' => 'email'],
'age' => ['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'],
'isActive' => ['type' => 'boolean', 'default' => true],
],
required: ['username', 'email']
)]
public function methodLevelWithProperties(string $username, string $email, int $age, bool $isActive = true): array
{
return compact('username', 'email', 'age', 'isActive');
}
/**
* Method-level Schema for complex array argument.
*/
#[Schema(
properties: [
'profiles' => [
'type' => 'array',
'description' => 'An array of user profiles to update.',
'minItems' => 1,
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'data' => ['type' => 'object', 'additionalProperties' => true],
],
'required' => ['id', 'data'],
],
],
],
required: ['profiles']
)]
public function methodLevelArrayArgument(array $profiles): array
{
return ['updated_count' => \count($profiles)];
}
// ===== PARAMETER-LEVEL SCHEMA SCENARIOS =====
/**
* Parameter-level Schema attributes only.
*/
public function parameterLevelOnly(
#[Schema(description: 'Recipient ID', pattern: '^user_')]
string $recipientId,
#[Schema(maxLength: 1024)]
string $messageBody,
#[Schema(type: 'integer', enum: [1, 2, 5])]
int $priority = 1,
#[Schema(
type: 'object',
properties: [
'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']],
'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'],
],
required: ['type']
)]
?array $notificationConfig = null,
): array {
return compact('recipientId', 'messageBody', 'priority', 'notificationConfig');
}
/**
* Parameter-level Schema with string constraints.
*/
public function parameterStringConstraints(
#[Schema(format: 'email')]
string $email,
#[Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$')]
string $password,
string $regularString,
): void {
}
/**
* Parameter-level Schema with numeric constraints.
*/
public function parameterNumericConstraints(
#[Schema(minimum: 18, maximum: 120)]
int $age,
#[Schema(minimum: 0, maximum: 5, exclusiveMaximum: true)]
float $rating,
#[Schema(multipleOf: 10)]
int $count,
): void {
}
/**
* Parameter-level Schema with array constraints.
*/
public function parameterArrayConstraints(
#[Schema(type: 'array', items: ['type' => 'string'], minItems: 1, uniqueItems: true)]
array $tags,
#[Schema(type: 'array', items: ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], minItems: 1, maxItems: 5)]
array $scores,
): void {
}
// ===== COMBINED SCENARIOS =====
/**
* Method-level + Parameter-level Schema combination.
*
* @param string $settingKey The key of the setting
* @param mixed $newValue The new value for the setting
*/
#[Schema(
properties: [
'settingKey' => ['type' => 'string', 'description' => 'The key of the setting.'],
'newValue' => ['description' => 'The new value for the setting (any type).'],
],
required: ['settingKey', 'newValue']
)]
public function methodAndParameterLevel(
string $settingKey,
#[Schema(description: 'The specific new boolean value.', type: 'boolean')]
mixed $newValue,
): array {
return compact('settingKey', 'newValue');
}
/**
* Type hints + DocBlock + Parameter-level Schema.
*
* @param string $username The user's name
* @param int $priority Task priority level
*/
public function typeHintDocBlockAndParameterSchema(
#[Schema(minLength: 3, pattern: '^[a-zA-Z0-9_]+$')]
string $username,
#[Schema(minimum: 1, maximum: 10)]
int $priority,
): void {
}
// ===== ENUM SCENARIOS =====
/**
* Various enum parameter types.
*
* @param BackedStringEnum $stringEnum Backed string enum
* @param BackedIntEnum $intEnum Backed int enum
* @param UnitEnum $unitEnum Unit enum
*/
public function enumParameters(
BackedStringEnum $stringEnum,
BackedIntEnum $intEnum,
UnitEnum $unitEnum,
?BackedStringEnum $nullableEnum = null,
BackedIntEnum $enumWithDefault = BackedIntEnum::First,
): void {
}
// ===== ARRAY TYPE SCENARIOS =====
/**
* Various array type scenarios.
*
* @param array $genericArray Generic array
* @param string[] $stringArray Array of strings
* @param int[] $intArray Array of integers
* @param array<string, mixed> $mixedMap Mixed array map
* @param array{name: string, age: int} $objectLikeArray Object-like array
* @param array{user: array{id: int, name: string}, items: int[]} $nestedObjectArray Nested object array
*/
public function arrayTypeScenarios(
array $genericArray,
array $stringArray,
array $intArray,
array $mixedMap,
array $objectLikeArray,
array $nestedObjectArray,
): void {
}
// ===== NULLABLE AND OPTIONAL SCENARIOS =====
/**
* Nullable and optional parameter scenarios.
*
* @param string|null $nullableString Nullable string
* @param int|null $nullableInt Nullable integer
*/
public function nullableAndOptional(
?string $nullableString,
?int $nullableInt = null,
string $optionalString = 'default',
bool $optionalBool = true,
array $optionalArray = [],
): void {
}
// ===== UNION TYPE SCENARIOS =====
/**
* Union type parameters.
*
* @param string|int $stringOrInt String or integer
* @param bool|string|null $multiUnion Bool, string or null
*/
public function unionTypes(
string|int $stringOrInt,
bool|string|null $multiUnion,
): void {
}
// ===== VARIADIC SCENARIOS =====
/**
* Variadic parameter scenarios.
*
* @param string ...$items Variadic strings
*/
public function variadicStrings(string ...$items): void
{
}
/**
* Variadic with Schema constraints.
*
* @param int ...$numbers Variadic integers
*/
public function variadicWithConstraints(
#[Schema(items: ['type' => 'integer', 'minimum' => 0])]
int ...$numbers,
): void {
}
// ===== MIXED TYPE SCENARIOS =====
/**
* Mixed type scenarios.
*
* @param mixed $anyValue Any value
* @param mixed $optionalAny Optional any value
*/
public function mixedTypes(
mixed $anyValue,
mixed $optionalAny = 'default',
): void {
}
// ===== COMPLEX NESTED SCENARIOS =====
/**
* Complex nested Schema constraints.
*/
public function complexNestedSchema(
#[Schema(
type: 'object',
properties: [
'customer' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'],
'name' => ['type' => 'string', 'minLength' => 2],
'email' => ['type' => 'string', 'format' => 'email'],
],
'required' => ['id', 'name'],
],
'items' => [
'type' => 'array',
'minItems' => 1,
'items' => [
'type' => 'object',
'properties' => [
'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'],
'quantity' => ['type' => 'integer', 'minimum' => 1],
'price' => ['type' => 'number', 'minimum' => 0],
],
'required' => ['product_id', 'quantity', 'price'],
],
],
'metadata' => [
'type' => 'object',
'additionalProperties' => true,
],
],
required: ['customer', 'items']
)]
array $order,
): array {
return ['order_id' => uniqid()];
}
// ===== TYPE PRECEDENCE SCENARIOS =====
/**
* Testing type precedence between PHP, DocBlock, and Schema.
*
* @param int $numericString DocBlock says integer despite string type hint
* @param string $stringWithConstraints String with Schema constraints
* @param array<string> $arrayWithItems Array with Schema item overrides
*/
public function typePrecedenceTest(
string $numericString,
#[Schema(format: 'email', minLength: 5)]
string $stringWithConstraints,
#[Schema(items: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])]
array $arrayWithItems,
): void {
}
// ===== ERROR EDGE CASES =====
/**
* Method with no parameters but Schema description.
*/
#[Schema(description: 'Gets server status. Takes no arguments.', properties: [])]
public function noParamsWithSchema(): array
{
return ['status' => 'OK'];
}
/**
* Parameter with Schema but inferred type.
*/
public function parameterSchemaInferredType(
#[Schema(description: 'Some parameter', minLength: 3)]
$inferredParam,
): void {
}
}

View file

@ -0,0 +1,330 @@
<?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\DocBlockParser;
use Mcp\Capability\Discovery\SchemaGenerator;
use PHPUnit\Framework\TestCase;
final class SchemaGeneratorTest extends TestCase
{
private SchemaGenerator $schemaGenerator;
protected function setUp(): void
{
$this->schemaGenerator = new SchemaGenerator(new DocBlockParser());
}
public function testGeneratesEmptyPropertiesObjectForMethodWithNoParameters()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParams');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals([
'type' => 'object',
'properties' => new \stdClass(),
], $schema);
$this->assertArrayNotHasKey('required', $schema);
}
public function testInfersBasicTypesFromPhpTypeHints()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsOnly');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['type' => 'string'], $schema['properties']['name']);
$this->assertEquals(['type' => 'integer'], $schema['properties']['age']);
$this->assertEquals(['type' => 'boolean'], $schema['properties']['active']);
$this->assertEquals(['type' => 'array'], $schema['properties']['tags']);
$this->assertEquals(['type' => ['null', 'object'], 'default' => null], $schema['properties']['config']);
$this->assertEqualsCanonicalizing(['name', 'age', 'active', 'tags'], $schema['required']);
}
public function testInfersTypesAndDescriptionsFromDocBlockTags()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOnly');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['type' => 'string', 'description' => 'The username'], $schema['properties']['username']);
$this->assertEquals(['type' => 'integer', 'description' => 'Number of items'], $schema['properties']['count']);
$this->assertEquals(['type' => 'boolean', 'description' => 'Whether enabled'], $schema['properties']['enabled']);
$this->assertEquals(['type' => 'array', 'description' => 'Some data'], $schema['properties']['data']);
$this->assertEqualsCanonicalizing(['username', 'count', 'enabled', 'data'], $schema['required']);
}
public function testUsesPhpTypeHintsForTypeAndDocBlockForDescriptions()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsWithDocBlock');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['type' => 'string', 'description' => 'User email address'], $schema['properties']['email']);
$this->assertEquals(['type' => 'integer', 'description' => 'User score'], $schema['properties']['score']);
$this->assertEquals(['type' => 'boolean', 'description' => 'Whether user is verified'], $schema['properties']['verified']);
$this->assertEqualsCanonicalizing(['email', 'score', 'verified'], $schema['required']);
}
public function testUsesCompleteSchemaDefinitionFromMethodLevelSchemaAttribute()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelCompleteDefinition');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals([
'type' => 'object',
'description' => 'Creates a custom filter with complete definition',
'properties' => [
'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']],
'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']],
'value' => ['description' => 'Value to filter by, type depends on field and operator'],
],
'required' => ['field', 'operator', 'value'],
'if' => [
'properties' => ['field' => ['const' => 'date']],
],
'then' => [
'properties' => ['value' => ['type' => 'string', 'format' => 'date']],
],
], $schema);
}
public function testGeneratesSchemaFromMethodLevelSchemaAttributeWithProperties()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelWithProperties');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals('Creates a new user with detailed information.', $schema['description']);
$this->assertEquals(['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'], $schema['properties']['username']);
$this->assertEquals(['type' => 'string', 'format' => 'email'], $schema['properties']['email']);
$this->assertEquals(['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'], $schema['properties']['age']);
$this->assertEquals(['type' => 'boolean', 'default' => true], $schema['properties']['isActive']);
$this->assertEqualsCanonicalizing(['age', 'username', 'email'], $schema['required']);
}
public function testGeneratesSchemaForSingleArrayArgumentFromMethodLevelSchemaAttribute()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelArrayArgument');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals([
'type' => 'array',
'description' => 'An array of user profiles to update.',
'minItems' => 1,
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'data' => ['type' => 'object', 'additionalProperties' => true],
],
'required' => ['id', 'data'],
],
], $schema['properties']['profiles']);
$this->assertEquals(['profiles'], $schema['required']);
}
public function testGeneratesSchemaFromIndividualParameterLevelSchemaAttributes()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterLevelOnly');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['description' => 'Recipient ID', 'pattern' => '^user_', 'type' => 'string'], $schema['properties']['recipientId']);
$this->assertEquals(['maxLength' => 1024, 'type' => 'string'], $schema['properties']['messageBody']);
$this->assertEquals(['type' => 'integer', 'enum' => [1, 2, 5], 'default' => 1], $schema['properties']['priority']);
$this->assertEquals([
'type' => 'object',
'properties' => [
'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']],
'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'],
],
'required' => ['type'],
'default' => null,
], $schema['properties']['notificationConfig']);
$this->assertEqualsCanonicalizing(['recipientId', 'messageBody'], $schema['required']);
}
public function testAppliesStringConstraintsFromParameterLevelSchemaAttributes()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterStringConstraints');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['format' => 'email', 'type' => 'string'], $schema['properties']['email']);
$this->assertEquals(['minLength' => 8, 'pattern' => '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$', 'type' => 'string'], $schema['properties']['password']);
$this->assertEquals(['type' => 'string'], $schema['properties']['regularString']);
$this->assertEqualsCanonicalizing(['email', 'password', 'regularString'], $schema['required']);
}
public function testAppliesNumericConstraintsFromParameterLevelSchemaAttributes()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterNumericConstraints');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['minimum' => 18, 'maximum' => 120, 'type' => 'integer'], $schema['properties']['age']);
$this->assertEquals(['minimum' => 0, 'maximum' => 5, 'exclusiveMaximum' => true, 'type' => 'number'], $schema['properties']['rating']);
$this->assertEquals(['multipleOf' => 10, 'type' => 'integer'], $schema['properties']['count']);
$this->assertEqualsCanonicalizing(['age', 'rating', 'count'], $schema['required']);
}
public function testAppliesArrayConstraintsFromParameterLevelSchemaAttributes()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterArrayConstraints');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['type' => 'array', 'items' => ['type' => 'string'], 'minItems' => 1, 'uniqueItems' => true], $schema['properties']['tags']);
$this->assertEquals(['type' => 'array', 'items' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], 'minItems' => 1, 'maxItems' => 5], $schema['properties']['scores']);
$this->assertEqualsCanonicalizing(['tags', 'scores'], $schema['required']);
}
public function testMergesMethodLevelAndParameterLevelSchemaAttributes()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodAndParameterLevel');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['type' => 'string', 'description' => 'The key of the setting.'], $schema['properties']['settingKey']);
$this->assertEquals(['description' => 'The specific new boolean value.', 'type' => 'boolean'], $schema['properties']['newValue']);
$this->assertEqualsCanonicalizing(['settingKey', 'newValue'], $schema['required']);
}
public function testCombinesPhpTypeHintsDocBlockDescriptionsAndParameterLevelSchemaConstraints()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintDocBlockAndParameterSchema');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$', 'type' => 'string', 'description' => "The user's name"], $schema['properties']['username']);
$this->assertEquals(['minimum' => 1, 'maximum' => 10, 'type' => 'integer', 'description' => 'Task priority level'], $schema['properties']['priority']);
$this->assertEqualsCanonicalizing(['username', 'priority'], $schema['required']);
}
public function testGeneratesCorrectSchemaForEnumParameters()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['type' => 'string', 'description' => 'Backed string enum', 'enum' => ['A', 'B']], $schema['properties']['stringEnum']);
$this->assertEquals(['type' => 'integer', 'description' => 'Backed int enum', 'enum' => [1, 2]], $schema['properties']['intEnum']);
$this->assertEquals(['type' => 'string', 'description' => 'Unit enum', 'enum' => ['Yes', 'No']], $schema['properties']['unitEnum']);
$this->assertEquals(['type' => ['null', 'string'], 'enum' => ['A', 'B'], 'default' => null], $schema['properties']['nullableEnum']);
$this->assertEquals(['type' => 'integer', 'enum' => [1, 2], 'default' => 1], $schema['properties']['enumWithDefault']);
$this->assertEqualsCanonicalizing(['stringEnum', 'intEnum', 'unitEnum'], $schema['required']);
}
public function testGeneratesCorrectSchemaForArrayTypeDeclarations()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['type' => 'array', 'description' => 'Generic array'], $schema['properties']['genericArray']);
$this->assertEquals(['type' => 'array', 'description' => 'Array of strings', 'items' => ['type' => 'string']], $schema['properties']['stringArray']);
$this->assertEquals(['type' => 'array', 'description' => 'Array of integers', 'items' => ['type' => 'integer']], $schema['properties']['intArray']);
$this->assertEquals(['type' => 'array', 'description' => 'Mixed array map'], $schema['properties']['mixedMap']);
$this->assertArrayHasKey('type', $schema['properties']['objectLikeArray']);
$this->assertEquals('object', $schema['properties']['objectLikeArray']['type']);
$this->assertArrayHasKey('properties', $schema['properties']['objectLikeArray']);
$this->assertArrayHasKey('name', $schema['properties']['objectLikeArray']['properties']);
$this->assertArrayHasKey('age', $schema['properties']['objectLikeArray']['properties']);
$this->assertEqualsCanonicalizing(['genericArray', 'stringArray', 'intArray', 'mixedMap', 'objectLikeArray', 'nestedObjectArray'], $schema['required']);
}
public function testHandlesNullableTypeHintsAndOptionalParameters()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['type' => ['null', 'string'], 'description' => 'Nullable string'], $schema['properties']['nullableString']);
$this->assertEquals(['type' => ['null', 'integer'], 'description' => 'Nullable integer', 'default' => null], $schema['properties']['nullableInt']);
$this->assertEquals(['type' => 'string', 'default' => 'default'], $schema['properties']['optionalString']);
$this->assertEquals(['type' => 'boolean', 'default' => true], $schema['properties']['optionalBool']);
$this->assertEquals(['type' => 'array', 'default' => []], $schema['properties']['optionalArray']);
$this->assertEqualsCanonicalizing(['nullableString'], $schema['required']);
}
public function testGeneratesSchemaForPhpUnionTypes()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'unionTypes');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['type' => ['integer', 'string'], 'description' => 'String or integer'], $schema['properties']['stringOrInt']);
$this->assertEquals(['type' => ['null', 'boolean', 'string'], 'description' => 'Bool, string or null'], $schema['properties']['multiUnion']);
$this->assertEqualsCanonicalizing(['stringOrInt', 'multiUnion'], $schema['required']);
}
public function testRepresentsVariadicStringParametersAsArrayOfStrings()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicStrings');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['type' => 'array', 'description' => 'Variadic strings', 'items' => ['type' => 'string']], $schema['properties']['items']);
$this->assertArrayNotHasKey('required', $schema);
}
public function testAppliesItemConstraintsToVariadicParameters()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicWithConstraints');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['items' => ['type' => 'integer', 'minimum' => 0], 'type' => 'array', 'description' => 'Variadic integers'], $schema['properties']['numbers']);
$this->assertArrayNotHasKey('required', $schema);
}
public function testHandlesMixedTypeHintsOmittingExplicitType()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['description' => 'Any value'], $schema['properties']['anyValue']);
$this->assertEquals(['description' => 'Optional any value', 'default' => 'default'], $schema['properties']['optionalAny']);
$this->assertEqualsCanonicalizing(['anyValue'], $schema['required']);
}
public function testGeneratesSchemaForComplexNestedObjectAndArrayStructures()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals([
'type' => 'object',
'properties' => [
'customer' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'],
'name' => ['type' => 'string', 'minLength' => 2],
'email' => ['type' => 'string', 'format' => 'email'],
],
'required' => ['id', 'name'],
],
'items' => [
'type' => 'array',
'minItems' => 1,
'items' => [
'type' => 'object',
'properties' => [
'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'],
'quantity' => ['type' => 'integer', 'minimum' => 1],
'price' => ['type' => 'number', 'minimum' => 0],
],
'required' => ['product_id', 'quantity', 'price'],
],
],
'metadata' => [
'type' => 'object',
'additionalProperties' => true,
],
],
'required' => ['customer', 'items'],
], $schema['properties']['order']);
$this->assertEquals(['order'], $schema['required']);
}
public function testTypePrecedenceParameterSchemaOverridesDocBlockOverridesPhpTypeHint()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typePrecedenceTest');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['type' => 'integer', 'description' => 'DocBlock says integer despite string type hint'], $schema['properties']['numericString']);
$this->assertEquals(['format' => 'email', 'minLength' => 5, 'type' => 'string', 'description' => 'String with Schema constraints'], $schema['properties']['stringWithConstraints']);
$this->assertEquals(['items' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], 'type' => 'array', 'description' => 'Array with Schema item overrides'], $schema['properties']['arrayWithItems']);
$this->assertEqualsCanonicalizing(['numericString', 'stringWithConstraints', 'arrayWithItems'], $schema['required']);
}
public function testGeneratesEmptyPropertiesObjectForMethodWithNoParametersEvenWithMethodLevelSchema()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals('Gets server status. Takes no arguments.', $schema['description']);
$this->assertInstanceOf(\stdClass::class, $schema['properties']);
$this->assertArrayNotHasKey('required', $schema);
}
public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven()
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterSchemaInferredType');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['description' => 'Some parameter', 'minLength' => 3], $schema['properties']['inferredParam']);
$this->assertEquals(['inferredParam'], $schema['required']);
}
}

View file

@ -0,0 +1,505 @@
<?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\Attribute\Schema;
use Mcp\Capability\Discovery\SchemaValidator;
use PHPUnit\Framework\TestCase;
class SchemaValidatorTest extends TestCase
{
private SchemaValidator $validator;
protected function setUp(): void
{
$this->validator = new SchemaValidator();
}
// --- Basic Validation Tests ---
public function testValidDataPassesValidation()
{
$schema = $this->getSimpleSchema();
$data = $this->getValidData();
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertEmpty($errors);
}
public function testInvalidTypeGeneratesTypeError()
{
$schema = $this->getSimpleSchema();
$data = $this->getValidData();
$data['age'] = 'thirty'; // Invalid type
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertCount(1, $errors);
$this->assertEquals('/age', $errors[0]['pointer']);
$this->assertEquals('type', $errors[0]['keyword']);
$this->assertStringContainsString('Expected `integer`', $errors[0]['message']);
}
public function testMissingRequiredPropertyGeneratesRequiredError()
{
$schema = $this->getSimpleSchema();
$data = $this->getValidData();
unset($data['name']); // Missing required
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertCount(1, $errors);
$this->assertEquals('required', $errors[0]['keyword']);
$this->assertStringContainsString('Missing required properties: `name`', $errors[0]['message']);
}
public function testAdditionalPropertyGeneratesAdditionalPropertiesError()
{
$schema = $this->getSimpleSchema();
$data = $this->getValidData();
$data['extra'] = 'not allowed'; // Additional property
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertCount(1, $errors);
$this->assertEquals('/', $errors[0]['pointer']); // Error reported at the object root
$this->assertEquals('additionalProperties', $errors[0]['keyword']);
$this->assertStringContainsString('Additional object properties are not allowed: ["extra"]', $errors[0]['message']);
}
// --- Keyword Constraint Tests ---
public function testEnumConstraintViolation()
{
$schema = ['type' => 'string', 'enum' => ['A', 'B']];
$data = 'C';
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertCount(1, $errors);
$this->assertEquals('enum', $errors[0]['keyword']);
$this->assertStringContainsString('must be one of the allowed values: "A", "B"', $errors[0]['message']);
}
public function testMinimumConstraintViolation()
{
$schema = ['type' => 'integer', 'minimum' => 10];
$data = 5;
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertCount(1, $errors);
$this->assertEquals('minimum', $errors[0]['keyword']);
$this->assertStringContainsString('must be greater than or equal to 10', $errors[0]['message']);
}
public function testMaxLengthConstraintViolation()
{
$schema = ['type' => 'string', 'maxLength' => 5];
$data = 'toolong';
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertCount(1, $errors);
$this->assertEquals('maxLength', $errors[0]['keyword']);
$this->assertStringContainsString('Maximum string length is 5, found 7', $errors[0]['message']);
}
public function testPatternConstraintViolation()
{
$schema = ['type' => 'string', 'pattern' => '^[a-z]+$'];
$data = '123';
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertCount(1, $errors);
$this->assertEquals('pattern', $errors[0]['keyword']);
$this->assertStringContainsString('does not match the required pattern: `^[a-z]+$`', $errors[0]['message']);
}
public function testMinItemsConstraintViolation()
{
$schema = ['type' => 'array', 'minItems' => 2];
$data = ['one'];
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertCount(1, $errors);
$this->assertEquals('minItems', $errors[0]['keyword']);
$this->assertStringContainsString('Array should have at least 2 items, 1 found', $errors[0]['message']);
}
public function testUniqueItemsConstraintViolation()
{
$schema = ['type' => 'array', 'uniqueItems' => true];
$data = ['a', 'b', 'a'];
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertCount(1, $errors);
$this->assertEquals('uniqueItems', $errors[0]['keyword']);
$this->assertStringContainsString('Array must have unique items', $errors[0]['message']);
}
// --- Nested Structures and Pointers ---
public function testNestedObjectValidationErrorPointer()
{
$schema = [
'type' => 'object',
'properties' => [
'user' => [
'type' => 'object',
'properties' => ['id' => ['type' => 'integer']],
'required' => ['id'],
],
],
'required' => ['user'],
];
$data = ['user' => ['id' => 'abc']]; // Invalid nested type
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertCount(1, $errors);
$this->assertEquals('/user/id', $errors[0]['pointer']);
}
public function testArrayItemValidationErrorPointer()
{
$schema = [
'type' => 'array',
'items' => ['type' => 'integer'],
];
$data = [1, 2, 'three', 4]; // Invalid item type
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertCount(1, $errors);
$this->assertEquals('/2', $errors[0]['pointer']); // Pointer to the index of the invalid item
}
// --- Data Conversion Tests ---
public function testValidatesDataPassedAsStdClassObject()
{
$schema = $this->getSimpleSchema();
$dataObj = json_decode(json_encode($this->getValidData())); // Convert to stdClass
$errors = $this->validator->validateAgainstJsonSchema($dataObj, $schema);
$this->assertEmpty($errors);
}
public function testValidatesDataWithNestedAssociativeArraysCorrectly()
{
$schema = [
'type' => 'object',
'properties' => [
'nested' => [
'type' => 'object',
'properties' => ['key' => ['type' => 'string']],
'required' => ['key'],
],
],
'required' => ['nested'],
];
$data = ['nested' => ['key' => 'value']]; // Nested assoc array
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertEmpty($errors);
}
// --- Edge Cases ---
public function testHandlesInvalidSchemaStructureGracefully()
{
$schema = ['type' => 'object', 'properties' => ['name' => ['type' => 123]]]; // Invalid type value
$data = ['name' => 'test'];
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertCount(1, $errors);
$this->assertEquals('internal', $errors[0]['keyword']);
$this->assertStringContainsString('Schema validation process failed', $errors[0]['message']);
}
public function testHandlesEmptyDataObjectAgainstSchemaRequiringProperties()
{
$schema = $this->getSimpleSchema(); // Requires name, age etc.
$data = []; // Empty data
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertNotEmpty($errors);
$this->assertEquals('required', $errors[0]['keyword']);
}
public function testHandlesEmptySchemaAllowsAnything()
{
$schema = []; // Empty schema object/array implies no constraints
$data = ['anything' => [1, 2], 'goes' => true];
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
$this->assertNotEmpty($errors);
$this->assertEquals('internal', $errors[0]['keyword']);
$this->assertStringContainsString('Invalid schema', $errors[0]['message']);
}
public function testValidatesSchemaWithStringFormatConstraintsFromSchemaAttribute()
{
$emailSchema = (new Schema(format: 'email'))->toArray();
// Valid email
$validErrors = $this->validator->validateAgainstJsonSchema('user@example.com', $emailSchema);
$this->assertEmpty($validErrors);
// Invalid email
$invalidErrors = $this->validator->validateAgainstJsonSchema('not-an-email', $emailSchema);
$this->assertNotEmpty($invalidErrors);
$this->assertEquals('format', $invalidErrors[0]['keyword']);
$this->assertStringContainsString('email', $invalidErrors[0]['message']);
}
public function testValidatesSchemaWithStringLengthConstraintsFromSchemaAttribute()
{
$passwordSchema = (new Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$'))->toArray();
// Valid password (meets length and pattern)
$validErrors = $this->validator->validateAgainstJsonSchema('Password123', $passwordSchema);
$this->assertEmpty($validErrors);
// Invalid - too short
$shortErrors = $this->validator->validateAgainstJsonSchema('Pass1', $passwordSchema);
$this->assertNotEmpty($shortErrors);
$this->assertEquals('minLength', $shortErrors[0]['keyword']);
// Invalid - no digit
$noDigitErrors = $this->validator->validateAgainstJsonSchema('PasswordXYZ', $passwordSchema);
$this->assertNotEmpty($noDigitErrors);
$this->assertEquals('pattern', $noDigitErrors[0]['keyword']);
}
public function testValidatesSchemaWithNumericConstraintsFromSchemaAttribute()
{
$ageSchema = (new Schema(minimum: 18, maximum: 120))->toArray();
// Valid age
$validErrors = $this->validator->validateAgainstJsonSchema(25, $ageSchema);
$this->assertEmpty($validErrors);
// Invalid - too low
$tooLowErrors = $this->validator->validateAgainstJsonSchema(15, $ageSchema);
$this->assertNotEmpty($tooLowErrors);
$this->assertEquals('minimum', $tooLowErrors[0]['keyword']);
// Invalid - too high
$tooHighErrors = $this->validator->validateAgainstJsonSchema(150, $ageSchema);
$this->assertNotEmpty($tooHighErrors);
$this->assertEquals('maximum', $tooHighErrors[0]['keyword']);
}
public function testValidatesSchemaWithArrayConstraintsFromSchemaAttribute()
{
$tagsSchema = (new Schema(uniqueItems: true, minItems: 2))->toArray();
// Valid tags array
$validErrors = $this->validator->validateAgainstJsonSchema(['php', 'javascript', 'python'], $tagsSchema);
$this->assertEmpty($validErrors);
// Invalid - duplicate items
$duplicateErrors = $this->validator->validateAgainstJsonSchema(['php', 'php', 'javascript'], $tagsSchema);
$this->assertNotEmpty($duplicateErrors);
$this->assertEquals('uniqueItems', $duplicateErrors[0]['keyword']);
// Invalid - too few items
$tooFewErrors = $this->validator->validateAgainstJsonSchema(['php'], $tagsSchema);
$this->assertNotEmpty($tooFewErrors);
$this->assertEquals('minItems', $tooFewErrors[0]['keyword']);
}
public function testValidatesSchemaWithObjectConstraintsFromSchemaAttribute()
{
$userSchema = (new Schema(
properties: [
'name' => ['type' => 'string', 'minLength' => 2],
'email' => ['type' => 'string', 'format' => 'email'],
'age' => ['type' => 'integer', 'minimum' => 18],
],
required: ['name', 'email']
))->toArray();
// Valid user object
$validUser = [
'name' => 'John',
'email' => 'john@example.com',
'age' => 25,
];
$validErrors = $this->validator->validateAgainstJsonSchema($validUser, $userSchema);
$this->assertEmpty($validErrors);
// Invalid - missing required email
$missingEmailUser = [
'name' => 'John',
'age' => 25,
];
$missingErrors = $this->validator->validateAgainstJsonSchema($missingEmailUser, $userSchema);
$this->assertNotEmpty($missingErrors);
$this->assertEquals('required', $missingErrors[0]['keyword']);
// Invalid - name too short
$shortNameUser = [
'name' => 'J',
'email' => 'john@example.com',
'age' => 25,
];
$nameErrors = $this->validator->validateAgainstJsonSchema($shortNameUser, $userSchema);
$this->assertNotEmpty($nameErrors);
$this->assertEquals('minLength', $nameErrors[0]['keyword']);
// Invalid - age too low
$youngUser = [
'name' => 'John',
'email' => 'john@example.com',
'age' => 15,
];
$ageErrors = $this->validator->validateAgainstJsonSchema($youngUser, $userSchema);
$this->assertNotEmpty($ageErrors);
$this->assertEquals('minimum', $ageErrors[0]['keyword']);
}
public function testValidatesSchemaWithNestedConstraintsFromSchemaAttribute()
{
$orderSchema = (new Schema(
properties: [
'customer' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'],
'name' => ['type' => 'string', 'minLength' => 2],
],
],
'items' => [
'type' => 'array',
'minItems' => 1,
'items' => [
'type' => 'object',
'properties' => [
'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'],
'quantity' => ['type' => 'integer', 'minimum' => 1],
],
'required' => ['product_id', 'quantity'],
],
],
],
required: ['customer', 'items']
))->toArray();
// Valid order
$validOrder = [
'customer' => [
'id' => 'CUS-123456',
'name' => 'John',
],
'items' => [
[
'product_id' => 'PRD-1234',
'quantity' => 2,
],
],
];
$validErrors = $this->validator->validateAgainstJsonSchema($validOrder, $orderSchema);
$this->assertEmpty($validErrors);
// Invalid - bad customer ID format
$badCustomerIdOrder = [
'customer' => [
'id' => 'CUST-123', // Wrong format
'name' => 'John',
],
'items' => [
[
'product_id' => 'PRD-1234',
'quantity' => 2,
],
],
];
$customerIdErrors = $this->validator->validateAgainstJsonSchema($badCustomerIdOrder, $orderSchema);
$this->assertNotEmpty($customerIdErrors);
$this->assertEquals('pattern', $customerIdErrors[0]['keyword']);
// Invalid - empty items array
$emptyItemsOrder = [
'customer' => [
'id' => 'CUS-123456',
'name' => 'John',
],
'items' => [],
];
$emptyItemsErrors = $this->validator->validateAgainstJsonSchema($emptyItemsOrder, $orderSchema);
$this->assertNotEmpty($emptyItemsErrors);
$this->assertEquals('minItems', $emptyItemsErrors[0]['keyword']);
// Invalid - missing required property in items
$missingProductIdOrder = [
'customer' => [
'id' => 'CUS-123456',
'name' => 'John',
],
'items' => [
[
// Missing product_id
'quantity' => 2,
],
],
];
$missingProductIdErrors = $this->validator->validateAgainstJsonSchema($missingProductIdOrder, $orderSchema);
$this->assertNotEmpty($missingProductIdErrors);
$this->assertEquals('required', $missingProductIdErrors[0]['keyword']);
}
/**
* @return array{
* type: 'object',
* properties: array<string, array<string, mixed>>,
* required: string[],
* additionalProperties: false,
* }
*/
private function getSimpleSchema(): array
{
return [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string', 'description' => 'The name'],
'age' => ['type' => 'integer', 'minimum' => 0],
'active' => ['type' => 'boolean'],
'score' => ['type' => 'number'],
'items' => ['type' => 'array', 'items' => ['type' => 'string']],
'nullableValue' => ['type' => ['string', 'null']],
'optionalValue' => ['type' => 'string'],
],
'required' => ['name', 'age', 'active', 'score', 'items', 'nullableValue'],
'additionalProperties' => false,
];
}
/**
* @return array{
* name: string,
* age: int,
* active: bool,
* score: float,
* items: string[],
* nullableValue: null,
* optionalValue: string
* }
*/
private function getValidData(): array
{
return [
'name' => 'Tester',
'age' => 30,
'active' => true,
'score' => 99.5,
'items' => ['a', 'b'],
'nullableValue' => null,
'optionalValue' => 'present',
];
}
}