Introduce Capability Registry & Discovery
This commit is contained in:
parent
09707b4798
commit
da0d5a87a0
58 changed files with 7063 additions and 8 deletions
|
|
@ -8,28 +8,33 @@
|
||||||
"name": "Christopher Hertel",
|
"name": "Christopher Hertel",
|
||||||
"email": "mail@christopher-hertel.de"
|
"email": "mail@christopher-hertel.de"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Tobias Nyholm",
|
|
||||||
"email": "tobias.nyholm@gmail.com"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Kyrian Obikwelu",
|
"name": "Kyrian Obikwelu",
|
||||||
"email": "koshnawaza@gmail.com"
|
"email": "koshnawaza@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tobias Nyholm",
|
||||||
|
"email": "tobias.nyholm@gmail.com"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.1",
|
"php": "^8.1",
|
||||||
"ext-fileinfo": "*",
|
"ext-fileinfo": "*",
|
||||||
|
"opis/json-schema": "^2.4",
|
||||||
|
"phpdocumentor/reflection-docblock": "^5.6",
|
||||||
|
"psr/event-dispatcher": "^1.0",
|
||||||
"psr/log": "^1.0 || ^2.0 || ^3.0",
|
"psr/log": "^1.0 || ^2.0 || ^3.0",
|
||||||
"symfony/uid": "^6.4 || ^7.0"
|
"symfony/finder": "^6.4 || ^7.2",
|
||||||
|
"symfony/uid": "^6.4 || ^7.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"nyholm/nsa": "^1.3",
|
||||||
|
"php-cs-fixer/shim": "^3.84",
|
||||||
"phpstan/phpstan": "^2.1",
|
"phpstan/phpstan": "^2.1",
|
||||||
"phpunit/phpunit": "^10.5",
|
"phpunit/phpunit": "^10.5",
|
||||||
"symfony/console": "^6.4 || ^7.0",
|
|
||||||
"psr/cache": "^3.0",
|
"psr/cache": "^3.0",
|
||||||
"php-cs-fixer/shim": "^3.84",
|
"symfony/console": "^6.4 || ^7.0",
|
||||||
"nyholm/nsa": "^1.3"
|
"symfony/event-dispatcher": "^6.4 || ^7.0"
|
||||||
},
|
},
|
||||||
"suggest": {
|
"suggest": {
|
||||||
"symfony/console": "To use SymfonyConsoleTransport for STDIO",
|
"symfony/console": "To use SymfonyConsoleTransport for STDIO",
|
||||||
|
|
@ -44,5 +49,8 @@
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Mcp\\Tests\\": "tests/"
|
"Mcp\\Tests\\": "tests/"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"sort-packages": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,14 @@ parameters:
|
||||||
- tests/
|
- tests/
|
||||||
excludePaths:
|
excludePaths:
|
||||||
- examples/cli/vendor/* (?)
|
- examples/cli/vendor/* (?)
|
||||||
|
- tests/Capability/Discovery/Fixtures/
|
||||||
|
- tests/Capability/Discovery/SchemaGeneratorFixture.php
|
||||||
treatPhpDocTypesAsCertain: false
|
treatPhpDocTypesAsCertain: false
|
||||||
ignoreErrors:
|
ignoreErrors:
|
||||||
-
|
-
|
||||||
message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#"
|
message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#"
|
||||||
|
# This errors should actually be fixed, but are ignored for now
|
||||||
|
-
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
path: src/Capability/Discovery/SchemaGenerator.php
|
||||||
|
count: 12
|
||||||
|
|
|
||||||
38
src/Capability/Attribute/CompletionProvider.php
Normal file
38
src/Capability/Attribute/CompletionProvider.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Attribute;
|
||||||
|
|
||||||
|
use Mcp\Capability\Prompt\Completion\ProviderInterface;
|
||||||
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
#[\Attribute(\Attribute::TARGET_PARAMETER)]
|
||||||
|
class CompletionProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param class-string<ProviderInterface>|ProviderInterface|null $provider if a class-string, it will be resolved
|
||||||
|
* from the container at the point of use
|
||||||
|
* @param ?array<int, int|float|string> $values a list of values to use for completion
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public ?string $providerClass = null,
|
||||||
|
public string|ProviderInterface|null $provider = null,
|
||||||
|
public ?array $values = null,
|
||||||
|
public ?string $enum = null,
|
||||||
|
) {
|
||||||
|
if (1 !== \count(array_filter([$provider, $values, $enum]))) {
|
||||||
|
throw new InvalidArgumentException('Only one of provider, values, or enum can be set.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Capability/Attribute/McpPrompt.php
Normal file
32
src/Capability/Attribute/McpPrompt.php
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a PHP method as an MCP Prompt generator.
|
||||||
|
* The method should return the prompt messages, potentially using arguments for templating.
|
||||||
|
*
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
|
||||||
|
final class McpPrompt
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param ?string $name overrides the prompt name (defaults to method name)
|
||||||
|
* @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public ?string $name = null,
|
||||||
|
public ?string $description = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/Capability/Attribute/McpResource.php
Normal file
42
src/Capability/Attribute/McpResource.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Attribute;
|
||||||
|
|
||||||
|
use Mcp\Schema\Annotations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a PHP class as representing or handling a specific MCP Resource instance.
|
||||||
|
* Used primarily for the 'resources/list' discovery.
|
||||||
|
*
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
|
||||||
|
final class McpResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $uri The specific URI identifying this resource instance. Must be unique within the server.
|
||||||
|
* @param ?string $name A human-readable name for this resource. If null, a default might be generated from the method name.
|
||||||
|
* @param ?string $description An optional description of the resource. Defaults to class DocBlock summary.
|
||||||
|
* @param ?string $mimeType the MIME type, if known and constant for this resource
|
||||||
|
* @param ?int $size the size in bytes, if known and constant
|
||||||
|
* @param Annotations|null $annotations optional annotations describing the resource
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $uri,
|
||||||
|
public ?string $name = null,
|
||||||
|
public ?string $description = null,
|
||||||
|
public ?string $mimeType = null,
|
||||||
|
public ?int $size = null,
|
||||||
|
public ?Annotations $annotations = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/Capability/Attribute/McpResourceTemplate.php
Normal file
40
src/Capability/Attribute/McpResourceTemplate.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Attribute;
|
||||||
|
|
||||||
|
use Mcp\Schema\Annotations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a PHP class definition as representing an MCP Resource Template.
|
||||||
|
* This is informational, used for 'resources/templates/list'.
|
||||||
|
*
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
|
||||||
|
final class McpResourceTemplate
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $uriTemplate the URI template string (RFC 6570)
|
||||||
|
* @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name.
|
||||||
|
* @param ?string $description Optional description. Defaults to class DocBlock summary.
|
||||||
|
* @param ?string $mimeType optional default MIME type for matching resources
|
||||||
|
* @param ?Annotations $annotations optional annotations describing the resource template
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $uriTemplate,
|
||||||
|
public ?string $name = null,
|
||||||
|
public ?string $description = null,
|
||||||
|
public ?string $mimeType = null,
|
||||||
|
public ?Annotations $annotations = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/Capability/Attribute/McpTool.php
Normal file
33
src/Capability/Attribute/McpTool.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Attribute;
|
||||||
|
|
||||||
|
use Mcp\Schema\ToolAnnotations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
|
||||||
|
class McpTool
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string|null $name The name of the tool (defaults to the method name)
|
||||||
|
* @param string|null $description The description of the tool (defaults to the DocBlock/inferred)
|
||||||
|
* @param ToolAnnotations|null $annotations Optional annotations describing tool behavior
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public ?string $name = null,
|
||||||
|
public ?string $description = null,
|
||||||
|
public ?ToolAnnotations $annotations = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
258
src/Capability/Attribute/Schema.php
Normal file
258
src/Capability/Attribute/Schema.php
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a JSON Schema for a method's input or an individual parameter.
|
||||||
|
*
|
||||||
|
* When used at the method level, it describes an object schema where properties
|
||||||
|
* correspond to the method's parameters.
|
||||||
|
*
|
||||||
|
* When used at the parameter level, it describes the schema for that specific parameter.
|
||||||
|
* If 'type' is omitted at the parameter level, it will be inferred.
|
||||||
|
*
|
||||||
|
* @phpstan-type SchemaAttributeData array{
|
||||||
|
* definition?: array<string, mixed>,
|
||||||
|
* type?: string,
|
||||||
|
* description?: string,
|
||||||
|
* enum?: array<int, float|string|int>,
|
||||||
|
* gormat?: string,
|
||||||
|
* minLength?: int,
|
||||||
|
* maxLength?: int,
|
||||||
|
* pattern?: string,
|
||||||
|
* minimum?: int,
|
||||||
|
* maximum?: int,
|
||||||
|
* exclusiveMinimum?: int,
|
||||||
|
* exclusiveMaximum?: int,
|
||||||
|
* multipleOf?: int|float,
|
||||||
|
* items?: array<string, mixed>,
|
||||||
|
* minItems?: int,
|
||||||
|
* maxItems?: int,
|
||||||
|
* uniqueItems?: bool,
|
||||||
|
* properties?: array<string, mixed>,
|
||||||
|
* required?: array<int, string>,
|
||||||
|
* additionalProperties?: bool|array<string, mixed>,
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER)]
|
||||||
|
class Schema
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The complete JSON schema array.
|
||||||
|
* If provided, it takes precedence over individual properties like $type, $properties, etc.
|
||||||
|
*
|
||||||
|
* @var ?array<string, mixed>
|
||||||
|
*/
|
||||||
|
public ?array $definition = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alternatively, provide individual top-level schema keywords.
|
||||||
|
* These are used if $definition is null.
|
||||||
|
*/
|
||||||
|
public ?string $type = null;
|
||||||
|
public ?string $description = null;
|
||||||
|
public mixed $default = null;
|
||||||
|
/**
|
||||||
|
* @var ?array<int, float|string|int>
|
||||||
|
*/
|
||||||
|
public ?array $enum = null; // list of allowed values
|
||||||
|
public ?string $format = null; // e.g., 'email', 'date-time'
|
||||||
|
|
||||||
|
// Constraints for string
|
||||||
|
public ?int $minLength = null;
|
||||||
|
public ?int $maxLength = null;
|
||||||
|
public ?string $pattern = null;
|
||||||
|
|
||||||
|
// Constraints for number/integer
|
||||||
|
public int|float|null $minimum = null;
|
||||||
|
public int|float|null $maximum = null;
|
||||||
|
public ?bool $exclusiveMinimum = null;
|
||||||
|
public ?bool $exclusiveMaximum = null;
|
||||||
|
public int|float|null $multipleOf = null;
|
||||||
|
|
||||||
|
// Constraints for array
|
||||||
|
/**
|
||||||
|
* @var ?array<string, mixed>
|
||||||
|
*/
|
||||||
|
public ?array $items = null; // JSON schema for array items
|
||||||
|
public ?int $minItems = null;
|
||||||
|
public ?int $maxItems = null;
|
||||||
|
public ?bool $uniqueItems = null;
|
||||||
|
|
||||||
|
// Constraints for object (primarily used when Schema is on a method or an object-typed parameter)
|
||||||
|
/**
|
||||||
|
* @var ?array<string, mixed>
|
||||||
|
*/
|
||||||
|
public ?array $properties = null; // [propertyName => [schema array], ...]
|
||||||
|
/**
|
||||||
|
* @var ?array<int, string>
|
||||||
|
*/
|
||||||
|
public ?array $required = null; // [propertyName, ...]
|
||||||
|
/**
|
||||||
|
* @var bool|array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public bool|array|null $additionalProperties = null; // true, false, or a schema array
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ?array<string, mixed> $definition A complete JSON schema array. If provided, other parameters are ignored.
|
||||||
|
* @param ?string $type the JSON schema type
|
||||||
|
* @param ?string $description description of the element
|
||||||
|
* @param ?array<int, float|string|int> $enum allowed enum values
|
||||||
|
* @param ?string $format String format (e.g., 'date-time', 'email').
|
||||||
|
* @param ?int $minLength minimum length for strings
|
||||||
|
* @param ?int $maxLength maximum length for strings
|
||||||
|
* @param ?string $pattern regex pattern for strings
|
||||||
|
* @param int|float|null $minimum minimum value for numbers/integers
|
||||||
|
* @param int|float|null $maximum maximum value for numbers/integers
|
||||||
|
* @param ?bool $exclusiveMinimum exclusive minimum
|
||||||
|
* @param ?bool $exclusiveMaximum exclusive maximum
|
||||||
|
* @param int|float|null $multipleOf must be a multiple of this value
|
||||||
|
* @param ?array<string, mixed> $items JSON Schema for items if type is 'array'
|
||||||
|
* @param ?int $minItems minimum items for an array
|
||||||
|
* @param ?int $maxItems maximum items for an array
|
||||||
|
* @param ?bool $uniqueItems whether array items must be unique
|
||||||
|
* @param ?array<string, mixed> $properties Property definitions if type is 'object'. [name => schema_array].
|
||||||
|
* @param ?array<int, string> $required list of required properties for an object
|
||||||
|
* @param bool|array<string, mixed>|null $additionalProperties policy for additional properties in an object
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
?array $definition = null,
|
||||||
|
?string $type = null,
|
||||||
|
?string $description = null,
|
||||||
|
?array $enum = null,
|
||||||
|
?string $format = null,
|
||||||
|
?int $minLength = null,
|
||||||
|
?int $maxLength = null,
|
||||||
|
?string $pattern = null,
|
||||||
|
int|float|null $minimum = null,
|
||||||
|
int|float|null $maximum = null,
|
||||||
|
?bool $exclusiveMinimum = null,
|
||||||
|
?bool $exclusiveMaximum = null,
|
||||||
|
int|float|null $multipleOf = null,
|
||||||
|
?array $items = null,
|
||||||
|
?int $minItems = null,
|
||||||
|
?int $maxItems = null,
|
||||||
|
?bool $uniqueItems = null,
|
||||||
|
?array $properties = null,
|
||||||
|
?array $required = null,
|
||||||
|
bool|array|null $additionalProperties = null,
|
||||||
|
) {
|
||||||
|
if (null !== $definition) {
|
||||||
|
$this->definition = $definition;
|
||||||
|
} else {
|
||||||
|
$this->type = $type;
|
||||||
|
$this->description = $description;
|
||||||
|
$this->enum = $enum;
|
||||||
|
$this->format = $format;
|
||||||
|
$this->minLength = $minLength;
|
||||||
|
$this->maxLength = $maxLength;
|
||||||
|
$this->pattern = $pattern;
|
||||||
|
$this->minimum = $minimum;
|
||||||
|
$this->maximum = $maximum;
|
||||||
|
$this->exclusiveMinimum = $exclusiveMinimum;
|
||||||
|
$this->exclusiveMaximum = $exclusiveMaximum;
|
||||||
|
$this->multipleOf = $multipleOf;
|
||||||
|
$this->items = $items;
|
||||||
|
$this->minItems = $minItems;
|
||||||
|
$this->maxItems = $maxItems;
|
||||||
|
$this->uniqueItems = $uniqueItems;
|
||||||
|
$this->properties = $properties;
|
||||||
|
$this->required = $required;
|
||||||
|
$this->additionalProperties = $additionalProperties;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the attribute's definition to a JSON schema array.
|
||||||
|
*
|
||||||
|
* @return SchemaAttributeData
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
if (null !== $this->definition) {
|
||||||
|
return [
|
||||||
|
'definition' => $this->definition,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$schema = [];
|
||||||
|
if (null !== $this->type) {
|
||||||
|
$schema['type'] = $this->type;
|
||||||
|
}
|
||||||
|
if (null !== $this->description) {
|
||||||
|
$schema['description'] = $this->description;
|
||||||
|
}
|
||||||
|
if (null !== $this->enum) {
|
||||||
|
$schema['enum'] = $this->enum;
|
||||||
|
}
|
||||||
|
if (null !== $this->format) {
|
||||||
|
$schema['format'] = $this->format;
|
||||||
|
}
|
||||||
|
|
||||||
|
// String
|
||||||
|
if (null !== $this->minLength) {
|
||||||
|
$schema['minLength'] = $this->minLength;
|
||||||
|
}
|
||||||
|
if (null !== $this->maxLength) {
|
||||||
|
$schema['maxLength'] = $this->maxLength;
|
||||||
|
}
|
||||||
|
if (null !== $this->pattern) {
|
||||||
|
$schema['pattern'] = $this->pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric
|
||||||
|
if (null !== $this->minimum) {
|
||||||
|
$schema['minimum'] = $this->minimum;
|
||||||
|
}
|
||||||
|
if (null !== $this->maximum) {
|
||||||
|
$schema['maximum'] = $this->maximum;
|
||||||
|
}
|
||||||
|
if (null !== $this->exclusiveMinimum) {
|
||||||
|
$schema['exclusiveMinimum'] = $this->exclusiveMinimum;
|
||||||
|
}
|
||||||
|
if (null !== $this->exclusiveMaximum) {
|
||||||
|
$schema['exclusiveMaximum'] = $this->exclusiveMaximum;
|
||||||
|
}
|
||||||
|
if (null !== $this->multipleOf) {
|
||||||
|
$schema['multipleOf'] = $this->multipleOf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array
|
||||||
|
if (null !== $this->items) {
|
||||||
|
$schema['items'] = $this->items;
|
||||||
|
}
|
||||||
|
if (null !== $this->minItems) {
|
||||||
|
$schema['minItems'] = $this->minItems;
|
||||||
|
}
|
||||||
|
if (null !== $this->maxItems) {
|
||||||
|
$schema['maxItems'] = $this->maxItems;
|
||||||
|
}
|
||||||
|
if (null !== $this->uniqueItems) {
|
||||||
|
$schema['uniqueItems'] = $this->uniqueItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object
|
||||||
|
if (null !== $this->properties) {
|
||||||
|
$schema['properties'] = $this->properties;
|
||||||
|
}
|
||||||
|
if (null !== $this->required) {
|
||||||
|
$schema['required'] = $this->required;
|
||||||
|
}
|
||||||
|
if (null !== $this->additionalProperties) {
|
||||||
|
$schema['additionalProperties'] = $this->additionalProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
}
|
||||||
412
src/Capability/Discovery/Discoverer.php
Normal file
412
src/Capability/Discovery/Discoverer.php
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Discovery;
|
||||||
|
|
||||||
|
use Mcp\Capability\Attribute\CompletionProvider;
|
||||||
|
use Mcp\Capability\Attribute\McpPrompt;
|
||||||
|
use Mcp\Capability\Attribute\McpResource;
|
||||||
|
use Mcp\Capability\Attribute\McpResourceTemplate;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Capability\Prompt\Completion\EnumCompletionProvider;
|
||||||
|
use Mcp\Capability\Prompt\Completion\ListCompletionProvider;
|
||||||
|
use Mcp\Capability\Prompt\Completion\ProviderInterface;
|
||||||
|
use Mcp\Capability\Registry;
|
||||||
|
use Mcp\Exception\ExceptionInterface;
|
||||||
|
use Mcp\Schema\Prompt;
|
||||||
|
use Mcp\Schema\PromptArgument;
|
||||||
|
use Mcp\Schema\Resource;
|
||||||
|
use Mcp\Schema\ResourceTemplate;
|
||||||
|
use Mcp\Schema\Tool;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\Finder\Finder;
|
||||||
|
use Symfony\Component\Finder\SplFileInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @phpstan-type DiscoveredCount array{
|
||||||
|
* tools: int,
|
||||||
|
* resources: int,
|
||||||
|
* prompts: int,
|
||||||
|
* resourceTemplates: int,
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
class Discoverer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Registry $registry,
|
||||||
|
private readonly LoggerInterface $logger = new NullLogger(),
|
||||||
|
private ?DocBlockParser $docBlockParser = null,
|
||||||
|
private ?SchemaGenerator $schemaGenerator = null,
|
||||||
|
) {
|
||||||
|
$this->docBlockParser = $docBlockParser ?? new DocBlockParser(logger: $this->logger);
|
||||||
|
$this->schemaGenerator = $schemaGenerator ?? new SchemaGenerator($this->docBlockParser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover MCP elements in the specified directories.
|
||||||
|
*
|
||||||
|
* @param string $basePath the base path for resolving directories
|
||||||
|
* @param array<string> $directories list of directories (relative to base path) to scan
|
||||||
|
* @param array<string> $excludeDirs list of directories (relative to base path) to exclude from the scan
|
||||||
|
*/
|
||||||
|
public function discover(string $basePath, array $directories, array $excludeDirs = []): void
|
||||||
|
{
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$discoveredCount = [
|
||||||
|
'tools' => 0,
|
||||||
|
'resources' => 0,
|
||||||
|
'prompts' => 0,
|
||||||
|
'resourceTemplates' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$finder = new Finder();
|
||||||
|
$absolutePaths = [];
|
||||||
|
foreach ($directories as $dir) {
|
||||||
|
$path = rtrim($basePath, '/').'/'.ltrim($dir, '/');
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$absolutePaths[] = $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($absolutePaths)) {
|
||||||
|
$this->logger->warning('No valid discovery directories found to scan.', [
|
||||||
|
'configured_paths' => $directories,
|
||||||
|
'base_path' => $basePath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$finder->files()
|
||||||
|
->in($absolutePaths)
|
||||||
|
->exclude($excludeDirs)
|
||||||
|
->name('*.php');
|
||||||
|
|
||||||
|
foreach ($finder as $file) {
|
||||||
|
$this->processFile($file, $discoveredCount);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->error('Error during file finding process for MCP discovery', [
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = microtime(true) - $startTime;
|
||||||
|
$this->logger->info('Attribute discovery finished.', [
|
||||||
|
'duration_sec' => round($duration, 3),
|
||||||
|
'tools' => $discoveredCount['tools'],
|
||||||
|
'resources' => $discoveredCount['resources'],
|
||||||
|
'prompts' => $discoveredCount['prompts'],
|
||||||
|
'resourceTemplates' => $discoveredCount['resourceTemplates'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single PHP file for MCP elements on classes or methods.
|
||||||
|
*
|
||||||
|
* @param DiscoveredCount $discoveredCount
|
||||||
|
*/
|
||||||
|
private function processFile(SplFileInfo $file, array &$discoveredCount): void
|
||||||
|
{
|
||||||
|
$filePath = $file->getRealPath();
|
||||||
|
if (false === $filePath) {
|
||||||
|
$this->logger->warning('Could not get real path for file', ['path' => $file->getPathname()]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$className = $this->getClassFromFile($filePath);
|
||||||
|
if (!$className) {
|
||||||
|
$this->logger->warning('No valid class found in file', ['file' => $filePath]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reflectionClass = new \ReflectionClass($className);
|
||||||
|
|
||||||
|
if ($reflectionClass->isAbstract() || $reflectionClass->isInterface() || $reflectionClass->isTrait() || $reflectionClass->isEnum()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$processedViaClassAttribute = false;
|
||||||
|
if ($reflectionClass->hasMethod('__invoke')) {
|
||||||
|
$invokeMethod = $reflectionClass->getMethod('__invoke');
|
||||||
|
if ($invokeMethod->isPublic() && !$invokeMethod->isStatic()) {
|
||||||
|
$attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class];
|
||||||
|
foreach ($attributeTypes as $attributeType) {
|
||||||
|
$classAttribute = $reflectionClass->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null;
|
||||||
|
if ($classAttribute) {
|
||||||
|
$this->processMethod($invokeMethod, $discoveredCount, $classAttribute);
|
||||||
|
$processedViaClassAttribute = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$processedViaClassAttribute) {
|
||||||
|
foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
|
||||||
|
if (
|
||||||
|
$method->getDeclaringClass()->getName() !== $reflectionClass->getName()
|
||||||
|
|| $method->isStatic() || $method->isAbstract() || $method->isConstructor() || $method->isDestructor() || '__invoke' === $method->getName()
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class];
|
||||||
|
foreach ($attributeTypes as $attributeType) {
|
||||||
|
$methodAttribute = $method->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null;
|
||||||
|
if ($methodAttribute) {
|
||||||
|
$this->processMethod($method, $discoveredCount, $methodAttribute);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\ReflectionException $e) {
|
||||||
|
$this->logger->error('Reflection error processing file for MCP discovery', ['file' => $filePath, 'class' => $className, 'exception' => $e->getMessage()]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->error('Unexpected error processing file for MCP discovery', [
|
||||||
|
'file' => $filePath,
|
||||||
|
'class' => $className,
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a method with a given MCP attribute instance.
|
||||||
|
* Can be called for regular methods or the __invoke method of an invokable class.
|
||||||
|
*
|
||||||
|
* @param \ReflectionMethod $method The target method (e.g., regular method or __invoke).
|
||||||
|
* @param DiscoveredCount $discoveredCount pass by reference to update counts
|
||||||
|
* @param \ReflectionAttribute<McpTool|McpResource|McpPrompt|McpResourceTemplate> $attribute the ReflectionAttribute instance found (on method or class)
|
||||||
|
*/
|
||||||
|
private function processMethod(\ReflectionMethod $method, array &$discoveredCount, \ReflectionAttribute $attribute): void
|
||||||
|
{
|
||||||
|
$className = $method->getDeclaringClass()->getName();
|
||||||
|
$classShortName = $method->getDeclaringClass()->getShortName();
|
||||||
|
$methodName = $method->getName();
|
||||||
|
$attributeClassName = $attribute->getName();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$instance = $attribute->newInstance();
|
||||||
|
|
||||||
|
switch ($attributeClassName) {
|
||||||
|
case McpTool::class:
|
||||||
|
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
|
||||||
|
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
|
||||||
|
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
|
||||||
|
$inputSchema = $this->schemaGenerator->generate($method);
|
||||||
|
$tool = new Tool($name, $inputSchema, $description, $instance->annotations);
|
||||||
|
$this->registry->registerTool($tool, [$className, $methodName]);
|
||||||
|
++$discoveredCount['tools'];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case McpResource::class:
|
||||||
|
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
|
||||||
|
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
|
||||||
|
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
|
||||||
|
$mimeType = $instance->mimeType;
|
||||||
|
$size = $instance->size;
|
||||||
|
$annotations = $instance->annotations;
|
||||||
|
$resource = new Resource($instance->uri, $name, $description, $mimeType, $annotations, $size);
|
||||||
|
$this->registry->registerResource($resource, [$className, $methodName]);
|
||||||
|
++$discoveredCount['resources'];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case McpPrompt::class:
|
||||||
|
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
|
||||||
|
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
|
||||||
|
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
|
||||||
|
$arguments = [];
|
||||||
|
$paramTags = $this->docBlockParser->getParamTags($docBlock);
|
||||||
|
foreach ($method->getParameters() as $param) {
|
||||||
|
$reflectionType = $param->getType();
|
||||||
|
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$paramTag = $paramTags['$'.$param->getName()] ?? null;
|
||||||
|
$arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable());
|
||||||
|
}
|
||||||
|
$prompt = new Prompt($name, $description, $arguments);
|
||||||
|
$completionProviders = $this->getCompletionProviders($method);
|
||||||
|
$this->registry->registerPrompt($prompt, [$className, $methodName], $completionProviders);
|
||||||
|
++$discoveredCount['prompts'];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case McpResourceTemplate::class:
|
||||||
|
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
|
||||||
|
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
|
||||||
|
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
|
||||||
|
$mimeType = $instance->mimeType;
|
||||||
|
$annotations = $instance->annotations;
|
||||||
|
$resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations);
|
||||||
|
$completionProviders = $this->getCompletionProviders($method);
|
||||||
|
$this->registry->registerResourceTemplate($resourceTemplate, [$className, $methodName], $completionProviders);
|
||||||
|
++$discoveredCount['resourceTemplates'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (ExceptionInterface $e) {
|
||||||
|
$this->logger->error("Failed to process MCP attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e->getMessage(), 'trace' => $e->getPrevious() ? $e->getPrevious()->getTraceAsString() : $e->getTraceAsString()]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->error("Unexpected error processing attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string|ProviderInterface>
|
||||||
|
*/
|
||||||
|
private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array
|
||||||
|
{
|
||||||
|
$completionProviders = [];
|
||||||
|
foreach ($reflectionMethod->getParameters() as $param) {
|
||||||
|
$reflectionType = $param->getType();
|
||||||
|
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF);
|
||||||
|
if (!empty($completionAttributes)) {
|
||||||
|
$attributeInstance = $completionAttributes[0]->newInstance();
|
||||||
|
|
||||||
|
if ($attributeInstance->provider) {
|
||||||
|
$completionProviders[$param->getName()] = $attributeInstance->provider;
|
||||||
|
} elseif ($attributeInstance->providerClass) {
|
||||||
|
$completionProviders[$param->getName()] = $attributeInstance->provider;
|
||||||
|
} elseif ($attributeInstance->values) {
|
||||||
|
$completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values);
|
||||||
|
} elseif ($attributeInstance->enum) {
|
||||||
|
$completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $completionProviders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to determine the FQCN from a PHP file path.
|
||||||
|
* Uses tokenization to extract namespace and class name.
|
||||||
|
*
|
||||||
|
* @param string $filePath absolute path to the PHP file
|
||||||
|
*
|
||||||
|
* @return class-string|null the FQCN or null if not found/determinable
|
||||||
|
*/
|
||||||
|
private function getClassFromFile(string $filePath): ?string
|
||||||
|
{
|
||||||
|
if (!file_exists($filePath) || !is_readable($filePath)) {
|
||||||
|
$this->logger->warning('File does not exist or is not readable.', ['file' => $filePath]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$content = file_get_contents($filePath);
|
||||||
|
if (false === $content) {
|
||||||
|
$this->logger->warning('Failed to read file content.', ['file' => $filePath]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (\strlen($content) > 500 * 1024) {
|
||||||
|
$this->logger->debug('Skipping large file during class discovery.', ['file' => $filePath]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = token_get_all($content);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->warning("Failed to read or tokenize file during class discovery: {$filePath}", ['exception' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$namespace = '';
|
||||||
|
$namespaceFound = false;
|
||||||
|
$level = 0;
|
||||||
|
$potentialClasses = [];
|
||||||
|
|
||||||
|
$tokenCount = \count($tokens);
|
||||||
|
for ($i = 0; $i < $tokenCount; ++$i) {
|
||||||
|
if (\is_array($tokens[$i]) && \T_NAMESPACE === $tokens[$i][0]) {
|
||||||
|
$namespace = '';
|
||||||
|
for ($j = $i + 1; $j < $tokenCount; ++$j) {
|
||||||
|
if (';' === $tokens[$j] || '{' === $tokens[$j]) {
|
||||||
|
$namespaceFound = true;
|
||||||
|
$i = $j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (\is_array($tokens[$j]) && \in_array($tokens[$j][0], [\T_STRING, \T_NAME_QUALIFIED])) {
|
||||||
|
$namespace .= $tokens[$j][1];
|
||||||
|
} elseif (\T_NS_SEPARATOR === $tokens[$j][0]) {
|
||||||
|
$namespace .= '\\';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($namespaceFound) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$namespace = trim($namespace, '\\');
|
||||||
|
|
||||||
|
for ($i = 0; $i < $tokenCount; ++$i) {
|
||||||
|
$token = $tokens[$i];
|
||||||
|
if ('{' === $token) {
|
||||||
|
++$level;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ('}' === $token) {
|
||||||
|
--$level;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($level === ($namespaceFound && str_contains($content, "namespace {$namespace} {") ? 1 : 0)) {
|
||||||
|
if (\is_array($token) && \in_array($token[0], [\T_CLASS, \T_INTERFACE, \T_TRAIT, \defined('T_ENUM') ? \T_ENUM : -1])) {
|
||||||
|
for ($j = $i + 1; $j < $tokenCount; ++$j) {
|
||||||
|
if (\is_array($tokens[$j]) && \T_STRING === $tokens[$j][0]) {
|
||||||
|
$className = $tokens[$j][1];
|
||||||
|
$potentialClasses[] = $namespace ? $namespace.'\\'.$className : $className;
|
||||||
|
$i = $j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (';' === $tokens[$j] || '{' === $tokens[$j] || ')' === $tokens[$j]) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($potentialClasses as $potentialClass) {
|
||||||
|
if (class_exists($potentialClass, true)) {
|
||||||
|
return $potentialClass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($potentialClasses)) {
|
||||||
|
if (!class_exists($potentialClasses[0], false)) {
|
||||||
|
$this->logger->debug('getClassFromFile returning potential non-class type. Are you sure this class has been autoloaded?', ['file' => $filePath, 'type' => $potentialClasses[0]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $potentialClasses[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/Capability/Discovery/DocBlockParser.php
Normal file
139
src/Capability/Discovery/DocBlockParser.php
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Discovery;
|
||||||
|
|
||||||
|
use phpDocumentor\Reflection\DocBlock;
|
||||||
|
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
||||||
|
use phpDocumentor\Reflection\DocBlockFactory;
|
||||||
|
use phpDocumentor\Reflection\DocBlockFactoryInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses DocBlocks using phpdocumentor/reflection-docblock.
|
||||||
|
*
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
class DocBlockParser
|
||||||
|
{
|
||||||
|
private DocBlockFactoryInterface $docBlockFactory;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
?DocBlockFactoryInterface $docBlockFactory = null,
|
||||||
|
private readonly LoggerInterface $logger = new NullLogger(),
|
||||||
|
) {
|
||||||
|
$this->docBlockFactory = $docBlockFactory ?? DocBlockFactory::createInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parses a DocComment string into a DocBlock object.
|
||||||
|
*/
|
||||||
|
public function parseDocBlock(string|false|null $docComment): ?DocBlock
|
||||||
|
{
|
||||||
|
if (false === $docComment || null === $docComment || empty($docComment)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return $this->docBlockFactory->create($docComment);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Log error or handle gracefully if invalid DocBlock syntax is encountered
|
||||||
|
$this->logger->warning('Failed to parse DocBlock', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'exception_trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the summary line from a DocBlock.
|
||||||
|
*/
|
||||||
|
public function getSummary(?DocBlock $docBlock): ?string
|
||||||
|
{
|
||||||
|
if (!$docBlock) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$summary = trim($docBlock->getSummary());
|
||||||
|
|
||||||
|
return $summary ?: null; // Return null if empty after trimming
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the description from a DocBlock (summary + description body).
|
||||||
|
*/
|
||||||
|
public function getDescription(?DocBlock $docBlock): ?string
|
||||||
|
{
|
||||||
|
if (!$docBlock) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$summary = trim($docBlock->getSummary());
|
||||||
|
$descriptionBody = trim((string) $docBlock->getDescription());
|
||||||
|
|
||||||
|
if ($summary && $descriptionBody) {
|
||||||
|
return $summary."\n\n".$descriptionBody;
|
||||||
|
}
|
||||||
|
if ($summary) {
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
if ($descriptionBody) {
|
||||||
|
return $descriptionBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts "@param" tag information from a DocBlock, keyed by variable name (e.g., '$paramName').
|
||||||
|
*
|
||||||
|
* @return array<string, Param>
|
||||||
|
*/
|
||||||
|
public function getParamTags(?DocBlock $docBlock): array
|
||||||
|
{
|
||||||
|
if (!$docBlock) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, Param> $paramTags */
|
||||||
|
$paramTags = [];
|
||||||
|
foreach ($docBlock->getTagsByName('param') as $tag) {
|
||||||
|
if ($tag instanceof Param && $tag->getVariableName()) {
|
||||||
|
$paramTags['$'.$tag->getVariableName()] = $tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $paramTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the description string from a Param tag.
|
||||||
|
*/
|
||||||
|
public function getParamDescription(?Param $paramTag): ?string
|
||||||
|
{
|
||||||
|
return $paramTag ? (trim((string) $paramTag->getDescription()) ?: null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the type string from a Param tag.
|
||||||
|
*/
|
||||||
|
public function getParamTypeString(?Param $paramTag): ?string
|
||||||
|
{
|
||||||
|
if ($paramTag && $paramTag->getType()) {
|
||||||
|
$typeFromTag = trim((string) $paramTag->getType());
|
||||||
|
if (!empty($typeFromTag)) {
|
||||||
|
return ltrim($typeFromTag, '\\');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/Capability/Discovery/HandlerResolver.php
Normal file
85
src/Capability/Discovery/HandlerResolver.php
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Discovery;
|
||||||
|
|
||||||
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class to validate and resolve MCP element handlers.
|
||||||
|
*
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
class HandlerResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validates and resolves a handler to a ReflectionMethod or ReflectionFunction instance.
|
||||||
|
*
|
||||||
|
* A handler can be:
|
||||||
|
* - A Closure: function() { ... }
|
||||||
|
* - An array: [ClassName::class, 'methodName'] (instance method)
|
||||||
|
* - An array: [ClassName::class, 'staticMethod'] (static method, if callable)
|
||||||
|
* - A string: InvokableClassName::class (which will resolve to its '__invoke' method)
|
||||||
|
*
|
||||||
|
* @param \Closure|array{0: string, 1: string}|string $handler the handler to resolve
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException If the handler format is invalid, the class/method doesn't exist,
|
||||||
|
* or the method is unsuitable (e.g., private, abstract).
|
||||||
|
*/
|
||||||
|
public static function resolve(\Closure|array|string $handler): \ReflectionMethod|\ReflectionFunction
|
||||||
|
{
|
||||||
|
if ($handler instanceof \Closure) {
|
||||||
|
return new \ReflectionFunction($handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_array($handler)) {
|
||||||
|
if (2 !== \count($handler) || !isset($handler[0]) || !isset($handler[1]) || !\is_string($handler[0]) || !\is_string($handler[1])) {
|
||||||
|
throw new InvalidArgumentException('Invalid array handler format. Expected [ClassName::class, \'methodName\'].');
|
||||||
|
}
|
||||||
|
[$className, $methodName] = $handler;
|
||||||
|
if (!class_exists($className)) {
|
||||||
|
throw new InvalidArgumentException(\sprintf('Handler class "%s" not found for array handler.', $className));
|
||||||
|
}
|
||||||
|
if (!method_exists($className, $methodName)) {
|
||||||
|
throw new InvalidArgumentException(\sprintf('Handler method "%s" not found in class "%s" for array handler.', $methodName, $className));
|
||||||
|
}
|
||||||
|
} elseif (class_exists($handler)) {
|
||||||
|
$className = $handler;
|
||||||
|
$methodName = '__invoke';
|
||||||
|
if (!method_exists($className, $methodName)) {
|
||||||
|
throw new InvalidArgumentException(\sprintf('Invokable handler class "%s" must have a public "__invoke" method.', $className));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new InvalidArgumentException('Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reflectionMethod = new \ReflectionMethod($className, $methodName);
|
||||||
|
|
||||||
|
// For discovered elements (non-manual), still reject static methods
|
||||||
|
// For manual elements, we'll allow static methods since they're callable
|
||||||
|
if (!$reflectionMethod->isPublic()) {
|
||||||
|
throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" must be public.', $className, $methodName));
|
||||||
|
}
|
||||||
|
if ($reflectionMethod->isAbstract()) {
|
||||||
|
throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" must be abstract.', $className, $methodName));
|
||||||
|
}
|
||||||
|
if ($reflectionMethod->isConstructor() || $reflectionMethod->isDestructor()) {
|
||||||
|
throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" cannot be a constructor or destructor.', $className, $methodName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reflectionMethod;
|
||||||
|
} catch (\ReflectionException $e) {
|
||||||
|
// This typically occurs if class_exists passed but ReflectionMethod still fails (rare)
|
||||||
|
throw new InvalidArgumentException(\sprintf('Reflection error for handler "%s::%s": %s', $className, $methodName, $e->getMessage()), 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
787
src/Capability/Discovery/SchemaGenerator.php
Normal file
787
src/Capability/Discovery/SchemaGenerator.php
Normal file
|
|
@ -0,0 +1,787 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Discovery;
|
||||||
|
|
||||||
|
use Mcp\Capability\Attribute\Schema;
|
||||||
|
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates JSON Schema for method parameters with intelligent Schema attribute handling.
|
||||||
|
*
|
||||||
|
* Priority system:
|
||||||
|
* 1. Schema attributes (method-level and parameter-level)
|
||||||
|
* 2. Reflection type information
|
||||||
|
* 3. DocBlock type information
|
||||||
|
*
|
||||||
|
* @phpstan-import-type SchemaAttributeData from Schema
|
||||||
|
*
|
||||||
|
* @phpstan-type ParameterInfo array{
|
||||||
|
* name: string,
|
||||||
|
* doc_block_tag: Param|null,
|
||||||
|
* reflection_param: \ReflectionParameter,
|
||||||
|
* reflection_type_object: \ReflectionType|null,
|
||||||
|
* type_string: string,
|
||||||
|
* description: string|null,
|
||||||
|
* required: bool,
|
||||||
|
* allows_null: bool,
|
||||||
|
* default_value: mixed|null,
|
||||||
|
* has_default: bool,
|
||||||
|
* is_variadic: bool,
|
||||||
|
* parameter_schema: array<string, mixed>
|
||||||
|
* }
|
||||||
|
* @phpstan-type InferredParameterSchema array{
|
||||||
|
* type?: string|array,
|
||||||
|
* description?: string,
|
||||||
|
* default?: mixed,
|
||||||
|
* enum?: array<string|int>,
|
||||||
|
* items?: array<string, mixed>,
|
||||||
|
* }
|
||||||
|
* @phpstan-type VariadicParameterSchema array{
|
||||||
|
* type: 'array',
|
||||||
|
* items?: array<string, mixed>,
|
||||||
|
* description?: string,
|
||||||
|
* parameter_schema?: array<string, mixed>
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
class SchemaGenerator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DocBlockParser $docBlockParser,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a JSON Schema object (as a PHP array) for a method's or function's parameters.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function generate(\ReflectionMethod|\ReflectionFunction $reflection): array
|
||||||
|
{
|
||||||
|
$methodSchema = $this->extractMethodLevelSchema($reflection);
|
||||||
|
|
||||||
|
if ($methodSchema && isset($methodSchema['definition'])) {
|
||||||
|
return $methodSchema['definition'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parametersInfo = $this->parseParametersInfo($reflection);
|
||||||
|
|
||||||
|
return $this->buildSchemaFromParameters($parametersInfo, $methodSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts method-level or function-level Schema attribute.
|
||||||
|
*
|
||||||
|
* @return SchemaAttributeData
|
||||||
|
*/
|
||||||
|
private function extractMethodLevelSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array
|
||||||
|
{
|
||||||
|
$schemaAttrs = $reflection->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF);
|
||||||
|
if (empty($schemaAttrs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Schema $schemaAttr */
|
||||||
|
$schemaAttr = $schemaAttrs[0]->newInstance();
|
||||||
|
|
||||||
|
return $schemaAttr->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts parameter-level Schema attribute.
|
||||||
|
*
|
||||||
|
* @return SchemaAttributeData
|
||||||
|
*/
|
||||||
|
private function extractParameterLevelSchema(\ReflectionParameter $parameter): array
|
||||||
|
{
|
||||||
|
$schemaAttrs = $parameter->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF);
|
||||||
|
if (empty($schemaAttrs)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Schema $schemaAttr */
|
||||||
|
$schemaAttr = $schemaAttrs[0]->newInstance();
|
||||||
|
|
||||||
|
return $schemaAttr->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the final schema from parameter information and method-level schema.
|
||||||
|
*
|
||||||
|
* @param ParameterInfo[] $parametersInfo
|
||||||
|
* @param SchemaAttributeData $methodSchema
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function buildSchemaFromParameters(array $parametersInfo, ?array $methodSchema): array
|
||||||
|
{
|
||||||
|
$schema = [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [],
|
||||||
|
'required' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apply method-level schema as base
|
||||||
|
if ($methodSchema) {
|
||||||
|
$schema = array_merge($schema, $methodSchema);
|
||||||
|
if (!isset($schema['type'])) {
|
||||||
|
$schema['type'] = 'object';
|
||||||
|
}
|
||||||
|
if (!isset($schema['properties'])) {
|
||||||
|
$schema['properties'] = [];
|
||||||
|
}
|
||||||
|
if (!isset($schema['required'])) {
|
||||||
|
$schema['required'] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($parametersInfo as $paramInfo) {
|
||||||
|
$paramName = $paramInfo['name'];
|
||||||
|
|
||||||
|
$methodLevelParamSchema = $schema['properties'][$paramName] ?? null;
|
||||||
|
|
||||||
|
$paramSchema = $this->buildParameterSchema($paramInfo, $methodLevelParamSchema);
|
||||||
|
|
||||||
|
$schema['properties'][$paramName] = $paramSchema;
|
||||||
|
|
||||||
|
if ($paramInfo['required'] && !\in_array($paramName, $schema['required'])) {
|
||||||
|
$schema['required'][] = $paramName;
|
||||||
|
} elseif (!$paramInfo['required'] && ($key = array_search($paramName, $schema['required'])) !== false) {
|
||||||
|
unset($schema['required'][$key]);
|
||||||
|
$schema['required'] = array_values($schema['required']); // Re-index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty properties
|
||||||
|
if (empty($schema['properties'])) {
|
||||||
|
$schema['properties'] = new \stdClass();
|
||||||
|
}
|
||||||
|
if (empty($schema['required'])) {
|
||||||
|
unset($schema['required']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the final schema for a single parameter by merging all three levels.
|
||||||
|
*
|
||||||
|
* @param ParameterInfo $paramInfo
|
||||||
|
* @param array<string, mixed>|null $methodLevelParamSchema
|
||||||
|
*/
|
||||||
|
private function buildParameterSchema(array $paramInfo, ?array $methodLevelParamSchema): array
|
||||||
|
{
|
||||||
|
if ($paramInfo['is_variadic']) {
|
||||||
|
return $this->buildVariadicParameterSchema($paramInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
$inferredSchema = $this->buildInferredParameterSchema($paramInfo);
|
||||||
|
|
||||||
|
// Method-level takes precedence over inferred schema
|
||||||
|
$mergedSchema = $inferredSchema;
|
||||||
|
if ($methodLevelParamSchema) {
|
||||||
|
$mergedSchema = array_merge($inferredSchema, $methodLevelParamSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameter-level takes highest precedence
|
||||||
|
$parameterLevelSchema = $paramInfo['parameter_schema'];
|
||||||
|
if (!empty($parameterLevelSchema)) {
|
||||||
|
$mergedSchema = array_merge($mergedSchema, $parameterLevelSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mergedSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds parameter schema from inferred type and docblock information only.
|
||||||
|
* Returns empty array for variadic parameters (handled separately).
|
||||||
|
*
|
||||||
|
* @param ParameterInfo $paramInfo
|
||||||
|
*
|
||||||
|
* @return InferredParameterSchema
|
||||||
|
*/
|
||||||
|
private function buildInferredParameterSchema(array $paramInfo): array
|
||||||
|
{
|
||||||
|
$paramSchema = [];
|
||||||
|
|
||||||
|
// Variadic parameters are handled separately
|
||||||
|
if ($paramInfo['is_variadic']) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer JSON Schema types
|
||||||
|
$jsonTypes = $this->inferParameterTypes($paramInfo);
|
||||||
|
|
||||||
|
if (1 === \count($jsonTypes)) {
|
||||||
|
$paramSchema['type'] = $jsonTypes[0];
|
||||||
|
} elseif (\count($jsonTypes) > 1) {
|
||||||
|
$paramSchema['type'] = $jsonTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add description from docblock
|
||||||
|
if ($paramInfo['description']) {
|
||||||
|
$paramSchema['description'] = $paramInfo['description'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add default value only if parameter actually has a default
|
||||||
|
if ($paramInfo['has_default']) {
|
||||||
|
$paramSchema['default'] = $paramInfo['default_value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle enums
|
||||||
|
$paramSchema = $this->applyEnumConstraints($paramSchema, $paramInfo);
|
||||||
|
|
||||||
|
// Handle array items
|
||||||
|
return $this->applyArrayConstraints($paramSchema, $paramInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds schema for variadic parameters.
|
||||||
|
*
|
||||||
|
* @param ParameterInfo $paramInfo
|
||||||
|
*
|
||||||
|
* @return VariadicParameterSchema
|
||||||
|
*/
|
||||||
|
private function buildVariadicParameterSchema(array $paramInfo): array
|
||||||
|
{
|
||||||
|
$paramSchema = ['type' => 'array'];
|
||||||
|
|
||||||
|
// Apply parameter-level Schema attributes first
|
||||||
|
if (!empty($paramInfo['parameter_schema'])) {
|
||||||
|
$paramSchema = array_merge($paramSchema, $paramInfo['parameter_schema']);
|
||||||
|
// Ensure type is always array for variadic
|
||||||
|
$paramSchema['type'] = 'array';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($paramInfo['description']) {
|
||||||
|
$paramSchema['description'] = $paramInfo['description'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no items specified by Schema attribute, infer from type
|
||||||
|
if (!isset($paramSchema['items'])) {
|
||||||
|
$itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']);
|
||||||
|
$nonNullItemTypes = array_filter($itemJsonTypes, fn ($t) => 'null' !== $t);
|
||||||
|
|
||||||
|
if (1 === \count($nonNullItemTypes)) {
|
||||||
|
$paramSchema['items'] = ['type' => $nonNullItemTypes[0]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $paramSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers JSON Schema types for a parameter.
|
||||||
|
*
|
||||||
|
* @param ParameterInfo $paramInfo
|
||||||
|
*/
|
||||||
|
private function inferParameterTypes(array $paramInfo): array
|
||||||
|
{
|
||||||
|
$jsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']);
|
||||||
|
|
||||||
|
if ($paramInfo['allows_null'] && 'mixed' !== strtolower($paramInfo['type_string']) && !\in_array('null', $jsonTypes)) {
|
||||||
|
$jsonTypes[] = 'null';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\count($jsonTypes) > 1) {
|
||||||
|
// Sort but ensure null comes first for consistency
|
||||||
|
$nullIndex = array_search('null', $jsonTypes);
|
||||||
|
if (false !== $nullIndex) {
|
||||||
|
unset($jsonTypes[$nullIndex]);
|
||||||
|
sort($jsonTypes);
|
||||||
|
array_unshift($jsonTypes, 'null');
|
||||||
|
} else {
|
||||||
|
sort($jsonTypes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $jsonTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies enum constraints to parameter schema.
|
||||||
|
*/
|
||||||
|
private function applyEnumConstraints(array $paramSchema, array $paramInfo): array
|
||||||
|
{
|
||||||
|
$reflectionType = $paramInfo['reflection_type_object'];
|
||||||
|
|
||||||
|
if (!($reflectionType instanceof \ReflectionNamedType) || $reflectionType->isBuiltin() || !enum_exists($reflectionType->getName())) {
|
||||||
|
return $paramSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
$enumClass = $reflectionType->getName();
|
||||||
|
$enumReflection = new \ReflectionEnum($enumClass);
|
||||||
|
$backingTypeReflection = $enumReflection->getBackingType();
|
||||||
|
|
||||||
|
if ($enumReflection->isBacked() && $backingTypeReflection instanceof \ReflectionNamedType) {
|
||||||
|
$paramSchema['enum'] = array_column($enumClass::cases(), 'value');
|
||||||
|
$jsonBackingType = match ($backingTypeReflection->getName()) {
|
||||||
|
'int' => 'integer',
|
||||||
|
'string' => 'string',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($jsonBackingType) {
|
||||||
|
if (isset($paramSchema['type']) && \is_array($paramSchema['type']) && \in_array('null', $paramSchema['type'])) {
|
||||||
|
$paramSchema['type'] = ['null', $jsonBackingType];
|
||||||
|
} else {
|
||||||
|
$paramSchema['type'] = $jsonBackingType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-backed enum - use names as enum values
|
||||||
|
$paramSchema['enum'] = array_column($enumClass::cases(), 'name');
|
||||||
|
if (isset($paramSchema['type']) && \is_array($paramSchema['type']) && \in_array('null', $paramSchema['type'])) {
|
||||||
|
$paramSchema['type'] = ['null', 'string'];
|
||||||
|
} else {
|
||||||
|
$paramSchema['type'] = 'string';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $paramSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies array-specific constraints to parameter schema.
|
||||||
|
*/
|
||||||
|
private function applyArrayConstraints(array $paramSchema, array $paramInfo): array
|
||||||
|
{
|
||||||
|
if (!isset($paramSchema['type'])) {
|
||||||
|
return $paramSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
$typeString = $paramInfo['type_string'];
|
||||||
|
$allowsNull = $paramInfo['allows_null'];
|
||||||
|
|
||||||
|
// Handle object-like arrays using array{} syntax
|
||||||
|
if (preg_match('/^array\s*{/i', $typeString)) {
|
||||||
|
$objectSchema = $this->inferArrayItemsType($typeString);
|
||||||
|
if (\is_array($objectSchema) && isset($objectSchema['properties'])) {
|
||||||
|
$paramSchema = array_merge($paramSchema, $objectSchema);
|
||||||
|
$paramSchema['type'] = $allowsNull ? ['object', 'null'] : 'object';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle regular arrays
|
||||||
|
elseif (\in_array('array', $this->mapPhpTypeToJsonSchemaType($typeString))) {
|
||||||
|
$itemsType = $this->inferArrayItemsType($typeString);
|
||||||
|
if ('any' !== $itemsType) {
|
||||||
|
if (\is_string($itemsType)) {
|
||||||
|
$paramSchema['items'] = ['type' => $itemsType];
|
||||||
|
} else {
|
||||||
|
if (!isset($itemsType['type']) && isset($itemsType['properties'])) {
|
||||||
|
$itemsType = array_merge(['type' => 'object'], $itemsType);
|
||||||
|
}
|
||||||
|
$paramSchema['items'] = $itemsType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($allowsNull) {
|
||||||
|
$paramSchema['type'] = ['array', 'null'];
|
||||||
|
sort($paramSchema['type']);
|
||||||
|
} else {
|
||||||
|
$paramSchema['type'] = 'array';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $paramSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses detailed information about a method's parameters.
|
||||||
|
*
|
||||||
|
* @return ParameterInfo[]
|
||||||
|
*/
|
||||||
|
private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $reflection): array
|
||||||
|
{
|
||||||
|
$docComment = $reflection->getDocComment() ?: null;
|
||||||
|
$docBlock = $this->docBlockParser->parseDocBlock($docComment);
|
||||||
|
$paramTags = $this->docBlockParser->getParamTags($docBlock);
|
||||||
|
$parametersInfo = [];
|
||||||
|
|
||||||
|
foreach ($reflection->getParameters() as $rp) {
|
||||||
|
$paramName = $rp->getName();
|
||||||
|
$paramTag = $paramTags['$'.$paramName] ?? null;
|
||||||
|
|
||||||
|
$reflectionType = $rp->getType();
|
||||||
|
$typeString = $this->getParameterTypeString($rp, $paramTag);
|
||||||
|
$description = $this->docBlockParser->getParamDescription($paramTag);
|
||||||
|
$hasDefault = $rp->isDefaultValueAvailable();
|
||||||
|
$defaultValue = $hasDefault ? $rp->getDefaultValue() : null;
|
||||||
|
$isVariadic = $rp->isVariadic();
|
||||||
|
|
||||||
|
$parameterSchema = $this->extractParameterLevelSchema($rp);
|
||||||
|
|
||||||
|
if ($defaultValue instanceof \BackedEnum) {
|
||||||
|
$defaultValue = $defaultValue->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($defaultValue instanceof \UnitEnum) {
|
||||||
|
$defaultValue = $defaultValue->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowsNull = false;
|
||||||
|
if ($reflectionType && $reflectionType->allowsNull()) {
|
||||||
|
$allowsNull = true;
|
||||||
|
} elseif ($hasDefault && null === $defaultValue) {
|
||||||
|
$allowsNull = true;
|
||||||
|
} elseif (str_contains($typeString, 'null') || 'mixed' === strtolower($typeString)) {
|
||||||
|
$allowsNull = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parametersInfo[] = [
|
||||||
|
'name' => $paramName,
|
||||||
|
'doc_block_tag' => $paramTag,
|
||||||
|
'reflection_param' => $rp,
|
||||||
|
'reflection_type_object' => $reflectionType,
|
||||||
|
'type_string' => $typeString,
|
||||||
|
'description' => $description,
|
||||||
|
'required' => !$rp->isOptional(),
|
||||||
|
'allows_null' => $allowsNull,
|
||||||
|
'default_value' => $defaultValue,
|
||||||
|
'has_default' => $hasDefault,
|
||||||
|
'is_variadic' => $isVariadic,
|
||||||
|
'parameter_schema' => $parameterSchema,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parametersInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the type string for a parameter, prioritizing DocBlock.
|
||||||
|
*/
|
||||||
|
private function getParameterTypeString(\ReflectionParameter $rp, ?Param $paramTag): string
|
||||||
|
{
|
||||||
|
$docBlockType = $this->docBlockParser->getParamTypeString($paramTag);
|
||||||
|
$isDocBlockTypeGeneric = false;
|
||||||
|
|
||||||
|
if (null !== $docBlockType) {
|
||||||
|
if (\in_array(strtolower($docBlockType), ['mixed', 'unknown', ''])) {
|
||||||
|
$isDocBlockTypeGeneric = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$isDocBlockTypeGeneric = true; // No tag or no type in tag implies generic
|
||||||
|
}
|
||||||
|
|
||||||
|
$reflectionType = $rp->getType();
|
||||||
|
$reflectionTypeString = null;
|
||||||
|
if ($reflectionType) {
|
||||||
|
$reflectionTypeString = $this->getTypeStringFromReflection($reflectionType, $rp->allowsNull());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prioritize Reflection if DocBlock type is generic AND Reflection provides a more specific type
|
||||||
|
if ($isDocBlockTypeGeneric && null !== $reflectionTypeString && 'mixed' !== $reflectionTypeString) {
|
||||||
|
return $reflectionTypeString;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use the DocBlock type if it was valid and non-generic
|
||||||
|
if (null !== $docBlockType && !$isDocBlockTypeGeneric) {
|
||||||
|
// Consider if DocBlock adds nullability missing from reflection
|
||||||
|
if (false !== stripos($docBlockType, 'null') && $reflectionTypeString && false === stripos($reflectionTypeString, 'null') && !str_ends_with($reflectionTypeString, '|null')) {
|
||||||
|
// If reflection didn't capture null, but docblock did, append |null (if not already mixed)
|
||||||
|
if ('mixed' !== $reflectionTypeString) {
|
||||||
|
return $reflectionTypeString.'|null';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $docBlockType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Reflection type even if it was generic ('mixed')
|
||||||
|
if (null !== $reflectionTypeString) {
|
||||||
|
return $reflectionTypeString;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to 'mixed' if nothing else found
|
||||||
|
return 'mixed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a ReflectionType object into a type string representation.
|
||||||
|
*/
|
||||||
|
private function getTypeStringFromReflection(?\ReflectionType $type, bool $nativeAllowsNull): string
|
||||||
|
{
|
||||||
|
if (null === $type) {
|
||||||
|
return 'mixed';
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = [];
|
||||||
|
if ($type instanceof \ReflectionUnionType) {
|
||||||
|
foreach ($type->getTypes() as $innerType) {
|
||||||
|
$types[] = $this->getTypeStringFromReflection($innerType, $innerType->allowsNull());
|
||||||
|
}
|
||||||
|
if ($nativeAllowsNull) {
|
||||||
|
$types = array_filter($types, fn ($t) => 'null' !== strtolower($t));
|
||||||
|
}
|
||||||
|
$typeString = implode('|', array_unique(array_filter($types)));
|
||||||
|
} elseif ($type instanceof \ReflectionIntersectionType) {
|
||||||
|
foreach ($type->getTypes() as $innerType) {
|
||||||
|
$types[] = $this->getTypeStringFromReflection($innerType, false);
|
||||||
|
}
|
||||||
|
$typeString = implode('&', array_unique(array_filter($types)));
|
||||||
|
} elseif ($type instanceof \ReflectionNamedType) {
|
||||||
|
$typeString = $type->getName();
|
||||||
|
} else {
|
||||||
|
return 'mixed';
|
||||||
|
}
|
||||||
|
|
||||||
|
$typeString = match (strtolower($typeString)) {
|
||||||
|
'bool' => 'boolean',
|
||||||
|
'int' => 'integer',
|
||||||
|
'float', 'double' => 'number',
|
||||||
|
'str' => 'string',
|
||||||
|
default => $typeString,
|
||||||
|
};
|
||||||
|
|
||||||
|
$isNullable = $nativeAllowsNull;
|
||||||
|
if ($type instanceof \ReflectionNamedType && 'mixed' === $type->getName()) {
|
||||||
|
$isNullable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type instanceof \ReflectionUnionType && !$nativeAllowsNull) {
|
||||||
|
foreach ($type->getTypes() as $innerType) {
|
||||||
|
if ($innerType instanceof \ReflectionNamedType && 'null' === strtolower($innerType->getName())) {
|
||||||
|
$isNullable = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isNullable && 'mixed' !== $typeString && false === stripos($typeString, 'null')) {
|
||||||
|
if (!str_ends_with($typeString, '|null') && !str_ends_with($typeString, '&null')) {
|
||||||
|
$typeString .= '|null';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove leading backslash from class names, but handle built-ins like 'int' or unions like 'int|string'
|
||||||
|
if (str_contains($typeString, '\\')) {
|
||||||
|
$parts = preg_split('/([|&])/', $typeString, -1, \PREG_SPLIT_DELIM_CAPTURE);
|
||||||
|
$processedParts = array_map(fn ($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts);
|
||||||
|
$typeString = implode('', $processedParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $typeString ?: 'mixed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a PHP type string (potentially a union) to an array of JSON Schema type names.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array
|
||||||
|
{
|
||||||
|
$normalizedType = strtolower(trim($phpTypeString));
|
||||||
|
|
||||||
|
// PRIORITY 1: Check for array{} syntax which should be treated as object
|
||||||
|
if (preg_match('/^array\s*{/i', $normalizedType)) {
|
||||||
|
return ['object'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRIORITY 2: Check for array syntax first (T[] or generics)
|
||||||
|
if (
|
||||||
|
str_contains($normalizedType, '[]')
|
||||||
|
|| preg_match('/^(array|list|iterable|collection)</i', $normalizedType)
|
||||||
|
) {
|
||||||
|
return ['array'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRIORITY 3: Handle unions (recursive)
|
||||||
|
if (str_contains($normalizedType, '|')) {
|
||||||
|
$types = explode('|', $normalizedType);
|
||||||
|
$jsonTypes = [];
|
||||||
|
foreach ($types as $type) {
|
||||||
|
$mapped = $this->mapPhpTypeToJsonSchemaType(trim($type));
|
||||||
|
$jsonTypes = array_merge($jsonTypes, $mapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($jsonTypes));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRIORITY 4: Handle simple built-in types
|
||||||
|
return match ($normalizedType) {
|
||||||
|
'string', 'scalar' => ['string'],
|
||||||
|
'?string' => ['null', 'string'],
|
||||||
|
'int', 'integer' => ['integer'],
|
||||||
|
'?int', '?integer' => ['null', 'integer'],
|
||||||
|
'float', 'double', 'number' => ['number'],
|
||||||
|
'?float', '?double', '?number' => ['null', 'number'],
|
||||||
|
'bool', 'boolean' => ['boolean'],
|
||||||
|
'?bool', '?boolean' => ['null', 'boolean'],
|
||||||
|
'array' => ['array'],
|
||||||
|
'?array' => ['null', 'array'],
|
||||||
|
'object', 'stdclass' => ['object'],
|
||||||
|
'?object', '?stdclass' => ['null', 'object'],
|
||||||
|
'null' => ['null'],
|
||||||
|
'resource', 'callable' => ['object'],
|
||||||
|
'mixed' => [],
|
||||||
|
'void', 'never' => [],
|
||||||
|
default => ['object'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers the 'items' schema type for an array based on DocBlock type hints.
|
||||||
|
*
|
||||||
|
* @return string|array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function inferArrayItemsType(string $phpTypeString): string|array
|
||||||
|
{
|
||||||
|
$normalizedType = trim($phpTypeString);
|
||||||
|
|
||||||
|
// Case 1: Simple T[] syntax (e.g., string[], int[], bool[], etc.)
|
||||||
|
if (preg_match('/^(\\??)([\w\\\\]+)\\s*\\[\\]$/i', $normalizedType, $matches)) {
|
||||||
|
$itemType = strtolower($matches[2]);
|
||||||
|
|
||||||
|
return $this->mapSimpleTypeToJsonSchema($itemType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Generic array<T> syntax (e.g., array<string>, array<int>, etc.)
|
||||||
|
if (preg_match('/^(\\??)array\s*<\s*([\w\\\\|]+)\s*>$/i', $normalizedType, $matches)) {
|
||||||
|
$itemType = strtolower($matches[2]);
|
||||||
|
|
||||||
|
return $this->mapSimpleTypeToJsonSchema($itemType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: Nested array<array<T>> syntax or T[][] syntax
|
||||||
|
if (
|
||||||
|
preg_match('/^(\\??)array\s*<\s*array\s*<\s*([\w\\\\|]+)\s*>\s*>$/i', $normalizedType, $matches)
|
||||||
|
|| preg_match('/^(\\??)([\w\\\\]+)\s*\[\]\[\]$/i', $normalizedType, $matches)
|
||||||
|
) {
|
||||||
|
$innerType = $this->mapSimpleTypeToJsonSchema(isset($matches[2]) ? strtolower($matches[2]) : 'any'); /* @phpstan-ignore isset.offset */
|
||||||
|
|
||||||
|
// Return a schema for array with items being arrays
|
||||||
|
return [
|
||||||
|
'type' => 'array',
|
||||||
|
'items' => [
|
||||||
|
'type' => $innerType,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 4: Object-like array syntax (e.g., array{name: string, age: int})
|
||||||
|
if (preg_match('/^(\\??)array\s*\{(.+)\}$/is', $normalizedType, $matches)) {
|
||||||
|
return $this->parseObjectLikeArray($matches[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'any';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses object-like array syntax into a JSON Schema object.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* type: 'object',
|
||||||
|
* properties?: array<string, mixed>,
|
||||||
|
* required?: array<string>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function parseObjectLikeArray(string $propertiesStr): array
|
||||||
|
{
|
||||||
|
$properties = [];
|
||||||
|
$required = [];
|
||||||
|
|
||||||
|
// Parse properties from the string, handling nested structures
|
||||||
|
$depth = 0;
|
||||||
|
$buffer = '';
|
||||||
|
|
||||||
|
for ($i = 0; $i < \strlen($propertiesStr); ++$i) {
|
||||||
|
$char = $propertiesStr[$i];
|
||||||
|
|
||||||
|
// Track nested braces
|
||||||
|
if ('{' === $char) {
|
||||||
|
++$depth;
|
||||||
|
$buffer .= $char;
|
||||||
|
} elseif ('}' === $char) {
|
||||||
|
--$depth;
|
||||||
|
$buffer .= $char;
|
||||||
|
}
|
||||||
|
// Property separator (comma)
|
||||||
|
elseif (',' === $char && 0 === $depth) {
|
||||||
|
// Process the completed property
|
||||||
|
$this->parsePropertyDefinition(trim($buffer), $properties, $required);
|
||||||
|
$buffer = '';
|
||||||
|
} else {
|
||||||
|
$buffer .= $char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the last property
|
||||||
|
if (!empty($buffer)) {
|
||||||
|
$this->parsePropertyDefinition(trim($buffer), $properties, $required);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($properties)) {
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => $properties,
|
||||||
|
'required' => $required,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['type' => 'object'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a single property definition from an object-like array syntax.
|
||||||
|
*/
|
||||||
|
private function parsePropertyDefinition(string $propDefinition, array &$properties, array &$required): void
|
||||||
|
{
|
||||||
|
// Match property name and type
|
||||||
|
if (preg_match('/^([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s*:\s*(.+)$/i', $propDefinition, $matches)) {
|
||||||
|
$propName = $matches[1];
|
||||||
|
$propType = trim($matches[2]);
|
||||||
|
|
||||||
|
// Add to required properties
|
||||||
|
$required[] = $propName;
|
||||||
|
|
||||||
|
// Check for nested array{} syntax
|
||||||
|
if (preg_match('/^array\s*\{(.+)\}$/is', $propType, $nestedMatches)) {
|
||||||
|
$nestedSchema = $this->parseObjectLikeArray($nestedMatches[1]);
|
||||||
|
$properties[$propName] = $nestedSchema;
|
||||||
|
}
|
||||||
|
// Check for array<T> or T[] syntax
|
||||||
|
elseif (
|
||||||
|
preg_match('/^array\s*<\s*([\w\\\\|]+)\s*>$/i', $propType, $arrayMatches)
|
||||||
|
|| preg_match('/^([\w\\\\]+)\s*\[\]$/i', $propType, $arrayMatches)
|
||||||
|
) {
|
||||||
|
$itemType = $arrayMatches[1] ?? 'any'; /* @phpstan-ignore nullCoalesce.offset */
|
||||||
|
$properties[$propName] = [
|
||||||
|
'type' => 'array',
|
||||||
|
'items' => [
|
||||||
|
'type' => $this->mapSimpleTypeToJsonSchema($itemType),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// Simple type
|
||||||
|
else {
|
||||||
|
$properties[$propName] = ['type' => $this->mapSimpleTypeToJsonSchema($propType)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to map basic PHP types to JSON Schema types.
|
||||||
|
*/
|
||||||
|
private function mapSimpleTypeToJsonSchema(string $type): string
|
||||||
|
{
|
||||||
|
return match (strtolower($type)) {
|
||||||
|
'string' => 'string',
|
||||||
|
'int', 'integer' => 'integer',
|
||||||
|
'bool', 'boolean' => 'boolean',
|
||||||
|
'float', 'double', 'number' => 'number',
|
||||||
|
'array' => 'array',
|
||||||
|
'object', 'stdclass' => 'object',
|
||||||
|
default => \in_array(strtolower($type), ['datetime', 'datetimeinterface']) ? 'string' : 'object',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
336
src/Capability/Discovery/SchemaValidator.php
Normal file
336
src/Capability/Discovery/SchemaValidator.php
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Discovery;
|
||||||
|
|
||||||
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
|
use Opis\JsonSchema\Errors\ValidationError;
|
||||||
|
use Opis\JsonSchema\Validator;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates data against JSON Schema definitions using opis/json-schema.
|
||||||
|
*
|
||||||
|
* @phpstan-type Error array{
|
||||||
|
* pointer: string,
|
||||||
|
* keyword: string,
|
||||||
|
* message: string,
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
class SchemaValidator
|
||||||
|
{
|
||||||
|
private ?Validator $jsonSchemaValidator = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private LoggerInterface $logger = new NullLogger(),
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates data against a JSON schema.
|
||||||
|
*
|
||||||
|
* @param mixed $data the data to validate (should generally be decoded JSON)
|
||||||
|
* @param array<string, mixed>|object $schema the JSON Schema definition (as PHP array or object)
|
||||||
|
*
|
||||||
|
* @return list<array{pointer: string, keyword: string, message: string}> array of validation errors, empty if valid
|
||||||
|
*/
|
||||||
|
public function validateAgainstJsonSchema(mixed $data, array|object $schema): array
|
||||||
|
{
|
||||||
|
if (\is_array($data) && empty($data)) {
|
||||||
|
$data = new \stdClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// --- Schema Preparation ---
|
||||||
|
if (\is_array($schema)) {
|
||||||
|
$schemaJson = json_encode($schema, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
|
||||||
|
$schemaObject = json_decode($schemaJson, false, 512, \JSON_THROW_ON_ERROR);
|
||||||
|
} elseif (\is_object($schema)) {
|
||||||
|
// This might be overly cautious but safer against varied inputs.
|
||||||
|
$schemaJson = json_encode($schema, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
|
||||||
|
$schemaObject = json_decode($schemaJson, false, 512, \JSON_THROW_ON_ERROR);
|
||||||
|
} else {
|
||||||
|
throw new InvalidArgumentException('Schema must be an array or object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data Preparation ---
|
||||||
|
// Opis Validator generally prefers objects for object validation
|
||||||
|
$dataToValidate = $this->convertDataForValidator($data);
|
||||||
|
} catch (\JsonException $e) {
|
||||||
|
$this->logger->error('MCP SDK: Invalid schema structure provided for validation (JSON conversion failed).', ['exception' => $e]);
|
||||||
|
|
||||||
|
return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Invalid schema definition provided (JSON error).']];
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
$this->logger->error('MCP SDK: Invalid schema structure provided for validation.', ['exception' => $e]);
|
||||||
|
|
||||||
|
return [['pointer' => '', 'keyword' => 'internal', 'message' => $e->getMessage()]];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->error('MCP SDK: Error preparing data/schema for validation.', ['exception' => $e]);
|
||||||
|
|
||||||
|
return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Internal validation preparation error.']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$validator = $this->getJsonSchemaValidator();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $validator->validate($dataToValidate, $schemaObject);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->error('MCP SDK: JSON Schema validation failed internally.', [
|
||||||
|
'exception_message' => $e->getMessage(),
|
||||||
|
'exception_trace' => $e->getTraceAsString(),
|
||||||
|
'data' => json_encode($dataToValidate),
|
||||||
|
'schema' => json_encode($schemaObject),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: '.$e->getMessage()]];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->isValid()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$formattedErrors = [];
|
||||||
|
$topError = $result->error();
|
||||||
|
|
||||||
|
if ($topError) {
|
||||||
|
$this->collectSubErrors($topError, $formattedErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($formattedErrors) && $topError) { // Fallback
|
||||||
|
$formattedErrors[] = [
|
||||||
|
'pointer' => $this->formatJsonPointerPath($topError->data()->path()),
|
||||||
|
'keyword' => $topError->keyword(),
|
||||||
|
'message' => $this->formatValidationError($topError),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formattedErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the JSON Schema validator instance.
|
||||||
|
*/
|
||||||
|
private function getJsonSchemaValidator(): Validator
|
||||||
|
{
|
||||||
|
if (null === $this->jsonSchemaValidator) {
|
||||||
|
$this->jsonSchemaValidator = new Validator();
|
||||||
|
// Potentially configure resolver here if needed later
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->jsonSchemaValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively converts associative arrays to stdClass objects for validator compatibility.
|
||||||
|
*/
|
||||||
|
private function convertDataForValidator(mixed $data): mixed
|
||||||
|
{
|
||||||
|
if (\is_array($data)) {
|
||||||
|
// Check if it's an associative array (keys are not sequential numbers 0..N-1)
|
||||||
|
if (!empty($data) && array_keys($data) !== range(0, \count($data) - 1)) {
|
||||||
|
$obj = new \stdClass();
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$obj->{$key} = $this->convertDataForValidator($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $obj;
|
||||||
|
} else {
|
||||||
|
// It's a list (sequential array), convert items recursively
|
||||||
|
return array_map([$this, 'convertDataForValidator'], $data);
|
||||||
|
}
|
||||||
|
} elseif (\is_object($data) && $data instanceof \stdClass) {
|
||||||
|
// Deep copy/convert stdClass objects as well
|
||||||
|
$obj = new \stdClass();
|
||||||
|
foreach (get_object_vars($data) as $key => $value) {
|
||||||
|
$obj->{$key} = $this->convertDataForValidator($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave other objects and scalar types as they are
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collects leaf validation errors.
|
||||||
|
*
|
||||||
|
* @param Error[] $collectedErrors
|
||||||
|
*/
|
||||||
|
private function collectSubErrors(ValidationError $error, array &$collectedErrors): void
|
||||||
|
{
|
||||||
|
$subErrors = $error->subErrors();
|
||||||
|
if (empty($subErrors)) {
|
||||||
|
$collectedErrors[] = [
|
||||||
|
'pointer' => $this->formatJsonPointerPath($error->data()->path()),
|
||||||
|
'keyword' => $error->keyword(),
|
||||||
|
'message' => $this->formatValidationError($error),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
foreach ($subErrors as $subError) {
|
||||||
|
$this->collectSubErrors($subError, $collectedErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the path array into a JSON Pointer string.
|
||||||
|
*
|
||||||
|
* @param string[]|int[]|null $pathComponents
|
||||||
|
*/
|
||||||
|
private function formatJsonPointerPath(?array $pathComponents): string
|
||||||
|
{
|
||||||
|
if (empty($pathComponents)) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
$escapedComponents = array_map(function ($component) {
|
||||||
|
$componentStr = (string) $component;
|
||||||
|
|
||||||
|
return str_replace(['~', '/'], ['~0', '~1'], $componentStr);
|
||||||
|
}, $pathComponents);
|
||||||
|
|
||||||
|
return '/'.implode('/', $escapedComponents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an Opis SchemaValidationError into a user-friendly message.
|
||||||
|
*/
|
||||||
|
private function formatValidationError(ValidationError $error): string
|
||||||
|
{
|
||||||
|
$keyword = $error->keyword();
|
||||||
|
$args = $error->args();
|
||||||
|
$message = "Constraint `{$keyword}` failed.";
|
||||||
|
|
||||||
|
switch (strtolower($keyword)) {
|
||||||
|
case 'required':
|
||||||
|
$missing = $args['missing'] ?? [];
|
||||||
|
$formattedMissing = implode(', ', array_map(fn ($p) => "`{$p}`", $missing));
|
||||||
|
$message = "Missing required properties: {$formattedMissing}.";
|
||||||
|
break;
|
||||||
|
case 'type':
|
||||||
|
$expected = implode('|', (array) ($args['expected'] ?? []));
|
||||||
|
$used = $args['used'] ?? 'unknown';
|
||||||
|
$message = "Invalid type. Expected `{$expected}`, but received `{$used}`.";
|
||||||
|
break;
|
||||||
|
case 'enum':
|
||||||
|
$schemaData = $error->schema()->info()->data();
|
||||||
|
$allowedValues = [];
|
||||||
|
if (\is_object($schemaData) && property_exists($schemaData, 'enum') && \is_array($schemaData->enum)) {
|
||||||
|
$allowedValues = $schemaData->enum;
|
||||||
|
} elseif (\is_array($schemaData) && isset($schemaData['enum']) && \is_array($schemaData['enum'])) {
|
||||||
|
$allowedValues = $schemaData['enum'];
|
||||||
|
} else {
|
||||||
|
$this->logger->warning("MCP SDK: Could not retrieve 'enum' values from schema info for error.", ['error_args' => $args]);
|
||||||
|
}
|
||||||
|
if (empty($allowedValues)) {
|
||||||
|
$message = 'Value does not match the allowed enumeration.';
|
||||||
|
} else {
|
||||||
|
$formattedAllowed = array_map(function ($v) { /* ... formatting logic ... */
|
||||||
|
if (\is_string($v)) {
|
||||||
|
return '"'.$v.'"';
|
||||||
|
}
|
||||||
|
if (\is_bool($v)) {
|
||||||
|
return $v ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
if (null === $v) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $v;
|
||||||
|
}, $allowedValues);
|
||||||
|
$message = 'Value must be one of the allowed values: '.implode(', ', $formattedAllowed).'.';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'const':
|
||||||
|
$expected = json_encode($args['expected'] ?? 'null', \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
|
||||||
|
$message = "Value must be equal to the constant value: {$expected}.";
|
||||||
|
break;
|
||||||
|
case 'minLength': // Corrected casing
|
||||||
|
$min = $args['min'] ?? '?';
|
||||||
|
$message = "String must be at least {$min} characters long.";
|
||||||
|
break;
|
||||||
|
case 'maxLength': // Corrected casing
|
||||||
|
$max = $args['max'] ?? '?';
|
||||||
|
$message = "String must not be longer than {$max} characters.";
|
||||||
|
break;
|
||||||
|
case 'pattern':
|
||||||
|
$pattern = $args['pattern'] ?? '?';
|
||||||
|
$message = "String does not match the required pattern: `{$pattern}`.";
|
||||||
|
break;
|
||||||
|
case 'minimum':
|
||||||
|
$min = $args['min'] ?? '?';
|
||||||
|
$message = "Number must be greater than or equal to {$min}.";
|
||||||
|
break;
|
||||||
|
case 'maximum':
|
||||||
|
$max = $args['max'] ?? '?';
|
||||||
|
$message = "Number must be less than or equal to {$max}.";
|
||||||
|
break;
|
||||||
|
case 'exclusiveMinimum': // Corrected casing
|
||||||
|
$min = $args['min'] ?? '?';
|
||||||
|
$message = "Number must be strictly greater than {$min}.";
|
||||||
|
break;
|
||||||
|
case 'exclusiveMaximum': // Corrected casing
|
||||||
|
$max = $args['max'] ?? '?';
|
||||||
|
$message = "Number must be strictly less than {$max}.";
|
||||||
|
break;
|
||||||
|
case 'multipleOf': // Corrected casing
|
||||||
|
$value = $args['value'] ?? '?';
|
||||||
|
$message = "Number must be a multiple of {$value}.";
|
||||||
|
break;
|
||||||
|
case 'minItems': // Corrected casing
|
||||||
|
$min = $args['min'] ?? '?';
|
||||||
|
$message = "Array must contain at least {$min} items.";
|
||||||
|
break;
|
||||||
|
case 'maxItems': // Corrected casing
|
||||||
|
$max = $args['max'] ?? '?';
|
||||||
|
$message = "Array must contain no more than {$max} items.";
|
||||||
|
break;
|
||||||
|
case 'uniqueItems': // Corrected casing
|
||||||
|
$message = 'Array items must be unique.';
|
||||||
|
break;
|
||||||
|
case 'minProperties': // Corrected casing
|
||||||
|
$min = $args['min'] ?? '?';
|
||||||
|
$message = "Object must have at least {$min} properties.";
|
||||||
|
break;
|
||||||
|
case 'maxProperties': // Corrected casing
|
||||||
|
$max = $args['max'] ?? '?';
|
||||||
|
$message = "Object must have no more than {$max} properties.";
|
||||||
|
break;
|
||||||
|
case 'additionalProperties': // Corrected casing
|
||||||
|
$unexpected = $args['properties'] ?? [];
|
||||||
|
$formattedUnexpected = implode(', ', array_map(fn ($p) => "`{$p}`", $unexpected));
|
||||||
|
$message = "Object contains unexpected additional properties: {$formattedUnexpected}.";
|
||||||
|
break;
|
||||||
|
case 'format':
|
||||||
|
$format = $args['format'] ?? 'unknown';
|
||||||
|
$message = "Value does not match the required format: `{$format}`.";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$builtInMessage = $error->message();
|
||||||
|
if ($builtInMessage && 'The data must match the schema' !== $builtInMessage) {
|
||||||
|
$placeholders = $args;
|
||||||
|
$builtInMessage = preg_replace_callback('/\{(\w+)\}/', function ($match) use ($placeholders) {
|
||||||
|
$key = $match[1];
|
||||||
|
$value = $placeholders[$key] ?? '{'.$key.'}';
|
||||||
|
|
||||||
|
return \is_array($value) ? json_encode($value) : (string) $value;
|
||||||
|
}, $builtInMessage);
|
||||||
|
$message = $builtInMessage;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/Capability/Prompt/Completion/EnumCompletionProvider.php
Normal file
52
src/Capability/Prompt/Completion/EnumCompletionProvider.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Prompt\Completion;
|
||||||
|
|
||||||
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
class EnumCompletionProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
private array $values;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param class-string $enumClass
|
||||||
|
*/
|
||||||
|
public function __construct(string $enumClass)
|
||||||
|
{
|
||||||
|
if (!enum_exists($enumClass)) {
|
||||||
|
throw new InvalidArgumentException(\sprintf('Class "%s" is not an enum.', $enumClass));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->values = array_map(
|
||||||
|
fn ($case) => isset($case->value) && \is_string($case->value) ? $case->value : $case->name,
|
||||||
|
$enumClass::cases()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCompletions(string $currentValue): array
|
||||||
|
{
|
||||||
|
if (empty($currentValue)) {
|
||||||
|
return $this->values;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->values,
|
||||||
|
fn (string $value) => str_starts_with($value, $currentValue)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/Capability/Prompt/Completion/ListCompletionProvider.php
Normal file
38
src/Capability/Prompt/Completion/ListCompletionProvider.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Prompt\Completion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
class ListCompletionProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string[] $values
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private array $values,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCompletions(string $currentValue): array
|
||||||
|
{
|
||||||
|
if (empty($currentValue)) {
|
||||||
|
return $this->values;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->values,
|
||||||
|
fn (string $value) => str_starts_with($value, $currentValue)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Capability/Prompt/Completion/ProviderInterface.php
Normal file
27
src/Capability/Prompt/Completion/ProviderInterface.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Prompt\Completion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
interface ProviderInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get completions for a given current value.
|
||||||
|
*
|
||||||
|
* @param string $currentValue the current value to get completions for
|
||||||
|
*
|
||||||
|
* @return string[] the completions
|
||||||
|
*/
|
||||||
|
public function getCompletions(string $currentValue): array;
|
||||||
|
}
|
||||||
256
src/Capability/Registry.php
Normal file
256
src/Capability/Registry.php
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability;
|
||||||
|
|
||||||
|
use Mcp\Capability\Registry\RegisteredElement;
|
||||||
|
use Mcp\Capability\Registry\RegisteredPrompt;
|
||||||
|
use Mcp\Capability\Registry\RegisteredResource;
|
||||||
|
use Mcp\Capability\Registry\RegisteredResourceTemplate;
|
||||||
|
use Mcp\Capability\Registry\RegisteredTool;
|
||||||
|
use Mcp\Event\PromptListChangedEvent;
|
||||||
|
use Mcp\Event\ResourceListChangedEvent;
|
||||||
|
use Mcp\Event\ResourceTemplateListChangedEvent;
|
||||||
|
use Mcp\Event\ToolListChangedEvent;
|
||||||
|
use Mcp\Schema\Prompt;
|
||||||
|
use Mcp\Schema\Resource;
|
||||||
|
use Mcp\Schema\ResourceTemplate;
|
||||||
|
use Mcp\Schema\Tool;
|
||||||
|
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @phpstan-import-type CallableArray from RegisteredElement
|
||||||
|
*
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
class Registry
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, RegisteredTool>
|
||||||
|
*/
|
||||||
|
private array $tools = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, RegisteredResource>
|
||||||
|
*/
|
||||||
|
private array $resources = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, RegisteredPrompt>
|
||||||
|
*/
|
||||||
|
private array $prompts = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, RegisteredResourceTemplate>
|
||||||
|
*/
|
||||||
|
private array $resourceTemplates = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ?EventDispatcherInterface $eventDispatcher = null,
|
||||||
|
private readonly LoggerInterface $logger = new NullLogger(),
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable|CallableArray|string $handler
|
||||||
|
*/
|
||||||
|
public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void
|
||||||
|
{
|
||||||
|
$toolName = $tool->name;
|
||||||
|
$existing = $this->tools[$toolName] ?? null;
|
||||||
|
|
||||||
|
if ($existing && !$isManual && $existing->isManual) {
|
||||||
|
$this->logger->debug("Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tools[$toolName] = new RegisteredTool($tool, $handler, $isManual);
|
||||||
|
|
||||||
|
$this->eventDispatcher?->dispatch(new ToolListChangedEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable|CallableArray|string $handler
|
||||||
|
*/
|
||||||
|
public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void
|
||||||
|
{
|
||||||
|
$uri = $resource->uri;
|
||||||
|
$existing = $this->resources[$uri] ?? null;
|
||||||
|
|
||||||
|
if ($existing && !$isManual && $existing->isManual) {
|
||||||
|
$this->logger->debug("Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resources[$uri] = new RegisteredResource($resource, $handler, $isManual);
|
||||||
|
|
||||||
|
$this->eventDispatcher?->dispatch(new ResourceListChangedEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable|CallableArray|string $handler
|
||||||
|
* @param array<string, class-string|object> $completionProviders
|
||||||
|
*/
|
||||||
|
public function registerResourceTemplate(
|
||||||
|
ResourceTemplate $template,
|
||||||
|
callable|array|string $handler,
|
||||||
|
array $completionProviders = [],
|
||||||
|
bool $isManual = false,
|
||||||
|
): void {
|
||||||
|
$uriTemplate = $template->uriTemplate;
|
||||||
|
$existing = $this->resourceTemplates[$uriTemplate] ?? null;
|
||||||
|
|
||||||
|
if ($existing && !$isManual && $existing->isManual) {
|
||||||
|
$this->logger->debug("Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resourceTemplates[$uriTemplate] = new RegisteredResourceTemplate($template, $handler, $isManual, $completionProviders);
|
||||||
|
|
||||||
|
$this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable|CallableArray|string $handler
|
||||||
|
* @param array<string, class-string|object> $completionProviders
|
||||||
|
*/
|
||||||
|
public function registerPrompt(
|
||||||
|
Prompt $prompt,
|
||||||
|
callable|array|string $handler,
|
||||||
|
array $completionProviders = [],
|
||||||
|
bool $isManual = false,
|
||||||
|
): void {
|
||||||
|
$promptName = $prompt->name;
|
||||||
|
$existing = $this->prompts[$promptName] ?? null;
|
||||||
|
|
||||||
|
if ($existing && !$isManual && $existing->isManual) {
|
||||||
|
$this->logger->debug("Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->prompts[$promptName] = new RegisteredPrompt($prompt, $handler, $isManual, $completionProviders);
|
||||||
|
|
||||||
|
$this->eventDispatcher?->dispatch(new PromptListChangedEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks if any elements (manual or discovered) are currently registered. */
|
||||||
|
public function hasElements(): bool
|
||||||
|
{
|
||||||
|
return !empty($this->tools)
|
||||||
|
|| !empty($this->resources)
|
||||||
|
|| !empty($this->prompts)
|
||||||
|
|| !empty($this->resourceTemplates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear discovered elements from registry.
|
||||||
|
*/
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$clearCount = 0;
|
||||||
|
|
||||||
|
foreach ($this->tools as $name => $tool) {
|
||||||
|
if (!$tool->isManual) {
|
||||||
|
unset($this->tools[$name]);
|
||||||
|
++$clearCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($this->resources as $uri => $resource) {
|
||||||
|
if (!$resource->isManual) {
|
||||||
|
unset($this->resources[$uri]);
|
||||||
|
++$clearCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($this->prompts as $name => $prompt) {
|
||||||
|
if (!$prompt->isManual) {
|
||||||
|
unset($this->prompts[$name]);
|
||||||
|
++$clearCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($this->resourceTemplates as $uriTemplate => $template) {
|
||||||
|
if (!$template->isManual) {
|
||||||
|
unset($this->resourceTemplates[$uriTemplate]);
|
||||||
|
++$clearCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($clearCount > 0) {
|
||||||
|
$this->logger->debug(\sprintf('Removed %d discovered elements from internal registry.', $clearCount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTool(string $name): ?RegisteredTool
|
||||||
|
{
|
||||||
|
return $this->tools[$name] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getResource(string $uri, bool $includeTemplates = true): RegisteredResource|RegisteredResourceTemplate|null
|
||||||
|
{
|
||||||
|
$registration = $this->resources[$uri] ?? null;
|
||||||
|
if ($registration) {
|
||||||
|
return $registration;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$includeTemplates) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->resourceTemplates as $template) {
|
||||||
|
if ($template->matches($uri)) {
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->debug('No resource matched URI.', ['uri' => $uri]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getResourceTemplate(string $uriTemplate): ?RegisteredResourceTemplate
|
||||||
|
{
|
||||||
|
return $this->resourceTemplates[$uriTemplate] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPrompt(string $name): ?RegisteredPrompt
|
||||||
|
{
|
||||||
|
return $this->prompts[$name] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, Tool> */
|
||||||
|
public function getTools(): array
|
||||||
|
{
|
||||||
|
return array_map(fn ($tool) => $tool->tool, $this->tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, resource> */
|
||||||
|
public function getResources(): array
|
||||||
|
{
|
||||||
|
return array_map(fn ($resource) => $resource->schema, $this->resources);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, Prompt> */
|
||||||
|
public function getPrompts(): array
|
||||||
|
{
|
||||||
|
return array_map(fn ($prompt) => $prompt->prompt, $this->prompts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, ResourceTemplate> */
|
||||||
|
public function getResourceTemplates(): array
|
||||||
|
{
|
||||||
|
return array_map(fn ($template) => $template->resourceTemplate, $this->resourceTemplates);
|
||||||
|
}
|
||||||
|
}
|
||||||
289
src/Capability/Registry/RegisteredElement.php
Normal file
289
src/Capability/Registry/RegisteredElement.php
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Registry;
|
||||||
|
|
||||||
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
|
use Mcp\Exception\RegistryException;
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @phpstan-type CallableArray array{0: object|string, 1: string}
|
||||||
|
*
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
class RegisteredElement implements \JsonSerializable
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var callable|CallableArray|string
|
||||||
|
*/
|
||||||
|
public readonly mixed $handler;
|
||||||
|
public readonly bool $isManual;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable|CallableArray|string $handler
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
callable|array|string $handler,
|
||||||
|
bool $isManual = false,
|
||||||
|
) {
|
||||||
|
$this->handler = $handler;
|
||||||
|
$this->isManual = $isManual;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
*/
|
||||||
|
public function handle(ContainerInterface $container, array $arguments): mixed
|
||||||
|
{
|
||||||
|
if (\is_string($this->handler)) {
|
||||||
|
if (class_exists($this->handler) && method_exists($this->handler, '__invoke')) {
|
||||||
|
$reflection = new \ReflectionMethod($this->handler, '__invoke');
|
||||||
|
$arguments = $this->prepareArguments($reflection, $arguments);
|
||||||
|
$instance = $container->get($this->handler);
|
||||||
|
|
||||||
|
return \call_user_func($instance, ...$arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\function_exists($this->handler)) {
|
||||||
|
$reflection = new \ReflectionFunction($this->handler);
|
||||||
|
$arguments = $this->prepareArguments($reflection, $arguments);
|
||||||
|
|
||||||
|
return \call_user_func($this->handler, ...$arguments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_callable($this->handler)) {
|
||||||
|
$reflection = $this->getReflectionForCallable($this->handler);
|
||||||
|
$arguments = $this->prepareArguments($reflection, $arguments);
|
||||||
|
|
||||||
|
return \call_user_func($this->handler, ...$arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_array($this->handler)) {
|
||||||
|
[$className, $methodName] = $this->handler;
|
||||||
|
$reflection = new \ReflectionMethod($className, $methodName);
|
||||||
|
$arguments = $this->prepareArguments($reflection, $arguments);
|
||||||
|
|
||||||
|
$instance = $container->get($className);
|
||||||
|
|
||||||
|
return \call_user_func([$instance, $methodName], ...$arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidArgumentException('Invalid handler type');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<int, mixed>
|
||||||
|
*/
|
||||||
|
protected function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments): array
|
||||||
|
{
|
||||||
|
$finalArgs = [];
|
||||||
|
|
||||||
|
foreach ($reflection->getParameters() as $parameter) {
|
||||||
|
// TODO: Handle variadic parameters.
|
||||||
|
$paramName = $parameter->getName();
|
||||||
|
$paramPosition = $parameter->getPosition();
|
||||||
|
|
||||||
|
if (isset($arguments[$paramName])) {
|
||||||
|
$argument = $arguments[$paramName];
|
||||||
|
try {
|
||||||
|
$finalArgs[$paramPosition] = $this->castArgumentType($argument, $parameter);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
throw RegistryException::invalidParams($e->getMessage(), $e);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
throw RegistryException::internalError("Error processing parameter `{$paramName}`: {$e->getMessage()}", $e);
|
||||||
|
}
|
||||||
|
} elseif ($parameter->isDefaultValueAvailable()) {
|
||||||
|
$finalArgs[$paramPosition] = $parameter->getDefaultValue();
|
||||||
|
} elseif ($parameter->allowsNull()) {
|
||||||
|
$finalArgs[$paramPosition] = null;
|
||||||
|
} elseif ($parameter->isOptional()) {
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
$reflectionName = $reflection instanceof \ReflectionMethod
|
||||||
|
? $reflection->class.'::'.$reflection->name
|
||||||
|
: 'Closure';
|
||||||
|
throw RegistryException::internalError("Missing required argument `{$paramName}` for {$reflectionName}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($finalArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a ReflectionMethod or ReflectionFunction for a callable.
|
||||||
|
*/
|
||||||
|
private function getReflectionForCallable(callable $handler): \ReflectionMethod|\ReflectionFunction
|
||||||
|
{
|
||||||
|
if (\is_string($handler)) {
|
||||||
|
return new \ReflectionFunction($handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($handler instanceof \Closure) {
|
||||||
|
return new \ReflectionFunction($handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_array($handler) && 2 === \count($handler)) {
|
||||||
|
[$class, $method] = $handler;
|
||||||
|
|
||||||
|
return new \ReflectionMethod($class, $method);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidArgumentException('Cannot create reflection for this callable type');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts type casting based on ReflectionParameter type hints.
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException if casting is impossible for the required type
|
||||||
|
*/
|
||||||
|
private function castArgumentType(mixed $argument, \ReflectionParameter $parameter): mixed
|
||||||
|
{
|
||||||
|
$type = $parameter->getType();
|
||||||
|
|
||||||
|
if (null === $argument) {
|
||||||
|
if ($type && $type->allowsNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$type instanceof \ReflectionNamedType) {
|
||||||
|
return $argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
$typeName = $type->getName();
|
||||||
|
|
||||||
|
if (enum_exists($typeName)) {
|
||||||
|
if (\is_object($argument) && $argument instanceof $typeName) {
|
||||||
|
return $argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_subclass_of($typeName, \BackedEnum::class)) {
|
||||||
|
$value = $typeName::tryFrom($argument);
|
||||||
|
if (null === $value) {
|
||||||
|
throw new InvalidArgumentException("Invalid value '{$argument}' for backed enum {$typeName}. Expected one of its backing values.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
} else {
|
||||||
|
if (\is_string($argument)) {
|
||||||
|
foreach ($typeName::cases() as $case) {
|
||||||
|
if ($case->name === $argument) {
|
||||||
|
return $case;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$validNames = array_map(fn ($c) => $c->name, $typeName::cases());
|
||||||
|
throw new InvalidArgumentException("Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: ".implode(', ', $validNames).'.');
|
||||||
|
} else {
|
||||||
|
throw new InvalidArgumentException("Invalid value type '{$argument}' for unit enum {$typeName}. Expected a string matching a case name.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return match (strtolower($typeName)) {
|
||||||
|
'int', 'integer' => $this->castToInt($argument),
|
||||||
|
'string' => (string) $argument,
|
||||||
|
'bool', 'boolean' => $this->castToBoolean($argument),
|
||||||
|
'float', 'double' => $this->castToFloat($argument),
|
||||||
|
'array' => $this->castToArray($argument),
|
||||||
|
default => $argument,
|
||||||
|
};
|
||||||
|
} catch (\TypeError $e) {
|
||||||
|
throw new InvalidArgumentException("Value cannot be cast to required type `{$typeName}`.", 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to cast strictly to boolean.
|
||||||
|
*/
|
||||||
|
private function castToBoolean(mixed $argument): bool
|
||||||
|
{
|
||||||
|
if (\is_bool($argument)) {
|
||||||
|
return $argument;
|
||||||
|
}
|
||||||
|
if (1 === $argument || '1' === $argument || 'true' === strtolower((string) $argument)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (0 === $argument || '0' === $argument || 'false' === strtolower((string) $argument)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidArgumentException('Cannot cast value to boolean. Use true/false/1/0.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to cast strictly to integer.
|
||||||
|
*/
|
||||||
|
private function castToInt(mixed $argument): int
|
||||||
|
{
|
||||||
|
if (\is_int($argument)) {
|
||||||
|
return $argument;
|
||||||
|
}
|
||||||
|
if (is_numeric($argument) && floor((float) $argument) == $argument && !\is_string($argument)) {
|
||||||
|
return (int) $argument;
|
||||||
|
}
|
||||||
|
if (\is_string($argument) && ctype_digit(ltrim($argument, '-'))) {
|
||||||
|
return (int) $argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidArgumentException('Cannot cast value to integer. Expected integer representation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to cast strictly to float.
|
||||||
|
*/
|
||||||
|
private function castToFloat(mixed $argument): float
|
||||||
|
{
|
||||||
|
if (\is_float($argument)) {
|
||||||
|
return $argument;
|
||||||
|
}
|
||||||
|
if (\is_int($argument)) {
|
||||||
|
return (float) $argument;
|
||||||
|
}
|
||||||
|
if (is_numeric($argument)) {
|
||||||
|
return (float) $argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidArgumentException('Cannot cast value to float. Expected numeric representation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to cast strictly to array.
|
||||||
|
*
|
||||||
|
* @return array<int, mixed>
|
||||||
|
*/
|
||||||
|
private function castToArray(mixed $argument): array
|
||||||
|
{
|
||||||
|
if (\is_array($argument)) {
|
||||||
|
return $argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidArgumentException('Cannot cast value to array. Expected array.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* handler: callable|CallableArray|string,
|
||||||
|
* isManual: bool,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'handler' => $this->handler,
|
||||||
|
'isManual' => $this->isManual,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
368
src/Capability/Registry/RegisteredPrompt.php
Normal file
368
src/Capability/Registry/RegisteredPrompt.php
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Registry;
|
||||||
|
|
||||||
|
use Mcp\Exception\RuntimeException;
|
||||||
|
use Mcp\Schema\Content\AudioContent;
|
||||||
|
use Mcp\Schema\Content\BlobResourceContents;
|
||||||
|
use Mcp\Schema\Content\Content;
|
||||||
|
use Mcp\Schema\Content\EmbeddedResource;
|
||||||
|
use Mcp\Schema\Content\ImageContent;
|
||||||
|
use Mcp\Schema\Content\PromptMessage;
|
||||||
|
use Mcp\Schema\Content\TextContent;
|
||||||
|
use Mcp\Schema\Content\TextResourceContents;
|
||||||
|
use Mcp\Schema\Enum\Role;
|
||||||
|
use Mcp\Schema\Prompt;
|
||||||
|
use Mcp\Schema\Result\CompletionCompleteResult;
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @phpstan-import-type CallableArray from RegisteredElement
|
||||||
|
*
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
class RegisteredPrompt extends RegisteredElement
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param callable|CallableArray|string $handler
|
||||||
|
* @param array<string, class-string|object> $completionProviders
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly Prompt $prompt,
|
||||||
|
callable|array|string $handler,
|
||||||
|
bool $isManual = false,
|
||||||
|
public readonly array $completionProviders = [],
|
||||||
|
) {
|
||||||
|
parent::__construct($handler, $isManual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self|false
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!isset($data['schema']) || !isset($data['handler'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$completionProviders = [];
|
||||||
|
foreach ($data['completionProviders'] ?? [] as $argument => $provider) {
|
||||||
|
$completionProviders[$argument] = unserialize($provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
Prompt::fromArray($data['schema']),
|
||||||
|
$data['handler'],
|
||||||
|
$data['isManual'] ?? false,
|
||||||
|
$completionProviders,
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the prompt messages.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return PromptMessage[]
|
||||||
|
*/
|
||||||
|
public function get(ContainerInterface $container, array $arguments): array
|
||||||
|
{
|
||||||
|
$result = $this->handle($container, $arguments);
|
||||||
|
|
||||||
|
return $this->formatResult($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(ContainerInterface $container, string $argument, string $value): CompletionCompleteResult
|
||||||
|
{
|
||||||
|
$providerClassOrInstance = $this->completionProviders[$argument] ?? null;
|
||||||
|
if (null === $providerClassOrInstance) {
|
||||||
|
return new CompletionCompleteResult([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_string($providerClassOrInstance)) {
|
||||||
|
if (!class_exists($providerClassOrInstance)) {
|
||||||
|
throw new RuntimeException("Completion provider class '{$providerClassOrInstance}' does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$provider = $container->get($providerClassOrInstance);
|
||||||
|
} else {
|
||||||
|
$provider = $providerClassOrInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
$completions = $provider->getCompletions($value);
|
||||||
|
|
||||||
|
$total = \count($completions);
|
||||||
|
$hasMore = $total > 100;
|
||||||
|
|
||||||
|
$pagedCompletions = \array_slice($completions, 0, 100);
|
||||||
|
|
||||||
|
return new CompletionCompleteResult($pagedCompletions, $total, $hasMore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the raw result of a prompt generator into an array of MCP PromptMessages.
|
||||||
|
*
|
||||||
|
* @param mixed $promptGenerationResult expected: array of message structures
|
||||||
|
*
|
||||||
|
* @return PromptMessage[] array of PromptMessage objects
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException if the result cannot be formatted
|
||||||
|
* @throws \JsonException if JSON encoding fails
|
||||||
|
*/
|
||||||
|
protected function formatResult(mixed $promptGenerationResult): array
|
||||||
|
{
|
||||||
|
if ($promptGenerationResult instanceof PromptMessage) {
|
||||||
|
return [$promptGenerationResult];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\is_array($promptGenerationResult)) {
|
||||||
|
throw new \RuntimeException('Prompt generator method must return an array of messages.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($promptGenerationResult)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_array($promptGenerationResult)) {
|
||||||
|
$allArePromptMessages = true;
|
||||||
|
$hasPromptMessages = false;
|
||||||
|
|
||||||
|
foreach ($promptGenerationResult as $item) {
|
||||||
|
if ($item instanceof PromptMessage) {
|
||||||
|
$hasPromptMessages = true;
|
||||||
|
} else {
|
||||||
|
$allArePromptMessages = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($allArePromptMessages && $hasPromptMessages) {
|
||||||
|
return $promptGenerationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasPromptMessages) {
|
||||||
|
$result = [];
|
||||||
|
foreach ($promptGenerationResult as $index => $item) {
|
||||||
|
if ($item instanceof PromptMessage) {
|
||||||
|
$result[] = $item;
|
||||||
|
} else {
|
||||||
|
$result = array_merge($result, $this->formatResult($item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!array_is_list($promptGenerationResult)) {
|
||||||
|
if (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) {
|
||||||
|
$result = [];
|
||||||
|
if (isset($promptGenerationResult['user'])) {
|
||||||
|
$userContent = $this->formatContent($promptGenerationResult['user']);
|
||||||
|
$result[] = new PromptMessage(Role::User, $userContent);
|
||||||
|
}
|
||||||
|
if (isset($promptGenerationResult['assistant'])) {
|
||||||
|
$assistantContent = $this->formatContent($promptGenerationResult['assistant']);
|
||||||
|
$result[] = new PromptMessage(Role::Assistant, $assistantContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($promptGenerationResult['role']) && isset($promptGenerationResult['content'])) {
|
||||||
|
return [$this->formatMessage($promptGenerationResult)];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('Associative array must contain either role/content keys or user/assistant keys.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$formattedMessages = [];
|
||||||
|
foreach ($promptGenerationResult as $index => $message) {
|
||||||
|
if ($message instanceof PromptMessage) {
|
||||||
|
$formattedMessages[] = $message;
|
||||||
|
} else {
|
||||||
|
$formattedMessages[] = $this->formatMessage($message, $index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formattedMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException('Invalid prompt generation result format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a single message into a PromptMessage.
|
||||||
|
*/
|
||||||
|
private function formatMessage(mixed $message, ?int $index = null): PromptMessage
|
||||||
|
{
|
||||||
|
$indexStr = null !== $index ? " at index {$index}" : '';
|
||||||
|
|
||||||
|
if (!\is_array($message) || !\array_key_exists('role', $message) || !\array_key_exists('content', $message)) {
|
||||||
|
throw new \RuntimeException("Invalid message format{$indexStr}. Expected an array with 'role' and 'content' keys.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']);
|
||||||
|
if (null === $role) {
|
||||||
|
throw new \RuntimeException("Invalid role '{$message['role']}' in prompt message{$indexStr}. Only 'user' or 'assistant' are supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = $this->formatContent($message['content'], $index);
|
||||||
|
|
||||||
|
return new PromptMessage($role, $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats content into a proper Content object.
|
||||||
|
*/
|
||||||
|
private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource
|
||||||
|
{
|
||||||
|
$indexStr = null !== $index ? " at index {$index}" : '';
|
||||||
|
|
||||||
|
if ($content instanceof Content) {
|
||||||
|
if (
|
||||||
|
$content instanceof TextContent || $content instanceof ImageContent
|
||||||
|
|| $content instanceof AudioContent || $content instanceof EmbeddedResource
|
||||||
|
) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
throw new \RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_string($content)) {
|
||||||
|
return new TextContent($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_array($content) && isset($content['type'])) {
|
||||||
|
return $this->formatTypedContent($content, $index);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_scalar($content) || null === $content) {
|
||||||
|
$stringContent = null === $content ? '(null)' : (\is_bool($content) ? ($content ? 'true' : 'false') : (string) $content);
|
||||||
|
|
||||||
|
return new TextContent($stringContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
$jsonContent = json_encode($content, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
return new TextContent($jsonContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats typed content arrays into Content objects.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $content
|
||||||
|
*/
|
||||||
|
private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource
|
||||||
|
{
|
||||||
|
$indexStr = null !== $index ? " at index {$index}" : '';
|
||||||
|
$type = $content['type'];
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'text' => $this->formatTextContent($content, $indexStr),
|
||||||
|
'image' => $this->formatImageContent($content, $indexStr),
|
||||||
|
'audio' => $this->formatAudioContent($content, $indexStr),
|
||||||
|
'resource' => $this->formatResourceContent($content, $indexStr),
|
||||||
|
default => throw new \RuntimeException("Invalid content type '{$type}'{$indexStr}."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $content
|
||||||
|
*/
|
||||||
|
private function formatTextContent(array $content, string $indexStr): TextContent
|
||||||
|
{
|
||||||
|
if (!isset($content['text']) || !\is_string($content['text'])) {
|
||||||
|
throw new RuntimeException(\sprintf('Invalid "text" content%s: Missing or invalid "text" string.', $indexStr));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextContent($content['text']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $content
|
||||||
|
*/
|
||||||
|
private function formatImageContent(array $content, string $indexStr): ImageContent
|
||||||
|
{
|
||||||
|
if (!isset($content['data']) || !\is_string($content['data'])) {
|
||||||
|
throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'data' string (base64).");
|
||||||
|
}
|
||||||
|
if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) {
|
||||||
|
throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'mimeType' string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ImageContent($content['data'], $content['mimeType']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $content
|
||||||
|
*/
|
||||||
|
private function formatAudioContent(array $content, string $indexStr): AudioContent
|
||||||
|
{
|
||||||
|
if (!isset($content['data']) || !\is_string($content['data'])) {
|
||||||
|
throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'data' string (base64).");
|
||||||
|
}
|
||||||
|
if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) {
|
||||||
|
throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'mimeType' string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AudioContent($content['data'], $content['mimeType']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $content
|
||||||
|
*/
|
||||||
|
private function formatResourceContent(array $content, string $indexStr): EmbeddedResource
|
||||||
|
{
|
||||||
|
if (!isset($content['resource']) || !\is_array($content['resource'])) {
|
||||||
|
throw new RuntimeException("Invalid 'resource' content{$indexStr}: Missing or invalid 'resource' object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$resource = $content['resource'];
|
||||||
|
if (!isset($resource['uri']) || !\is_string($resource['uri'])) {
|
||||||
|
throw new RuntimeException("Invalid resource{$indexStr}: Missing or invalid 'uri'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($resource['text']) && \is_string($resource['text'])) {
|
||||||
|
$resourceObj = new TextResourceContents($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']);
|
||||||
|
} elseif (isset($resource['blob']) && \is_string($resource['blob'])) {
|
||||||
|
$resourceObj = new BlobResourceContents(
|
||||||
|
$resource['uri'],
|
||||||
|
$resource['mimeType'] ?? 'application/octet-stream',
|
||||||
|
$resource['blob']
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException("Invalid resource{$indexStr}: Must contain 'text' or 'blob'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EmbeddedResource($resourceObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
$completionProviders = [];
|
||||||
|
foreach ($this->completionProviders as $argument => $provider) {
|
||||||
|
$completionProviders[$argument] = serialize($provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'schema' => $this->prompt,
|
||||||
|
'completionProviders' => $completionProviders,
|
||||||
|
...parent::jsonSerialize(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
248
src/Capability/Registry/RegisteredResource.php
Normal file
248
src/Capability/Registry/RegisteredResource.php
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Capability\Registry;
|
||||||
|
|
||||||
|
use Mcp\Exception\RuntimeException;
|
||||||
|
use Mcp\Schema\Content\BlobResourceContents;
|
||||||
|
use Mcp\Schema\Content\EmbeddedResource;
|
||||||
|
use Mcp\Schema\Content\ResourceContents;
|
||||||
|
use Mcp\Schema\Content\TextResourceContents;
|
||||||
|
use Mcp\Schema\Resource;
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @phpstan-import-type CallableArray from RegisteredElement
|
||||||
|
*
|
||||||
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||||
|
*/
|
||||||
|
class RegisteredResource extends RegisteredElement
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param callable|CallableArray|string $handler
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly Resource $schema,
|
||||||
|
callable|array|string $handler,
|
||||||
|
bool $isManual = false,
|
||||||
|
) {
|
||||||
|
parent::__construct($handler, $isManual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self|false
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!isset($data['schema']) || !isset($data['handler'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
Resource::fromArray($data['schema']),
|
||||||
|
$data['handler'],
|
||||||
|
$data['isManual'] ?? false,
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the resource content.
|
||||||
|
*
|
||||||
|
* @return ResourceContents[] array of ResourceContents objects
|
||||||
|
*/
|
||||||
|
public function read(ContainerInterface $container, string $uri): array
|
||||||
|
{
|
||||||
|
$result = $this->handle($container, ['uri' => $uri]);
|
||||||
|
|
||||||
|
return $this->formatResult($result, $uri, $this->schema->mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the raw result of a resource read operation into MCP ResourceContent items.
|
||||||
|
*
|
||||||
|
* @param mixed $readResult the raw result from the resource handler method
|
||||||
|
* @param string $uri the URI of the resource that was read
|
||||||
|
* @param ?string $mimeType the MIME type from the ResourceDefinition
|
||||||
|
*
|
||||||
|
* @return ResourceContents[] array of ResourceContents objects
|
||||||
|
*
|
||||||
|
* @throws RuntimeException If the result cannot be formatted.
|
||||||
|
*
|
||||||
|
* Supported result types:
|
||||||
|
* - ResourceContent: Used as-is
|
||||||
|
* - EmbeddedResource: Resource is extracted from the EmbeddedResource
|
||||||
|
* - string: Converted to text content with guessed or provided MIME type
|
||||||
|
* - stream resource: Read and converted to blob with provided MIME type
|
||||||
|
* - array with 'blob' key: Used as blob content
|
||||||
|
* - array with 'text' key: Used as text content
|
||||||
|
* - SplFileInfo: Read and converted to blob
|
||||||
|
* - array: Converted to JSON if MIME type is application/json or contains 'json'
|
||||||
|
* For other MIME types, will try to convert to JSON with a warning
|
||||||
|
*/
|
||||||
|
protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array
|
||||||
|
{
|
||||||
|
if ($readResult instanceof ResourceContents) {
|
||||||
|
return [$readResult];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($readResult instanceof EmbeddedResource) {
|
||||||
|
return [$readResult->resource];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_array($readResult)) {
|
||||||
|
if (empty($readResult)) {
|
||||||
|
return [new TextResourceContents($uri, 'application/json', '[]')];
|
||||||
|
}
|
||||||
|
|
||||||
|
$allAreResourceContents = true;
|
||||||
|
$hasResourceContents = false;
|
||||||
|
$allAreEmbeddedResource = true;
|
||||||
|
$hasEmbeddedResource = false;
|
||||||
|
|
||||||
|
foreach ($readResult as $item) {
|
||||||
|
if ($item instanceof ResourceContents) {
|
||||||
|
$hasResourceContents = true;
|
||||||
|
$allAreEmbeddedResource = false;
|
||||||
|
} elseif ($item instanceof EmbeddedResource) {
|
||||||
|
$hasEmbeddedResource = true;
|
||||||
|
$allAreResourceContents = false;
|
||||||
|
} else {
|
||||||
|
$allAreResourceContents = false;
|
||||||
|
$allAreEmbeddedResource = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($allAreResourceContents && $hasResourceContents) {
|
||||||
|
return $readResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($allAreEmbeddedResource && $hasEmbeddedResource) {
|
||||||
|
return array_map(fn ($item) => $item->resource, $readResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasResourceContents || $hasEmbeddedResource) {
|
||||||
|
$result = [];
|
||||||
|
foreach ($readResult as $item) {
|
||||||
|
if ($item instanceof ResourceContents) {
|
||||||
|
$result[] = $item;
|
||||||
|
} elseif ($item instanceof EmbeddedResource) {
|
||||||
|
$result[] = $item->resource;
|
||||||
|
} else {
|
||||||
|
$result = array_merge($result, $this->formatResult($item, $uri, $mimeType));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_string($readResult)) {
|
||||||
|
$mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult);
|
||||||
|
|
||||||
|
return [new TextResourceContents($uri, $mimeType, $readResult)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_resource($readResult) && 'stream' === get_resource_type($readResult)) {
|
||||||
|
$result = BlobResourceContents::fromStream(
|
||||||
|
$uri,
|
||||||
|
$readResult,
|
||||||
|
$mimeType ?? 'application/octet-stream'
|
||||||
|
);
|
||||||
|
|
||||||
|
@fclose($readResult);
|
||||||
|
|
||||||
|
return [$result];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_array($readResult) && isset($readResult['blob']) && \is_string($readResult['blob'])) {
|
||||||
|
$mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream';
|
||||||
|
|
||||||
|
return [new BlobResourceContents($uri, $mimeType, $readResult['blob'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_array($readResult) && isset($readResult['text']) && \is_string($readResult['text'])) {
|
||||||
|
$mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain';
|
||||||
|
|
||||||
|
return [new TextResourceContents($uri, $mimeType, $readResult['text'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) {
|
||||||
|
if ($mimeType && str_contains(strtolower($mimeType), 'text')) {
|
||||||
|
return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()))];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_array($readResult)) {
|
||||||
|
if ($mimeType && (str_contains(strtolower($mimeType), 'json')
|
||||||
|
|| 'application/json' === $mimeType)) {
|
||||||
|
try {
|
||||||
|
$jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT);
|
||||||
|
|
||||||
|
return [new TextResourceContents($uri, $mimeType, $jsonString)];
|
||||||
|
} catch (\JsonException $e) {
|
||||||
|
throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT);
|
||||||
|
$mimeType = $mimeType ?? 'application/json';
|
||||||
|
|
||||||
|
return [new TextResourceContents($uri, $mimeType, $jsonString)];
|
||||||
|
} catch (\JsonException $e) {
|
||||||
|
throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(\sprintf('Cannot format resource read result for URI "%s". Handler method returned unhandled type: ', $uri).\gettype($readResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guesses MIME type from string content (very basic) */
|
||||||
|
private function guessMimeTypeFromString(string $content): string
|
||||||
|
{
|
||||||
|
$trimmed = ltrim($content);
|
||||||
|
|
||||||
|
if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) {
|
||||||
|
if (str_contains($trimmed, '<html')) {
|
||||||
|
return 'text/html';
|
||||||
|
}
|
||||||
|
if (str_contains($trimmed, '<?xml')) {
|
||||||
|
return 'application/xml';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text/plain';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($trimmed, '{') && str_ends_with(rtrim($content), '}')) {
|
||||||
|
return 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($trimmed, '[') && str_ends_with(rtrim($content), ']')) {
|
||||||
|
return 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text/plain';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'schema' => $this->schema,
|
||||||
|
...parent::jsonSerialize(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue