Skip to content

Commit 30c1edb

Browse files
committed
feat: Add output schema support to MCP tools
1 parent 7a58ab3 commit 30c1edb

19 files changed

+600
-43
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

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: 80 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,47 @@ 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+
* Checks for explicit outputSchema in McpTool attribute first, then auto-generates from return type.
88+
*
89+
* @return array<string, mixed>|null
90+
*/
91+
public function generateOutputSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array
92+
{
93+
// Check if McpTool attribute has explicit outputSchema
94+
$mcpToolAttrs = $reflection->getAttributes(McpTool::class, \ReflectionAttribute::IS_INSTANCEOF);
95+
if (!empty($mcpToolAttrs)) {
96+
$mcpToolInstance = $mcpToolAttrs[0]->newInstance();
97+
if (null !== $mcpToolInstance->outputSchema) {
98+
return $mcpToolInstance->outputSchema;
99+
}
100+
}
101+
102+
$docComment = $reflection->getDocComment() ?: null;
103+
$docBlock = $this->docBlockParser->parseDocBlock($docComment);
104+
105+
$docBlockReturnType = $this->docBlockParser->getReturnTypeString($docBlock);
106+
$returnDescription = $this->docBlockParser->getReturnDescription($docBlock);
107+
108+
$reflectionReturnType = $reflection->getReturnType();
109+
$reflectionReturnTypeString = $reflectionReturnType
110+
? $this->getTypeStringFromReflection($reflectionReturnType, $reflectionReturnType->allowsNull())
111+
: null;
112+
113+
// Use DocBlock with generics, otherwise reflection, otherwise DocBlock
114+
$returnTypeString = ($docBlockReturnType && str_contains($docBlockReturnType, '<'))
115+
? $docBlockReturnType
116+
: ($reflectionReturnTypeString ?: $docBlockReturnType);
117+
118+
if (!$returnTypeString || 'void' === strtolower($returnTypeString)) {
119+
return null;
120+
}
121+
122+
return $this->buildOutputSchemaFromType($returnTypeString, $returnDescription);
123+
}
124+
83125
/**
84126
* Extracts method-level or function-level Schema attribute.
85127
*
@@ -794,4 +836,42 @@ private function mapSimpleTypeToJsonSchema(string $type): string
794836
default => \in_array(strtolower($type), ['datetime', 'datetimeinterface']) ? 'string' : 'object',
795837
};
796838
}
839+
840+
/**
841+
* Builds an output schema from a return type string.
842+
*
843+
* @return array<string, mixed>
844+
*/
845+
private function buildOutputSchemaFromType(string $returnTypeString, ?string $description): array
846+
{
847+
// Handle array types - treat as object with additionalProperties
848+
if (str_contains($returnTypeString, 'array')) {
849+
$schema = [
850+
'type' => 'object',
851+
'additionalProperties' => true,
852+
];
853+
} else {
854+
// Use mapPhpTypeToJsonSchemaType to handle union types and nullable types
855+
$mappedTypes = $this->mapPhpTypeToJsonSchemaType($returnTypeString);
856+
857+
$nonNullTypes = array_filter($mappedTypes, fn ($type) => 'null' !== $type);
858+
859+
// If it's a union type use the array directly, or use the first (and only) type
860+
$typeSchema = \count($nonNullTypes) > 1 ? array_values($nonNullTypes) : ($nonNullTypes[0] ?? 'object');
861+
862+
$schema = [
863+
'type' => 'object',
864+
'properties' => [
865+
'result' => ['type' => $typeSchema],
866+
],
867+
'required' => ['result'],
868+
];
869+
}
870+
871+
if ($description) {
872+
$schema['description'] = $description;
873+
}
874+
875+
return $schema;
876+
}
797877
}

src/Capability/Registry/ToolReference.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,50 @@ 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['properties']) || isset($outputSchema['additionalProperties'])) {
135+
if (\is_array($toolExecutionResult)) {
136+
return $toolExecutionResult;
137+
}
138+
139+
if (\is_object($toolExecutionResult) && !($toolExecutionResult instanceof Content)) {
140+
return $this->normalizeValue($toolExecutionResult);
141+
}
142+
}
143+
144+
return null;
145+
}
146+
147+
/**
148+
* Convert objects to arrays for a normalized structured content.
149+
*
150+
* @throws \JsonException if JSON encoding fails for non-Content array/object results
151+
*/
152+
private function normalizeValue(mixed $value): mixed
153+
{
154+
if (\is_object($value) && !($value instanceof Content)) {
155+
return json_decode(json_encode($value, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR);
156+
}
157+
158+
return $value;
159+
}
114160
}

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

0 commit comments

Comments
 (0)