Skip to content

Commit b45e298

Browse files
peter-siclaude
andcommitted
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) <noreply@anthropic.com>
1 parent cfad5b4 commit b45e298

22 files changed

Lines changed: 921 additions & 122 deletions

docs/mcp-elements.md

Lines changed: 106 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -747,35 +747,47 @@ validation requirements that cannot be achieved through the priority system.
747747

748748
### Custom Type Describers
749749

750-
When a tool parameter is type-hinted with a class, the generator falls back to `{type: "object"}` — which tells the
751-
LLM nothing about the expected shape. For value-object types (timestamps, identifiers, money, …) you can register a
752-
**property describer** that maps the class to a targeted JSON Schema fragment.
753-
754-
A describer declares the class (or base class / interface) it handles and the fragment to emit:
750+
When a tool parameter or return value is type-hinted with a class, the generator falls back to `{type: "object"}` and
751+
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
752+
(timestamps, identifiers, money, whole DTOs, …) you register a **property handler** that teaches the SDK about the type
753+
in up to three directions. Each direction is its own interface, so a handler opts into only what it needs; a single
754+
class may implement any combination:
755755

756756
```php
757-
interface PropertyDescriberInterface
758-
{
759-
/** @return class-string The class/interface this describer handles (subtypes match too) */
760-
public static function supportedClass(): string;
757+
use Mcp\Capability\Discovery\PropertyDescriberInterface;
758+
use Mcp\Capability\Discovery\PropertyDenormalizerInterface;
759+
use Mcp\Capability\Discovery\PropertyNormalizerInterface;
761760

762-
/** @return array<string, mixed> JSON Schema fragment for the supported type */
761+
// All three share PropertyHandlerInterface::supportedClass(): class-string
762+
763+
interface PropertyDescriberInterface // type → JSON Schema (input + output schema)
764+
{
763765
public function describe(): array;
764766
}
767+
768+
interface PropertyDenormalizerInterface // client input → instance (tool arguments)
769+
{
770+
public function denormalize(mixed $value, string $class): mixed;
771+
}
772+
773+
interface PropertyNormalizerInterface // instance → JSON (tool results)
774+
{
775+
public function normalize(object $value): mixed;
776+
}
765777
```
766778

767-
A parameter is dispatched to a describer when its type is `supportedClass()` **or any subtype of it** — so a describer
768-
for `\DateTimeInterface` also covers `\DateTimeImmutable`, and one for `Uuid` covers `UuidV4`, `UuidV7`, etc.
779+
A type is dispatched to a handler when it is `supportedClass()` **or any subtype of it** — so a handler for
780+
`\DateTimeInterface` also covers `\DateTimeImmutable`, and one for `Uuid` covers `UuidV4`, `UuidV7`, etc. Handlers are
781+
consulted in **registration order**; the first whose supported class matches wins.
769782

770-
Two describers ship with the SDK (both opt-in):
783+
Two handlers ship with the SDK (both opt-in), each implementing all three directions:
771784

772-
| Describer | Handles | Emits |
773-
| --- | --- | --- |
774-
| `Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber` | any `\DateTimeInterface` | `{type: "string", format: "date-time"}` |
775-
| `Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber` | `Symfony\Component\Uid\Uuid` (and subclasses) | `{type: "string", format: "uuid"}` |
785+
| Handler | Handles | Schema | Upcasts / normalizes |
786+
| --- | --- | --- | --- |
787+
| `Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber` | any `\DateTimeInterface` | `{type: "string", format: "date-time"}` | string ⇄ `\DateTime(Immutable)` (ISO-8601) |
788+
| `Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber` | `Symfony\Component\Uid\Uuid` (and subclasses) | `{type: "string", format: "uuid"}` | string ⇄ `Uuid` (RFC 4122) |
776789

777-
Register them — and your own — on the builder. Describers are consulted in **registration order**; the first one whose
778-
supported class matches the parameter wins:
790+
Register them — and your own — on the builder:
779791

780792
```php
781793
use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber;
@@ -788,16 +800,33 @@ $server = Server::builder()
788800
->build();
789801
```
790802

