Skip to content

[Server] Add PropertyDescriberInterface extension point for SchemaGenerator#314

Open
peter-si wants to merge 3 commits into
modelcontextprotocol:mainfrom
peter-si:feat/property-describer-extension-point
Open

[Server] Add PropertyDescriberInterface extension point for SchemaGenerator#314
peter-si wants to merge 3 commits into
modelcontextprotocol:mainfrom
peter-si:feat/property-describer-extension-point

Conversation

@peter-si
Copy link
Copy Markdown

Summary

Adds a PropertyDescriberInterface extension point to SchemaGenerator so 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 \DateTimeInterface implementation → {type: "string", format: "date-time"}
  • Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriberSymfony\Component\Uid\Uuid and 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

  • New constructor parameter defaults to an empty iterable.
  • Behavior is unchanged for callers that don't pass any describers.
  • No public API removed or modified.

Tests

  • New `DateTimePropertyDescriberTest` and `UuidPropertyDescriberTest` cover per-describer happy paths and pass-through.
  • New integration tests in `SchemaGeneratorTest` cover: fallback when no describer claims the type, describer override of generic inference, docblock-description layering, nullable + default handling, first-non-null-wins ordering, and that describers don't intercept unrelated class types.
  • Full `unit` suite: 690 tests, 2215 assertions, all green.

🤖 Generated with Claude Code

@peter-si peter-si force-pushed the feat/property-describer-extension-point branch from 3f148f3 to cd2ef84 Compare May 19, 2026 09:27
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.
@peter-si peter-si force-pushed the feat/property-describer-extension-point branch from cd2ef84 to 972bb42 Compare May 19, 2026 09:39
@peter-si peter-si marked this pull request as ready for review May 19, 2026 10:00
Copy link
Copy Markdown
Member

@chr-hertel chr-hertel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/Capability/Discovery/SchemaGenerator.php Outdated
{
}

public function uuidParam(\Symfony\Component\Uid\Uuid $bookingId): void
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use an import here for the type

Copy link
Copy Markdown
Author

@peter-si peter-si May 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - added use Symfony\Component\Uid\Uuid;; the fixture parameter is now Uuid $bookingId.

@chr-hertel chr-hertel added Server Issues & PRs related to the Server component enhancement Request for a new feature that's not currently supported needs more work Not ready to be merged yet, needs additional follow-up from the author(s). labels May 25, 2026
@chr-hertel chr-hertel changed the title Add PropertyDescriberInterface extension point for SchemaGenerator [Server] Add PropertyDescriberInterface extension point for SchemaGenerator May 25, 2026
…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>
@peter-si
Copy link
Copy Markdown
Author

peter-si commented May 25, 2026

Hi @chr-hertel

thanks for the review. I've pushed_ cfad5b4 addressing the feedback:

  • Interface - explicit static supportedClass(): class-string + non-nullable describe(): array; support is no longer an implicit null. Matching against supportedClass() (subtypes included) lives in SchemaGenerator, with the describers materialized once and resolution memoized per concrete class.
  • Builder - added addPropertyDescriber() (opt-in; consulted in registration order, first match wins). Mutually exclusive with setSchemaGenerator().
  • Docs - new Custom Type Describers section under Schema Generation and Validation, plus a Method Reference entry in server-builder.md.
  • Import nit in the fixture fixed.

Kept it simple for now - no priority system; happy to add one if you'd prefer it over registration order.

@chr-hertel
Copy link
Copy Markdown
Member

Thanks for the update - i wonder tho if this is usable already or rather incomplete - how would you use this feature?

@peter-si
Copy link
Copy Markdown
Author

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.

@chr-hertel
Copy link
Copy Markdown
Member

let's talk features, so you have a tool that uses Uuid or DateTime as argument or output schema or how do I understand? just want to follow your use case here :)

@peter-si
Copy link
Copy Markdown
Author

peter-si commented May 26, 2026

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.

@chr-hertel
Copy link
Copy Markdown
Member

and yes the input parameter could be an UUID of a town

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)

@peter-si
Copy link
Copy Markdown
Author

peter-si commented May 26, 2026

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>
@peter-si
Copy link
Copy Markdown
Author

Pushed an update that closes the gap you flagged — getTownShopList(Uuid $id) now receives a real Uuid, not the raw client string.

The describer extension point is now three small interfaces sharing supportedClass(), and a single class may implement any combination:

  • PropertyDescriberInterfacedescribe(): type → JSON Schema (unchanged)
  • PropertyDenormalizerInterfacedenormalize($value, $class): client input → instance, applied in ReferenceHandler for class-typed arguments
  • PropertyNormalizerInterfacenormalize($value): instance → JSON, applied to a class-typed tool result before it's returned

The shipped Uuid/DateTime describers implement all three, and the is_a lookup that was inline in SchemaGenerator is now a shared PropertyHandlerResolver used by the schema, input and output paths.

The important part is that there are now real extension points where you can plug the Symfony components in: register one handler that delegates denormalize/normalize to symfony/serializer (and describe to your schema source) and your value objects and DTOs round-trip end-to-end, without subclassing the generator. Examples in the docs: Custom Type Describers.

Output: class-typed results are normalized to JSON via PropertyNormalizerInterface, and an outputSchema is inferred from the return type when the describer yields an object schema.

addPropertyDescriber() stays mutually exclusive with setSchemaGenerator() / setReferenceHandler() (use those if you'd rather configure the components yourself). Docs + tests updated; unit suite green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Request for a new feature that's not currently supported needs more work Not ready to be merged yet, needs additional follow-up from the author(s). Server Issues & PRs related to the Server component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants