Introduce Capability Registry & Discovery

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

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

View 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,
) {
}
}

View 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,
) {
}
}

View 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,
) {
}
}

View 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,
) {
}
}

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

View file

@ -0,0 +1,412 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Capability\Discovery;
use Mcp\Capability\Attribute\CompletionProvider;
use Mcp\Capability\Attribute\McpPrompt;
use Mcp\Capability\Attribute\McpResource;
use Mcp\Capability\Attribute\McpResourceTemplate;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Prompt\Completion\EnumCompletionProvider;
use Mcp\Capability\Prompt\Completion\ListCompletionProvider;
use Mcp\Capability\Prompt\Completion\ProviderInterface;
use Mcp\Capability\Registry;
use Mcp\Exception\ExceptionInterface;
use Mcp\Schema\Prompt;
use Mcp\Schema\PromptArgument;
use Mcp\Schema\Resource;
use Mcp\Schema\ResourceTemplate;
use Mcp\Schema\Tool;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
/**
* @phpstan-type DiscoveredCount array{
* tools: int,
* resources: int,
* prompts: int,
* resourceTemplates: int,
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class Discoverer
{
public function __construct(
private readonly Registry $registry,
private readonly LoggerInterface $logger = new NullLogger(),
private ?DocBlockParser $docBlockParser = null,
private ?SchemaGenerator $schemaGenerator = null,
) {
$this->docBlockParser = $docBlockParser ?? new DocBlockParser(logger: $this->logger);
$this->schemaGenerator = $schemaGenerator ?? new SchemaGenerator($this->docBlockParser);
}
/**
* Discover MCP elements in the specified directories.
*
* @param string $basePath the base path for resolving directories
* @param array<string> $directories list of directories (relative to base path) to scan
* @param array<string> $excludeDirs list of directories (relative to base path) to exclude from the scan
*/
public function discover(string $basePath, array $directories, array $excludeDirs = []): void
{
$startTime = microtime(true);
$discoveredCount = [
'tools' => 0,
'resources' => 0,
'prompts' => 0,
'resourceTemplates' => 0,
];
try {
$finder = new Finder();
$absolutePaths = [];
foreach ($directories as $dir) {
$path = rtrim($basePath, '/').'/'.ltrim($dir, '/');
if (is_dir($path)) {
$absolutePaths[] = $path;
}
}
if (empty($absolutePaths)) {
$this->logger->warning('No valid discovery directories found to scan.', [
'configured_paths' => $directories,
'base_path' => $basePath,
]);
return;
}
$finder->files()
->in($absolutePaths)
->exclude($excludeDirs)
->name('*.php');
foreach ($finder as $file) {
$this->processFile($file, $discoveredCount);
}
} catch (\Throwable $e) {
$this->logger->error('Error during file finding process for MCP discovery', [
'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
$duration = microtime(true) - $startTime;
$this->logger->info('Attribute discovery finished.', [
'duration_sec' => round($duration, 3),
'tools' => $discoveredCount['tools'],
'resources' => $discoveredCount['resources'],
'prompts' => $discoveredCount['prompts'],
'resourceTemplates' => $discoveredCount['resourceTemplates'],
]);
}
/**
* Process a single PHP file for MCP elements on classes or methods.
*
* @param DiscoveredCount $discoveredCount
*/
private function processFile(SplFileInfo $file, array &$discoveredCount): void
{
$filePath = $file->getRealPath();
if (false === $filePath) {
$this->logger->warning('Could not get real path for file', ['path' => $file->getPathname()]);
return;
}
$className = $this->getClassFromFile($filePath);
if (!$className) {
$this->logger->warning('No valid class found in file', ['file' => $filePath]);
return;
}
try {
$reflectionClass = new \ReflectionClass($className);
if ($reflectionClass->isAbstract() || $reflectionClass->isInterface() || $reflectionClass->isTrait() || $reflectionClass->isEnum()) {
return;
}
$processedViaClassAttribute = false;
if ($reflectionClass->hasMethod('__invoke')) {
$invokeMethod = $reflectionClass->getMethod('__invoke');
if ($invokeMethod->isPublic() && !$invokeMethod->isStatic()) {
$attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class];
foreach ($attributeTypes as $attributeType) {
$classAttribute = $reflectionClass->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null;
if ($classAttribute) {
$this->processMethod($invokeMethod, $discoveredCount, $classAttribute);
$processedViaClassAttribute = true;
break;
}
}
}
}
if (!$processedViaClassAttribute) {
foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
if (
$method->getDeclaringClass()->getName() !== $reflectionClass->getName()
|| $method->isStatic() || $method->isAbstract() || $method->isConstructor() || $method->isDestructor() || '__invoke' === $method->getName()
) {
continue;
}
$attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class];
foreach ($attributeTypes as $attributeType) {
$methodAttribute = $method->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null;
if ($methodAttribute) {
$this->processMethod($method, $discoveredCount, $methodAttribute);
break;
}
}
}
}
} catch (\ReflectionException $e) {
$this->logger->error('Reflection error processing file for MCP discovery', ['file' => $filePath, 'class' => $className, 'exception' => $e->getMessage()]);
} catch (\Throwable $e) {
$this->logger->error('Unexpected error processing file for MCP discovery', [
'file' => $filePath,
'class' => $className,
'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
/**
* Process a method with a given MCP attribute instance.
* Can be called for regular methods or the __invoke method of an invokable class.
*
* @param \ReflectionMethod $method The target method (e.g., regular method or __invoke).
* @param DiscoveredCount $discoveredCount pass by reference to update counts
* @param \ReflectionAttribute<McpTool|McpResource|McpPrompt|McpResourceTemplate> $attribute the ReflectionAttribute instance found (on method or class)
*/
private function processMethod(\ReflectionMethod $method, array &$discoveredCount, \ReflectionAttribute $attribute): void
{
$className = $method->getDeclaringClass()->getName();
$classShortName = $method->getDeclaringClass()->getShortName();
$methodName = $method->getName();
$attributeClassName = $attribute->getName();
try {
$instance = $attribute->newInstance();
switch ($attributeClassName) {
case McpTool::class:
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
$inputSchema = $this->schemaGenerator->generate($method);
$tool = new Tool($name, $inputSchema, $description, $instance->annotations);
$this->registry->registerTool($tool, [$className, $methodName]);
++$discoveredCount['tools'];
break;
case McpResource::class:
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
$mimeType = $instance->mimeType;
$size = $instance->size;
$annotations = $instance->annotations;
$resource = new Resource($instance->uri, $name, $description, $mimeType, $annotations, $size);
$this->registry->registerResource($resource, [$className, $methodName]);
++$discoveredCount['resources'];
break;
case McpPrompt::class:
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
$arguments = [];
$paramTags = $this->docBlockParser->getParamTags($docBlock);
foreach ($method->getParameters() as $param) {
$reflectionType = $param->getType();
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
continue;
}
$paramTag = $paramTags['$'.$param->getName()] ?? null;
$arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable());
}
$prompt = new Prompt($name, $description, $arguments);
$completionProviders = $this->getCompletionProviders($method);
$this->registry->registerPrompt($prompt, [$className, $methodName], $completionProviders);
++$discoveredCount['prompts'];
break;
case McpResourceTemplate::class:
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
$mimeType = $instance->mimeType;
$annotations = $instance->annotations;
$resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations);
$completionProviders = $this->getCompletionProviders($method);
$this->registry->registerResourceTemplate($resourceTemplate, [$className, $methodName], $completionProviders);
++$discoveredCount['resourceTemplates'];
break;
}
} catch (ExceptionInterface $e) {
$this->logger->error("Failed to process MCP attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e->getMessage(), 'trace' => $e->getPrevious() ? $e->getPrevious()->getTraceAsString() : $e->getTraceAsString()]);
} catch (\Throwable $e) {
$this->logger->error("Unexpected error processing attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
}
}
/**
* @return array<string, string|ProviderInterface>
*/
private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array
{
$completionProviders = [];
foreach ($reflectionMethod->getParameters() as $param) {
$reflectionType = $param->getType();
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
continue;
}
$completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF);
if (!empty($completionAttributes)) {
$attributeInstance = $completionAttributes[0]->newInstance();
if ($attributeInstance->provider) {
$completionProviders[$param->getName()] = $attributeInstance->provider;
} elseif ($attributeInstance->providerClass) {
$completionProviders[$param->getName()] = $attributeInstance->provider;
} elseif ($attributeInstance->values) {
$completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values);
} elseif ($attributeInstance->enum) {
$completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum);
}
}
}
return $completionProviders;
}
/**
* Attempt to determine the FQCN from a PHP file path.
* Uses tokenization to extract namespace and class name.
*
* @param string $filePath absolute path to the PHP file
*
* @return class-string|null the FQCN or null if not found/determinable
*/
private function getClassFromFile(string $filePath): ?string
{
if (!file_exists($filePath) || !is_readable($filePath)) {
$this->logger->warning('File does not exist or is not readable.', ['file' => $filePath]);
return null;
}
try {
$content = file_get_contents($filePath);
if (false === $content) {
$this->logger->warning('Failed to read file content.', ['file' => $filePath]);
return null;
}
if (\strlen($content) > 500 * 1024) {
$this->logger->debug('Skipping large file during class discovery.', ['file' => $filePath]);
return null;
}
$tokens = token_get_all($content);
} catch (\Throwable $e) {
$this->logger->warning("Failed to read or tokenize file during class discovery: {$filePath}", ['exception' => $e->getMessage()]);
return null;
}
$namespace = '';
$namespaceFound = false;
$level = 0;
$potentialClasses = [];
$tokenCount = \count($tokens);
for ($i = 0; $i < $tokenCount; ++$i) {
if (\is_array($tokens[$i]) && \T_NAMESPACE === $tokens[$i][0]) {
$namespace = '';
for ($j = $i + 1; $j < $tokenCount; ++$j) {
if (';' === $tokens[$j] || '{' === $tokens[$j]) {
$namespaceFound = true;
$i = $j;
break;
}
if (\is_array($tokens[$j]) && \in_array($tokens[$j][0], [\T_STRING, \T_NAME_QUALIFIED])) {
$namespace .= $tokens[$j][1];
} elseif (\T_NS_SEPARATOR === $tokens[$j][0]) {
$namespace .= '\\';
}
}
if ($namespaceFound) {
break;
}
}
}
$namespace = trim($namespace, '\\');
for ($i = 0; $i < $tokenCount; ++$i) {
$token = $tokens[$i];
if ('{' === $token) {
++$level;
continue;
}
if ('}' === $token) {
--$level;
continue;
}
if ($level === ($namespaceFound && str_contains($content, "namespace {$namespace} {") ? 1 : 0)) {
if (\is_array($token) && \in_array($token[0], [\T_CLASS, \T_INTERFACE, \T_TRAIT, \defined('T_ENUM') ? \T_ENUM : -1])) {
for ($j = $i + 1; $j < $tokenCount; ++$j) {
if (\is_array($tokens[$j]) && \T_STRING === $tokens[$j][0]) {
$className = $tokens[$j][1];
$potentialClasses[] = $namespace ? $namespace.'\\'.$className : $className;
$i = $j;
break;
}
if (';' === $tokens[$j] || '{' === $tokens[$j] || ')' === $tokens[$j]) {
break;
}
}
}
}
}
foreach ($potentialClasses as $potentialClass) {
if (class_exists($potentialClass, true)) {
return $potentialClass;
}
}
if (!empty($potentialClasses)) {
if (!class_exists($potentialClasses[0], false)) {
$this->logger->debug('getClassFromFile returning potential non-class type. Are you sure this class has been autoloaded?', ['file' => $filePath, 'type' => $potentialClasses[0]]);
}
return $potentialClasses[0];
}
return null;
}
}

