Skip to content

Commit 4f9df3d

Browse files
committed
feat: Add output schema support to MCP tools
1 parent 415f8dd commit 4f9df3d

File tree

10 files changed

+242
-15
lines changed

10 files changed

+242
-15
lines changed

examples/stdio-env-variables/EnvToolHandler.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,35 @@ 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 (only in debug mode)',
38+
],
39+
'processed_input_length' => [
40+
'type' => 'integer',
41+
'description' => 'The length of the processed input (only in production mode)',
42+
],
43+
'original_input' => [
44+
'type' => 'string',
45+
'description' => 'The original input data (only in default mode)',
46+
],
47+
'message' => [
48+
'type' => 'string',
49+
'description' => 'A descriptive message about the processing',
50+
],
51+
],
52+
'required' => ['mode', 'message'],
53+
]
54+
)]
2755
public function processData(string $input): array
2856
{
2957
$appMode = getenv('APP_MODE'); // Read from environment

src/Capability/Attribute/McpTool.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,16 @@
2020
class McpTool
2121
{
2222
/**
23-
* @param string|null $name The name of the tool (defaults to the method name)
24-
* @param string|null $description The description of the tool (defaults to the DocBlock/inferred)
25-
* @param ToolAnnotations|null $annotations Optional annotations describing tool behavior
23+
* @param string|null $name The name of the tool (defaults to the method name)
24+
* @param string|null $description The description of the tool (defaults to the DocBlock/inferred)
25+
* @param ToolAnnotations|null $annotations Optional annotations describing tool behavior
26+
* @param array<string, mixed>|null $outputSchema Optional JSON Schema object (as a PHP array) defining the expected output structure
2627
*/
2728
public function __construct(
2829
public ?string $name = null,
2930
public ?string $description = null,
3031
public ?ToolAnnotations $annotations = null,
32+
public ?array $outputSchema = null,
3133
) {
3234
}
3335
}

src/Capability/Discovery/Discoverer.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,8 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
231231
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
232232
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
233233
$inputSchema = $this->schemaGenerator->generate($method);
234-
$tool = new Tool($name, $inputSchema, $description, $instance->annotations);
234+
$outputSchema = $instance->outputSchema ?? $this->schemaGenerator->generateOutputSchema($method);
235+
$tool = new Tool($name, $inputSchema, $description, $instance->annotations, $outputSchema);
235236
$tools[$name] = new ToolReference($tool, [$className, $methodName], false);
236237
++$discoveredCount['tools'];
237238
break;

src/Capability/Discovery/DocBlockParser.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,51 @@ public function getParamTypeString(?Param $paramTag): ?string
136136

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

src/Capability/Discovery/SchemaGenerator.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,36 @@ public function generate(\ReflectionMethod|\ReflectionFunction $reflection): arr
7979
return $this->buildSchemaFromParameters($parametersInfo, $methodSchema);
8080
}
8181

