Skip to content

Commit 329533b

Browse files
committed
feat: Add output schema support to MCP tools
1 parent 08a1e54 commit 329533b

27 files changed

+710
-85
lines changed

examples/env-variables/EnvToolHandler.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,31 @@ final class EnvToolHandler
2323
*
2424
* @return array<string, string|int> the result, varying by APP_MODE
2525
*/
26-
#[McpTool(name: 'process_data_by_mode')]
26+
#[McpTool(
27+
name: 'process_data_by_mode',
28+
outputSchema: [
29+
'type' => 'object',
30+
'properties' => [
31+
'mode' => [
32+
'type' => 'string',
33+
'description' => 'The processing mode used',
34+
],
35+
'processed_input' => [
36+
'type' => 'string',
37+
'description' => 'The processed input data',
38+
],
39+
'original_input' => [
40+
'type' => 'string',
41+
'description' => 'The original input data (only in default mode)',
42+
],
43+
'message' => [
44+
'type' => 'string',
45+
'description' => 'A descriptive message about the processing',
46+
],
47+
],
48+
'required' => ['mode', 'message'],
49+
]
50+
)]
2751
public function processData(string $input): array
2852
{
2953
$appMode = getenv('APP_MODE'); // Read from environment

phpstan-baseline.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,9 @@ parameters:
55
identifier: return.type
66
count: 1
77
path: src/Schema/Result/ReadResourceResult.php
8+
9+
-
10+
message: '#^Method Mcp\\Tests\\Unit\\Capability\\Discovery\\DocBlockTestFixture\:\:methodWithMultipleTags\(\) has RuntimeException in PHPDoc @throws tag but it''s not thrown\.$#'
11+
identifier: throws.unusedType
12+
count: 1
13+
path: tests/Unit/Capability/Discovery/DocBlockTestFixture.php

src/Capability/Attribute/McpTool.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,20 @@
2121
class McpTool
2222
{
2323
/**
24-
* @param string|null $name The name of the tool (defaults to the method name)
25-
* @param string|null $description The description of the tool (defaults to the DocBlock/inferred)
26-
* @param ToolAnnotations|null $annotations Optional annotations describing tool behavior
27-
* @param ?Icon[] $icons Optional list of icon URLs representing the tool
28-
* @param ?array<string, mixed> $meta Optional metadata
24+
* @param string|null $name The name of the tool (defaults to the method name)
25+
* @param string|null $description The description of the tool (defaults to the DocBlock/inferred)
26+
* @param ToolAnnotations|null $annotations Optional annotations describing tool behavior
27+
* @param ?Icon[] $icons Optional list of icon URLs representing the tool
28+
* @param ?array<string, mixed> $meta Optional metadata
29+
* @param array<string, mixed> $outputSchema Optional JSON Schema object for defining the expected output structure
2930
*/
3031
public function __construct(
3132
public ?string $name = null,
3233
public ?string $description = null,
3334
public ?ToolAnnotations $annotations = null,
3435
public ?array $icons = null,
3536
public ?array $meta = null,
37+
public ?array $outputSchema = null,
3638
) {
3739
}
3840
}

src/Capability/Discovery/Discoverer.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,13 +222,15 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
222222
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
223223
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
224224
$inputSchema = $this->schemaGenerator->generate($method);
225+
$outputSchema = $this->schemaGenerator->generateOutputSchema($method);
225226
$tool = new Tool(
226227
$name,
227228
$inputSchema,
228229
$description,
229230
$instance->annotations,
230231
$instance->icons,
231232
$instance->meta,
233+
$outputSchema,
232234
);
233235
$tools[$name] = new ToolReference($tool, [$className, $methodName], false);
234236
++$discoveredCount['tools'];

src/Capability/Discovery/DocBlockParser.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use phpDocumentor\Reflection\DocBlock;
1515
use phpDocumentor\Reflection\DocBlock\Tags\Param;
16+
use phpDocumentor\Reflection\DocBlock\Tags\TagWithType;
1617
use phpDocumentor\Reflection\DocBlockFactory;
1718
use phpDocumentor\Reflection\DocBlockFactoryInterface;
1819
use Psr\Log\LoggerInterface;
@@ -136,4 +137,53 @@ public function getParamTypeString(?Param $paramTag): ?string
136137

137138
return null;
138139
}
140+
141+
/**
142+
* Gets the return type string from a Return tag.
143+
*/
144+
public function getReturnTypeString(?DocBlock $docBlock): ?string
145+
{
146+
if (null === $docBlock) {
147+
return null;
148+
}
149+
150+
$returnTags = $docBlock->getTagsByName('return');
151+
if ([] === $returnTags) {
152+
return null;
153+
}
154+
155+
$returnTag = $returnTags[0];
156+
if (!$returnTag instanceof TagWithType) {
157+
return null;
158+
}
159+
160+
$typeFromTag = trim((string) $returnTag->getType());
161+
if (!empty($typeFromTag)) {
162+
return ltrim($typeFromTag, '\\');
163+
}
164+
165+
return null;
166+
}
167+
168+
/**
169+
* Gets the return type description from a Return tag.
170+
*/
171+
public function getReturnDescription(?DocBlock $docBlock): ?string
172+
{
173+
if (null === $docBlock) {
174+
return null;
175+
}
176+
177+
$returnTags = $docBlock->getTagsByName('return');
178+
if ([] === $returnTags) {
179+
return null;
180+
}
181+
182+
$returnTag = $returnTags[0];
183+
if (!$returnTag instanceof TagWithType) {
184+
return null;
185+
}
186+
187+
return trim((string) $returnTag->getDescription()) ?: null;
188+
}
139189
}

