Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/servers/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
16 changes: 9 additions & 7 deletions src/Capability/Attribute/McpTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $meta Optional metadata
* @param array<string, mixed> $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<string, mixed> $meta Optional metadata
* @param array<string, mixed> $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,
Expand All @@ -37,6 +38,7 @@ public function __construct(
public ?array $icons = null,
public ?array $meta = null,
public ?array $outputSchema = null,
public bool $structuredContentOnly = false,
) {
}
}
2 changes: 1 addition & 1 deletion src/Capability/Discovery/Discoverer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/Capability/Registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,15 @@ 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(
\sprintf('Tool name "%s" is invalid. Tool names should only contain letters (a-z, A-Z), numbers, dots, hyphens, underscores, and forward slashes.', $tool->name),
);
}

$reference = new ToolReference($tool, $handler);
$reference = new ToolReference($tool, $handler, $structuredContentOnly);
$this->tools[$tool->name] = $reference;

$this->dispatch(new ToolListChangedEvent());
Expand Down
2 changes: 1 addition & 1 deletion src/Capability/Registry/Loader/DiscoveryLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
3 changes: 2 additions & 1 deletion src/Capability/Registry/Loader/ReflectedElementLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ final class ReflectedElementLoader implements LoaderInterface
* annotations: ?ToolAnnotations,
* icons: ?Icon[],
* meta: ?array<string, mixed>,
* structuredContentOnly?: bool,
* outputSchema: ?array<string, mixed>
* }[] $tools
* @param array{
Expand Down Expand Up @@ -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}");
Expand Down
1 change: 1 addition & 0 deletions src/Capability/Registry/ToolReference.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Capability/RegistryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ final class Builder
* icons: ?Icon[],
* meta: ?array<string, mixed>,
* outputSchema: ?array<string, mixed>,
* structuredContentOnly: bool,
* }[]
*/
private array $tools = [];
Expand Down Expand Up @@ -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<string, mixed>|null $inputSchema
* @param ?Icon[] $icons
* @param array<string, mixed>|null $meta
* @param array<string, mixed>|null $outputSchema
* @param bool $structuredContentOnly Omit generated content when structured content is available
*/
public function addTool(
callable|array|string $handler,
Expand All @@ -706,6 +708,7 @@ public function addTool(
?array $icons = null,
?array $meta = null,
?array $outputSchema = null,
bool $structuredContentOnly = false,
): self {
$this->tools[] = compact(
'handler',
Expand All @@ -717,6 +720,7 @@ public function addTool(
'icons',
'meta',
'outputSchema',
'structuredContentOnly',
);

return $this;
Expand Down
3 changes: 2 additions & 1 deletion src/Server/Handler/Request/CallToolHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions tests/Unit/Capability/Discovery/DiscovererToolTitleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')],
Expand All @@ -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}'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public function testLoadPropagatesToolTitleToRegisteredTool(): void
'icons' => null,
'meta' => null,
'outputSchema' => null,
'structuredContentOnly' => true,
],
];

Expand All @@ -44,5 +45,6 @@ public function testLoadPropagatesToolTitleToRegisteredTool(): void

$toolRef = $registry->getTool('weather_lookup');
$this->assertSame('Weather Lookup', $toolRef->tool->title);
$this->assertTrue($toolRef->structuredContentOnly);
}
}
11 changes: 11 additions & 0 deletions tests/Unit/Server/BuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions tests/Unit/Server/Handler/Request/CallToolHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
Loading