feat: Add output schema support to MCP tools (#153)

This commit is contained in:
Sule-Balogun Olanrewaju 2026-01-11 20:14:48 +00:00 • committed by GitHub
commit c0938a19bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 605 additions and 55 deletions

View file

@ -21,11 +21,12 @@ use Mcp\Schema\ToolAnnotations;
class McpTool
{
/**
* @param string|null $name The name of the tool (defaults to the method name)
* @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 string|null $name The name of the tool (defaults to the method name)
* @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
*/
public function __construct(
public ?string $name = null,
@ -33,6 +34,7 @@ class McpTool
public ?ToolAnnotations $annotations = null,
public ?array $icons = null,
public ?array $meta = null,
public ?array $outputSchema = null,
) {
}
}

View file

@ -222,6 +222,7 @@ class Discoverer
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
$inputSchema = $this->schemaGenerator->generate($method);
$outputSchema = $this->schemaGenerator->generateOutputSchema($method);
$tool = new Tool(
$name,
$inputSchema,
@ -229,6 +230,7 @@ class Discoverer
$instance->annotations,
$instance->icons,
$instance->meta,
$outputSchema,
);
$tools[$name] = new ToolReference($tool, [$className, $methodName], false);
++$discoveredCount['tools'];

View file

@ -11,6 +11,7 @@
namespace Mcp\Capability\Discovery;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Server\RequestContext;
@ -81,6 +82,27 @@ class SchemaGenerator
return $this->buildSchemaFromParameters($parametersInfo, $methodSchema);
}
/**
* Generates a JSON Schema object (as a PHP array) for a method's or function's return type.
*
* Only returns an outputSchema if explicitly provided in the McpTool attribute.
* Per MCP spec, outputSchema should only be present when explicitly provided.
*
* @return array<string, mixed>|null
*/
public function generateOutputSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array
{
// Only return outputSchema if explicitly provided in McpTool attribute
$mcpToolAttrs = $reflection->getAttributes(McpTool::class, \ReflectionAttribute::IS_INSTANCEOF);
if ($mcpToolAttrs) {
$mcpToolInstance = $mcpToolAttrs[0]->newInstance();
return $mcpToolInstance->outputSchema;
}
return null;
}
/**
* Extracts method-level or function-level Schema attribute.
*

View file

@ -47,7 +47,8 @@ final class ArrayLoader implements LoaderInterface
* description: ?string,
* annotations: ?ToolAnnotations,
* icons: ?Icon[],
* meta: ?array<string, mixed>
* meta: ?array<string, mixed>,
* outputSchema: ?array<string, mixed>
* }[] $tools
* @param array{
* handler: Handler,
@ -117,6 +118,7 @@ final class ArrayLoader implements LoaderInterface
annotations: $data['annotations'] ?? null,
icons: $data['icons'] ?? null,
meta: $data['meta'] ?? null,
outputSchema: $data['outputSchema'] ?? null,
);
$registry->registerTool($tool, $data['handler'], true);

View file

@ -111,4 +111,33 @@ class ToolReference extends ElementReference
return [new TextContent($jsonResult)];
}
/**
* Extracts structured content from a tool result using the output schema.
*
* @param mixed $toolExecutionResult the raw value returned by the tool's PHP method
*
* @return array<string, mixed>|null the structured content, or null if not extractable
*
* @throws \JsonException if JSON encoding fails for non-Content array/object results
*/
public function extractStructuredContent(mixed $toolExecutionResult): ?array
{
if (\is_array($toolExecutionResult)) {
return $toolExecutionResult;
}
if (\is_object($toolExecutionResult) && !($toolExecutionResult instanceof Content)) {
$jsonResult = json_encode(
$toolExecutionResult,
\JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE
);
return json_decode(
$jsonResult, true, 512, \JSON_THROW_ON_ERROR
);
}
return null;
}
}

View file

@ -83,6 +83,7 @@ class CallToolResult implements ResultInterface
* content: array<mixed>,
* isError?: bool,
* _meta?: array<string, mixed>,
* structuredContent?: array<string, mixed>
* } $data
*/
public static function fromArray(array $data): self

View file