791-
Now `public function schedule(\DateTimeImmutable $until)` generates `{type: "string", format: "date-time"}` for
792-
`$until` instead of `{type: "object"}`. Docblock descriptions, defaults and nullability are still layered on top of the
793-
describer's fragment.
803+
With these registered, a tool like:
794804

795-
Writing a custom describer for a domain value object:
805+
```php
806+
public function getTownShopList(Uuid $id): \DateTimeImmutable
807+
```
808+
809+
generates `{type: "string", format: "uuid"}` for `$id`, upcasts the client's `"id"` string into a real `Uuid` before the
810+
method is called, and normalizes the returned `\DateTimeImmutable` to an ISO-8601 string in the result content. Docblock
811+
descriptions, defaults and nullability are still layered on top of the describer's schema fragment for input parameters.
812+
813+
**Schema vs. value — and the object rule.** A describer fragment is used directly as a tool's `outputSchema` **only when
814+
it is an `object` schema**, because per the MCP spec an `outputSchema` describes the object-typed `structuredContent`.
815+
A scalar fragment (uuid/date-time strings) is therefore *not* advertised as an output schema; such a return is
816+
normalized to a string and carried in the result's text `content` instead. This is what makes the **DTO** case the
817+
primary use of output schemas: a handler whose `describe()` returns `{type: "object", properties: {...}}` for your DTO
818+
class gets that emitted as the tool's `outputSchema`, while its `normalize()` produces the matching
819+
`structuredContent`. Note that normalization is applied to the **top-level** returned value; values nested inside a DTO
820+
are the registered DTO handler's responsibility (e.g. delegated to a serializer — see below).
821+
822+
Writing a custom handler for a domain value object — implement only the directions you need:
796823

797824
```php
798825
use Mcp\Capability\Discovery\PropertyDescriberInterface;
826+
use Mcp\Capability\Discovery\PropertyDenormalizerInterface;
827+
use Mcp\Capability\Discovery\PropertyNormalizerInterface;
799828

800-
final class MoneyPropertyDescriber implements PropertyDescriberInterface
829+
final class MoneyPropertyHandler implements PropertyDescriberInterface, PropertyDenormalizerInterface, PropertyNormalizerInterface
801830
{
802831
public static function supportedClass(): string
803832
{
@@ -808,14 +837,63 @@ final class MoneyPropertyDescriber implements PropertyDescriberInterface
808837
{
809838
return ['type' => 'string', 'pattern' => '^\d+(\.\d{2})? [A-Z]{3}$'];
810839
}
840+
841+
public function denormalize(mixed $value, string $class): \App\Money
842+
{
843+
return \App\Money::fromString((string) $value);
844+
}
845+
846+
public function normalize(object $value): string
847+
{
848+
return (string) $value;
849+
}
811850
}
812851

813-
$builder->addPropertyDescriber(new MoneyPropertyDescriber());
852+
$builder->addPropertyDescriber(new MoneyPropertyHandler());
814853
```
815854

816-
To override a shipped describer, register your own for the same class **before** it — the first matching describer
817-
wins. Note that `addPropertyDescriber()` cannot be combined with `setSchemaGenerator()` — if you supply your own
818-
`SchemaGeneratorInterface`, configure the describers on that generator directly.
855+
#### Delegating whole DTOs to a serializer
856+
857+
Because `describe()` may return any schema fragment and `denormalize()`/`normalize()` receive the concrete class, a
858+
single handler registered against a DTO base class (or marker interface) can cover **all** your DTOs by delegating to a
859+
serializer you already use — e.g. `symfony/serializer` — instead of the SDK reflecting your objects:
860+
861+
```php
862+
use Mcp\Capability\Discovery\PropertyDenormalizerInterface;
863+
use Mcp\Capability\Discovery\PropertyNormalizerInterface;
864+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
865+
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
866+
867+
final class SerializerDtoHandler implements PropertyDenormalizerInterface, PropertyNormalizerInterface
868+
{
869+
public function __construct(private NormalizerInterface&DenormalizerInterface $serializer)
870+
{
871+
}
872+
873+
public static function supportedClass(): string
874+
{
875+
return \App\Dto\AbstractDto::class;
876+
}
877+
878+
public function denormalize(mixed $value, string $class): object
879+
{
880+
return $this->serializer->denormalize($value, $class);
881+
}
882+
883+
public function normalize(object $value): mixed
884+
{
885+
return $this->serializer->normalize($value);
886+
}
887+
}
888+
```
889+
890+
(For the output **schema** of such DTOs, also implement `PropertyDescriberInterface` and return the nested schema —
891+
assembled however you like, e.g. via `symfony/property-info` or `api-platform/json-schema`. The SDK itself does not
892+
reflect class properties.)
893+
894+
To override a shipped handler, register your own for the same class **before** it — the first match wins. Note that
895+
`addPropertyDescriber()` cannot be combined with `setSchemaGenerator()` (configure describers on your own generator
896+
instead) nor with `setReferenceHandler()` (wire the handlers onto your own reference handler instead).
819897

