From 972bb42f4c2dcc5ea02009b66b6ded15b532400b Mon Sep 17 00:00:00 2001 From: Peter Marci Date: Tue, 19 May 2026 11:11:10 +0200 Subject: [PATCH 1/3] Add PropertyDescriberInterface extension point for SchemaGenerator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets callers teach SchemaGenerator how to render specific value-object types (DateTime, Uuid, Money, ...) as more useful JSON Schema fragments than the generic `{type: "object"}` fallback. Describers are consulted, in order, for class-typed parameters before generic class inspection. The first non-null result wins; description / default / nullable are layered onto the described schema without overwriting it. Ships two default describers in `Mcp\Capability\Discovery\PropertyDescriber\`: - DateTimePropertyDescriber → {type: "string", format: "date-time"} - UuidPropertyDescriber → {type: "string", format: "uuid"} The new constructor parameter defaults to an empty iterable, so existing callers stay unaffected. --- .../DateTimePropertyDescriber.php | 30 +++++ .../UuidPropertyDescriber.php | 31 +++++ .../Discovery/PropertyDescriberInterface.php | 31 +++++ src/Capability/Discovery/SchemaGenerator.php | 78 +++++++++++- .../DateTimePropertyDescriberTest.php | 51 ++++++++ .../UuidPropertyDescriberTest.php | 54 ++++++++ .../Discovery/SchemaGeneratorFixture.php | 31 +++++ .../Discovery/SchemaGeneratorTest.php | 118 ++++++++++++++++++ 8 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 src/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriber.php create mode 100644 src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php create mode 100644 src/Capability/Discovery/PropertyDescriberInterface.php create mode 100644 tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php create mode 100644 tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php diff --git a/src/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriber.php b/src/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriber.php new file mode 100644 index 00000000..ba4e0dfb --- /dev/null +++ b/src/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriber.php @@ -0,0 +1,30 @@ + 'string', 'format' => 'date-time']; + } +} diff --git a/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php b/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php new file mode 100644 index 00000000..31ab807b --- /dev/null +++ b/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php @@ -0,0 +1,31 @@ + 'string', 'format' => 'uuid']; + } +} diff --git a/src/Capability/Discovery/PropertyDescriberInterface.php b/src/Capability/Discovery/PropertyDescriberInterface.php new file mode 100644 index 00000000..ac747d0b --- /dev/null +++ b/src/Capability/Discovery/PropertyDescriberInterface.php @@ -0,0 +1,31 @@ +|null Schema fragment, or null to pass to the next describer + */ + public function describe(string $className): ?array; +} diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 04ee08f6..79a54de9 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -60,8 +60,14 @@ */ final class SchemaGenerator implements SchemaGeneratorInterface { + /** + * @param iterable $propertyDescribers Consulted, in order, before + * generic class inspection; + * first non-null result wins + */ public function __construct( private readonly DocBlockParser $docBlockParser, + private readonly iterable $propertyDescribers = [], ) { } @@ -253,13 +259,22 @@ private function buildParameterSchema(array $paramInfo, ?array $methodLevelParam */ private function buildInferredParameterSchema(array $paramInfo): array { - $paramSchema = []; - // Variadic parameters are handled separately if ($paramInfo['is_variadic']) { return []; } + // Consult property describers for class-typed parameters first; the + // first describer that claims the class (returns non-null) wins. This + // lets callers teach the generator about value-object types like + // DateTime, Uuid, Money, etc. without subclassing the generator. + $describedSchema = $this->describeClassType($paramInfo); + if (null !== $describedSchema) { + return $this->applyParameterMetadata($describedSchema, $paramInfo); + } + + $paramSchema = []; + // Infer JSON Schema types $jsonTypes = $this->inferParameterTypes($paramInfo); @@ -349,6 +364,65 @@ private function inferParameterTypes(array $paramInfo): array return $jsonTypes; } + /** + * Looks for a matching describer when the parameter's PHP type is a + * concrete class. Returns the first non-null describer result, or null + * if no describer claimed the type. Union and intersection types are + * not dispatched — describers see only single named, non-builtin types. + * + * @param ParameterInfo $paramInfo + * + * @return array|null + */ + private function describeClassType(array $paramInfo): ?array + { + $reflectionType = $paramInfo['reflection_type_object']; + if (!$reflectionType instanceof \ReflectionNamedType || $reflectionType->isBuiltin()) { + return null; + } + + $className = $reflectionType->getName(); + foreach ($this->propertyDescribers as $describer) { + $described = $describer->describe($className); + if (null !== $described) { + return $described; + } + } + + return null; + } + + /** + * Layers parameter-level metadata (description, default, nullable) onto + * a describer-provided schema fragment without overwriting fields the + * describer already set. + * + * @param array $schema + * @param ParameterInfo $paramInfo + * + * @return array + */ + private function applyParameterMetadata(array $schema, array $paramInfo): array + { + if ($paramInfo['description'] && !isset($schema['description'])) { + $schema['description'] = $paramInfo['description']; + } + + if ($paramInfo['has_default'] && !isset($schema['default'])) { + $schema['default'] = $paramInfo['default_value']; + } + + if ($paramInfo['allows_null'] && isset($schema['type'])) { + $types = \is_array($schema['type']) ? $schema['type'] : [$schema['type']]; + if (!\in_array('null', $types, true)) { + array_unshift($types, 'null'); + } + $schema['type'] = 1 === \count($types) ? $types[0] : $types; + } + + return $schema; + } + /** * Applies enum constraints to parameter schema. */ diff --git a/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php b/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php new file mode 100644 index 00000000..6a439764 --- /dev/null +++ b/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php @@ -0,0 +1,51 @@ +describer = new DateTimePropertyDescriber(); + } + + public function testDescribesDateTimeInterfaceAsIsoDateTimeString(): void + { + $this->assertSame( + ['type' => 'string', 'format' => 'date-time'], + $this->describer->describe(\DateTimeInterface::class), + ); + } + + public function testDescribesDateTimeImplementations(): void + { + $this->assertSame( + ['type' => 'string', 'format' => 'date-time'], + $this->describer->describe(\DateTime::class), + ); + $this->assertSame( + ['type' => 'string', 'format' => 'date-time'], + $this->describer->describe(\DateTimeImmutable::class), + ); + } + + public function testPassesOnUnrelatedClass(): void + { + $this->assertNull($this->describer->describe(\stdClass::class)); + $this->assertNull($this->describer->describe(\Exception::class)); + } +} diff --git a/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php b/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php new file mode 100644 index 00000000..3a16c3c2 --- /dev/null +++ b/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php @@ -0,0 +1,54 @@ +describer = new UuidPropertyDescriber(); + } + + public function testDescribesUuidAsUuidFormatString(): void + { + $this->assertSame( + ['type' => 'string', 'format' => 'uuid'], + $this->describer->describe(Uuid::class), + ); + } + + public function testDescribesUuidSubclasses(): void + { + $this->assertSame( + ['type' => 'string', 'format' => 'uuid'], + $this->describer->describe(UuidV4::class), + ); + $this->assertSame( + ['type' => 'string', 'format' => 'uuid'], + $this->describer->describe(UuidV6::class), + ); + } + + public function testPassesOnUnrelatedClass(): void + { + $this->assertNull($this->describer->describe(\stdClass::class)); + $this->assertNull($this->describer->describe(\DateTime::class)); + } +} diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 0d40026c..f248d797 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -438,6 +438,37 @@ public function withParameterNamedRequest(string $_request): void { } + // ===== PROPERTY DESCRIBER FIXTURES ===== + + public function dateTimeParam(\DateTimeImmutable $createdAt): void + { + } + + /** + * @param \DateTimeInterface $until The cutoff timestamp + */ + public function dateTimeWithDescription(\DateTimeInterface $until): void + { + } + + public function nullableDateTimeParam(?\DateTimeImmutable $finishedAt = null): void + { + } + + public function uuidParam(\Symfony\Component\Uid\Uuid $bookingId): void + { + } + + public function unrelatedObjectParam(\stdClass $config): void + { + } + + public function dateTimeWithSchemaAttributeOverride( + #[Schema(description: 'explicit attribute description')] + \DateTimeImmutable $deadline, + ): void { + } + // ===== OUTPUT SCHEMA FIXTURES ===== #[McpTool( outputSchema: [ diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index c92121e6..d8af5ecb 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -12,6 +12,9 @@ namespace Mcp\Tests\Unit\Capability\Discovery; use Mcp\Capability\Discovery\DocBlockParser; +use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber; +use Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber; +use Mcp\Capability\Discovery\PropertyDescriberInterface; use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Exception\InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; @@ -387,4 +390,119 @@ public function testGenerateOutputSchemaForComplexNestedSchema(): void 'additionalProperties' => true, ], $schema); } + + // ===== PROPERTY DESCRIBER INTEGRATION ===== + + public function testFallsBackToObjectWhenNoDescriberClaimsClassType(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'dateTimeParam'); + $schema = $this->schemaGenerator->generate($method); + $this->assertSame(['type' => 'object'], $schema['properties']['createdAt']); + } + + public function testDescriberOverridesGenericObjectInferenceForKnownClass(): void + { + $generator = new SchemaGenerator( + new DocBlockParser(), + [new DateTimePropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'dateTimeParam'); + $schema = $generator->generate($method); + $this->assertSame( + ['type' => 'string', 'format' => 'date-time'], + $schema['properties']['createdAt'], + ); + } + + public function testDescribedSchemaLayersDocBlockDescription(): void + { + $generator = new SchemaGenerator( + new DocBlockParser(), + [new DateTimePropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'dateTimeWithDescription'); + $schema = $generator->generate($method); + $this->assertSame( + ['type' => 'string', 'format' => 'date-time', 'description' => 'The cutoff timestamp'], + $schema['properties']['until'], + ); + } + + public function testDescribedSchemaPicksUpNullableAndDefault(): void + { + $generator = new SchemaGenerator( + new DocBlockParser(), + [new DateTimePropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableDateTimeParam'); + $schema = $generator->generate($method); + $this->assertSame( + ['type' => ['null', 'string'], 'format' => 'date-time', 'default' => null], + $schema['properties']['finishedAt'], + ); + } + + public function testFirstNonNullDescriberWins(): void + { + $loudDescriber = new class implements PropertyDescriberInterface { + public function describe(string $className): ?array + { + if (is_a($className, \DateTimeInterface::class, true)) { + return ['type' => 'string', 'format' => 'custom-loud']; + } + + return null; + } + }; + + $generator = new SchemaGenerator( + new DocBlockParser(), + [$loudDescriber, new DateTimePropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'dateTimeParam'); + $schema = $generator->generate($method); + $this->assertSame( + ['type' => 'string', 'format' => 'custom-loud'], + $schema['properties']['createdAt'], + ); + } + + public function testUuidDescriberClaimsUuidClass(): void + { + $generator = new SchemaGenerator( + new DocBlockParser(), + [new UuidPropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'uuidParam'); + $schema = $generator->generate($method); + $this->assertSame( + ['type' => 'string', 'format' => 'uuid'], + $schema['properties']['bookingId'], + ); + } + + public function testDescribersDoNotInterceptUnrelatedClassTypes(): void + { + $generator = new SchemaGenerator( + new DocBlockParser(), + [new DateTimePropertyDescriber(), new UuidPropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'unrelatedObjectParam'); + $schema = $generator->generate($method); + $this->assertSame(['type' => 'object'], $schema['properties']['config']); + } + + public function testParameterLevelSchemaAttributeOverridesDescribedSchema(): void + { + $generator = new SchemaGenerator( + new DocBlockParser(), + [new DateTimePropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'dateTimeWithSchemaAttributeOverride'); + $schema = $generator->generate($method); + $this->assertSame( + ['type' => 'string', 'format' => 'date-time', 'description' => 'explicit attribute description'], + $schema['properties']['deadline'], + ); + } } From cfad5b47eee7788498e8f6319361e96152d14294 Mon Sep 17 00:00:00 2001 From: Peter Marci Date: Mon, 25 May 2026 18:34:50 +0200 Subject: [PATCH 2/3] Address review: explicit PropertyDescriber interface + Builder registration Rework the describer extension point per review feedback: - Replace `describe(string $className): ?array` with an explicit `static supportedClass(): class-string` + non-nullable `describe(): array`; support is declared, not encoded as a null return. - SchemaGenerator matches a parameter's class against `supportedClass()` via `is_a()` (subtypes included), materializes the describer iterable once so an injected Generator isn't exhausted across parameters, and memoizes resolution per concrete class. - Add `Builder::addPropertyDescriber()` (opt-in; consulted in registration order, first match wins); mutually exclusive with setSchemaGenerator(). - Use a `use` import for Uuid in the test fixture. - Document the feature under Schema Generation and Validation. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/mcp-elements.md | 72 +++++++++++++++ docs/server-builder.md | 1 + .../DateTimePropertyDescriber.php | 9 +- .../UuidPropertyDescriber.php | 9 +- .../Discovery/PropertyDescriberInterface.php | 22 +++-- src/Capability/Discovery/SchemaGenerator.php | 63 ++++++++++--- src/Server/Builder.php | 52 ++++++++++- .../DateTimePropertyDescriberTest.php | 21 +---- .../UuidPropertyDescriberTest.php | 23 +---- .../Discovery/SchemaGeneratorFixture.php | 3 +- .../Discovery/SchemaGeneratorTest.php | 13 +-- tests/Unit/Server/BuilderTest.php | 89 +++++++++++++++++++ 12 files changed, 302 insertions(+), 75 deletions(-) diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index b1a045f5..b866d4a0 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -745,6 +745,78 @@ public function makeApiRequest(string $endpoint, string $method, array $headers) **Warning:** Only use complete schema override if you're well-versed with JSON Schema specification and have complex validation requirements that cannot be achieved through the priority system. +### Custom Type Describers + +When a tool parameter is type-hinted with a class, the generator falls back to `{type: "object"}` — which tells the +LLM nothing about the expected shape. For value-object types (timestamps, identifiers, money, …) you can register a +**property describer** that maps the class to a targeted JSON Schema fragment. + +A describer declares the class (or base class / interface) it handles and the fragment to emit: + +```php +interface PropertyDescriberInterface +{ + /** @return class-string The class/interface this describer handles (subtypes match too) */ + public static function supportedClass(): string; + + /** @return array JSON Schema fragment for the supported type */ + public function describe(): array; +} +``` + +A parameter is dispatched to a describer when its type is `supportedClass()` **or any subtype of it** — so a describer +for `\DateTimeInterface` also covers `\DateTimeImmutable`, and one for `Uuid` covers `UuidV4`, `UuidV7`, etc. + +Two describers ship with the SDK (both opt-in): + +| Describer | Handles | Emits | +| --- | --- | --- | +| `Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber` | any `\DateTimeInterface` | `{type: "string", format: "date-time"}` | +| `Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber` | `Symfony\Component\Uid\Uuid` (and subclasses) | `{type: "string", format: "uuid"}` | + +Register them — and your own — on the builder. Describers are consulted in **registration order**; the first one whose +supported class matches the parameter wins: + +```php +use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber; +use Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber; + +$server = Server::builder() + ->setServerInfo('my-server', '1.0.0') + ->addPropertyDescriber(new DateTimePropertyDescriber()) + ->addPropertyDescriber(new UuidPropertyDescriber()) + ->build(); +``` + +Now `public function schedule(\DateTimeImmutable $until)` generates `{type: "string", format: "date-time"}` for +`$until` instead of `{type: "object"}`. Docblock descriptions, defaults and nullability are still layered on top of the +describer's fragment. + +Writing a custom describer for a domain value object: + +```php +use Mcp\Capability\Discovery\PropertyDescriberInterface; + +final class MoneyPropertyDescriber implements PropertyDescriberInterface +{ + public static function supportedClass(): string + { + return \App\Money::class; + } + + public function describe(): array + { + return ['type' => 'string', 'pattern' => '^\d+(\.\d{2})? [A-Z]{3}$']; + } +} + +$builder->addPropertyDescriber(new MoneyPropertyDescriber()); +``` + +To override a shipped describer, register your own for the same class **before** it — the first matching describer +wins. Note that `addPropertyDescriber()` cannot be combined with `setSchemaGenerator()` — if you supply your own +`SchemaGeneratorInterface`, configure the describers on that generator directly. + ## Discovery vs Manual Registration ### Attribute-Based Discovery diff --git a/docs/server-builder.md b/docs/server-builder.md index a48e96da..a04b0ba8 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -619,4 +619,5 @@ $server = Server::builder() | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | | `addPrompt()` | handler, name?, description? | Register prompt | +| `addPropertyDescriber()` | describer | Register a [property describer](mcp-elements.md#custom-type-describers) for class-typed parameters | | `build()` | - | Create the server instance | diff --git a/src/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriber.php b/src/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriber.php index ba4e0dfb..7a77b928 100644 --- a/src/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriber.php +++ b/src/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriber.php @@ -19,12 +19,13 @@ */ final class DateTimePropertyDescriber implements PropertyDescriberInterface { - public function describe(string $className): ?array + public static function supportedClass(): string { - if (!is_a($className, \DateTimeInterface::class, true)) { - return null; - } + return \DateTimeInterface::class; + } + public function describe(): array + { return ['type' => 'string', 'format' => 'date-time']; } } diff --git a/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php b/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php index 31ab807b..e4a1245f 100644 --- a/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php +++ b/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php @@ -20,12 +20,13 @@ */ final class UuidPropertyDescriber implements PropertyDescriberInterface { - public function describe(string $className): ?array + public static function supportedClass(): string { - if (!is_a($className, Uuid::class, true)) { - return null; - } + return Uuid::class; + } + public function describe(): array + { return ['type' => 'string', 'format' => 'uuid']; } } diff --git a/src/Capability/Discovery/PropertyDescriberInterface.php b/src/Capability/Discovery/PropertyDescriberInterface.php index ac747d0b..7aba7b96 100644 --- a/src/Capability/Discovery/PropertyDescriberInterface.php +++ b/src/Capability/Discovery/PropertyDescriberInterface.php @@ -14,18 +14,26 @@ /** * Translates a PHP class type into a JSON Schema fragment. * - * The {@see SchemaGenerator} consults registered describers, in order, before - * falling back to generic class inspection. The first describer that returns - * a non-null schema wins. Implementations let callers teach the generator - * about value-object types (DateTime, Uuid, etc.) whose JSON Schema + * A describer declares the class (or base class/interface) it handles via + * {@see self::supportedClass()}. The {@see SchemaGenerator} matches a + * parameter's concrete class against that type — directly or through its + * parents and interfaces — and, when several describers are registered, + * consults them in priority order. Implementations let callers teach the + * generator about value-object types (DateTime, Uuid, etc.) whose JSON Schema * representation is more specific than a generic `{type: "object"}`. */ interface PropertyDescriberInterface { /** - * @param class-string $className + * The class or interface this describer handles. Parameters whose type is + * the class itself or any subtype of it are dispatched to this describer. * - * @return array|null Schema fragment, or null to pass to the next describer + * @return class-string */ - public function describe(string $className): ?array; + public static function supportedClass(): string; + + /** + * @return array Schema fragment for the supported type + */ + public function describe(): array; } diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 79a54de9..b86aa5fe 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -61,14 +61,32 @@ final class SchemaGenerator implements SchemaGeneratorInterface { /** - * @param iterable $propertyDescribers Consulted, in order, before - * generic class inspection; - * first non-null result wins + * @var list Consulted in registration order before generic class + * inspection; the first matching describer wins + */ + private readonly array $propertyDescribers; + + /** + * Memoizes describer resolution per concrete class. Holds the matching + * describer or `false` when none matched, so repeated parameter types — + * and the common "no describer" case — are resolved at most once. + * + * @var array + */ + private array $describerCache = []; + + /** + * @param iterable $propertyDescribers Consulted in order before + * generic class inspection; the + * first matching describer wins */ public function __construct( private readonly DocBlockParser $docBlockParser, - private readonly iterable $propertyDescribers = [], + iterable $propertyDescribers = [], ) { + $this->propertyDescribers = \is_array($propertyDescribers) + ? array_values($propertyDescribers) + : iterator_to_array($propertyDescribers, false); } /** @@ -265,7 +283,7 @@ private function buildInferredParameterSchema(array $paramInfo): array } // Consult property describers for class-typed parameters first; the - // first describer that claims the class (returns non-null) wins. This + // first registered describer whose supported class matches wins. This // lets callers teach the generator about value-object types like // DateTime, Uuid, Money, etc. without subclassing the generator. $describedSchema = $this->describeClassType($paramInfo); @@ -365,10 +383,9 @@ private function inferParameterTypes(array $paramInfo): array } /** - * Looks for a matching describer when the parameter's PHP type is a - * concrete class. Returns the first non-null describer result, or null - * if no describer claimed the type. Union and intersection types are - * not dispatched — describers see only single named, non-builtin types. + * Describes the parameter when its PHP type is a concrete class claimed by + * a registered describer, or null otherwise. Union and intersection types + * are not dispatched — describers see only single named, non-builtin types. * * @param ParameterInfo $paramInfo * @@ -381,11 +398,31 @@ private function describeClassType(array $paramInfo): ?array return null; } - $className = $reflectionType->getName(); + return $this->resolveDescriber($reflectionType->getName())?->describe(); + } + + /** + * Resolves the describer for a concrete class, matching against each + * describer's {@see PropertyDescriberInterface::supportedClass()} (the + * class itself or any subtype). Results are memoized per class. + * + * @param class-string $className + */ + private function resolveDescriber(string $className): ?PropertyDescriberInterface + { + $cached = $this->describerCache[$className] ??= $this->findDescriber($className) ?? false; + + return $cached ?: null; + } + + /** + * @param class-string $className + */ + private function findDescriber(string $className): ?PropertyDescriberInterface + { foreach ($this->propertyDescribers as $describer) { - $described = $describer->describe($className); - if (null !== $described) { - return $described; + if (is_a($className, $describer::supportedClass(), true)) { + return $describer; } } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index ba26d23f..97200395 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -14,6 +14,9 @@ use Mcp\Capability\Discovery\CachedDiscoverer; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DiscovererInterface; +use Mcp\Capability\Discovery\DocBlockParser; +use Mcp\Capability\Discovery\PropertyDescriberInterface; +use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Capability\Discovery\SchemaGeneratorInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; @@ -179,6 +182,11 @@ final class Builder */ private array $loaders = []; + /** + * @var array + */ + private array $propertyDescribers = []; + /** * Sets the server's identity. Required. * @@ -540,6 +548,22 @@ public function addLoaders(iterable $loaders): self return $this; } + /** + * Registers a property describer that teaches the schema generator how to + * render a value-object class (e.g. DateTime, Uuid) as a targeted JSON + * Schema fragment instead of a generic `{type: "object"}`. + * + * Describers are consulted in registration order; the first one whose + * supported class matches the parameter wins. Cannot be combined with a + * generator set via setSchemaGenerator(). + */ + public function addPropertyDescriber(PropertyDescriberInterface $describer): self + { + $this->propertyDescribers[] = $describer; + + return $this; + } + /** * Builds the fully configured Server instance. */ @@ -556,16 +580,18 @@ public function build(): Server $this->gcDivisor, ); + $schemaGenerator = $this->resolveSchemaGenerator($logger); + // ArrayLoader runs before DiscoveryLoader so manual entries are seen first; DiscoveryLoader's // identity check then preserves them against same-name discovered entries. $loaders = [ ...$this->loaders, - new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), + new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $schemaGenerator), ]; if (null !== $this->discoveryBasePath) { if (null !== $this->discoverer || class_exists(Finder::class)) { - $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); + $discoverer = $this->discoverer ?? $this->createDiscoverer($logger, $schemaGenerator); $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer, $this->discoveryNamePatterns, $logger); } else { $logger->warning('File-based discovery requires symfony/finder. Skipping automatic discovery. Run: composer require symfony/finder'); @@ -625,9 +651,9 @@ public function build(): Server return new Server($protocol, $logger); } - private function createDiscoverer(LoggerInterface $logger): DiscovererInterface + private function createDiscoverer(LoggerInterface $logger, ?SchemaGeneratorInterface $schemaGenerator): DiscovererInterface { - $discoverer = new Discoverer($logger, null, $this->schemaGenerator); + $discoverer = new Discoverer($logger, null, $schemaGenerator); if (null !== $this->discoveryCache) { return new CachedDiscoverer($discoverer, $this->discoveryCache, $logger); @@ -635,4 +661,22 @@ private function createDiscoverer(LoggerInterface $logger): DiscovererInterface return $discoverer; } + + /** + * Builds the schema generator from registered property describers, or + * returns the explicitly configured one. The two are mutually exclusive: + * describers belong on the explicit generator if one is set. + */ + private function resolveSchemaGenerator(LoggerInterface $logger): ?SchemaGeneratorInterface + { + if ([] === $this->propertyDescribers) { + return $this->schemaGenerator; + } + + if (null !== $this->schemaGenerator) { + throw new InvalidArgumentException('Cannot combine addPropertyDescriber() with a generator set via setSchemaGenerator(). Configure the describers on that generator instead.'); + } + + return new SchemaGenerator(new DocBlockParser(logger: $logger), $this->propertyDescribers); + } } diff --git a/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php b/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php index 6a439764..d3ab27a1 100644 --- a/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php +++ b/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php @@ -23,29 +23,16 @@ protected function setUp(): void $this->describer = new DateTimePropertyDescriber(); } - public function testDescribesDateTimeInterfaceAsIsoDateTimeString(): void + public function testSupportsDateTimeInterface(): void { - $this->assertSame( - ['type' => 'string', 'format' => 'date-time'], - $this->describer->describe(\DateTimeInterface::class), - ); + $this->assertSame(\DateTimeInterface::class, DateTimePropertyDescriber::supportedClass()); } - public function testDescribesDateTimeImplementations(): void + public function testDescribesAsIsoDateTimeString(): void { $this->assertSame( ['type' => 'string', 'format' => 'date-time'], - $this->describer->describe(\DateTime::class), + $this->describer->describe(), ); - $this->assertSame( - ['type' => 'string', 'format' => 'date-time'], - $this->describer->describe(\DateTimeImmutable::class), - ); - } - - public function testPassesOnUnrelatedClass(): void - { - $this->assertNull($this->describer->describe(\stdClass::class)); - $this->assertNull($this->describer->describe(\Exception::class)); } } diff --git a/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php b/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php index 3a16c3c2..ac9d19d1 100644 --- a/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php +++ b/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php @@ -14,8 +14,6 @@ use Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber; use PHPUnit\Framework\TestCase; use Symfony\Component\Uid\Uuid; -use Symfony\Component\Uid\UuidV4; -use Symfony\Component\Uid\UuidV6; final class UuidPropertyDescriberTest extends TestCase { @@ -26,29 +24,16 @@ protected function setUp(): void $this->describer = new UuidPropertyDescriber(); } - public function testDescribesUuidAsUuidFormatString(): void + public function testSupportsUuid(): void { - $this->assertSame( - ['type' => 'string', 'format' => 'uuid'], - $this->describer->describe(Uuid::class), - ); + $this->assertSame(Uuid::class, UuidPropertyDescriber::supportedClass()); } - public function testDescribesUuidSubclasses(): void + public function testDescribesAsUuidFormatString(): void { $this->assertSame( ['type' => 'string', 'format' => 'uuid'], - $this->describer->describe(UuidV4::class), + $this->describer->describe(), ); - $this->assertSame( - ['type' => 'string', 'format' => 'uuid'], - $this->describer->describe(UuidV6::class), - ); - } - - public function testPassesOnUnrelatedClass(): void - { - $this->assertNull($this->describer->describe(\stdClass::class)); - $this->assertNull($this->describer->describe(\DateTime::class)); } } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index f248d797..ad8ce1f8 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -16,6 +16,7 @@ use Mcp\Tests\Unit\Fixtures\Enum\BackedIntEnum; use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum; use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum; +use Symfony\Component\Uid\Uuid; /** * Comprehensive fixture for testing SchemaGenerator with various scenarios. @@ -455,7 +456,7 @@ public function nullableDateTimeParam(?\DateTimeImmutable $finishedAt = null): v { } - public function uuidParam(\Symfony\Component\Uid\Uuid $bookingId): void + public function uuidParam(Uuid $bookingId): void { } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index d8af5ecb..c4944143 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -442,16 +442,17 @@ public function testDescribedSchemaPicksUpNullableAndDefault(): void ); } - public function testFirstNonNullDescriberWins(): void + public function testFirstMatchingDescriberWins(): void { $loudDescriber = new class implements PropertyDescriberInterface { - public function describe(string $className): ?array + public static function supportedClass(): string { - if (is_a($className, \DateTimeInterface::class, true)) { - return ['type' => 'string', 'format' => 'custom-loud']; - } + return \DateTimeInterface::class; + } - return null; + public function describe(): array + { + return ['type' => 'string', 'format' => 'custom-loud']; } }; diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 8a92d599..07ee3e90 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -11,13 +11,20 @@ namespace Mcp\Tests\Unit\Server; +use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber; +use Mcp\Capability\Discovery\PropertyDescriberInterface; +use Mcp\Capability\Discovery\SchemaGeneratorInterface; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; +use Mcp\Schema\Request\ListToolsRequest; +use Mcp\Schema\Result\ListToolsResult; use Mcp\Server; use Mcp\Server\Handler\Request\CallToolHandler; +use Mcp\Server\Handler\Request\ListToolsHandler; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -79,6 +86,88 @@ public function testCustomReferenceHandlerIsUsedForToolCalls(): void $this->assertSame('intercepted', $result); } + #[TestDox('addPropertyDescriber() applies to generated tool input schemas')] + public function testAddPropertyDescriberAppliesToGeneratedToolSchema(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addPropertyDescriber(new DateTimePropertyDescriber()) + ->addTool(static fn (\DateTimeImmutable $when): string => 'ok', name: 'dt_tool', description: 'A tool') + ->build(); + + $schema = $this->toolInputSchema($server, 'dt_tool'); + + $this->assertSame(['type' => 'string', 'format' => 'date-time'], $schema['properties']['when']); + } + + #[TestDox('addPropertyDescriber() consults describers in registration order (first match wins)')] + public function testAddPropertyDescriberConsultsInRegistrationOrder(): void + { + $custom = new class implements PropertyDescriberInterface { + public static function supportedClass(): string + { + return \DateTimeInterface::class; + } + + public function describe(): array + { + return ['type' => 'string', 'format' => 'custom']; + } + }; + + // Registered before the default, so the custom describer wins for DateTime types. + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addPropertyDescriber($custom) + ->addPropertyDescriber(new DateTimePropertyDescriber()) + ->addTool(static fn (\DateTimeImmutable $when): string => 'ok', name: 'dt_tool', description: 'A tool') + ->build(); + + $schema = $this->toolInputSchema($server, 'dt_tool'); + + $this->assertSame(['type' => 'string', 'format' => 'custom'], $schema['properties']['when']); + } + + #[TestDox('addPropertyDescriber() cannot be combined with setSchemaGenerator()')] + public function testAddPropertyDescriberConflictsWithExplicitGenerator(): void + { + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setSchemaGenerator($this->createStub(SchemaGeneratorInterface::class)) + ->addPropertyDescriber(new DateTimePropertyDescriber()); + + $this->expectException(InvalidArgumentException::class); + + $builder->build(); + } + + /** + * @return array + */ + private function toolInputSchema(Server $server, string $toolName): array + { + $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); + $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); + + foreach ($requestHandlers as $handler) { + if ($handler instanceof ListToolsHandler) { + $request = (new ListToolsRequest())->withId('test-1'); + $response = $handler->handle($request, $this->createStub(SessionInterface::class)); + \assert($response->result instanceof ListToolsResult); + + foreach ($response->result->tools as $tool) { + if ($tool->name === $toolName) { + return $tool->inputSchema; + } + } + + $this->fail(\sprintf('Tool "%s" not found in tools/list result', $toolName)); + } + } + + $this->fail('ListToolsHandler not found in request handlers'); + } + private function callTool(Server $server, string $toolName): mixed { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); From b45e298ab06a2025d2501d27133c6d506a5e8f7f Mon Sep 17 00:00:00 2001 From: Peter Marci Date: Tue, 26 May 2026 18:01:45 +0200 Subject: [PATCH 3/3] Add input upcasting and output normalization to property handlers Complete the describer extension point so a registered handler can also upcast incoming client input into an instance and normalize an instance back to JSON, not just describe its schema. - Split into PropertyDescriberInterface / PropertyDenormalizerInterface / PropertyNormalizerInterface (sharing PropertyHandlerInterface); a single class may implement any combination. - Extract the is_a matching + per-class/concern memoization into a shared PropertyHandlerResolver used by the schema, input and output paths. - ReferenceHandler upcasts class-typed arguments via a denormalizer and verifies the result is an instance of the parameter type, so a subtype mismatch surfaces as invalid params rather than an internal error. - CallToolHandler normalizes a class-typed result before formatting it. - SchemaGenerator infers a tool outputSchema from the return type, but only when the describer yields an object schema (an MCP outputSchema describes the object-typed structuredContent); ArrayLoader does the same for manually registered tools. - Shipped Uuid/DateTime describers implement all three directions. addPropertyDescriber() now also rejects being combined with setReferenceHandler(), mirroring the existing setSchemaGenerator() guard. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/mcp-elements.md | 134 ++++++++++++++---- docs/server-builder.md | 2 +- .../PropertyDenormalizerInterface.php | 33 +++++ .../DateTimePropertyDescriber.php | 28 +++- .../UuidPropertyDescriber.php | 27 +++- .../Discovery/PropertyDescriberInterface.php | 22 +-- .../Discovery/PropertyHandlerInterface.php | 35 +++++ .../Discovery/PropertyHandlerResolver.php | 79 +++++++++++ .../Discovery/PropertyNormalizerInterface.php | 30 ++++ src/Capability/Discovery/SchemaGenerator.php | 79 ++++------- .../Registry/Loader/ArrayLoader.php | 3 +- src/Capability/Registry/ReferenceHandler.php | 39 +++++ src/Server/Builder.php | 44 +++--- .../Handler/Request/CallToolHandler.php | 18 +++ .../DateTimePropertyDescriberTest.php | 29 ++++ .../UuidPropertyDescriberTest.php | 29 ++++ .../Discovery/PropertyHandlerResolverTest.php | 97 +++++++++++++ .../Discovery/SchemaGeneratorFixture.php | 23 +++ .../Discovery/SchemaGeneratorTest.php | 48 +++++++ .../Registry/ReferenceHandlerTest.php | 99 +++++++++++++ tests/Unit/Server/BuilderTest.php | 109 +++++++++++++- .../Handler/Request/CallToolHandlerTest.php | 36 +++++ 22 files changed, 921 insertions(+), 122 deletions(-) create mode 100644 src/Capability/Discovery/PropertyDenormalizerInterface.php create mode 100644 src/Capability/Discovery/PropertyHandlerInterface.php create mode 100644 src/Capability/Discovery/PropertyHandlerResolver.php create mode 100644 src/Capability/Discovery/PropertyNormalizerInterface.php create mode 100644 tests/Unit/Capability/Discovery/PropertyHandlerResolverTest.php create mode 100644 tests/Unit/Capability/Registry/ReferenceHandlerTest.php diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index b866d4a0..6b389b84 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -747,35 +747,47 @@ validation requirements that cannot be achieved through the priority system. ### Custom Type Describers -When a tool parameter is type-hinted with a class, the generator falls back to `{type: "object"}` — which tells the -LLM nothing about the expected shape. For value-object types (timestamps, identifiers, money, …) you can register a -**property describer** that maps the class to a targeted JSON Schema fragment. - -A describer declares the class (or base class / interface) it handles and the fragment to emit: +When a tool parameter or return value is type-hinted with a class, the generator falls back to `{type: "object"}` and +the SDK has no idea how to turn the client's JSON into that class (or that class back into JSON). For value-object types +(timestamps, identifiers, money, whole DTOs, …) you register a **property handler** that teaches the SDK about the type +in up to three directions. Each direction is its own interface, so a handler opts into only what it needs; a single +class may implement any combination: ```php -interface PropertyDescriberInterface -{ - /** @return class-string The class/interface this describer handles (subtypes match too) */ - public static function supportedClass(): string; +use Mcp\Capability\Discovery\PropertyDescriberInterface; +use Mcp\Capability\Discovery\PropertyDenormalizerInterface; +use Mcp\Capability\Discovery\PropertyNormalizerInterface; - /** @return array JSON Schema fragment for the supported type */ +// All three share PropertyHandlerInterface::supportedClass(): class-string + +interface PropertyDescriberInterface // type → JSON Schema (input + output schema) +{ public function describe(): array; } + +interface PropertyDenormalizerInterface // client input → instance (tool arguments) +{ + public function denormalize(mixed $value, string $class): mixed; +} + +interface PropertyNormalizerInterface // instance → JSON (tool results) +{ + public function normalize(object $value): mixed; +} ``` -A parameter is dispatched to a describer when its type is `supportedClass()` **or any subtype of it** — so a describer -for `\DateTimeInterface` also covers `\DateTimeImmutable`, and one for `Uuid` covers `UuidV4`, `UuidV7`, etc. +A type is dispatched to a handler when it is `supportedClass()` **or any subtype of it** — so a handler for +`\DateTimeInterface` also covers `\DateTimeImmutable`, and one for `Uuid` covers `UuidV4`, `UuidV7`, etc. Handlers are +consulted in **registration order**; the first whose supported class matches wins. -Two describers ship with the SDK (both opt-in): +Two handlers ship with the SDK (both opt-in), each implementing all three directions: -| Describer | Handles | Emits | -| --- | --- | --- | -| `Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber` | any `\DateTimeInterface` | `{type: "string", format: "date-time"}` | -| `Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber` | `Symfony\Component\Uid\Uuid` (and subclasses) | `{type: "string", format: "uuid"}` | +| Handler | Handles | Schema | Upcasts / normalizes | +| --- | --- | --- | --- | +| `Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber` | any `\DateTimeInterface` | `{type: "string", format: "date-time"}` | string ⇄ `\DateTime(Immutable)` (ISO-8601) | +| `Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber` | `Symfony\Component\Uid\Uuid` (and subclasses) | `{type: "string", format: "uuid"}` | string ⇄ `Uuid` (RFC 4122) | -Register them — and your own — on the builder. Describers are consulted in **registration order**; the first one whose -supported class matches the parameter wins: +Register them — and your own — on the builder: ```php use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber; @@ -788,16 +800,33 @@ $server = Server::builder() ->build(); ``` -Now `public function schedule(\DateTimeImmutable $until)` generates `{type: "string", format: "date-time"}` for -`$until` instead of `{type: "object"}`. Docblock descriptions, defaults and nullability are still layered on top of the -describer's fragment. +With these registered, a tool like: -Writing a custom describer for a domain value object: +```php +public function getTownShopList(Uuid $id): \DateTimeImmutable +``` + +generates `{type: "string", format: "uuid"}` for `$id`, upcasts the client's `"id"` string into a real `Uuid` before the +method is called, and normalizes the returned `\DateTimeImmutable` to an ISO-8601 string in the result content. Docblock +descriptions, defaults and nullability are still layered on top of the describer's schema fragment for input parameters. + +**Schema vs. value — and the object rule.** A describer fragment is used directly as a tool's `outputSchema` **only when +it is an `object` schema**, because per the MCP spec an `outputSchema` describes the object-typed `structuredContent`. +A scalar fragment (uuid/date-time strings) is therefore *not* advertised as an output schema; such a return is +normalized to a string and carried in the result's text `content` instead. This is what makes the **DTO** case the +primary use of output schemas: a handler whose `describe()` returns `{type: "object", properties: {...}}` for your DTO +class gets that emitted as the tool's `outputSchema`, while its `normalize()` produces the matching +`structuredContent`. Note that normalization is applied to the **top-level** returned value; values nested inside a DTO +are the registered DTO handler's responsibility (e.g. delegated to a serializer — see below). + +Writing a custom handler for a domain value object — implement only the directions you need: ```php use Mcp\Capability\Discovery\PropertyDescriberInterface; +use Mcp\Capability\Discovery\PropertyDenormalizerInterface; +use Mcp\Capability\Discovery\PropertyNormalizerInterface; -final class MoneyPropertyDescriber implements PropertyDescriberInterface +final class MoneyPropertyHandler implements PropertyDescriberInterface, PropertyDenormalizerInterface, PropertyNormalizerInterface { public static function supportedClass(): string { @@ -808,14 +837,63 @@ final class MoneyPropertyDescriber implements PropertyDescriberInterface { return ['type' => 'string', 'pattern' => '^\d+(\.\d{2})? [A-Z]{3}$']; } + + public function denormalize(mixed $value, string $class): \App\Money + { + return \App\Money::fromString((string) $value); + } + + public function normalize(object $value): string + { + return (string) $value; + } } -$builder->addPropertyDescriber(new MoneyPropertyDescriber()); +$builder->addPropertyDescriber(new MoneyPropertyHandler()); ``` -To override a shipped describer, register your own for the same class **before** it — the first matching describer -wins. Note that `addPropertyDescriber()` cannot be combined with `setSchemaGenerator()` — if you supply your own -`SchemaGeneratorInterface`, configure the describers on that generator directly. +#### Delegating whole DTOs to a serializer + +Because `describe()` may return any schema fragment and `denormalize()`/`normalize()` receive the concrete class, a +single handler registered against a DTO base class (or marker interface) can cover **all** your DTOs by delegating to a +serializer you already use — e.g. `symfony/serializer` — instead of the SDK reflecting your objects: + +```php +use Mcp\Capability\Discovery\PropertyDenormalizerInterface; +use Mcp\Capability\Discovery\PropertyNormalizerInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class SerializerDtoHandler implements PropertyDenormalizerInterface, PropertyNormalizerInterface +{ + public function __construct(private NormalizerInterface&DenormalizerInterface $serializer) + { + } + + public static function supportedClass(): string + { + return \App\Dto\AbstractDto::class; + } + + public function denormalize(mixed $value, string $class): object + { + return $this->serializer->denormalize($value, $class); + } + + public function normalize(object $value): mixed + { + return $this->serializer->normalize($value); + } +} +``` + +(For the output **schema** of such DTOs, also implement `PropertyDescriberInterface` and return the nested schema — +assembled however you like, e.g. via `symfony/property-info` or `api-platform/json-schema`. The SDK itself does not +reflect class properties.) + +To override a shipped handler, register your own for the same class **before** it — the first match wins. Note that +`addPropertyDescriber()` cannot be combined with `setSchemaGenerator()` (configure describers on your own generator +instead) nor with `setReferenceHandler()` (wire the handlers onto your own reference handler instead). ## Discovery vs Manual Registration diff --git a/docs/server-builder.md b/docs/server-builder.md index a04b0ba8..70ae1b43 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -619,5 +619,5 @@ $server = Server::builder() | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | | `addPrompt()` | handler, name?, description? | Register prompt | -| `addPropertyDescriber()` | describer | Register a [property describer](mcp-elements.md#custom-type-describers) for class-typed parameters | +| `addPropertyDescriber()` | handler | Register a [property handler](mcp-elements.md#custom-type-describers) (schema / input upcasting / output normalization) for a class-typed value object | | `build()` | - | Create the server instance | diff --git a/src/Capability/Discovery/PropertyDenormalizerInterface.php b/src/Capability/Discovery/PropertyDenormalizerInterface.php new file mode 100644 index 00000000..4d230e3c --- /dev/null +++ b/src/Capability/Discovery/PropertyDenormalizerInterface.php @@ -0,0 +1,33 @@ + 'string', 'format' => 'date-time']; } + + public function denormalize(mixed $value, string $class): \DateTimeInterface + { + if ($value instanceof \DateTimeInterface) { + return $value; + } + + return \DateTime::class === $class + ? new \DateTime((string) $value) + : new \DateTimeImmutable((string) $value); + } + + public function normalize(object $value): string + { + \assert($value instanceof \DateTimeInterface); + + return $value->format(\DateTimeInterface::ATOM); + } } diff --git a/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php b/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php index e4a1245f..04011e30 100644 --- a/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php +++ b/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php @@ -11,14 +11,18 @@ namespace Mcp\Capability\Discovery\PropertyDescriber; +use Mcp\Capability\Discovery\PropertyDenormalizerInterface; use Mcp\Capability\Discovery\PropertyDescriberInterface; +use Mcp\Capability\Discovery\PropertyNormalizerInterface; use Symfony\Component\Uid\Uuid; /** - * Describes Symfony UID {@see Uuid} (and subclasses like `UuidV4`, `UuidV7`) - * as a uuid-format string. + * Handles Symfony UID {@see Uuid} (and subclasses like `UuidV4`, `UuidV7`): + * describes it as a uuid-format string, upcasts an incoming string into a + * {@see Uuid} instance, and renders a returned instance back to its RFC 4122 + * string form. */ -final class UuidPropertyDescriber implements PropertyDescriberInterface +final class UuidPropertyDescriber implements PropertyDescriberInterface, PropertyDenormalizerInterface, PropertyNormalizerInterface { public static function supportedClass(): string { @@ -29,4 +33,21 @@ public function describe(): array { return ['type' => 'string', 'format' => 'uuid']; } + + public function denormalize(mixed $value, string $class): Uuid + { + if ($value instanceof Uuid) { + return $value; + } + + // Uuid::fromString detects the version and returns the matching subtype. + return Uuid::fromString((string) $value); + } + + public function normalize(object $value): string + { + \assert($value instanceof Uuid); + + return (string) $value; + } } diff --git a/src/Capability/Discovery/PropertyDescriberInterface.php b/src/Capability/Discovery/PropertyDescriberInterface.php index 7aba7b96..9558ff04 100644 --- a/src/Capability/Discovery/PropertyDescriberInterface.php +++ b/src/Capability/Discovery/PropertyDescriberInterface.php @@ -15,23 +15,15 @@ * Translates a PHP class type into a JSON Schema fragment. * * A describer declares the class (or base class/interface) it handles via - * {@see self::supportedClass()}. The {@see SchemaGenerator} matches a - * parameter's concrete class against that type — directly or through its - * parents and interfaces — and, when several describers are registered, - * consults them in priority order. Implementations let callers teach the - * generator about value-object types (DateTime, Uuid, etc.) whose JSON Schema - * representation is more specific than a generic `{type: "object"}`. + * {@see PropertyHandlerInterface::supportedClass()}. The {@see SchemaGenerator} + * matches a parameter's or return type's concrete class against that type — + * directly or through its parents and interfaces — and, when several describers + * are registered, consults them in priority order. Implementations let callers + * teach the generator about value-object types (DateTime, Uuid, etc.) whose JSON + * Schema representation is more specific than a generic `{type: "object"}`. */ -interface PropertyDescriberInterface +interface PropertyDescriberInterface extends PropertyHandlerInterface { - /** - * The class or interface this describer handles. Parameters whose type is - * the class itself or any subtype of it are dispatched to this describer. - * - * @return class-string - */ - public static function supportedClass(): string; - /** * @return array Schema fragment for the supported type */ diff --git a/src/Capability/Discovery/PropertyHandlerInterface.php b/src/Capability/Discovery/PropertyHandlerInterface.php new file mode 100644 index 00000000..1f89c7f6 --- /dev/null +++ b/src/Capability/Discovery/PropertyHandlerInterface.php @@ -0,0 +1,35 @@ + + */ + private readonly array $handlers; + + /** + * Holds the matching handler or `false` when none matched, keyed by + * `"$className\0$concern"`. + * + * @var array + */ + private array $cache = []; + + /** + * @param iterable $handlers + */ + public function __construct(iterable $handlers = []) + { + $this->handlers = \is_array($handlers) + ? array_values($handlers) + : iterator_to_array($handlers, false); + } + + /** + * @template T of PropertyHandlerInterface + * + * @param class-string $className the concrete type to resolve a handler for + * @param class-string $concern the concern interface the handler must implement + * + * @return T|null + */ + public function resolve(string $className, string $concern): ?PropertyHandlerInterface + { + $key = $className."\0".$concern; + $cached = $this->cache[$key] ??= $this->find($className, $concern) ?? false; + + return $cached ?: null; + } + + /** + * @param class-string $className + * @param class-string $concern + */ + private function find(string $className, string $concern): ?PropertyHandlerInterface + { + foreach ($this->handlers as $handler) { + if ($handler instanceof $concern && is_a($className, $handler::supportedClass(), true)) { + return $handler; + } + } + + return null; + } +} diff --git a/src/Capability/Discovery/PropertyNormalizerInterface.php b/src/Capability/Discovery/PropertyNormalizerInterface.php new file mode 100644 index 00000000..09e37b85 --- /dev/null +++ b/src/Capability/Discovery/PropertyNormalizerInterface.php @@ -0,0 +1,30 @@ + Consulted in registration order before generic class - * inspection; the first matching describer wins - */ - private readonly array $propertyDescribers; + private readonly PropertyHandlerResolver $handlerResolver; /** - * Memoizes describer resolution per concrete class. Holds the matching - * describer or `false` when none matched, so repeated parameter types — - * and the common "no describer" case — are resolved at most once. - * - * @var array - */ - private array $describerCache = []; - - /** - * @param iterable $propertyDescribers Consulted in order before - * generic class inspection; the - * first matching describer wins + * @param iterable $propertyHandlers Consulted in registration order; + * the first whose supported class + * matches a type wins */ public function __construct( private readonly DocBlockParser $docBlockParser, - iterable $propertyDescribers = [], + iterable $propertyHandlers = [], ) { - $this->propertyDescribers = \is_array($propertyDescribers) - ? array_values($propertyDescribers) - : iterator_to_array($propertyDescribers, false); + $this->handlerResolver = new PropertyHandlerResolver($propertyHandlers); } /** @@ -133,15 +118,29 @@ public function generateOutputSchema(\Reflector $reflection): ?array throw new BadMethodCallException(\sprintf('Schema generation from %s is not supported. Use ReflectionMethod or ReflectionFunction instead.', $reflection::class)); } - // Only return outputSchema if explicitly provided in McpTool attribute + // An explicit outputSchema on the McpTool attribute always wins. $mcpToolAttrs = $reflection->getAttributes(McpTool::class, \ReflectionAttribute::IS_INSTANCEOF); if ($mcpToolAttrs) { - $mcpToolInstance = $mcpToolAttrs[0]->newInstance(); + $explicit = $mcpToolAttrs[0]->newInstance()->outputSchema; + if (null !== $explicit) { + return $explicit; + } + } - return $mcpToolInstance->outputSchema; + // Otherwise fall back to a describer registered for the return type. + $returnType = $reflection->getReturnType(); + if (!$returnType instanceof \ReflectionNamedType || $returnType->isBuiltin()) { + return null; } - return null; + $schema = $this->handlerResolver->resolve($returnType->getName(), PropertyDescriberInterface::class)?->describe(); + + // An output schema describes a tool's structuredContent, which is a JSON + // object. A scalar describer fragment (e.g. a uuid-format string) is valid + // as an input property but not as a top-level output schema — emitting it + // would violate the spec and Tool::fromArray. Such returns are surfaced as + // text content only (after normalization), with no output schema. + return isset($schema['type']) && 'object' === $schema['type'] ? $schema : null; } /** @@ -398,35 +397,7 @@ private function describeClassType(array $paramInfo): ?array return null; } - return $this->resolveDescriber($reflectionType->getName())?->describe(); - } - - /** - * Resolves the describer for a concrete class, matching against each - * describer's {@see PropertyDescriberInterface::supportedClass()} (the - * class itself or any subtype). Results are memoized per class. - * - * @param class-string $className - */ - private function resolveDescriber(string $className): ?PropertyDescriberInterface - { - $cached = $this->describerCache[$className] ??= $this->findDescriber($className) ?? false; - - return $cached ?: null; - } - - /** - * @param class-string $className - */ - private function findDescriber(string $className): ?PropertyDescriberInterface - { - foreach ($this->propertyDescribers as $describer) { - if (is_a($className, $describer::supportedClass(), true)) { - return $describer; - } - } - - return null; + return $this->handlerResolver->resolve($reflectionType->getName(), PropertyDescriberInterface::class)?->describe(); } /** diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index 8865b0ff..6bbaf46a 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -115,6 +115,7 @@ public function load(RegistryInterface $registry): void } $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); + $outputSchema = $data['outputSchema'] ?? $schemaGenerator->generateOutputSchema($reflection); $tool = new Tool( name: $name, @@ -124,7 +125,7 @@ public function load(RegistryInterface $registry): void annotations: $data['annotations'] ?? null, icons: $data['icons'] ?? null, meta: $data['meta'] ?? null, - outputSchema: $data['outputSchema'] ?? null, + outputSchema: $outputSchema, ); $registry->registerTool($tool, $data['handler']); diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 7b4a0cdc..5afacfde 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -11,6 +11,9 @@ namespace Mcp\Capability\Registry; +use Mcp\Capability\Discovery\PropertyDenormalizerInterface; +use Mcp\Capability\Discovery\PropertyHandlerInterface; +use Mcp\Capability\Discovery\PropertyHandlerResolver; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; use Mcp\Server\RequestContext; @@ -22,9 +25,17 @@ */ final class ReferenceHandler implements ReferenceHandlerInterface { + private readonly PropertyHandlerResolver $handlerResolver; + + /** + * @param iterable $propertyHandlers Consulted to upcast class-typed + * arguments into instances + */ public function __construct( private readonly ?ContainerInterface $container = null, + iterable $propertyHandlers = [], ) { + $this->handlerResolver = new PropertyHandlerResolver($propertyHandlers); } /** @@ -198,6 +209,34 @@ private function castArgumentType(mixed $argument, \ReflectionParameter $paramet throw new InvalidArgumentException("Invalid value type '{$argument}' for unit enum {$typeName}. Expected a string matching a case name."); } + if (!$type->isBuiltin()) { + if ($argument instanceof $typeName) { + return $argument; + } + + $denormalizer = $this->handlerResolver->resolve($typeName, PropertyDenormalizerInterface::class); + if (null === $denormalizer) { + return $argument; + } + + try { + $denormalized = $denormalizer->denormalize($argument, $typeName); + } catch (InvalidArgumentException $e) { + throw $e; + } catch (\Throwable $e) { + throw new InvalidArgumentException(\sprintf('Value could not be denormalized into `%s`: %s', $typeName, $e->getMessage()), 0, $e); + } + + // Guard against a denormalizer that produces the wrong concrete type + // (e.g. a UuidV7 for a `UuidV4` parameter) so it surfaces as invalid + // input rather than a TypeError at call time reported as an internal error. + if (!$denormalized instanceof $typeName) { + throw new InvalidArgumentException(\sprintf('Denormalized value for `%s` is not an instance of that type.', $typeName)); + } + + return $denormalized; + } + try { return match (strtolower($typeName)) { 'int', 'integer' => $this->castToInt($argument), diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 97200395..c9d7e6d8 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -15,7 +15,10 @@ use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DiscovererInterface; use Mcp\Capability\Discovery\DocBlockParser; +use Mcp\Capability\Discovery\PropertyDenormalizerInterface; use Mcp\Capability\Discovery\PropertyDescriberInterface; +use Mcp\Capability\Discovery\PropertyHandlerInterface; +use Mcp\Capability\Discovery\PropertyNormalizerInterface; use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Capability\Discovery\SchemaGeneratorInterface; use Mcp\Capability\Registry; @@ -183,9 +186,9 @@ final class Builder private array $loaders = []; /** - * @var array + * @var array */ - private array $propertyDescribers = []; + private array $propertyHandlers = []; /** * Sets the server's identity. Required. @@ -549,17 +552,22 @@ public function addLoaders(iterable $loaders): self } /** - * Registers a property describer that teaches the schema generator how to - * render a value-object class (e.g. DateTime, Uuid) as a targeted JSON - * Schema fragment instead of a generic `{type: "object"}`. + * Registers a property handler for a value-object class (e.g. DateTime, Uuid). + * Depending on the interfaces it implements, a handler teaches the SDK how to + * render the class in the JSON Schema ({@see PropertyDescriberInterface}), + * upcast incoming client input into an instance + * ({@see PropertyDenormalizerInterface}) and/or normalize a returned instance + * back to JSON ({@see PropertyNormalizerInterface}). A single class may + * implement any combination of them. * - * Describers are consulted in registration order; the first one whose - * supported class matches the parameter wins. Cannot be combined with a - * generator set via setSchemaGenerator(). + * Handlers are consulted in registration order; the first one whose supported + * class matches the type wins. Describing cannot be combined with a generator + * set via setSchemaGenerator(), nor denormalization with a handler set via + * setReferenceHandler(). */ - public function addPropertyDescriber(PropertyDescriberInterface $describer): self + public function addPropertyDescriber(PropertyHandlerInterface $handler): self { - $this->propertyDescribers[] = $describer; + $this->propertyHandlers[] = $handler; return $this; } @@ -617,10 +625,14 @@ public function build(): Server $serverInfo = $this->serverInfo ?? new Implementation(); $configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); - $referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container); + + if ([] !== $this->propertyHandlers && null !== $this->referenceHandler) { + throw new InvalidArgumentException('Cannot combine addPropertyDescriber() with a handler set via setReferenceHandler(). Configure the property handlers on that handler instead.'); + } + $referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container, $this->propertyHandlers); $requestHandlers = array_merge($this->requestHandlers, [ - new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger), + new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger, propertyHandlers: $this->propertyHandlers), new Handler\Request\CompletionCompleteHandler($registry, $container), new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), new Handler\Request\InitializeHandler($configuration), @@ -663,13 +675,13 @@ private function createDiscoverer(LoggerInterface $logger, ?SchemaGeneratorInter } /** - * Builds the schema generator from registered property describers, or + * Builds the schema generator from registered property handlers, or * returns the explicitly configured one. The two are mutually exclusive: - * describers belong on the explicit generator if one is set. + * handlers belong on the explicit generator if one is set. */ private function resolveSchemaGenerator(LoggerInterface $logger): ?SchemaGeneratorInterface { - if ([] === $this->propertyDescribers) { + if ([] === $this->propertyHandlers) { return $this->schemaGenerator; } @@ -677,6 +689,6 @@ private function resolveSchemaGenerator(LoggerInterface $logger): ?SchemaGenerat throw new InvalidArgumentException('Cannot combine addPropertyDescriber() with a generator set via setSchemaGenerator(). Configure the describers on that generator instead.'); } - return new SchemaGenerator(new DocBlockParser(logger: $logger), $this->propertyDescribers); + return new SchemaGenerator(new DocBlockParser(logger: $logger), $this->propertyHandlers); } } diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index e78ce1b9..e96ae822 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -11,11 +11,15 @@ namespace Mcp\Server\Handler\Request; +use Mcp\Capability\Discovery\PropertyHandlerInterface; +use Mcp\Capability\Discovery\PropertyHandlerResolver; +use Mcp\Capability\Discovery\PropertyNormalizerInterface; use Mcp\Capability\Discovery\SchemaValidator; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; +use Mcp\Schema\Content\Content; use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; @@ -35,14 +39,21 @@ final class CallToolHandler implements RequestHandlerInterface { private SchemaValidator $schemaValidator; + private PropertyHandlerResolver $handlerResolver; + /** + * @param iterable $propertyHandlers Consulted to normalize a class-typed + * tool result before it is encoded + */ public function __construct( private readonly RegistryInterface $registry, private readonly ReferenceHandlerInterface $referenceHandler, private readonly LoggerInterface $logger = new NullLogger(), ?SchemaValidator $schemaValidator = null, + iterable $propertyHandlers = [], ) { $this->schemaValidator = $schemaValidator ?? new SchemaValidator($logger); + $this->handlerResolver = new PropertyHandlerResolver($propertyHandlers); } public function supports(Request $request): bool @@ -95,6 +106,13 @@ public function handle(Request $request, SessionInterface $session): Response|Er try { $result = $this->referenceHandler->handle($reference, $arguments); + if (\is_object($result) && !$result instanceof CallToolResult && !$result instanceof Content) { + $normalizer = $this->handlerResolver->resolve($result::class, PropertyNormalizerInterface::class); + if (null !== $normalizer) { + $result = $normalizer->normalize($result); + } + } + $structuredContent = null; if (!$result instanceof CallToolResult) { $structuredContent = $reference->extractStructuredContent($result); diff --git a/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php b/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php index d3ab27a1..5d22a352 100644 --- a/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php +++ b/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php @@ -35,4 +35,33 @@ public function testDescribesAsIsoDateTimeString(): void $this->describer->describe(), ); } + + public function testDenormalizesStringIntoDateTimeImmutableByDefault(): void + { + $date = $this->describer->denormalize('2026-05-26T10:00:00+00:00', \DateTimeInterface::class); + + $this->assertInstanceOf(\DateTimeImmutable::class, $date); + $this->assertSame('2026-05-26T10:00:00+00:00', $date->format(\DateTimeInterface::ATOM)); + } + + public function testDenormalizesIntoConcreteMutableDateTimeWhenTargeted(): void + { + $date = $this->describer->denormalize('2026-05-26T10:00:00+00:00', \DateTime::class); + + $this->assertInstanceOf(\DateTime::class, $date); + } + + public function testDenormalizePassesThroughExistingInstance(): void + { + $date = new \DateTimeImmutable('2026-05-26T10:00:00+00:00'); + + $this->assertSame($date, $this->describer->denormalize($date, \DateTimeInterface::class)); + } + + public function testNormalizesInstanceToIso8601String(): void + { + $date = new \DateTimeImmutable('2026-05-26T10:00:00+00:00'); + + $this->assertSame('2026-05-26T10:00:00+00:00', $this->describer->normalize($date)); + } } diff --git a/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php b/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php index ac9d19d1..bfa5cc1e 100644 --- a/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php +++ b/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php @@ -36,4 +36,33 @@ public function testDescribesAsUuidFormatString(): void $this->describer->describe(), ); } + + public function testDenormalizesStringIntoUuidInstance(): void + { + $uuid = $this->describer->denormalize('9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', Uuid::class); + + $this->assertInstanceOf(Uuid::class, $uuid); + $this->assertSame('9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', $uuid->toRfc4122()); + } + + public function testDenormalizePassesThroughExistingInstance(): void + { + $uuid = Uuid::v4(); + + $this->assertSame($uuid, $this->describer->denormalize($uuid, Uuid::class)); + } + + public function testDenormalizeRejectsMalformedString(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->describer->denormalize('not-a-uuid', Uuid::class); + } + + public function testNormalizesInstanceToRfc4122String(): void + { + $uuid = Uuid::fromString('9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'); + + $this->assertSame('9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', $this->describer->normalize($uuid)); + } } diff --git a/tests/Unit/Capability/Discovery/PropertyHandlerResolverTest.php b/tests/Unit/Capability/Discovery/PropertyHandlerResolverTest.php new file mode 100644 index 00000000..6b20f190 --- /dev/null +++ b/tests/Unit/Capability/Discovery/PropertyHandlerResolverTest.php @@ -0,0 +1,97 @@ +assertInstanceOf(UuidPropertyDescriber::class, $resolver->resolve(Uuid::class, PropertyDescriberInterface::class)); + $this->assertInstanceOf(UuidPropertyDescriber::class, $resolver->resolve(Uuid::class, PropertyDenormalizerInterface::class)); + $this->assertInstanceOf(UuidPropertyDescriber::class, $resolver->resolve(Uuid::class, PropertyNormalizerInterface::class)); + } + + public function testMatchesSubtypesOfSupportedClass(): void + { + $resolver = new PropertyHandlerResolver([new UuidPropertyDescriber()]); + + $this->assertInstanceOf(UuidPropertyDescriber::class, $resolver->resolve(UuidV4::class, PropertyDescriberInterface::class)); + } + + public function testReturnsNullWhenNoHandlerSupportsClass(): void + { + $resolver = new PropertyHandlerResolver([new UuidPropertyDescriber()]); + + $this->assertNull($resolver->resolve(\DateTimeImmutable::class, PropertyDescriberInterface::class)); + } + + public function testFiltersByConcernInterface(): void + { + $describeOnly = new class implements PropertyDescriberInterface { + public static function supportedClass(): string + { + return Uuid::class; + } + + public function describe(): array + { + return ['type' => 'string']; + } + }; + + $resolver = new PropertyHandlerResolver([$describeOnly]); + + $this->assertSame($describeOnly, $resolver->resolve(Uuid::class, PropertyDescriberInterface::class)); + $this->assertNull($resolver->resolve(Uuid::class, PropertyDenormalizerInterface::class)); + } + + public function testFirstRegisteredMatchWins(): void + { + $first = new class implements PropertyDescriberInterface { + public static function supportedClass(): string + { + return \DateTimeInterface::class; + } + + public function describe(): array + { + return ['type' => 'string', 'format' => 'first']; + } + }; + + $resolver = new PropertyHandlerResolver([$first, new DateTimePropertyDescriber()]); + + $this->assertSame($first, $resolver->resolve(\DateTimeImmutable::class, PropertyDescriberInterface::class)); + } + + public function testResolutionIsStableAcrossCalls(): void + { + $resolver = new PropertyHandlerResolver([new UuidPropertyDescriber()]); + + $this->assertSame( + $resolver->resolve(Uuid::class, PropertyDescriberInterface::class), + $resolver->resolve(Uuid::class, PropertyDescriberInterface::class), + ); + } +} diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index ad8ce1f8..d685df3a 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -485,4 +485,27 @@ public function returnWithExplicitOutputSchema(): array { return ['message' => 'result']; } + + public function returnsUuid(): Uuid + { + return Uuid::v4(); + } + + public function returnsStdClass(): \stdClass + { + return new \stdClass(); + } + + #[McpTool( + outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'format' => 'explicit'], + ], + ] + )] + public function returnsUuidWithExplicitOutputSchema(): Uuid + { + return Uuid::v4(); + } } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index c4944143..8912e525 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -391,6 +391,54 @@ public function testGenerateOutputSchemaForComplexNestedSchema(): void ], $schema); } + public function testScalarReturnTypeDescriberProducesNoOutputSchema(): void + { + // A uuid/date-time return is normalized to a string in the result content, + // but a scalar fragment is not a valid output schema (which must be an + // object), so none is advertised. + $generator = new SchemaGenerator(new DocBlockParser(), [new UuidPropertyDescriber()]); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'returnsUuid'); + $this->assertNull($generator->generateOutputSchema($method)); + } + + public function testObjectReturningDescriberProducesOutputSchema(): void + { + $describer = new class implements PropertyDescriberInterface { + public static function supportedClass(): string + { + return \stdClass::class; + } + + public function describe(): array + { + return ['type' => 'object', 'properties' => ['ok' => ['type' => 'boolean']]]; + } + }; + + $generator = new SchemaGenerator(new DocBlockParser(), [$describer]); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'returnsStdClass'); + $this->assertSame( + ['type' => 'object', 'properties' => ['ok' => ['type' => 'boolean']]], + $generator->generateOutputSchema($method), + ); + } + + public function testExplicitOutputSchemaWinsOverReturnTypeDescriber(): void + { + $generator = new SchemaGenerator(new DocBlockParser(), [new UuidPropertyDescriber()]); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'returnsUuidWithExplicitOutputSchema'); + $this->assertSame( + ['type' => 'object', 'properties' => ['id' => ['type' => 'string', 'format' => 'explicit']]], + $generator->generateOutputSchema($method), + ); + } + + public function testGenerateOutputSchemaIsNullForClassReturnTypeWithoutDescriber(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'returnsUuid'); + $this->assertNull($this->schemaGenerator->generateOutputSchema($method)); + } + // ===== PROPERTY DESCRIBER INTEGRATION ===== public function testFallsBackToObjectWhenNoDescriberClaimsClassType(): void diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php new file mode 100644 index 00000000..40827800 --- /dev/null +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -0,0 +1,99 @@ + $id->toRfc4122()); + + $result = $handler->handle($reference, [ + 'id' => '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', + '_session' => $this->createStub(SessionInterface::class), + ]); + + $this->assertSame('9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', $result); + } + + public function testPassesThroughArgumentAlreadyOfTargetType(): void + { + $handler = new ReferenceHandler(null, [new UuidPropertyDescriber()]); + $uuid = Uuid::v4(); + $reference = new ElementReference(static fn (Uuid $id): Uuid => $id); + + $result = $handler->handle($reference, [ + 'id' => $uuid, + '_session' => $this->createStub(SessionInterface::class), + ]); + + $this->assertSame($uuid, $result); + } + + public function testMalformedValueForDenormalizedTypeMapsToInvalidParams(): void + { + $handler = new ReferenceHandler(null, [new UuidPropertyDescriber()]); + $reference = new ElementReference(static fn (Uuid $id): string => $id->toRfc4122()); + + try { + $handler->handle($reference, [ + 'id' => 'not-a-uuid', + '_session' => $this->createStub(SessionInterface::class), + ]); + $this->fail('Expected a RegistryException'); + } catch (RegistryException $e) { + $this->assertSame(Error::INVALID_PARAMS, $e->getCode()); + } + } + + public function testDenormalizedSubtypeMismatchMapsToInvalidParams(): void + { + // A v7 UUID string denormalizes to a UuidV7, which is not a UuidV4; this + // must surface as invalid params, not a TypeError reported as an internal error. + $handler = new ReferenceHandler(null, [new UuidPropertyDescriber()]); + $reference = new ElementReference(static fn (UuidV4 $id): string => (string) $id); + + try { + $handler->handle($reference, [ + 'id' => (string) Uuid::v7(), + '_session' => $this->createStub(SessionInterface::class), + ]); + $this->fail('Expected a RegistryException'); + } catch (RegistryException $e) { + $this->assertSame(Error::INVALID_PARAMS, $e->getCode()); + } + } + + public function testBuiltinCastingIsUnaffectedWhenNoHandlerRegistered(): void + { + $handler = new ReferenceHandler(); + $reference = new ElementReference(static fn (int $n): int => $n); + + $result = $handler->handle($reference, [ + 'n' => '42', + '_session' => $this->createStub(SessionInterface::class), + ]); + + $this->assertSame(42, $result); + } +} diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 07ee3e90..41213aee 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -12,6 +12,7 @@ namespace Mcp\Tests\Unit\Server; use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber; +use Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber; use Mcp\Capability\Discovery\PropertyDescriberInterface; use Mcp\Capability\Discovery\SchemaGeneratorInterface; use Mcp\Capability\Registry\ElementReference; @@ -28,6 +29,7 @@ use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\Uuid; final class BuilderTest extends TestCase { @@ -141,6 +143,76 @@ public function testAddPropertyDescriberConflictsWithExplicitGenerator(): void $builder->build(); } + #[TestDox('addPropertyDescriber() cannot be combined with setReferenceHandler()')] + public function testAddPropertyDescriberConflictsWithReferenceHandler(): void + { + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setReferenceHandler($this->createStub(ReferenceHandlerInterface::class)) + ->addPropertyDescriber(new UuidPropertyDescriber()); + + $this->expectException(InvalidArgumentException::class); + + $builder->build(); + } + + #[TestDox('A registered handler upcasts a class-typed tool argument from client input')] + public function testAddPropertyDescriberUpcastsToolArguments(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addPropertyDescriber(new UuidPropertyDescriber()) + ->addTool(static fn (Uuid $id): string => $id->toRfc4122(), name: 'echo_uuid', description: 'A tool') + ->build(); + + $result = $this->callToolWithArguments($server, 'echo_uuid', ['id' => '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d']); + + $this->assertSame('9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', $result); + } + + #[TestDox('A registered handler normalizes a scalar value-object result to a string (no output schema)')] + public function testAddPropertyDescriberNormalizesToolResult(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addPropertyDescriber(new DateTimePropertyDescriber()) + ->addTool(static fn (): \DateTimeImmutable => new \DateTimeImmutable('2026-05-26T10:00:00+00:00'), name: 'now_tool', description: 'A tool') + ->build(); + + // The value comes back as a single ISO-8601 string in the content... + $this->assertSame('2026-05-26T10:00:00+00:00', $this->callTool($server, 'now_tool')); + // ...and no output schema is advertised, because a scalar is not a valid + // MCP output schema (which describes the object-typed structuredContent). + $this->assertNull($this->toolOutputSchema($server, 'now_tool')); + } + + #[TestDox('An object-returning describer applies to the generated tool output schema')] + public function testObjectDescriberAppliesToGeneratedOutputSchema(): void + { + $describer = new class implements PropertyDescriberInterface { + public static function supportedClass(): string + { + return \stdClass::class; + } + + public function describe(): array + { + return ['type' => 'object', 'properties' => ['ok' => ['type' => 'boolean']]]; + } + }; + + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addPropertyDescriber($describer) + ->addTool(static fn (): \stdClass => new \stdClass(), name: 'make_obj', description: 'A tool') + ->build(); + + $this->assertSame( + ['type' => 'object', 'properties' => ['ok' => ['type' => 'boolean']]], + $this->toolOutputSchema($server, 'make_obj'), + ); + } + /** * @return array */ @@ -169,6 +241,14 @@ private function toolInputSchema(Server $server, string $toolName): array } private function callTool(Server $server, string $toolName): mixed + { + return $this->callToolWithArguments($server, $toolName, []); + } + + /** + * @param array $arguments + */ + private function callToolWithArguments(Server $server, string $toolName, array $arguments): mixed { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); @@ -179,7 +259,7 @@ private function callTool(Server $server, string $toolName): mixed 'jsonrpc' => '2.0', 'method' => 'tools/call', 'id' => 'test-1', - 'params' => ['name' => $toolName, 'arguments' => []], + 'params' => ['name' => $toolName, 'arguments' => $arguments], ]); $session = $this->createStub(SessionInterface::class); @@ -197,4 +277,31 @@ private function callTool(Server $server, string $toolName): mixed $this->fail('CallToolHandler not found in request handlers'); } + + /** + * @return array|null + */ + private function toolOutputSchema(Server $server, string $toolName): ?array + { + $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); + $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); + + foreach ($requestHandlers as $handler) { + if ($handler instanceof ListToolsHandler) { + $request = (new ListToolsRequest())->withId('test-1'); + $response = $handler->handle($request, $this->createStub(SessionInterface::class)); + \assert($response->result instanceof ListToolsResult); + + foreach ($response->result->tools as $tool) { + if ($tool->name === $toolName) { + return $tool->outputSchema; + } + } + + $this->fail(\sprintf('Tool "%s" not found in tools/list result', $toolName)); + } + } + + $this->fail('ListToolsHandler not found in request handlers'); + } } diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index b804f76a..4c6be1b7 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; +use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ToolReference; use Mcp\Capability\RegistryInterface; @@ -514,6 +515,41 @@ public function testValidationError(): void $this->assertEquals(Error::INVALID_PARAMS, $response->code); } + public function testHandleNormalizesClassTypedResultBeforeFormatting(): void + { + $request = $this->createCallToolRequest('when_tool', []); + $schema = ['type' => 'object', 'properties' => ['example' => ['type' => 'string']], 'required' => []]; + $tool = new Tool('when_tool', null, $schema, null, null); + $toolReference = new ToolReference($tool, static fn (): \DateTimeImmutable => new \DateTimeImmutable('2026-05-26T10:00:00+00:00')); + + $this->registry + ->expects($this->once()) + ->method('getTool') + ->with('when_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->willReturn(new \DateTimeImmutable('2026-05-26T10:00:00+00:00')); + + $handler = new CallToolHandler( + $this->registry, + $this->referenceHandler, + $this->logger, + propertyHandlers: [new DateTimePropertyDescriber()], + ); + + $response = $handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $result = $response->result; + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('2026-05-26T10:00:00+00:00', $result->content[0]->text); + } + /** * @param array $arguments */