From 4f9df3dd5eec44aefac94f7a04aee0e1c3fbee42 Mon Sep 17 00:00:00 2001 From: Sule-balogun Olanrewaju Date: Thu, 9 Oct 2025 21:25:49 +0100 Subject: [PATCH] feat: Add output schema support to MCP tools --- .../stdio-env-variables/EnvToolHandler.php | 30 ++++++++- src/Capability/Attribute/McpTool.php | 8 ++- src/Capability/Discovery/Discoverer.php | 3 +- src/Capability/Discovery/DocBlockParser.php | 47 ++++++++++++++ src/Capability/Discovery/SchemaGenerator.php | 62 +++++++++++++++++++ src/Schema/Tool.php | 40 +++++++++--- src/Server/Builder.php | 7 ++- .../ManualStdioExampleTest-tools_list.json | 12 ++++ ...StdioCalculatorExampleTest-tools_list.json | 19 ++++++ .../Unit/Capability/Attribute/McpToolTest.php | 29 ++++++++- 10 files changed, 242 insertions(+), 15 deletions(-) diff --git a/examples/stdio-env-variables/EnvToolHandler.php b/examples/stdio-env-variables/EnvToolHandler.php index 49c914d5..1d4ee5a0 100644 --- a/examples/stdio-env-variables/EnvToolHandler.php +++ b/examples/stdio-env-variables/EnvToolHandler.php @@ -23,7 +23,35 @@ final class EnvToolHandler * * @return array 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 (only in debug mode)', + ], + 'processed_input_length' => [ + 'type' => 'integer', + 'description' => 'The length of the processed input (only in production mode)', + ], + '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 diff --git a/src/Capability/Attribute/McpTool.php b/src/Capability/Attribute/McpTool.php index d4af3e6c..f800dc2d 100644 --- a/src/Capability/Attribute/McpTool.php +++ b/src/Capability/Attribute/McpTool.php @@ -20,14 +20,16 @@ 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 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 array|null $outputSchema Optional JSON Schema object (as a PHP array) defining the expected output structure */ public function __construct( public ?string $name = null, public ?string $description = null, public ?ToolAnnotations $annotations = null, + public ?array $outputSchema = null, ) { } } diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 1520a8e1..7fab0628 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -231,7 +231,8 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $this->schemaGenerator->generate($method); - $tool = new Tool($name, $inputSchema, $description, $instance->annotations); + $outputSchema = $instance->outputSchema ?? $this->schemaGenerator->generateOutputSchema($method); + $tool = new Tool($name, $inputSchema, $description, $instance->annotations, $outputSchema); $tools[$name] = new ToolReference($tool, [$className, $methodName], false); ++$discoveredCount['tools']; break; diff --git a/src/Capability/Discovery/DocBlockParser.php b/src/Capability/Discovery/DocBlockParser.php index 91f417f2..6855b35a 100644 --- a/src/Capability/Discovery/DocBlockParser.php +++ b/src/Capability/Discovery/DocBlockParser.php @@ -136,4 +136,51 @@ public function getParamTypeString(?Param $paramTag): ?string return null; } + + /** + * Gets the return type string from a Return tag. + */ + public function getReturnTypeString(?DocBlock $docBlock): ?string + { + if (!$docBlock) { + return null; + } + + $returnTags = $docBlock->getTagsByName('return'); + if (empty($returnTags)) { + return null; + } + + $returnTag = $returnTags[0]; + if (method_exists($returnTag, 'getType') && $returnTag->getType()) { + $typeFromTag = trim((string) $returnTag->getType()); + if (!empty($typeFromTag)) { + return ltrim($typeFromTag, '\\'); + } + } + + return null; + } + + /** + * Gets the return type description from a Return tag. + */ + public function getReturnDescription(?DocBlock $docBlock): ?string + { + if (!$docBlock) { + return null; + } + + $returnTags = $docBlock->getTagsByName('return'); + if (empty($returnTags)) { + return null; + } + + $returnTag = $returnTags[0]; + $description = method_exists($returnTag, 'getDescription') + ? trim((string) $returnTag->getDescription()) + : ''; + + return $description ?: null; + } } diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 936a35cf..bde797f4 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -79,6 +79,36 @@ public function generate(\ReflectionMethod|\ReflectionFunction $reflection): arr return $this->buildSchemaFromParameters($parametersInfo, $methodSchema); } + /** + * Generates a JSON Schema object (as a PHP array) for a method's or function's return type. + * + * @return array|null + */ + public function generateOutputSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array + { + $docComment = $reflection->getDocComment() ?: null; + $docBlock = $this->docBlockParser->parseDocBlock($docComment); + + $docBlockReturnType = $this->docBlockParser->getReturnTypeString($docBlock); + $returnDescription = $this->docBlockParser->getReturnDescription($docBlock); + + $reflectionReturnType = $reflection->getReturnType(); + $reflectionReturnTypeString = $reflectionReturnType + ? $this->getTypeStringFromReflection($reflectionReturnType, $reflectionReturnType->allowsNull()) + : null; + + // Use DocBlock with generics, otherwise reflection, otherwise DocBlock + $returnTypeString = ($docBlockReturnType && str_contains($docBlockReturnType, '<')) + ? $docBlockReturnType + : ($reflectionReturnTypeString ?: $docBlockReturnType); + + if (!$returnTypeString || 'void' === strtolower($returnTypeString)) { + return null; + } + + return $this->buildOutputSchemaFromType($returnTypeString, $returnDescription); + } + /** * Extracts method-level or function-level Schema attribute. * @@ -784,4 +814,36 @@ private function mapSimpleTypeToJsonSchema(string $type): string default => \in_array(strtolower($type), ['datetime', 'datetimeinterface']) ? 'string' : 'object', }; } + + /** + * Builds an output schema from a return type string. + * + * @return array + */ + private function buildOutputSchemaFromType(string $returnTypeString, ?string $description): array + { + // Handle array types - treat as object with additionalProperties + if (str_contains($returnTypeString, 'array')) { + $schema = [ + 'type' => 'object', + 'additionalProperties' => ['type' => 'mixed'], + ]; + } else { + // Handle other types - wrap in object for MCP compatibility + $mappedType = $this->mapSimpleTypeToJsonSchema($returnTypeString); + $schema = [ + 'type' => 'object', + 'properties' => [ + 'result' => ['type' => $mappedType], + ], + 'required' => ['result'], + ]; + } + + if ($description) { + $schema['description'] = $description; + } + + return $schema; + } } diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 29646efc..af12d83a 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -23,11 +23,17 @@ * properties: array, * required: string[]|null * } + * @phpstan-type ToolOutputSchema array{ + * type: 'object', + * properties: array, + * required: string[]|null + * } * @phpstan-type ToolData array{ * name: string, * inputSchema: ToolInputSchema, * description?: string|null, * annotations?: ToolAnnotationsData, + * outputSchema?: ToolOutputSchema, * } * * @author Kyrian Obikwelu @@ -35,22 +41,28 @@ class Tool implements \JsonSerializable { /** - * @param string $name the name of the tool - * @param string|null $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|null $annotations optional additional tool information + * @param string $name the name of the tool + * @param string|null $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|null $annotations optional additional tool information + * @param ToolOutputSchema|null $outputSchema optional JSON Schema object (as a PHP array) defining the expected output structure */ public function __construct( public readonly string $name, public readonly array $inputSchema, public readonly ?string $description, public readonly ?ToolAnnotations $annotations, + 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".'); } + + if (null !== $outputSchema && (!isset($outputSchema['type']) || 'object' !== $outputSchema['type'])) { + throw new InvalidArgumentException('Tool outputSchema must be a JSON Schema of type "object" or null.'); + } } /** @@ -71,11 +83,21 @@ public static function fromArray(array $data): self $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['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, + $data['outputSchema'] ); } @@ -85,6 +107,7 @@ public static function fromArray(array $data): self * inputSchema: ToolInputSchema, * description?: string, * annotations?: ToolAnnotations, + * outputSchema?: ToolOutputSchema, * } */ public function jsonSerialize(): array @@ -99,6 +122,9 @@ public function jsonSerialize(): array if (null !== $this->annotations) { $data['annotations'] = $this->annotations; } + if (null !== $this->outputSchema) { + $data['outputSchema'] = $this->outputSchema; + } return $data; } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 8ac2dc4a..062e96c1 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -269,6 +269,7 @@ public function setDiscovery( * * @param Handler $handler * @param array|null $inputSchema + * @param array|null $outputSchema */ public function addTool( callable|array|string $handler, @@ -276,8 +277,9 @@ public function addTool( ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null, + ?array $outputSchema = null, ): self { - $this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema'); + $this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema', 'outputSchema'); return $this; } @@ -433,8 +435,9 @@ private function registerCapabilities( } $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); + $outputSchema = $data['outputSchema'] ?? $schemaGenerator->generateOutputSchema($reflection); - $tool = new Tool($name, $inputSchema, $description, $data['annotations']); + $tool = new Tool($name, $inputSchema, $description, $data['annotations'], $outputSchema); $registry->registerTool($tool, $data['handler'], true); $handlerDesc = $this->getHandlerDescription($data['handler']); diff --git a/tests/Inspector/snapshots/ManualStdioExampleTest-tools_list.json b/tests/Inspector/snapshots/ManualStdioExampleTest-tools_list.json index a5ecc534..cadd0633 100644 --- a/tests/Inspector/snapshots/ManualStdioExampleTest-tools_list.json +++ b/tests/Inspector/snapshots/ManualStdioExampleTest-tools_list.json @@ -14,6 +14,18 @@ "required": [ "text" ] + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "string" + } + }, + "required": [ + "result" + ], + "description": "the echoed text" } } ] diff --git a/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json b/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json index 2812c849..67cb1d38 100644 --- a/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json +++ b/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json @@ -24,6 +24,18 @@ "b", "operation" ] + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "object" + } + }, + "required": [ + "result" + ], + "description": "the result of the calculation, or an error message string" } }, { @@ -44,6 +56,13 @@ "setting", "value" ] + }, + "outputSchema": { + "type": "object", + "additionalProperties": { + "type": "mixed" + }, + "description": "success message or error" } } ] diff --git a/tests/Unit/Capability/Attribute/McpToolTest.php b/tests/Unit/Capability/Attribute/McpToolTest.php index e2814af7..3a55a5ac 100644 --- a/tests/Unit/Capability/Attribute/McpToolTest.php +++ b/tests/Unit/Capability/Attribute/McpToolTest.php @@ -33,11 +33,12 @@ public function testInstantiatesWithCorrectProperties(): void public function testInstantiatesWithNullValuesForNameAndDescription(): void { // Arrange & Act - $attribute = new McpTool(name: null, description: null); + $attribute = new McpTool(name: null, description: null, outputSchema: null); // Assert $this->assertNull($attribute->name); $this->assertNull($attribute->description); + $this->assertNull($attribute->outputSchema); } public function testInstantiatesWithMissingOptionalArguments(): void @@ -48,5 +49,31 @@ public function testInstantiatesWithMissingOptionalArguments(): void // Assert $this->assertNull($attribute->name); $this->assertNull($attribute->description); + $this->assertNull($attribute->outputSchema); + } + + public function testInstantiatesWithOutputSchema(): void + { + // Arrange + $name = 'test-tool-name'; + $description = 'This is a test description.'; + $outputSchema = [ + 'type' => 'object', + 'properties' => [ + 'result' => [ + 'type' => 'string', + 'description' => 'The result of the operation', + ], + ], + 'required' => ['result'], + ]; + + // Act + $attribute = new McpTool(name: $name, description: $description, outputSchema: $outputSchema); + + // Assert + $this->assertSame($name, $attribute->name); + $this->assertSame($description, $attribute->description); + $this->assertSame($outputSchema, $attribute->outputSchema); } }