diff --git a/composer.json b/composer.json index 8314eecb..84463893 100644 --- a/composer.json +++ b/composer.json @@ -8,28 +8,33 @@ "name": "Christopher Hertel", "email": "mail@christopher-hertel.de" }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com" - }, { "name": "Kyrian Obikwelu", "email": "koshnawaza@gmail.com" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" } ], "require": { "php": "^8.1", "ext-fileinfo": "*", + "opis/json-schema": "^2.4", + "phpdocumentor/reflection-docblock": "^5.6", + "psr/event-dispatcher": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/uid": "^6.4 || ^7.0" + "symfony/finder": "^6.4 || ^7.2", + "symfony/uid": "^6.4 || ^7.2" }, "require-dev": { + "nyholm/nsa": "^1.3", + "php-cs-fixer/shim": "^3.84", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.5", - "symfony/console": "^6.4 || ^7.0", "psr/cache": "^3.0", - "php-cs-fixer/shim": "^3.84", - "nyholm/nsa": "^1.3" + "symfony/console": "^6.4 || ^7.0", + "symfony/event-dispatcher": "^6.4 || ^7.0" }, "suggest": { "symfony/console": "To use SymfonyConsoleTransport for STDIO", @@ -44,5 +49,8 @@ "psr-4": { "Mcp\\Tests\\": "tests/" } + }, + "config": { + "sort-packages": true } } diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 6dee9037..41320080 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -6,7 +6,14 @@ parameters: - tests/ excludePaths: - examples/cli/vendor/* (?) + - tests/Capability/Discovery/Fixtures/ + - tests/Capability/Discovery/SchemaGeneratorFixture.php treatPhpDocTypesAsCertain: false ignoreErrors: - message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#" + # This errors should actually be fixed, but are ignored for now + - + identifier: missingType.iterableValue + path: src/Capability/Discovery/SchemaGenerator.php + count: 12 diff --git a/src/Capability/Attribute/CompletionProvider.php b/src/Capability/Attribute/CompletionProvider.php new file mode 100644 index 00000000..2ef139b7 --- /dev/null +++ b/src/Capability/Attribute/CompletionProvider.php @@ -0,0 +1,38 @@ + + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class CompletionProvider +{ + /** + * @param class-string|ProviderInterface|null $provider if a class-string, it will be resolved + * from the container at the point of use + * @param ?array $values a list of values to use for completion + */ + public function __construct( + public ?string $providerClass = null, + public string|ProviderInterface|null $provider = null, + public ?array $values = null, + public ?string $enum = null, + ) { + if (1 !== \count(array_filter([$provider, $values, $enum]))) { + throw new InvalidArgumentException('Only one of provider, values, or enum can be set.'); + } + } +} diff --git a/src/Capability/Attribute/McpPrompt.php b/src/Capability/Attribute/McpPrompt.php new file mode 100644 index 00000000..73677e33 --- /dev/null +++ b/src/Capability/Attribute/McpPrompt.php @@ -0,0 +1,32 @@ + + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)] +final class McpPrompt +{ + /** + * @param ?string $name overrides the prompt name (defaults to method name) + * @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary. + */ + public function __construct( + public ?string $name = null, + public ?string $description = null, + ) { + } +} diff --git a/src/Capability/Attribute/McpResource.php b/src/Capability/Attribute/McpResource.php new file mode 100644 index 00000000..873d485c --- /dev/null +++ b/src/Capability/Attribute/McpResource.php @@ -0,0 +1,42 @@ + + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)] +final class McpResource +{ + /** + * @param string $uri The specific URI identifying this resource instance. Must be unique within the server. + * @param ?string $name A human-readable name for this resource. If null, a default might be generated from the method name. + * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary. + * @param ?string $mimeType the MIME type, if known and constant for this resource + * @param ?int $size the size in bytes, if known and constant + * @param Annotations|null $annotations optional annotations describing the resource + */ + public function __construct( + public string $uri, + public ?string $name = null, + public ?string $description = null, + public ?string $mimeType = null, + public ?int $size = null, + public ?Annotations $annotations = null, + ) { + } +} diff --git a/src/Capability/Attribute/McpResourceTemplate.php b/src/Capability/Attribute/McpResourceTemplate.php new file mode 100644 index 00000000..9b8887f1 --- /dev/null +++ b/src/Capability/Attribute/McpResourceTemplate.php @@ -0,0 +1,40 @@ + + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)] +final class McpResourceTemplate +{ + /** + * @param string $uriTemplate the URI template string (RFC 6570) + * @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name. + * @param ?string $description Optional description. Defaults to class DocBlock summary. + * @param ?string $mimeType optional default MIME type for matching resources + * @param ?Annotations $annotations optional annotations describing the resource template + */ + public function __construct( + public string $uriTemplate, + public ?string $name = null, + public ?string $description = null, + public ?string $mimeType = null, + public ?Annotations $annotations = null, + ) { + } +} diff --git a/src/Capability/Attribute/McpTool.php b/src/Capability/Attribute/McpTool.php new file mode 100644 index 00000000..d4af3e6c --- /dev/null +++ b/src/Capability/Attribute/McpTool.php @@ -0,0 +1,33 @@ + + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)] +class McpTool +{ + /** + * @param string|null $name The name of the tool (defaults to the method name) + * @param string|null $description The description of the tool (defaults to the DocBlock/inferred) + * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior + */ + public function __construct( + public ?string $name = null, + public ?string $description = null, + public ?ToolAnnotations $annotations = null, + ) { + } +} diff --git a/src/Capability/Attribute/Schema.php b/src/Capability/Attribute/Schema.php new file mode 100644 index 00000000..5e5507f0 --- /dev/null +++ b/src/Capability/Attribute/Schema.php @@ -0,0 +1,258 @@ +, + * type?: string, + * description?: string, + * enum?: array, + * gormat?: string, + * minLength?: int, + * maxLength?: int, + * pattern?: string, + * minimum?: int, + * maximum?: int, + * exclusiveMinimum?: int, + * exclusiveMaximum?: int, + * multipleOf?: int|float, + * items?: array, + * minItems?: int, + * maxItems?: int, + * uniqueItems?: bool, + * properties?: array, + * required?: array, + * additionalProperties?: bool|array, + * } + * + * @author Kyrian Obikwelu + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER)] +class Schema +{ + /** + * The complete JSON schema array. + * If provided, it takes precedence over individual properties like $type, $properties, etc. + * + * @var ?array + */ + public ?array $definition = null; + + /** + * Alternatively, provide individual top-level schema keywords. + * These are used if $definition is null. + */ + public ?string $type = null; + public ?string $description = null; + public mixed $default = null; + /** + * @var ?array + */ + public ?array $enum = null; // list of allowed values + public ?string $format = null; // e.g., 'email', 'date-time' + + // Constraints for string + public ?int $minLength = null; + public ?int $maxLength = null; + public ?string $pattern = null; + + // Constraints for number/integer + public int|float|null $minimum = null; + public int|float|null $maximum = null; + public ?bool $exclusiveMinimum = null; + public ?bool $exclusiveMaximum = null; + public int|float|null $multipleOf = null; + + // Constraints for array + /** + * @var ?array + */ + public ?array $items = null; // JSON schema for array items + public ?int $minItems = null; + public ?int $maxItems = null; + public ?bool $uniqueItems = null; + + // Constraints for object (primarily used when Schema is on a method or an object-typed parameter) + /** + * @var ?array + */ + public ?array $properties = null; // [propertyName => [schema array], ...] + /** + * @var ?array + */ + public ?array $required = null; // [propertyName, ...] + /** + * @var bool|array|null + */ + public bool|array|null $additionalProperties = null; // true, false, or a schema array + + /** + * @param ?array $definition A complete JSON schema array. If provided, other parameters are ignored. + * @param ?string $type the JSON schema type + * @param ?string $description description of the element + * @param ?array $enum allowed enum values + * @param ?string $format String format (e.g., 'date-time', 'email'). + * @param ?int $minLength minimum length for strings + * @param ?int $maxLength maximum length for strings + * @param ?string $pattern regex pattern for strings + * @param int|float|null $minimum minimum value for numbers/integers + * @param int|float|null $maximum maximum value for numbers/integers + * @param ?bool $exclusiveMinimum exclusive minimum + * @param ?bool $exclusiveMaximum exclusive maximum + * @param int|float|null $multipleOf must be a multiple of this value + * @param ?array $items JSON Schema for items if type is 'array' + * @param ?int $minItems minimum items for an array + * @param ?int $maxItems maximum items for an array + * @param ?bool $uniqueItems whether array items must be unique + * @param ?array $properties Property definitions if type is 'object'. [name => schema_array]. + * @param ?array $required list of required properties for an object + * @param bool|array|null $additionalProperties policy for additional properties in an object + */ + public function __construct( + ?array $definition = null, + ?string $type = null, + ?string $description = null, + ?array $enum = null, + ?string $format = null, + ?int $minLength = null, + ?int $maxLength = null, + ?string $pattern = null, + int|float|null $minimum = null, + int|float|null $maximum = null, + ?bool $exclusiveMinimum = null, + ?bool $exclusiveMaximum = null, + int|float|null $multipleOf = null, + ?array $items = null, + ?int $minItems = null, + ?int $maxItems = null, + ?bool $uniqueItems = null, + ?array $properties = null, + ?array $required = null, + bool|array|null $additionalProperties = null, + ) { + if (null !== $definition) { + $this->definition = $definition; + } else { + $this->type = $type; + $this->description = $description; + $this->enum = $enum; + $this->format = $format; + $this->minLength = $minLength; + $this->maxLength = $maxLength; + $this->pattern = $pattern; + $this->minimum = $minimum; + $this->maximum = $maximum; + $this->exclusiveMinimum = $exclusiveMinimum; + $this->exclusiveMaximum = $exclusiveMaximum; + $this->multipleOf = $multipleOf; + $this->items = $items; + $this->minItems = $minItems; + $this->maxItems = $maxItems; + $this->uniqueItems = $uniqueItems; + $this->properties = $properties; + $this->required = $required; + $this->additionalProperties = $additionalProperties; + } + } + + /** + * Converts the attribute's definition to a JSON schema array. + * + * @return SchemaAttributeData + */ + public function toArray(): array + { + if (null !== $this->definition) { + return [ + 'definition' => $this->definition, + ]; + } + + $schema = []; + if (null !== $this->type) { + $schema['type'] = $this->type; + } + if (null !== $this->description) { + $schema['description'] = $this->description; + } + if (null !== $this->enum) { + $schema['enum'] = $this->enum; + } + if (null !== $this->format) { + $schema['format'] = $this->format; + } + + // String + if (null !== $this->minLength) { + $schema['minLength'] = $this->minLength; + } + if (null !== $this->maxLength) { + $schema['maxLength'] = $this->maxLength; + } + if (null !== $this->pattern) { + $schema['pattern'] = $this->pattern; + } + + // Numeric + if (null !== $this->minimum) { + $schema['minimum'] = $this->minimum; + } + if (null !== $this->maximum) { + $schema['maximum'] = $this->maximum; + } + if (null !== $this->exclusiveMinimum) { + $schema['exclusiveMinimum'] = $this->exclusiveMinimum; + } + if (null !== $this->exclusiveMaximum) { + $schema['exclusiveMaximum'] = $this->exclusiveMaximum; + } + if (null !== $this->multipleOf) { + $schema['multipleOf'] = $this->multipleOf; + } + + // Array + if (null !== $this->items) { + $schema['items'] = $this->items; + } + if (null !== $this->minItems) { + $schema['minItems'] = $this->minItems; + } + if (null !== $this->maxItems) { + $schema['maxItems'] = $this->maxItems; + } + if (null !== $this->uniqueItems) { + $schema['uniqueItems'] = $this->uniqueItems; + } + + // Object + if (null !== $this->properties) { + $schema['properties'] = $this->properties; + } + if (null !== $this->required) { + $schema['required'] = $this->required; + } + if (null !== $this->additionalProperties) { + $schema['additionalProperties'] = $this->additionalProperties; + } + + return $schema; + } +} diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php new file mode 100644 index 00000000..4d9651cb --- /dev/null +++ b/src/Capability/Discovery/Discoverer.php @@ -0,0 +1,412 @@ + + */ +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 $directories list of directories (relative to base path) to scan + * @param array $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 $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 + */ + 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; + } +} diff --git a/src/Capability/Discovery/DocBlockParser.php b/src/Capability/Discovery/DocBlockParser.php new file mode 100644 index 00000000..91f417f2 --- /dev/null +++ b/src/Capability/Discovery/DocBlockParser.php @@ -0,0 +1,139 @@ + + */ +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 + */ + public function getParamTags(?DocBlock $docBlock): array + { + if (!$docBlock) { + return []; + } + + /** @var array $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; + } +} diff --git a/src/Capability/Discovery/HandlerResolver.php b/src/Capability/Discovery/HandlerResolver.php new file mode 100644 index 00000000..cd78aaf4 --- /dev/null +++ b/src/Capability/Discovery/HandlerResolver.php @@ -0,0 +1,85 @@ + + */ +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); + } + } +} diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php new file mode 100644 index 00000000..936a35cf --- /dev/null +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -0,0 +1,787 @@ + + * } + * @phpstan-type InferredParameterSchema array{ + * type?: string|array, + * description?: string, + * default?: mixed, + * enum?: array, + * items?: array, + * } + * @phpstan-type VariadicParameterSchema array{ + * type: 'array', + * items?: array, + * description?: string, + * parameter_schema?: array + * } + * + * @author Kyrian Obikwelu + */ +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 + */ + 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 + */ + 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|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)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 + */ + 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 syntax (e.g., array, array, etc.) + if (preg_match('/^(\\??)array\s*<\s*([\w\\\\|]+)\s*>$/i', $normalizedType, $matches)) { + $itemType = strtolower($matches[2]); + + return $this->mapSimpleTypeToJsonSchema($itemType); + } + + // Case 3: Nested array> 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, + * required?: array + * } + */ + 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 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', + }; + } +} diff --git a/src/Capability/Discovery/SchemaValidator.php b/src/Capability/Discovery/SchemaValidator.php new file mode 100644 index 00000000..cc6be11d --- /dev/null +++ b/src/Capability/Discovery/SchemaValidator.php @@ -0,0 +1,336 @@ + + */ +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|object $schema the JSON Schema definition (as PHP array or object) + * + * @return list 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; + } +} diff --git a/src/Capability/Prompt/Completion/EnumCompletionProvider.php b/src/Capability/Prompt/Completion/EnumCompletionProvider.php new file mode 100644 index 00000000..dd09662e --- /dev/null +++ b/src/Capability/Prompt/Completion/EnumCompletionProvider.php @@ -0,0 +1,52 @@ + + */ +class EnumCompletionProvider implements ProviderInterface +{ + /** + * @var string[] + */ + private array $values; + + /** + * @param class-string $enumClass + */ + public function __construct(string $enumClass) + { + if (!enum_exists($enumClass)) { + throw new InvalidArgumentException(\sprintf('Class "%s" is not an enum.', $enumClass)); + } + + $this->values = array_map( + fn ($case) => isset($case->value) && \is_string($case->value) ? $case->value : $case->name, + $enumClass::cases() + ); + } + + public function getCompletions(string $currentValue): array + { + if (empty($currentValue)) { + return $this->values; + } + + return array_values(array_filter( + $this->values, + fn (string $value) => str_starts_with($value, $currentValue) + )); + } +} diff --git a/src/Capability/Prompt/Completion/ListCompletionProvider.php b/src/Capability/Prompt/Completion/ListCompletionProvider.php new file mode 100644 index 00000000..73a5dd4c --- /dev/null +++ b/src/Capability/Prompt/Completion/ListCompletionProvider.php @@ -0,0 +1,38 @@ + + */ +class ListCompletionProvider implements ProviderInterface +{ + /** + * @param string[] $values + */ + public function __construct( + private array $values, + ) { + } + + public function getCompletions(string $currentValue): array + { + if (empty($currentValue)) { + return $this->values; + } + + return array_values(array_filter( + $this->values, + fn (string $value) => str_starts_with($value, $currentValue) + )); + } +} diff --git a/src/Capability/Prompt/Completion/ProviderInterface.php b/src/Capability/Prompt/Completion/ProviderInterface.php new file mode 100644 index 00000000..04ec23b8 --- /dev/null +++ b/src/Capability/Prompt/Completion/ProviderInterface.php @@ -0,0 +1,27 @@ + + */ +interface ProviderInterface +{ + /** + * Get completions for a given current value. + * + * @param string $currentValue the current value to get completions for + * + * @return string[] the completions + */ + public function getCompletions(string $currentValue): array; +} diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php new file mode 100644 index 00000000..003e9c44 --- /dev/null +++ b/src/Capability/Registry.php @@ -0,0 +1,256 @@ + + */ +class Registry +{ + /** + * @var array + */ + private array $tools = []; + + /** + * @var array + */ + private array $resources = []; + + /** + * @var array + */ + private array $prompts = []; + + /** + * @var array + */ + private array $resourceTemplates = []; + + public function __construct( + private readonly ?EventDispatcherInterface $eventDispatcher = null, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * @param callable|CallableArray|string $handler + */ + public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void + { + $toolName = $tool->name; + $existing = $this->tools[$toolName] ?? null; + + if ($existing && !$isManual && $existing->isManual) { + $this->logger->debug("Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one."); + + return; + } + + $this->tools[$toolName] = new RegisteredTool($tool, $handler, $isManual); + + $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); + } + + /** + * @param callable|CallableArray|string $handler + */ + public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void + { + $uri = $resource->uri; + $existing = $this->resources[$uri] ?? null; + + if ($existing && !$isManual && $existing->isManual) { + $this->logger->debug("Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one."); + + return; + } + + $this->resources[$uri] = new RegisteredResource($resource, $handler, $isManual); + + $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); + } + + /** + * @param callable|CallableArray|string $handler + * @param array $completionProviders + */ + public function registerResourceTemplate( + ResourceTemplate $template, + callable|array|string $handler, + array $completionProviders = [], + bool $isManual = false, + ): void { + $uriTemplate = $template->uriTemplate; + $existing = $this->resourceTemplates[$uriTemplate] ?? null; + + if ($existing && !$isManual && $existing->isManual) { + $this->logger->debug("Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one."); + + return; + } + + $this->resourceTemplates[$uriTemplate] = new RegisteredResourceTemplate($template, $handler, $isManual, $completionProviders); + + $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); + } + + /** + * @param callable|CallableArray|string $handler + * @param array $completionProviders + */ + public function registerPrompt( + Prompt $prompt, + callable|array|string $handler, + array $completionProviders = [], + bool $isManual = false, + ): void { + $promptName = $prompt->name; + $existing = $this->prompts[$promptName] ?? null; + + if ($existing && !$isManual && $existing->isManual) { + $this->logger->debug("Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one."); + + return; + } + + $this->prompts[$promptName] = new RegisteredPrompt($prompt, $handler, $isManual, $completionProviders); + + $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); + } + + /** Checks if any elements (manual or discovered) are currently registered. */ + public function hasElements(): bool + { + return !empty($this->tools) + || !empty($this->resources) + || !empty($this->prompts) + || !empty($this->resourceTemplates); + } + + /** + * Clear discovered elements from registry. + */ + public function clear(): void + { + $clearCount = 0; + + foreach ($this->tools as $name => $tool) { + if (!$tool->isManual) { + unset($this->tools[$name]); + ++$clearCount; + } + } + foreach ($this->resources as $uri => $resource) { + if (!$resource->isManual) { + unset($this->resources[$uri]); + ++$clearCount; + } + } + foreach ($this->prompts as $name => $prompt) { + if (!$prompt->isManual) { + unset($this->prompts[$name]); + ++$clearCount; + } + } + foreach ($this->resourceTemplates as $uriTemplate => $template) { + if (!$template->isManual) { + unset($this->resourceTemplates[$uriTemplate]); + ++$clearCount; + } + } + + if ($clearCount > 0) { + $this->logger->debug(\sprintf('Removed %d discovered elements from internal registry.', $clearCount)); + } + } + + public function getTool(string $name): ?RegisteredTool + { + return $this->tools[$name] ?? null; + } + + public function getResource(string $uri, bool $includeTemplates = true): RegisteredResource|RegisteredResourceTemplate|null + { + $registration = $this->resources[$uri] ?? null; + if ($registration) { + return $registration; + } + + if (!$includeTemplates) { + return null; + } + + foreach ($this->resourceTemplates as $template) { + if ($template->matches($uri)) { + return $template; + } + } + + $this->logger->debug('No resource matched URI.', ['uri' => $uri]); + + return null; + } + + public function getResourceTemplate(string $uriTemplate): ?RegisteredResourceTemplate + { + return $this->resourceTemplates[$uriTemplate] ?? null; + } + + public function getPrompt(string $name): ?RegisteredPrompt + { + return $this->prompts[$name] ?? null; + } + + /** @return array */ + public function getTools(): array + { + return array_map(fn ($tool) => $tool->tool, $this->tools); + } + + /** @return array */ + public function getResources(): array + { + return array_map(fn ($resource) => $resource->schema, $this->resources); + } + + /** @return array */ + public function getPrompts(): array + { + return array_map(fn ($prompt) => $prompt->prompt, $this->prompts); + } + + /** @return array */ + public function getResourceTemplates(): array + { + return array_map(fn ($template) => $template->resourceTemplate, $this->resourceTemplates); + } +} diff --git a/src/Capability/Registry/RegisteredElement.php b/src/Capability/Registry/RegisteredElement.php new file mode 100644 index 00000000..1195c91a --- /dev/null +++ b/src/Capability/Registry/RegisteredElement.php @@ -0,0 +1,289 @@ + + */ +class RegisteredElement implements \JsonSerializable +{ + /** + * @var callable|CallableArray|string + */ + public readonly mixed $handler; + public readonly bool $isManual; + + /** + * @param callable|CallableArray|string $handler + */ + public function __construct( + callable|array|string $handler, + bool $isManual = false, + ) { + $this->handler = $handler; + $this->isManual = $isManual; + } + + /** + * @param array $arguments + */ + public function handle(ContainerInterface $container, array $arguments): mixed + { + if (\is_string($this->handler)) { + if (class_exists($this->handler) && method_exists($this->handler, '__invoke')) { + $reflection = new \ReflectionMethod($this->handler, '__invoke'); + $arguments = $this->prepareArguments($reflection, $arguments); + $instance = $container->get($this->handler); + + return \call_user_func($instance, ...$arguments); + } + + if (\function_exists($this->handler)) { + $reflection = new \ReflectionFunction($this->handler); + $arguments = $this->prepareArguments($reflection, $arguments); + + return \call_user_func($this->handler, ...$arguments); + } + } + + if (\is_callable($this->handler)) { + $reflection = $this->getReflectionForCallable($this->handler); + $arguments = $this->prepareArguments($reflection, $arguments); + + return \call_user_func($this->handler, ...$arguments); + } + + if (\is_array($this->handler)) { + [$className, $methodName] = $this->handler; + $reflection = new \ReflectionMethod($className, $methodName); + $arguments = $this->prepareArguments($reflection, $arguments); + + $instance = $container->get($className); + + return \call_user_func([$instance, $methodName], ...$arguments); + } + + throw new InvalidArgumentException('Invalid handler type'); + } + + /** + * @param array $arguments + * + * @return array + */ + protected function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments): array + { + $finalArgs = []; + + foreach ($reflection->getParameters() as $parameter) { + // TODO: Handle variadic parameters. + $paramName = $parameter->getName(); + $paramPosition = $parameter->getPosition(); + + if (isset($arguments[$paramName])) { + $argument = $arguments[$paramName]; + try { + $finalArgs[$paramPosition] = $this->castArgumentType($argument, $parameter); + } catch (InvalidArgumentException $e) { + throw RegistryException::invalidParams($e->getMessage(), $e); + } catch (\Throwable $e) { + throw RegistryException::internalError("Error processing parameter `{$paramName}`: {$e->getMessage()}", $e); + } + } elseif ($parameter->isDefaultValueAvailable()) { + $finalArgs[$paramPosition] = $parameter->getDefaultValue(); + } elseif ($parameter->allowsNull()) { + $finalArgs[$paramPosition] = null; + } elseif ($parameter->isOptional()) { + continue; + } else { + $reflectionName = $reflection instanceof \ReflectionMethod + ? $reflection->class.'::'.$reflection->name + : 'Closure'; + throw RegistryException::internalError("Missing required argument `{$paramName}` for {$reflectionName}."); + } + } + + return array_values($finalArgs); + } + + /** + * Gets a ReflectionMethod or ReflectionFunction for a callable. + */ + private function getReflectionForCallable(callable $handler): \ReflectionMethod|\ReflectionFunction + { + if (\is_string($handler)) { + return new \ReflectionFunction($handler); + } + + if ($handler instanceof \Closure) { + return new \ReflectionFunction($handler); + } + + if (\is_array($handler) && 2 === \count($handler)) { + [$class, $method] = $handler; + + return new \ReflectionMethod($class, $method); + } + + throw new InvalidArgumentException('Cannot create reflection for this callable type'); + } + + /** + * Attempts type casting based on ReflectionParameter type hints. + * + * @throws InvalidArgumentException if casting is impossible for the required type + */ + private function castArgumentType(mixed $argument, \ReflectionParameter $parameter): mixed + { + $type = $parameter->getType(); + + if (null === $argument) { + if ($type && $type->allowsNull()) { + return null; + } + } + + if (!$type instanceof \ReflectionNamedType) { + return $argument; + } + + $typeName = $type->getName(); + + if (enum_exists($typeName)) { + if (\is_object($argument) && $argument instanceof $typeName) { + return $argument; + } + + if (is_subclass_of($typeName, \BackedEnum::class)) { + $value = $typeName::tryFrom($argument); + if (null === $value) { + throw new InvalidArgumentException("Invalid value '{$argument}' for backed enum {$typeName}. Expected one of its backing values."); + } + + return $value; + } else { + if (\is_string($argument)) { + foreach ($typeName::cases() as $case) { + if ($case->name === $argument) { + return $case; + } + } + $validNames = array_map(fn ($c) => $c->name, $typeName::cases()); + throw new InvalidArgumentException("Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: ".implode(', ', $validNames).'.'); + } else { + throw new InvalidArgumentException("Invalid value type '{$argument}' for unit enum {$typeName}. Expected a string matching a case name."); + } + } + } + + try { + return match (strtolower($typeName)) { + 'int', 'integer' => $this->castToInt($argument), + 'string' => (string) $argument, + 'bool', 'boolean' => $this->castToBoolean($argument), + 'float', 'double' => $this->castToFloat($argument), + 'array' => $this->castToArray($argument), + default => $argument, + }; + } catch (\TypeError $e) { + throw new InvalidArgumentException("Value cannot be cast to required type `{$typeName}`.", 0, $e); + } + } + + /** + * Helper to cast strictly to boolean. + */ + private function castToBoolean(mixed $argument): bool + { + if (\is_bool($argument)) { + return $argument; + } + if (1 === $argument || '1' === $argument || 'true' === strtolower((string) $argument)) { + return true; + } + if (0 === $argument || '0' === $argument || 'false' === strtolower((string) $argument)) { + return false; + } + + throw new InvalidArgumentException('Cannot cast value to boolean. Use true/false/1/0.'); + } + + /** + * Helper to cast strictly to integer. + */ + private function castToInt(mixed $argument): int + { + if (\is_int($argument)) { + return $argument; + } + if (is_numeric($argument) && floor((float) $argument) == $argument && !\is_string($argument)) { + return (int) $argument; + } + if (\is_string($argument) && ctype_digit(ltrim($argument, '-'))) { + return (int) $argument; + } + + throw new InvalidArgumentException('Cannot cast value to integer. Expected integer representation.'); + } + + /** + * Helper to cast strictly to float. + */ + private function castToFloat(mixed $argument): float + { + if (\is_float($argument)) { + return $argument; + } + if (\is_int($argument)) { + return (float) $argument; + } + if (is_numeric($argument)) { + return (float) $argument; + } + + throw new InvalidArgumentException('Cannot cast value to float. Expected numeric representation.'); + } + + /** + * Helper to cast strictly to array. + * + * @return array + */ + private function castToArray(mixed $argument): array + { + if (\is_array($argument)) { + return $argument; + } + + throw new InvalidArgumentException('Cannot cast value to array. Expected array.'); + } + + /** + * @return array{ + * handler: callable|CallableArray|string, + * isManual: bool, + * } + */ + public function jsonSerialize(): array + { + return [ + 'handler' => $this->handler, + 'isManual' => $this->isManual, + ]; + } +} diff --git a/src/Capability/Registry/RegisteredPrompt.php b/src/Capability/Registry/RegisteredPrompt.php new file mode 100644 index 00000000..0d2213f9 --- /dev/null +++ b/src/Capability/Registry/RegisteredPrompt.php @@ -0,0 +1,368 @@ + + */ +class RegisteredPrompt extends RegisteredElement +{ + /** + * @param callable|CallableArray|string $handler + * @param array $completionProviders + */ + public function __construct( + public readonly Prompt $prompt, + callable|array|string $handler, + bool $isManual = false, + public readonly array $completionProviders = [], + ) { + parent::__construct($handler, $isManual); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self|false + { + try { + if (!isset($data['schema']) || !isset($data['handler'])) { + return false; + } + + $completionProviders = []; + foreach ($data['completionProviders'] ?? [] as $argument => $provider) { + $completionProviders[$argument] = unserialize($provider); + } + + return new self( + Prompt::fromArray($data['schema']), + $data['handler'], + $data['isManual'] ?? false, + $completionProviders, + ); + } catch (\Throwable) { + return false; + } + } + + /** + * Gets the prompt messages. + * + * @param array $arguments + * + * @return PromptMessage[] + */ + public function get(ContainerInterface $container, array $arguments): array + { + $result = $this->handle($container, $arguments); + + return $this->formatResult($result); + } + + public function complete(ContainerInterface $container, string $argument, string $value): CompletionCompleteResult + { + $providerClassOrInstance = $this->completionProviders[$argument] ?? null; + if (null === $providerClassOrInstance) { + return new CompletionCompleteResult([]); + } + + if (\is_string($providerClassOrInstance)) { + if (!class_exists($providerClassOrInstance)) { + throw new RuntimeException("Completion provider class '{$providerClassOrInstance}' does not exist."); + } + + $provider = $container->get($providerClassOrInstance); + } else { + $provider = $providerClassOrInstance; + } + + $completions = $provider->getCompletions($value); + + $total = \count($completions); + $hasMore = $total > 100; + + $pagedCompletions = \array_slice($completions, 0, 100); + + return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); + } + + /** + * Formats the raw result of a prompt generator into an array of MCP PromptMessages. + * + * @param mixed $promptGenerationResult expected: array of message structures + * + * @return PromptMessage[] array of PromptMessage objects + * + * @throws \RuntimeException if the result cannot be formatted + * @throws \JsonException if JSON encoding fails + */ + protected function formatResult(mixed $promptGenerationResult): array + { + if ($promptGenerationResult instanceof PromptMessage) { + return [$promptGenerationResult]; + } + + if (!\is_array($promptGenerationResult)) { + throw new \RuntimeException('Prompt generator method must return an array of messages.'); + } + + if (empty($promptGenerationResult)) { + return []; + } + + if (\is_array($promptGenerationResult)) { + $allArePromptMessages = true; + $hasPromptMessages = false; + + foreach ($promptGenerationResult as $item) { + if ($item instanceof PromptMessage) { + $hasPromptMessages = true; + } else { + $allArePromptMessages = false; + } + } + + if ($allArePromptMessages && $hasPromptMessages) { + return $promptGenerationResult; + } + + if ($hasPromptMessages) { + $result = []; + foreach ($promptGenerationResult as $index => $item) { + if ($item instanceof PromptMessage) { + $result[] = $item; + } else { + $result = array_merge($result, $this->formatResult($item)); + } + } + + return $result; + } + + if (!array_is_list($promptGenerationResult)) { + if (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) { + $result = []; + if (isset($promptGenerationResult['user'])) { + $userContent = $this->formatContent($promptGenerationResult['user']); + $result[] = new PromptMessage(Role::User, $userContent); + } + if (isset($promptGenerationResult['assistant'])) { + $assistantContent = $this->formatContent($promptGenerationResult['assistant']); + $result[] = new PromptMessage(Role::Assistant, $assistantContent); + } + + return $result; + } + + if (isset($promptGenerationResult['role']) && isset($promptGenerationResult['content'])) { + return [$this->formatMessage($promptGenerationResult)]; + } + + throw new RuntimeException('Associative array must contain either role/content keys or user/assistant keys.'); + } + + $formattedMessages = []; + foreach ($promptGenerationResult as $index => $message) { + if ($message instanceof PromptMessage) { + $formattedMessages[] = $message; + } else { + $formattedMessages[] = $this->formatMessage($message, $index); + } + } + + return $formattedMessages; + } + + throw new \RuntimeException('Invalid prompt generation result format.'); + } + + /** + * Formats a single message into a PromptMessage. + */ + private function formatMessage(mixed $message, ?int $index = null): PromptMessage + { + $indexStr = null !== $index ? " at index {$index}" : ''; + + if (!\is_array($message) || !\array_key_exists('role', $message) || !\array_key_exists('content', $message)) { + throw new \RuntimeException("Invalid message format{$indexStr}. Expected an array with 'role' and 'content' keys."); + } + + $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']); + if (null === $role) { + throw new \RuntimeException("Invalid role '{$message['role']}' in prompt message{$indexStr}. Only 'user' or 'assistant' are supported."); + } + + $content = $this->formatContent($message['content'], $index); + + return new PromptMessage($role, $content); + } + + /** + * Formats content into a proper Content object. + */ + private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + { + $indexStr = null !== $index ? " at index {$index}" : ''; + + if ($content instanceof Content) { + if ( + $content instanceof TextContent || $content instanceof ImageContent + || $content instanceof AudioContent || $content instanceof EmbeddedResource + ) { + return $content; + } + throw new \RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource."); + } + + if (\is_string($content)) { + return new TextContent($content); + } + + if (\is_array($content) && isset($content['type'])) { + return $this->formatTypedContent($content, $index); + } + + if (\is_scalar($content) || null === $content) { + $stringContent = null === $content ? '(null)' : (\is_bool($content) ? ($content ? 'true' : 'false') : (string) $content); + + return new TextContent($stringContent); + } + + $jsonContent = json_encode($content, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR); + + return new TextContent($jsonContent); + } + + /** + * Formats typed content arrays into Content objects. + * + * @param array $content + */ + private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + { + $indexStr = null !== $index ? " at index {$index}" : ''; + $type = $content['type']; + + return match ($type) { + 'text' => $this->formatTextContent($content, $indexStr), + 'image' => $this->formatImageContent($content, $indexStr), + 'audio' => $this->formatAudioContent($content, $indexStr), + 'resource' => $this->formatResourceContent($content, $indexStr), + default => throw new \RuntimeException("Invalid content type '{$type}'{$indexStr}."), + }; + } + + /** + * @param array $content + */ + private function formatTextContent(array $content, string $indexStr): TextContent + { + if (!isset($content['text']) || !\is_string($content['text'])) { + throw new RuntimeException(\sprintf('Invalid "text" content%s: Missing or invalid "text" string.', $indexStr)); + } + + return new TextContent($content['text']); + } + + /** + * @param array $content + */ + private function formatImageContent(array $content, string $indexStr): ImageContent + { + if (!isset($content['data']) || !\is_string($content['data'])) { + throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'data' string (base64)."); + } + if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) { + throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'mimeType' string."); + } + + return new ImageContent($content['data'], $content['mimeType']); + } + + /** + * @param array $content + */ + private function formatAudioContent(array $content, string $indexStr): AudioContent + { + if (!isset($content['data']) || !\is_string($content['data'])) { + throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'data' string (base64)."); + } + if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) { + throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'mimeType' string."); + } + + return new AudioContent($content['data'], $content['mimeType']); + } + + /** + * @param array $content + */ + private function formatResourceContent(array $content, string $indexStr): EmbeddedResource + { + if (!isset($content['resource']) || !\is_array($content['resource'])) { + throw new RuntimeException("Invalid 'resource' content{$indexStr}: Missing or invalid 'resource' object."); + } + + $resource = $content['resource']; + if (!isset($resource['uri']) || !\is_string($resource['uri'])) { + throw new RuntimeException("Invalid resource{$indexStr}: Missing or invalid 'uri'."); + } + + if (isset($resource['text']) && \is_string($resource['text'])) { + $resourceObj = new TextResourceContents($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); + } elseif (isset($resource['blob']) && \is_string($resource['blob'])) { + $resourceObj = new BlobResourceContents( + $resource['uri'], + $resource['mimeType'] ?? 'application/octet-stream', + $resource['blob'] + ); + } else { + throw new RuntimeException("Invalid resource{$indexStr}: Must contain 'text' or 'blob'."); + } + + return new EmbeddedResource($resourceObj); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $completionProviders = []; + foreach ($this->completionProviders as $argument => $provider) { + $completionProviders[$argument] = serialize($provider); + } + + return [ + 'schema' => $this->prompt, + 'completionProviders' => $completionProviders, + ...parent::jsonSerialize(), + ]; + } +} diff --git a/src/Capability/Registry/RegisteredResource.php b/src/Capability/Registry/RegisteredResource.php new file mode 100644 index 00000000..e6175f63 --- /dev/null +++ b/src/Capability/Registry/RegisteredResource.php @@ -0,0 +1,248 @@ + + */ +class RegisteredResource extends RegisteredElement +{ + /** + * @param callable|CallableArray|string $handler + */ + public function __construct( + public readonly Resource $schema, + callable|array|string $handler, + bool $isManual = false, + ) { + parent::__construct($handler, $isManual); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self|false + { + try { + if (!isset($data['schema']) || !isset($data['handler'])) { + return false; + } + + return new self( + Resource::fromArray($data['schema']), + $data['handler'], + $data['isManual'] ?? false, + ); + } catch (\Throwable) { + return false; + } + } + + /** + * Reads the resource content. + * + * @return ResourceContents[] array of ResourceContents objects + */ + public function read(ContainerInterface $container, string $uri): array + { + $result = $this->handle($container, ['uri' => $uri]); + + return $this->formatResult($result, $uri, $this->schema->mimeType); + } + + /** + * Formats the raw result of a resource read operation into MCP ResourceContent items. + * + * @param mixed $readResult the raw result from the resource handler method + * @param string $uri the URI of the resource that was read + * @param ?string $mimeType the MIME type from the ResourceDefinition + * + * @return ResourceContents[] array of ResourceContents objects + * + * @throws RuntimeException If the result cannot be formatted. + * + * Supported result types: + * - ResourceContent: Used as-is + * - EmbeddedResource: Resource is extracted from the EmbeddedResource + * - string: Converted to text content with guessed or provided MIME type + * - stream resource: Read and converted to blob with provided MIME type + * - array with 'blob' key: Used as blob content + * - array with 'text' key: Used as text content + * - SplFileInfo: Read and converted to blob + * - array: Converted to JSON if MIME type is application/json or contains 'json' + * For other MIME types, will try to convert to JSON with a warning + */ + protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array + { + if ($readResult instanceof ResourceContents) { + return [$readResult]; + } + + if ($readResult instanceof EmbeddedResource) { + return [$readResult->resource]; + } + + if (\is_array($readResult)) { + if (empty($readResult)) { + return [new TextResourceContents($uri, 'application/json', '[]')]; + } + + $allAreResourceContents = true; + $hasResourceContents = false; + $allAreEmbeddedResource = true; + $hasEmbeddedResource = false; + + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $hasResourceContents = true; + $allAreEmbeddedResource = false; + } elseif ($item instanceof EmbeddedResource) { + $hasEmbeddedResource = true; + $allAreResourceContents = false; + } else { + $allAreResourceContents = false; + $allAreEmbeddedResource = false; + } + } + + if ($allAreResourceContents && $hasResourceContents) { + return $readResult; + } + + if ($allAreEmbeddedResource && $hasEmbeddedResource) { + return array_map(fn ($item) => $item->resource, $readResult); + } + + if ($hasResourceContents || $hasEmbeddedResource) { + $result = []; + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $result[] = $item; + } elseif ($item instanceof EmbeddedResource) { + $result[] = $item->resource; + } else { + $result = array_merge($result, $this->formatResult($item, $uri, $mimeType)); + } + } + + return $result; + } + } + + if (\is_string($readResult)) { + $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); + + return [new TextResourceContents($uri, $mimeType, $readResult)]; + } + + if (\is_resource($readResult) && 'stream' === get_resource_type($readResult)) { + $result = BlobResourceContents::fromStream( + $uri, + $readResult, + $mimeType ?? 'application/octet-stream' + ); + + @fclose($readResult); + + return [$result]; + } + + if (\is_array($readResult) && isset($readResult['blob']) && \is_string($readResult['blob'])) { + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; + + return [new BlobResourceContents($uri, $mimeType, $readResult['blob'])]; + } + + if (\is_array($readResult) && isset($readResult['text']) && \is_string($readResult['text'])) { + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; + + return [new TextResourceContents($uri, $mimeType, $readResult['text'])]; + } + + if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { + if ($mimeType && str_contains(strtolower($mimeType), 'text')) { + return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()))]; + } + + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)]; + } + + if (\is_array($readResult)) { + if ($mimeType && (str_contains(strtolower($mimeType), 'json') + || 'application/json' === $mimeType)) { + try { + $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); + + return [new TextResourceContents($uri, $mimeType, $jsonString)]; + } catch (\JsonException $e) { + throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage())); + } + } + + try { + $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); + $mimeType = $mimeType ?? 'application/json'; + + return [new TextResourceContents($uri, $mimeType, $jsonString)]; + } catch (\JsonException $e) { + throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage())); + } + } + + throw new RuntimeException(\sprintf('Cannot format resource read result for URI "%s". Handler method returned unhandled type: ', $uri).\gettype($readResult)); + } + + /** Guesses MIME type from string content (very basic) */ + private function guessMimeTypeFromString(string $content): string + { + $trimmed = ltrim($content); + + if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { + if (str_contains($trimmed, ' $this->schema, + ...parent::jsonSerialize(), + ]; + } +} diff --git a/src/Capability/Registry/RegisteredResourceTemplate.php b/src/Capability/Registry/RegisteredResourceTemplate.php new file mode 100644 index 00000000..8bc4a738 --- /dev/null +++ b/src/Capability/Registry/RegisteredResourceTemplate.php @@ -0,0 +1,357 @@ + + */ +class RegisteredResourceTemplate extends RegisteredElement +{ + /** + * @var array + */ + protected array $variableNames; + /** + * @var array + */ + protected array $uriVariables; + protected string $uriTemplateRegex; + + /** + * @param callable|CallableArray|string $handler + * @param array $completionProviders + */ + public function __construct( + public readonly ResourceTemplate $resourceTemplate, + callable|array|string $handler, + bool $isManual = false, + public readonly array $completionProviders = [], + ) { + parent::__construct($handler, $isManual); + + $this->compileTemplate(); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self|false + { + try { + if (!isset($data['schema']) || !isset($data['handler'])) { + return false; + } + + $completionProviders = []; + foreach ($data['completionProviders'] ?? [] as $argument => $provider) { + $completionProviders[$argument] = unserialize($provider); + } + + return new self( + ResourceTemplate::fromArray($data['schema']), + $data['handler'], + $data['isManual'] ?? false, + $completionProviders, + ); + } catch (\Throwable) { + return false; + } + } + + /** + * Gets the resource template. + * + * @return array array of ResourceContents objects + */ + public function read(ContainerInterface $container, string $uri): array + { + $arguments = array_merge($this->uriVariables, ['uri' => $uri]); + + $result = $this->handle($container, $arguments); + + return $this->formatResult($result, $uri, $this->resourceTemplate->mimeType); + } + + public function complete(ContainerInterface $container, string $argument, string $value): CompletionCompleteResult + { + $providerClassOrInstance = $this->completionProviders[$argument] ?? null; + if (null === $providerClassOrInstance) { + return new CompletionCompleteResult([]); + } + + if (\is_string($providerClassOrInstance)) { + if (!class_exists($providerClassOrInstance)) { + throw new RuntimeException(\sprintf('Completion provider class "%s" does not exist.', $providerClassOrInstance)); + } + + $provider = $container->get($providerClassOrInstance); + } else { + $provider = $providerClassOrInstance; + } + + $completions = $provider->getCompletions($value); + + $total = \count($completions); + $hasMore = $total > 100; + + $pagedCompletions = \array_slice($completions, 0, 100); + + return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); + } + + /** + * @return array + */ + public function getVariableNames(): array + { + return $this->variableNames; + } + + public function matches(string $uri): bool + { + if (preg_match($this->uriTemplateRegex, $uri, $matches)) { + $variables = []; + foreach ($this->variableNames as $varName) { + if (isset($matches[$varName])) { + $variables[$varName] = $matches[$varName]; + } + } + + $this->uriVariables = $variables; + + return true; + } + + return false; + } + + private function compileTemplate(): void + { + $this->variableNames = []; + $regexParts = []; + + $segments = preg_split('/(\{\w+\})/', $this->resourceTemplate->uriTemplate, -1, \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY); + + foreach ($segments as $segment) { + if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) { + $varName = $matches[1]; + $this->variableNames[] = $varName; + $regexParts[] = '(?P<'.$varName.'>[^/]+)'; + } else { + $regexParts[] = preg_quote($segment, '#'); + } + } + + $this->uriTemplateRegex = '#^'.implode('', $regexParts).'$#'; + } + + /** + * Formats the raw result of a resource read operation into MCP ResourceContent items. + * + * @param mixed $readResult the raw result from the resource handler method + * @param string $uri the URI of the resource that was read + * + * @return array array of ResourceContents objects + * + * @throws \RuntimeException If the result cannot be formatted. + * + * Supported result types: + * - ResourceContent: Used as-is + * - EmbeddedResource: Resource is extracted from the EmbeddedResource + * - string: Converted to text content with guessed or provided MIME type + * - stream resource: Read and converted to blob with provided MIME type + * - array with 'blob' key: Used as blob content + * - array with 'text' key: Used as text content + * - SplFileInfo: Read and converted to blob + * - array: Converted to JSON if MIME type is application/json or contains 'json' + * For other MIME types, will try to convert to JSON with a warning + */ + protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array + { + if ($readResult instanceof ResourceContents) { + return [$readResult]; + } + + if ($readResult instanceof EmbeddedResource) { + return [$readResult->resource]; + } + + if (\is_array($readResult)) { + if (empty($readResult)) { + return [new TextResourceContents($uri, 'application/json', '[]')]; + } + + $allAreResourceContents = true; + $hasResourceContents = false; + $allAreEmbeddedResource = true; + $hasEmbeddedResource = false; + + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $hasResourceContents = true; + $allAreEmbeddedResource = false; + } elseif ($item instanceof EmbeddedResource) { + $hasEmbeddedResource = true; + $allAreResourceContents = false; + } else { + $allAreResourceContents = false; + $allAreEmbeddedResource = false; + } + } + + if ($allAreResourceContents && $hasResourceContents) { + return $readResult; + } + + if ($allAreEmbeddedResource && $hasEmbeddedResource) { + return array_map(fn ($item) => $item->resource, $readResult); + } + + if ($hasResourceContents || $hasEmbeddedResource) { + $result = []; + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $result[] = $item; + } elseif ($item instanceof EmbeddedResource) { + $result[] = $item->resource; + } else { + $result = array_merge($result, $this->formatResult($item, $uri, $mimeType)); + } + } + + return $result; + } + } + + if (\is_string($readResult)) { + $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); + + return [new TextResourceContents($uri, $mimeType, $readResult)]; + } + + if (\is_resource($readResult) && 'stream' === get_resource_type($readResult)) { + $result = BlobResourceContents::fromStream( + $uri, + $readResult, + $mimeType ?? 'application/octet-stream' + ); + + @fclose($readResult); + + return [$result]; + } + + if (\is_array($readResult) && isset($readResult['blob']) && \is_string($readResult['blob'])) { + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; + + return [new BlobResourceContents($uri, $mimeType, $readResult['blob'])]; + } + + if (\is_array($readResult) && isset($readResult['text']) && \is_string($readResult['text'])) { + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; + + return [new TextResourceContents($uri, $mimeType, $readResult['text'])]; + } + + if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { + if ($mimeType && str_contains(strtolower($mimeType), 'text')) { + return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()))]; + } + + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)]; + } + + if (\is_array($readResult)) { + if ($mimeType && (str_contains(strtolower($mimeType), 'json') + || 'application/json' === $mimeType)) { + try { + $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); + + return [new TextResourceContents($uri, $mimeType, $jsonString)]; + } catch (\JsonException $e) { + throw new RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); + } + } + + try { + $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); + $mimeType = $mimeType ?? 'application/json'; + + return [new TextResourceContents($uri, $mimeType, $jsonString)]; + } catch (\JsonException $e) { + throw new RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); + } + } + + throw new RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: ".\gettype($readResult)); + } + + /** Guesses MIME type from string content (very basic) */ + private function guessMimeTypeFromString(string $content): string + { + $trimmed = ltrim($content); + + if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { + if (str_contains($trimmed, ', + * handler: callable|CallableArray|string, + * isManual: bool, + * } + */ + public function jsonSerialize(): array + { + $completionProviders = []; + foreach ($this->completionProviders as $argument => $provider) { + $completionProviders[$argument] = serialize($provider); + } + + return [ + 'schema' => $this->resourceTemplate, + 'completionProviders' => $completionProviders, + ...parent::jsonSerialize(), + ]; + } +} diff --git a/src/Capability/Registry/RegisteredTool.php b/src/Capability/Registry/RegisteredTool.php new file mode 100644 index 00000000..eed8872f --- /dev/null +++ b/src/Capability/Registry/RegisteredTool.php @@ -0,0 +1,164 @@ + + */ +class RegisteredTool extends RegisteredElement +{ + /** + * @param callable|CallableArray|string $handler + */ + public function __construct( + public readonly Tool $tool, + callable|array|string $handler, + bool $isManual = false, + ) { + parent::__construct($handler, $isManual); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self|false + { + try { + if (!isset($data['schema']) || !isset($data['handler'])) { + return false; + } + + return new self( + Tool::fromArray($data['schema']), + $data['handler'], + $data['isManual'] ?? false, + ); + } catch (\Throwable) { + return false; + } + } + + /** + * Calls the underlying handler for this tool. + * + * @param array $arguments + * + * @return Content[] the content items for CallToolResult + */ + public function call(ContainerInterface $container, array $arguments): array + { + $result = $this->handle($container, $arguments); + + return $this->formatResult($result); + } + + /** + * Formats the result of a tool execution into an array of MCP Content items. + * + * - If the result is already a Content object, it's wrapped in an array. + * - If the result is an array: + * - If all elements are Content objects, the array is returned as is. + * - If it's a mixed array (Content and non-Content items), non-Content items are + * individually formatted (scalars to TextContent, others to JSON TextContent). + * - If it's an array with no Content items, the entire array is JSON-encoded into a single TextContent. + * - Scalars (string, int, float, bool) are wrapped in TextContent. + * - null is represented as TextContent('(null)'). + * - Other objects are JSON-encoded and wrapped in TextContent. + * + * @param mixed $toolExecutionResult the raw value returned by the tool's PHP method + * + * @return Content[] the content items for CallToolResult + * + * @throws \JsonException if JSON encoding fails for non-Content array/object results + */ + private function formatResult(mixed $toolExecutionResult): array + { + if ($toolExecutionResult instanceof Content) { + return [$toolExecutionResult]; + } + + if (\is_array($toolExecutionResult)) { + if (empty($toolExecutionResult)) { + return [new TextContent('[]')]; + } + + $allAreContent = true; + $hasContent = false; + + foreach ($toolExecutionResult as $item) { + if ($item instanceof Content) { + $hasContent = true; + } else { + $allAreContent = false; + } + } + + if ($allAreContent && $hasContent) { + return $toolExecutionResult; + } + + if ($hasContent) { + $result = []; + foreach ($toolExecutionResult as $item) { + if ($item instanceof Content) { + $result[] = $item; + } else { + $result = array_merge($result, $this->formatResult($item)); + } + } + + return $result; + } + } + + if (null === $toolExecutionResult) { + return [new TextContent('(null)')]; + } + + if (\is_bool($toolExecutionResult)) { + return [new TextContent($toolExecutionResult ? 'true' : 'false')]; + } + + if (\is_scalar($toolExecutionResult)) { + return [new TextContent($toolExecutionResult)]; + } + + $jsonResult = json_encode( + $toolExecutionResult, + \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE + ); + + return [new TextContent($jsonResult)]; + } + + /** + * @return array{ + * schema: Tool, + * handler: callable|CallableArray|string, + * isManual: bool, + * } + */ + public function jsonSerialize(): array + { + return [ + 'schema' => $this->tool, + ...parent::jsonSerialize(), + ]; + } +} diff --git a/src/Event/PromptListChangedEvent.php b/src/Event/PromptListChangedEvent.php new file mode 100644 index 00000000..2e869181 --- /dev/null +++ b/src/Event/PromptListChangedEvent.php @@ -0,0 +1,19 @@ + + */ +final class PromptListChangedEvent +{ +} diff --git a/src/Event/ResourceListChangedEvent.php b/src/Event/ResourceListChangedEvent.php new file mode 100644 index 00000000..83120d68 --- /dev/null +++ b/src/Event/ResourceListChangedEvent.php @@ -0,0 +1,19 @@ + + */ +final class ResourceListChangedEvent +{ +} diff --git a/src/Event/ResourceTemplateListChangedEvent.php b/src/Event/ResourceTemplateListChangedEvent.php new file mode 100644 index 00000000..0c13f654 --- /dev/null +++ b/src/Event/ResourceTemplateListChangedEvent.php @@ -0,0 +1,19 @@ + + */ +final class ResourceTemplateListChangedEvent +{ +} diff --git a/src/Event/ToolListChangedEvent.php b/src/Event/ToolListChangedEvent.php new file mode 100644 index 00000000..84d175a2 --- /dev/null +++ b/src/Event/ToolListChangedEvent.php @@ -0,0 +1,19 @@ + + */ +final class ToolListChangedEvent +{ +} diff --git a/src/Exception/RegistryException.php b/src/Exception/RegistryException.php new file mode 100644 index 00000000..c483a01e --- /dev/null +++ b/src/Exception/RegistryException.php @@ -0,0 +1,35 @@ + str_starts_with($item, $currentValue)); + } +} diff --git a/tests/Capability/Attribute/CompletionProviderTest.php b/tests/Capability/Attribute/CompletionProviderTest.php new file mode 100644 index 00000000..40b70a00 --- /dev/null +++ b/tests/Capability/Attribute/CompletionProviderTest.php @@ -0,0 +1,85 @@ +assertSame(CompletionProviderFixture::class, $attribute->provider); + $this->assertNull($attribute->values); + $this->assertNull($attribute->enum); + } + + public function testCanBeConstructedWithProviderInstance(): void + { + $instance = new CompletionProviderFixture(); + $attribute = new CompletionProvider(provider: $instance); + + $this->assertSame($instance, $attribute->provider); + $this->assertNull($attribute->values); + $this->assertNull($attribute->enum); + } + + public function testCanBeConstructedWithValuesArray(): void + { + $values = ['draft', 'published', 'archived']; + $attribute = new CompletionProvider(values: $values); + + $this->assertNull($attribute->provider); + $this->assertSame($values, $attribute->values); + $this->assertNull($attribute->enum); + } + + public function testCanBeConstructedWithEnumClass(): void + { + $attribute = new CompletionProvider(enum: StatusEnum::class); + + $this->assertNull($attribute->provider); + $this->assertNull($attribute->values); + $this->assertSame(StatusEnum::class, $attribute->enum); + } + + public function testThrowsExceptionWhenNoParametersProvided(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Only one of provider, values, or enum can be set'); + new CompletionProvider(); + } + + public function testThrowsExceptionWhenMultipleParametersProvided(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Only one of provider, values, or enum can be set'); + new CompletionProvider( + provider: CompletionProviderFixture::class, + values: ['test'] + ); + } + + public function testThrowsExceptionWhenAllParametersProvided(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Only one of provider, values, or enum can be set'); + new CompletionProvider( + provider: CompletionProviderFixture::class, + values: ['test'], + enum: StatusEnum::class + ); + } +} diff --git a/tests/Capability/Attribute/McpPromptTest.php b/tests/Capability/Attribute/McpPromptTest.php new file mode 100644 index 00000000..a954e00a --- /dev/null +++ b/tests/Capability/Attribute/McpPromptTest.php @@ -0,0 +1,52 @@ +assertSame($name, $attribute->name); + $this->assertSame($description, $attribute->description); + } + + public function testInstantiatesWithNullValuesForNameAndDescription(): void + { + // Arrange & Act + $attribute = new McpPrompt(name: null, description: null); + + // Assert + $this->assertNull($attribute->name); + $this->assertNull($attribute->description); + } + + public function testInstantiatesWithMissingOptionalArguments(): void + { + // Arrange & Act + $attribute = new McpPrompt(); // Use default constructor values + + // Assert + $this->assertNull($attribute->name); + $this->assertNull($attribute->description); + } +} diff --git a/tests/Capability/Attribute/McpResourceTemplateTest.php b/tests/Capability/Attribute/McpResourceTemplateTest.php new file mode 100644 index 00000000..25d468a7 --- /dev/null +++ b/tests/Capability/Attribute/McpResourceTemplateTest.php @@ -0,0 +1,71 @@ +assertSame($uriTemplate, $attribute->uriTemplate); + $this->assertSame($name, $attribute->name); + $this->assertSame($description, $attribute->description); + $this->assertSame($mimeType, $attribute->mimeType); + } + + public function testInstantiatesWithNullValuesForNameAndDescription(): void + { + // Arrange & Act + $attribute = new McpResourceTemplate( + uriTemplate: 'test://{id}', // uriTemplate is required + name: null, + description: null, + mimeType: null, + ); + + // Assert + $this->assertSame('test://{id}', $attribute->uriTemplate); + $this->assertNull($attribute->name); + $this->assertNull($attribute->description); + $this->assertNull($attribute->mimeType); + } + + public function testInstantiatesWithMissingOptionalArguments(): void + { + // Arrange & Act + $uriTemplate = 'tmpl://{key}'; + $attribute = new McpResourceTemplate(uriTemplate: $uriTemplate); + + // Assert + $this->assertSame($uriTemplate, $attribute->uriTemplate); + $this->assertNull($attribute->name); + $this->assertNull($attribute->description); + $this->assertNull($attribute->mimeType); + } +} diff --git a/tests/Capability/Attribute/McpResourceTest.php b/tests/Capability/Attribute/McpResourceTest.php new file mode 100644 index 00000000..b63efeb8 --- /dev/null +++ b/tests/Capability/Attribute/McpResourceTest.php @@ -0,0 +1,77 @@ +assertSame($uri, $attribute->uri); + $this->assertSame($name, $attribute->name); + $this->assertSame($description, $attribute->description); + $this->assertSame($mimeType, $attribute->mimeType); + $this->assertSame($size, $attribute->size); + } + + public function testInstantiatesWithNullValuesForNameAndDescription(): void + { + // Arrange & Act + $attribute = new McpResource( + uri: 'file:///test', // URI is required + name: null, + description: null, + mimeType: null, + size: null, + ); + + // Assert + $this->assertSame('file:///test', $attribute->uri); + $this->assertNull($attribute->name); + $this->assertNull($attribute->description); + $this->assertNull($attribute->mimeType); + $this->assertNull($attribute->size); + } + + public function testInstantiatesWithMissingOptionalArguments(): void + { + // Arrange & Act + $uri = 'file:///only-uri'; + $attribute = new McpResource(uri: $uri); + + // Assert + $this->assertSame($uri, $attribute->uri); + $this->assertNull($attribute->name); + $this->assertNull($attribute->description); + $this->assertNull($attribute->mimeType); + $this->assertNull($attribute->size); + } +} diff --git a/tests/Capability/Attribute/McpToolTest.php b/tests/Capability/Attribute/McpToolTest.php new file mode 100644 index 00000000..39550bd7 --- /dev/null +++ b/tests/Capability/Attribute/McpToolTest.php @@ -0,0 +1,52 @@ +assertSame($name, $attribute->name); + $this->assertSame($description, $attribute->description); + } + + public function testInstantiatesWithNullValuesForNameAndDescription(): void + { + // Arrange & Act + $attribute = new McpTool(name: null, description: null); + + // Assert + $this->assertNull($attribute->name); + $this->assertNull($attribute->description); + } + + public function testInstantiatesWithMissingOptionalArguments(): void + { + // Arrange & Act + $attribute = new McpTool(); // Use default constructor values + + // Assert + $this->assertNull($attribute->name); + $this->assertNull($attribute->description); + } +} diff --git a/tests/Capability/Discovery/DiscoveryTest.php b/tests/Capability/Discovery/DiscoveryTest.php new file mode 100644 index 00000000..dc88f17c --- /dev/null +++ b/tests/Capability/Discovery/DiscoveryTest.php @@ -0,0 +1,182 @@ +registry = new Registry(); + $this->discoverer = new Discoverer($this->registry); + } + + public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles() + { + $this->discoverer->discover(__DIR__, ['Fixtures']); + + $tools = $this->registry->getTools(); + $this->assertCount(4, $tools); + + $greetUserTool = $this->registry->getTool('greet_user'); + $this->assertInstanceOf(RegisteredTool::class, $greetUserTool); + $this->assertFalse($greetUserTool->isManual); + $this->assertEquals('greet_user', $greetUserTool->tool->name); + $this->assertEquals('Greets a user by name.', $greetUserTool->tool->description); + $this->assertEquals([DiscoverableToolHandler::class, 'greet'], $greetUserTool->handler); + $this->assertArrayHasKey('name', $greetUserTool->tool->inputSchema['properties'] ?? []); + + $repeatActionTool = $this->registry->getTool('repeatAction'); + $this->assertInstanceOf(RegisteredTool::class, $repeatActionTool); + $this->assertEquals('A tool with more complex parameters and inferred name/description.', $repeatActionTool->tool->description); + $this->assertTrue($repeatActionTool->tool->annotations->readOnlyHint); + $this->assertEquals(['count', 'loudly', 'mode'], array_keys($repeatActionTool->tool->inputSchema['properties'] ?? [])); + + $invokableCalcTool = $this->registry->getTool('InvokableCalculator'); + $this->assertInstanceOf(RegisteredTool::class, $invokableCalcTool); + $this->assertFalse($invokableCalcTool->isManual); + $this->assertEquals([InvocableToolFixture::class, '__invoke'], $invokableCalcTool->handler); + + $this->assertNull($this->registry->getTool('private_tool_should_be_ignored')); + $this->assertNull($this->registry->getTool('protected_tool_should_be_ignored')); + $this->assertNull($this->registry->getTool('static_tool_should_be_ignored')); + + $resources = $this->registry->getResources(); + $this->assertCount(3, $resources); + + $appVersionRes = $this->registry->getResource('app://info/version'); + $this->assertInstanceOf(RegisteredResource::class, $appVersionRes); + $this->assertFalse($appVersionRes->isManual); + $this->assertEquals('app_version', $appVersionRes->schema->name); + $this->assertEquals('text/plain', $appVersionRes->schema->mimeType); + + $invokableStatusRes = $this->registry->getResource('invokable://config/status'); + $this->assertInstanceOf(RegisteredResource::class, $invokableStatusRes); + $this->assertFalse($invokableStatusRes->isManual); + $this->assertEquals([InvocableResourceFixture::class, '__invoke'], $invokableStatusRes->handler); + + $prompts = $this->registry->getPrompts(); + $this->assertCount(4, $prompts); + + $storyPrompt = $this->registry->getPrompt('creative_story_prompt'); + $this->assertInstanceOf(RegisteredPrompt::class, $storyPrompt); + $this->assertFalse($storyPrompt->isManual); + $this->assertCount(2, $storyPrompt->prompt->arguments); + $this->assertEquals(CompletionProviderFixture::class, $storyPrompt->completionProviders['genre']); + + $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); + $this->assertInstanceOf(RegisteredPrompt::class, $simplePrompt); + $this->assertFalse($simplePrompt->isManual); + + $invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt'); + $this->assertInstanceOf(RegisteredPrompt::class, $invokableGreeter); + $this->assertFalse($invokableGreeter->isManual); + $this->assertEquals([InvocablePromptFixture::class, '__invoke'], $invokableGreeter->handler); + + $contentCreatorPrompt = $this->registry->getPrompt('content_creator'); + $this->assertInstanceOf(RegisteredPrompt::class, $contentCreatorPrompt); + $this->assertFalse($contentCreatorPrompt->isManual); + $this->assertCount(3, $contentCreatorPrompt->completionProviders); + + $templates = $this->registry->getResourceTemplates(); + $this->assertCount(4, $templates); + + $productTemplate = $this->registry->getResourceTemplate('product://{region}/details/{productId}'); + $this->assertInstanceOf(RegisteredResourceTemplate::class, $productTemplate); + $this->assertFalse($productTemplate->isManual); + $this->assertEquals('product_details_template', $productTemplate->resourceTemplate->name); + $this->assertEquals(CompletionProviderFixture::class, $productTemplate->completionProviders['region']); + $this->assertEqualsCanonicalizing(['region', 'productId'], $productTemplate->getVariableNames()); + + $invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}'); + $this->assertInstanceOf(RegisteredResourceTemplate::class, $invokableUserTemplate); + $this->assertFalse($invokableUserTemplate->isManual); + $this->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $invokableUserTemplate->handler); + } + + public function testDoesNotDiscoverElementsFromExcludedDirectories() + { + $this->discoverer->discover(__DIR__, ['Fixtures']); + $this->assertInstanceOf(RegisteredTool::class, $this->registry->getTool('hidden_subdir_tool')); + + $this->registry->clear(); + + $this->discoverer->discover(__DIR__, ['Fixtures'], ['SubDir']); + $this->assertNull($this->registry->getTool('hidden_subdir_tool')); + } + + public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles() + { + $this->discoverer->discover(__DIR__, ['EmptyDir']); + $this->assertEmpty($this->registry->getTools()); + } + + public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute() + { + $this->discoverer->discover(__DIR__, ['Fixtures']); + + $repeatActionTool = $this->registry->getTool('repeatAction'); + $this->assertEquals('repeatAction', $repeatActionTool->tool->name); + $this->assertEquals('A tool with more complex parameters and inferred name/description.', $repeatActionTool->tool->description); + + $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); + $this->assertEquals('simpleQuestionPrompt', $simplePrompt->prompt->name); + $this->assertNull($simplePrompt->prompt->description); + + $invokableCalc = $this->registry->getTool('InvokableCalculator'); + $this->assertEquals('InvokableCalculator', $invokableCalc->tool->name); + $this->assertEquals('An invokable calculator tool.', $invokableCalc->tool->description); + } + + public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttributes() + { + $this->discoverer->discover(__DIR__, ['Fixtures']); + + $contentPrompt = $this->registry->getPrompt('content_creator'); + $this->assertInstanceOf(RegisteredPrompt::class, $contentPrompt); + $this->assertCount(3, $contentPrompt->completionProviders); + + $typeProvider = $contentPrompt->completionProviders['type']; + $this->assertInstanceOf(ListCompletionProvider::class, $typeProvider); + + $statusProvider = $contentPrompt->completionProviders['status']; + $this->assertInstanceOf(EnumCompletionProvider::class, $statusProvider); + + $priorityProvider = $contentPrompt->completionProviders['priority']; + $this->assertInstanceOf(EnumCompletionProvider::class, $priorityProvider); + + $contentTemplate = $this->registry->getResourceTemplate('content://{category}/{slug}'); + $this->assertInstanceOf(RegisteredResourceTemplate::class, $contentTemplate); + $this->assertCount(1, $contentTemplate->completionProviders); + + $categoryProvider = $contentTemplate->completionProviders['category']; + $this->assertInstanceOf(ListCompletionProvider::class, $categoryProvider); + } +} diff --git a/tests/Capability/Discovery/DocBlockParserTest.php b/tests/Capability/Discovery/DocBlockParserTest.php new file mode 100644 index 00000000..612ea4c4 --- /dev/null +++ b/tests/Capability/Discovery/DocBlockParserTest.php @@ -0,0 +1,143 @@ +parser = new DocBlockParser(); + } + + public function testGetSummaryReturnsCorrectSummary() + { + $method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); + $docComment = $method->getDocComment() ?: null; + $docBlock = $this->parser->parseDocBlock($docComment); + $this->assertEquals('Simple summary line.', $this->parser->getSummary($docBlock)); + + $method2 = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription'); + $docComment2 = $method2->getDocComment() ?: null; + $docBlock2 = $this->parser->parseDocBlock($docComment2); + $this->assertEquals('Summary line here.', $this->parser->getSummary($docBlock2)); + } + + public function testGetDescriptionReturnsCorrectDescription() + { + $method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription'); + $docComment = $method->getDocComment() ?: null; + $docBlock = $this->parser->parseDocBlock($docComment); + $expectedDesc = "Summary line here.\n\nThis is a longer description spanning\nmultiple lines.\nIt might contain *markdown* or `code`."; + $this->assertEquals($expectedDesc, $this->parser->getDescription($docBlock)); + + $method2 = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); + $docComment2 = $method2->getDocComment() ?: null; + $docBlock2 = $this->parser->parseDocBlock($docComment2); + $this->assertEquals('Simple summary line.', $this->parser->getDescription($docBlock2)); + } + + public function testGetParamTagsReturnsStructuredParamInfo() + { + $method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithParams'); + $docComment = $method->getDocComment() ?: null; + $docBlock = $this->parser->parseDocBlock($docComment); + $params = $this->parser->getParamTags($docBlock); + + $this->assertCount(6, $params); + $this->assertArrayHasKey('$param1', $params); + $this->assertArrayHasKey('$param2', $params); + $this->assertArrayHasKey('$param3', $params); + $this->assertArrayHasKey('$param4', $params); + $this->assertArrayHasKey('$param5', $params); + $this->assertArrayHasKey('$param6', $params); + + $this->assertInstanceOf(Param::class, $params['$param1']); + $this->assertEquals('param1', $params['$param1']->getVariableName()); + $this->assertEquals('string', $this->parser->getParamTypeString($params['$param1'])); + $this->assertEquals('description for string param', $this->parser->getParamDescription($params['$param1'])); + + $this->assertInstanceOf(Param::class, $params['$param2']); + $this->assertEquals('param2', $params['$param2']->getVariableName()); + $this->assertEquals('int|null', $this->parser->getParamTypeString($params['$param2'])); + $this->assertEquals('description for nullable int param', $this->parser->getParamDescription($params['$param2'])); + + $this->assertInstanceOf(Param::class, $params['$param3']); + $this->assertEquals('param3', $params['$param3']->getVariableName()); + $this->assertEquals('bool', $this->parser->getParamTypeString($params['$param3'])); + $this->assertEquals('nothing to say', $this->parser->getParamDescription($params['$param3'])); + + $this->assertInstanceOf(Param::class, $params['$param4']); + $this->assertEquals('param4', $params['$param4']->getVariableName()); + $this->assertEquals('mixed', $this->parser->getParamTypeString($params['$param4'])); + $this->assertEquals('Missing type', $this->parser->getParamDescription($params['$param4'])); + + $this->assertInstanceOf(Param::class, $params['$param5']); + $this->assertEquals('param5', $params['$param5']->getVariableName()); + $this->assertEquals('array', $this->parser->getParamTypeString($params['$param5'])); + $this->assertEquals('array description', $this->parser->getParamDescription($params['$param5'])); + + $this->assertInstanceOf(Param::class, $params['$param6']); + $this->assertEquals('param6', $params['$param6']->getVariableName()); + $this->assertEquals('stdClass', $this->parser->getParamTypeString($params['$param6'])); + $this->assertEquals('object param', $this->parser->getParamDescription($params['$param6'])); + } + + public function testGetTagsByNameReturnsSpecificTags() + { + $method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithMultipleTags'); + $docComment = $method->getDocComment() ?: null; + $docBlock = $this->parser->parseDocBlock($docComment); + + $this->assertInstanceOf(DocBlock::class, $docBlock); + + $throwsTags = $docBlock->getTagsByName('throws'); + $this->assertCount(1, $throwsTags); + $this->assertInstanceOf(Throws::class, $throwsTags[0]); + $this->assertEquals('\\RuntimeException', (string) $throwsTags[0]->getType()); + $this->assertEquals('if processing fails', $throwsTags[0]->getDescription()->render()); + + $deprecatedTags = $docBlock->getTagsByName('deprecated'); + $this->assertCount(1, $deprecatedTags); + $this->assertInstanceOf(Deprecated::class, $deprecatedTags[0]); + $this->assertEquals('use newMethod() instead', $deprecatedTags[0]->getDescription()->render()); + + $seeTags = $docBlock->getTagsByName('see'); + $this->assertCount(1, $seeTags); + $this->assertInstanceOf(See::class, $seeTags[0]); + $this->assertStringContainsString('DocBlockTestFixture::newMethod()', (string) $seeTags[0]->getReference()); + + $nonExistentTags = $docBlock->getTagsByName('nosuchtag'); + $this->assertEmpty($nonExistentTags); + } + + public function testHandlesMethodWithNoDocblockGracefully() + { + $method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithNoDocBlock'); + $docComment = $method->getDocComment() ?: null; + $docBlock = $this->parser->parseDocBlock($docComment); + + $this->assertNull($docBlock); + $this->assertNull($this->parser->getSummary($docBlock)); + $this->assertNull($this->parser->getDescription($docBlock)); + $this->assertEmpty($this->parser->getParamTags($docBlock)); + } +} diff --git a/tests/Capability/Discovery/DocBlockTestFixture.php b/tests/Capability/Discovery/DocBlockTestFixture.php new file mode 100644 index 00000000..2d237b42 --- /dev/null +++ b/tests/Capability/Discovery/DocBlockTestFixture.php @@ -0,0 +1,96 @@ + $param5 array description + * @param \stdClass $param6 object param + */ + /* @phpstan-ignore-next-line missingType.parameter */ + public function methodWithParams(string $param1, ?int $param2, bool $param3, $param4, array $param5, \stdClass $param6): void + { + } + + /** + * Method with return tag. + * + * @return string the result of the operation + */ + public function methodWithReturn(): string + { + return ''; + } + + /** + * Method with multiple tags. + * + * @param float $value the value to process + * + * @return bool status of the operation + * + * @throws \RuntimeException if processing fails + * + * @deprecated use newMethod() instead + * @see DocBlockTestFixture::newMethod() + */ + public function methodWithMultipleTags(float $value): bool /* @phpstan-ignore throws.unusedType */ + { + return true; + } + + /** + * Malformed docblock - missing closing. + */ + public function methodWithMalformedDocBlock(): void + { + } + + public function methodWithNoDocBlock(): void + { + } + + // Some other method needed for a @see tag perhaps + public function newMethod(): void + { + } +} diff --git a/tests/Capability/Discovery/Fixtures/DiscoverablePromptHandler.php b/tests/Capability/Discovery/Fixtures/DiscoverablePromptHandler.php new file mode 100644 index 00000000..770c1a17 --- /dev/null +++ b/tests/Capability/Discovery/Fixtures/DiscoverablePromptHandler.php @@ -0,0 +1,47 @@ + 'user', 'content' => "Write a {$genre} story about a lost robot, approximately {$lengthWords} words long."], + ]; + } + + #[McpPrompt] + public function simpleQuestionPrompt(string $question): array + { + return [ + ['role' => 'user', 'content' => $question], + ['role' => 'assistant', 'content' => 'I will try to answer that.'], + ]; + } +} diff --git a/tests/Capability/Discovery/Fixtures/DiscoverableResourceHandler.php b/tests/Capability/Discovery/Fixtures/DiscoverableResourceHandler.php new file mode 100644 index 00000000..1b8cf6d1 --- /dev/null +++ b/tests/Capability/Discovery/Fixtures/DiscoverableResourceHandler.php @@ -0,0 +1,50 @@ + 'dark', 'fontSize' => 14]; + } + + public function someOtherMethod(): void + { + } +} diff --git a/tests/Capability/Discovery/Fixtures/DiscoverableTemplateHandler.php b/tests/Capability/Discovery/Fixtures/DiscoverableTemplateHandler.php new file mode 100644 index 00000000..2e6f19e7 --- /dev/null +++ b/tests/Capability/Discovery/Fixtures/DiscoverableTemplateHandler.php @@ -0,0 +1,51 @@ + $productId, + 'name' => 'Product '.$productId, + 'region' => $region, + 'price' => ('EU' === $region ? '€' : '$').(hexdec(substr(md5($productId), 0, 4)) / 100), + ]; + } + + #[McpResourceTemplate(uriTemplate: 'file://{path}/{filename}.{extension}')] + public function getFileContent(string $path, string $filename, string $extension): string + { + return "Content of {$path}/{$filename}.{$extension}"; + } +} diff --git a/tests/Capability/Discovery/Fixtures/DiscoverableToolHandler.php b/tests/Capability/Discovery/Fixtures/DiscoverableToolHandler.php new file mode 100644 index 00000000..b6ab10e3 --- /dev/null +++ b/tests/Capability/Discovery/Fixtures/DiscoverableToolHandler.php @@ -0,0 +1,68 @@ + $count, 'loudly' => $loudly, 'mode' => $mode->value, 'message' => 'Action repeated.']; + } + + // This method should NOT be discovered as a tool + public function internalHelperMethod(int $value): int + { + return $value * 2; + } + + #[McpTool(name: 'private_tool_should_be_ignored')] // On private method + private function aPrivateTool(): void + { + } + + #[McpTool(name: 'protected_tool_should_be_ignored')] // On protected method + protected function aProtectedTool(): void + { + } + + #[McpTool(name: 'static_tool_should_be_ignored')] // On static method + public static function aStaticTool(): void + { + } +} diff --git a/tests/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php b/tests/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php new file mode 100644 index 00000000..b980befb --- /dev/null +++ b/tests/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php @@ -0,0 +1,57 @@ + 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority}"], + ]; + } + + /** + * Resource template with list completion for categories. + */ + #[McpResourceTemplate( + uriTemplate: 'content://{category}/{slug}', + name: 'content_template' + )] + public function getContent( + #[CompletionProvider(values: ['news', 'blog', 'docs', 'api'])] + string $category, + string $slug, + ): array { + return [ + 'category' => $category, + 'slug' => $slug, + 'url' => "https://example.com/{$category}/{$slug}", + ]; + } +} diff --git a/tests/Capability/Discovery/Fixtures/InvocablePromptFixture.php b/tests/Capability/Discovery/Fixtures/InvocablePromptFixture.php new file mode 100644 index 00000000..16cd278e --- /dev/null +++ b/tests/Capability/Discovery/Fixtures/InvocablePromptFixture.php @@ -0,0 +1,23 @@ + 'user', 'content' => "Generate a short greeting for {$personName}."]]; + } +} diff --git a/tests/Capability/Discovery/Fixtures/InvocableResourceFixture.php b/tests/Capability/Discovery/Fixtures/InvocableResourceFixture.php new file mode 100644 index 00000000..87e3b865 --- /dev/null +++ b/tests/Capability/Discovery/Fixtures/InvocableResourceFixture.php @@ -0,0 +1,23 @@ + 'OK', 'load' => rand(1, 100) / 100.0]; + } +} diff --git a/tests/Capability/Discovery/Fixtures/InvocableResourceTemplateFixture.php b/tests/Capability/Discovery/Fixtures/InvocableResourceTemplateFixture.php new file mode 100644 index 00000000..9a7d0c10 --- /dev/null +++ b/tests/Capability/Discovery/Fixtures/InvocableResourceTemplateFixture.php @@ -0,0 +1,23 @@ + $userId, 'email' => "user{$userId}@example-invokable.com"]; + } +} diff --git a/tests/Capability/Discovery/Fixtures/InvocableToolFixture.php b/tests/Capability/Discovery/Fixtures/InvocableToolFixture.php new file mode 100644 index 00000000..54f1b342 --- /dev/null +++ b/tests/Capability/Discovery/Fixtures/InvocableToolFixture.php @@ -0,0 +1,26 @@ +assertInstanceOf(\ReflectionFunction::class, $resolved); + $this->assertEquals(1, $resolved->getNumberOfParameters()); + $this->assertInstanceOf(\ReflectionNamedType::class, $returnType = $resolved->getReturnType()); + $this->assertEquals('string', $returnType->getName()); + } + + public function testResolvesValidArrayHandler() + { + $handler = [ValidHandlerClass::class, 'publicMethod']; + $resolved = HandlerResolver::resolve($handler); + $this->assertInstanceOf(\ReflectionMethod::class, $resolved); + $this->assertEquals('publicMethod', $resolved->getName()); + $this->assertEquals(ValidHandlerClass::class, $resolved->getDeclaringClass()->getName()); + } + + public function testResolvesValidInvokableClassStringHandler() + { + $handler = ValidInvokableClass::class; + $resolved = HandlerResolver::resolve($handler); + $this->assertInstanceOf(\ReflectionMethod::class, $resolved); + $this->assertEquals('__invoke', $resolved->getName()); + $this->assertEquals(ValidInvokableClass::class, $resolved->getDeclaringClass()->getName()); + } + + public function testResolvesStaticMethodsForManualRegistration() + { + $handler = [ValidHandlerClass::class, 'staticMethod']; + $resolved = HandlerResolver::resolve($handler); + $this->assertInstanceOf(\ReflectionMethod::class, $resolved); + $this->assertEquals('staticMethod', $resolved->getName()); + $this->assertTrue($resolved->isStatic()); + } + + public function testThrowsForInvalidArrayHandlerFormatCount() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid array handler format. Expected [ClassName::class, 'methodName']."); + HandlerResolver::resolve([ValidHandlerClass::class]); /* @phpstan-ignore argument.type */ + } + + public function testThrowsForInvalidArrayHandlerFormatTypes() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid array handler format. Expected [ClassName::class, 'methodName']."); + HandlerResolver::resolve([ValidHandlerClass::class, 123]); /* @phpstan-ignore argument.type */ + } + + public function testThrowsForNonExistentClassInArrayHandler() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Handler class "NonExistentClass" not found'); + HandlerResolver::resolve(['NonExistentClass', 'method']); + } + + public function testThrowsForNonExistentMethodInArrayHandler() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Handler method "nonExistentMethod" not found in class'); + HandlerResolver::resolve([ValidHandlerClass::class, 'nonExistentMethod']); + } + + public function testThrowsForNonExistentClassInStringHandler() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); + HandlerResolver::resolve('NonExistentInvokableClass'); + } + + public function testThrowsForNonInvokableClassStringHandler() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invokable handler class "Mcp\\Tests\\Capability\\Discovery\\NonInvokableClass" must have a public "__invoke" method.'); + HandlerResolver::resolve(NonInvokableClass::class); + } + + public function testThrowsForProtectedMethodHandler() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must be public'); + HandlerResolver::resolve([ValidHandlerClass::class, 'protectedMethod']); + } + + public function testThrowsForPrivateMethodHandler() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must be public'); + HandlerResolver::resolve([ValidHandlerClass::class, 'privateMethod']); + } + + public function testThrowsForConstructorAsHandler() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot be a constructor or destructor'); + HandlerResolver::resolve([ValidHandlerClass::class, '__construct']); + } + + public function testThrowsForDestructorAsHandler() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot be a constructor or destructor'); + HandlerResolver::resolve([ValidHandlerClass::class, '__destruct']); + } + + public function testThrowsForAbstractMethodHandler() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Handler method "Mcp\Tests\Capability\Discovery\AbstractHandlerClass::abstractMethod" must be abstract.'); + HandlerResolver::resolve([AbstractHandlerClass::class, 'abstractMethod']); + } + + public function testResolvesClosuresWithDifferentSignatures() + { + $noParams = function () { + return 'test'; + }; + $withParams = function (int $a, string $b = 'default') { + return $a.$b; + }; + $variadic = function (...$args) { + return $args; + }; + $this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($noParams)); + $this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($withParams)); + $this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($variadic)); + $this->assertEquals(0, HandlerResolver::resolve($noParams)->getNumberOfParameters()); + $this->assertEquals(2, HandlerResolver::resolve($withParams)->getNumberOfParameters()); + $this->assertTrue(HandlerResolver::resolve($variadic)->isVariadic()); + } + + public function testDistinguishesBetweenClosuresAndCallableArrays() + { + $closure = function () { + return 'closure'; + }; + $array = [ValidHandlerClass::class, 'publicMethod']; + $string = ValidInvokableClass::class; + $this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($closure)); + $this->assertInstanceOf(\ReflectionMethod::class, HandlerResolver::resolve($array)); + $this->assertInstanceOf(\ReflectionMethod::class, HandlerResolver::resolve($string)); + } +} + +// Helper classes +class ValidHandlerClass +{ + public function publicMethod(): void + { + } + + protected function protectedMethod(): void + { + } + + private function privateMethod(): void /* @phpstan-ignore method.unused */ + { + } + + public static function staticMethod(): void + { + } + + public function __construct() + { + } + + public function __destruct() + { + } +} +class ValidInvokableClass +{ + public function __invoke(): void + { + } +} +class NonInvokableClass +{ +} +abstract class AbstractHandlerClass +{ + abstract public function abstractMethod(): void; +} diff --git a/tests/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Capability/Discovery/SchemaGeneratorFixture.php new file mode 100644 index 00000000..736487ed --- /dev/null +++ b/tests/Capability/Discovery/SchemaGeneratorFixture.php @@ -0,0 +1,415 @@ + 'object', + 'description' => 'Creates a custom filter with complete definition', + 'properties' => [ + 'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']], + 'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']], + 'value' => ['description' => 'Value to filter by, type depends on field and operator'], + ], + 'required' => ['field', 'operator', 'value'], + 'if' => [ + 'properties' => ['field' => ['const' => 'date']], + ], + 'then' => [ + 'properties' => ['value' => ['type' => 'string', 'format' => 'date']], + ], + ])] + public function methodLevelCompleteDefinition(string $field, string $operator, mixed $value): array + { + return compact('field', 'operator', 'value'); + } + + /** + * Method-level Schema defining properties. + */ + #[Schema( + description: 'Creates a new user with detailed information.', + properties: [ + 'username' => ['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'], + 'email' => ['type' => 'string', 'format' => 'email'], + 'age' => ['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'], + 'isActive' => ['type' => 'boolean', 'default' => true], + ], + required: ['username', 'email'] + )] + public function methodLevelWithProperties(string $username, string $email, int $age, bool $isActive = true): array + { + return compact('username', 'email', 'age', 'isActive'); + } + + /** + * Method-level Schema for complex array argument. + */ + #[Schema( + properties: [ + 'profiles' => [ + 'type' => 'array', + 'description' => 'An array of user profiles to update.', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'data' => ['type' => 'object', 'additionalProperties' => true], + ], + 'required' => ['id', 'data'], + ], + ], + ], + required: ['profiles'] + )] + public function methodLevelArrayArgument(array $profiles): array + { + return ['updated_count' => \count($profiles)]; + } + + // ===== PARAMETER-LEVEL SCHEMA SCENARIOS ===== + + /** + * Parameter-level Schema attributes only. + */ + public function parameterLevelOnly( + #[Schema(description: 'Recipient ID', pattern: '^user_')] + string $recipientId, + #[Schema(maxLength: 1024)] + string $messageBody, + #[Schema(type: 'integer', enum: [1, 2, 5])] + int $priority = 1, + #[Schema( + type: 'object', + properties: [ + 'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']], + 'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'], + ], + required: ['type'] + )] + ?array $notificationConfig = null, + ): array { + return compact('recipientId', 'messageBody', 'priority', 'notificationConfig'); + } + + /** + * Parameter-level Schema with string constraints. + */ + public function parameterStringConstraints( + #[Schema(format: 'email')] + string $email, + #[Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$')] + string $password, + string $regularString, + ): void { + } + + /** + * Parameter-level Schema with numeric constraints. + */ + public function parameterNumericConstraints( + #[Schema(minimum: 18, maximum: 120)] + int $age, + #[Schema(minimum: 0, maximum: 5, exclusiveMaximum: true)] + float $rating, + #[Schema(multipleOf: 10)] + int $count, + ): void { + } + + /** + * Parameter-level Schema with array constraints. + */ + public function parameterArrayConstraints( + #[Schema(type: 'array', items: ['type' => 'string'], minItems: 1, uniqueItems: true)] + array $tags, + #[Schema(type: 'array', items: ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], minItems: 1, maxItems: 5)] + array $scores, + ): void { + } + + // ===== COMBINED SCENARIOS ===== + + /** + * Method-level + Parameter-level Schema combination. + * + * @param string $settingKey The key of the setting + * @param mixed $newValue The new value for the setting + */ + #[Schema( + properties: [ + 'settingKey' => ['type' => 'string', 'description' => 'The key of the setting.'], + 'newValue' => ['description' => 'The new value for the setting (any type).'], + ], + required: ['settingKey', 'newValue'] + )] + public function methodAndParameterLevel( + string $settingKey, + #[Schema(description: 'The specific new boolean value.', type: 'boolean')] + mixed $newValue, + ): array { + return compact('settingKey', 'newValue'); + } + + /** + * Type hints + DocBlock + Parameter-level Schema. + * + * @param string $username The user's name + * @param int $priority Task priority level + */ + public function typeHintDocBlockAndParameterSchema( + #[Schema(minLength: 3, pattern: '^[a-zA-Z0-9_]+$')] + string $username, + #[Schema(minimum: 1, maximum: 10)] + int $priority, + ): void { + } + + // ===== ENUM SCENARIOS ===== + + /** + * Various enum parameter types. + * + * @param BackedStringEnum $stringEnum Backed string enum + * @param BackedIntEnum $intEnum Backed int enum + * @param UnitEnum $unitEnum Unit enum + */ + public function enumParameters( + BackedStringEnum $stringEnum, + BackedIntEnum $intEnum, + UnitEnum $unitEnum, + ?BackedStringEnum $nullableEnum = null, + BackedIntEnum $enumWithDefault = BackedIntEnum::First, + ): void { + } + + // ===== ARRAY TYPE SCENARIOS ===== + + /** + * Various array type scenarios. + * + * @param array $genericArray Generic array + * @param string[] $stringArray Array of strings + * @param int[] $intArray Array of integers + * @param array $mixedMap Mixed array map + * @param array{name: string, age: int} $objectLikeArray Object-like array + * @param array{user: array{id: int, name: string}, items: int[]} $nestedObjectArray Nested object array + */ + public function arrayTypeScenarios( + array $genericArray, + array $stringArray, + array $intArray, + array $mixedMap, + array $objectLikeArray, + array $nestedObjectArray, + ): void { + } + + // ===== NULLABLE AND OPTIONAL SCENARIOS ===== + + /** + * Nullable and optional parameter scenarios. + * + * @param string|null $nullableString Nullable string + * @param int|null $nullableInt Nullable integer + */ + public function nullableAndOptional( + ?string $nullableString, + ?int $nullableInt = null, + string $optionalString = 'default', + bool $optionalBool = true, + array $optionalArray = [], + ): void { + } + + // ===== UNION TYPE SCENARIOS ===== + + /** + * Union type parameters. + * + * @param string|int $stringOrInt String or integer + * @param bool|string|null $multiUnion Bool, string or null + */ + public function unionTypes( + string|int $stringOrInt, + bool|string|null $multiUnion, + ): void { + } + + // ===== VARIADIC SCENARIOS ===== + + /** + * Variadic parameter scenarios. + * + * @param string ...$items Variadic strings + */ + public function variadicStrings(string ...$items): void + { + } + + /** + * Variadic with Schema constraints. + * + * @param int ...$numbers Variadic integers + */ + public function variadicWithConstraints( + #[Schema(items: ['type' => 'integer', 'minimum' => 0])] + int ...$numbers, + ): void { + } + + // ===== MIXED TYPE SCENARIOS ===== + + /** + * Mixed type scenarios. + * + * @param mixed $anyValue Any value + * @param mixed $optionalAny Optional any value + */ + public function mixedTypes( + mixed $anyValue, + mixed $optionalAny = 'default', + ): void { + } + + // ===== COMPLEX NESTED SCENARIOS ===== + + /** + * Complex nested Schema constraints. + */ + public function complexNestedSchema( + #[Schema( + type: 'object', + properties: [ + 'customer' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], + 'name' => ['type' => 'string', 'minLength' => 2], + 'email' => ['type' => 'string', 'format' => 'email'], + ], + 'required' => ['id', 'name'], + ], + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], + 'quantity' => ['type' => 'integer', 'minimum' => 1], + 'price' => ['type' => 'number', 'minimum' => 0], + ], + 'required' => ['product_id', 'quantity', 'price'], + ], + ], + 'metadata' => [ + 'type' => 'object', + 'additionalProperties' => true, + ], + ], + required: ['customer', 'items'] + )] + array $order, + ): array { + return ['order_id' => uniqid()]; + } + + // ===== TYPE PRECEDENCE SCENARIOS ===== + + /** + * Testing type precedence between PHP, DocBlock, and Schema. + * + * @param int $numericString DocBlock says integer despite string type hint + * @param string $stringWithConstraints String with Schema constraints + * @param array $arrayWithItems Array with Schema item overrides + */ + public function typePrecedenceTest( + string $numericString, + #[Schema(format: 'email', minLength: 5)] + string $stringWithConstraints, + #[Schema(items: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])] + array $arrayWithItems, + ): void { + } + + // ===== ERROR EDGE CASES ===== + + /** + * Method with no parameters but Schema description. + */ + #[Schema(description: 'Gets server status. Takes no arguments.', properties: [])] + public function noParamsWithSchema(): array + { + return ['status' => 'OK']; + } + + /** + * Parameter with Schema but inferred type. + */ + public function parameterSchemaInferredType( + #[Schema(description: 'Some parameter', minLength: 3)] + $inferredParam, + ): void { + } +} diff --git a/tests/Capability/Discovery/SchemaGeneratorTest.php b/tests/Capability/Discovery/SchemaGeneratorTest.php new file mode 100644 index 00000000..19fb2288 --- /dev/null +++ b/tests/Capability/Discovery/SchemaGeneratorTest.php @@ -0,0 +1,330 @@ +schemaGenerator = new SchemaGenerator(new DocBlockParser()); + } + + public function testGeneratesEmptyPropertiesObjectForMethodWithNoParameters() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParams'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals([ + 'type' => 'object', + 'properties' => new \stdClass(), + ], $schema); + $this->assertArrayNotHasKey('required', $schema); + } + + public function testInfersBasicTypesFromPhpTypeHints() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsOnly'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['type' => 'string'], $schema['properties']['name']); + $this->assertEquals(['type' => 'integer'], $schema['properties']['age']); + $this->assertEquals(['type' => 'boolean'], $schema['properties']['active']); + $this->assertEquals(['type' => 'array'], $schema['properties']['tags']); + $this->assertEquals(['type' => ['null', 'object'], 'default' => null], $schema['properties']['config']); + $this->assertEqualsCanonicalizing(['name', 'age', 'active', 'tags'], $schema['required']); + } + + public function testInfersTypesAndDescriptionsFromDocBlockTags() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOnly'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['type' => 'string', 'description' => 'The username'], $schema['properties']['username']); + $this->assertEquals(['type' => 'integer', 'description' => 'Number of items'], $schema['properties']['count']); + $this->assertEquals(['type' => 'boolean', 'description' => 'Whether enabled'], $schema['properties']['enabled']); + $this->assertEquals(['type' => 'array', 'description' => 'Some data'], $schema['properties']['data']); + $this->assertEqualsCanonicalizing(['username', 'count', 'enabled', 'data'], $schema['required']); + } + + public function testUsesPhpTypeHintsForTypeAndDocBlockForDescriptions() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsWithDocBlock'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['type' => 'string', 'description' => 'User email address'], $schema['properties']['email']); + $this->assertEquals(['type' => 'integer', 'description' => 'User score'], $schema['properties']['score']); + $this->assertEquals(['type' => 'boolean', 'description' => 'Whether user is verified'], $schema['properties']['verified']); + $this->assertEqualsCanonicalizing(['email', 'score', 'verified'], $schema['required']); + } + + public function testUsesCompleteSchemaDefinitionFromMethodLevelSchemaAttribute() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelCompleteDefinition'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals([ + 'type' => 'object', + 'description' => 'Creates a custom filter with complete definition', + 'properties' => [ + 'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']], + 'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']], + 'value' => ['description' => 'Value to filter by, type depends on field and operator'], + ], + 'required' => ['field', 'operator', 'value'], + 'if' => [ + 'properties' => ['field' => ['const' => 'date']], + ], + 'then' => [ + 'properties' => ['value' => ['type' => 'string', 'format' => 'date']], + ], + ], $schema); + } + + public function testGeneratesSchemaFromMethodLevelSchemaAttributeWithProperties() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelWithProperties'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals('Creates a new user with detailed information.', $schema['description']); + $this->assertEquals(['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'], $schema['properties']['username']); + $this->assertEquals(['type' => 'string', 'format' => 'email'], $schema['properties']['email']); + $this->assertEquals(['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'], $schema['properties']['age']); + $this->assertEquals(['type' => 'boolean', 'default' => true], $schema['properties']['isActive']); + $this->assertEqualsCanonicalizing(['age', 'username', 'email'], $schema['required']); + } + + public function testGeneratesSchemaForSingleArrayArgumentFromMethodLevelSchemaAttribute() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelArrayArgument'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals([ + 'type' => 'array', + 'description' => 'An array of user profiles to update.', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'data' => ['type' => 'object', 'additionalProperties' => true], + ], + 'required' => ['id', 'data'], + ], + ], $schema['properties']['profiles']); + $this->assertEquals(['profiles'], $schema['required']); + } + + public function testGeneratesSchemaFromIndividualParameterLevelSchemaAttributes() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterLevelOnly'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['description' => 'Recipient ID', 'pattern' => '^user_', 'type' => 'string'], $schema['properties']['recipientId']); + $this->assertEquals(['maxLength' => 1024, 'type' => 'string'], $schema['properties']['messageBody']); + $this->assertEquals(['type' => 'integer', 'enum' => [1, 2, 5], 'default' => 1], $schema['properties']['priority']); + $this->assertEquals([ + 'type' => 'object', + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']], + 'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'], + ], + 'required' => ['type'], + 'default' => null, + ], $schema['properties']['notificationConfig']); + $this->assertEqualsCanonicalizing(['recipientId', 'messageBody'], $schema['required']); + } + + public function testAppliesStringConstraintsFromParameterLevelSchemaAttributes() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterStringConstraints'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['format' => 'email', 'type' => 'string'], $schema['properties']['email']); + $this->assertEquals(['minLength' => 8, 'pattern' => '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$', 'type' => 'string'], $schema['properties']['password']); + $this->assertEquals(['type' => 'string'], $schema['properties']['regularString']); + $this->assertEqualsCanonicalizing(['email', 'password', 'regularString'], $schema['required']); + } + + public function testAppliesNumericConstraintsFromParameterLevelSchemaAttributes() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterNumericConstraints'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['minimum' => 18, 'maximum' => 120, 'type' => 'integer'], $schema['properties']['age']); + $this->assertEquals(['minimum' => 0, 'maximum' => 5, 'exclusiveMaximum' => true, 'type' => 'number'], $schema['properties']['rating']); + $this->assertEquals(['multipleOf' => 10, 'type' => 'integer'], $schema['properties']['count']); + $this->assertEqualsCanonicalizing(['age', 'rating', 'count'], $schema['required']); + } + + public function testAppliesArrayConstraintsFromParameterLevelSchemaAttributes() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterArrayConstraints'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['type' => 'array', 'items' => ['type' => 'string'], 'minItems' => 1, 'uniqueItems' => true], $schema['properties']['tags']); + $this->assertEquals(['type' => 'array', 'items' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], 'minItems' => 1, 'maxItems' => 5], $schema['properties']['scores']); + $this->assertEqualsCanonicalizing(['tags', 'scores'], $schema['required']); + } + + public function testMergesMethodLevelAndParameterLevelSchemaAttributes() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodAndParameterLevel'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['type' => 'string', 'description' => 'The key of the setting.'], $schema['properties']['settingKey']); + $this->assertEquals(['description' => 'The specific new boolean value.', 'type' => 'boolean'], $schema['properties']['newValue']); + $this->assertEqualsCanonicalizing(['settingKey', 'newValue'], $schema['required']); + } + + public function testCombinesPhpTypeHintsDocBlockDescriptionsAndParameterLevelSchemaConstraints() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintDocBlockAndParameterSchema'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$', 'type' => 'string', 'description' => "The user's name"], $schema['properties']['username']); + $this->assertEquals(['minimum' => 1, 'maximum' => 10, 'type' => 'integer', 'description' => 'Task priority level'], $schema['properties']['priority']); + $this->assertEqualsCanonicalizing(['username', 'priority'], $schema['required']); + } + + public function testGeneratesCorrectSchemaForEnumParameters() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['type' => 'string', 'description' => 'Backed string enum', 'enum' => ['A', 'B']], $schema['properties']['stringEnum']); + $this->assertEquals(['type' => 'integer', 'description' => 'Backed int enum', 'enum' => [1, 2]], $schema['properties']['intEnum']); + $this->assertEquals(['type' => 'string', 'description' => 'Unit enum', 'enum' => ['Yes', 'No']], $schema['properties']['unitEnum']); + $this->assertEquals(['type' => ['null', 'string'], 'enum' => ['A', 'B'], 'default' => null], $schema['properties']['nullableEnum']); + $this->assertEquals(['type' => 'integer', 'enum' => [1, 2], 'default' => 1], $schema['properties']['enumWithDefault']); + $this->assertEqualsCanonicalizing(['stringEnum', 'intEnum', 'unitEnum'], $schema['required']); + } + + public function testGeneratesCorrectSchemaForArrayTypeDeclarations() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['type' => 'array', 'description' => 'Generic array'], $schema['properties']['genericArray']); + $this->assertEquals(['type' => 'array', 'description' => 'Array of strings', 'items' => ['type' => 'string']], $schema['properties']['stringArray']); + $this->assertEquals(['type' => 'array', 'description' => 'Array of integers', 'items' => ['type' => 'integer']], $schema['properties']['intArray']); + $this->assertEquals(['type' => 'array', 'description' => 'Mixed array map'], $schema['properties']['mixedMap']); + $this->assertArrayHasKey('type', $schema['properties']['objectLikeArray']); + $this->assertEquals('object', $schema['properties']['objectLikeArray']['type']); + $this->assertArrayHasKey('properties', $schema['properties']['objectLikeArray']); + $this->assertArrayHasKey('name', $schema['properties']['objectLikeArray']['properties']); + $this->assertArrayHasKey('age', $schema['properties']['objectLikeArray']['properties']); + $this->assertEqualsCanonicalizing(['genericArray', 'stringArray', 'intArray', 'mixedMap', 'objectLikeArray', 'nestedObjectArray'], $schema['required']); + } + + public function testHandlesNullableTypeHintsAndOptionalParameters() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['type' => ['null', 'string'], 'description' => 'Nullable string'], $schema['properties']['nullableString']); + $this->assertEquals(['type' => ['null', 'integer'], 'description' => 'Nullable integer', 'default' => null], $schema['properties']['nullableInt']); + $this->assertEquals(['type' => 'string', 'default' => 'default'], $schema['properties']['optionalString']); + $this->assertEquals(['type' => 'boolean', 'default' => true], $schema['properties']['optionalBool']); + $this->assertEquals(['type' => 'array', 'default' => []], $schema['properties']['optionalArray']); + $this->assertEqualsCanonicalizing(['nullableString'], $schema['required']); + } + + public function testGeneratesSchemaForPhpUnionTypes() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'unionTypes'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['type' => ['integer', 'string'], 'description' => 'String or integer'], $schema['properties']['stringOrInt']); + $this->assertEquals(['type' => ['null', 'boolean', 'string'], 'description' => 'Bool, string or null'], $schema['properties']['multiUnion']); + $this->assertEqualsCanonicalizing(['stringOrInt', 'multiUnion'], $schema['required']); + } + + public function testRepresentsVariadicStringParametersAsArrayOfStrings() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicStrings'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['type' => 'array', 'description' => 'Variadic strings', 'items' => ['type' => 'string']], $schema['properties']['items']); + $this->assertArrayNotHasKey('required', $schema); + } + + public function testAppliesItemConstraintsToVariadicParameters() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicWithConstraints'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['items' => ['type' => 'integer', 'minimum' => 0], 'type' => 'array', 'description' => 'Variadic integers'], $schema['properties']['numbers']); + $this->assertArrayNotHasKey('required', $schema); + } + + public function testHandlesMixedTypeHintsOmittingExplicitType() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['description' => 'Any value'], $schema['properties']['anyValue']); + $this->assertEquals(['description' => 'Optional any value', 'default' => 'default'], $schema['properties']['optionalAny']); + $this->assertEqualsCanonicalizing(['anyValue'], $schema['required']); + } + + public function testGeneratesSchemaForComplexNestedObjectAndArrayStructures() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals([ + 'type' => 'object', + 'properties' => [ + 'customer' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], + 'name' => ['type' => 'string', 'minLength' => 2], + 'email' => ['type' => 'string', 'format' => 'email'], + ], + 'required' => ['id', 'name'], + ], + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], + 'quantity' => ['type' => 'integer', 'minimum' => 1], + 'price' => ['type' => 'number', 'minimum' => 0], + ], + 'required' => ['product_id', 'quantity', 'price'], + ], + ], + 'metadata' => [ + 'type' => 'object', + 'additionalProperties' => true, + ], + ], + 'required' => ['customer', 'items'], + ], $schema['properties']['order']); + $this->assertEquals(['order'], $schema['required']); + } + + public function testTypePrecedenceParameterSchemaOverridesDocBlockOverridesPhpTypeHint() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typePrecedenceTest'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['type' => 'integer', 'description' => 'DocBlock says integer despite string type hint'], $schema['properties']['numericString']); + $this->assertEquals(['format' => 'email', 'minLength' => 5, 'type' => 'string', 'description' => 'String with Schema constraints'], $schema['properties']['stringWithConstraints']); + $this->assertEquals(['items' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], 'type' => 'array', 'description' => 'Array with Schema item overrides'], $schema['properties']['arrayWithItems']); + $this->assertEqualsCanonicalizing(['numericString', 'stringWithConstraints', 'arrayWithItems'], $schema['required']); + } + + public function testGeneratesEmptyPropertiesObjectForMethodWithNoParametersEvenWithMethodLevelSchema() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals('Gets server status. Takes no arguments.', $schema['description']); + $this->assertInstanceOf(\stdClass::class, $schema['properties']); + $this->assertArrayNotHasKey('required', $schema); + } + + public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven() + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterSchemaInferredType'); + $schema = $this->schemaGenerator->generate($method); + $this->assertEquals(['description' => 'Some parameter', 'minLength' => 3], $schema['properties']['inferredParam']); + $this->assertEquals(['inferredParam'], $schema['required']); + } +} diff --git a/tests/Capability/Discovery/SchemaValidatorTest.php b/tests/Capability/Discovery/SchemaValidatorTest.php new file mode 100644 index 00000000..b1c8a705 --- /dev/null +++ b/tests/Capability/Discovery/SchemaValidatorTest.php @@ -0,0 +1,505 @@ +validator = new SchemaValidator(); + } + + // --- Basic Validation Tests --- + + public function testValidDataPassesValidation() + { + $schema = $this->getSimpleSchema(); + $data = $this->getValidData(); + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + + $this->assertEmpty($errors); + } + + public function testInvalidTypeGeneratesTypeError() + { + $schema = $this->getSimpleSchema(); + $data = $this->getValidData(); + $data['age'] = 'thirty'; // Invalid type + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + + $this->assertCount(1, $errors); + $this->assertEquals('/age', $errors[0]['pointer']); + $this->assertEquals('type', $errors[0]['keyword']); + $this->assertStringContainsString('Expected `integer`', $errors[0]['message']); + } + + public function testMissingRequiredPropertyGeneratesRequiredError() + { + $schema = $this->getSimpleSchema(); + $data = $this->getValidData(); + unset($data['name']); // Missing required + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + $this->assertCount(1, $errors); + $this->assertEquals('required', $errors[0]['keyword']); + $this->assertStringContainsString('Missing required properties: `name`', $errors[0]['message']); + } + + public function testAdditionalPropertyGeneratesAdditionalPropertiesError() + { + $schema = $this->getSimpleSchema(); + $data = $this->getValidData(); + $data['extra'] = 'not allowed'; // Additional property + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + $this->assertCount(1, $errors); + $this->assertEquals('/', $errors[0]['pointer']); // Error reported at the object root + $this->assertEquals('additionalProperties', $errors[0]['keyword']); + $this->assertStringContainsString('Additional object properties are not allowed: ["extra"]', $errors[0]['message']); + } + + // --- Keyword Constraint Tests --- + + public function testEnumConstraintViolation() + { + $schema = ['type' => 'string', 'enum' => ['A', 'B']]; + $data = 'C'; + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + $this->assertCount(1, $errors); + $this->assertEquals('enum', $errors[0]['keyword']); + $this->assertStringContainsString('must be one of the allowed values: "A", "B"', $errors[0]['message']); + } + + public function testMinimumConstraintViolation() + { + $schema = ['type' => 'integer', 'minimum' => 10]; + $data = 5; + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + $this->assertCount(1, $errors); + $this->assertEquals('minimum', $errors[0]['keyword']); + $this->assertStringContainsString('must be greater than or equal to 10', $errors[0]['message']); + } + + public function testMaxLengthConstraintViolation() + { + $schema = ['type' => 'string', 'maxLength' => 5]; + $data = 'toolong'; + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + $this->assertCount(1, $errors); + $this->assertEquals('maxLength', $errors[0]['keyword']); + $this->assertStringContainsString('Maximum string length is 5, found 7', $errors[0]['message']); + } + + public function testPatternConstraintViolation() + { + $schema = ['type' => 'string', 'pattern' => '^[a-z]+$']; + $data = '123'; + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + $this->assertCount(1, $errors); + $this->assertEquals('pattern', $errors[0]['keyword']); + $this->assertStringContainsString('does not match the required pattern: `^[a-z]+$`', $errors[0]['message']); + } + + public function testMinItemsConstraintViolation() + { + $schema = ['type' => 'array', 'minItems' => 2]; + $data = ['one']; + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + $this->assertCount(1, $errors); + $this->assertEquals('minItems', $errors[0]['keyword']); + $this->assertStringContainsString('Array should have at least 2 items, 1 found', $errors[0]['message']); + } + + public function testUniqueItemsConstraintViolation() + { + $schema = ['type' => 'array', 'uniqueItems' => true]; + $data = ['a', 'b', 'a']; + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + $this->assertCount(1, $errors); + $this->assertEquals('uniqueItems', $errors[0]['keyword']); + $this->assertStringContainsString('Array must have unique items', $errors[0]['message']); + } + + // --- Nested Structures and Pointers --- + public function testNestedObjectValidationErrorPointer() + { + $schema = [ + 'type' => 'object', + 'properties' => [ + 'user' => [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer']], + 'required' => ['id'], + ], + ], + 'required' => ['user'], + ]; + $data = ['user' => ['id' => 'abc']]; // Invalid nested type + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + $this->assertCount(1, $errors); + $this->assertEquals('/user/id', $errors[0]['pointer']); + } + + public function testArrayItemValidationErrorPointer() + { + $schema = [ + 'type' => 'array', + 'items' => ['type' => 'integer'], + ]; + $data = [1, 2, 'three', 4]; // Invalid item type + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + $this->assertCount(1, $errors); + $this->assertEquals('/2', $errors[0]['pointer']); // Pointer to the index of the invalid item + } + + // --- Data Conversion Tests --- + public function testValidatesDataPassedAsStdClassObject() + { + $schema = $this->getSimpleSchema(); + $dataObj = json_decode(json_encode($this->getValidData())); // Convert to stdClass + + $errors = $this->validator->validateAgainstJsonSchema($dataObj, $schema); + $this->assertEmpty($errors); + } + + public function testValidatesDataWithNestedAssociativeArraysCorrectly() + { + $schema = [ + 'type' => 'object', + 'properties' => [ + 'nested' => [ + 'type' => 'object', + 'properties' => ['key' => ['type' => 'string']], + 'required' => ['key'], + ], + ], + 'required' => ['nested'], + ]; + $data = ['nested' => ['key' => 'value']]; // Nested assoc array + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + $this->assertEmpty($errors); + } + + // --- Edge Cases --- + public function testHandlesInvalidSchemaStructureGracefully() + { + $schema = ['type' => 'object', 'properties' => ['name' => ['type' => 123]]]; // Invalid type value + $data = ['name' => 'test']; + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + $this->assertCount(1, $errors); + $this->assertEquals('internal', $errors[0]['keyword']); + $this->assertStringContainsString('Schema validation process failed', $errors[0]['message']); + } + + public function testHandlesEmptyDataObjectAgainstSchemaRequiringProperties() + { + $schema = $this->getSimpleSchema(); // Requires name, age etc. + $data = []; // Empty data + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + + $this->assertNotEmpty($errors); + $this->assertEquals('required', $errors[0]['keyword']); + } + + public function testHandlesEmptySchemaAllowsAnything() + { + $schema = []; // Empty schema object/array implies no constraints + $data = ['anything' => [1, 2], 'goes' => true]; + + $errors = $this->validator->validateAgainstJsonSchema($data, $schema); + + $this->assertNotEmpty($errors); + $this->assertEquals('internal', $errors[0]['keyword']); + $this->assertStringContainsString('Invalid schema', $errors[0]['message']); + } + + public function testValidatesSchemaWithStringFormatConstraintsFromSchemaAttribute() + { + $emailSchema = (new Schema(format: 'email'))->toArray(); + + // Valid email + $validErrors = $this->validator->validateAgainstJsonSchema('user@example.com', $emailSchema); + $this->assertEmpty($validErrors); + + // Invalid email + $invalidErrors = $this->validator->validateAgainstJsonSchema('not-an-email', $emailSchema); + $this->assertNotEmpty($invalidErrors); + $this->assertEquals('format', $invalidErrors[0]['keyword']); + $this->assertStringContainsString('email', $invalidErrors[0]['message']); + } + + public function testValidatesSchemaWithStringLengthConstraintsFromSchemaAttribute() + { + $passwordSchema = (new Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$'))->toArray(); + + // Valid password (meets length and pattern) + $validErrors = $this->validator->validateAgainstJsonSchema('Password123', $passwordSchema); + $this->assertEmpty($validErrors); + + // Invalid - too short + $shortErrors = $this->validator->validateAgainstJsonSchema('Pass1', $passwordSchema); + $this->assertNotEmpty($shortErrors); + $this->assertEquals('minLength', $shortErrors[0]['keyword']); + + // Invalid - no digit + $noDigitErrors = $this->validator->validateAgainstJsonSchema('PasswordXYZ', $passwordSchema); + $this->assertNotEmpty($noDigitErrors); + $this->assertEquals('pattern', $noDigitErrors[0]['keyword']); + } + + public function testValidatesSchemaWithNumericConstraintsFromSchemaAttribute() + { + $ageSchema = (new Schema(minimum: 18, maximum: 120))->toArray(); + + // Valid age + $validErrors = $this->validator->validateAgainstJsonSchema(25, $ageSchema); + $this->assertEmpty($validErrors); + + // Invalid - too low + $tooLowErrors = $this->validator->validateAgainstJsonSchema(15, $ageSchema); + $this->assertNotEmpty($tooLowErrors); + $this->assertEquals('minimum', $tooLowErrors[0]['keyword']); + + // Invalid - too high + $tooHighErrors = $this->validator->validateAgainstJsonSchema(150, $ageSchema); + $this->assertNotEmpty($tooHighErrors); + $this->assertEquals('maximum', $tooHighErrors[0]['keyword']); + } + + public function testValidatesSchemaWithArrayConstraintsFromSchemaAttribute() + { + $tagsSchema = (new Schema(uniqueItems: true, minItems: 2))->toArray(); + + // Valid tags array + $validErrors = $this->validator->validateAgainstJsonSchema(['php', 'javascript', 'python'], $tagsSchema); + $this->assertEmpty($validErrors); + + // Invalid - duplicate items + $duplicateErrors = $this->validator->validateAgainstJsonSchema(['php', 'php', 'javascript'], $tagsSchema); + $this->assertNotEmpty($duplicateErrors); + $this->assertEquals('uniqueItems', $duplicateErrors[0]['keyword']); + + // Invalid - too few items + $tooFewErrors = $this->validator->validateAgainstJsonSchema(['php'], $tagsSchema); + $this->assertNotEmpty($tooFewErrors); + $this->assertEquals('minItems', $tooFewErrors[0]['keyword']); + } + + public function testValidatesSchemaWithObjectConstraintsFromSchemaAttribute() + { + $userSchema = (new Schema( + properties: [ + 'name' => ['type' => 'string', 'minLength' => 2], + 'email' => ['type' => 'string', 'format' => 'email'], + 'age' => ['type' => 'integer', 'minimum' => 18], + ], + required: ['name', 'email'] + ))->toArray(); + + // Valid user object + $validUser = [ + 'name' => 'John', + 'email' => 'john@example.com', + 'age' => 25, + ]; + $validErrors = $this->validator->validateAgainstJsonSchema($validUser, $userSchema); + $this->assertEmpty($validErrors); + + // Invalid - missing required email + $missingEmailUser = [ + 'name' => 'John', + 'age' => 25, + ]; + $missingErrors = $this->validator->validateAgainstJsonSchema($missingEmailUser, $userSchema); + $this->assertNotEmpty($missingErrors); + $this->assertEquals('required', $missingErrors[0]['keyword']); + + // Invalid - name too short + $shortNameUser = [ + 'name' => 'J', + 'email' => 'john@example.com', + 'age' => 25, + ]; + $nameErrors = $this->validator->validateAgainstJsonSchema($shortNameUser, $userSchema); + $this->assertNotEmpty($nameErrors); + $this->assertEquals('minLength', $nameErrors[0]['keyword']); + + // Invalid - age too low + $youngUser = [ + 'name' => 'John', + 'email' => 'john@example.com', + 'age' => 15, + ]; + $ageErrors = $this->validator->validateAgainstJsonSchema($youngUser, $userSchema); + $this->assertNotEmpty($ageErrors); + $this->assertEquals('minimum', $ageErrors[0]['keyword']); + } + + public function testValidatesSchemaWithNestedConstraintsFromSchemaAttribute() + { + $orderSchema = (new Schema( + properties: [ + 'customer' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], + 'name' => ['type' => 'string', 'minLength' => 2], + ], + ], + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], + 'quantity' => ['type' => 'integer', 'minimum' => 1], + ], + 'required' => ['product_id', 'quantity'], + ], + ], + ], + required: ['customer', 'items'] + ))->toArray(); + + // Valid order + $validOrder = [ + 'customer' => [ + 'id' => 'CUS-123456', + 'name' => 'John', + ], + 'items' => [ + [ + 'product_id' => 'PRD-1234', + 'quantity' => 2, + ], + ], + ]; + $validErrors = $this->validator->validateAgainstJsonSchema($validOrder, $orderSchema); + $this->assertEmpty($validErrors); + + // Invalid - bad customer ID format + $badCustomerIdOrder = [ + 'customer' => [ + 'id' => 'CUST-123', // Wrong format + 'name' => 'John', + ], + 'items' => [ + [ + 'product_id' => 'PRD-1234', + 'quantity' => 2, + ], + ], + ]; + $customerIdErrors = $this->validator->validateAgainstJsonSchema($badCustomerIdOrder, $orderSchema); + $this->assertNotEmpty($customerIdErrors); + $this->assertEquals('pattern', $customerIdErrors[0]['keyword']); + + // Invalid - empty items array + $emptyItemsOrder = [ + 'customer' => [ + 'id' => 'CUS-123456', + 'name' => 'John', + ], + 'items' => [], + ]; + $emptyItemsErrors = $this->validator->validateAgainstJsonSchema($emptyItemsOrder, $orderSchema); + $this->assertNotEmpty($emptyItemsErrors); + $this->assertEquals('minItems', $emptyItemsErrors[0]['keyword']); + + // Invalid - missing required property in items + $missingProductIdOrder = [ + 'customer' => [ + 'id' => 'CUS-123456', + 'name' => 'John', + ], + 'items' => [ + [ + // Missing product_id + 'quantity' => 2, + ], + ], + ]; + $missingProductIdErrors = $this->validator->validateAgainstJsonSchema($missingProductIdOrder, $orderSchema); + $this->assertNotEmpty($missingProductIdErrors); + $this->assertEquals('required', $missingProductIdErrors[0]['keyword']); + } + + /** + * @return array{ + * type: 'object', + * properties: array>, + * required: string[], + * additionalProperties: false, + * } + */ + private function getSimpleSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'The name'], + 'age' => ['type' => 'integer', 'minimum' => 0], + 'active' => ['type' => 'boolean'], + 'score' => ['type' => 'number'], + 'items' => ['type' => 'array', 'items' => ['type' => 'string']], + 'nullableValue' => ['type' => ['string', 'null']], + 'optionalValue' => ['type' => 'string'], + ], + 'required' => ['name', 'age', 'active', 'score', 'items', 'nullableValue'], + 'additionalProperties' => false, + ]; + } + + /** + * @return array{ + * name: string, + * age: int, + * active: bool, + * score: float, + * items: string[], + * nullableValue: null, + * optionalValue: string + * } + */ + private function getValidData(): array + { + return [ + 'name' => 'Tester', + 'age' => 30, + 'active' => true, + 'score' => 99.5, + 'items' => ['a', 'b'], + 'nullableValue' => null, + 'optionalValue' => 'present', + ]; + } +} diff --git a/tests/Capability/Prompt/Completion/EnumCompletionProviderTest.php b/tests/Capability/Prompt/Completion/EnumCompletionProviderTest.php new file mode 100644 index 00000000..7d568f3c --- /dev/null +++ b/tests/Capability/Prompt/Completion/EnumCompletionProviderTest.php @@ -0,0 +1,85 @@ +getCompletions(''); + $this->assertSame(['draft', 'published', 'archived'], $result); + } + + public function testCreatesProviderFromIntBackedEnumUsingNames() + { + $provider = new EnumCompletionProvider(PriorityEnum::class); + $result = $provider->getCompletions(''); + + $this->assertSame(['LOW', 'MEDIUM', 'HIGH'], $result); + } + + public function testCreatesProviderFromUnitEnumUsingNames() + { + $provider = new EnumCompletionProvider(UnitEnum::class); + $result = $provider->getCompletions(''); + + $this->assertSame(['Yes', 'No'], $result); + } + + public function testFiltersStringEnumValuesByPrefix() + { + $provider = new EnumCompletionProvider(StatusEnum::class); + $result = $provider->getCompletions('ar'); + + $this->assertEquals(['archived'], $result); + } + + public function testFiltersUnitEnumValuesByPrefix() + { + $provider = new EnumCompletionProvider(UnitEnum::class); + $result = $provider->getCompletions('Y'); + + $this->assertSame(['Yes'], $result); + } + + public function testReturnsEmptyArrayWhenNoValuesMatchPrefix() + { + $provider = new EnumCompletionProvider(StatusEnum::class); + $result = $provider->getCompletions('xyz'); + + $this->assertSame([], $result); + } + + public function testThrowsExceptionForNonEnumClass() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Class "stdClass" is not an enum.'); + + new EnumCompletionProvider(\stdClass::class); + } + + public function testThrowsExceptionForNonExistentClass() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Class "NonExistentClass" is not an enum.'); + + new EnumCompletionProvider('NonExistentClass'); /* @phpstan-ignore argument.type */ + } +} diff --git a/tests/Capability/Prompt/Completion/ListCompletionProviderTest.php b/tests/Capability/Prompt/Completion/ListCompletionProviderTest.php new file mode 100644 index 00000000..b5def78d --- /dev/null +++ b/tests/Capability/Prompt/Completion/ListCompletionProviderTest.php @@ -0,0 +1,80 @@ +getCompletions(''); + + $this->assertSame($values, $result); + } + + public function testFiltersValuesBasedOnCurrentValuePrefix() + { + $values = ['apple', 'apricot', 'banana', 'cherry']; + $provider = new ListCompletionProvider($values); + $result = $provider->getCompletions('ap'); + + $this->assertSame(['apple', 'apricot'], $result); + } + + public function testReturnsEmptyArrayWhenNoValuesMatch() + { + $values = ['apple', 'banana', 'cherry']; + $provider = new ListCompletionProvider($values); + $result = $provider->getCompletions('xyz'); + + $this->assertSame([], $result); + } + + public function testWorksWithSingleCharacterPrefix() + { + $values = ['apple', 'banana', 'cherry']; + $provider = new ListCompletionProvider($values); + $result = $provider->getCompletions('a'); + + $this->assertSame(['apple'], $result); + } + + public function testIsCaseSensitiveByDefault() + { + $values = ['Apple', 'apple', 'APPLE']; + $provider = new ListCompletionProvider($values); + $result = $provider->getCompletions('A'); + + $this->assertEquals(['Apple', 'APPLE'], $result); + } + + public function testHandlesEmptyValuesArray() + { + $provider = new ListCompletionProvider([]); + $result = $provider->getCompletions('test'); + + $this->assertSame([], $result); + } + + public function testPreservesArrayOrder() + { + $values = ['zebra', 'apple', 'banana']; + $provider = new ListCompletionProvider($values); + $result = $provider->getCompletions(''); + + $this->assertSame(['zebra', 'apple', 'banana'], $result); + } +} diff --git a/tests/Fixtures/Enum/BackedIntEnum.php b/tests/Fixtures/Enum/BackedIntEnum.php new file mode 100644 index 00000000..32fa8ca8 --- /dev/null +++ b/tests/Fixtures/Enum/BackedIntEnum.php @@ -0,0 +1,18 @@ +