[Server] Add PropertyDescriberInterface extension point for SchemaGenerator#314
[Server] Add PropertyDescriberInterface extension point for SchemaGenerator#314peter-si wants to merge 3 commits into
Conversation
3f148f3 to
cd2ef84
Compare
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.
cd2ef84 to
972bb42
Compare
chr-hertel
left a comment
There was a problem hiding this comment.
Hi @peter-si,
thanks for this proposal - i like the general idea, but left some comments about the design.
also, i think it would be good to add docs and think about higher-level usage => how to add describers on the Mcp\Server\Builder.
Thanks already!
| * | ||
| * @return array<string, mixed>|null Schema fragment, or null to pass to the next describer | ||
| */ | ||
| public function describe(string $className): ?array; |
There was a problem hiding this comment.
since we're only relying on the $className and don't have any dynamic, we could be more explicit while designing this interface.
think of sth like public static function supportedClass(): class-string
=> no implicit nullable as encoded non-support
=> static index+lookup instead of iterating over all describers for a property
There was a problem hiding this comment.
Changed the interface to static supportedClass(): class-string plus a non-nullable describe(): array, so support is declared explicitly rather than encoded as a null return. The generator matches a parameter's class against supportedClass() via is_a() (so \DateTimeInterface still covers \DateTimeImmutable, Uuid covers UuidV4, etc.) and memoizes the resolution per concrete class, so describers aren't re-scanned per property.
I kept an ordered scan + per-class cache rather than a flat exact-class index, because the shipped describers need to match subclasses/interfaces, which an exact-key map can't express.
| { | ||
| } | ||
|
|
||
| public function uuidParam(\Symfony\Component\Uid\Uuid $bookingId): void |
There was a problem hiding this comment.
please use an import here for the type
There was a problem hiding this comment.
Done - added use Symfony\Component\Uid\Uuid;; the fixture parameter is now Uuid $bookingId.
…ration 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) <noreply@anthropic.com>
|
Hi @chr-hertel thanks for the review. I've pushed_ cfad5b4 addressing the feedback:
Kept it simple for now - no priority system; happy to add one if you'd prefer it over registration order. |
|
Thanks for the update - i wonder tho if this is usable already or rather incomplete - how would you use this feature? |
|
When you say you think it's incomplete what specifically do you have in mind? What do you think is missing? This is the ground work to make the schema generation extendable. In our Symfony project we had to create decorators, because there was no entry point to modify how the schema is generated. I took the inspiration from Symfony Serializer (with it's normalizer approach). Whether it is usable today - sure you just need to make your own describers for your own specific VOs Of course we could ship more standard describers (https://github.com/symfony/serializer/tree/8.1/Normalizer), but that can maybe be another PR once this one is more adopted. |
|
let's talk features, so you have a tool that uses |
|
It's a bit related to what I'm suggesting here symfony/symfony#64279. Our usecase is that we have a Symfony app. We already have a scaffolding (using Nelmio api doc) which describes our controllers into openapi spec using only strict typed objects as a source (no OA attributes needed). This way the docs never drift from the actual code Now that we implemented the MCP protocol in our app I want to achieve the same thing. We have a code which defines the tools and uses all our already existing DTOs as input/output parameters. Similar to standard controllers. Something like this: #[McpTool(
name: 'location_town_shop_list',
description: 'List shops available in a town by slug.',
annotations: new ToolAnnotations(
title: 'Town Shop List',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
),
)]
#[McpToolAccess(roles: [Role::ROLE_INTERNAL])]
public function getTownShopList(string $slug): GetTownShopListV1Dto
{
return $this->guard->run(fn (): GetTownShopListV1Dto => $this->locationService->getTownShopList($slug));
}and yes the input parameter could be an UUID of a town (we like strict types), but also on the return side since we serialize/return whole DTOs we need to be able to give them proper format. |
this was also what i was thinking and I don't think this PR addresses this fully. yes, we can generate an UUID hint in the schema now, but we cannot upcast what is coming in from the client to a real Uuid instance to be handed to the tool as argument: talking about: public function getTownShopList(Uuid $id) |
|
yes, you are right. I thought it could be separate topic, but yes we can put it all together since it's touching the same concerns. There is also the output side concern. Because currently it's just a simple json_encode(). But doing so would mean a much bigger scope for this PR - new property-reflection engine (big, absent on input side too). Do you think we should address that as well? |
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>
|
Pushed an update that closes the gap you flagged — The describer extension point is now three small interfaces sharing
The shipped The important part is that there are now real extension points where you can plug the Symfony components in: register one handler that delegates Output: class-typed results are normalized to JSON via
|
Summary
Adds a
PropertyDescriberInterfaceextension point toSchemaGeneratorso callers can teach it how to render specific class types (DateTime,Uuid, domain value objects) as targeted JSON Schema fragments rather 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; parameter-level metadata (description, default, nullable) is layered onto the described schema without overwriting fields the describer already set.
Ships two default describers:
Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber— any\DateTimeInterfaceimplementation →{type: "string", format: "date-time"}Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber—Symfony\Component\Uid\Uuidand subclasses →{type: "string", format: "uuid"}Why
If an MCP tool method takes e.g.
\DateTimeInterface \$until, the generator currently falls back to{type: "object"}, which tells the LLM nothing about the expected shape and forces ad-hoc workarounds in every tool. The describer chain gives a clean extension point for the long tail of value-object types every project has, without needing to subclass or fork `SchemaGenerator`.Downstream we extend the chain further with app-specific types (Money, PhoneNumber, ...), but the two shipped here are general enough that they feel like they belong upstream.
Backwards compatibility
Tests
🤖 Generated with Claude Code