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'], + ); + } }