82+
/**
83+
* Generates a JSON Schema object (as a PHP array) for a method's or function's return type.
84+
*
85+
* @return array<string, mixed>|null
86+
*/
87+
public function generateOutputSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array
88+
{
89+
$docComment = $reflection->getDocComment() ?: null;
90+
$docBlock = $this->docBlockParser->parseDocBlock($docComment);
91+
92+
$docBlockReturnType = $this->docBlockParser->getReturnTypeString($docBlock);
93+
$returnDescription = $this->docBlockParser->getReturnDescription($docBlock);
94+
95+
$reflectionReturnType = $reflection->getReturnType();
96+
$reflectionReturnTypeString = $reflectionReturnType
97+
? $this->getTypeStringFromReflection($reflectionReturnType, $reflectionReturnType->allowsNull())
98+
: null;
99+
100+
// Use DocBlock with generics, otherwise reflection, otherwise DocBlock
101+
$returnTypeString = ($docBlockReturnType && str_contains($docBlockReturnType, '<'))
102+
? $docBlockReturnType
103+
: ($reflectionReturnTypeString ?: $docBlockReturnType);
104+
105+
if (!$returnTypeString || 'void' === strtolower($returnTypeString)) {
106+
return null;
107+
}
108+
109+
return $this->buildOutputSchemaFromType($returnTypeString, $returnDescription);
110+
}
111+
82112
/**
83113
* Extracts method-level or function-level Schema attribute.
84114
*
@@ -784,4 +814,36 @@ private function mapSimpleTypeToJsonSchema(string $type): string
784814
default => \in_array(strtolower($type), ['datetime', 'datetimeinterface']) ? 'string' : 'object',
785815
};
786816
}
817+
818+
/**
819+
* Builds an output schema from a return type string.
820+
*
821+
* @return array<string, mixed>
822+
*/
823+
private function buildOutputSchemaFromType(string $returnTypeString, ?string $description): array
824+
{
825+
// Handle array types - treat as object with additionalProperties
826+
if (str_contains($returnTypeString, 'array')) {
827+
$schema = [
828+
'type' => 'object',
829+
'additionalProperties' => ['type' => 'mixed'],
830+
];
831+
} else {
832+
// Handle other types - wrap in object for MCP compatibility
833+
$mappedType = $this->mapSimpleTypeToJsonSchema($returnTypeString);
834+
$schema = [
835+
'type' => 'object',
836+
'properties' => [
837+
'result' => ['type' => $mappedType],
838+
],
839+
'required' => ['result'],
840+
];
841+
}
842+
843+
if ($description) {
844+
$schema['description'] = $description;
845+
}
846+
847+
return $schema;
848+
}
787849
}

src/Schema/Tool.php

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,34 +23,46 @@
2323
* properties: array<string, mixed>,
2424
* required: string[]|null
2525
* }
26+
* @phpstan-type ToolOutputSchema array{
27+
* type: 'object',
28+
* properties: array<string, mixed>,
29+
* required: string[]|null
30+
* }
2631
* @phpstan-type ToolData array{
2732
* name: string,
2833
* inputSchema: ToolInputSchema,
2934
* description?: string|null,
3035
* annotations?: ToolAnnotationsData,
36+
* outputSchema?: ToolOutputSchema,
3137
* }
3238
*
3339
* @author Kyrian Obikwelu <[email protected]>
3440
*/
3541
class Tool implements \JsonSerializable
3642
{
3743
/**
38-
* @param string $name the name of the tool
39-
* @param string|null $description A human-readable description of the tool.
40-
* This can be used by clients to improve the LLM's understanding of
41-
* available tools. It can be thought of like a "hint" to the model.
42-
* @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool
43-
* @param ToolAnnotations|null $annotations optional additional tool information
44+
* @param string $name the name of the tool
45+
* @param string|null $description A human-readable description of the tool.
46+
* This can be used by clients to improve the LLM's understanding of
47+
* available tools. It can be thought of like a "hint" to the model.
48+
* @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool
49+
* @param ToolAnnotations|null $annotations optional additional tool information
50+
* @param ToolOutputSchema|null $outputSchema optional JSON Schema object (as a PHP array) defining the expected output structure
4451
*/
4552
public function __construct(
4653
public readonly string $name,
4754
public readonly array $inputSchema,
4855
public readonly ?string $description,
4956
public readonly ?ToolAnnotations $annotations,
57+
public readonly ?array $outputSchema = null,
5058
) {
5159
if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) {
5260
throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".');
5361
}
62+
63+
if (null !== $outputSchema && (!isset($outputSchema['type']) || 'object' !== $outputSchema['type'])) {
64+
throw new InvalidArgumentException('Tool outputSchema must be a JSON Schema of type "object" or null.');
65+
}
5466
}
5567

