From 13d5b77648649316ee7858052454bcda1bd0edb5 Mon Sep 17 00:00:00 2001 From: Sule-balogun Olanrewaju Date: Sat, 29 Nov 2025 19:07:26 +0000 Subject: [PATCH] feat: Add output schema support to MCP tools --- examples/env-variables/EnvToolHandler.php | 26 +++++++- phpstan-baseline.neon | 6 ++ src/Capability/Attribute/McpTool.php | 12 ++-- src/Capability/Discovery/Discoverer.php | 2 + src/Capability/Discovery/SchemaGenerator.php | 23 +++++++ src/Capability/Registry/ToolReference.php | 44 +++++++++++++ src/Schema/Result/CallToolResult.php | 10 +-- src/Schema/Tool.php | 46 +++++++++---- src/Server/Builder.php | 6 +- .../Handler/Request/CallToolHandler.php | 13 +++- ...owcaseTest-tools_call-calculate_range.json | 14 ++-- ...maShowcaseTest-tools_call-format_text.json | 14 ++-- ...owcaseTest-tools_call-generate_config.json | 14 ++-- ...maShowcaseTest-tools_call-manage_list.json | 14 ++-- ...howcaseTest-tools_call-schedule_event.json | 14 ++-- ...wcaseTest-tools_call-validate_profile.json | 14 ++-- ...dioDiscoveryCalculatorTest-tools_call.json | 5 +- ...lesTest-tools_call-process_data_debug.json | 7 +- ...sTest-tools_call-process_data_default.json | 7 +- ...st-tools_call-process_data_production.json | 7 +- .../StdioEnvVariablesTest-tools_list.json | 25 ++++++++ .../Unit/Capability/Attribute/McpToolTest.php | 31 ++++++++- .../Discovery/SchemaGeneratorFixture.php | 29 +++++++++ .../Discovery/SchemaGeneratorTest.php | 41 ++++++++++++ tests/Unit/Capability/RegistryTest.php | 47 +++++++++++++- .../Handler/Request/CallToolHandlerTest.php | 64 +++++++++++++++---- 26 files changed, 450 insertions(+), 85 deletions(-) diff --git a/examples/env-variables/EnvToolHandler.php b/examples/env-variables/EnvToolHandler.php index f7cad817..bab055e9 100644 --- a/examples/env-variables/EnvToolHandler.php +++ b/examples/env-variables/EnvToolHandler.php @@ -23,7 +23,31 @@ 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', + ], + '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/phpstan-baseline.neon b/phpstan-baseline.neon index f5901b28..a65e9974 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5,3 +5,9 @@ parameters: identifier: return.type count: 1 path: src/Schema/Result/ReadResourceResult.php + + - + message: '#^Method Mcp\\Tests\\Unit\\Capability\\Discovery\\DocBlockTestFixture\:\:methodWithMultipleTags\(\) has RuntimeException in PHPDoc @throws tag but it''s not thrown\.$#' + identifier: throws.unusedType + count: 1 + path: tests/Unit/Capability/Discovery/DocBlockTestFixture.php diff --git a/src/Capability/Attribute/McpTool.php b/src/Capability/Attribute/McpTool.php index 85dbc225..fe754e90 100644 --- a/src/Capability/Attribute/McpTool.php +++ b/src/Capability/Attribute/McpTool.php @@ -21,11 +21,12 @@ 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 $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 $meta Optional metadata + * @param array $outputSchema Optional JSON Schema object for defining the expected output structure */ public function __construct( public ?string $name = null, @@ -33,6 +34,7 @@ public function __construct( public ?ToolAnnotations $annotations = null, public ?array $icons = null, public ?array $meta = null, + public ?array $outputSchema = null, ) { } } diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 88ec4117..f30d5fff 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -222,6 +222,7 @@ 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); + $outputSchema = $this->schemaGenerator->generateOutputSchema($method); $tool = new Tool( $name, $inputSchema, @@ -229,6 +230,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $instance->annotations, $instance->icons, $instance->meta, + $outputSchema, ); $tools[$name] = new ToolReference($tool, [$className, $methodName], false); ++$discoveredCount['tools']; diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 2557f559..4f950865 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -11,6 +11,7 @@ namespace Mcp\Capability\Discovery; +use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; use Mcp\Server\ClientGateway; use phpDocumentor\Reflection\DocBlock\Tags\Param; @@ -80,6 +81,28 @@ 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. + * + * Only returns an outputSchema if explicitly provided in the McpTool attribute. + * Per MCP spec, outputSchema should only be present when explicitly provided. + * + * @return array|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 (!empty($mcpToolAttrs)) { + $mcpToolInstance = $mcpToolAttrs[0]->newInstance(); + if (null !== $mcpToolInstance->outputSchema) { + return $mcpToolInstance->outputSchema; + } + } + + return null; + } + /** * Extracts method-level or function-level Schema attribute. * diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index e5b5a7df..79b0a4b1 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -111,4 +111,48 @@ public function formatResult(mixed $toolExecutionResult): array 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|null the structured content, or null if not extractable + */ + public function extractStructuredContent(mixed $toolExecutionResult): ?array + { + $outputSchema = $this->tool->outputSchema; + if (null === $outputSchema) { + return null; + } + + if (\is_array($toolExecutionResult)) { + if (array_is_list($toolExecutionResult) && isset($outputSchema['additionalProperties'])) { + // Wrap list in "object" schema for additionalProperties + return ['items' => $toolExecutionResult]; + } + + return $toolExecutionResult; + } + + if (\is_object($toolExecutionResult) && !($toolExecutionResult instanceof Content)) { + return $this->normalizeValue($toolExecutionResult); + } + + return null; + } + + /** + * Convert objects to arrays for a normalized structured content. + * + * @throws \JsonException if JSON encoding fails for non-Content array/object results + */ + private function normalizeValue(mixed $value): mixed + { + if (\is_object($value) && !($value instanceof Content)) { + return json_decode(json_encode($value, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR); + } + + return $value; + } } diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index 4f31e034..3da016dc 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -59,12 +59,13 @@ public function __construct( /** * Create a new CallToolResult with success status. * - * @param Content[] $content The content of the tool result - * @param array|null $meta Optional metadata + * @param Content[] $content The content of the tool result + * @param array|null $meta Optional metadata + * @param array|null $structuredContent Optional structured content matching the tool's outputSchema */ - public static function success(array $content, ?array $meta = null): self + public static function success(array $content, ?array $meta = null, ?array $structuredContent = null): self { - return new self($content, false, null, $meta); + return new self($content, false, $meta, $structuredContent); } /** @@ -83,6 +84,7 @@ public static function error(array $content, ?array $meta = null): self * content: array, * isError?: bool, * _meta?: array, + * structuredContent?: array * } $data */ public static function fromArray(array $data): self diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 3a4e8193..4a1768ee 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -24,13 +24,21 @@ * properties: array, * required: string[]|null * } + * @phpstan-type ToolOutputSchema array{ + * type: 'object', + * properties?: array, + * required?: string[]|null, + * additionalProperties?: bool|array, + * description?: string + * } * @phpstan-type ToolData array{ * name: string, * inputSchema: ToolInputSchema, * description?: string|null, * annotations?: ToolAnnotationsData, * icons?: IconData[], - * _meta?: array + * _meta?: array, + * outputSchema?: ToolOutputSchema * } * * @author Kyrian Obikwelu @@ -38,14 +46,15 @@ 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 $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 $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 @@ public function __construct( 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 @@ 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['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 @@ public static function fromArray(array $data): self * description?: string, * annotations?: ToolAnnotations, * icons?: Icon[], - * _meta?: array + * _meta?: array, + * outputSchema?: ToolOutputSchema * } */ public function jsonSerialize(): array @@ -116,6 +137,9 @@ public function jsonSerialize(): array if (null !== $this->meta) { $data['_meta'] = $this->meta; } + if (null !== $this->outputSchema) { + $data['outputSchema'] = $this->outputSchema; + } return $data; } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 1d1f8d07..4b411645 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -87,7 +87,8 @@ final class Builder * description: ?string, * annotations: ?ToolAnnotations, * icons: ?Icon[], - * meta: ?array + * meta: ?array, + * output: ?array, * }[] */ private array $tools = []; @@ -330,6 +331,7 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self * @param array|null $inputSchema * @param ?Icon[] $icons * @param array|null $meta + * @param array|null $outputSchema */ public function addTool( callable|array|string $handler, @@ -339,6 +341,7 @@ public function addTool( ?array $inputSchema = null, ?array $icons = null, ?array $meta = null, + ?array $outputSchema = null, ): self { $this->tools[] = compact( 'handler', @@ -348,6 +351,7 @@ public function addTool( 'inputSchema', 'icons', 'meta', + 'outputSchema', ); return $this; diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index e0430802..1b01518a 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -62,15 +62,22 @@ public function handle(Request $request, SessionInterface $session): Response|Er $arguments['_session'] = $session; - $result = $this->referenceHandler->handle($reference, $arguments); + $rawResult = $this->referenceHandler->handle($reference, $arguments); - if (!$result instanceof CallToolResult) { - $result = new CallToolResult($reference->formatResult($result)); + $structuredContent = null; + if (null !== $reference->tool->outputSchema && !$rawResult instanceof CallToolResult) { + $structuredContent = $reference->extractStructuredContent($rawResult); + } + + $result = $rawResult; + if (!$rawResult instanceof CallToolResult) { + $result = new CallToolResult($reference->formatResult($rawResult), structuredContent: $structuredContent); } $this->logger->debug('Tool executed successfully', [ 'name' => $toolName, 'result_type' => \gettype($result), + 'structured_content' => $structuredContent, ]); return new Response($request->getId(), $result); diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json index 817d33d9..b9804312 100644 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json @@ -1,9 +1,9 @@ { - "content": [ - { - "type": "text", - "text": "{\n \"result\": 50,\n \"operation\": \"10 multiply 5\",\n \"precision\": 2,\n \"within_bounds\": true\n}" - } - ], - "isError": false + "content": [ + { + "type": "text", + "text": "{\n \"result\": 50,\n \"operation\": \"10 multiply 5\",\n \"precision\": 2,\n \"within_bounds\": true\n}" + } + ], + "isError": false } diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json index eb9d89de..f6b476f5 100644 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json @@ -1,9 +1,9 @@ { - "content": [ - { - "type": "text", - "text": "{\n \"original\": \"Hello World Test\",\n \"formatted\": \"HELLO WORLD TEST\",\n \"length\": 16,\n \"format_applied\": \"uppercase\"\n}" - } - ], - "isError": false + "content": [ + { + "type": "text", + "text": "{\n \"original\": \"Hello World Test\",\n \"formatted\": \"HELLO WORLD TEST\",\n \"length\": 16,\n \"format_applied\": \"uppercase\"\n}" + } + ], + "isError": false } diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json index e193e9fb..5a6c9bbd 100644 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json @@ -1,9 +1,9 @@ { - "content": [ - { - "type": "text", - "text": "{\n \"success\": true,\n \"config\": {\n \"app\": {\n \"name\": \"TestApp\",\n \"env\": \"development\",\n \"debug\": true,\n \"url\": \"https://example.com\",\n \"port\": 8080\n },\n \"generated_at\": \"2025-01-01T00:00:00+00:00\",\n \"version\": \"1.0.0\",\n \"features\": {\n \"logging\": true,\n \"caching\": false,\n \"analytics\": false,\n \"rate_limiting\": false\n }\n },\n \"validation\": {\n \"app_name_valid\": true,\n \"url_valid\": true,\n \"port_in_range\": true\n }\n}" - } - ], - "isError": false + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"config\": {\n \"app\": {\n \"name\": \"TestApp\",\n \"env\": \"development\",\n \"debug\": true,\n \"url\": \"https://example.com\",\n \"port\": 8080\n },\n \"generated_at\": \"2025-01-01T00:00:00+00:00\",\n \"version\": \"1.0.0\",\n \"features\": {\n \"logging\": true,\n \"caching\": false,\n \"analytics\": false,\n \"rate_limiting\": false\n }\n },\n \"validation\": {\n \"app_name_valid\": true,\n \"url_valid\": true,\n \"port_in_range\": true\n }\n}" + } + ], + "isError": false } diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json index 25623f28..d79c5bf6 100644 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json @@ -1,9 +1,9 @@ { - "content": [ - { - "type": "text", - "text": "{\n \"original_count\": 4,\n \"processed_count\": 4,\n \"action\": \"sort\",\n \"original\": [\n \"apple\",\n \"banana\",\n \"cherry\",\n \"date\"\n ],\n \"processed\": [\n \"apple\",\n \"banana\",\n \"cherry\",\n \"date\"\n ],\n \"stats\": {\n \"average_length\": 5.25,\n \"shortest\": 4,\n \"longest\": 6\n }\n}" - } - ], - "isError": false + "content": [ + { + "type": "text", + "text": "{\n \"original_count\": 4,\n \"processed_count\": 4,\n \"action\": \"sort\",\n \"original\": [\n \"apple\",\n \"banana\",\n \"cherry\",\n \"date\"\n ],\n \"processed\": [\n \"apple\",\n \"banana\",\n \"cherry\",\n \"date\"\n ],\n \"stats\": {\n \"average_length\": 5.25,\n \"shortest\": 4,\n \"longest\": 6\n }\n}" + } + ], + "isError": false } diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json index 924527dc..8bc1623e 100644 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json @@ -1,9 +1,9 @@ { - "content": [ - { - "type": "text", - "text": "{\n \"success\": true,\n \"event\": {\n \"id\": \"event_test123456789\",\n \"title\": \"Team Meeting\",\n \"start_time\": \"2025-01-01T00:00:00+00:00\",\n \"end_time\": \"2025-01-01T00:00:00+00:00\",\n \"duration_hours\": 1.5,\n \"priority\": \"high\",\n \"attendees\": [\n \"alice@example.com\",\n \"bob@example.com\"\n ],\n \"created_at\": \"2025-01-01T00:00:00+00:00\"\n },\n \"info\": {\n \"attendee_count\": 2,\n \"is_all_day\": false,\n \"is_future\": false,\n \"timezone_note\": \"Times are in UTC\"\n }\n}" - } - ], - "isError": false + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"event\": {\n \"id\": \"event_test123456789\",\n \"title\": \"Team Meeting\",\n \"start_time\": \"2025-01-01T00:00:00+00:00\",\n \"end_time\": \"2025-01-01T00:00:00+00:00\",\n \"duration_hours\": 1.5,\n \"priority\": \"high\",\n \"attendees\": [\n \"alice@example.com\",\n \"bob@example.com\"\n ],\n \"created_at\": \"2025-01-01T00:00:00+00:00\"\n },\n \"info\": {\n \"attendee_count\": 2,\n \"is_all_day\": false,\n \"is_future\": false,\n \"timezone_note\": \"Times are in UTC\"\n }\n}" + } + ], + "isError": false } diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json index 9fe2fa53..10307165 100644 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json @@ -1,9 +1,9 @@ { - "content": [ - { - "type": "text", - "text": "{\n \"valid\": true,\n \"profile\": {\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\",\n \"age\": 30,\n \"role\": \"user\"\n },\n \"errors\": [],\n \"warnings\": [],\n \"processed_at\": \"2025-01-01 00:00:00\"\n}" - } - ], - "isError": false + "content": [ + { + "type": "text", + "text": "{\n \"valid\": true,\n \"profile\": {\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\",\n \"age\": 30,\n \"role\": \"user\"\n },\n \"errors\": [],\n \"warnings\": [],\n \"processed_at\": \"2025-01-01 00:00:00\"\n}" + } + ], + "isError": false } diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json index a73c8b94..bdfec0c0 100644 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json @@ -5,5 +5,8 @@ "text": "19.8" } ], - "isError": false + "isError": false, + "structuredContent": { + "result": 19.8 + } } diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_debug.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_debug.json index 3b11d407..b046832e 100644 --- a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_debug.json +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_debug.json @@ -5,5 +5,10 @@ "text": "{\n \"mode\": \"debug\",\n \"processed_input\": \"DEBUG TEST\",\n \"message\": \"Processed in DEBUG mode.\"\n}" } ], - "isError": false + "isError": false, + "structuredContent": { + "mode": "debug", + "processed_input": "DEBUG TEST", + "message": "Processed in DEBUG mode." + } } diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_default.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_default.json index fde189ee..af00a82b 100644 --- a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_default.json +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_default.json @@ -5,5 +5,10 @@ "text": "{\n \"mode\": \"default\",\n \"original_input\": \"test data\",\n \"message\": \"Processed in default mode (APP_MODE not recognized or not set).\"\n}" } ], - "isError": false + "isError": false, + "structuredContent": { + "mode": "default", + "original_input": "test data", + "message": "Processed in default mode (APP_MODE not recognized or not set)." + } } diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_production.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_production.json index dd4cd9dc..4f30f8a0 100644 --- a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_production.json +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_production.json @@ -5,5 +5,10 @@ "text": "{\n \"mode\": \"production\",\n \"processed_input_length\": 15,\n \"message\": \"Processed in PRODUCTION mode (summary only).\"\n}" } ], - "isError": false + "isError": false, + "structuredContent": { + "mode": "production", + "processed_input_length": 15, + "message": "Processed in PRODUCTION mode (summary only)." + } } diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json index 32141675..f5d19c41 100644 --- a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json @@ -14,6 +14,31 @@ "required": [ "input" ] + }, + "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" + ] } } ] diff --git a/tests/Unit/Capability/Attribute/McpToolTest.php b/tests/Unit/Capability/Attribute/McpToolTest.php index e2814af7..b6ab86d5 100644 --- a/tests/Unit/Capability/Attribute/McpToolTest.php +++ b/tests/Unit/Capability/Attribute/McpToolTest.php @@ -30,14 +30,15 @@ public function testInstantiatesWithCorrectProperties(): void $this->assertSame($description, $attribute->description); } - public function testInstantiatesWithNullValuesForNameAndDescription(): void + public function testInstantiatesWithNullValuesForNameDescriptionAndOutputSchema(): 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); } } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 5a7fcaeb..842f225a 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Capability\Discovery; +use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; use Mcp\Tests\Unit\Fixtures\Enum\BackedIntEnum; use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum; @@ -337,6 +338,12 @@ public function mixedTypes( /** * Complex nested Schema constraints. */ + #[McpTool( + outputSchema: [ + 'type' => 'object', + 'additionalProperties' => true, + ] + )] public function complexNestedSchema( #[Schema( type: 'object', @@ -399,6 +406,12 @@ public function typePrecedenceTest( * Method with no parameters but Schema description. */ #[Schema(description: 'Gets server status. Takes no arguments.', properties: [])] + #[McpTool( + outputSchema: [ + 'type' => 'object', + 'additionalProperties' => true, + ] + )] public function noParamsWithSchema(): array { return ['status' => 'OK']; @@ -412,4 +425,20 @@ public function parameterSchemaInferredType( $inferredParam, ): void { } + + // ===== OUTPUT SCHEMA FIXTURES ===== + #[McpTool( + outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'message' => ['type' => 'string'], + ], + 'required' => ['message'], + 'description' => 'The result of the operation', + ] + )] + public function returnWithExplicitOutputSchema(): array + { + return ['message' => 'result']; + } } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 4cbfce52..91f6de78 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -327,4 +327,45 @@ public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven() $this->assertEquals(['description' => 'Some parameter', 'minLength' => 3], $schema['properties']['inferredParam']); $this->assertEquals(['inferredParam'], $schema['required']); } + + public function testGenerateOutputSchemaReturnsNullForVoidReturnType(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParams'); + $schema = $this->schemaGenerator->generateOutputSchema($method); + $this->assertNull($schema); + } + + public function testGenerateOutputSchemaWithReturnDescription(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'returnWithExplicitOutputSchema'); + $schema = $this->schemaGenerator->generateOutputSchema($method); + $this->assertEquals([ + 'type' => 'object', + 'properties' => [ + 'message' => ['type' => 'string'], + ], + 'required' => ['message'], + 'description' => 'The result of the operation', + ], $schema); + } + + public function testGenerateOutputSchemaForArrayReturnType(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema'); + $schema = $this->schemaGenerator->generateOutputSchema($method); + $this->assertEquals([ + 'type' => 'object', + 'additionalProperties' => true, + ], $schema); + } + + public function testGenerateOutputSchemaForComplexNestedSchema(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema'); + $schema = $this->schemaGenerator->generateOutputSchema($method); + $this->assertEquals([ + 'type' => 'object', + 'additionalProperties' => true, + ], $schema); + } } diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index d97ccf41..3b0d35d9 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -527,7 +527,49 @@ public function testMultipleRegistrationsOfSameElementWithSameType(): void $this->assertEquals('second', ($toolRef->handler)()); } - private function createValidTool(string $name): Tool + public function testExtractStructuredContentReturnsNullWhenOutputSchemaIsNull(): void + { + $tool = $this->createValidTool('test_tool', null); + $this->registry->registerTool($tool, fn () => 'result'); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertNull($toolRef->extractStructuredContent('result')); + } + + public function testExtractStructuredContentReturnsArrayMatchingSchema(): void + { + $tool = $this->createValidTool('test_tool', [ + 'type' => 'object', + 'properties' => [ + 'param' => ['type' => 'string'], + ], + 'required' => ['param'], + ]); + $this->registry->registerTool($tool, fn () => [ + 'param' => 'test', + ]); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertEquals([ + 'param' => 'test', + ], $toolRef->extractStructuredContent([ + 'param' => 'test', + ])); + } + + public function testExtractStructuredContentReturnsArrayDirectlyForAdditionalProperties(): void + { + $tool = $this->createValidTool('test_tool', [ + 'type' => 'object', + 'additionalProperties' => true, + ]); + $this->registry->registerTool($tool, fn () => ['success' => true, 'message' => 'done']); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertEquals(['success' => true, 'message' => 'done'], $toolRef->extractStructuredContent(['success' => true, 'message' => 'done'])); + } + + private function createValidTool(string $name, ?array $outputSchema = null): Tool { return new Tool( name: $name, @@ -540,6 +582,9 @@ private function createValidTool(string $name): Tool ], description: "Test tool: {$name}", annotations: null, + icons: null, + meta: null, + outputSchema: $outputSchema ); } diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 5b03f2bb..d48f1687 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -21,6 +21,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Tool; use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; @@ -59,7 +60,9 @@ public function testSupportsCallToolRequest(): void public function testHandleSuccessfulToolCall(): void { $request = $this->createCallToolRequest('greet_user', ['name' => 'John']); - $toolReference = $this->createMock(ToolReference::class); + $toolReference = $this->createToolReference('greet_user', function () { + return 'Hello, John!'; + }); $expectedResult = new CallToolResult([new TextContent('Hello, John!')]); $this->registry @@ -92,7 +95,9 @@ public function testHandleSuccessfulToolCall(): void public function testHandleToolCallWithEmptyArguments(): void { $request = $this->createCallToolRequest('simple_tool', []); - $toolReference = $this->createMock(ToolReference::class); + $toolReference = $this->createToolReference('greet_user', function () { + return 'Hello, John!'; + }); $expectedResult = new CallToolResult([new TextContent('Simple result')]); $this->registry @@ -129,7 +134,9 @@ public function testHandleToolCallWithComplexArguments(): void 'null_param' => null, ]; $request = $this->createCallToolRequest('complex_tool', $arguments); - $toolReference = $this->createMock(ToolReference::class); + $toolReference = $this->createToolReference('greet_user', function () { + return 'Hello, John!'; + }); $expectedResult = new CallToolResult([new TextContent('Complex result')]); $this->registry @@ -182,7 +189,9 @@ public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): voi $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); $exception = new ToolCallException('Tool execution failed'); - $toolReference = $this->createMock(ToolReference::class); + $toolReference = $this->createToolReference('greet_user', function () { + return 'Hello, John!'; + }); $this->registry ->expects($this->once()) ->method('getTool') @@ -217,7 +226,9 @@ public function testHandleWithNullResult(): void $request = $this->createCallToolRequest('null_tool', []); $expectedResult = new CallToolResult([]); - $toolReference = $this->createMock(ToolReference::class); + $toolReference = $this->createToolReference('greet_user', function () { + return 'Hello, John!'; + }); $this->registry ->expects($this->once()) ->method('getTool') @@ -254,7 +265,9 @@ public function testHandleLogsErrorWithCorrectParameters(): void $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); $exception = new ToolCallException('Custom error message'); - $toolReference = $this->createMock(ToolReference::class); + $toolReference = $this->createToolReference('greet_user', function () { + return 'Hello, John!'; + }); $this->registry ->expects($this->once()) ->method('getTool') @@ -297,7 +310,9 @@ public function testHandleGenericExceptionReturnsError(): void $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); $exception = new \RuntimeException('Internal database connection failed'); - $toolReference = $this->createMock(ToolReference::class); + $toolReference = $this->createToolReference('greet_user', function () { + return 'Hello, John!'; + }); $this->registry ->expects($this->once()) ->method('getTool') @@ -324,7 +339,10 @@ public function testHandleWithSpecialCharactersInToolName(): void $request = $this->createCallToolRequest('tool-with_special.chars', []); $expectedResult = new CallToolResult([new TextContent('Special tool result')]); - $toolReference = $this->createMock(ToolReference::class); + $toolReference = $this->createToolReference('greet_user', function () { + return 'Hello, John!'; + }); + $this->registry ->expects($this->once()) ->method('getTool') @@ -359,7 +377,9 @@ public function testHandleWithSpecialCharactersInArguments(): void $request = $this->createCallToolRequest('unicode_tool', $arguments); $expectedResult = new CallToolResult([new TextContent('Unicode handled')]); - $toolReference = $this->createMock(ToolReference::class); + $toolReference = $this->createToolReference('greet_user', function () { + return 'Hello, John!'; + }); $this->registry ->expects($this->once()) ->method('getTool') @@ -387,7 +407,9 @@ public function testHandleWithSpecialCharactersInArguments(): void public function testHandleReturnsStructuredContentResult(): void { $request = $this->createCallToolRequest('structured_tool', ['query' => 'php']); - $toolReference = $this->createMock(ToolReference::class); + $toolReference = $this->createToolReference('greet_user', function () { + return 'Hello, John!'; + }); $structuredResult = new CallToolResult([new TextContent('Rendered results')], false, ['result' => 'Rendered results']); $this->registry @@ -416,7 +438,9 @@ public function testHandleReturnsStructuredContentResult(): void public function testHandleReturnsCallToolResult(): void { $request = $this->createCallToolRequest('result_tool', ['query' => 'php']); - $toolReference = $this->createMock(ToolReference::class); + $toolReference = $this->createToolReference('greet_user', function () { + return 'Hello, John!'; + }); $callToolResult = new CallToolResult([new TextContent('Error result')], true); $this->registry @@ -457,4 +481,22 @@ private function createCallToolRequest(string $name, array $arguments): CallTool ], ]); } + + private function createToolReference( + string $name, + callable $handler, + ?array $outputSchema = null, + array $methodsToMock = ['formatResult'], + ): ToolReference&MockObject { + $tool = new Tool($name, ['type' => 'object', 'properties' => [], 'required' => null], null, null, null, null, $outputSchema); + + $builder = $this->getMockBuilder(ToolReference::class) + ->setConstructorArgs([$tool, $handler]); + + if (!empty($methodsToMock)) { + $builder->onlyMethods($methodsToMock); + } + + return $builder->getMock(); + } }