View file

@ -0,0 +1,139 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Capability\Discovery;
use phpDocumentor\Reflection\DocBlock;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
use phpDocumentor\Reflection\DocBlockFactory;
use phpDocumentor\Reflection\DocBlockFactoryInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* Parses DocBlocks using phpdocumentor/reflection-docblock.
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class DocBlockParser
{
private DocBlockFactoryInterface $docBlockFactory;
public function __construct(
?DocBlockFactoryInterface $docBlockFactory = null,
private readonly LoggerInterface $logger = new NullLogger(),
) {
$this->docBlockFactory = $docBlockFactory ?? DocBlockFactory::createInstance();
}
/**
* Safely parses a DocComment string into a DocBlock object.
*/
public function parseDocBlock(string|false|null $docComment): ?DocBlock
{
if (false === $docComment || null === $docComment || empty($docComment)) {
return null;
}
try {
return $this->docBlockFactory->create($docComment);
} catch (\Throwable $e) {
// Log error or handle gracefully if invalid DocBlock syntax is encountered
$this->logger->warning('Failed to parse DocBlock', [
'error' => $e->getMessage(),
'exception_trace' => $e->getTraceAsString(),
]);
return null;
}
}
/**
* Gets the summary line from a DocBlock.
*/
public function getSummary(?DocBlock $docBlock): ?string
{
if (!$docBlock) {
return null;
}
$summary = trim($docBlock->getSummary());
return $summary ?: null; // Return null if empty after trimming
}
/**
* Gets the description from a DocBlock (summary + description body).
*/
public function getDescription(?DocBlock $docBlock): ?string
{
if (!$docBlock) {
return null;
}
$summary = trim($docBlock->getSummary());
$descriptionBody = trim((string) $docBlock->getDescription());
if ($summary && $descriptionBody) {
return $summary."\n\n".$descriptionBody;
}
if ($summary) {
return $summary;
}
if ($descriptionBody) {
return $descriptionBody;
}
return null;
}
/**
* Extracts "@param" tag information from a DocBlock, keyed by variable name (e.g., '$paramName').
*
* @return array<string, Param>
*/
public function getParamTags(?DocBlock $docBlock): array
{
if (!$docBlock) {
return [];
}
/** @var array<string, Param> $paramTags */
$paramTags = [];
foreach ($docBlock->getTagsByName('param') as $tag) {
if ($tag instanceof Param && $tag->getVariableName()) {
$paramTags['$'.$tag->getVariableName()] = $tag;
}
}
return $paramTags;
}
/**
* Gets the description string from a Param tag.
*/
public function getParamDescription(?Param $paramTag): ?string
{
return $paramTag ? (trim((string) $paramTag->getDescription()) ?: null) : null;
}
/**
* Gets the type string from a Param tag.
*/
public function getParamTypeString(?Param $paramTag): ?string
{
if ($paramTag && $paramTag->getType()) {
$typeFromTag = trim((string) $paramTag->getType());
if (!empty($typeFromTag)) {
return ltrim($typeFromTag, '\\');
}
}
return null;
}
}