5668
/**
@@ -71,11 +83,21 @@ public static function fromArray(array $data): self
7183
$data['inputSchema']['properties'] = new \stdClass();
7284
}
7385

86+
if (isset($data['outputSchema']) && \is_array($data['outputSchema'])) {
87+
if (!isset($data['outputSchema']['type']) || 'object' !== $data['outputSchema']['type']) {
88+
throw new InvalidArgumentException('Tool outputSchema must be of type "object".');
89+
}
90+
if (isset($data['outputSchema']['properties']) && \is_array($data['outputSchema']['properties']) && empty($data['outputSchema']['properties'])) {
91+
$data['outputSchema']['properties'] = new \stdClass();
92+
}
93+
}
94+
7495
return new self(
7596
$data['name'],
7697
$data['inputSchema'],
7798
isset($data['description']) && \is_string($data['description']) ? $data['description'] : null,
78-
isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null
99+
isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null,
100+
$data['outputSchema']
79101
);
80102
}
81103

@@ -85,6 +107,7 @@ public static function fromArray(array $data): self
85107
* inputSchema: ToolInputSchema,
86108
* description?: string,
87109
* annotations?: ToolAnnotations,
110+
* outputSchema?: ToolOutputSchema,
88111
* }
89112
*/
90113
public function jsonSerialize(): array
@@ -99,6 +122,9 @@ public function jsonSerialize(): array
99122
if (null !== $this->annotations) {
100123
$data['annotations'] = $this->annotations;
101124
}
125+
if (null !== $this->outputSchema) {
126+
$data['outputSchema'] = $this->outputSchema;
127+
}
102128

103129
return $data;
104130
}

src/Server/Builder.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,15 +269,17 @@ public function setDiscovery(
269269
*
270270
* @param Handler $handler
271271
* @param array<string, mixed>|null $inputSchema
272+
* @param array<string, mixed>|null $outputSchema
272273
*/
273274
public function addTool(
274275
callable|array|string $handler,
275276
?string $name = null,
276277
?string $description = null,
277278
?ToolAnnotations $annotations = null,
278279
?array $inputSchema = null,
280+
?array $outputSchema = null,
279281
): self {
280-
$this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema');
282+
$this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema', 'outputSchema');
281283

282284
return $this;
283285
}
@@ -433,8 +435,9 @@ private function registerCapabilities(
433435
}
434436

435437
$inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection);
438+
$outputSchema = $data['outputSchema'] ?? $schemaGenerator->generateOutputSchema($reflection);
436439

437-
$tool = new Tool($name, $inputSchema, $description, $data['annotations']);
440+
$tool = new Tool($name, $inputSchema, $description, $data['annotations'], $outputSchema);
438441
$registry->registerTool($tool, $data['handler'], true);
439442

440443
$handlerDesc = $this->getHandlerDescription($data['handler']);

tests/Inspector/snapshots/ManualStdioExampleTest-tools_list.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@
1414
"required": [
1515
"text"
1616
]
17+
},
18+
"outputSchema": {
19+
"type": "object",
20+
"properties": {
21+
"result": {
22+
"type": "string"
23+
}
24+
},
25+
"required": [
26+
"result"
27+
],
28+
"description": "the echoed text"
1729
}
1830
}
1931
]

tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@
2424
"b",
2525
"operation"
2626
]
27+
},
28+
"outputSchema": {
29+
"type": "object",
30+
"properties": {
31+
"result": {
32+
"type": "object"
33+
}
34+
},
35+
"required": [
36+
"result"
37+
],
38+
"description": "the result of the calculation, or an error message string"
2739
}
2840
},
2941
{
@@ -44,6 +56,13 @@
4456
"setting",
4557
"value"
4658
]
59+
},
60+
"outputSchema": {
61+
"type": "object",
62+
"additionalProperties": {
63+
"type": "mixed"
64+
},
65+
"description": "success message or error"
4766
}
4867
}
4968
]

0 commit comments

Comments
 (0)