Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?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 function describe(string $className): ?array
{
if (!is_a($className, \DateTimeInterface::class, true)) {
return null;
}

return ['type' => 'string', 'format' => 'date-time'];
}
}
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;
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 function describe(string $className): ?array
{
if (!is_a($className, Uuid::class, true)) {
return null;
}

return ['type' => 'string', 'format' => 'uuid'];
}
}
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.
*
* The {@see SchemaGenerator} consults registered describers, in order, before
* falling back to generic class inspection. The first describer that returns
* a non-null schema wins. 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
{
/**
* @param class-string $className
*
* @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.

}
78 changes: 76 additions & 2 deletions src/Capability/Discovery/SchemaGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,14 @@
*/
final class SchemaGenerator implements SchemaGeneratorInterface
{
/**
* @param iterable<PropertyDescriberInterface> $propertyDescribers Consulted, in order, before
* generic class inspection;
* first non-null result wins
*/
public function __construct(
private readonly DocBlockParser $docBlockParser,
private readonly iterable $propertyDescribers = [],
) {
}

Expand Down Expand Up @@ -253,13 +259,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 describer that claims the class (returns non-null) 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 +364,65 @@ private function inferParameterTypes(array $paramInfo): array
return $jsonTypes;
}

/**
* Looks for a matching describer when the parameter's PHP type is a
* concrete class. Returns the first non-null describer result, or null
* if no describer claimed the type. 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;
}

$className = $reflectionType->getName();
foreach ($this->propertyDescribers as $describer) {
Comment thread
chr-hertel marked this conversation as resolved.
Outdated
$described = $describer->describe($className);
if (null !== $described) {
return $described;
}
}

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?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\Tests\Unit\Capability\Discovery\PropertyDescriber;

use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber;
use PHPUnit\Framework\TestCase;

final class DateTimePropertyDescriberTest extends TestCase
{
private DateTimePropertyDescriber $describer;

protected function setUp(): void
{
$this->describer = new DateTimePropertyDescriber();
}

public function testDescribesDateTimeInterfaceAsIsoDateTimeString(): void
{
$this->assertSame(
['type' => 'string', 'format' => 'date-time'],
$this->describer->describe(\DateTimeInterface::class),
);
}

public function testDescribesDateTimeImplementations(): void
{
$this->assertSame(
['type' => 'string', 'format' => 'date-time'],
$this->describer->describe(\DateTime::class),
);
$this->assertSame(
['type' => 'string', 'format' => 'date-time'],
$this->describer->describe(\DateTimeImmutable::class),
);
}

public function testPassesOnUnrelatedClass(): void
{
$this->assertNull($this->describer->describe(\stdClass::class));
$this->assertNull($this->describer->describe(\Exception::class));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?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\Tests\Unit\Capability\Discovery\PropertyDescriber;

use Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Uid\UuidV4;
use Symfony\Component\Uid\UuidV6;

final class UuidPropertyDescriberTest extends TestCase
{
private UuidPropertyDescriber $describer;

protected function setUp(): void
{
$this->describer = new UuidPropertyDescriber();
}

public function testDescribesUuidAsUuidFormatString(): void
{
$this->assertSame(
['type' => 'string', 'format' => 'uuid'],
$this->describer->describe(Uuid::class),
);
}

public function testDescribesUuidSubclasses(): void
{
$this->assertSame(
['type' => 'string', 'format' => 'uuid'],
$this->describer->describe(UuidV4::class),
);
$this->assertSame(
['type' => 'string', 'format' => 'uuid'],
$this->describer->describe(UuidV6::class),
);
}

public function testPassesOnUnrelatedClass(): void
{
$this->assertNull($this->describer->describe(\stdClass::class));
$this->assertNull($this->describer->describe(\DateTime::class));
}
}
31 changes: 31 additions & 0 deletions tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,37 @@ public function withParameterNamedRequest(string $_request): void
{
}

// ===== PROPERTY DESCRIBER FIXTURES =====

public function dateTimeParam(\DateTimeImmutable $createdAt): void
{
}

/**
* @param \DateTimeInterface $until The cutoff timestamp
*/
public function dateTimeWithDescription(\DateTimeInterface $until): void
{
}

public function nullableDateTimeParam(?\DateTimeImmutable $finishedAt = null): void
{
}

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

@peter-si peter-si May 25, 2026

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.

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

{
}

public function unrelatedObjectParam(\stdClass $config): void
{
}

public function dateTimeWithSchemaAttributeOverride(
#[Schema(description: 'explicit attribute description')]
\DateTimeImmutable $deadline,
): void {
}

// ===== OUTPUT SCHEMA FIXTURES =====
#[McpTool(
outputSchema: [
Expand Down
Loading