diff --git a/examples/server/conformance/Elements.php b/examples/server/conformance/Elements.php index 81d25b32..b7f74ff2 100644 --- a/examples/server/conformance/Elements.php +++ b/examples/server/conformance/Elements.php @@ -11,13 +11,13 @@ namespace Mcp\Example\Server\Conformance; -use Mcp\Schema\Content\Content; use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\PromptMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\Enum\Role; +use Mcp\Schema\Result\CallToolResult; use Mcp\Server\Protocol; use Mcp\Server\RequestContext; @@ -28,12 +28,9 @@ final class Elements // Sample base64 encoded minimal WAV file for testing public const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; - /** - * @return Content[] - */ - public function toolMultipleTypes(): array + public function toolMultipleTypes(): CallToolResult { - return [ + return new CallToolResult([ new TextContent('Multiple content types test:'), new ImageContent(self::TEST_IMAGE_BASE64, 'image/png'), EmbeddedResource::fromText( @@ -41,7 +38,7 @@ public function toolMultipleTypes(): array '{ "test" = "data", "value" = 123 }', 'application/json', ), - ]; + ]); } public function toolWithLogging(RequestContext $context): string diff --git a/examples/server/env-variables/EnvToolHandler.php b/examples/server/env-variables/EnvToolHandler.php index 7c6cc8df..c11c6633 100644 --- a/examples/server/env-variables/EnvToolHandler.php +++ b/examples/server/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/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 95a3fe5a..74031e56 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 0676e8d9..f711a0d0 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\Exception\InvalidArgumentException; use Mcp\Server\RequestContext; @@ -81,6 +82,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/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index fef17263..f23e5270 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -47,7 +47,8 @@ final class ArrayLoader implements LoaderInterface * description: ?string, * annotations: ?ToolAnnotations, * icons: ?Icon[], - * meta: ?array + * meta: ?array, + * outputSchema: ?array * }[] $tools * @param array{ * handler: Handler, @@ -117,6 +118,7 @@ public function load(RegistryInterface $registry): void annotations: $data['annotations'] ?? null, icons: $data['icons'] ?? null, meta: $data['meta'] ?? null, + outputSchema: $data['outputSchema'] ?? null, ); $registry->registerTool($tool, $data['handler'], true); diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index e5b5a7df..bc750b1d 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -111,4 +111,33 @@ 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 + * + * @throws \JsonException if JSON encoding fails for non-Content array/object results + */ + public function extractStructuredContent(mixed $toolExecutionResult): ?array + { + if (\is_array($toolExecutionResult)) { + return $toolExecutionResult; + } + + if (\is_object($toolExecutionResult) && !($toolExecutionResult instanceof Content)) { + $jsonResult = json_encode( + $toolExecutionResult, + \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE + ); + + return json_decode( + $jsonResult, true, 512, \JSON_THROW_ON_ERROR + ); + } + + return null; + } } diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index 4f31e034..bfbc9fab 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -83,6 +83,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 363a09c5..1079c8a4 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, + * outputSchema: ?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 13a4b536..c19b5b8c 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -65,13 +65,16 @@ public function handle(Request $request, SessionInterface $session): Response|Er $result = $this->referenceHandler->handle($reference, $arguments); + $structuredContent = null; if (!$result instanceof CallToolResult) { - $result = new CallToolResult($reference->formatResult($result)); + $structuredContent = $reference->extractStructuredContent($result); + $result = new CallToolResult($reference->formatResult($result), 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/HttpComplexToolSchemaTest-tools_call-schedule_event_all_day_reminder.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_all_day_reminder.json index 1e7667fa..5f6e553f 100644 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_all_day_reminder.json +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_all_day_reminder.json @@ -5,5 +5,18 @@ "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Project Deadline\\\" scheduled successfully for \\\"2024-12-15\\\".\",\n \"event_details\": {\n \"title\": \"Project Deadline\",\n \"date\": \"2024-12-15\",\n \"type\": \"reminder\",\n \"time\": \"All day\",\n \"priority\": \"Normal\",\n \"attendees\": [],\n \"invites_will_be_sent\": false\n }\n}" } ], + "structuredContent": { + "success": true, + "message": "Event \"Project Deadline\" scheduled successfully for \"2024-12-15\".", + "event_details": { + "title": "Project Deadline", + "date": "2024-12-15", + "type": "reminder", + "time": "All day", + "priority": "Normal", + "attendees": [], + "invites_will_be_sent": false + } + }, "isError": false } diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_high_priority.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_high_priority.json index 5309d2e9..8a702475 100644 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_high_priority.json +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_high_priority.json @@ -5,5 +5,20 @@ "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Client Call\\\" scheduled successfully for \\\"2024-12-02\\\".\",\n \"event_details\": {\n \"title\": \"Client Call\",\n \"date\": \"2024-12-02\",\n \"type\": \"call\",\n \"time\": \"14:30\",\n \"priority\": \"Normal\",\n \"attendees\": [\n \"client@example.com\"\n ],\n \"invites_will_be_sent\": false\n }\n}" } ], + "structuredContent": { + "success": true, + "message": "Event \"Client Call\" scheduled successfully for \"2024-12-02\".", + "event_details": { + "title": "Client Call", + "date": "2024-12-02", + "type": "call", + "time": "14:30", + "priority": "Normal", + "attendees": [ + "client@example.com" + ], + "invites_will_be_sent": false + } + }, "isError": false } diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_low_priority.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_low_priority.json index a9f4d35f..97c2340e 100644 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_low_priority.json +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_low_priority.json @@ -5,5 +5,20 @@ "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Office Party\\\" scheduled successfully for \\\"2024-12-20\\\".\",\n \"event_details\": {\n \"title\": \"Office Party\",\n \"date\": \"2024-12-20\",\n \"type\": \"other\",\n \"time\": \"18:00\",\n \"priority\": \"Normal\",\n \"attendees\": [\n \"team@company.com\"\n ],\n \"invites_will_be_sent\": true\n }\n}" } ], + "structuredContent": { + "success": true, + "message": "Event \"Office Party\" scheduled successfully for \"2024-12-20\".", + "event_details": { + "title": "Office Party", + "date": "2024-12-20", + "type": "other", + "time": "18:00", + "priority": "Normal", + "attendees": [ + "team@company.com" + ], + "invites_will_be_sent": true + } + }, "isError": false } diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_meeting_with_time.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_meeting_with_time.json index 68c6f014..cf9d7b1d 100644 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_meeting_with_time.json +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_meeting_with_time.json @@ -5,5 +5,21 @@ "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Team Standup\\\" scheduled successfully for \\\"2024-12-01\\\".\",\n \"event_details\": {\n \"title\": \"Team Standup\",\n \"date\": \"2024-12-01\",\n \"type\": \"meeting\",\n \"time\": \"09:00\",\n \"priority\": \"Normal\",\n \"attendees\": [\n \"alice@example.com\",\n \"bob@example.com\"\n ],\n \"invites_will_be_sent\": true\n }\n}" } ], + "structuredContent": { + "success": true, + "message": "Event \"Team Standup\" scheduled successfully for \"2024-12-01\".", + "event_details": { + "title": "Team Standup", + "date": "2024-12-01", + "type": "meeting", + "time": "09:00", + "priority": "Normal", + "attendees": [ + "alice@example.com", + "bob@example.com" + ], + "invites_will_be_sent": true + } + }, "isError": false } diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-send_welcome.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-send_welcome.json index 95ed1898..29908263 100644 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-send_welcome.json +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-send_welcome.json @@ -5,5 +5,9 @@ "text": "{\n \"success\": true,\n \"message_sent\": \"Welcome, Alice! Welcome to our platform!\"\n}" } ], + "structuredContent": { + "success": true, + "message_sent": "Welcome, Alice! Welcome to our platform!" + }, "isError": false } diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-test_tool_without_params.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-test_tool_without_params.json index cac9850a..d680ebab 100644 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-test_tool_without_params.json +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-test_tool_without_params.json @@ -5,5 +5,9 @@ "text": "{\n \"success\": true,\n \"message\": \"Test tool without params\"\n}" } ], + "structuredContent": { + "success": true, + "message": "Test tool without params" + }, "isError": false } 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..d21488a0 100644 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json @@ -5,5 +5,11 @@ "text": "{\n \"result\": 50,\n \"operation\": \"10 multiply 5\",\n \"precision\": 2,\n \"within_bounds\": true\n}" } ], + "structuredContent": { + "result": 50, + "operation": "10 multiply 5", + "precision": 2, + "within_bounds": true + }, "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..c14b72a5 100644 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json @@ -5,5 +5,11 @@ "text": "{\n \"original\": \"Hello World Test\",\n \"formatted\": \"HELLO WORLD TEST\",\n \"length\": 16,\n \"format_applied\": \"uppercase\"\n}" } ], + "structuredContent": { + "original": "Hello World Test", + "formatted": "HELLO WORLD TEST", + "length": 16, + "format_applied": "uppercase" + }, "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..37b4540a 100644 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json @@ -5,5 +5,30 @@ "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}" } ], + "structuredContent": { + "success": true, + "config": { + "app": { + "name": "TestApp", + "env": "development", + "debug": true, + "url": "https://example.com", + "port": 8080 + }, + "generated_at": "2025-01-01T00:00:00+00:00", + "version": "1.0.0", + "features": { + "logging": true, + "caching": false, + "analytics": false, + "rate_limiting": false + } + }, + "validation": { + "app_name_valid": true, + "url_valid": true, + "port_in_range": true + } + }, "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..18c414ec 100644 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json @@ -5,5 +5,27 @@ "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}" } ], + "structuredContent": { + "original_count": 4, + "processed_count": 4, + "action": "sort", + "original": [ + "apple", + "banana", + "cherry", + "date" + ], + "processed": [ + "apple", + "banana", + "cherry", + "date" + ], + "stats": { + "average_length": 5.25, + "shortest": 4, + "longest": 6 + } + }, "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..f597105d 100644 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json @@ -5,5 +5,27 @@ "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}" } ], + "structuredContent": { + "success": true, + "event": { + "id": "event_test123456789", + "title": "Team Meeting", + "start_time": "2025-01-01T00:00:00+00:00", + "end_time": "2025-01-01T00:00:00+00:00", + "duration_hours": 1.5, + "priority": "high", + "attendees": [ + "alice@example.com", + "bob@example.com" + ], + "created_at": "2025-01-01T00:00:00+00:00" + }, + "info": { + "attendee_count": 2, + "is_all_day": false, + "is_future": false, + "timezone_note": "Times are in UTC" + } + }, "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..e87f6cbe 100644 --- a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json @@ -5,5 +5,17 @@ "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}" } ], + "structuredContent": { + "valid": true, + "profile": { + "name": "John Doe", + "email": "john@example.com", + "age": 30, + "role": "user" + }, + "errors": [], + "warnings": [], + "processed_at": "2025-01-01 00:00:00" + }, "isError": false } diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-add_task.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-add_task.json index 9ded3d2a..193d8b8e 100644 --- a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-add_task.json +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-add_task.json @@ -5,5 +5,12 @@ "text": "{\n \"id\": 4,\n \"userId\": \"alice\",\n \"description\": \"Complete the project documentation\",\n \"completed\": false,\n \"createdAt\": \"2025-01-01T00:00:00+00:00\"\n}" } ], + "structuredContent": { + "id": 4, + "userId": "alice", + "description": "Complete the project documentation", + "completed": false, + "createdAt": "2025-01-01T00:00:00+00:00" + }, "isError": false -} +} \ No newline at end of file diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-complete_task.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-complete_task.json index 3d852eda..3e17038b 100644 --- a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-complete_task.json +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-complete_task.json @@ -5,5 +5,9 @@ "text": "{\n \"success\": true,\n \"message\": \"Task 1 completed.\"\n}" } ], + "structuredContent": { + "success": true, + "message": "Task 1 completed." + }, "isError": false } diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-update_setting.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-update_setting.json index 37b42155..51786523 100644 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-update_setting.json +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-update_setting.json @@ -5,5 +5,9 @@ "text": "{\n \"success\": true,\n \"message\": \"Precision updated to 3.\"\n}" } ], + "structuredContent": { + "success": true, + "message": "Precision updated to 3." + }, "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/DocBlockParserTest.php b/tests/Unit/Capability/Discovery/DocBlockParserTest.php index b8501b28..df914755 100644 --- a/tests/Unit/Capability/Discovery/DocBlockParserTest.php +++ b/tests/Unit/Capability/Discovery/DocBlockParserTest.php @@ -16,7 +16,6 @@ use phpDocumentor\Reflection\DocBlock\Tags\Deprecated; use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\DocBlock\Tags\See; -use phpDocumentor\Reflection\DocBlock\Tags\Throws; use PHPUnit\Framework\TestCase; class DocBlockParserTest extends TestCase @@ -109,12 +108,6 @@ public function testGetTagsByNameReturnsSpecificTags() $this->assertInstanceOf(DocBlock::class, $docBlock); - $throwsTags = $docBlock->getTagsByName('throws'); - $this->assertCount(1, $throwsTags); - $this->assertInstanceOf(Throws::class, $throwsTags[0]); - $this->assertEquals('\\RuntimeException', (string) $throwsTags[0]->getType()); - $this->assertEquals('if processing fails', $throwsTags[0]->getDescription()->render()); - $deprecatedTags = $docBlock->getTagsByName('deprecated'); $this->assertCount(1, $deprecatedTags); $this->assertInstanceOf(Deprecated::class, $deprecatedTags[0]); diff --git a/tests/Unit/Capability/Discovery/DocBlockTestFixture.php b/tests/Unit/Capability/Discovery/DocBlockTestFixture.php index a218ad63..7a753ddf 100644 --- a/tests/Unit/Capability/Discovery/DocBlockTestFixture.php +++ b/tests/Unit/Capability/Discovery/DocBlockTestFixture.php @@ -70,8 +70,6 @@ public function methodWithReturn(): string * * @return bool status of the operation * - * @throws \RuntimeException if processing fails - * * @deprecated use newMethod() instead * @see DocBlockTestFixture::newMethod() */ diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 786342e8..0d40026c 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']; @@ -424,4 +437,20 @@ public function withParameterNamedSessionWithWeirdCase(string $_sesSion): void public function withParameterNamedRequest(string $_request): 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 e9e37f6f..5089e7d7 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -346,4 +346,45 @@ public function testGenerateWithForbiddenParameterNames(string $methodName) $this->expectException(InvalidArgumentException::class); $this->schemaGenerator->generate($method); } + + 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..c5c3230c 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -527,7 +527,91 @@ 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'])); + } + + public function testExtractStructuredContentReturnsArrayDirectlyForArrayOutputSchema(): void + { + // Arrange + $outputSchema = [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'foo' => [ + 'type' => 'string', + 'description' => 'A static value', + ], + ], + 'required' => ['foo'], + ], + ]; + + $tool = $this->createValidTool('list_static_data', $outputSchema); + $toolReturnValue = [ + ['foo' => 'bar'], + ['foo' => 'bar'], + ['foo' => 'bar'], + ['foo' => 'bar'], + ]; + + $this->registry->registerTool($tool, fn () => $toolReturnValue); + + // Act + $toolRef = $this->registry->getTool('list_static_data'); + $structuredContent = $toolRef->extractStructuredContent($toolReturnValue); + + // Assert + $this->assertNotNull($structuredContent); + $this->assertCount(4, $structuredContent); + $this->assertEquals([ + ['foo' => 'bar'], + ['foo' => 'bar'], + ['foo' => 'bar'], + ['foo' => 'bar'], + ], $structuredContent); + } + + private function createValidTool(string $name, ?array $outputSchema = null): Tool { return new Tool( name: $name, @@ -540,6 +624,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 eb2f2ab6..90af0c84 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(); + } }