src/Capability/Discovery/SchemaGenerator.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Mcp\Capability\Discovery;
1313

14+
use Mcp\Capability\Attribute\McpTool;
1415
use Mcp\Capability\Attribute\Schema;
1516
use Mcp\Server\ClientGateway;
1617
use phpDocumentor\Reflection\DocBlock\Tags\Param;
@@ -80,6 +81,28 @@ public function generate(\ReflectionMethod|\ReflectionFunction $reflection): arr
8081
return $this->buildSchemaFromParameters($parametersInfo, $methodSchema);
8182
}
8283

84+
/**
85+
* Generates a JSON Schema object (as a PHP array) for a method's or function's return type.
86+
*
87+
* Only returns an outputSchema if explicitly provided in the McpTool attribute.
88+
* Per MCP spec, outputSchema should only be present when explicitly provided.
89+
*
90+
* @return array<string, mixed>|null
91+
*/
92+
public function generateOutputSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array
93+
{
94+
// Only return outputSchema if explicitly provided in McpTool attribute
95+
$mcpToolAttrs = $reflection->getAttributes(McpTool::class, \ReflectionAttribute::IS_INSTANCEOF);
96+
if (!empty($mcpToolAttrs)) {
97+
$mcpToolInstance = $mcpToolAttrs[0]->newInstance();
98+
if (null !== $mcpToolInstance->outputSchema) {
99+
return $mcpToolInstance->outputSchema;
100+
}
101+
}
102+
103+
return null;
104+
}
105+
83106
/**
84107
* Extracts method-level or function-level Schema attribute.
85108
*

src/Capability/Registry/ToolReference.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,66 @@ public function formatResult(mixed $toolExecutionResult): array
111111

112112
return [new TextContent($jsonResult)];
113113
}
114+
115+
/**
116+
* Extracts structured content from a tool result using the output schema.
117+
*
118+
* @param mixed $toolExecutionResult the raw value returned by the tool's PHP method
119+
*
120+
* @return array<string, mixed>|null the structured content, or null if not extractable
121+
*/
122+
public function extractStructuredContent(mixed $toolExecutionResult): ?array
123+
{
124+
$outputSchema = $this->tool->outputSchema;
125+
if (null === $outputSchema) {
126+
return null;
127+
}
128+
129+
// If outputSchema has properties.result, wrap in result key
130+
if (isset($outputSchema['properties']['result'])) {
131+
return ['result' => $this->normalizeValue($toolExecutionResult)];
132+
}
133+
134+
if (isset($outputSchema['additionalProperties'])) {
135+
if (\is_array($toolExecutionResult)) {
136+
// Check if it's a numeric array (list)
137+
if (array_is_list($toolExecutionResult)) {
138+
// Wrap list in "object" schema
139+
return ['items' => $toolExecutionResult];
140+
}
141+
142+
return $toolExecutionResult;
143+
}
144+
145+
if (\is_object($toolExecutionResult) && !($toolExecutionResult instanceof Content)) {
146+
return $this->normalizeValue($toolExecutionResult);
147+
}
148+
}
149+
150+
if (isset($outputSchema['properties'])) {
151+
if (\is_array($toolExecutionResult)) {
152+
return $toolExecutionResult;
153+
}
154+
155+
if (\is_object($toolExecutionResult) && !($toolExecutionResult instanceof Content)) {
156+
return $this->normalizeValue($toolExecutionResult);
157+
}
158+
}
159+
160+
return null;
161+
}
162+
163+
/**
164+
* Convert objects to arrays for a normalized structured content.
165+
*
166+
* @throws \JsonException if JSON encoding fails for non-Content array/object results
167+
*/
168+
private function normalizeValue(mixed $value): mixed
169+
{
170+
if (\is_object($value) && !($value instanceof Content)) {
171+
return json_decode(json_encode($value, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR);
172+
}
173+
174+
return $value;
175+
}
114176
}

