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
72 changes: 72 additions & 0 deletions docs/mcp-elements.md
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,78 @@ 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 is type-hinted with a class, the generator falls back to `{type: "object"}` — which tells the
LLM nothing about the expected shape. For value-object types (timestamps, identifiers, money, …) you can register a
**property describer** that maps the class to a targeted JSON Schema fragment.

A describer declares the class (or base class / interface) it handles and the fragment to emit:

```php
interface PropertyDescriberInterface
{
/** @return class-string The class/interface this describer handles (subtypes match too) */
public static function supportedClass(): string;

/** @return array<string, mixed> JSON Schema fragment for the supported type */
public function describe(): array;
}
```

A parameter is dispatched to a describer when its type is `supportedClass()` **or any subtype of it** — so a describer
for `\DateTimeInterface` also covers `\DateTimeImmutable`, and one for `Uuid` covers `UuidV4`, `UuidV7`, etc.

Two describers ship with the SDK (both opt-in):

| Describer | Handles | Emits |
| --- | --- | --- |
| `Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber` | any `\DateTimeInterface` | `{type: "string", format: "date-time"}` |
| `Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber` | `Symfony\Component\Uid\Uuid` (and subclasses) | `{type: "string", format: "uuid"}` |

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

```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();
```

Now `public function schedule(\DateTimeImmutable $until)` generates `{type: "string", format: "date-time"}` for
`$until` instead of `{type: "object"}`. Docblock descriptions, defaults and nullability are still layered on top of the
describer's fragment.

Writing a custom describer for a domain value object:

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

