@@ -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
781793use 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
798825use 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
0 commit comments