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
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;
}
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) {
$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
{
}

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