Skip to content

Commit cd2ef84

Browse files
committed
Add PropertyDescriberInterface extension point for SchemaGenerator
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.
1 parent 3e45b1f commit cd2ef84

8 files changed

Lines changed: 401 additions & 2 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Capability\Discovery\PropertyDescriber;
13+
14+
use Mcp\Capability\Discovery\PropertyDescriberInterface;
15+
16+
/**
17+
* Describes any {@see \DateTimeInterface} implementation as an ISO-8601
18+
* date-time string.
19+
*/
20+
final class DateTimePropertyDescriber implements PropertyDescriberInterface
21+
{
22+
public function describe(string $className): ?array
23+
{
24+
if (!is_a($className, \DateTimeInterface::class, true)) {
25+
return null;
26+
}
27+
28+
return ['type' => 'string', 'format' => 'date-time'];
29+
}
30+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Capability\Discovery\PropertyDescriber;
13+
14+
use Mcp\Capability\Discovery\PropertyDescriberInterface;
15+
use Symfony\Component\Uid\Uuid;
16+
17+
/**
18+
* Describes Symfony UID {@see Uuid} (and subclasses like `UuidV4`, `UuidV7`)
19+
* as a uuid-format string.
20+
*/
21+
final class UuidPropertyDescriber implements PropertyDescriberInterface
22+
{
23+
public function describe(string $className): ?array
24+
{
25+
if (!is_a($className, Uuid::class, true)) {
26+
return null;
27+
}
28+
29+
return ['type' => 'string', 'format' => 'uuid'];
30+
}
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Capability\Discovery;
13+
14+
/**
15+
* Translates a PHP class type into a JSON Schema fragment.
16+
*
17+
* The {@see SchemaGenerator} consults registered describers, in order, before
18+
* falling back to generic class inspection. The first describer that returns
19+
* a non-null schema wins. Implementations let callers teach the generator
20+
* about value-object types (DateTime, Uuid, etc.) whose JSON Schema
21+
* representation is more specific than a generic `{type: "object"}`.
22+
*/
23+
interface PropertyDescriberInterface
24+
{
25+
/**
26+
* @param class-string $className
27+
*
28+
* @return array<string, mixed>|null Schema fragment, or null to pass to the next describer
29+
*/
30+
public function describe(string $className): ?array;
31+
}

src/Capability/Discovery/SchemaGenerator.php

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,14 @@
6060
*/
6161
final class SchemaGenerator implements SchemaGeneratorInterface
6262
{
63+
/**
64+
* @param iterable<PropertyDescriberInterface> $propertyDescribers Consulted, in order, before
65+
* generic class inspection;
66+
* first non-null result wins
67+
*/
6368
public function __construct(
6469
private readonly DocBlockParser $docBlockParser,
70+
private readonly iterable $propertyDescribers = [],
6571
) {
6672
}
6773

@@ -253,13 +259,22 @@ private function buildParameterSchema(array $paramInfo, ?array $methodLevelParam
253259
*/
254260
private function buildInferredParameterSchema(array $paramInfo): array
255261
{
256-
$paramSchema = [];
257-
258262
// Variadic parameters are handled separately
259263
if ($paramInfo['is_variadic']) {
260264
return [];
261265
}
262266

267+
// Consult property describers for class-typed parameters first; the
268+
// first describer that claims the class (returns non-null) wins. This
269+
// lets callers teach the generator about value-object types like
270+
// DateTime, Uuid, Money, etc. without subclassing the generator.
271+
$describedSchema = $this->describeClassType($paramInfo);
272+
if (null !== $describedSchema) {
273+
return $this->applyParameterMetadata($describedSchema, $paramInfo);
274+
}
275+
276+
$paramSchema = [];
277+
263278
// Infer JSON Schema types
264279
$jsonTypes = $this->inferParameterTypes($paramInfo);
265280

@@ -349,6 +364,64 @@ private function inferParameterTypes(array $paramInfo): array
349364
return $jsonTypes;
350365
}
351366

367+
/**
368+
* Looks for a matching describer when the parameter's PHP type is a
369+
* concrete class. Returns the first non-null describer result, or null
370+
* if no describer claimed the type.
371+
*
372+
* @param ParameterInfo $paramInfo
373+
*
374+
* @return array<string, mixed>|null
375+
*/
376+
private function describeClassType(array $paramInfo): ?array
377+
{
378+
$reflectionType = $paramInfo['reflection_type_object'];
379+
if (!$reflectionType instanceof \ReflectionNamedType || $reflectionType->isBuiltin()) {
380+
return null;
381+
}
382+
383+
$className = $reflectionType->getName();
384+
foreach ($this->propertyDescribers as $describer) {
385+
$described = $describer->describe($className);
386+
if (null !== $described) {
387+
return $described;
388+
}
389+
}
390+
391+
return null;
392+
}
393+
394+
/**
395+
* Layers parameter-level metadata (description, default, nullable) onto
396+
* a describer-provided schema fragment without overwriting fields the
397+
* describer already set.
398+
*
399+
* @param array<string, mixed> $schema
400+
* @param ParameterInfo $paramInfo
401+
*
402+
* @return array<string, mixed>
403+
*/
404+
private function applyParameterMetadata(array $schema, array $paramInfo): array
405+
{
406+
if ($paramInfo['description'] && !isset($schema['description'])) {
407+
$schema['description'] = $paramInfo['description'];
408+
}
409+
410+
if ($paramInfo['has_default'] && !isset($schema['default'])) {
411+
$schema['default'] = $paramInfo['default_value'];
412+
}
413+
414+
if ($paramInfo['allows_null'] && isset($schema['type'])) {
415+
$types = \is_array($schema['type']) ? $schema['type'] : [$schema['type']];
416+
if (!\in_array('null', $types, true)) {
417+
$types[] = 'null';
418+
}
419+
$schema['type'] = 1 === \count($types) ? $types[0] : $types;
420+
}
421+
422+
return $schema;
423+
}
424+
352425
/**
353426
* Applies enum constraints to parameter schema.
354427
*/
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Tests\Unit\Capability\Discovery\PropertyDescriber;
13+
14+
use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber;
15+
use PHPUnit\Framework\TestCase;
16+
17+
final class DateTimePropertyDescriberTest extends TestCase
18+
{
19+
private DateTimePropertyDescriber $describer;
20+
21+
protected function setUp(): void
22+
{
23+
$this->describer = new DateTimePropertyDescriber();
24+
}
25+
26+
public function testDescribesDateTimeInterfaceAsIsoDateTimeString(): void
27+
{
28+
$this->assertSame(
29+
['type' => 'string', 'format' => 'date-time'],
30+
$this->describer->describe(\DateTimeInterface::class),
31+
);
32+
}
33+
34+
public function testDescribesDateTimeImplementations(): void
35+
{
36+
$this->assertSame(
37+
['type' => 'string', 'format' => 'date-time'],
38+
$this->describer->describe(\DateTime::class),
39+
);
40+
$this->assertSame(
41+
['type' => 'string', 'format' => 'date-time'],
42+
$this->describer->describe(\DateTimeImmutable::class),
43+
);
44+
}
45+
46+
public function testPassesOnUnrelatedClass(): void
47+
{
48+
$this->assertNull($this->describer->describe(\stdClass::class));
49+
$this->assertNull($this->describer->describe(\Exception::class));
50+
}
51+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Tests\Unit\Capability\Discovery\PropertyDescriber;
13+
14+
use Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\Component\Uid\Uuid;
17+
use Symfony\Component\Uid\UuidV4;
18+
use Symfony\Component\Uid\UuidV6;
19+
20+
final class UuidPropertyDescriberTest extends TestCase
21+
{
22+
private UuidPropertyDescriber $describer;
23+
24+
protected function setUp(): void
25+
{
26+
$this->describer = new UuidPropertyDescriber();
27+
}
28+
29+
public function testDescribesUuidAsUuidFormatString(): void
30+
{
31+
$this->assertSame(
32+
['type' => 'string', 'format' => 'uuid'],
33+
$this->describer->describe(Uuid::class),
34+
);
35+
}
36+
37+
public function testDescribesUuidSubclasses(): void
38+
{
39+
$this->assertSame(
40+
['type' => 'string', 'format' => 'uuid'],
41+
$this->describer->describe(UuidV4::class),
42+
);
43+
$this->assertSame(
44+
['type' => 'string', 'format' => 'uuid'],
45+
$this->describer->describe(UuidV6::class),
46+
);
47+
}
48+
49+
public function testPassesOnUnrelatedClass(): void
50+
{
51+
$this->assertNull($this->describer->describe(\stdClass::class));
52+
$this->assertNull($this->describer->describe(\DateTime::class));
53+
}
54+
}

tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,31 @@ public function withParameterNamedRequest(string $_request): void
438438
{
439439
}
440440

441+
// ===== PROPERTY DESCRIBER FIXTURES =====
442+
443+
public function dateTimeParam(\DateTimeImmutable $createdAt): void
444+
{
445+
}
446+
447+
/**
448+
* @param \DateTimeInterface $until The cutoff timestamp
449+
*/
450+
public function dateTimeWithDescription(\DateTimeInterface $until): void
451+
{
452+
}
453+
454+
public function nullableDateTimeParam(?\DateTimeImmutable $finishedAt = null): void
455+
{
456+
}
457+
458+
public function uuidParam(\Symfony\Component\Uid\Uuid $bookingId): void
459+
{
460+
}
461+
462+
public function unrelatedObjectParam(\stdClass $config): void
463+
{
464+
}
465+
441466
// ===== OUTPUT SCHEMA FIXTURES =====
442467
#[McpTool(
443468
outputSchema: [

0 commit comments

Comments
 (0)