src/Schema/Result/CallToolResult.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,13 @@ public function __construct(
5959
/**
6060
* Create a new CallToolResult with success status.
6161
*
62-
* @param Content[] $content The content of the tool result
63-
* @param array<string, mixed>|null $meta Optional metadata
62+
* @param Content[] $content The content of the tool result
63+
* @param array<string, mixed>|null $meta Optional metadata
64+
* @param array<string, mixed>|null $structuredContent Optional structured content matching the tool's outputSchema
6465
*/
65-
public static function success(array $content, ?array $meta = null): self
66+
public static function success(array $content, ?array $meta = null, ?array $structuredContent = null): self
6667
{
67-
return new self($content, false, null, $meta);
68+
return new self($content, false, $meta, $structuredContent);
6869
}
6970

7071
/**
@@ -83,6 +84,7 @@ public static function error(array $content, ?array $meta = null): self
8384
* content: array<mixed>,
8485
* isError?: bool,
8586
* _meta?: array<string, mixed>,
87+
* structuredContent?: array<string, mixed>
8688
* } $data
8789
*/
8890
public static function fromArray(array $data): self

src/Schema/Tool.php

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,28 +24,37 @@
2424
* properties: array<string, mixed>,
2525
* required: string[]|null
2626
* }
27+
* @phpstan-type ToolOutputSchema array{
28+
* type: 'object',
29+
* properties?: array<string, mixed>,
30+
* required?: string[]|null,
31+
* additionalProperties?: bool|array<string, mixed>,
32+
* description?: string
33+
* }
2734
* @phpstan-type ToolData array{
2835
* name: string,
2936
* inputSchema: ToolInputSchema,
3037
* description?: string|null,
3138
* annotations?: ToolAnnotationsData,
3239
* icons?: IconData[],
33-
* _meta?: array<string, mixed>
40+
* _meta?: array<string, mixed>,
41+
* outputSchema?: ToolOutputSchema
3442
* }
3543
*
3644
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
3745
*/
3846
class Tool implements \JsonSerializable
3947
{
4048
/**
41-
* @param string $name the name of the tool
42-
* @param ?string $description A human-readable description of the tool.
43-
* This can be used by clients to improve the LLM's understanding of
44-
* available tools. It can be thought of like a "hint" to the model.
45-
* @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool
46-
* @param ?ToolAnnotations $annotations optional additional tool information
47-
* @param ?Icon[] $icons optional icons representing the tool
48-
* @param ?array<string, mixed> $meta Optional metadata
49+
* @param string $name the name of the tool
50+
* @param ?string $description A human-readable description of the tool.
51+
* This can be used by clients to improve the LLM's understanding of
52+
* available tools. It can be thought of like a "hint" to the model.
53+
* @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool
54+
* @param ?ToolAnnotations $annotations optional additional tool information
55+
* @param ?Icon[] $icons optional icons representing the tool
56+
* @param ?array<string, mixed> $meta Optional metadata
57+
* @param ToolOutputSchema|null $outputSchema optional JSON Schema object (as a PHP array) defining the expected output structure
4958
*/
5059
public function __construct(
5160
public readonly string $name,
@@ -54,6 +63,7 @@ public function __construct(
5463
public readonly ?ToolAnnotations $annotations,
5564
public readonly ?array $icons = null,
5665
public readonly ?array $meta = null,
66+
public readonly ?array $outputSchema = null,
5767
) {
5868
if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) {
5969
throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".');
@@ -78,13 +88,23 @@ public static function fromArray(array $data): self
7888
$data['inputSchema']['properties'] = new \stdClass();
7989
}
8090

91+
if (isset($data['outputSchema']) && \is_array($data['outputSchema'])) {
92+
if (!isset($data['outputSchema']['type']) || 'object' !== $data['outputSchema']['type']) {
93+
throw new InvalidArgumentException('Tool outputSchema must be of type "object".');
94+
}
95+
if (isset($data['outputSchema']['properties']) && \is_array($data['outputSchema']['properties']) && empty($data['outputSchema']['properties'])) {
96+
$data['outputSchema']['properties'] = new \stdClass();
97+
}
98+
}
99+
81100
return new self(
82101
$data['name'],
83102
$data['inputSchema'],
84103
isset($data['description']) && \is_string($data['description']) ? $data['description'] : null,
85104
isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null,
86105
isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null,
87-
isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null
106+
isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null,
107+
isset($data['outputSchema']) && \is_array($data['outputSchema']) ? $data['outputSchema'] : null,
88108
);
89109
}
90110

@@ -95,7 +115,8 @@ public static function fromArray(array $data): self
95115
* description?: string,
96116
* annotations?: ToolAnnotations,
97117
* icons?: Icon[],
98-
* _meta?: array<string, mixed>
118+
* _meta?: array<string, mixed>,
119+
* outputSchema?: ToolOutputSchema
99120
* }
100121
*/
101122
public function jsonSerialize(): array
@@ -116,6 +137,9 @@ public function jsonSerialize(): array
116137
if (null !== $this->meta) {
117138
$data['_meta'] = $this->meta;
118139
}
140+
if (null !== $this->outputSchema) {
141+
$data['outputSchema'] = $this->outputSchema;
142+
}
119143

120144
return $data;
121145
}

0 commit comments

Comments
 (0)