820898
## Discovery vs Manual Registration
821899

docs/server-builder.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,5 +619,5 @@ $server = Server::builder()
619619
| `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource |
620620
| `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template |
621621
| `addPrompt()` | handler, name?, description? | Register prompt |
622-
| `addPropertyDescriber()` | describer | Register a [property describer](mcp-elements.md#custom-type-describers) for class-typed parameters |
622+
| `addPropertyDescriber()` | handler | Register a [property handler](mcp-elements.md#custom-type-describers) (schema / input upcasting / output normalization) for a class-typed value object |
623623
| `build()` | - | Create the server instance |
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
* Upcasts an incoming client value into a class-typed argument.
16+
*
17+
* The counterpart to {@see PropertyDescriberInterface}: where the describer
18+
* teaches the schema what a class type looks like on the wire, the
19+
* denormalizer turns the value the client actually sent back into a PHP
20+
* instance of that type before it is passed to the tool method. Without it, a
21+
* tool like `getTownShopList(Uuid $id)` would receive the raw string and fail.
22+
*/
23+
interface PropertyDenormalizerInterface extends PropertyHandlerInterface
24+
{
25+
/**
26+
* @param mixed $value the JSON-decoded value received from the client
27+
* @param class-string $class the concrete parameter type to produce (the class
28+
* itself or a subtype of {@see self::supportedClass()})
29+
*
30+
* @return mixed an instance of $class
31+
*/
32+
public function denormalize(mixed $value, string $class): mixed;
33+
}

src/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriber.php

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@
1111

1212
namespace Mcp\Capability\Discovery\PropertyDescriber;
1313

14+
use Mcp\Capability\Discovery\PropertyDenormalizerInterface;
1415
use Mcp\Capability\Discovery\PropertyDescriberInterface;
16+
use Mcp\Capability\Discovery\PropertyNormalizerInterface;
1517

1618
/**
17-
* Describes any {@see \DateTimeInterface} implementation as an ISO-8601
18-
* date-time string.
19+
* Handles any {@see \DateTimeInterface} implementation: describes it as an
20+
* ISO-8601 date-time string, parses an incoming string into a date-time
21+
* instance (honoring a concrete `\DateTime` vs `\DateTimeImmutable` target),
22+
* and renders a returned instance back to an ISO-8601 string.
1923
*/
20-
final class DateTimePropertyDescriber implements PropertyDescriberInterface
24+
final class DateTimePropertyDescriber implements PropertyDescriberInterface, PropertyDenormalizerInterface, PropertyNormalizerInterface
2125
{
2226
public static function supportedClass(): string
2327
{
@@ -28,4 +32,22 @@ public function describe(): array
2832
{
2933
return ['type' => 'string', 'format' => 'date-time'];
3034
}
35+
36+
public function denormalize(mixed $value, string $class): \DateTimeInterface
37+
{
38+
if ($value instanceof \DateTimeInterface) {
39+
return $value;
40+
}
41+
42+
return \DateTime::class === $class
43+
? new \DateTime((string) $value)
44+
: new \DateTimeImmutable((string) $value);
45+
}
46+
47+
public function normalize(object $value): string
48+
{
49+
\assert($value instanceof \DateTimeInterface);
50+
51+
return $value->format(\DateTimeInterface::ATOM);
52+
}
3153
}

src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,18 @@
1111

1212
namespace Mcp\Capability\Discovery\PropertyDescriber;
1313

14+
use Mcp\Capability\Discovery\PropertyDenormalizerInterface;
1415
use Mcp\Capability\Discovery\PropertyDescriberInterface;
16+
use Mcp\Capability\Discovery\PropertyNormalizerInterface;
1517
use Symfony\Component\Uid\Uuid;
1618

1719
/**
18-
* Describes Symfony UID {@see Uuid} (and subclasses like `UuidV4`, `UuidV7`)
19-
* as a uuid-format string.
20+
* Handles Symfony UID {@see Uuid} (and subclasses like `UuidV4`, `UuidV7`):
21+
* describes it as a uuid-format string, upcasts an incoming string into a
22+
* {@see Uuid} instance, and renders a returned instance back to its RFC 4122
23+
* string form.
2024
*/
21-
final class UuidPropertyDescriber implements PropertyDescriberInterface
25+
final class UuidPropertyDescriber implements PropertyDescriberInterface, PropertyDenormalizerInterface, PropertyNormalizerInterface
2226
{
2327
public static function supportedClass(): string
2428
{
@@ -29,4 +33,21 @@ public function describe(): array
2933
{
3034
return ['type' => 'string', 'format' => 'uuid'];
3135
}
36+
37+
public function denormalize(mixed $value, string $class): Uuid
38+
{
39+
if ($value instanceof Uuid) {
40+
return $value;
41+
}
42+
43+
// Uuid::fromString detects the version and returns the matching subtype.
44+
return Uuid::fromString((string) $value);
45+
}
46+
47+
public function normalize(object $value): string
48+
{
49+
\assert($value instanceof Uuid);
50+
51+
return (string) $value;
52+
}
3253
}

src/Capability/Discovery/PropertyDescriberInterface.php

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,15 @@
1515
* Translates a PHP class type into a JSON Schema fragment.
1616
*
1717
* A describer declares the class (or base class/interface) it handles via
18-
* {@see self::supportedClass()}. The {@see SchemaGenerator} matches a
19-
* parameter's concrete class against that type — directly or through its
20-
* parents and interfaces — and, when several describers are registered,
21-
* consults them in priority order. Implementations let callers teach the
22-
* generator about value-object types (DateTime, Uuid, etc.) whose JSON Schema
23-
* representation is more specific than a generic `{type: "object"}`.
18+
* {@see PropertyHandlerInterface::supportedClass()}. The {@see SchemaGenerator}
19+
* matches a parameter's or return type's concrete class against that type —
20+
* directly or through its parents and interfaces — and, when several describers
21+
* are registered, consults them in priority order. Implementations let callers
22+
* teach the generator about value-object types (DateTime, Uuid, etc.) whose JSON
23+
* Schema representation is more specific than a generic `{type: "object"}`.
2424
*/
25-
interface PropertyDescriberInterface
25+
interface PropertyDescriberInterface extends PropertyHandlerInterface
2626
{
27-
/**
28-
* The class or interface this describer handles. Parameters whose type is
29-
* the class itself or any subtype of it are dispatched to this describer.
30-
*
31-
* @return class-string
32-
*/
33-
public static function supportedClass(): string;
34-
3527
/**
3628
* @return array<string, mixed> Schema fragment for the supported type
3729
*/
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
* Base contract for handlers that teach the SDK about a specific PHP class type.
16+
*
17+
* A handler declares the class (or base class / interface) it handles via
18+
* {@see self::supportedClass()}; a parameter, property or return type is
19+
* dispatched to it when its type is that class or any subtype of it. The
20+
* concern-specific behaviour lives in the sub-interfaces:
21+
* {@see PropertyDescriberInterface} (type → JSON Schema),
22+
* {@see PropertyDenormalizerInterface} (client input → instance) and
23+
* {@see PropertyNormalizerInterface} (instance → JSON output). A single class
24+
* may implement any combination of them.
25+
*/
26+
interface PropertyHandlerInterface
27+
{
28+
/**
29+
* The class or interface this handler covers. Types that are the class
30+
* itself or any subtype of it are dispatched to this handler.
31+
*
32+
* @return class-string
33+
*/
34+
public static function supportedClass(): string;
35+
}

0 commit comments

Comments
 (0)