diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index b1a045f5..6b389b84 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -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 diff --git a/docs/server-builder.md b/docs/server-builder.md index a48e96da..70ae1b43 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -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 | diff --git a/src/Capability/Discovery/PropertyDenormalizerInterface.php b/src/Capability/Discovery/PropertyDenormalizerInterface.php new file mode 100644 index 00000000..4d230e3c --- /dev/null +++ b/src/Capability/Discovery/PropertyDenormalizerInterface.php @@ -0,0 +1,33 @@ + '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); + } +} diff --git a/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php b/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php new file mode 100644 index 00000000..04011e30 --- /dev/null +++ b/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php @@ -0,0 +1,53 @@ + '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; + } +} diff --git a/src/Capability/Discovery/PropertyDescriberInterface.php b/src/Capability/Discovery/PropertyDescriberInterface.php new file mode 100644 index 00000000..9558ff04 --- /dev/null +++ b/src/Capability/Discovery/PropertyDescriberInterface.php @@ -0,0 +1,31 @@ + Schema fragment for the supported type + */ + public function describe(): array; +} diff --git a/src/Capability/Discovery/PropertyHandlerInterface.php b/src/Capability/Discovery/PropertyHandlerInterface.php new file mode 100644 index 00000000..1f89c7f6 --- /dev/null +++ b/src/Capability/Discovery/PropertyHandlerInterface.php @@ -0,0 +1,35 @@ + + */ + private readonly array $handlers; + + /** + * Holds the matching handler or `false` when none matched, keyed by + * `"$className\0$concern"`. + * + * @var array + */ + private array $cache = []; + + /** + * @param iterable $handlers + */ + public function __construct(iterable $handlers = []) + { + $this->handlers = \is_array($handlers) + ? array_values($handlers) + : iterator_to_array($handlers, false); + } + + /** + * @template T of PropertyHandlerInterface + * + * @param class-string $className the concrete type to resolve a handler for + * @param class-string $concern the concern interface the handler must implement + * + * @return T|null + */ + public function resolve(string $className, string $concern): ?PropertyHandlerInterface + { + $key = $className."\0".$concern; + $cached = $this->cache[$key] ??= $this->find($className, $concern) ?? false; + + return $cached ?: null; + } + + /** + * @param class-string $className + * @param class-string $concern + */ + private function find(string $className, string $concern): ?PropertyHandlerInterface + { + foreach ($this->handlers as $handler) { + if ($handler instanceof $concern && is_a($className, $handler::supportedClass(), true)) { + return $handler; + } + } + + return null; + } +} diff --git a/src/Capability/Discovery/PropertyNormalizerInterface.php b/src/Capability/Discovery/PropertyNormalizerInterface.php new file mode 100644 index 00000000..09e37b85 --- /dev/null +++ b/src/Capability/Discovery/PropertyNormalizerInterface.php @@ -0,0 +1,30 @@ + $propertyHandlers Consulted in registration order; + * the first whose supported class + * matches a type wins + */ public function __construct( private readonly DocBlockParser $docBlockParser, + iterable $propertyHandlers = [], ) { + $this->handlerResolver = new PropertyHandlerResolver($propertyHandlers); } /** @@ -109,15 +118,29 @@ public function generateOutputSchema(\Reflector $reflection): ?array throw new BadMethodCallException(\sprintf('Schema generation from %s is not supported. Use ReflectionMethod or ReflectionFunction instead.', $reflection::class)); } - // Only return outputSchema if explicitly provided in McpTool attribute + // An explicit outputSchema on the McpTool attribute always wins. $mcpToolAttrs = $reflection->getAttributes(McpTool::class, \ReflectionAttribute::IS_INSTANCEOF); if ($mcpToolAttrs) { - $mcpToolInstance = $mcpToolAttrs[0]->newInstance(); + $explicit = $mcpToolAttrs[0]->newInstance()->outputSchema; + if (null !== $explicit) { + return $explicit; + } + } - return $mcpToolInstance->outputSchema; + // Otherwise fall back to a describer registered for the return type. + $returnType = $reflection->getReturnType(); + if (!$returnType instanceof \ReflectionNamedType || $returnType->isBuiltin()) { + return null; } - return null; + $schema = $this->handlerResolver->resolve($returnType->getName(), PropertyDescriberInterface::class)?->describe(); + + // An output schema describes a tool's structuredContent, which is a JSON + // object. A scalar describer fragment (e.g. a uuid-format string) is valid + // as an input property but not as a top-level output schema — emitting it + // would violate the spec and Tool::fromArray. Such returns are surfaced as + // text content only (after normalization), with no output schema. + return isset($schema['type']) && 'object' === $schema['type'] ? $schema : null; } /** @@ -253,13 +276,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); @@ -349,6 +381,56 @@ 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|null + */ + private function describeClassType(array $paramInfo): ?array + { + $reflectionType = $paramInfo['reflection_type_object']; + if (!$reflectionType instanceof \ReflectionNamedType || $reflectionType->isBuiltin()) { + return null; + } + + return $this->handlerResolver->resolve($reflectionType->getName(), PropertyDescriberInterface::class)?->describe(); + } + + /** + * Layers parameter-level metadata (description, default, nullable) onto + * a describer-provided schema fragment without overwriting fields the + * describer already set. + * + * @param array $schema + * @param ParameterInfo $paramInfo + * + * @return array + */ + 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. */ diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index 8865b0ff..6bbaf46a 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -115,6 +115,7 @@ public function load(RegistryInterface $registry): void } $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); + $outputSchema = $data['outputSchema'] ?? $schemaGenerator->generateOutputSchema($reflection); $tool = new Tool( name: $name, @@ -124,7 +125,7 @@ public function load(RegistryInterface $registry): void annotations: $data['annotations'] ?? null, icons: $data['icons'] ?? null, meta: $data['meta'] ?? null, - outputSchema: $data['outputSchema'] ?? null, + outputSchema: $outputSchema, ); $registry->registerTool($tool, $data['handler']); diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 7b4a0cdc..5afacfde 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -11,6 +11,9 @@ namespace Mcp\Capability\Registry; +use Mcp\Capability\Discovery\PropertyDenormalizerInterface; +use Mcp\Capability\Discovery\PropertyHandlerInterface; +use Mcp\Capability\Discovery\PropertyHandlerResolver; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; use Mcp\Server\RequestContext; @@ -22,9 +25,17 @@ */ final class ReferenceHandler implements ReferenceHandlerInterface { + private readonly PropertyHandlerResolver $handlerResolver; + + /** + * @param iterable $propertyHandlers Consulted to upcast class-typed + * arguments into instances + */ public function __construct( private readonly ?ContainerInterface $container = null, + iterable $propertyHandlers = [], ) { + $this->handlerResolver = new PropertyHandlerResolver($propertyHandlers); } /** @@ -198,6 +209,34 @@ private function castArgumentType(mixed $argument, \ReflectionParameter $paramet throw new InvalidArgumentException("Invalid value type '{$argument}' for unit enum {$typeName}. Expected a string matching a case name."); } + if (!$type->isBuiltin()) { + if ($argument instanceof $typeName) { + return $argument; + } + + $denormalizer = $this->handlerResolver->resolve($typeName, PropertyDenormalizerInterface::class); + if (null === $denormalizer) { + return $argument; + } + + try { + $denormalized = $denormalizer->denormalize($argument, $typeName); + } catch (InvalidArgumentException $e) { + throw $e; + } catch (\Throwable $e) { + throw new InvalidArgumentException(\sprintf('Value could not be denormalized into `%s`: %s', $typeName, $e->getMessage()), 0, $e); + } + + // Guard against a denormalizer that produces the wrong concrete type + // (e.g. a UuidV7 for a `UuidV4` parameter) so it surfaces as invalid + // input rather than a TypeError at call time reported as an internal error. + if (!$denormalized instanceof $typeName) { + throw new InvalidArgumentException(\sprintf('Denormalized value for `%s` is not an instance of that type.', $typeName)); + } + + return $denormalized; + } + try { return match (strtolower($typeName)) { 'int', 'integer' => $this->castToInt($argument), diff --git a/src/Server/Builder.php b/src/Server/Builder.php index ba26d23f..c9d7e6d8 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -14,6 +14,12 @@ use Mcp\Capability\Discovery\CachedDiscoverer; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DiscovererInterface; +use Mcp\Capability\Discovery\DocBlockParser; +use Mcp\Capability\Discovery\PropertyDenormalizerInterface; +use Mcp\Capability\Discovery\PropertyDescriberInterface; +use Mcp\Capability\Discovery\PropertyHandlerInterface; +use Mcp\Capability\Discovery\PropertyNormalizerInterface; +use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Capability\Discovery\SchemaGeneratorInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; @@ -179,6 +185,11 @@ final class Builder */ private array $loaders = []; + /** + * @var array + */ + private array $propertyHandlers = []; + /** * Sets the server's identity. Required. * @@ -540,6 +551,27 @@ public function addLoaders(iterable $loaders): self return $this; } + /** + * Registers a property handler for a value-object class (e.g. DateTime, Uuid). + * Depending on the interfaces it implements, a handler teaches the SDK how to + * render the class in the JSON Schema ({@see PropertyDescriberInterface}), + * upcast incoming client input into an instance + * ({@see PropertyDenormalizerInterface}) and/or normalize a returned instance + * back to JSON ({@see PropertyNormalizerInterface}). A single class may + * implement any combination of them. + * + * Handlers are consulted in registration order; the first one whose supported + * class matches the type wins. Describing cannot be combined with a generator + * set via setSchemaGenerator(), nor denormalization with a handler set via + * setReferenceHandler(). + */ + public function addPropertyDescriber(PropertyHandlerInterface $handler): self + { + $this->propertyHandlers[] = $handler; + + return $this; + } + /** * Builds the fully configured Server instance. */ @@ -556,16 +588,18 @@ public function build(): Server $this->gcDivisor, ); + $schemaGenerator = $this->resolveSchemaGenerator($logger); + // ArrayLoader runs before DiscoveryLoader so manual entries are seen first; DiscoveryLoader's // identity check then preserves them against same-name discovered entries. $loaders = [ ...$this->loaders, - new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), + new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $schemaGenerator), ]; if (null !== $this->discoveryBasePath) { if (null !== $this->discoverer || class_exists(Finder::class)) { - $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); + $discoverer = $this->discoverer ?? $this->createDiscoverer($logger, $schemaGenerator); $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer, $this->discoveryNamePatterns, $logger); } else { $logger->warning('File-based discovery requires symfony/finder. Skipping automatic discovery. Run: composer require symfony/finder'); @@ -591,10 +625,14 @@ public function build(): Server $serverInfo = $this->serverInfo ?? new Implementation(); $configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); - $referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container); + + if ([] !== $this->propertyHandlers && null !== $this->referenceHandler) { + throw new InvalidArgumentException('Cannot combine addPropertyDescriber() with a handler set via setReferenceHandler(). Configure the property handlers on that handler instead.'); + } + $referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container, $this->propertyHandlers); $requestHandlers = array_merge($this->requestHandlers, [ - new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger), + new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger, propertyHandlers: $this->propertyHandlers), new Handler\Request\CompletionCompleteHandler($registry, $container), new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), new Handler\Request\InitializeHandler($configuration), @@ -625,9 +663,9 @@ public function build(): Server return new Server($protocol, $logger); } - private function createDiscoverer(LoggerInterface $logger): DiscovererInterface + private function createDiscoverer(LoggerInterface $logger, ?SchemaGeneratorInterface $schemaGenerator): DiscovererInterface { - $discoverer = new Discoverer($logger, null, $this->schemaGenerator); + $discoverer = new Discoverer($logger, null, $schemaGenerator); if (null !== $this->discoveryCache) { return new CachedDiscoverer($discoverer, $this->discoveryCache, $logger); @@ -635,4 +673,22 @@ private function createDiscoverer(LoggerInterface $logger): DiscovererInterface return $discoverer; } + + /** + * Builds the schema generator from registered property handlers, or + * returns the explicitly configured one. The two are mutually exclusive: + * handlers belong on the explicit generator if one is set. + */ + private function resolveSchemaGenerator(LoggerInterface $logger): ?SchemaGeneratorInterface + { + if ([] === $this->propertyHandlers) { + return $this->schemaGenerator; + } + + if (null !== $this->schemaGenerator) { + throw new InvalidArgumentException('Cannot combine addPropertyDescriber() with a generator set via setSchemaGenerator(). Configure the describers on that generator instead.'); + } + + return new SchemaGenerator(new DocBlockParser(logger: $logger), $this->propertyHandlers); + } } diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index e78ce1b9..e96ae822 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -11,11 +11,15 @@ namespace Mcp\Server\Handler\Request; +use Mcp\Capability\Discovery\PropertyHandlerInterface; +use Mcp\Capability\Discovery\PropertyHandlerResolver; +use Mcp\Capability\Discovery\PropertyNormalizerInterface; use Mcp\Capability\Discovery\SchemaValidator; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; +use Mcp\Schema\Content\Content; use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; @@ -35,14 +39,21 @@ final class CallToolHandler implements RequestHandlerInterface { private SchemaValidator $schemaValidator; + private PropertyHandlerResolver $handlerResolver; + /** + * @param iterable $propertyHandlers Consulted to normalize a class-typed + * tool result before it is encoded + */ public function __construct( private readonly RegistryInterface $registry, private readonly ReferenceHandlerInterface $referenceHandler, private readonly LoggerInterface $logger = new NullLogger(), ?SchemaValidator $schemaValidator = null, + iterable $propertyHandlers = [], ) { $this->schemaValidator = $schemaValidator ?? new SchemaValidator($logger); + $this->handlerResolver = new PropertyHandlerResolver($propertyHandlers); } public function supports(Request $request): bool @@ -95,6 +106,13 @@ public function handle(Request $request, SessionInterface $session): Response|Er try { $result = $this->referenceHandler->handle($reference, $arguments); + if (\is_object($result) && !$result instanceof CallToolResult && !$result instanceof Content) { + $normalizer = $this->handlerResolver->resolve($result::class, PropertyNormalizerInterface::class); + if (null !== $normalizer) { + $result = $normalizer->normalize($result); + } + } + $structuredContent = null; if (!$result instanceof CallToolResult) { $structuredContent = $reference->extractStructuredContent($result); diff --git a/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php b/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php new file mode 100644 index 00000000..5d22a352 --- /dev/null +++ b/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php @@ -0,0 +1,67 @@ +describer = new DateTimePropertyDescriber(); + } + + public function testSupportsDateTimeInterface(): void + { + $this->assertSame(\DateTimeInterface::class, DateTimePropertyDescriber::supportedClass()); + } + + public function testDescribesAsIsoDateTimeString(): void + { + $this->assertSame( + ['type' => 'string', 'format' => 'date-time'], + $this->describer->describe(), + ); + } + + public function testDenormalizesStringIntoDateTimeImmutableByDefault(): void + { + $date = $this->describer->denormalize('2026-05-26T10:00:00+00:00', \DateTimeInterface::class); + + $this->assertInstanceOf(\DateTimeImmutable::class, $date); + $this->assertSame('2026-05-26T10:00:00+00:00', $date->format(\DateTimeInterface::ATOM)); + } + + public function testDenormalizesIntoConcreteMutableDateTimeWhenTargeted(): void + { + $date = $this->describer->denormalize('2026-05-26T10:00:00+00:00', \DateTime::class); + + $this->assertInstanceOf(\DateTime::class, $date); + } + + public function testDenormalizePassesThroughExistingInstance(): void + { + $date = new \DateTimeImmutable('2026-05-26T10:00:00+00:00'); + + $this->assertSame($date, $this->describer->denormalize($date, \DateTimeInterface::class)); + } + + public function testNormalizesInstanceToIso8601String(): void + { + $date = new \DateTimeImmutable('2026-05-26T10:00:00+00:00'); + + $this->assertSame('2026-05-26T10:00:00+00:00', $this->describer->normalize($date)); + } +} diff --git a/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php b/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php new file mode 100644 index 00000000..bfa5cc1e --- /dev/null +++ b/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php @@ -0,0 +1,68 @@ +describer = new UuidPropertyDescriber(); + } + + public function testSupportsUuid(): void + { + $this->assertSame(Uuid::class, UuidPropertyDescriber::supportedClass()); + } + + public function testDescribesAsUuidFormatString(): void + { + $this->assertSame( + ['type' => 'string', 'format' => 'uuid'], + $this->describer->describe(), + ); + } + + public function testDenormalizesStringIntoUuidInstance(): void + { + $uuid = $this->describer->denormalize('9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', Uuid::class); + + $this->assertInstanceOf(Uuid::class, $uuid); + $this->assertSame('9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', $uuid->toRfc4122()); + } + + public function testDenormalizePassesThroughExistingInstance(): void + { + $uuid = Uuid::v4(); + + $this->assertSame($uuid, $this->describer->denormalize($uuid, Uuid::class)); + } + + public function testDenormalizeRejectsMalformedString(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->describer->denormalize('not-a-uuid', Uuid::class); + } + + public function testNormalizesInstanceToRfc4122String(): void + { + $uuid = Uuid::fromString('9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'); + + $this->assertSame('9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', $this->describer->normalize($uuid)); + } +} diff --git a/tests/Unit/Capability/Discovery/PropertyHandlerResolverTest.php b/tests/Unit/Capability/Discovery/PropertyHandlerResolverTest.php new file mode 100644 index 00000000..6b20f190 --- /dev/null +++ b/tests/Unit/Capability/Discovery/PropertyHandlerResolverTest.php @@ -0,0 +1,97 @@ +assertInstanceOf(UuidPropertyDescriber::class, $resolver->resolve(Uuid::class, PropertyDescriberInterface::class)); + $this->assertInstanceOf(UuidPropertyDescriber::class, $resolver->resolve(Uuid::class, PropertyDenormalizerInterface::class)); + $this->assertInstanceOf(UuidPropertyDescriber::class, $resolver->resolve(Uuid::class, PropertyNormalizerInterface::class)); + } + + public function testMatchesSubtypesOfSupportedClass(): void + { + $resolver = new PropertyHandlerResolver([new UuidPropertyDescriber()]); + + $this->assertInstanceOf(UuidPropertyDescriber::class, $resolver->resolve(UuidV4::class, PropertyDescriberInterface::class)); + } + + public function testReturnsNullWhenNoHandlerSupportsClass(): void + { + $resolver = new PropertyHandlerResolver([new UuidPropertyDescriber()]); + + $this->assertNull($resolver->resolve(\DateTimeImmutable::class, PropertyDescriberInterface::class)); + } + + public function testFiltersByConcernInterface(): void + { + $describeOnly = new class implements PropertyDescriberInterface { + public static function supportedClass(): string + { + return Uuid::class; + } + + public function describe(): array + { + return ['type' => 'string']; + } + }; + + $resolver = new PropertyHandlerResolver([$describeOnly]); + + $this->assertSame($describeOnly, $resolver->resolve(Uuid::class, PropertyDescriberInterface::class)); + $this->assertNull($resolver->resolve(Uuid::class, PropertyDenormalizerInterface::class)); + } + + public function testFirstRegisteredMatchWins(): void + { + $first = new class implements PropertyDescriberInterface { + public static function supportedClass(): string + { + return \DateTimeInterface::class; + } + + public function describe(): array + { + return ['type' => 'string', 'format' => 'first']; + } + }; + + $resolver = new PropertyHandlerResolver([$first, new DateTimePropertyDescriber()]); + + $this->assertSame($first, $resolver->resolve(\DateTimeImmutable::class, PropertyDescriberInterface::class)); + } + + public function testResolutionIsStableAcrossCalls(): void + { + $resolver = new PropertyHandlerResolver([new UuidPropertyDescriber()]); + + $this->assertSame( + $resolver->resolve(Uuid::class, PropertyDescriberInterface::class), + $resolver->resolve(Uuid::class, PropertyDescriberInterface::class), + ); + } +} diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 0d40026c..d685df3a 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -16,6 +16,7 @@ use Mcp\Tests\Unit\Fixtures\Enum\BackedIntEnum; use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum; use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum; +use Symfony\Component\Uid\Uuid; /** * Comprehensive fixture for testing SchemaGenerator with various scenarios. @@ -438,6 +439,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(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: [ @@ -453,4 +485,27 @@ public function returnWithExplicitOutputSchema(): array { return ['message' => 'result']; } + + public function returnsUuid(): Uuid + { + return Uuid::v4(); + } + + public function returnsStdClass(): \stdClass + { + return new \stdClass(); + } + + #[McpTool( + outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'format' => 'explicit'], + ], + ] + )] + public function returnsUuidWithExplicitOutputSchema(): Uuid + { + return Uuid::v4(); + } } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index c92121e6..8912e525 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -12,6 +12,9 @@ namespace Mcp\Tests\Unit\Capability\Discovery; use Mcp\Capability\Discovery\DocBlockParser; +use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber; +use Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber; +use Mcp\Capability\Discovery\PropertyDescriberInterface; use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Exception\InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; @@ -387,4 +390,168 @@ public function testGenerateOutputSchemaForComplexNestedSchema(): void 'additionalProperties' => true, ], $schema); } + + public function testScalarReturnTypeDescriberProducesNoOutputSchema(): void + { + // A uuid/date-time return is normalized to a string in the result content, + // but a scalar fragment is not a valid output schema (which must be an + // object), so none is advertised. + $generator = new SchemaGenerator(new DocBlockParser(), [new UuidPropertyDescriber()]); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'returnsUuid'); + $this->assertNull($generator->generateOutputSchema($method)); + } + + public function testObjectReturningDescriberProducesOutputSchema(): void + { + $describer = new class implements PropertyDescriberInterface { + public static function supportedClass(): string + { + return \stdClass::class; + } + + public function describe(): array + { + return ['type' => 'object', 'properties' => ['ok' => ['type' => 'boolean']]]; + } + }; + + $generator = new SchemaGenerator(new DocBlockParser(), [$describer]); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'returnsStdClass'); + $this->assertSame( + ['type' => 'object', 'properties' => ['ok' => ['type' => 'boolean']]], + $generator->generateOutputSchema($method), + ); + } + + public function testExplicitOutputSchemaWinsOverReturnTypeDescriber(): void + { + $generator = new SchemaGenerator(new DocBlockParser(), [new UuidPropertyDescriber()]); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'returnsUuidWithExplicitOutputSchema'); + $this->assertSame( + ['type' => 'object', 'properties' => ['id' => ['type' => 'string', 'format' => 'explicit']]], + $generator->generateOutputSchema($method), + ); + } + + public function testGenerateOutputSchemaIsNullForClassReturnTypeWithoutDescriber(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'returnsUuid'); + $this->assertNull($this->schemaGenerator->generateOutputSchema($method)); + } + + // ===== PROPERTY DESCRIBER INTEGRATION ===== + + public function testFallsBackToObjectWhenNoDescriberClaimsClassType(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'dateTimeParam'); + $schema = $this->schemaGenerator->generate($method); + $this->assertSame(['type' => 'object'], $schema['properties']['createdAt']); + } + + public function testDescriberOverridesGenericObjectInferenceForKnownClass(): void + { + $generator = new SchemaGenerator( + new DocBlockParser(), + [new DateTimePropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'dateTimeParam'); + $schema = $generator->generate($method); + $this->assertSame( + ['type' => 'string', 'format' => 'date-time'], + $schema['properties']['createdAt'], + ); + } + + public function testDescribedSchemaLayersDocBlockDescription(): void + { + $generator = new SchemaGenerator( + new DocBlockParser(), + [new DateTimePropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'dateTimeWithDescription'); + $schema = $generator->generate($method); + $this->assertSame( + ['type' => 'string', 'format' => 'date-time', 'description' => 'The cutoff timestamp'], + $schema['properties']['until'], + ); + } + + public function testDescribedSchemaPicksUpNullableAndDefault(): void + { + $generator = new SchemaGenerator( + new DocBlockParser(), + [new DateTimePropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableDateTimeParam'); + $schema = $generator->generate($method); + $this->assertSame( + ['type' => ['null', 'string'], 'format' => 'date-time', 'default' => null], + $schema['properties']['finishedAt'], + ); + } + + public function testFirstMatchingDescriberWins(): void + { + $loudDescriber = new class implements PropertyDescriberInterface { + public static function supportedClass(): string + { + return \DateTimeInterface::class; + } + + public function describe(): array + { + return ['type' => 'string', 'format' => 'custom-loud']; + } + }; + + $generator = new SchemaGenerator( + new DocBlockParser(), + [$loudDescriber, new DateTimePropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'dateTimeParam'); + $schema = $generator->generate($method); + $this->assertSame( + ['type' => 'string', 'format' => 'custom-loud'], + $schema['properties']['createdAt'], + ); + } + + public function testUuidDescriberClaimsUuidClass(): void + { + $generator = new SchemaGenerator( + new DocBlockParser(), + [new UuidPropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'uuidParam'); + $schema = $generator->generate($method); + $this->assertSame( + ['type' => 'string', 'format' => 'uuid'], + $schema['properties']['bookingId'], + ); + } + + public function testDescribersDoNotInterceptUnrelatedClassTypes(): void + { + $generator = new SchemaGenerator( + new DocBlockParser(), + [new DateTimePropertyDescriber(), new UuidPropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'unrelatedObjectParam'); + $schema = $generator->generate($method); + $this->assertSame(['type' => 'object'], $schema['properties']['config']); + } + + public function testParameterLevelSchemaAttributeOverridesDescribedSchema(): void + { + $generator = new SchemaGenerator( + new DocBlockParser(), + [new DateTimePropertyDescriber()], + ); + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'dateTimeWithSchemaAttributeOverride'); + $schema = $generator->generate($method); + $this->assertSame( + ['type' => 'string', 'format' => 'date-time', 'description' => 'explicit attribute description'], + $schema['properties']['deadline'], + ); + } } diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php new file mode 100644 index 00000000..40827800 --- /dev/null +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -0,0 +1,99 @@ + $id->toRfc4122()); + + $result = $handler->handle($reference, [ + 'id' => '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', + '_session' => $this->createStub(SessionInterface::class), + ]); + + $this->assertSame('9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', $result); + } + + public function testPassesThroughArgumentAlreadyOfTargetType(): void + { + $handler = new ReferenceHandler(null, [new UuidPropertyDescriber()]); + $uuid = Uuid::v4(); + $reference = new ElementReference(static fn (Uuid $id): Uuid => $id); + + $result = $handler->handle($reference, [ + 'id' => $uuid, + '_session' => $this->createStub(SessionInterface::class), + ]); + + $this->assertSame($uuid, $result); + } + + public function testMalformedValueForDenormalizedTypeMapsToInvalidParams(): void + { + $handler = new ReferenceHandler(null, [new UuidPropertyDescriber()]); + $reference = new ElementReference(static fn (Uuid $id): string => $id->toRfc4122()); + + try { + $handler->handle($reference, [ + 'id' => 'not-a-uuid', + '_session' => $this->createStub(SessionInterface::class), + ]); + $this->fail('Expected a RegistryException'); + } catch (RegistryException $e) { + $this->assertSame(Error::INVALID_PARAMS, $e->getCode()); + } + } + + public function testDenormalizedSubtypeMismatchMapsToInvalidParams(): void + { + // A v7 UUID string denormalizes to a UuidV7, which is not a UuidV4; this + // must surface as invalid params, not a TypeError reported as an internal error. + $handler = new ReferenceHandler(null, [new UuidPropertyDescriber()]); + $reference = new ElementReference(static fn (UuidV4 $id): string => (string) $id); + + try { + $handler->handle($reference, [ + 'id' => (string) Uuid::v7(), + '_session' => $this->createStub(SessionInterface::class), + ]); + $this->fail('Expected a RegistryException'); + } catch (RegistryException $e) { + $this->assertSame(Error::INVALID_PARAMS, $e->getCode()); + } + } + + public function testBuiltinCastingIsUnaffectedWhenNoHandlerRegistered(): void + { + $handler = new ReferenceHandler(); + $reference = new ElementReference(static fn (int $n): int => $n); + + $result = $handler->handle($reference, [ + 'n' => '42', + '_session' => $this->createStub(SessionInterface::class), + ]); + + $this->assertSame(42, $result); + } +} diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 8a92d599..41213aee 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -11,16 +11,25 @@ namespace Mcp\Tests\Unit\Server; +use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber; +use Mcp\Capability\Discovery\PropertyDescriber\UuidPropertyDescriber; +use Mcp\Capability\Discovery\PropertyDescriberInterface; +use Mcp\Capability\Discovery\SchemaGeneratorInterface; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; +use Mcp\Schema\Request\ListToolsRequest; +use Mcp\Schema\Result\ListToolsResult; use Mcp\Server; use Mcp\Server\Handler\Request\CallToolHandler; +use Mcp\Server\Handler\Request\ListToolsHandler; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\Uuid; final class BuilderTest extends TestCase { @@ -79,7 +88,167 @@ public function testCustomReferenceHandlerIsUsedForToolCalls(): void $this->assertSame('intercepted', $result); } + #[TestDox('addPropertyDescriber() applies to generated tool input schemas')] + public function testAddPropertyDescriberAppliesToGeneratedToolSchema(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addPropertyDescriber(new DateTimePropertyDescriber()) + ->addTool(static fn (\DateTimeImmutable $when): string => 'ok', name: 'dt_tool', description: 'A tool') + ->build(); + + $schema = $this->toolInputSchema($server, 'dt_tool'); + + $this->assertSame(['type' => 'string', 'format' => 'date-time'], $schema['properties']['when']); + } + + #[TestDox('addPropertyDescriber() consults describers in registration order (first match wins)')] + public function testAddPropertyDescriberConsultsInRegistrationOrder(): void + { + $custom = new class implements PropertyDescriberInterface { + public static function supportedClass(): string + { + return \DateTimeInterface::class; + } + + public function describe(): array + { + return ['type' => 'string', 'format' => 'custom']; + } + }; + + // Registered before the default, so the custom describer wins for DateTime types. + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addPropertyDescriber($custom) + ->addPropertyDescriber(new DateTimePropertyDescriber()) + ->addTool(static fn (\DateTimeImmutable $when): string => 'ok', name: 'dt_tool', description: 'A tool') + ->build(); + + $schema = $this->toolInputSchema($server, 'dt_tool'); + + $this->assertSame(['type' => 'string', 'format' => 'custom'], $schema['properties']['when']); + } + + #[TestDox('addPropertyDescriber() cannot be combined with setSchemaGenerator()')] + public function testAddPropertyDescriberConflictsWithExplicitGenerator(): void + { + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setSchemaGenerator($this->createStub(SchemaGeneratorInterface::class)) + ->addPropertyDescriber(new DateTimePropertyDescriber()); + + $this->expectException(InvalidArgumentException::class); + + $builder->build(); + } + + #[TestDox('addPropertyDescriber() cannot be combined with setReferenceHandler()')] + public function testAddPropertyDescriberConflictsWithReferenceHandler(): void + { + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setReferenceHandler($this->createStub(ReferenceHandlerInterface::class)) + ->addPropertyDescriber(new UuidPropertyDescriber()); + + $this->expectException(InvalidArgumentException::class); + + $builder->build(); + } + + #[TestDox('A registered handler upcasts a class-typed tool argument from client input')] + public function testAddPropertyDescriberUpcastsToolArguments(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addPropertyDescriber(new UuidPropertyDescriber()) + ->addTool(static fn (Uuid $id): string => $id->toRfc4122(), name: 'echo_uuid', description: 'A tool') + ->build(); + + $result = $this->callToolWithArguments($server, 'echo_uuid', ['id' => '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d']); + + $this->assertSame('9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', $result); + } + + #[TestDox('A registered handler normalizes a scalar value-object result to a string (no output schema)')] + public function testAddPropertyDescriberNormalizesToolResult(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addPropertyDescriber(new DateTimePropertyDescriber()) + ->addTool(static fn (): \DateTimeImmutable => new \DateTimeImmutable('2026-05-26T10:00:00+00:00'), name: 'now_tool', description: 'A tool') + ->build(); + + // The value comes back as a single ISO-8601 string in the content... + $this->assertSame('2026-05-26T10:00:00+00:00', $this->callTool($server, 'now_tool')); + // ...and no output schema is advertised, because a scalar is not a valid + // MCP output schema (which describes the object-typed structuredContent). + $this->assertNull($this->toolOutputSchema($server, 'now_tool')); + } + + #[TestDox('An object-returning describer applies to the generated tool output schema')] + public function testObjectDescriberAppliesToGeneratedOutputSchema(): void + { + $describer = new class implements PropertyDescriberInterface { + public static function supportedClass(): string + { + return \stdClass::class; + } + + public function describe(): array + { + return ['type' => 'object', 'properties' => ['ok' => ['type' => 'boolean']]]; + } + }; + + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addPropertyDescriber($describer) + ->addTool(static fn (): \stdClass => new \stdClass(), name: 'make_obj', description: 'A tool') + ->build(); + + $this->assertSame( + ['type' => 'object', 'properties' => ['ok' => ['type' => 'boolean']]], + $this->toolOutputSchema($server, 'make_obj'), + ); + } + + /** + * @return array + */ + private function toolInputSchema(Server $server, string $toolName): array + { + $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); + $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); + + foreach ($requestHandlers as $handler) { + if ($handler instanceof ListToolsHandler) { + $request = (new ListToolsRequest())->withId('test-1'); + $response = $handler->handle($request, $this->createStub(SessionInterface::class)); + \assert($response->result instanceof ListToolsResult); + + foreach ($response->result->tools as $tool) { + if ($tool->name === $toolName) { + return $tool->inputSchema; + } + } + + $this->fail(\sprintf('Tool "%s" not found in tools/list result', $toolName)); + } + } + + $this->fail('ListToolsHandler not found in request handlers'); + } + private function callTool(Server $server, string $toolName): mixed + { + return $this->callToolWithArguments($server, $toolName, []); + } + + /** + * @param array $arguments + */ + private function callToolWithArguments(Server $server, string $toolName, array $arguments): mixed { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); @@ -90,7 +259,7 @@ private function callTool(Server $server, string $toolName): mixed 'jsonrpc' => '2.0', 'method' => 'tools/call', 'id' => 'test-1', - 'params' => ['name' => $toolName, 'arguments' => []], + 'params' => ['name' => $toolName, 'arguments' => $arguments], ]); $session = $this->createStub(SessionInterface::class); @@ -108,4 +277,31 @@ private function callTool(Server $server, string $toolName): mixed $this->fail('CallToolHandler not found in request handlers'); } + + /** + * @return array|null + */ + private function toolOutputSchema(Server $server, string $toolName): ?array + { + $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); + $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); + + foreach ($requestHandlers as $handler) { + if ($handler instanceof ListToolsHandler) { + $request = (new ListToolsRequest())->withId('test-1'); + $response = $handler->handle($request, $this->createStub(SessionInterface::class)); + \assert($response->result instanceof ListToolsResult); + + foreach ($response->result->tools as $tool) { + if ($tool->name === $toolName) { + return $tool->outputSchema; + } + } + + $this->fail(\sprintf('Tool "%s" not found in tools/list result', $toolName)); + } + } + + $this->fail('ListToolsHandler not found in request handlers'); + } } diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index b804f76a..4c6be1b7 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; +use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ToolReference; use Mcp\Capability\RegistryInterface; @@ -514,6 +515,41 @@ public function testValidationError(): void $this->assertEquals(Error::INVALID_PARAMS, $response->code); } + public function testHandleNormalizesClassTypedResultBeforeFormatting(): void + { + $request = $this->createCallToolRequest('when_tool', []); + $schema = ['type' => 'object', 'properties' => ['example' => ['type' => 'string']], 'required' => []]; + $tool = new Tool('when_tool', null, $schema, null, null); + $toolReference = new ToolReference($tool, static fn (): \DateTimeImmutable => new \DateTimeImmutable('2026-05-26T10:00:00+00:00')); + + $this->registry + ->expects($this->once()) + ->method('getTool') + ->with('when_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->willReturn(new \DateTimeImmutable('2026-05-26T10:00:00+00:00')); + + $handler = new CallToolHandler( + $this->registry, + $this->referenceHandler, + $this->logger, + propertyHandlers: [new DateTimePropertyDescriber()], + ); + + $response = $handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $result = $response->result; + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('2026-05-26T10:00:00+00:00', $result->content[0]->text); + } + /** * @param array $arguments */