Introduce Capability Registry & Discovery

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

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

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

View file

@ -0,0 +1,85 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\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);
}
}
}

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

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