final class MoneyPropertyDescriber implements PropertyDescriberInterface
{
public static function supportedClass(): string
{
return \App\Money::class;
}

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

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

To override a shipped describer, register your own for the same class **before** it — the first matching describer
wins. Note that `addPropertyDescriber()` cannot be combined with `setSchemaGenerator()` — if you supply your own
`SchemaGeneratorInterface`, configure the describers on that generator directly.

## 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()` | describer | Register a [property describer](mcp-elements.md#custom-type-describers) for class-typed parameters |
| `build()` | - | Create the server instance |
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\PropertyDescriber;

use Mcp\Capability\Discovery\PropertyDescriberInterface;

/**
* Describes any {@see \DateTimeInterface} implementation as an ISO-8601
* date-time string.
*/
final class DateTimePropertyDescriber implements PropertyDescriberInterface
{
public static function supportedClass(): string
{
return \DateTimeInterface::class;
}

public function describe(): array
{
return ['type' => 'string', 'format' => 'date-time'];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?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\PropertyDescriberInterface;
use Symfony\Component\Uid\Uuid;

/**
* Describes Symfony UID {@see Uuid} (and subclasses like `UuidV4`, `UuidV7`)
* as a uuid-format string.
*/
final class UuidPropertyDescriber implements PropertyDescriberInterface
{
public static function supportedClass(): string
{
return Uuid::class;
}

public function describe(): array
{
return ['type' => 'string', 'format' => 'uuid'];
}
}
39 changes: 39 additions & 0 deletions src/Capability/Discovery/PropertyDescriberInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?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 self::supportedClass()}. The {@see SchemaGenerator} matches a
* parameter'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
{
/**
* The class or interface this describer handles. Parameters whose type is
* the class itself or any subtype of it are dispatched to this describer.
*
* @return class-string
*/
public static function supportedClass(): string;

/**
* @return array<string, mixed> Schema fragment for the supported type
*/
public function describe(): array;
}
115 changes: 113 additions & 2 deletions src/Capability/Discovery/SchemaGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,33 @@
*/
final class SchemaGenerator implements SchemaGeneratorInterface
{
/**
* @var list<PropertyDescriberInterface> Consulted in registration order before generic class
* inspection; the first matching describer wins
*/
private readonly array $propertyDescribers;

/**
* Memoizes describer resolution per concrete class. Holds the matching
* describer or `false` when none matched, so repeated parameter types —
* and the common "no describer" case — are resolved at most once.
*
* @var array<class-string, PropertyDescriberInterface|false>
*/
private array $describerCache = [];

/**
* @param iterable<PropertyDescriberInterface> $propertyDescribers Consulted in order before
* generic class inspection; the
* first matching describer wins
*/
public function __construct(
private readonly DocBlockParser $docBlockParser,
iterable $propertyDescribers = [],
) {
$this->propertyDescribers = \is_array($propertyDescribers)
? array_values($propertyDescribers)
: iterator_to_array($propertyDescribers, false);
}

/**
Expand Down Expand Up @@ -253,13 +277,22 @@ private function buildParameterSchema(array $paramInfo, ?array $methodLevelParam
*/
private function buildInferredParameterSchema(array $paramInfo): array
{
$paramSchema = [];

// Variadic parameters are handled separately
if ($paramInfo['is_variadic']) {
return [];
}

// Consult property describers for class-typed parameters first; the
// first registered describer whose supported class matches wins. This
// lets callers teach the generator about value-object types like
// DateTime, Uuid, Money, etc. without subclassing the generator.
$describedSchema = $this->describeClassType($paramInfo);
if (null !== $describedSchema) {
return $this->applyParameterMetadata($describedSchema, $paramInfo);
}

$paramSchema = [];

// Infer JSON Schema types
$jsonTypes = $this->inferParameterTypes($paramInfo);

Expand Down Expand Up @@ -349,6 +382,84 @@ private function inferParameterTypes(array $paramInfo): array
return $jsonTypes;
}

/**
* Describes the parameter when its PHP type is a concrete class claimed by
* a registered describer, or null otherwise. Union and intersection types
* are not dispatched — describers see only single named, non-builtin types.
*
* @param ParameterInfo $paramInfo
*
* @return array<string, mixed>|null
*/
private function describeClassType(array $paramInfo): ?array
{
$reflectionType = $paramInfo['reflection_type_object'];
if (!$reflectionType instanceof \ReflectionNamedType || $reflectionType->isBuiltin()) {
return null;
}

return $this->resolveDescriber($reflectionType->getName())?->describe();
}

/**
* Resolves the describer for a concrete class, matching against each
* describer's {@see PropertyDescriberInterface::supportedClass()} (the
* class itself or any subtype). Results are memoized per class.
*
* @param class-string $className
*/
private function resolveDescriber(string $className): ?PropertyDescriberInterface
{
$cached = $this->describerCache[$className] ??= $this->findDescriber($className) ?? false;

return $cached ?: null;
}

/**
* @param class-string $className
*/
private function findDescriber(string $className): ?PropertyDescriberInterface
{
foreach ($this->propertyDescribers as $describer) {
Comment thread
chr-hertel marked this conversation as resolved.
if (is_a($className, $describer::supportedClass(), true)) {
return $describer;
}
}

return null;
}

/**
* Layers parameter-level metadata (description, default, nullable) onto
* a describer-provided schema fragment without overwriting fields the
* describer already set.
*
* @param array<string, mixed> $schema
* @param ParameterInfo $paramInfo
*
* @return array<string, mixed>
*/
private function applyParameterMetadata(array $schema, array $paramInfo): array
{
if ($paramInfo['description'] && !isset($schema['description'])) {
$schema['description'] = $paramInfo['description'];
}

if ($paramInfo['has_default'] && !isset($schema['default'])) {
$schema['default'] = $paramInfo['default_value'];
}

if ($paramInfo['allows_null'] && isset($schema['type'])) {
$types = \is_array($schema['type']) ? $schema['type'] : [$schema['type']];
if (!\in_array('null', $types, true)) {
array_unshift($types, 'null');
}
$schema['type'] = 1 === \count($types) ? $types[0] : $types;
}

return $schema;
}

/**
* Applies enum constraints to parameter schema.
*/
Expand Down
Loading