View file

@ -0,0 +1,85 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Capability\Discovery;
use Mcp\Exception\InvalidArgumentException;
/**
* Utility class to validate and resolve MCP element handlers.
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class HandlerResolver
{
/**
* Validates and resolves a handler to a ReflectionMethod or ReflectionFunction instance.
*
* A handler can be:
* - A Closure: function() { ... }
* - An array: [ClassName::class, 'methodName'] (instance method)
* - An array: [ClassName::class, 'staticMethod'] (static method, if callable)
* - A string: InvokableClassName::class (which will resolve to its '__invoke' method)
*
* @param \Closure|array{0: string, 1: string}|string $handler the handler to resolve
*
* @throws InvalidArgumentException If the handler format is invalid, the class/method doesn't exist,
* or the method is unsuitable (e.g., private, abstract).
*/
public static function resolve(\Closure|array|string $handler): \ReflectionMethod|\ReflectionFunction
{
if ($handler instanceof \Closure) {
return new \ReflectionFunction($handler);
}
if (\is_array($handler)) {
if (2 !== \count($handler) || !isset($handler[0]) || !isset($handler[1]) || !\is_string($handler[0]) || !\is_string($handler[1])) {
throw new InvalidArgumentException('Invalid array handler format. Expected [ClassName::class, \'methodName\'].');
}
[$className, $methodName] = $handler;
if (!class_exists($className)) {
throw new InvalidArgumentException(\sprintf('Handler class "%s" not found for array handler.', $className));
}
if (!method_exists($className, $methodName)) {
throw new InvalidArgumentException(\sprintf('Handler method "%s" not found in class "%s" for array handler.', $methodName, $className));
}
} elseif (class_exists($handler)) {
$className = $handler;
$methodName = '__invoke';
if (!method_exists($className, $methodName)) {
throw new InvalidArgumentException(\sprintf('Invokable handler class "%s" must have a public "__invoke" method.', $className));
}
} else {
throw new InvalidArgumentException('Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.');
}
try {
$reflectionMethod = new \ReflectionMethod($className, $methodName);
// For discovered elements (non-manual), still reject static methods
// For manual elements, we'll allow static methods since they're callable
if (!$reflectionMethod->isPublic()) {
throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" must be public.', $className, $methodName));
}
if ($reflectionMethod->isAbstract()) {
throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" must be abstract.', $className, $methodName));
}
if ($reflectionMethod->isConstructor() || $reflectionMethod->isDestructor()) {
throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" cannot be a constructor or destructor.', $className, $methodName));
}
return $reflectionMethod;
} catch (\ReflectionException $e) {
// This typically occurs if class_exists passed but ReflectionMethod still fails (rare)
throw new InvalidArgumentException(\sprintf('Reflection error for handler "%s::%s": %s', $className, $methodName, $e->getMessage()), 0, $e);
}
}
}

View file

