Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions docs/mcp-elements.md
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,156 @@ public function makeApiRequest(string $endpoint, string $method, array $headers)
**Warning:** Only use complete schema override if you're well-versed with JSON Schema specification and have complex
validation requirements that cannot be achieved through the priority system.

### Custom Type Describers

When a tool parameter or return value is type-hinted with a class, the generator falls back to `{type: "object"}` and
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
(timestamps, identifiers, money, whole DTOs, …) you register a **property handler** that teaches the SDK about the type
in up to three directions. Each direction is its own interface, so a handler opts into only what it needs; a single
class may implement any combination:

```php
use Mcp\Capability\Discovery\PropertyDescriberInterface;
use Mcp\Capability\Discovery\PropertyDenormalizerInterface;
use Mcp\Capability\Discovery\PropertyNormalizerInterface;

// All three share PropertyHandlerInterface::supportedClass(): class-string

interface PropertyDescriberInterface // type → JSON Schema (input + output schema)
{
public function describe(): array;
}

interface PropertyDenormalizerInterface // client input → instance (tool arguments)
{
public function denormalize(mixed $value, string $class): mixed;
}

interface PropertyNormalizerInterface // instance → JSON (tool results)
{
public function normalize(object $value): mixed;
}
```

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

Two handlers ship with the SDK (both opt-in), each implementing all three directions:

| Handler | Handles | Schema | Upcasts / normalizes |
| --- | --- | --- | --- |
| `Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber` | any `\DateTimeInterface` | `{type: "string", format: "date-time"}` | string ⇄ `\DateTime(Immutable)` (ISO-8601) |
| `Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber` | `Symfony\Component\Uid\Uuid` (and subclasses) | `{type: "string", format: "uuid"}` | string ⇄ `Uuid` (RFC 4122) |

Register them — and your own — on the builder:

```php
use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber;
use Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber;

$server = Server::builder()
->setServerInfo('my-server', '1.0.0')
->addPropertyDescriber(new DateTimePropertyDescriber())
->addPropertyDescriber(new UuidPropertyDescriber())
->build();
```

With these registered, a tool like:

```php
public function getTownShopList(Uuid $id): \DateTimeImmutable
```

generates `{type: "string", format: "uuid"}` for `$id`, upcasts the client's `"id"` string into a real `Uuid` before the
method is called, and normalizes the returned `\DateTimeImmutable` to an ISO-8601 string in the result content. Docblock
descriptions, defaults and nullability are still layered on top of the describer's schema fragment for input parameters.

**Schema vs. value — and the object rule.** A describer fragment is used directly as a tool's `outputSchema` **only when
it is an `object` schema**, because per the MCP spec an `outputSchema` describes the object-typed `structuredContent`.
A scalar fragment (uuid/date-time strings) is therefore *not* advertised as an output schema; such a return is
normalized to a string and carried in the result's text `content` instead. This is what makes the **DTO** case the
primary use of output schemas: a handler whose `describe()` returns `{type: "object", properties: {...}}` for your DTO
class gets that emitted as the tool's `outputSchema`, while its `normalize()` produces the matching
`structuredContent`. Note that normalization is applied to the **top-level** returned value; values nested inside a DTO
are the registered DTO handler's responsibility (e.g. delegated to a serializer — see below).

Writing a custom handler for a domain value object — implement only the directions you need:

```php
use Mcp\Capability\Discovery\PropertyDescriberInterface;
use Mcp\Capability\Discovery\PropertyDenormalizerInterface;
use Mcp\Capability\Discovery\PropertyNormalizerInterface;

final class MoneyPropertyHandler implements PropertyDescriberInterface, PropertyDenormalizerInterface, PropertyNormalizerInterface
{
public static function supportedClass(): string
{
return \App\Money::class;
}

public function describe(): array
{
return ['type' => 'string', 'pattern' => '^\d+(\.\d{2})? [A-Z]{3}$'];
}

public function denormalize(mixed $value, string $class): \App\Money
{
return \App\Money::fromString((string) $value);
}

public function normalize(object $value): string
{
return (string) $value;
}
}

$builder->addPropertyDescriber(new MoneyPropertyHandler());
```

#### Delegating whole DTOs to a serializer

Because `describe()` may return any schema fragment and `denormalize()`/`normalize()` receive the concrete class, a
single handler registered against a DTO base class (or marker interface) can cover **all** your DTOs by delegating to a
serializer you already use — e.g. `symfony/serializer` — instead of the SDK reflecting your objects:

```php
use Mcp\Capability\Discovery\PropertyDenormalizerInterface;
use Mcp\Capability\Discovery\PropertyNormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

final class SerializerDtoHandler implements PropertyDenormalizerInterface, PropertyNormalizerInterface
{
public function __construct(private NormalizerInterface&DenormalizerInterface $serializer)
{
}

public static function supportedClass(): string
{
return \App\Dto\AbstractDto::class;
}

public function denormalize(mixed $value, string $class): object
{
return $this->serializer->denormalize($value, $class);
}

public function normalize(object $value): mixed
{
return $this->serializer->normalize($value);
}
}
```

(For the output **schema** of such DTOs, also implement `PropertyDescriberInterface` and return the nested schema —
assembled however you like, e.g. via `symfony/property-info` or `api-platform/json-schema`. The SDK itself does not
reflect class properties.)

To override a shipped handler, register your own for the same class **before** it — the first match wins. Note that
`addPropertyDescriber()` cannot be combined with `setSchemaGenerator()` (configure describers on your own generator
instead) nor with `setReferenceHandler()` (wire the handlers onto your own reference handler instead).