@ -24,13 +24,21 @@ use Mcp\Exception\InvalidArgumentException;
* properties: array<string, mixed>,
* required: string[]|null
* }
* @phpstan-type ToolOutputSchema array{
* type: 'object',
* properties?: array<string, mixed>,
* required?: string[]|null,
* additionalProperties?: bool|array<string, mixed>,
* description?: string
* }
* @phpstan-type ToolData array{
* name: string,
* inputSchema: ToolInputSchema,
* description?: string|null,
* annotations?: ToolAnnotationsData,
* icons?: IconData[],
* _meta?: array<string, mixed>
* _meta?: array<string, mixed>,
* outputSchema?: ToolOutputSchema
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
@ -38,14 +46,15 @@ use Mcp\Exception\InvalidArgumentException;
class Tool implements \JsonSerializable
{
/**
* @param string $name the name of the tool
* @param ?string $description A human-readable description of the tool.
* This can be used by clients to improve the LLM's understanding of
* available tools. It can be thought of like a "hint" to the model.
* @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool
* @param ?ToolAnnotations $annotations optional additional tool information
* @param ?Icon[] $icons optional icons representing the tool
* @param ?array<string, mixed> $meta Optional metadata
* @param string $name the name of the tool
* @param ?string $description A human-readable description of the tool.
* This can be used by clients to improve the LLM's understanding of
* available tools. It can be thought of like a "hint" to the model.
* @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool
* @param ?ToolAnnotations $annotations optional additional tool information
* @param ?Icon[] $icons optional icons representing the tool
* @param ?array<string, mixed> $meta Optional metadata
* @param ToolOutputSchema|null $outputSchema optional JSON Schema object (as a PHP array) defining the expected output structure
*/
public function __construct(
public readonly string $name,
@ -54,6 +63,7 @@ class Tool implements \JsonSerializable
public readonly ?ToolAnnotations $annotations,
public readonly ?array $icons = null,
public readonly ?array $meta = null,
public readonly ?array $outputSchema = null,
) {
if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) {
throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".');
@ -78,13 +88,23 @@ class Tool implements \JsonSerializable
$data['inputSchema']['properties'] = new \stdClass();
}
if (isset($data['outputSchema']) && \is_array($data['outputSchema'])) {
if (!isset($data['outputSchema']['type']) || 'object' !== $data['outputSchema']['type']) {
throw new InvalidArgumentException('Tool outputSchema must be of type "object".');
}
if (isset($data['outputSchema']['properties']) && \is_array($data['outputSchema']['properties']) && empty($data['outputSchema']['properties'])) {
$data['outputSchema']['properties'] = new \stdClass();
}
}
return new self(
$data['name'],
$data['inputSchema'],
isset($data['description']) && \is_string($data['description']) ? $data['description'] : null,
isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null,
isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null,
isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null
isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null,
isset($data['outputSchema']) && \is_array($data['outputSchema']) ? $data['outputSchema'] : null,
);
}
@ -95,7 +115,8 @@ class Tool implements \JsonSerializable
* description?: string,
* annotations?: ToolAnnotations,
* icons?: Icon[],
* _meta?: array<string, mixed>
* _meta?: array<string, mixed>,
* outputSchema?: ToolOutputSchema
* }
*/
public function jsonSerialize(): array
@ -116,6 +137,9 @@ class Tool implements \JsonSerializable
if (null !== $this->meta) {
$data['_meta'] = $this->meta;
}
if (null !== $this->outputSchema) {
$data['outputSchema'] = $this->outputSchema;
}
return $data;
}

View file

@ -87,7 +87,8 @@ final class Builder
* description: ?string,
* annotations: ?ToolAnnotations,
* icons: ?Icon[],
* meta: ?array<string, mixed>
* meta: ?array<string, mixed>,
* outputSchema: ?array<string, mixed>,
* }[]
*/
private array $tools = [];
@ -330,6 +331,7 @@ final class Builder
* @param array<string, mixed>|null $inputSchema
* @param ?Icon[] $icons
* @param array<string, mixed>|null $meta
* @param array<string, mixed>|null $outputSchema
*/
public function addTool(
callable|array|string $handler,
@ -339,6 +341,7 @@ final class Builder
?array $inputSchema = null,
?array $icons = null,
?array $meta = null,
?array $outputSchema = null,
): self {
$this->tools[] = compact(
'handler',
@ -348,6 +351,7 @@ final class Builder
'inputSchema',
'icons',
'meta',
'outputSchema',
);
return $this;

View file

@ -65,13 +65,16 @@ final class CallToolHandler implements RequestHandlerInterface
$result = $this->referenceHandler->handle($reference, $arguments);
$structuredContent = null;
if (!$result instanceof CallToolResult) {
$result = new CallToolResult($reference->formatResult($result));
$structuredContent = $reference->extractStructuredContent($result);
$result = new CallToolResult($reference->formatResult($result), structuredContent: $structuredContent);
}
$this->logger->debug('Tool executed successfully', [
'name' => $toolName,
'result_type' => \gettype($result),
'structured_content' => $structuredContent,
]);
return new Response($request->getId(), $result);