@ -0,0 +1,787 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Capability\Discovery;
use Mcp\Capability\Attribute\Schema;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
/**
* Generates JSON Schema for method parameters with intelligent Schema attribute handling.
*
* Priority system:
* 1. Schema attributes (method-level and parameter-level)
* 2. Reflection type information
* 3. DocBlock type information
*
* @phpstan-import-type SchemaAttributeData from Schema
*
* @phpstan-type ParameterInfo array{
* name: string,
* doc_block_tag: Param|null,
* reflection_param: \ReflectionParameter,
* reflection_type_object: \ReflectionType|null,
* type_string: string,
* description: string|null,
* required: bool,
* allows_null: bool,
* default_value: mixed|null,
* has_default: bool,
* is_variadic: bool,
* parameter_schema: array<string, mixed>
* }
* @phpstan-type InferredParameterSchema array{
* type?: string|array,
* description?: string,
* default?: mixed,
* enum?: array<string|int>,
* items?: array<string, mixed>,
* }
* @phpstan-type VariadicParameterSchema array{
* type: 'array',
* items?: array<string, mixed>,
* description?: string,
* parameter_schema?: array<string, mixed>
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class SchemaGenerator
{
public function __construct(
private readonly DocBlockParser $docBlockParser,
) {
}
/**
* Generates a JSON Schema object (as a PHP array) for a method's or function's parameters.
*
* @return array<string, mixed>
*/
public function generate(\ReflectionMethod|\ReflectionFunction $reflection): array
{
$methodSchema = $this->extractMethodLevelSchema($reflection);
if ($methodSchema && isset($methodSchema['definition'])) {
return $methodSchema['definition'];
}
$parametersInfo = $this->parseParametersInfo($reflection);
return $this->buildSchemaFromParameters($parametersInfo, $methodSchema);
}
/**
* Extracts method-level or function-level Schema attribute.
*
* @return SchemaAttributeData
*/
private function extractMethodLevelSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array
{
$schemaAttrs = $reflection->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF);
if (empty($schemaAttrs)) {
return null;
}
/** @var Schema $schemaAttr */
$schemaAttr = $schemaAttrs[0]->newInstance();
return $schemaAttr->toArray();
}
/**
* Extracts parameter-level Schema attribute.
*
* @return SchemaAttributeData
*/
private function extractParameterLevelSchema(\ReflectionParameter $parameter): array
{
$schemaAttrs = $parameter->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF);
if (empty($schemaAttrs)) {
return [];
}
/** @var Schema $schemaAttr */
$schemaAttr = $schemaAttrs[0]->newInstance();
return $schemaAttr->toArray();
}
/**
* Builds the final schema from parameter information and method-level schema.
*
* @param ParameterInfo[] $parametersInfo
* @param SchemaAttributeData $methodSchema
*
* @return array<string, mixed>
*/
private function buildSchemaFromParameters(array $parametersInfo, ?array $methodSchema): array
{
$schema = [
'type' => 'object',
'properties' => [],
'required' => [],
];
// Apply method-level schema as base
if ($methodSchema) {
$schema = array_merge($schema, $methodSchema);
if (!isset($schema['type'])) {
$schema['type'] = 'object';
}
if (!isset($schema['properties'])) {
$schema['properties'] = [];
}
if (!isset($schema['required'])) {
$schema['required'] = [];
}
}
foreach ($parametersInfo as $paramInfo) {
$paramName = $paramInfo['name'];
$methodLevelParamSchema = $schema['properties'][$paramName] ?? null;
$paramSchema = $this->buildParameterSchema($paramInfo, $methodLevelParamSchema);
$schema['properties'][$paramName] = $paramSchema;
if ($paramInfo['required'] && !\in_array($paramName, $schema['required'])) {
$schema['required'][] = $paramName;
} elseif (!$paramInfo['required'] && ($key = array_search($paramName, $schema['required'])) !== false) {
unset($schema['required'][$key]);
$schema['required'] = array_values($schema['required']); // Re-index
}
}
// Clean up empty properties
if (empty($schema['properties'])) {
$schema['properties'] = new \stdClass();
}
if (empty($schema['required'])) {
unset($schema['required']);
}
return $schema;
}
/**
* Builds the final schema for a single parameter by merging all three levels.
*
* @param ParameterInfo $paramInfo
* @param array<string, mixed>|null $methodLevelParamSchema
*/
private function buildParameterSchema(array $paramInfo, ?array $methodLevelParamSchema): array
{
if ($paramInfo['is_variadic']) {
return $this->buildVariadicParameterSchema($paramInfo);
}
$inferredSchema = $this->buildInferredParameterSchema($paramInfo);
// Method-level takes precedence over inferred schema
$mergedSchema = $inferredSchema;
if ($methodLevelParamSchema) {
$mergedSchema = array_merge($inferredSchema, $methodLevelParamSchema);
}
// Parameter-level takes highest precedence
$parameterLevelSchema = $paramInfo['parameter_schema'];
if (!empty($parameterLevelSchema)) {
$mergedSchema = array_merge($mergedSchema, $parameterLevelSchema);
}
return $mergedSchema;
}
/**
* Builds parameter schema from inferred type and docblock information only.
* Returns empty array for variadic parameters (handled separately).
*
* @param ParameterInfo $paramInfo
*
* @return InferredParameterSchema
*/
private function buildInferredParameterSchema(array $paramInfo): array
{
$paramSchema = [];
// Variadic parameters are handled separately
if ($paramInfo['is_variadic']) {
return [];
}
// Infer JSON Schema types
$jsonTypes = $this->inferParameterTypes($paramInfo);
if (1 === \count($jsonTypes)) {
$paramSchema['type'] = $jsonTypes[0];
} elseif (\count($jsonTypes) > 1) {
$paramSchema['type'] = $jsonTypes;
}
// Add description from docblock
if ($paramInfo['description']) {
$paramSchema['description'] = $paramInfo['description'];
}
// Add default value only if parameter actually has a default
if ($paramInfo['has_default']) {
$paramSchema['default'] = $paramInfo['default_value'];
}
// Handle enums
$paramSchema = $this->applyEnumConstraints($paramSchema, $paramInfo);
// Handle array items
return $this->applyArrayConstraints($paramSchema, $paramInfo);
}
/**
* Builds schema for variadic parameters.
*
* @param ParameterInfo $paramInfo
*
* @return VariadicParameterSchema
*/
private function buildVariadicParameterSchema(array $paramInfo): array
{
$paramSchema = ['type' => 'array'];
// Apply parameter-level Schema attributes first
if (!empty($paramInfo['parameter_schema'])) {
$paramSchema = array_merge($paramSchema, $paramInfo['parameter_schema']);
// Ensure type is always array for variadic
$paramSchema['type'] = 'array';
}
if ($paramInfo['description']) {
$paramSchema['description'] = $paramInfo['description'];
}
// If no items specified by Schema attribute, infer from type
if (!isset($paramSchema['items'])) {
$itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']);
$nonNullItemTypes = array_filter($itemJsonTypes, fn ($t) => 'null' !== $t);
if (1 === \count($nonNullItemTypes)) {
$paramSchema['items'] = ['type' => $nonNullItemTypes[0]];
}
}
return $paramSchema;
}
/**
* Infers JSON Schema types for a parameter.
*
* @param ParameterInfo $paramInfo
*/
private function inferParameterTypes(array $paramInfo): array
{
$jsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']);
if ($paramInfo['allows_null'] && 'mixed' !== strtolower($paramInfo['type_string']) && !\in_array('null', $jsonTypes)) {
$jsonTypes[] = 'null';
}
if (\count($jsonTypes) > 1) {
// Sort but ensure null comes first for consistency
$nullIndex = array_search('null', $jsonTypes);
if (false !== $nullIndex) {
unset($jsonTypes[$nullIndex]);
sort($jsonTypes);
array_unshift($jsonTypes, 'null');
} else {
sort($jsonTypes);
}
}
return $jsonTypes;
}
/**
* Applies enum constraints to parameter schema.
*/
private function applyEnumConstraints(array $paramSchema, array $paramInfo): array
{
$reflectionType = $paramInfo['reflection_type_object'];
if (!($reflectionType instanceof \ReflectionNamedType) || $reflectionType->isBuiltin() || !enum_exists($reflectionType->getName())) {
return $paramSchema;
}
$enumClass = $reflectionType->getName();
$enumReflection = new \ReflectionEnum($enumClass);
$backingTypeReflection = $enumReflection->getBackingType();
if ($enumReflection->isBacked() && $backingTypeReflection instanceof \ReflectionNamedType) {
$paramSchema['enum'] = array_column($enumClass::cases(), 'value');
$jsonBackingType = match ($backingTypeReflection->getName()) {
'int' => 'integer',
'string' => 'string',
default => null,
};
if ($jsonBackingType) {
if (isset($paramSchema['type']) && \is_array($paramSchema['type']) && \in_array('null', $paramSchema['type'])) {
$paramSchema['type'] = ['null', $jsonBackingType];
} else {
$paramSchema['type'] = $jsonBackingType;
}
}
} else {
// Non-backed enum - use names as enum values
$paramSchema['enum'] = array_column($enumClass::cases(), 'name');
if (isset($paramSchema['type']) && \is_array($paramSchema['type']) && \in_array('null', $paramSchema['type'])) {
$paramSchema['type'] = ['null', 'string'];
} else {
$paramSchema['type'] = 'string';
}
}
return $paramSchema;
}
/**
* Applies array-specific constraints to parameter schema.
*/
private function applyArrayConstraints(array $paramSchema, array $paramInfo): array
{
if (!isset($paramSchema['type'])) {
return $paramSchema;
}
$typeString = $paramInfo['type_string'];
$allowsNull = $paramInfo['allows_null'];
// Handle object-like arrays using array{} syntax
if (preg_match('/^array\s*{/i', $typeString)) {
$objectSchema = $this->inferArrayItemsType($typeString);
if (\is_array($objectSchema) && isset($objectSchema['properties'])) {
$paramSchema = array_merge($paramSchema, $objectSchema);
$paramSchema['type'] = $allowsNull ? ['object', 'null'] : 'object';
}
}
// Handle regular arrays
elseif (\in_array('array', $this->mapPhpTypeToJsonSchemaType($typeString))) {
$itemsType = $this->inferArrayItemsType($typeString);
if ('any' !== $itemsType) {
if (\is_string($itemsType)) {
$paramSchema['items'] = ['type' => $itemsType];
} else {
if (!isset($itemsType['type']) && isset($itemsType['properties'])) {
$itemsType = array_merge(['type' => 'object'], $itemsType);
}
$paramSchema['items'] = $itemsType;
}
}
if ($allowsNull) {
$paramSchema['type'] = ['array', 'null'];
sort($paramSchema['type']);
} else {
$paramSchema['type'] = 'array';
}
}
return $paramSchema;
}
/**
* Parses detailed information about a method's parameters.
*
* @return ParameterInfo[]
*/
private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $reflection): array
{
$docComment = $reflection->getDocComment() ?: null;
$docBlock = $this->docBlockParser->parseDocBlock($docComment);
$paramTags = $this->docBlockParser->getParamTags($docBlock);
$parametersInfo = [];
foreach ($reflection->getParameters() as $rp) {
$paramName = $rp->getName();
$paramTag = $paramTags['$'.$paramName] ?? null;
$reflectionType = $rp->getType();
$typeString = $this->getParameterTypeString($rp, $paramTag);
$description = $this->docBlockParser->getParamDescription($paramTag);
$hasDefault = $rp->isDefaultValueAvailable();
$defaultValue = $hasDefault ? $rp->getDefaultValue() : null;
$isVariadic = $rp->isVariadic();
$parameterSchema = $this->extractParameterLevelSchema($rp);
if ($defaultValue instanceof \BackedEnum) {
$defaultValue = $defaultValue->value;
}
if ($defaultValue instanceof \UnitEnum) {
$defaultValue = $defaultValue->name;
}
$allowsNull = false;
if ($reflectionType && $reflectionType->allowsNull()) {
$allowsNull = true;
} elseif ($hasDefault && null === $defaultValue) {
$allowsNull = true;
} elseif (str_contains($typeString, 'null') || 'mixed' === strtolower($typeString)) {
$allowsNull = true;
}
$parametersInfo[] = [
'name' => $paramName,
'doc_block_tag' => $paramTag,
'reflection_param' => $rp,
'reflection_type_object' => $reflectionType,
'type_string' => $typeString,
'description' => $description,
'required' => !$rp->isOptional(),
'allows_null' => $allowsNull,
'default_value' => $defaultValue,
'has_default' => $hasDefault,
'is_variadic' => $isVariadic,
'parameter_schema' => $parameterSchema,
];
}
return $parametersInfo;
}
/**
* Determines the type string for a parameter, prioritizing DocBlock.
*/
private function getParameterTypeString(\ReflectionParameter $rp, ?Param $paramTag): string
{
$docBlockType = $this->docBlockParser->getParamTypeString($paramTag);
$isDocBlockTypeGeneric = false;
if (null !== $docBlockType) {
if (\in_array(strtolower($docBlockType), ['mixed', 'unknown', ''])) {
$isDocBlockTypeGeneric = true;
}
} else {
$isDocBlockTypeGeneric = true; // No tag or no type in tag implies generic
}
$reflectionType = $rp->getType();
$reflectionTypeString = null;
if ($reflectionType) {
$reflectionTypeString = $this->getTypeStringFromReflection($reflectionType, $rp->allowsNull());
}
// Prioritize Reflection if DocBlock type is generic AND Reflection provides a more specific type
if ($isDocBlockTypeGeneric && null !== $reflectionTypeString && 'mixed' !== $reflectionTypeString) {
return $reflectionTypeString;
}
// Otherwise, use the DocBlock type if it was valid and non-generic
if (null !== $docBlockType && !$isDocBlockTypeGeneric) {
// Consider if DocBlock adds nullability missing from reflection
if (false !== stripos($docBlockType, 'null') && $reflectionTypeString && false === stripos($reflectionTypeString, 'null') && !str_ends_with($reflectionTypeString, '|null')) {
// If reflection didn't capture null, but docblock did, append |null (if not already mixed)
if ('mixed' !== $reflectionTypeString) {
return $reflectionTypeString.'|null';
}
}
return $docBlockType;
}
// Fallback to Reflection type even if it was generic ('mixed')
if (null !== $reflectionTypeString) {
return $reflectionTypeString;
}
// Default to 'mixed' if nothing else found
return 'mixed';
}
/**
* Converts a ReflectionType object into a type string representation.
*/
private function getTypeStringFromReflection(?\ReflectionType $type, bool $nativeAllowsNull): string
{
if (null === $type) {
return 'mixed';
}
$types = [];
if ($type instanceof \ReflectionUnionType) {
foreach ($type->getTypes() as $innerType) {
$types[] = $this->getTypeStringFromReflection($innerType, $innerType->allowsNull());
}
if ($nativeAllowsNull) {
$types = array_filter($types, fn ($t) => 'null' !== strtolower($t));
}
$typeString = implode('|', array_unique(array_filter($types)));
} elseif ($type instanceof \ReflectionIntersectionType) {
foreach ($type->getTypes() as $innerType) {
$types[] = $this->getTypeStringFromReflection($innerType, false);
}
$typeString = implode('&', array_unique(array_filter($types)));
} elseif ($type instanceof \ReflectionNamedType) {
$typeString = $type->getName();
} else {
return 'mixed';
}
$typeString = match (strtolower($typeString)) {
'bool' => 'boolean',
'int' => 'integer',
'float', 'double' => 'number',
'str' => 'string',
default => $typeString,
};
$isNullable = $nativeAllowsNull;
if ($type instanceof \ReflectionNamedType && 'mixed' === $type->getName()) {
$isNullable = true;
}
if ($type instanceof \ReflectionUnionType && !$nativeAllowsNull) {
foreach ($type->getTypes() as $innerType) {
if ($innerType instanceof \ReflectionNamedType && 'null' === strtolower($innerType->getName())) {
$isNullable = true;
break;
}
}
}
if ($isNullable && 'mixed' !== $typeString && false === stripos($typeString, 'null')) {
if (!str_ends_with($typeString, '|null') && !str_ends_with($typeString, '&null')) {
$typeString .= '|null';
}
}
// Remove leading backslash from class names, but handle built-ins like 'int' or unions like 'int|string'
if (str_contains($typeString, '\\')) {
$parts = preg_split('/([|&])/', $typeString, -1, \PREG_SPLIT_DELIM_CAPTURE);
$processedParts = array_map(fn ($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts);
$typeString = implode('', $processedParts);
}
return $typeString ?: 'mixed';
}
/**
* Maps a PHP type string (potentially a union) to an array of JSON Schema type names.
*
* @return string[]
*/
private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array
{
$normalizedType = strtolower(trim($phpTypeString));
// PRIORITY 1: Check for array{} syntax which should be treated as object
if (preg_match('/^array\s*{/i', $normalizedType)) {
return ['object'];
}
// PRIORITY 2: Check for array syntax first (T[] or generics)
if (
str_contains($normalizedType, '[]')
|| preg_match('/^(array|list|iterable|collection)</i', $normalizedType)
) {
return ['array'];
}
// PRIORITY 3: Handle unions (recursive)
if (str_contains($normalizedType, '|')) {
$types = explode('|', $normalizedType);
$jsonTypes = [];
foreach ($types as $type) {
$mapped = $this->mapPhpTypeToJsonSchemaType(trim($type));
$jsonTypes = array_merge($jsonTypes, $mapped);
}
return array_values(array_unique($jsonTypes));
}
// PRIORITY 4: Handle simple built-in types
return match ($normalizedType) {
'string', 'scalar' => ['string'],
'?string' => ['null', 'string'],
'int', 'integer' => ['integer'],
'?int', '?integer' => ['null', 'integer'],
'float', 'double', 'number' => ['number'],
'?float', '?double', '?number' => ['null', 'number'],
'bool', 'boolean' => ['boolean'],
'?bool', '?boolean' => ['null', 'boolean'],
'array' => ['array'],
'?array' => ['null', 'array'],
'object', 'stdclass' => ['object'],
'?object', '?stdclass' => ['null', 'object'],
'null' => ['null'],
'resource', 'callable' => ['object'],
'mixed' => [],
'void', 'never' => [],
default => ['object'],
};
}
/**
* Infers the 'items' schema type for an array based on DocBlock type hints.
*
* @return string|array<string, mixed>
*/
private function inferArrayItemsType(string $phpTypeString): string|array
{
$normalizedType = trim($phpTypeString);
// Case 1: Simple T[] syntax (e.g., string[], int[], bool[], etc.)
if (preg_match('/^(\\??)([\w\\\\]+)\\s*\\[\\]$/i', $normalizedType, $matches)) {
$itemType = strtolower($matches[2]);
return $this->mapSimpleTypeToJsonSchema($itemType);
}
// Case 2: Generic array<T> syntax (e.g., array<string>, array<int>, etc.)
if (preg_match('/^(\\??)array\s*<\s*([\w\\\\|]+)\s*>$/i', $normalizedType, $matches)) {
$itemType = strtolower($matches[2]);
return $this->mapSimpleTypeToJsonSchema($itemType);
}
// Case 3: Nested array<array<T>> syntax or T[][] syntax
if (
preg_match('/^(\\??)array\s*<\s*array\s*<\s*([\w\\\\|]+)\s*>\s*>$/i', $normalizedType, $matches)
|| preg_match('/^(\\??)([\w\\\\]+)\s*\[\]\[\]$/i', $normalizedType, $matches)
) {
$innerType = $this->mapSimpleTypeToJsonSchema(isset($matches[2]) ? strtolower($matches[2]) : 'any'); /* @phpstan-ignore isset.offset */
// Return a schema for array with items being arrays
return [
'type' => 'array',
'items' => [
'type' => $innerType,
],
];
}
// Case 4: Object-like array syntax (e.g., array{name: string, age: int})
if (preg_match('/^(\\??)array\s*\{(.+)\}$/is', $normalizedType, $matches)) {
return $this->parseObjectLikeArray($matches[2]);
}
return 'any';
}
/**
* Parses object-like array syntax into a JSON Schema object.
*
* @return array{
* type: 'object',
* properties?: array<string, mixed>,
* required?: array<string>
* }
*/
private function parseObjectLikeArray(string $propertiesStr): array
{
$properties = [];
$required = [];
// Parse properties from the string, handling nested structures
$depth = 0;
$buffer = '';
for ($i = 0; $i < \strlen($propertiesStr); ++$i) {
$char = $propertiesStr[$i];
// Track nested braces
if ('{' === $char) {
++$depth;
$buffer .= $char;
} elseif ('}' === $char) {
--$depth;
$buffer .= $char;
}
// Property separator (comma)
elseif (',' === $char && 0 === $depth) {
// Process the completed property
$this->parsePropertyDefinition(trim($buffer), $properties, $required);
$buffer = '';
} else {
$buffer .= $char;
}
}
// Process the last property
if (!empty($buffer)) {
$this->parsePropertyDefinition(trim($buffer), $properties, $required);
}
if (!empty($properties)) {
return [
'type' => 'object',
'properties' => $properties,
'required' => $required,
];
}
return ['type' => 'object'];
}
/**
* Parses a single property definition from an object-like array syntax.
*/
private function parsePropertyDefinition(string $propDefinition, array &$properties, array &$required): void
{
// Match property name and type
if (preg_match('/^([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s*:\s*(.+)$/i', $propDefinition, $matches)) {
$propName = $matches[1];
$propType = trim($matches[2]);
// Add to required properties
$required[] = $propName;
// Check for nested array{} syntax
if (preg_match('/^array\s*\{(.+)\}$/is', $propType, $nestedMatches)) {
$nestedSchema = $this->parseObjectLikeArray($nestedMatches[1]);
$properties[$propName] = $nestedSchema;
}
// Check for array<T> or T[] syntax
elseif (
preg_match('/^array\s*<\s*([\w\\\\|]+)\s*>$/i', $propType, $arrayMatches)
|| preg_match('/^([\w\\\\]+)\s*\[\]$/i', $propType, $arrayMatches)
) {
$itemType = $arrayMatches[1] ?? 'any'; /* @phpstan-ignore nullCoalesce.offset */
$properties[$propName] = [
'type' => 'array',
'items' => [
'type' => $this->mapSimpleTypeToJsonSchema($itemType),
],
];
}
// Simple type
else {
$properties[$propName] = ['type' => $this->mapSimpleTypeToJsonSchema($propType)];
}
}
}
/**
* Helper method to map basic PHP types to JSON Schema types.
*/
private function mapSimpleTypeToJsonSchema(string $type): string
{
return match (strtolower($type)) {
'string' => 'string',
'int', 'integer' => 'integer',
'bool', 'boolean' => 'boolean',
'float', 'double', 'number' => 'number',
'array' => 'array',
'object', 'stdclass' => 'object',
default => \in_array(strtolower($type), ['datetime', 'datetimeinterface']) ? 'string' : 'object',
};
}
}

