Introduce Capability Registry & Discovery
This commit is contained in:
parent
09707b4798
commit
da0d5a87a0
5 changed files with 7063 additions and 8 deletions
412
src/Capability/Discovery/Discoverer.php
Normal file
412
src/Capability/Discovery/Discoverer.php
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
<?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\Capability\Discovery;
|
||||
|
||||
use Mcp\Capability\Attribute\CompletionProvider;
|
||||
use Mcp\Capability\Attribute\McpPrompt;
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Capability\Attribute\McpResourceTemplate;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Capability\Prompt\Completion\EnumCompletionProvider;
|
||||
use Mcp\Capability\Prompt\Completion\ListCompletionProvider;
|
||||
use Mcp\Capability\Prompt\Completion\ProviderInterface;
|
||||
use Mcp\Capability\Registry;
|
||||
use Mcp\Exception\ExceptionInterface;
|
||||
use Mcp\Schema\Prompt;
|
||||
use Mcp\Schema\PromptArgument;
|
||||
use Mcp\Schema\Resource;
|
||||
use Mcp\Schema\ResourceTemplate;
|
||||
use Mcp\Schema\Tool;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Symfony\Component\Finder\SplFileInfo;
|
||||
|
||||
/**
|
||||
* @phpstan-type DiscoveredCount array{
|
||||
* tools: int,
|
||||
* resources: int,
|
||||
* prompts: int,
|
||||
* resourceTemplates: int,
|
||||
* }
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
class Discoverer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Registry $registry,
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
private ?DocBlockParser $docBlockParser = null,
|
||||
private ?SchemaGenerator $schemaGenerator = null,
|
||||
) {
|
||||
$this->docBlockParser = $docBlockParser ?? new DocBlockParser(logger: $this->logger);
|
||||
$this->schemaGenerator = $schemaGenerator ?? new SchemaGenerator($this->docBlockParser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover MCP elements in the specified directories.
|
||||
*
|
||||
* @param string $basePath the base path for resolving directories
|
||||
* @param array<string> $directories list of directories (relative to base path) to scan
|
||||
* @param array<string> $excludeDirs list of directories (relative to base path) to exclude from the scan
|
||||
*/
|
||||
public function discover(string $basePath, array $directories, array $excludeDirs = []): void
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$discoveredCount = [
|
||||
'tools' => 0,
|
||||
'resources' => 0,
|
||||
'prompts' => 0,
|
||||
'resourceTemplates' => 0,
|
||||
];
|
||||
|
||||
try {
|
||||
$finder = new Finder();
|
||||
$absolutePaths = [];
|
||||
foreach ($directories as $dir) {
|
||||
$path = rtrim($basePath, '/').'/'.ltrim($dir, '/');
|
||||
if (is_dir($path)) {
|
||||
$absolutePaths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($absolutePaths)) {
|
||||
$this->logger->warning('No valid discovery directories found to scan.', [
|
||||
'configured_paths' => $directories,
|
||||
'base_path' => $basePath,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$finder->files()
|
||||
->in($absolutePaths)
|
||||
->exclude($excludeDirs)
|
||||
->name('*.php');
|
||||
|
||||
foreach ($finder as $file) {
|
||||
$this->processFile($file, $discoveredCount);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Error during file finding process for MCP discovery', [
|
||||
'exception' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
|
||||
$duration = microtime(true) - $startTime;
|
||||
$this->logger->info('Attribute discovery finished.', [
|
||||
'duration_sec' => round($duration, 3),
|
||||
'tools' => $discoveredCount['tools'],
|
||||
'resources' => $discoveredCount['resources'],
|
||||
'prompts' => $discoveredCount['prompts'],
|
||||
'resourceTemplates' => $discoveredCount['resourceTemplates'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single PHP file for MCP elements on classes or methods.
|
||||
*
|
||||
* @param DiscoveredCount $discoveredCount
|
||||
*/
|
||||
private function processFile(SplFileInfo $file, array &$discoveredCount): void
|
||||
{
|
||||
$filePath = $file->getRealPath();
|
||||
if (false === $filePath) {
|
||||
$this->logger->warning('Could not get real path for file', ['path' => $file->getPathname()]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$className = $this->getClassFromFile($filePath);
|
||||
if (!$className) {
|
||||
$this->logger->warning('No valid class found in file', ['file' => $filePath]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$reflectionClass = new \ReflectionClass($className);
|
||||
|
||||
if ($reflectionClass->isAbstract() || $reflectionClass->isInterface() || $reflectionClass->isTrait() || $reflectionClass->isEnum()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$processedViaClassAttribute = false;
|
||||
if ($reflectionClass->hasMethod('__invoke')) {
|
||||
$invokeMethod = $reflectionClass->getMethod('__invoke');
|
||||
if ($invokeMethod->isPublic() && !$invokeMethod->isStatic()) {
|
||||
$attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class];
|
||||
foreach ($attributeTypes as $attributeType) {
|
||||
$classAttribute = $reflectionClass->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null;
|
||||
if ($classAttribute) {
|
||||
$this->processMethod($invokeMethod, $discoveredCount, $classAttribute);
|
||||
$processedViaClassAttribute = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$processedViaClassAttribute) {
|
||||
foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
|
||||
if (
|
||||
$method->getDeclaringClass()->getName() !== $reflectionClass->getName()
|
||||
|| $method->isStatic() || $method->isAbstract() || $method->isConstructor() || $method->isDestructor() || '__invoke' === $method->getName()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
$attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class];
|
||||
foreach ($attributeTypes as $attributeType) {
|
||||
$methodAttribute = $method->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null;
|
||||
if ($methodAttribute) {
|
||||
$this->processMethod($method, $discoveredCount, $methodAttribute);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\ReflectionException $e) {
|
||||
$this->logger->error('Reflection error processing file for MCP discovery', ['file' => $filePath, 'class' => $className, 'exception' => $e->getMessage()]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Unexpected error processing file for MCP discovery', [
|
||||
'file' => $filePath,
|
||||
'class' => $className,
|
||||
'exception' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a method with a given MCP attribute instance.
|
||||
* Can be called for regular methods or the __invoke method of an invokable class.
|
||||
*
|
||||
* @param \ReflectionMethod $method The target method (e.g., regular method or __invoke).
|
||||
* @param DiscoveredCount $discoveredCount pass by reference to update counts
|
||||
* @param \ReflectionAttribute<McpTool|McpResource|McpPrompt|McpResourceTemplate> $attribute the ReflectionAttribute instance found (on method or class)
|
||||
*/
|
||||
private function processMethod(\ReflectionMethod $method, array &$discoveredCount, \ReflectionAttribute $attribute): void
|
||||
{
|
||||
$className = $method->getDeclaringClass()->getName();
|
||||
$classShortName = $method->getDeclaringClass()->getShortName();
|
||||
$methodName = $method->getName();
|
||||
$attributeClassName = $attribute->getName();
|
||||
|
||||
try {
|
||||
$instance = $attribute->newInstance();
|
||||
|
||||
switch ($attributeClassName) {
|
||||
case McpTool::class:
|
||||
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
|
||||
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
|
||||
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
|
||||
$inputSchema = $this->schemaGenerator->generate($method);
|
||||
$tool = new Tool($name, $inputSchema, $description, $instance->annotations);
|
||||
$this->registry->registerTool($tool, [$className, $methodName]);
|
||||
++$discoveredCount['tools'];
|
||||
break;
|
||||
|
||||
case McpResource::class:
|
||||
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
|
||||
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
|
||||
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
|
||||
$mimeType = $instance->mimeType;
|
||||
$size = $instance->size;
|
||||
$annotations = $instance->annotations;
|
||||
$resource = new Resource($instance->uri, $name, $description, $mimeType, $annotations, $size);
|
||||
$this->registry->registerResource($resource, [$className, $methodName]);
|
||||
++$discoveredCount['resources'];
|
||||
break;
|
||||
|
||||
case McpPrompt::class:
|
||||
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
|
||||
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
|
||||
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
|
||||
$arguments = [];
|
||||
$paramTags = $this->docBlockParser->getParamTags($docBlock);
|
||||
foreach ($method->getParameters() as $param) {
|
||||
$reflectionType = $param->getType();
|
||||
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
|
||||
continue;
|
||||
}
|
||||
$paramTag = $paramTags['$'.$param->getName()] ?? null;
|
||||
$arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable());
|
||||
}
|
||||
$prompt = new Prompt($name, $description, $arguments);
|
||||
$completionProviders = $this->getCompletionProviders($method);
|
||||
$this->registry->registerPrompt($prompt, [$className, $methodName], $completionProviders);
|
||||
++$discoveredCount['prompts'];
|
||||
break;
|
||||
|
||||
case McpResourceTemplate::class:
|
||||
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
|
||||
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
|
||||
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
|
||||
$mimeType = $instance->mimeType;
|
||||
$annotations = $instance->annotations;
|
||||
$resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations);
|
||||
$completionProviders = $this->getCompletionProviders($method);
|
||||
$this->registry->registerResourceTemplate($resourceTemplate, [$className, $methodName], $completionProviders);
|
||||
++$discoveredCount['resourceTemplates'];
|
||||
break;
|
||||
}
|
||||
} catch (ExceptionInterface $e) {
|
||||
$this->logger->error("Failed to process MCP attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e->getMessage(), 'trace' => $e->getPrevious() ? $e->getPrevious()->getTraceAsString() : $e->getTraceAsString()]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error("Unexpected error processing attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|ProviderInterface>
|
||||
*/
|
||||
private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array
|
||||
{
|
||||
$completionProviders = [];
|
||||
foreach ($reflectionMethod->getParameters() as $param) {
|
||||
$reflectionType = $param->getType();
|
||||
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF);
|
||||
if (!empty($completionAttributes)) {
|
||||
$attributeInstance = $completionAttributes[0]->newInstance();
|
||||
|
||||
if ($attributeInstance->provider) {
|
||||
$completionProviders[$param->getName()] = $attributeInstance->provider;
|
||||
} elseif ($attributeInstance->providerClass) {
|
||||
$completionProviders[$param->getName()] = $attributeInstance->provider;
|
||||
} elseif ($attributeInstance->values) {
|
||||
$completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values);
|
||||
} elseif ($attributeInstance->enum) {
|
||||
$completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $completionProviders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to determine the FQCN from a PHP file path.
|
||||
* Uses tokenization to extract namespace and class name.
|
||||
*
|
||||
* @param string $filePath absolute path to the PHP file
|
||||
*
|
||||
* @return class-string|null the FQCN or null if not found/determinable
|
||||
*/
|
||||
private function getClassFromFile(string $filePath): ?string
|
||||
{
|
||||
if (!file_exists($filePath) || !is_readable($filePath)) {
|
||||
$this->logger->warning('File does not exist or is not readable.', ['file' => $filePath]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$content = file_get_contents($filePath);
|
||||
if (false === $content) {
|
||||
$this->logger->warning('Failed to read file content.', ['file' => $filePath]);
|
||||
|
||||
return null;
|
||||
}
|
||||
if (\strlen($content) > 500 * 1024) {
|
||||
$this->logger->debug('Skipping large file during class discovery.', ['file' => $filePath]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$tokens = token_get_all($content);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning("Failed to read or tokenize file during class discovery: {$filePath}", ['exception' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$namespace = '';
|
||||
$namespaceFound = false;
|
||||
$level = 0;
|
||||
$potentialClasses = [];
|
||||
|
||||
$tokenCount = \count($tokens);
|
||||
for ($i = 0; $i < $tokenCount; ++$i) {
|
||||
if (\is_array($tokens[$i]) && \T_NAMESPACE === $tokens[$i][0]) {
|
||||
$namespace = '';
|
||||
for ($j = $i + 1; $j < $tokenCount; ++$j) {
|
||||
if (';' === $tokens[$j] || '{' === $tokens[$j]) {
|
||||
$namespaceFound = true;
|
||||
$i = $j;
|
||||
break;
|
||||
}
|
||||
if (\is_array($tokens[$j]) && \in_array($tokens[$j][0], [\T_STRING, \T_NAME_QUALIFIED])) {
|
||||
$namespace .= $tokens[$j][1];
|
||||
} elseif (\T_NS_SEPARATOR === $tokens[$j][0]) {
|
||||
$namespace .= '\\';
|
||||
}
|
||||
}
|
||||
if ($namespaceFound) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$namespace = trim($namespace, '\\');
|
||||
|
||||
for ($i = 0; $i < $tokenCount; ++$i) {
|
||||
$token = $tokens[$i];
|
||||
if ('{' === $token) {
|
||||
++$level;
|
||||
|
||||
continue;
|
||||
}
|
||||
if ('}' === $token) {
|
||||
--$level;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($level === ($namespaceFound && str_contains($content, "namespace {$namespace} {") ? 1 : 0)) {
|
||||
if (\is_array($token) && \in_array($token[0], [\T_CLASS, \T_INTERFACE, \T_TRAIT, \defined('T_ENUM') ? \T_ENUM : -1])) {
|
||||
for ($j = $i + 1; $j < $tokenCount; ++$j) {
|
||||
if (\is_array($tokens[$j]) && \T_STRING === $tokens[$j][0]) {
|
||||
$className = $tokens[$j][1];
|
||||
$potentialClasses[] = $namespace ? $namespace.'\\'.$className : $className;
|
||||
$i = $j;
|
||||
break;
|
||||
}
|
||||
if (';' === $tokens[$j] || '{' === $tokens[$j] || ')' === $tokens[$j]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($potentialClasses as $potentialClass) {
|
||||
if (class_exists($potentialClass, true)) {
|
||||
return $potentialClass;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($potentialClasses)) {
|
||||
if (!class_exists($potentialClasses[0], false)) {
|
||||
$this->logger->debug('getClassFromFile returning potential non-class type. Are you sure this class has been autoloaded?', ['file' => $filePath, 'type' => $potentialClasses[0]]);
|
||||
}
|
||||
|
||||
return $potentialClasses[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
139
src/Capability/Discovery/DocBlockParser.php
Normal file
139
src/Capability/Discovery/DocBlockParser.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?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\Capability\Discovery;
|
||||
|
||||
use phpDocumentor\Reflection\DocBlock;
|
||||
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
||||
use phpDocumentor\Reflection\DocBlockFactory;
|
||||
use phpDocumentor\Reflection\DocBlockFactoryInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* Parses DocBlocks using phpdocumentor/reflection-docblock.
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
class DocBlockParser
|
||||
{
|
||||
private DocBlockFactoryInterface $docBlockFactory;
|
||||
|
||||
public function __construct(
|
||||
?DocBlockFactoryInterface $docBlockFactory = null,
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
) {
|
||||
$this->docBlockFactory = $docBlockFactory ?? DocBlockFactory::createInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parses a DocComment string into a DocBlock object.
|
||||
*/
|
||||
public function parseDocBlock(string|false|null $docComment): ?DocBlock
|
||||
{
|
||||
if (false === $docComment || null === $docComment || empty($docComment)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return $this->docBlockFactory->create($docComment);
|
||||
} catch (\Throwable $e) {
|
||||
// Log error or handle gracefully if invalid DocBlock syntax is encountered
|
||||
$this->logger->warning('Failed to parse DocBlock', [
|
||||
'error' => $e->getMessage(),
|
||||
'exception_trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the summary line from a DocBlock.
|
||||
*/
|
||||
public function getSummary(?DocBlock $docBlock): ?string
|
||||
{
|
||||
if (!$docBlock) {
|
||||
return null;
|
||||
}
|
||||
$summary = trim($docBlock->getSummary());
|
||||
|
||||
return $summary ?: null; // Return null if empty after trimming
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the description from a DocBlock (summary + description body).
|
||||
*/
|
||||
public function getDescription(?DocBlock $docBlock): ?string
|
||||
{
|
||||
if (!$docBlock) {
|
||||
return null;
|
||||
}
|
||||
$summary = trim($docBlock->getSummary());
|
||||
$descriptionBody = trim((string) $docBlock->getDescription());
|
||||
|
||||
if ($summary && $descriptionBody) {
|
||||
return $summary."\n\n".$descriptionBody;
|
||||
}
|
||||
if ($summary) {
|
||||
return $summary;
|
||||
}
|
||||
if ($descriptionBody) {
|
||||
return $descriptionBody;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts "@param" tag information from a DocBlock, keyed by variable name (e.g., '$paramName').
|
||||
*
|
||||
* @return array<string, Param>
|
||||
*/
|
||||
public function getParamTags(?DocBlock $docBlock): array
|
||||
{
|
||||
if (!$docBlock) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var array<string, Param> $paramTags */
|
||||
$paramTags = [];
|
||||
foreach ($docBlock->getTagsByName('param') as $tag) {
|
||||
if ($tag instanceof Param && $tag->getVariableName()) {
|
||||
$paramTags['$'.$tag->getVariableName()] = $tag;
|
||||
}
|
||||
}
|
||||
|
||||
return $paramTags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the description string from a Param tag.
|
||||
*/
|
||||
public function getParamDescription(?Param $paramTag): ?string
|
||||
{
|
||||
return $paramTag ? (trim((string) $paramTag->getDescription()) ?: null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the type string from a Param tag.
|
||||
*/
|
||||
public function getParamTypeString(?Param $paramTag): ?string
|
||||
{
|
||||
if ($paramTag && $paramTag->getType()) {
|
||||
$typeFromTag = trim((string) $paramTag->getType());
|
||||
if (!empty($typeFromTag)) {
|
||||
return ltrim($typeFromTag, '\\');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
85
src/Capability/Discovery/HandlerResolver.php
Normal file
85
src/Capability/Discovery/HandlerResolver.php
Normal 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\Capability\Discovery;
|
||||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Utility class to validate and resolve MCP element handlers.
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
class HandlerResolver
|
||||
{
|
||||
/**
|
||||
* Validates and resolves a handler to a ReflectionMethod or ReflectionFunction instance.
|
||||
*
|
||||
* A handler can be:
|
||||
* - A Closure: function() { ... }
|
||||
* - An array: [ClassName::class, 'methodName'] (instance method)
|
||||
* - An array: [ClassName::class, 'staticMethod'] (static method, if callable)
|
||||
* - A string: InvokableClassName::class (which will resolve to its '__invoke' method)
|
||||
*
|
||||
* @param \Closure|array{0: string, 1: string}|string $handler the handler to resolve
|
||||
*
|
||||
* @throws InvalidArgumentException If the handler format is invalid, the class/method doesn't exist,
|
||||
* or the method is unsuitable (e.g., private, abstract).
|
||||
*/
|
||||
public static function resolve(\Closure|array|string $handler): \ReflectionMethod|\ReflectionFunction
|
||||
{
|
||||
if ($handler instanceof \Closure) {
|
||||
return new \ReflectionFunction($handler);
|
||||
}
|
||||
|
||||
if (\is_array($handler)) {
|
||||
if (2 !== \count($handler) || !isset($handler[0]) || !isset($handler[1]) || !\is_string($handler[0]) || !\is_string($handler[1])) {
|
||||
throw new InvalidArgumentException('Invalid array handler format. Expected [ClassName::class, \'methodName\'].');
|
||||
}
|
||||
[$className, $methodName] = $handler;
|
||||
if (!class_exists($className)) {
|
||||
throw new InvalidArgumentException(\sprintf('Handler class "%s" not found for array handler.', $className));
|
||||
}
|
||||
if (!method_exists($className, $methodName)) {
|
||||
throw new InvalidArgumentException(\sprintf('Handler method "%s" not found in class "%s" for array handler.', $methodName, $className));
|
||||
}
|
||||
} elseif (class_exists($handler)) {
|
||||
$className = $handler;
|
||||
$methodName = '__invoke';
|
||||
if (!method_exists($className, $methodName)) {
|
||||
throw new InvalidArgumentException(\sprintf('Invokable handler class "%s" must have a public "__invoke" method.', $className));
|
||||
}
|
||||
} else {
|
||||
throw new InvalidArgumentException('Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.');
|
||||
}
|
||||
|
||||
try {
|
||||
$reflectionMethod = new \ReflectionMethod($className, $methodName);
|
||||
|
||||
// For discovered elements (non-manual), still reject static methods
|
||||
// For manual elements, we'll allow static methods since they're callable
|
||||
if (!$reflectionMethod->isPublic()) {
|
||||
throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" must be public.', $className, $methodName));
|
||||
}
|
||||
if ($reflectionMethod->isAbstract()) {
|
||||
throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" must be abstract.', $className, $methodName));
|
||||
}
|
||||
if ($reflectionMethod->isConstructor() || $reflectionMethod->isDestructor()) {
|
||||
throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" cannot be a constructor or destructor.', $className, $methodName));
|
||||
}
|
||||
|
||||
return $reflectionMethod;
|
||||
} catch (\ReflectionException $e) {
|
||||
// This typically occurs if class_exists passed but ReflectionMethod still fails (rare)
|
||||
throw new InvalidArgumentException(\sprintf('Reflection error for handler "%s::%s": %s', $className, $methodName, $e->getMessage()), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
787
src/Capability/Discovery/SchemaGenerator.php
Normal file
787
src/Capability/Discovery/SchemaGenerator.php
Normal file
|
|
@ -0,0 +1,787 @@
|
|||
<?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\Capability\Discovery;
|
||||
|
||||
use Mcp\Capability\Attribute\Schema;
|
||||
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
||||
|
||||
/**
|
||||
* Generates JSON Schema for method parameters with intelligent Schema attribute handling.
|
||||
*
|
||||
* Priority system:
|
||||
* 1. Schema attributes (method-level and parameter-level)
|
||||
* 2. Reflection type information
|
||||
* 3. DocBlock type information
|
||||
*
|
||||
* @phpstan-import-type SchemaAttributeData from Schema
|
||||
*
|
||||
* @phpstan-type ParameterInfo array{
|
||||
* name: string,
|
||||
* doc_block_tag: Param|null,
|
||||
* reflection_param: \ReflectionParameter,
|
||||
* reflection_type_object: \ReflectionType|null,
|
||||
* type_string: string,
|
||||
* description: string|null,
|
||||
* required: bool,
|
||||
* allows_null: bool,
|
||||
* default_value: mixed|null,
|
||||
* has_default: bool,
|
||||
* is_variadic: bool,
|
||||
* parameter_schema: array<string, mixed>
|
||||
* }
|
||||
* @phpstan-type InferredParameterSchema array{
|
||||
* type?: string|array,
|
||||
* description?: string,
|
||||
* default?: mixed,
|
||||
* enum?: array<string|int>,
|
||||
* items?: array<string, mixed>,
|
||||
* }
|
||||
* @phpstan-type VariadicParameterSchema array{
|
||||
* type: 'array',
|
||||
* items?: array<string, mixed>,
|
||||
* description?: string,
|
||||
* parameter_schema?: array<string, mixed>
|
||||
* }
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
class SchemaGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DocBlockParser $docBlockParser,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a JSON Schema object (as a PHP array) for a method's or function's parameters.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function generate(\ReflectionMethod|\ReflectionFunction $reflection): array
|
||||
{
|
||||
$methodSchema = $this->extractMethodLevelSchema($reflection);
|
||||
|
||||
if ($methodSchema && isset($methodSchema['definition'])) {
|
||||
return $methodSchema['definition'];
|
||||
}
|
||||
|
||||
$parametersInfo = $this->parseParametersInfo($reflection);
|
||||
|
||||
return $this->buildSchemaFromParameters($parametersInfo, $methodSchema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts method-level or function-level Schema attribute.
|
||||
*
|
||||
* @return SchemaAttributeData
|
||||
*/
|
||||
private function extractMethodLevelSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array
|
||||
{
|
||||
$schemaAttrs = $reflection->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF);
|
||||
if (empty($schemaAttrs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var Schema $schemaAttr */
|
||||
$schemaAttr = $schemaAttrs[0]->newInstance();
|
||||
|
||||
return $schemaAttr->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts parameter-level Schema attribute.
|
||||
*
|
||||
* @return SchemaAttributeData
|
||||
*/
|
||||
private function extractParameterLevelSchema(\ReflectionParameter $parameter): array
|
||||
{
|
||||
$schemaAttrs = $parameter->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF);
|
||||
if (empty($schemaAttrs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var Schema $schemaAttr */
|
||||
$schemaAttr = $schemaAttrs[0]->newInstance();
|
||||
|
||||
return $schemaAttr->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the final schema from parameter information and method-level schema.
|
||||
*
|
||||
* @param ParameterInfo[] $parametersInfo
|
||||
* @param SchemaAttributeData $methodSchema
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildSchemaFromParameters(array $parametersInfo, ?array $methodSchema): array
|
||||
{
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
'properties' => [],
|
||||
'required' => [],
|
||||
];
|
||||
|
||||
// Apply method-level schema as base
|
||||
if ($methodSchema) {
|
||||
$schema = array_merge($schema, $methodSchema);
|
||||
if (!isset($schema['type'])) {
|
||||
$schema['type'] = 'object';
|
||||
}
|
||||
if (!isset($schema['properties'])) {
|
||||
$schema['properties'] = [];
|
||||
}
|
||||
if (!isset($schema['required'])) {
|
||||
$schema['required'] = [];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($parametersInfo as $paramInfo) {
|
||||
$paramName = $paramInfo['name'];
|
||||
|
||||
$methodLevelParamSchema = $schema['properties'][$paramName] ?? null;
|
||||
|
||||
$paramSchema = $this->buildParameterSchema($paramInfo, $methodLevelParamSchema);
|
||||
|
||||
$schema['properties'][$paramName] = $paramSchema;
|
||||
|
||||
if ($paramInfo['required'] && !\in_array($paramName, $schema['required'])) {
|
||||
$schema['required'][] = $paramName;
|
||||
} elseif (!$paramInfo['required'] && ($key = array_search($paramName, $schema['required'])) !== false) {
|
||||
unset($schema['required'][$key]);
|
||||
$schema['required'] = array_values($schema['required']); // Re-index
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty properties
|
||||
if (empty($schema['properties'])) {
|
||||
$schema['properties'] = new \stdClass();
|
||||
}
|
||||
if (empty($schema['required'])) {
|
||||
unset($schema['required']);
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the final schema for a single parameter by merging all three levels.
|
||||
*
|
||||
* @param ParameterInfo $paramInfo
|
||||
* @param array<string, mixed>|null $methodLevelParamSchema
|
||||
*/
|
||||
private function buildParameterSchema(array $paramInfo, ?array $methodLevelParamSchema): array
|
||||
{
|
||||
if ($paramInfo['is_variadic']) {
|
||||
return $this->buildVariadicParameterSchema($paramInfo);
|
||||
}
|
||||
|
||||
$inferredSchema = $this->buildInferredParameterSchema($paramInfo);
|
||||
|
||||
// Method-level takes precedence over inferred schema
|
||||
$mergedSchema = $inferredSchema;
|
||||
if ($methodLevelParamSchema) {
|
||||
$mergedSchema = array_merge($inferredSchema, $methodLevelParamSchema);
|
||||
}
|
||||
|
||||
// Parameter-level takes highest precedence
|
||||
$parameterLevelSchema = $paramInfo['parameter_schema'];
|
||||
if (!empty($parameterLevelSchema)) {
|
||||
$mergedSchema = array_merge($mergedSchema, $parameterLevelSchema);
|
||||
}
|
||||
|
||||
return $mergedSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds parameter schema from inferred type and docblock information only.
|
||||
* Returns empty array for variadic parameters (handled separately).
|
||||
*
|
||||
* @param ParameterInfo $paramInfo
|
||||
*
|
||||
* @return InferredParameterSchema
|
||||
*/
|
||||
private function buildInferredParameterSchema(array $paramInfo): array
|
||||
{
|
||||
$paramSchema = [];
|
||||
|
||||
// Variadic parameters are handled separately
|
||||
if ($paramInfo['is_variadic']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Infer JSON Schema types
|
||||
$jsonTypes = $this->inferParameterTypes($paramInfo);
|
||||
|
||||
if (1 === \count($jsonTypes)) {
|
||||
$paramSchema['type'] = $jsonTypes[0];
|
||||
} elseif (\count($jsonTypes) > 1) {
|
||||
$paramSchema['type'] = $jsonTypes;
|
||||
}
|
||||
|
||||
// Add description from docblock
|
||||
if ($paramInfo['description']) {
|
||||
$paramSchema['description'] = $paramInfo['description'];
|
||||
}
|
||||
|
||||
// Add default value only if parameter actually has a default
|
||||
if ($paramInfo['has_default']) {
|
||||
$paramSchema['default'] = $paramInfo['default_value'];
|
||||
}
|
||||
|
||||
// Handle enums
|
||||
$paramSchema = $this->applyEnumConstraints($paramSchema, $paramInfo);
|
||||
|
||||
// Handle array items
|
||||
return $this->applyArrayConstraints($paramSchema, $paramInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds schema for variadic parameters.
|
||||
*
|
||||
* @param ParameterInfo $paramInfo
|
||||
*
|
||||
* @return VariadicParameterSchema
|
||||
*/
|
||||
private function buildVariadicParameterSchema(array $paramInfo): array
|
||||
{
|
||||
$paramSchema = ['type' => 'array'];
|
||||
|
||||
// Apply parameter-level Schema attributes first
|
||||
if (!empty($paramInfo['parameter_schema'])) {
|
||||
$paramSchema = array_merge($paramSchema, $paramInfo['parameter_schema']);
|
||||
// Ensure type is always array for variadic
|
||||
$paramSchema['type'] = 'array';
|
||||
}
|
||||
|
||||
if ($paramInfo['description']) {
|
||||
$paramSchema['description'] = $paramInfo['description'];
|
||||
}
|
||||
|
||||
// If no items specified by Schema attribute, infer from type
|
||||
if (!isset($paramSchema['items'])) {
|
||||
$itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']);
|
||||
$nonNullItemTypes = array_filter($itemJsonTypes, fn ($t) => 'null' !== $t);
|
||||
|
||||
if (1 === \count($nonNullItemTypes)) {
|
||||
$paramSchema['items'] = ['type' => $nonNullItemTypes[0]];
|
||||
}
|
||||
}
|
||||
|
||||
return $paramSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers JSON Schema types for a parameter.
|
||||
*
|
||||
* @param ParameterInfo $paramInfo
|
||||
*/
|
||||
private function inferParameterTypes(array $paramInfo): array
|
||||
{
|
||||
$jsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']);
|
||||
|
||||
if ($paramInfo['allows_null'] && 'mixed' !== strtolower($paramInfo['type_string']) && !\in_array('null', $jsonTypes)) {
|
||||
$jsonTypes[] = 'null';
|
||||
}
|
||||
|
||||
if (\count($jsonTypes) > 1) {
|
||||
// Sort but ensure null comes first for consistency
|
||||
$nullIndex = array_search('null', $jsonTypes);
|
||||
if (false !== $nullIndex) {
|
||||
unset($jsonTypes[$nullIndex]);
|
||||
sort($jsonTypes);
|
||||
array_unshift($jsonTypes, 'null');
|
||||
} else {
|
||||
sort($jsonTypes);
|
||||
}
|
||||
}
|
||||
|
||||
return $jsonTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies enum constraints to parameter schema.
|
||||
*/
|
||||
private function applyEnumConstraints(array $paramSchema, array $paramInfo): array
|
||||
{
|
||||
$reflectionType = $paramInfo['reflection_type_object'];
|
||||
|
||||
if (!($reflectionType instanceof \ReflectionNamedType) || $reflectionType->isBuiltin() || !enum_exists($reflectionType->getName())) {
|
||||
return $paramSchema;
|
||||
}
|
||||
|
||||
$enumClass = $reflectionType->getName();
|
||||
$enumReflection = new \ReflectionEnum($enumClass);
|
||||
$backingTypeReflection = $enumReflection->getBackingType();
|
||||
|
||||
if ($enumReflection->isBacked() && $backingTypeReflection instanceof \ReflectionNamedType) {
|
||||
$paramSchema['enum'] = array_column($enumClass::cases(), 'value');
|
||||
$jsonBackingType = match ($backingTypeReflection->getName()) {
|
||||
'int' => 'integer',
|
||||
'string' => 'string',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($jsonBackingType) {
|
||||
if (isset($paramSchema['type']) && \is_array($paramSchema['type']) && \in_array('null', $paramSchema['type'])) {
|
||||
$paramSchema['type'] = ['null', $jsonBackingType];
|
||||
} else {
|
||||
$paramSchema['type'] = $jsonBackingType;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-backed enum - use names as enum values
|
||||
$paramSchema['enum'] = array_column($enumClass::cases(), 'name');
|
||||
if (isset($paramSchema['type']) && \is_array($paramSchema['type']) && \in_array('null', $paramSchema['type'])) {
|
||||
$paramSchema['type'] = ['null', 'string'];
|
||||
} else {
|
||||
$paramSchema['type'] = 'string';
|
||||
}
|
||||
}
|
||||
|
||||
return $paramSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies array-specific constraints to parameter schema.
|
||||
*/
|
||||
private function applyArrayConstraints(array $paramSchema, array $paramInfo): array
|
||||
{
|
||||
if (!isset($paramSchema['type'])) {
|
||||
return $paramSchema;
|
||||
}
|
||||
|
||||
$typeString = $paramInfo['type_string'];
|
||||
$allowsNull = $paramInfo['allows_null'];
|
||||
|
||||
// Handle object-like arrays using array{} syntax
|
||||
if (preg_match('/^array\s*{/i', $typeString)) {
|
||||
$objectSchema = $this->inferArrayItemsType($typeString);
|
||||
if (\is_array($objectSchema) && isset($objectSchema['properties'])) {
|
||||
$paramSchema = array_merge($paramSchema, $objectSchema);
|
||||
$paramSchema['type'] = $allowsNull ? ['object', 'null'] : 'object';
|
||||
}
|
||||
}
|
||||
// Handle regular arrays
|
||||
elseif (\in_array('array', $this->mapPhpTypeToJsonSchemaType($typeString))) {
|
||||
$itemsType = $this->inferArrayItemsType($typeString);
|
||||
if ('any' !== $itemsType) {
|
||||
if (\is_string($itemsType)) {
|
||||
$paramSchema['items'] = ['type' => $itemsType];
|
||||
} else {
|
||||
if (!isset($itemsType['type']) && isset($itemsType['properties'])) {
|
||||
$itemsType = array_merge(['type' => 'object'], $itemsType);
|
||||
}
|
||||
$paramSchema['items'] = $itemsType;
|
||||
}
|
||||
}
|
||||
|
||||
if ($allowsNull) {
|
||||
$paramSchema['type'] = ['array', 'null'];
|
||||
sort($paramSchema['type']);
|
||||
} else {
|
||||
$paramSchema['type'] = 'array';
|
||||
}
|
||||
}
|
||||
|
||||
return $paramSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses detailed information about a method's parameters.
|
||||
*
|
||||
* @return ParameterInfo[]
|
||||
*/
|
||||
private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $reflection): array
|
||||
{
|
||||
$docComment = $reflection->getDocComment() ?: null;
|
||||
$docBlock = $this->docBlockParser->parseDocBlock($docComment);
|
||||
$paramTags = $this->docBlockParser->getParamTags($docBlock);
|
||||
$parametersInfo = [];
|
||||
|
||||
foreach ($reflection->getParameters() as $rp) {
|
||||
$paramName = $rp->getName();
|
||||
$paramTag = $paramTags['$'.$paramName] ?? null;
|
||||
|
||||
$reflectionType = $rp->getType();
|
||||
$typeString = $this->getParameterTypeString($rp, $paramTag);
|
||||
$description = $this->docBlockParser->getParamDescription($paramTag);
|
||||
$hasDefault = $rp->isDefaultValueAvailable();
|
||||
$defaultValue = $hasDefault ? $rp->getDefaultValue() : null;
|
||||
$isVariadic = $rp->isVariadic();
|
||||
|
||||
$parameterSchema = $this->extractParameterLevelSchema($rp);
|
||||
|
||||
if ($defaultValue instanceof \BackedEnum) {
|
||||
$defaultValue = $defaultValue->value;
|
||||
}
|
||||
|
||||
if ($defaultValue instanceof \UnitEnum) {
|
||||
$defaultValue = $defaultValue->name;
|
||||
}
|
||||
|
||||
$allowsNull = false;
|
||||
if ($reflectionType && $reflectionType->allowsNull()) {
|
||||
$allowsNull = true;
|
||||
} elseif ($hasDefault && null === $defaultValue) {
|
||||
$allowsNull = true;
|
||||
} elseif (str_contains($typeString, 'null') || 'mixed' === strtolower($typeString)) {
|
||||
$allowsNull = true;
|
||||
}
|
||||
|
||||
$parametersInfo[] = [
|
||||
'name' => $paramName,
|
||||
'doc_block_tag' => $paramTag,
|
||||
'reflection_param' => $rp,
|
||||
'reflection_type_object' => $reflectionType,
|
||||
'type_string' => $typeString,
|
||||
'description' => $description,
|
||||
'required' => !$rp->isOptional(),
|
||||
'allows_null' => $allowsNull,
|
||||
'default_value' => $defaultValue,
|
||||
'has_default' => $hasDefault,
|
||||
'is_variadic' => $isVariadic,
|
||||
'parameter_schema' => $parameterSchema,
|
||||
];
|
||||
}
|
||||
|
||||
return $parametersInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the type string for a parameter, prioritizing DocBlock.
|
||||
*/
|
||||
private function getParameterTypeString(\ReflectionParameter $rp, ?Param $paramTag): string
|
||||
{
|
||||
$docBlockType = $this->docBlockParser->getParamTypeString($paramTag);
|
||||
$isDocBlockTypeGeneric = false;
|
||||
|
||||
if (null !== $docBlockType) {
|
||||
if (\in_array(strtolower($docBlockType), ['mixed', 'unknown', ''])) {
|
||||
$isDocBlockTypeGeneric = true;
|
||||
}
|
||||
} else {
|
||||
$isDocBlockTypeGeneric = true; // No tag or no type in tag implies generic
|
||||
}
|
||||
|
||||
$reflectionType = $rp->getType();
|
||||
$reflectionTypeString = null;
|
||||
if ($reflectionType) {
|
||||
$reflectionTypeString = $this->getTypeStringFromReflection($reflectionType, $rp->allowsNull());
|
||||
}
|
||||
|
||||
// Prioritize Reflection if DocBlock type is generic AND Reflection provides a more specific type
|
||||
if ($isDocBlockTypeGeneric && null !== $reflectionTypeString && 'mixed' !== $reflectionTypeString) {
|
||||
return $reflectionTypeString;
|
||||
}
|
||||
|
||||
// Otherwise, use the DocBlock type if it was valid and non-generic
|
||||
if (null !== $docBlockType && !$isDocBlockTypeGeneric) {
|
||||
// Consider if DocBlock adds nullability missing from reflection
|
||||
if (false !== stripos($docBlockType, 'null') && $reflectionTypeString && false === stripos($reflectionTypeString, 'null') && !str_ends_with($reflectionTypeString, '|null')) {
|
||||
// If reflection didn't capture null, but docblock did, append |null (if not already mixed)
|
||||
if ('mixed' !== $reflectionTypeString) {
|
||||
return $reflectionTypeString.'|null';
|
||||
}
|
||||
}
|
||||
|
||||
return $docBlockType;
|
||||
}
|
||||
|
||||
// Fallback to Reflection type even if it was generic ('mixed')
|
||||
if (null !== $reflectionTypeString) {
|
||||
return $reflectionTypeString;
|
||||
}
|
||||
|
||||
// Default to 'mixed' if nothing else found
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ReflectionType object into a type string representation.
|
||||
*/
|
||||
private function getTypeStringFromReflection(?\ReflectionType $type, bool $nativeAllowsNull): string
|
||||
{
|
||||
if (null === $type) {
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
$types = [];
|
||||
if ($type instanceof \ReflectionUnionType) {
|
||||
foreach ($type->getTypes() as $innerType) {
|
||||
$types[] = $this->getTypeStringFromReflection($innerType, $innerType->allowsNull());
|
||||
}
|
||||
if ($nativeAllowsNull) {
|
||||
$types = array_filter($types, fn ($t) => 'null' !== strtolower($t));
|
||||
}
|
||||
$typeString = implode('|', array_unique(array_filter($types)));
|
||||
} elseif ($type instanceof \ReflectionIntersectionType) {
|
||||
foreach ($type->getTypes() as $innerType) {
|
||||
$types[] = $this->getTypeStringFromReflection($innerType, false);
|
||||
}
|
||||
$typeString = implode('&', array_unique(array_filter($types)));
|
||||
} elseif ($type instanceof \ReflectionNamedType) {
|
||||
$typeString = $type->getName();
|
||||
} else {
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
$typeString = match (strtolower($typeString)) {
|
||||
'bool' => 'boolean',
|
||||
'int' => 'integer',
|
||||
'float', 'double' => 'number',
|
||||
'str' => 'string',
|
||||
default => $typeString,
|
||||
};
|
||||
|
||||
$isNullable = $nativeAllowsNull;
|
||||
if ($type instanceof \ReflectionNamedType && 'mixed' === $type->getName()) {
|
||||
$isNullable = true;
|
||||
}
|
||||
|
||||
if ($type instanceof \ReflectionUnionType && !$nativeAllowsNull) {
|
||||
foreach ($type->getTypes() as $innerType) {
|
||||
if ($innerType instanceof \ReflectionNamedType && 'null' === strtolower($innerType->getName())) {
|
||||
$isNullable = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($isNullable && 'mixed' !== $typeString && false === stripos($typeString, 'null')) {
|
||||
if (!str_ends_with($typeString, '|null') && !str_ends_with($typeString, '&null')) {
|
||||
$typeString .= '|null';
|
||||
}
|
||||
}
|
||||
|
||||
// Remove leading backslash from class names, but handle built-ins like 'int' or unions like 'int|string'
|
||||
if (str_contains($typeString, '\\')) {
|
||||
$parts = preg_split('/([|&])/', $typeString, -1, \PREG_SPLIT_DELIM_CAPTURE);
|
||||
$processedParts = array_map(fn ($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts);
|
||||
$typeString = implode('', $processedParts);
|
||||
}
|
||||
|
||||
return $typeString ?: 'mixed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a PHP type string (potentially a union) to an array of JSON Schema type names.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array
|
||||
{
|
||||
$normalizedType = strtolower(trim($phpTypeString));
|
||||
|
||||
// PRIORITY 1: Check for array{} syntax which should be treated as object
|
||||
if (preg_match('/^array\s*{/i', $normalizedType)) {
|
||||
return ['object'];
|
||||
}
|
||||
|
||||
// PRIORITY 2: Check for array syntax first (T[] or generics)
|
||||
if (
|
||||
str_contains($normalizedType, '[]')
|
||||
|| preg_match('/^(array|list|iterable|collection)</i', $normalizedType)
|
||||
) {
|
||||
return ['array'];
|
||||
}
|
||||
|
||||
// PRIORITY 3: Handle unions (recursive)
|
||||
if (str_contains($normalizedType, '|')) {
|
||||
$types = explode('|', $normalizedType);
|
||||
$jsonTypes = [];
|
||||
foreach ($types as $type) {
|
||||
$mapped = $this->mapPhpTypeToJsonSchemaType(trim($type));
|
||||
$jsonTypes = array_merge($jsonTypes, $mapped);
|
||||
}
|
||||
|
||||
return array_values(array_unique($jsonTypes));
|
||||
}
|
||||
|
||||
// PRIORITY 4: Handle simple built-in types
|
||||
return match ($normalizedType) {
|
||||
'string', 'scalar' => ['string'],
|
||||
'?string' => ['null', 'string'],
|
||||
'int', 'integer' => ['integer'],
|
||||
'?int', '?integer' => ['null', 'integer'],
|
||||
'float', 'double', 'number' => ['number'],
|
||||
'?float', '?double', '?number' => ['null', 'number'],
|
||||
'bool', 'boolean' => ['boolean'],
|
||||
'?bool', '?boolean' => ['null', 'boolean'],
|
||||
'array' => ['array'],
|
||||
'?array' => ['null', 'array'],
|
||||
'object', 'stdclass' => ['object'],
|
||||
'?object', '?stdclass' => ['null', 'object'],
|
||||
'null' => ['null'],
|
||||
'resource', 'callable' => ['object'],
|
||||
'mixed' => [],
|
||||
'void', 'never' => [],
|
||||
default => ['object'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers the 'items' schema type for an array based on DocBlock type hints.
|
||||
*
|
||||
* @return string|array<string, mixed>
|
||||
*/
|
||||
private function inferArrayItemsType(string $phpTypeString): string|array
|
||||
{
|
||||
$normalizedType = trim($phpTypeString);
|
||||
|
||||
// Case 1: Simple T[] syntax (e.g., string[], int[], bool[], etc.)
|
||||
if (preg_match('/^(\\??)([\w\\\\]+)\\s*\\[\\]$/i', $normalizedType, $matches)) {
|
||||
$itemType = strtolower($matches[2]);
|
||||
|
||||
return $this->mapSimpleTypeToJsonSchema($itemType);
|
||||
}
|
||||
|
||||
// Case 2: Generic array<T> syntax (e.g., array<string>, array<int>, etc.)
|
||||
if (preg_match('/^(\\??)array\s*<\s*([\w\\\\|]+)\s*>$/i', $normalizedType, $matches)) {
|
||||
$itemType = strtolower($matches[2]);
|
||||
|
||||
return $this->mapSimpleTypeToJsonSchema($itemType);
|
||||
}
|
||||
|
||||
// Case 3: Nested array<array<T>> syntax or T[][] syntax
|
||||
if (
|
||||
preg_match('/^(\\??)array\s*<\s*array\s*<\s*([\w\\\\|]+)\s*>\s*>$/i', $normalizedType, $matches)
|
||||
|| preg_match('/^(\\??)([\w\\\\]+)\s*\[\]\[\]$/i', $normalizedType, $matches)
|
||||
) {
|
||||
$innerType = $this->mapSimpleTypeToJsonSchema(isset($matches[2]) ? strtolower($matches[2]) : 'any'); /* @phpstan-ignore isset.offset */
|
||||
|
||||
// Return a schema for array with items being arrays
|
||||
return [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => $innerType,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Case 4: Object-like array syntax (e.g., array{name: string, age: int})
|
||||
if (preg_match('/^(\\??)array\s*\{(.+)\}$/is', $normalizedType, $matches)) {
|
||||
return $this->parseObjectLikeArray($matches[2]);
|
||||
}
|
||||
|
||||
return 'any';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses object-like array syntax into a JSON Schema object.
|
||||
*
|
||||
* @return array{
|
||||
* type: 'object',
|
||||
* properties?: array<string, mixed>,
|
||||
* required?: array<string>
|
||||
* }
|
||||
*/
|
||||
private function parseObjectLikeArray(string $propertiesStr): array
|
||||
{
|
||||
$properties = [];
|
||||
$required = [];
|
||||
|
||||
// Parse properties from the string, handling nested structures
|
||||
$depth = 0;
|
||||
$buffer = '';
|
||||
|
||||
for ($i = 0; $i < \strlen($propertiesStr); ++$i) {
|
||||
$char = $propertiesStr[$i];
|
||||
|
||||
// Track nested braces
|
||||
if ('{' === $char) {
|
||||
++$depth;
|
||||
$buffer .= $char;
|
||||
} elseif ('}' === $char) {
|
||||
--$depth;
|
||||
$buffer .= $char;
|
||||
}
|
||||
// Property separator (comma)
|
||||
elseif (',' === $char && 0 === $depth) {
|
||||
// Process the completed property
|
||||
$this->parsePropertyDefinition(trim($buffer), $properties, $required);
|
||||
$buffer = '';
|
||||
} else {
|
||||
$buffer .= $char;
|
||||
}
|
||||
}
|
||||
|
||||
// Process the last property
|
||||
if (!empty($buffer)) {
|
||||
$this->parsePropertyDefinition(trim($buffer), $properties, $required);
|
||||
}
|
||||
|
||||
if (!empty($properties)) {
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => $properties,
|
||||
'required' => $required,
|
||||
];
|
||||
}
|
||||
|
||||
return ['type' => 'object'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single property definition from an object-like array syntax.
|
||||
*/
|
||||
private function parsePropertyDefinition(string $propDefinition, array &$properties, array &$required): void
|
||||
{
|
||||
// Match property name and type
|
||||
if (preg_match('/^([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s*:\s*(.+)$/i', $propDefinition, $matches)) {
|
||||
$propName = $matches[1];
|
||||
$propType = trim($matches[2]);
|
||||
|
||||
// Add to required properties
|
||||
$required[] = $propName;
|
||||
|
||||
// Check for nested array{} syntax
|
||||
if (preg_match('/^array\s*\{(.+)\}$/is', $propType, $nestedMatches)) {
|
||||
$nestedSchema = $this->parseObjectLikeArray($nestedMatches[1]);
|
||||
$properties[$propName] = $nestedSchema;
|
||||
}
|
||||
// Check for array<T> or T[] syntax
|
||||
elseif (
|
||||
preg_match('/^array\s*<\s*([\w\\\\|]+)\s*>$/i', $propType, $arrayMatches)
|
||||
|| preg_match('/^([\w\\\\]+)\s*\[\]$/i', $propType, $arrayMatches)
|
||||
) {
|
||||
$itemType = $arrayMatches[1] ?? 'any'; /* @phpstan-ignore nullCoalesce.offset */
|
||||
$properties[$propName] = [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => $this->mapSimpleTypeToJsonSchema($itemType),
|
||||
],
|
||||
];
|
||||
}
|
||||
// Simple type
|
||||
else {
|
||||
$properties[$propName] = ['type' => $this->mapSimpleTypeToJsonSchema($propType)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to map basic PHP types to JSON Schema types.
|
||||
*/
|
||||
private function mapSimpleTypeToJsonSchema(string $type): string
|
||||
{
|
||||
return match (strtolower($type)) {
|
||||
'string' => 'string',
|
||||
'int', 'integer' => 'integer',
|
||||
'bool', 'boolean' => 'boolean',
|
||||
'float', 'double', 'number' => 'number',
|
||||
'array' => 'array',
|
||||
'object', 'stdclass' => 'object',
|
||||
default => \in_array(strtolower($type), ['datetime', 'datetimeinterface']) ? 'string' : 'object',
|
||||
};
|
||||
}
|
||||
}
|
||||
336
src/Capability/Discovery/SchemaValidator.php
Normal file
336
src/Capability/Discovery/SchemaValidator.php
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
<?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\Capability\Discovery;
|
||||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Opis\JsonSchema\Errors\ValidationError;
|
||||
use Opis\JsonSchema\Validator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* Validates data against JSON Schema definitions using opis/json-schema.
|
||||
*
|
||||
* @phpstan-type Error array{
|
||||
* pointer: string,
|
||||
* keyword: string,
|
||||
* message: string,
|
||||
* }
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
class SchemaValidator
|
||||
{
|
||||
private ?Validator $jsonSchemaValidator = null;
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger = new NullLogger(),
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates data against a JSON schema.
|
||||
*
|
||||
* @param mixed $data the data to validate (should generally be decoded JSON)
|
||||
* @param array<string, mixed>|object $schema the JSON Schema definition (as PHP array or object)
|
||||
*
|
||||
* @return list<array{pointer: string, keyword: string, message: string}> array of validation errors, empty if valid
|
||||
*/
|
||||
public function validateAgainstJsonSchema(mixed $data, array|object $schema): array
|
||||
{
|
||||
if (\is_array($data) && empty($data)) {
|
||||
$data = new \stdClass();
|
||||
}
|
||||
|
||||
try {
|
||||
// --- Schema Preparation ---
|
||||
if (\is_array($schema)) {
|
||||
$schemaJson = json_encode($schema, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
|
||||
$schemaObject = json_decode($schemaJson, false, 512, \JSON_THROW_ON_ERROR);
|
||||
} elseif (\is_object($schema)) {
|
||||
// This might be overly cautious but safer against varied inputs.
|
||||
$schemaJson = json_encode($schema, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
|
||||
$schemaObject = json_decode($schemaJson, false, 512, \JSON_THROW_ON_ERROR);
|
||||
} else {
|
||||
throw new InvalidArgumentException('Schema must be an array or object.');
|
||||
}
|
||||
|
||||
// --- Data Preparation ---
|
||||
// Opis Validator generally prefers objects for object validation
|
||||
$dataToValidate = $this->convertDataForValidator($data);
|
||||
} catch (\JsonException $e) {
|
||||
$this->logger->error('MCP SDK: Invalid schema structure provided for validation (JSON conversion failed).', ['exception' => $e]);
|
||||
|
||||
return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Invalid schema definition provided (JSON error).']];
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->logger->error('MCP SDK: Invalid schema structure provided for validation.', ['exception' => $e]);
|
||||
|
||||
return [['pointer' => '', 'keyword' => 'internal', 'message' => $e->getMessage()]];
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('MCP SDK: Error preparing data/schema for validation.', ['exception' => $e]);
|
||||
|
||||
return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Internal validation preparation error.']];
|
||||
}
|
||||
|
||||
$validator = $this->getJsonSchemaValidator();
|
||||
|
||||
try {
|
||||
$result = $validator->validate($dataToValidate, $schemaObject);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('MCP SDK: JSON Schema validation failed internally.', [
|
||||
'exception_message' => $e->getMessage(),
|
||||
'exception_trace' => $e->getTraceAsString(),
|
||||
'data' => json_encode($dataToValidate),
|
||||
'schema' => json_encode($schemaObject),
|
||||
]);
|
||||
|
||||
return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: '.$e->getMessage()]];
|
||||
}
|
||||
|
||||
if ($result->isValid()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$formattedErrors = [];
|
||||
$topError = $result->error();
|
||||
|
||||
if ($topError) {
|
||||
$this->collectSubErrors($topError, $formattedErrors);
|
||||
}
|
||||
|
||||
if (empty($formattedErrors) && $topError) { // Fallback
|
||||
$formattedErrors[] = [
|
||||
'pointer' => $this->formatJsonPointerPath($topError->data()->path()),
|
||||
'keyword' => $topError->keyword(),
|
||||
'message' => $this->formatValidationError($topError),
|
||||
];
|
||||
}
|
||||
|
||||
return $formattedErrors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the JSON Schema validator instance.
|
||||
*/
|
||||
private function getJsonSchemaValidator(): Validator
|
||||
{
|
||||
if (null === $this->jsonSchemaValidator) {
|
||||
$this->jsonSchemaValidator = new Validator();
|
||||
// Potentially configure resolver here if needed later
|
||||
}
|
||||
|
||||
return $this->jsonSchemaValidator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively converts associative arrays to stdClass objects for validator compatibility.
|
||||
*/
|
||||
private function convertDataForValidator(mixed $data): mixed
|
||||
{
|
||||
if (\is_array($data)) {
|
||||
// Check if it's an associative array (keys are not sequential numbers 0..N-1)
|
||||
if (!empty($data) && array_keys($data) !== range(0, \count($data) - 1)) {
|
||||
$obj = new \stdClass();
|
||||
foreach ($data as $key => $value) {
|
||||
$obj->{$key} = $this->convertDataForValidator($value);
|
||||
}
|
||||
|
||||
return $obj;
|
||||
} else {
|
||||
// It's a list (sequential array), convert items recursively
|
||||
return array_map([$this, 'convertDataForValidator'], $data);
|
||||
}
|
||||
} elseif (\is_object($data) && $data instanceof \stdClass) {
|
||||
// Deep copy/convert stdClass objects as well
|
||||
$obj = new \stdClass();
|
||||
foreach (get_object_vars($data) as $key => $value) {
|
||||
$obj->{$key} = $this->convertDataForValidator($value);
|
||||
}
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
// Leave other objects and scalar types as they are
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collects leaf validation errors.
|
||||
*
|
||||
* @param Error[] $collectedErrors
|
||||
*/
|
||||
private function collectSubErrors(ValidationError $error, array &$collectedErrors): void
|
||||
{
|
||||
$subErrors = $error->subErrors();
|
||||
if (empty($subErrors)) {
|
||||
$collectedErrors[] = [
|
||||
'pointer' => $this->formatJsonPointerPath($error->data()->path()),
|
||||
'keyword' => $error->keyword(),
|
||||
'message' => $this->formatValidationError($error),
|
||||
];
|
||||
} else {
|
||||
foreach ($subErrors as $subError) {
|
||||
$this->collectSubErrors($subError, $collectedErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the path array into a JSON Pointer string.
|
||||
*
|
||||
* @param string[]|int[]|null $pathComponents
|
||||
*/
|
||||
private function formatJsonPointerPath(?array $pathComponents): string
|
||||
{
|
||||
if (empty($pathComponents)) {
|
||||
return '/';
|
||||
}
|
||||
$escapedComponents = array_map(function ($component) {
|
||||
$componentStr = (string) $component;
|
||||
|
||||
return str_replace(['~', '/'], ['~0', '~1'], $componentStr);
|
||||
}, $pathComponents);
|
||||
|
||||
return '/'.implode('/', $escapedComponents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an Opis SchemaValidationError into a user-friendly message.
|
||||
*/
|
||||
private function formatValidationError(ValidationError $error): string
|
||||
{
|
||||
$keyword = $error->keyword();
|
||||
$args = $error->args();
|
||||
$message = "Constraint `{$keyword}` failed.";
|
||||
|
||||
switch (strtolower($keyword)) {
|
||||
case 'required':
|
||||
$missing = $args['missing'] ?? [];
|
||||
$formattedMissing = implode(', ', array_map(fn ($p) => "`{$p}`", $missing));
|
||||
$message = "Missing required properties: {$formattedMissing}.";
|
||||
break;
|
||||
case 'type':
|
||||
$expected = implode('|', (array) ($args['expected'] ?? []));
|
||||
$used = $args['used'] ?? 'unknown';
|
||||
$message = "Invalid type. Expected `{$expected}`, but received `{$used}`.";
|
||||
break;
|
||||
case 'enum':
|
||||
$schemaData = $error->schema()->info()->data();
|
||||
$allowedValues = [];
|
||||
if (\is_object($schemaData) && property_exists($schemaData, 'enum') && \is_array($schemaData->enum)) {
|
||||
$allowedValues = $schemaData->enum;
|
||||
} elseif (\is_array($schemaData) && isset($schemaData['enum']) && \is_array($schemaData['enum'])) {
|
||||
$allowedValues = $schemaData['enum'];
|
||||
} else {
|
||||
$this->logger->warning("MCP SDK: Could not retrieve 'enum' values from schema info for error.", ['error_args' => $args]);
|
||||
}
|
||||
if (empty($allowedValues)) {
|
||||
$message = 'Value does not match the allowed enumeration.';
|
||||
} else {
|
||||
$formattedAllowed = array_map(function ($v) { /* ... formatting logic ... */
|
||||
if (\is_string($v)) {
|
||||
return '"'.$v.'"';
|
||||
}
|
||||
if (\is_bool($v)) {
|
||||
return $v ? 'true' : 'false';
|
||||
}
|
||||
if (null === $v) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
return (string) $v;
|
||||
}, $allowedValues);
|
||||
$message = 'Value must be one of the allowed values: '.implode(', ', $formattedAllowed).'.';
|
||||
}
|
||||
break;
|
||||
case 'const':
|
||||
$expected = json_encode($args['expected'] ?? 'null', \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
|
||||
$message = "Value must be equal to the constant value: {$expected}.";
|
||||
break;
|
||||
case 'minLength': // Corrected casing
|
||||
$min = $args['min'] ?? '?';
|
||||
$message = "String must be at least {$min} characters long.";
|
||||
break;
|
||||
case 'maxLength': // Corrected casing
|
||||
$max = $args['max'] ?? '?';
|
||||
$message = "String must not be longer than {$max} characters.";
|
||||
break;
|
||||
case 'pattern':
|
||||
$pattern = $args['pattern'] ?? '?';
|
||||
$message = "String does not match the required pattern: `{$pattern}`.";
|
||||
break;
|
||||
case 'minimum':
|
||||
$min = $args['min'] ?? '?';
|
||||
$message = "Number must be greater than or equal to {$min}.";
|
||||
break;
|
||||
case 'maximum':
|
||||
$max = $args['max'] ?? '?';
|
||||
$message = "Number must be less than or equal to {$max}.";
|
||||
break;
|
||||
case 'exclusiveMinimum': // Corrected casing
|
||||
$min = $args['min'] ?? '?';
|
||||
$message = "Number must be strictly greater than {$min}.";
|
||||
break;
|
||||
case 'exclusiveMaximum': // Corrected casing
|
||||
$max = $args['max'] ?? '?';
|
||||
$message = "Number must be strictly less than {$max}.";
|
||||
break;
|
||||
case 'multipleOf': // Corrected casing
|
||||
$value = $args['value'] ?? '?';
|
||||
$message = "Number must be a multiple of {$value}.";
|
||||
break;
|
||||
case 'minItems': // Corrected casing
|
||||
$min = $args['min'] ?? '?';
|
||||
$message = "Array must contain at least {$min} items.";
|
||||
break;
|
||||
case 'maxItems': // Corrected casing
|
||||
$max = $args['max'] ?? '?';
|
||||
$message = "Array must contain no more than {$max} items.";
|
||||
break;
|
||||
case 'uniqueItems': // Corrected casing
|
||||
$message = 'Array items must be unique.';
|
||||
break;
|
||||
case 'minProperties': // Corrected casing
|
||||
$min = $args['min'] ?? '?';
|
||||
$message = "Object must have at least {$min} properties.";
|
||||
break;
|
||||
case 'maxProperties': // Corrected casing
|
||||
$max = $args['max'] ?? '?';
|
||||
$message = "Object must have no more than {$max} properties.";
|
||||
break;
|
||||
case 'additionalProperties': // Corrected casing
|
||||
$unexpected = $args['properties'] ?? [];
|
||||
$formattedUnexpected = implode(', ', array_map(fn ($p) => "`{$p}`", $unexpected));
|
||||
$message = "Object contains unexpected additional properties: {$formattedUnexpected}.";
|
||||
break;
|
||||
case 'format':
|
||||
$format = $args['format'] ?? 'unknown';
|
||||
$message = "Value does not match the required format: `{$format}`.";
|
||||
break;
|
||||
default:
|
||||
$builtInMessage = $error->message();
|
||||
if ($builtInMessage && 'The data must match the schema' !== $builtInMessage) {
|
||||
$placeholders = $args;
|
||||
$builtInMessage = preg_replace_callback('/\{(\w+)\}/', function ($match) use ($placeholders) {
|
||||
$key = $match[1];
|
||||
$value = $placeholders[$key] ?? '{'.$key.'}';
|
||||
|
||||
return \is_array($value) ? json_encode($value) : (string) $value;
|
||||
}, $builtInMessage);
|
||||
$message = $builtInMessage;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue