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
39 changed files with 605 additions and 55 deletions

View file

@ -2,6 +2,11 @@
All notable changes to `mcp/sdk` will be documented in this file.
0.3
-----
* Add output schema support to MCP tools
0.2.2
-----

View file

@ -23,7 +23,31 @@ final class EnvToolHandler
*
* @return array<string, string|int> the result, varying by APP_MODE
*/
#[McpTool(name: 'process_data_by_mode')]
#[McpTool(
name: 'process_data_by_mode',
outputSchema: [
'type' => 'object',
'properties' => [
'mode' => [
'type' => 'string',
'description' => 'The processing mode used',
],
'processed_input' => [
'type' => 'string',
'description' => 'The processed input data',
],
'original_input' => [
'type' => 'string',
'description' => 'The original input data (only in default mode)',
],
'message' => [
'type' => 'string',
'description' => 'A descriptive message about the processing',
],
],
'required' => ['mode', 'message'],
]
)]
public function processData(string $input): array
{
$appMode = getenv('APP_MODE'); // Read from environment

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);

View file

@ -11,13 +11,13 @@
namespace Mcp\Tests\Conformance;
use Mcp\Schema\Content\Content;
use Mcp\Schema\Content\EmbeddedResource;
use Mcp\Schema\Content\ImageContent;
use Mcp\Schema\Content\PromptMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Content\TextResourceContents;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Server\Protocol;
use Mcp\Server\RequestContext;
@ -26,12 +26,9 @@ final class Elements
public const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
public const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=';
/**
* @return Content[]
*/
public function toolMultipleTypes(): array
public function toolMultipleTypes(): CallToolResult
{
return [
return new CallToolResult([
new TextContent('Multiple content types test:'),
new ImageContent(self::TEST_IMAGE_BASE64, 'image/png'),
EmbeddedResource::fromText(
@ -39,7 +36,7 @@ final class Elements
'{ "test" = "data", "value" = 123 }',
'application/json',
),
];
]);
}
public function toolWithLogging(RequestContext $context): string

View file

@ -5,5 +5,18 @@
"text": "{\n \"success\": true,\n \"message\": \"Event \\\"Project Deadline\\\" scheduled successfully for \\\"2024-12-15\\\".\",\n \"event_details\": {\n \"title\": \"Project Deadline\",\n \"date\": \"2024-12-15\",\n \"type\": \"reminder\",\n \"time\": \"All day\",\n \"priority\": \"Normal\",\n \"attendees\": [],\n \"invites_will_be_sent\": false\n }\n}"
}
],
"structuredContent": {
"success": true,
"message": "Event \"Project Deadline\" scheduled successfully for \"2024-12-15\".",
"event_details": {
"title": "Project Deadline",
"date": "2024-12-15",
"type": "reminder",
"time": "All day",
"priority": "Normal",
"attendees": [],
"invites_will_be_sent": false
}
},
"isError": false
}

View file

@ -5,5 +5,20 @@
"text": "{\n \"success\": true,\n \"message\": \"Event \\\"Client Call\\\" scheduled successfully for \\\"2024-12-02\\\".\",\n \"event_details\": {\n \"title\": \"Client Call\",\n \"date\": \"2024-12-02\",\n \"type\": \"call\",\n \"time\": \"14:30\",\n \"priority\": \"Normal\",\n \"attendees\": [\n \"client@example.com\"\n ],\n \"invites_will_be_sent\": false\n }\n}"
}
],
"structuredContent": {
"success": true,
"message": "Event \"Client Call\" scheduled successfully for \"2024-12-02\".",
"event_details": {
"title": "Client Call",
"date": "2024-12-02",
"type": "call",
"time": "14:30",
"priority": "Normal",
"attendees": [
"client@example.com"
],
"invites_will_be_sent": false
}
},
"isError": false
}

View file

@ -5,5 +5,20 @@
"text": "{\n \"success\": true,\n \"message\": \"Event \\\"Office Party\\\" scheduled successfully for \\\"2024-12-20\\\".\",\n \"event_details\": {\n \"title\": \"Office Party\",\n \"date\": \"2024-12-20\",\n \"type\": \"other\",\n \"time\": \"18:00\",\n \"priority\": \"Normal\",\n \"attendees\": [\n \"team@company.com\"\n ],\n \"invites_will_be_sent\": true\n }\n}"
}
],
"structuredContent": {
"success": true,
"message": "Event \"Office Party\" scheduled successfully for \"2024-12-20\".",
"event_details": {
"title": "Office Party",
"date": "2024-12-20",
"type": "other",
"time": "18:00",
"priority": "Normal",
"attendees": [
"team@company.com"
],
"invites_will_be_sent": true
}
},
"isError": false
}

View file

@ -5,5 +5,21 @@
"text": "{\n \"success\": true,\n \"message\": \"Event \\\"Team Standup\\\" scheduled successfully for \\\"2024-12-01\\\".\",\n \"event_details\": {\n \"title\": \"Team Standup\",\n \"date\": \"2024-12-01\",\n \"type\": \"meeting\",\n \"time\": \"09:00\",\n \"priority\": \"Normal\",\n \"attendees\": [\n \"alice@example.com\",\n \"bob@example.com\"\n ],\n \"invites_will_be_sent\": true\n }\n}"
}
],
"structuredContent": {
"success": true,
"message": "Event \"Team Standup\" scheduled successfully for \"2024-12-01\".",
"event_details": {
"title": "Team Standup",
"date": "2024-12-01",
"type": "meeting",
"time": "09:00",
"priority": "Normal",
"attendees": [
"alice@example.com",
"bob@example.com"
],
"invites_will_be_sent": true
}
},
"isError": false
}

View file

@ -5,5 +5,9 @@
"text": "{\n \"success\": true,\n \"message_sent\": \"Welcome, Alice! Welcome to our platform!\"\n}"
}
],
"structuredContent": {
"success": true,
"message_sent": "Welcome, Alice! Welcome to our platform!"
},
"isError": false
}

View file

@ -5,5 +5,9 @@
"text": "{\n \"success\": true,\n \"message\": \"Test tool without params\"\n}"
}
],
"structuredContent": {
"success": true,
"message": "Test tool without params"
},
"isError": false
}

View file

@ -5,5 +5,11 @@
"text": "{\n \"result\": 50,\n \"operation\": \"10 multiply 5\",\n \"precision\": 2,\n \"within_bounds\": true\n}"
}
],
"structuredContent": {
"result": 50,
"operation": "10 multiply 5",
"precision": 2,
"within_bounds": true
},
"isError": false
}

View file

@ -5,5 +5,11 @@
"text": "{\n \"original\": \"Hello World Test\",\n \"formatted\": \"HELLO WORLD TEST\",\n \"length\": 16,\n \"format_applied\": \"uppercase\"\n}"
}
],
"structuredContent": {
"original": "Hello World Test",
"formatted": "HELLO WORLD TEST",
"length": 16,
"format_applied": "uppercase"
},
"isError": false
}

Some files were not shown because too many files have changed in this diff Show more