diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index b1a045f5..b866d4a0 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -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 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 diff --git a/docs/server-builder.md b/docs/server-builder.md index a48e96da..a04b0ba8 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()` | describer | Register a [property describer](mcp-elements.md#custom-type-describers) for class-typed parameters | | `build()` | - | Create the server instance | diff --git a/src/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriber.php b/src/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriber.php new file mode 100644 index 00000000..7a77b928 --- /dev/null +++ b/src/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriber.php @@ -0,0 +1,31 @@ + 'string', 'format' => 'date-time']; + } +} diff --git a/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php b/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php new file mode 100644 index 00000000..e4a1245f --- /dev/null +++ b/src/Capability/Discovery/PropertyDescriber/UuidPropertyDescriber.php @@ -0,0 +1,32 @@ + 'string', 'format' => 'uuid']; + } +} diff --git a/src/Capability/Discovery/PropertyDescriberInterface.php b/src/Capability/Discovery/PropertyDescriberInterface.php new file mode 100644 index 00000000..7aba7b96 --- /dev/null +++ b/src/Capability/Discovery/PropertyDescriberInterface.php @@ -0,0 +1,39 @@ + Schema fragment for the supported type + */ + public function describe(): array; +} diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 04ee08f6..b86aa5fe 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -60,9 +60,33 @@ */ final class SchemaGenerator implements SchemaGeneratorInterface { + /** + * @var list 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 + */ + private array $describerCache = []; + + /** + * @param iterable $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); } /** @@ -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); @@ -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|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) { + 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 $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/Server/Builder.php b/src/Server/Builder.php index ba26d23f..97200395 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -14,6 +14,9 @@ use Mcp\Capability\Discovery\CachedDiscoverer; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DiscovererInterface; +use Mcp\Capability\Discovery\DocBlockParser; +use Mcp\Capability\Discovery\PropertyDescriberInterface; +use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Capability\Discovery\SchemaGeneratorInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; @@ -179,6 +182,11 @@ final class Builder */ private array $loaders = []; + /** + * @var array + */ + private array $propertyDescribers = []; + /** * Sets the server's identity. Required. * @@ -540,6 +548,22 @@ public function addLoaders(iterable $loaders): self return $this; } + /** + * Registers a property describer that teaches the schema generator how to + * render a value-object class (e.g. DateTime, Uuid) as a targeted JSON + * Schema fragment instead of a generic `{type: "object"}`. + * + * Describers are consulted in registration order; the first one whose + * supported class matches the parameter wins. Cannot be combined with a + * generator set via setSchemaGenerator(). + */ + public function addPropertyDescriber(PropertyDescriberInterface $describer): self + { + $this->propertyDescribers[] = $describer; + + return $this; + } + /** * Builds the fully configured Server instance. */ @@ -556,16 +580,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'); @@ -625,9 +651,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 +661,22 @@ private function createDiscoverer(LoggerInterface $logger): DiscovererInterface return $discoverer; } + + /** + * Builds the schema generator from registered property describers, or + * returns the explicitly configured one. The two are mutually exclusive: + * describers belong on the explicit generator if one is set. + */ + private function resolveSchemaGenerator(LoggerInterface $logger): ?SchemaGeneratorInterface + { + if ([] === $this->propertyDescribers) { + 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->propertyDescribers); + } } diff --git a/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php b/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php new file mode 100644 index 00000000..d3ab27a1 --- /dev/null +++ b/tests/Unit/Capability/Discovery/PropertyDescriber/DateTimePropertyDescriberTest.php @@ -0,0 +1,38 @@ +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(), + ); + } +} diff --git a/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php b/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php new file mode 100644 index 00000000..ac9d19d1 --- /dev/null +++ b/tests/Unit/Capability/Discovery/PropertyDescriber/UuidPropertyDescriberTest.php @@ -0,0 +1,39 @@ +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(), + ); + } +} diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 0d40026c..ad8ce1f8 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: [ diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index c92121e6..c4944143 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,120 @@ public function testGenerateOutputSchemaForComplexNestedSchema(): void 'additionalProperties' => true, ], $schema); } + + // ===== 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/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 8a92d599..07ee3e90 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -11,13 +11,20 @@ namespace Mcp\Tests\Unit\Server; +use Mcp\Capability\Discovery\PropertyDescriber\DateTimePropertyDescriber; +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; @@ -79,6 +86,88 @@ 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(); + } + + /** + * @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 { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server);