## Discovery vs Manual Registration

### Attribute-Based Discovery
Expand Down
1 change: 1 addition & 0 deletions docs/server-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -619,4 +619,5 @@ $server = Server::builder()
| `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource |
| `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template |
| `addPrompt()` | handler, name?, description? | Register prompt |
| `addPropertyDescriber()` | handler | Register a [property handler](mcp-elements.md#custom-type-describers) (schema / input upcasting / output normalization) for a class-typed value object |
| `build()` | - | Create the server instance |
33 changes: 33 additions & 0 deletions src/Capability/Discovery/PropertyDenormalizerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Capability\Discovery;

/**
* Upcasts an incoming client value into a class-typed argument.
*
* The counterpart to {@see PropertyDescriberInterface}: where the describer
* teaches the schema what a class type looks like on the wire, the
* denormalizer turns the value the client actually sent back into a PHP
* instance of that type before it is passed to the tool method. Without it, a
* tool like `getTownShopList(Uuid $id)` would receive the raw string and fail.
*/
interface PropertyDenormalizerInterface extends PropertyHandlerInterface
{
/**
* @param mixed $value the JSON-decoded value received from the client
* @param class-string $class the concrete parameter type to produce (the class
* itself or a subtype of {@see self::supportedClass()})
*
* @return mixed an instance of $class
*/
public function denormalize(mixed $value, string $class): mixed;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Capability\Discovery\PropertyDescriber;

use Mcp\Capability\Discovery\PropertyDenormalizerInterface;
use Mcp\Capability\Discovery\PropertyDescriberInterface;
use Mcp\Capability\Discovery\PropertyNormalizerInterface;

/**
* Handles any {@see \DateTimeInterface} implementation: describes it as an
* ISO-8601 date-time string, parses an incoming string into a date-time
* instance (honoring a concrete `\DateTime` vs `\DateTimeImmutable` target),
* and renders a returned instance back to an ISO-8601 string.
*/
final class DateTimePropertyDescriber implements PropertyDescriberInterface, PropertyDenormalizerInterface, PropertyNormalizerInterface
{
public static function supportedClass(): string
{
return \DateTimeInterface::class;
}

public function describe(): array
{
return ['type' => 'string', 'format' => 'date-time'];
}

public function denormalize(mixed $value, string $class): \DateTimeInterface
{
if ($value instanceof \DateTimeInterface) {
return $value;
}

return \DateTime::class === $class
? new \DateTime((string) $value)
: new \DateTimeImmutable((string) $value);
}

public function normalize(object $value): string
{
\assert($value instanceof \DateTimeInterface);

return $value->format(\DateTimeInterface::ATOM);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Capability\Discovery\PropertyDescriber;

use Mcp\Capability\Discovery\PropertyDenormalizerInterface;
use Mcp\Capability\Discovery\PropertyDescriberInterface;
use Mcp\Capability\Discovery\PropertyNormalizerInterface;
use Symfony\Component\Uid\Uuid;

/**
* Handles Symfony UID {@see Uuid} (and subclasses like `UuidV4`, `UuidV7`):
* describes it as a uuid-format string, upcasts an incoming string into a
* {@see Uuid} instance, and renders a returned instance back to its RFC 4122
* string form.
*/
final class UuidPropertyDescriber implements PropertyDescriberInterface, PropertyDenormalizerInterface, PropertyNormalizerInterface
{
public static function supportedClass(): string
{
return Uuid::class;
}

public function describe(): array
{
return ['type' => 'string', 'format' => 'uuid'];
}

public function denormalize(mixed $value, string $class): Uuid
{
if ($value instanceof Uuid) {
return $value;
}

// Uuid::fromString detects the version and returns the matching subtype.
return Uuid::fromString((string) $value);
}

public function normalize(object $value): string
{
\assert($value instanceof Uuid);

return (string) $value;
}
}
31 changes: 31 additions & 0 deletions src/Capability/Discovery/PropertyDescriberInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Capability\Discovery;

/**
* Translates a PHP class type into a JSON Schema fragment.
*
* A describer declares the class (or base class/interface) it handles via
* {@see PropertyHandlerInterface::supportedClass()}. The {@see SchemaGenerator}
* matches a parameter's or return type's concrete class against that type —
* directly or through its parents and interfaces — and, when several describers
* are registered, consults them in priority order. Implementations let callers
* teach the generator about value-object types (DateTime, Uuid, etc.) whose JSON
* Schema representation is more specific than a generic `{type: "object"}`.
*/
interface PropertyDescriberInterface extends PropertyHandlerInterface
{
/**
* @return array<string, mixed> Schema fragment for the supported type
*/
public function describe(): array;
}
35 changes: 35 additions & 0 deletions src/Capability/Discovery/PropertyHandlerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Capability\Discovery;

/**
* Base contract for handlers that teach the SDK about a specific PHP class type.
*
* A handler declares the class (or base class / interface) it handles via
* {@see self::supportedClass()}; a parameter, property or return type is
* dispatched to it when its type is that class or any subtype of it. The
* concern-specific behaviour lives in the sub-interfaces:
* {@see PropertyDescriberInterface} (type → JSON Schema),
* {@see PropertyDenormalizerInterface} (client input → instance) and
* {@see PropertyNormalizerInterface} (instance → JSON output). A single class
* may implement any combination of them.
*/
interface PropertyHandlerInterface
{
/**
* The class or interface this handler covers. Types that are the class
* itself or any subtype of it are dispatched to this handler.
*
* @return class-string
*/
public static function supportedClass(): string;
}
Loading
Loading