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