diff --git a/docs/servers/tools.md b/docs/servers/tools.md index c123fc97..86867cae 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -157,6 +157,22 @@ The same schema can be passed to manual registration: $builder->addTool([WeatherHandler::class, 'getWeather'], outputSchema: [/* ... */]); ``` +To omit the generated text representation for a tool, opt in with `structuredContentOnly`: + +```php +#[McpTool(structuredContentOnly: true)] +public function getData(): array +{ + return ['foo' => 'bar']; +} +``` + +For manual registration, use `$builder->addTool($handler, structuredContentOnly: true)`. +When structured content is available, the result has `content: []` and the data in `structuredContent`. +The default behavior remains unchanged. If the return value cannot be sent as structured content under the +call's protocol revision, the SDK keeps its regular `content` representation. Explicit `Content` items and +manually returned `CallToolResult` instances retain their existing behavior. + The SDK fills `structuredContent` whenever the return value qualifies — `outputSchema` is what tells clients to expect it and lets them validate it. What qualifies depends on the protocol revision the call is served under: diff --git a/src/Capability/Attribute/McpTool.php b/src/Capability/Attribute/McpTool.php index 1c71e2cd..a28c0928 100644 --- a/src/Capability/Attribute/McpTool.php +++ b/src/Capability/Attribute/McpTool.php @@ -21,13 +21,14 @@ class McpTool { /** - * @param string|null $name The name of the tool (defaults to the method name) - * @param string|null $title Optional human-readable title for display in UI - * @param string|null $description The description of the tool (defaults to the DocBlock/inferred) - * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior - * @param ?Icon[] $icons Optional list of icon URLs representing the tool - * @param ?array $meta Optional metadata - * @param array $outputSchema Optional JSON Schema object for defining the expected output structure + * @param string|null $name The name of the tool (defaults to the method name) + * @param string|null $title Optional human-readable title for display in UI + * @param string|null $description The description of the tool (defaults to the DocBlock/inferred) + * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior + * @param ?Icon[] $icons Optional list of icon URLs representing the tool + * @param ?array $meta Optional metadata + * @param array $outputSchema Optional JSON Schema object for defining the expected output structure + * @param bool $structuredContentOnly Omit generated content when structured content is available */ public function __construct( public ?string $name = null, @@ -37,6 +38,7 @@ public function __construct( public ?array $icons = null, public ?array $meta = null, public ?array $outputSchema = null, + public bool $structuredContentOnly = false, ) { } } diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index f0a212fd..92427732 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -241,7 +241,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun meta: $instance->meta, outputSchema: $outputSchema, ); - $tools[$name] = new ToolReference($tool, [$className, $methodName]); + $tools[$name] = new ToolReference($tool, [$className, $methodName], $instance->structuredContentOnly); ++$discoveredCount['tools']; } elseif ($instance instanceof McpResource) { $name = ElementMetadataResolver::resolveName($method, $instance->name); diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 7d043aae..b873f470 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -114,7 +114,7 @@ public function loadFrom(LoaderInterface $loader): void } } - public function registerTool(Tool $tool, callable|array|string $handler): ToolReference + public function registerTool(Tool $tool, callable|array|string $handler, bool $structuredContentOnly = false): ToolReference { if (!$this->nameValidator->isValid($tool->name)) { $this->logger->warning( @@ -122,7 +122,7 @@ public function registerTool(Tool $tool, callable|array|string $handler): ToolRe ); } - $reference = new ToolReference($tool, $handler); + $reference = new ToolReference($tool, $handler, $structuredContentOnly); $this->tools[$tool->name] = $reference; $this->dispatch(new ToolListChangedEvent()); diff --git a/src/Capability/Registry/Loader/DiscoveryLoader.php b/src/Capability/Registry/Loader/DiscoveryLoader.php index d2b95384..c89c9b9e 100644 --- a/src/Capability/Registry/Loader/DiscoveryLoader.php +++ b/src/Capability/Registry/Loader/DiscoveryLoader.php @@ -89,7 +89,7 @@ private function writeDiscovered(RegistryInterface $registry, DiscoveryState $di if (!$this->mayWriteTool($registry, $name)) { continue; } - $tools[$name] = $registry->registerTool($reference->tool, $reference->handler); + $tools[$name] = $registry->registerTool($reference->tool, $reference->handler, $reference->structuredContentOnly); } $resources = []; diff --git a/src/Capability/Registry/Loader/ReflectedElementLoader.php b/src/Capability/Registry/Loader/ReflectedElementLoader.php index 2d6257f9..d2183d0f 100644 --- a/src/Capability/Registry/Loader/ReflectedElementLoader.php +++ b/src/Capability/Registry/Loader/ReflectedElementLoader.php @@ -51,6 +51,7 @@ final class ReflectedElementLoader implements LoaderInterface * annotations: ?ToolAnnotations, * icons: ?Icon[], * meta: ?array, + * structuredContentOnly?: bool, * outputSchema: ?array * }[] $tools * @param array{ @@ -124,7 +125,7 @@ public function load(RegistryInterface $registry): void meta: $data['meta'] ?? null, outputSchema: $data['outputSchema'] ?? null, ); - $registry->registerTool($tool, $data['handler']); + $registry->registerTool($tool, $data['handler'], $data['structuredContentOnly'] ?? false); $handlerDesc = $this->getHandlerDescription($data['handler']); $this->logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index 32bacd9d..e6803314 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -29,6 +29,7 @@ class ToolReference extends ElementReference public function __construct( public readonly Tool $tool, callable|array|string $handler, + public readonly bool $structuredContentOnly = false, ) { parent::__construct($handler); } diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php index 94b0db23..9f24b879 100644 --- a/src/Capability/RegistryInterface.php +++ b/src/Capability/RegistryInterface.php @@ -39,7 +39,7 @@ interface RegistryInterface * * @param Handler $handler */ - public function registerTool(Tool $tool, callable|array|string $handler): ToolReference; + public function registerTool(Tool $tool, callable|array|string $handler, bool $structuredContentOnly = false): ToolReference; /** * Registers a resource with its handler. Overwrites any prior registration of the same URI. diff --git a/src/Server/Builder.php b/src/Server/Builder.php index d16ec8b6..aa1b8638 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -166,6 +166,7 @@ final class Builder * icons: ?Icon[], * meta: ?array, * outputSchema: ?array, + * structuredContentOnly: bool, * }[] */ private array $tools = []; @@ -690,11 +691,12 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self * Manually registers a tool handler. * * @param Handler $handler - * @param ?string $title Optional human-readable title for display in UI + * @param ?string $title Optional human-readable title for display in UI * @param array|null $inputSchema * @param ?Icon[] $icons * @param array|null $meta * @param array|null $outputSchema + * @param bool $structuredContentOnly Omit generated content when structured content is available */ public function addTool( callable|array|string $handler, @@ -706,6 +708,7 @@ public function addTool( ?array $icons = null, ?array $meta = null, ?array $outputSchema = null, + bool $structuredContentOnly = false, ): self { $this->tools[] = compact( 'handler', @@ -717,6 +720,7 @@ public function addTool( 'icons', 'meta', 'outputSchema', + 'structuredContentOnly', ); return $this; diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index d58a8485..e129b77a 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -114,7 +114,8 @@ public function handle(Request $request, SessionInterface $session): Response|Er ]); } - $result = new CallToolResult($reference->formatResult($result), structuredContent: $structuredContent); + $content = $reference->structuredContentOnly && null !== $structuredContent ? [] : $reference->formatResult($result); + $result = new CallToolResult($content, structuredContent: $structuredContent); } elseif ($protocolVersion->requiresObjectStructuredContent() && null !== $result->structuredContent && !self::isJsonObject($result->structuredContent) diff --git a/tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php b/tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php index 93754256..eb2515d6 100644 --- a/tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php +++ b/tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php @@ -29,5 +29,7 @@ public function testDiscoveryPropagatesMcpToolTitleToToolTitle(): void $toolRef = $tools['greet_user']; $this->assertInstanceOf(ToolReference::class, $toolRef); $this->assertSame('Greet User', $toolRef->tool->title); + $this->assertFalse($toolRef->structuredContentOnly); + $this->assertTrue($tools['repeatAction']->structuredContentOnly); } } diff --git a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php index f20cda58..479ff3ad 100644 --- a/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php +++ b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php @@ -39,7 +39,7 @@ public function greet(string $name): string * * @return array an array with results */ - #[McpTool(annotations: new ToolAnnotations(readOnlyHint: true))] + #[McpTool(annotations: new ToolAnnotations(readOnlyHint: true), structuredContentOnly: true)] public function repeatAction(int $count, bool $loudly = false, BackedStringEnum $mode = BackedStringEnum::OptionA): array { return ['count' => $count, 'loudly' => $loudly, 'mode' => $mode->value, 'message' => 'Action repeated.']; diff --git a/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php b/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php index 25279a9f..65633d7e 100644 --- a/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php +++ b/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php @@ -42,7 +42,7 @@ protected function setUp(): void public function testLoadRegistersAllDiscoveredElements(): void { $loader = new DiscoveryLoader('/base', [], [], new MutableDiscoverer(new DiscoveryState( - tools: ['t1' => new ToolReference($this->makeTool('t1'), static fn () => 't1')], + tools: ['t1' => new ToolReference($this->makeTool('t1'), static fn () => 't1', structuredContentOnly: true)], resources: ['r://1' => new ResourceReference($this->makeResource('r://1'), static fn () => 'r1')], prompts: ['p1' => new PromptReference($this->makePrompt('p1'), static fn () => [])], resourceTemplates: ['t://{id}' => new ResourceTemplateReference($this->makeTemplate('t://{id}'), static fn () => 'tpl')], @@ -51,6 +51,7 @@ public function testLoadRegistersAllDiscoveredElements(): void $loader->load($this->registry); $this->assertInstanceOf(ToolReference::class, $this->registry->getTool('t1')); + $this->assertTrue($this->registry->getTool('t1')->structuredContentOnly); $this->assertInstanceOf(ResourceReference::class, $this->registry->getResource('r://1', false)); $this->assertInstanceOf(PromptReference::class, $this->registry->getPrompt('p1')); $this->assertInstanceOf(ResourceTemplateReference::class, $this->registry->getResourceTemplate('t://{id}')); diff --git a/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php index 8537fcc8..4aa69598 100644 --- a/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php +++ b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php @@ -34,6 +34,7 @@ public function testLoadPropagatesToolTitleToRegisteredTool(): void 'icons' => null, 'meta' => null, 'outputSchema' => null, + 'structuredContentOnly' => true, ], ]; @@ -44,5 +45,6 @@ public function testLoadPropagatesToolTitleToRegisteredTool(): void $toolRef = $registry->getTool('weather_lookup'); $this->assertSame('Weather Lookup', $toolRef->tool->title); + $this->assertTrue($toolRef->structuredContentOnly); } } diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 117ee531..6e15c68b 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -495,6 +495,17 @@ private function extractServerCapabilities(Server $server): ServerCapabilities $this->fail('InitializeHandler not found in request handlers'); } + public function testStructuredContentOnlyOptionReachesRegistry(): void + { + $registry = new Registry(); + Server::builder() + ->setRegistry($registry) + ->addTool(static fn (): array => ['foo' => 'bar'], name: 'data', structuredContentOnly: true) + ->build(); + + $this->assertTrue($registry->getTool('data')->structuredContentOnly); + } + private function callTool(Server $server, string $toolName): mixed { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index dd66a6fd..39940c4b 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -60,6 +60,46 @@ protected function setUp(): void ); } + #[\PHPUnit\Framework\Attributes\DataProvider('structuredContentOnlyProvider')] + public function testStructuredContentOnly(mixed $value, bool $enabled, bool $expectStructured): void + { + $request = $this->createCallToolRequest('data', []); + $tool = new Tool('data', null, ['type' => 'object', 'properties' => new \stdClass(), 'required' => []], null, null); + $reference = new ToolReference($tool, static fn () => $value, structuredContentOnly: $enabled); + $this->registry->method('getTool')->willReturn($reference); + $this->referenceHandler->method('handle')->willReturn($value); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertInstanceOf(CallToolResult::class, $response->result); + if ($value instanceof CallToolResult) { + $this->assertSame($value, $response->result); + + return; + } + + $this->assertSame($expectStructured ? ['foo' => 'bar'] : null, $response->result->structuredContent); + if ($enabled && $expectStructured) { + $this->assertSame([], $response->result->content); + } else { + $this->assertNotEmpty($response->result->content); + } + } + + public static function structuredContentOnlyProvider(): iterable + { + yield 'opt in' => [['foo' => 'bar'], true, true]; + yield 'default' => [['foo' => 'bar'], false, true]; + yield 'object' => [(object) ['foo' => 'bar'], true, true]; + yield 'string fallback' => ['hello', true, false]; + yield 'null fallback' => [null, true, false]; + yield 'list fallback for legacy protocol' => [[1, 2], true, false]; + yield 'content' => [new TextContent('hello'), true, false]; + yield 'mixed content' => [[new TextContent('hello'), 'world'], true, false]; + yield 'explicit result' => [new CallToolResult([new TextContent('explicit')], structuredContent: ['foo' => 'bar']), true, true]; + } + public function testSupportsCallToolRequest(): void { $request = $this->createCallToolRequest('test_tool', ['param' => 'value']);