View file

@ -0,0 +1,336 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Capability\Discovery;
use Mcp\Exception\InvalidArgumentException;
use Opis\JsonSchema\Errors\ValidationError;
use Opis\JsonSchema\Validator;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* Validates data against JSON Schema definitions using opis/json-schema.
*
* @phpstan-type Error array{
* pointer: string,
* keyword: string,
* message: string,
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class SchemaValidator
{
private ?Validator $jsonSchemaValidator = null;
public function __construct(
private LoggerInterface $logger = new NullLogger(),
) {
}
/**
* Validates data against a JSON schema.
*
* @param mixed $data the data to validate (should generally be decoded JSON)
* @param array<string, mixed>|object $schema the JSON Schema definition (as PHP array or object)
*
* @return list<array{pointer: string, keyword: string, message: string}> array of validation errors, empty if valid
*/
public function validateAgainstJsonSchema(mixed $data, array|object $schema): array
{
if (\is_array($data) && empty($data)) {
$data = new \stdClass();
}
try {
// --- Schema Preparation ---
if (\is_array($schema)) {
$schemaJson = json_encode($schema, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
$schemaObject = json_decode($schemaJson, false, 512, \JSON_THROW_ON_ERROR);
} elseif (\is_object($schema)) {
// This might be overly cautious but safer against varied inputs.
$schemaJson = json_encode($schema, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
$schemaObject = json_decode($schemaJson, false, 512, \JSON_THROW_ON_ERROR);
} else {
throw new InvalidArgumentException('Schema must be an array or object.');
}
// --- Data Preparation ---
// Opis Validator generally prefers objects for object validation
$dataToValidate = $this->convertDataForValidator($data);
} catch (\JsonException $e) {
$this->logger->error('MCP SDK: Invalid schema structure provided for validation (JSON conversion failed).', ['exception' => $e]);
return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Invalid schema definition provided (JSON error).']];
} catch (InvalidArgumentException $e) {
$this->logger->error('MCP SDK: Invalid schema structure provided for validation.', ['exception' => $e]);
return [['pointer' => '', 'keyword' => 'internal', 'message' => $e->getMessage()]];
} catch (\Throwable $e) {
$this->logger->error('MCP SDK: Error preparing data/schema for validation.', ['exception' => $e]);
return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Internal validation preparation error.']];
}
$validator = $this->getJsonSchemaValidator();
try {
$result = $validator->validate($dataToValidate, $schemaObject);
} catch (\Throwable $e) {
$this->logger->error('MCP SDK: JSON Schema validation failed internally.', [
'exception_message' => $e->getMessage(),
'exception_trace' => $e->getTraceAsString(),
'data' => json_encode($dataToValidate),
'schema' => json_encode($schemaObject),
]);
return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: '.$e->getMessage()]];
}
if ($result->isValid()) {
return [];
}
$formattedErrors = [];
$topError = $result->error();
if ($topError) {
$this->collectSubErrors($topError, $formattedErrors);
}
if (empty($formattedErrors) && $topError) { // Fallback
$formattedErrors[] = [
'pointer' => $this->formatJsonPointerPath($topError->data()->path()),
'keyword' => $topError->keyword(),
'message' => $this->formatValidationError($topError),
];
}
return $formattedErrors;
}
/**
* Get or create the JSON Schema validator instance.
*/
private function getJsonSchemaValidator(): Validator
{
if (null === $this->jsonSchemaValidator) {
$this->jsonSchemaValidator = new Validator();
// Potentially configure resolver here if needed later
}
return $this->jsonSchemaValidator;
}
/**
* Recursively converts associative arrays to stdClass objects for validator compatibility.
*/
private function convertDataForValidator(mixed $data): mixed
{
if (\is_array($data)) {
// Check if it's an associative array (keys are not sequential numbers 0..N-1)
if (!empty($data) && array_keys($data) !== range(0, \count($data) - 1)) {
$obj = new \stdClass();
foreach ($data as $key => $value) {
$obj->{$key} = $this->convertDataForValidator($value);
}
return $obj;
} else {
// It's a list (sequential array), convert items recursively
return array_map([$this, 'convertDataForValidator'], $data);
}
} elseif (\is_object($data) && $data instanceof \stdClass) {
// Deep copy/convert stdClass objects as well
$obj = new \stdClass();
foreach (get_object_vars($data) as $key => $value) {
$obj->{$key} = $this->convertDataForValidator($value);
}
return $obj;
}
// Leave other objects and scalar types as they are
return $data;
}
/**
* Recursively collects leaf validation errors.
*
* @param Error[] $collectedErrors
*/
private function collectSubErrors(ValidationError $error, array &$collectedErrors): void
{
$subErrors = $error->subErrors();
if (empty($subErrors)) {
$collectedErrors[] = [
'pointer' => $this->formatJsonPointerPath($error->data()->path()),
'keyword' => $error->keyword(),
'message' => $this->formatValidationError($error),
];
} else {
foreach ($subErrors as $subError) {
$this->collectSubErrors($subError, $collectedErrors);
}
}
}
/**
* Formats the path array into a JSON Pointer string.
*
* @param string[]|int[]|null $pathComponents
*/
private function formatJsonPointerPath(?array $pathComponents): string
{
if (empty($pathComponents)) {
return '/';
}
$escapedComponents = array_map(function ($component) {
$componentStr = (string) $component;
return str_replace(['~', '/'], ['~0', '~1'], $componentStr);
}, $pathComponents);
return '/'.implode('/', $escapedComponents);
}
/**
* Formats an Opis SchemaValidationError into a user-friendly message.
*/
private function formatValidationError(ValidationError $error): string
{
$keyword = $error->keyword();
$args = $error->args();
$message = "Constraint `{$keyword}` failed.";
switch (strtolower($keyword)) {
case 'required':
$missing = $args['missing'] ?? [];
$formattedMissing = implode(', ', array_map(fn ($p) => "`{$p}`", $missing));
$message = "Missing required properties: {$formattedMissing}.";
break;
case 'type':
$expected = implode('|', (array) ($args['expected'] ?? []));
$used = $args['used'] ?? 'unknown';
$message = "Invalid type. Expected `{$expected}`, but received `{$used}`.";
break;
case 'enum':
$schemaData = $error->schema()->info()->data();
$allowedValues = [];
if (\is_object($schemaData) && property_exists($schemaData, 'enum') && \is_array($schemaData->enum)) {
$allowedValues = $schemaData->enum;
} elseif (\is_array($schemaData) && isset($schemaData['enum']) && \is_array($schemaData['enum'])) {
$allowedValues = $schemaData['enum'];
} else {
$this->logger->warning("MCP SDK: Could not retrieve 'enum' values from schema info for error.", ['error_args' => $args]);
}
if (empty($allowedValues)) {
$message = 'Value does not match the allowed enumeration.';
} else {
$formattedAllowed = array_map(function ($v) { /* ... formatting logic ... */
if (\is_string($v)) {
return '"'.$v.'"';
}
if (\is_bool($v)) {
return $v ? 'true' : 'false';
}
if (null === $v) {
return 'null';
}
return (string) $v;
}, $allowedValues);
$message = 'Value must be one of the allowed values: '.implode(', ', $formattedAllowed).'.';
}
break;
case 'const':
$expected = json_encode($args['expected'] ?? 'null', \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
$message = "Value must be equal to the constant value: {$expected}.";
break;
case 'minLength': // Corrected casing
$min = $args['min'] ?? '?';
$message = "String must be at least {$min} characters long.";
break;
case 'maxLength': // Corrected casing
$max = $args['max'] ?? '?';
$message = "String must not be longer than {$max} characters.";
break;
case 'pattern':
$pattern = $args['pattern'] ?? '?';
$message = "String does not match the required pattern: `{$pattern}`.";
break;
case 'minimum':
$min = $args['min'] ?? '?';
$message = "Number must be greater than or equal to {$min}.";
break;
case 'maximum':
$max = $args['max'] ?? '?';
$message = "Number must be less than or equal to {$max}.";
break;
case 'exclusiveMinimum': // Corrected casing
$min = $args['min'] ?? '?';
$message = "Number must be strictly greater than {$min}.";
break;
case 'exclusiveMaximum': // Corrected casing
$max = $args['max'] ?? '?';
$message = "Number must be strictly less than {$max}.";
break;
case 'multipleOf': // Corrected casing
$value = $args['value'] ?? '?';
$message = "Number must be a multiple of {$value}.";
break;
case 'minItems': // Corrected casing
$min = $args['min'] ?? '?';
$message = "Array must contain at least {$min} items.";
break;
case 'maxItems': // Corrected casing
$max = $args['max'] ?? '?';
$message = "Array must contain no more than {$max} items.";
break;
case 'uniqueItems': // Corrected casing
$message = 'Array items must be unique.';
break;
case 'minProperties': // Corrected casing
$min = $args['min'] ?? '?';
$message = "Object must have at least {$min} properties.";
break;
case 'maxProperties': // Corrected casing
$max = $args['max'] ?? '?';
$message = "Object must have no more than {$max} properties.";
break;
case 'additionalProperties': // Corrected casing
$unexpected = $args['properties'] ?? [];
$formattedUnexpected = implode(', ', array_map(fn ($p) => "`{$p}`", $unexpected));
$message = "Object contains unexpected additional properties: {$formattedUnexpected}.";
break;
case 'format':
$format = $args['format'] ?? 'unknown';
$message = "Value does not match the required format: `{$format}`.";
break;
default:
$builtInMessage = $error->message();
if ($builtInMessage && 'The data must match the schema' !== $builtInMessage) {
$placeholders = $args;
$builtInMessage = preg_replace_callback('/\{(\w+)\}/', function ($match) use ($placeholders) {
$key = $match[1];
$value = $placeholders[$key] ?? '{'.$key.'}';
return \is_array($value) ? json_encode($value) : (string) $value;
}, $builtInMessage);
$message = $builtInMessage;
}
break;
}
return $message;
}
}

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

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

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

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

View 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(),
];
}
}

View 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(),
];
}
}

View 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(),
];
}
}

View 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