diff --git a/src/Response.php b/src/Response.php index 35cbae0..e5e2878 100644 --- a/src/Response.php +++ b/src/Response.php @@ -6,6 +6,7 @@ use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; +use InvalidArgumentException; use JsonException; use Laravel\Mcp\Enums\Role; use Laravel\Mcp\Exceptions\NotImplementedException; @@ -58,6 +59,28 @@ public static function blob(string $content): static return new static(new Blob($content)); } + /** + * @param array $response + * + * @throws JsonException + */ + public static function structured(array $response): ResponseFactory + { + if ($response === []) { + throw new InvalidArgumentException('Structured content cannot be empty.'); + } + + try { + $json = json_encode($response, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + } catch (JsonException $jsonException) { + throw new InvalidArgumentException("Invalid structured content: {$jsonException->getMessage()}", 0, $jsonException); + } + + $content = Response::text($json); + + return (new ResponseFactory($content))->withStructuredContent($response); + } + public static function error(string $text): static { return new static(new Text($text), isError: true); diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php index 12b89b2..7cc2517 100644 --- a/src/ResponseFactory.php +++ b/src/ResponseFactory.php @@ -10,11 +10,13 @@ use Illuminate\Support\Traits\Macroable; use InvalidArgumentException; use Laravel\Mcp\Server\Concerns\HasMeta; +use Laravel\Mcp\Server\Concerns\HasStructuredContent; class ResponseFactory { use Conditionable; use HasMeta; + use HasStructuredContent; use Macroable; /** @@ -50,6 +52,16 @@ public function withMeta(string|array $meta, mixed $value = null): static return $this; } + /** + * @param array $structuredContent + */ + public function withStructuredContent(array $structuredContent): static + { + $this->setStructuredContent($structuredContent); + + return $this; + } + /** * @return Collection */ @@ -65,4 +77,12 @@ public function getMeta(): ?array { return $this->meta; } + + /** + * @return array|null + */ + public function getStructuredContent(): ?array + { + return $this->structuredContent; + } } diff --git a/src/Server/Concerns/HasStructuredContent.php b/src/Server/Concerns/HasStructuredContent.php new file mode 100644 index 0000000..54e5cd0 --- /dev/null +++ b/src/Server/Concerns/HasStructuredContent.php @@ -0,0 +1,36 @@ +|null + */ + protected ?array $structuredContent = null; + + /** + * @param array $structuredContent + */ + public function setStructuredContent(array $structuredContent): void + { + $this->structuredContent ??= []; + + $this->structuredContent = array_merge($this->structuredContent, $structuredContent); + } + + /** + * @param array $baseArray + * @return array + */ + public function mergeStructuredContent(array $baseArray): array + { + if ($this->structuredContent === null) { + return $baseArray; + } + + return array_merge($baseArray, ['structuredContent' => $this->structuredContent]); + } +} diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index ff88581..a4ba16e 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -65,9 +65,11 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat */ protected function serializable(Tool $tool): callable { - return fn (ResponseFactory $factory): array => $factory->mergeMeta([ - 'content' => $factory->responses()->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), - 'isError' => $factory->responses()->contains(fn (Response $response): bool => $response->isError()), - ]); + return fn (ResponseFactory $factory): array => $factory->mergeStructuredContent( + $factory->mergeMeta([ + 'content' => $factory->responses()->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), + 'isError' => $factory->responses()->contains(fn (Response $response): bool => $response->isError()), + ]) + ); } } diff --git a/src/Server/Tool.php b/src/Server/Tool.php index 0019841..f79cf2f 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -19,6 +19,16 @@ public function schema(JsonSchema $schema): array return []; } + /** + * Define the output schema for this tool's results. + * + * @return array + */ + public function outputSchema(JsonSchema $schema): array + { + return []; + } + /** * @return array */ @@ -51,6 +61,7 @@ public function toMethodCall(): array * title?: string|null, * description?: string|null, * inputSchema?: array, + * outputSchema?: array, * annotations?: array|object, * _meta?: array * } @@ -63,16 +74,25 @@ public function toArray(): array $this->schema(...), )->toArray(); + $outputSchema = JsonSchema::object( + $this->outputSchema(...), + )->toArray(); + $schema['properties'] ??= (object) []; - // @phpstan-ignore return.type - return $this->mergeMeta([ + $result = [ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), 'inputSchema' => $schema, 'annotations' => $annotations === [] ? (object) [] : $annotations, - ]); + ]; + + if ($outputSchema !== [] && $outputSchema !== ['type' => 'object']) { + $result['outputSchema'] = $outputSchema; + } + // @phpstan-ignore return.type + return $this->mergeMeta($result); } } diff --git a/tests/Fixtures/ResponseFactoryWithStructuredContentTool.php b/tests/Fixtures/ResponseFactoryWithStructuredContentTool.php new file mode 100644 index 0000000..a3ec1bc --- /dev/null +++ b/tests/Fixtures/ResponseFactoryWithStructuredContentTool.php @@ -0,0 +1,31 @@ +withStructuredContent([ + 'status' => 'success', + 'code' => 200, + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/tests/Fixtures/StructuredContentTool.php b/tests/Fixtures/StructuredContentTool.php new file mode 100644 index 0000000..30fa63c --- /dev/null +++ b/tests/Fixtures/StructuredContentTool.php @@ -0,0 +1,30 @@ + 22.5, + 'conditions' => 'Partly cloudy', + 'humidity' => 65, + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/tests/Fixtures/StructuredContentWithMetaTool.php b/tests/Fixtures/StructuredContentWithMetaTool.php new file mode 100644 index 0000000..278ae85 --- /dev/null +++ b/tests/Fixtures/StructuredContentWithMetaTool.php @@ -0,0 +1,28 @@ + 'The operation completed successfully', + ])->withMeta(['requestId' => 'abc123']); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/tests/Fixtures/ToolWithOutputSchema.php b/tests/Fixtures/ToolWithOutputSchema.php new file mode 100644 index 0000000..c4550d6 --- /dev/null +++ b/tests/Fixtures/ToolWithOutputSchema.php @@ -0,0 +1,39 @@ + 123, + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->description('User ID')->required(), + 'name' => $schema->string()->description('User name')->required(), + 'email' => $schema->string()->description('User email')->required(), + ]; + } +} diff --git a/tests/Fixtures/ToolWithoutOutputSchema.php b/tests/Fixtures/ToolWithoutOutputSchema.php new file mode 100644 index 0000000..5a2b250 --- /dev/null +++ b/tests/Fixtures/ToolWithoutOutputSchema.php @@ -0,0 +1,25 @@ +validate([ + 'location' => 'required|string', + ]); + + return Response::structured([ + 'temperature' => 22.5, + 'conditions' => 'Partly cloudy', + 'humidity' => 65, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'location' => $schema->string()->description('City name or zip code')->required(), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'temperature' => $schema->number()->description('Temperature in celsius')->required(), + 'conditions' => $schema->string()->description('Weather conditions description')->required(), + 'humidity' => $schema->number()->description('Humidity percentage')->required(), + ]; + } +} diff --git a/tests/Unit/Methods/CallToolTest.php b/tests/Unit/Methods/CallToolTest.php index 0536e3d..eebc2f2 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -5,11 +5,15 @@ use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; use Tests\Fixtures\CurrentTimeTool; +use Tests\Fixtures\ResponseFactoryWithStructuredContentTool; use Tests\Fixtures\SayHiTool; use Tests\Fixtures\SayHiTwiceTool; use Tests\Fixtures\SayHiWithMetaTool; +use Tests\Fixtures\StructuredContentTool; +use Tests\Fixtures\StructuredContentWithMetaTool; use Tests\Fixtures\ToolWithBothMetaTool; use Tests\Fixtures\ToolWithResultMetaTool; +use Tests\Fixtures\WeatherTool; it('returns a valid call tool response', function (): void { $request = JsonRpcRequest::from([ @@ -344,3 +348,297 @@ ], ]); }); + +it('returns structured content in tool response', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'structured-content-tool', + 'arguments' => [], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [StructuredContentTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + $this->instance('mcp.request', $request->toRequest()); + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + + $payload = $response->toArray(); + + expect($payload['id'])->toEqual(1) + ->and($payload['result'])->toHaveKey('structuredContent') + ->and($payload['result']['structuredContent'])->toEqual([ + 'temperature' => 22.5, + 'conditions' => 'Partly cloudy', + 'humidity' => 65, + ]) + ->and($payload['result']['content'])->toHaveCount(1) + ->and($payload['result']['content'][0]['type'])->toBe('text') + ->and($payload['result']['content'][0]['text'])->toContain('"temperature": 22.5') + ->and($payload['result']['isError'])->toBeFalse(); +}); + +it('returns structured content with meta in tool response', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'structured-content-with-meta-tool', + 'arguments' => [], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [StructuredContentWithMetaTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + $this->instance('mcp.request', $request->toRequest()); + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + + $payload = $response->toArray(); + + expect($payload['id'])->toEqual(1) + ->and($payload['result'])->toHaveKey('structuredContent') + ->and($payload['result']['structuredContent'])->toEqual([ + 'result' => 'The operation completed successfully', + ]) + ->and($payload['result'])->toHaveKey('_meta') + ->and($payload['result']['_meta'])->toEqual(['requestId' => 'abc123']) + ->and($payload['result']['content'])->toHaveCount(1) + ->and($payload['result']['isError'])->toBeFalse(); +}); + +it('returns ResponseFactory with structured content added via withStructuredContent', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'response-factory-with-structured-content-tool', + 'arguments' => [], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [ResponseFactoryWithStructuredContentTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + $this->instance('mcp.request', $request->toRequest()); + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + + $payload = $response->toArray(); + + expect($payload['id'])->toEqual(1) + ->and($payload['result'])->toHaveKey('structuredContent') + ->and($payload['result']['structuredContent'])->toEqual([ + 'status' => 'success', + 'code' => 200, + ]) + ->and($payload['result']['content'])->toHaveCount(1) + ->and($payload['result']['content'][0]['type'])->toBe('text') + ->and($payload['result']['content'][0]['text'])->toBe('Processing complete with status: success') + ->and($payload['result']['isError'])->toBeFalse(); +}); + +it('tool with outputSchema returns matching structuredContent', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'weather-tool', + 'arguments' => [ + 'location' => 'San Francisco', + ], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [WeatherTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + $this->instance('mcp.request', $request->toRequest()); + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + + $payload = $response->toArray(); + + expect($payload['id'])->toEqual(1) + ->and($payload['result'])->toHaveKey('structuredContent') + ->and($payload['result']['structuredContent'])->toHaveKey('temperature') + ->and($payload['result']['structuredContent'])->toHaveKey('conditions') + ->and($payload['result']['structuredContent'])->toHaveKey('humidity') + ->and($payload['result']['structuredContent']['temperature'])->toBe(22.5) + ->and($payload['result']['structuredContent']['conditions'])->toBe('Partly cloudy') + ->and($payload['result']['structuredContent']['humidity'])->toBe(65) + ->and($payload['result']['isError'])->toBeFalse(); +}); + +it('validates weather tool response matches outputSchema from spec', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 5, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'weather-tool', + 'arguments' => [ + 'location' => 'Los Angeles', + ], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [WeatherTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + $this->instance('mcp.request', $request->toRequest()); + $response = $method->handle($request, $context); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class); + + $payload = $response->toArray(); + + expect($payload['id'])->toEqual(5) + ->and($payload['result']['content'])->toHaveCount(1) + ->and($payload['result']['content'][0]['type'])->toBe('text') + ->and($payload['result']['content'][0]['text'])->toContain('"temperature": 22.5') + ->and($payload['result']['content'][0]['text'])->toContain('"conditions": "Partly cloudy"') + ->and($payload['result']['content'][0]['text'])->toContain('"humidity": 65') + ->and($payload['result']['structuredContent'])->toEqual([ + 'temperature' => 22.5, + 'conditions' => 'Partly cloudy', + 'humidity' => 65, + ]); +}); + +it('throws an exception when the name parameter is missing', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'arguments' => ['name' => 'John Doe'], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [SayHiTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + expect(fn (): Generator|JsonRpcResponse => $method->handle($request, $context)) + ->toThrow( + Laravel\Mcp\Server\Exceptions\JsonRpcException::class, + 'Missing [name] parameter.' + ); +}); + +it('throws exception when tool is not found', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'non-existent-tool', + 'arguments' => ['name' => 'John Doe'], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [SayHiTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + expect(fn (): Generator|JsonRpcResponse => $method->handle($request, $context)) + ->toThrow( + Laravel\Mcp\Server\Exceptions\JsonRpcException::class, + 'Tool [non-existent-tool] not found.' + ); +}); diff --git a/tests/Unit/Methods/ListToolsTest.php b/tests/Unit/Methods/ListToolsTest.php index 9c55e7f..41d9ac6 100644 --- a/tests/Unit/Methods/ListToolsTest.php +++ b/tests/Unit/Methods/ListToolsTest.php @@ -7,6 +7,9 @@ use Laravel\Mcp\Server\Transport\JsonRpcResponse; use Tests\Fixtures\SayHiTool; use Tests\Fixtures\SayHiWithMetaTool; +use Tests\Fixtures\ToolWithoutOutputSchema; +use Tests\Fixtures\ToolWithOutputSchema; +use Tests\Fixtures\WeatherTool; if (! class_exists('Tests\\Unit\\Methods\\DummyTool1')) { for ($i = 1; $i <= 12; $i++) { @@ -405,3 +408,180 @@ public function shouldRegister(Request $request): bool ], ]); }); + +it('includes outputSchema when tool defines it', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'list-tools', + 'params' => [], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 5, + tools: [WeatherTool::class], + resources: [], + prompts: [], + ); + + $listTools = new ListTools; + + $response = $listTools->handle($request, $context); + + $payload = $response->toArray(); + $tool = $payload['result']['tools'][0]; + + expect($response)->toBeInstanceOf(JsonRpcResponse::class) + ->and($payload)->toMatchArray(['id' => 1]) + ->and($payload['result']['tools'])->toHaveCount(1) + ->and($tool)->toHaveKey('outputSchema') + ->and($tool['outputSchema'])->toMatchArray([ + 'type' => 'object', + 'properties' => [ + 'temperature' => [ + 'type' => 'number', + 'description' => 'Temperature in celsius', + ], + 'conditions' => [ + 'type' => 'string', + 'description' => 'Weather conditions description', + ], + 'humidity' => [ + 'type' => 'number', + 'description' => 'Humidity percentage', + ], + ], + 'required' => ['temperature', 'conditions', 'humidity'], + ]); +}); + +it('excludes outputSchema when tool returns empty schema', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'list-tools', + 'params' => [], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 5, + tools: [ToolWithoutOutputSchema::class], + resources: [], + prompts: [], + ); + + $listTools = new ListTools; + + $response = $listTools->handle($request, $context); + + $payload = $response->toArray(); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class) + ->and($payload)->toMatchArray(['id' => 1]) + ->and($payload['result']['tools'])->toHaveCount(1) + ->and($payload['result']['tools'][0])->not->toHaveKey('outputSchema'); +}); + +it('excludes outputSchema for default object type only', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'list-tools', + 'params' => [], + ]); + + $toolWithDefaultObjectType = new class extends SayHiTool + { + public function outputSchema(\Illuminate\JsonSchema\JsonSchema $schema): array + { + return []; + } + }; + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 5, + tools: [$toolWithDefaultObjectType], + resources: [], + prompts: [], + ); + + $listTools = new ListTools; + + $response = $listTools->handle($request, $context); + + $payload = $response->toArray(); + + expect($response)->toBeInstanceOf(JsonRpcResponse::class) + ->and($payload)->toMatchArray(['id' => 1]) + ->and($payload['result']['tools'])->toHaveCount(1) + ->and($payload['result']['tools'][0])->not->toHaveKey('outputSchema'); +}); + +it('outputSchema structure matches JSON Schema format with required fields', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'list-tools', + 'params' => [], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 5, + tools: [ToolWithOutputSchema::class], + resources: [], + prompts: [], + ); + + $listTools = new ListTools; + + $response = $listTools->handle($request, $context); + + $payload = $response->toArray(); + $outputSchema = $payload['result']['tools'][0]['outputSchema']; + + expect($response)->toBeInstanceOf(JsonRpcResponse::class) + ->and($payload)->toMatchArray(['id' => 1]) + ->and($payload['result']['tools'])->toHaveCount(1) + ->and($outputSchema)->toBeArray() + ->toHaveKeys(['type', 'properties', 'required']) + ->and($outputSchema['type'])->toBe('object') + ->and($outputSchema['required'])->toBeArray() + ->toContain('id', 'name', 'email') + ->and($outputSchema['properties'])->toHaveKeys(['id', 'name', 'email']) + ->and($outputSchema['properties']['id'])->toMatchArray([ + 'type' => 'integer', + 'description' => 'User ID', + ]) + ->and($outputSchema['properties']['name'])->toMatchArray([ + 'type' => 'string', + 'description' => 'User name', + ]) + ->and($outputSchema['properties']['email'])->toMatchArray([ + 'type' => 'string', + 'description' => 'User email', + ]); +}); diff --git a/tests/Unit/ResponseFactoryTest.php b/tests/Unit/ResponseFactoryTest.php index 55b6740..dfa26d3 100644 --- a/tests/Unit/ResponseFactoryTest.php +++ b/tests/Unit/ResponseFactoryTest.php @@ -71,3 +71,66 @@ expect($factory->getMeta())->toEqual(['result_meta' => 'result_value']) ->and($factory->responses()->first())->toBe($response); }); + +it('creates a structured content response with Response::structured', function (): void { + $factory = Response::structured(['result' => 'The result of the tool.']); + + expect($factory)->toBeInstanceOf(ResponseFactory::class) + ->and($factory->getStructuredContent())->toEqual(['result' => 'The result of the tool.']) + ->and($factory->responses())->toHaveCount(1); + + $textResponse = $factory->responses()->first(); + expect($textResponse->content()->toArray()['text']) + ->toContain('"result": "The result of the tool."'); +}); + +it('creates a structured content response with meta using Response::structured', function (): void { + $factory = Response::structured([ + 'entry' => 'The result of the tool.', + ])->withMeta(['x' => 'y']); + + expect($factory)->toBeInstanceOf(ResponseFactory::class) + ->and($factory->getStructuredContent())->toEqual(['entry' => 'The result of the tool.']) + ->and($factory->getMeta())->toEqual(['x' => 'y']) + ->and($factory->responses())->toHaveCount(1); +}); + +it('adds structured content to existing ResponseFactory with withStructuredContent', function (): void { + $factory = Response::make([ + Response::text('result is this'), + ])->withStructuredContent(['result' => 'result is this']); + + expect($factory)->toBeInstanceOf(ResponseFactory::class) + ->and($factory->getStructuredContent())->toEqual(['result' => 'result is this']) + ->and($factory->responses())->toHaveCount(1); + + $textResponse = $factory->responses()->first(); + expect($textResponse->content()->toArray()['text'])->toBe('result is this'); +}); + +it('adds structured content with meta to ResponseFactory', function (): void { + $factory = Response::make([ + Response::text('result is this')->withMeta(['x' => 'y']), + ])->withStructuredContent(['result' => 'result is this']); + + expect($factory)->toBeInstanceOf(ResponseFactory::class) + ->and($factory->getStructuredContent())->toEqual(['result' => 'result is this']) + ->and($factory->responses())->toHaveCount(1); +}); + +it('merges multiple withStructuredContent calls', function (): void { + $factory = (new ResponseFactory(Response::text('Hello'))) + ->withStructuredContent(['key1' => 'value1']) + ->withStructuredContent(['key2' => 'value1']) + ->withStructuredContent(['key2' => 'value2']); + + expect($factory->getStructuredContent())->toEqual(['key1' => 'value1', 'key2' => 'value2']); +}); + +it('throws exception when Response::structured result is wrapped in ResponseFactory::make', function (): void { + expect(fn (): ResponseFactory => Response::make([ + Response::structured(['result' => 'The result of the tool.']), + ]))->toThrow( + InvalidArgumentException::class, + ); +}); diff --git a/tests/Unit/Server/Concerns/HasMetaTest.php b/tests/Unit/Server/Concerns/HasMetaTest.php new file mode 100644 index 0000000..4e0d4ac --- /dev/null +++ b/tests/Unit/Server/Concerns/HasMetaTest.php @@ -0,0 +1,131 @@ +meta; + } + }; + + $object->setMeta(['key1' => 'value1', 'key2' => 'value2']); + + expect($object->getMeta())->toEqual([ + 'key1' => 'value1', + 'key2' => 'value2', + ]); +}); + +it('can set meta with a key-value signature', function (): void { + $object = new class + { + use HasMeta; + + public function getMeta(): ?array + { + return $this->meta; + } + }; + + $object->setMeta('key1', 'value1'); + $object->setMeta('key2', 'value2'); + + expect($object->getMeta())->toEqual([ + 'key1' => 'value1', + 'key2' => 'value2', + ]); +}); + +it('throws exception when using key-value signature without value', function (): void { + $object = new class + { + use HasMeta; + }; + + expect(fn () => $object->setMeta('key1')) + ->toThrow(InvalidArgumentException::class, 'Value is required when using key-value signature.'); +}); + +it('merges meta into base array', function (): void { + $object = new class + { + use HasMeta; + }; + + $object->setMeta(['key1' => 'value1']); + + $result = $object->mergeMeta([ + 'name' => 'test', + 'description' => 'A test', + ]); + + expect($result)->toEqual([ + 'name' => 'test', + 'description' => 'A test', + '_meta' => [ + 'key1' => 'value1', + ], + ]); +}); + +it('returns base array when meta is null', function (): void { + $object = new class + { + use HasMeta; + }; + + $result = $object->mergeMeta([ + 'name' => 'test', + 'description' => 'A test', + ]); + + expect($result)->toEqual([ + 'name' => 'test', + 'description' => 'A test', + ])->not->toHaveKey('_meta'); +}); + +it('merges multiple setMeta calls with arrays', function (): void { + $object = new class + { + use HasMeta; + + public function getMeta(): ?array + { + return $this->meta; + } + }; + + $object->setMeta(['key1' => 'value1']); + $object->setMeta(['key2' => 'value2', 'key3' => 'value3']); + + expect($object->getMeta())->toEqual([ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3', + ]); +}); + +it('overwrites existing keys when setting meta', function (): void { + $object = new class + { + use HasMeta; + + public function getMeta(): ?array + { + return $this->meta; + } + }; + + $object->setMeta(['key1' => 'value1']); + $object->setMeta(['key1' => 'value2']); + + expect($object->getMeta())->toEqual([ + 'key1' => 'value2', + ]); +}); diff --git a/tests/Unit/Server/Concerns/HasStructuredContentTest.php b/tests/Unit/Server/Concerns/HasStructuredContentTest.php new file mode 100644 index 0000000..5602802 --- /dev/null +++ b/tests/Unit/Server/Concerns/HasStructuredContentTest.php @@ -0,0 +1,102 @@ +structuredContent; + } + }; + + $object->setStructuredContent(['key1' => 'value1', 'key2' => 'value2']); + + expect($object->getStructuredContent())->toEqual([ + 'key1' => 'value1', + 'key2' => 'value2', + ]); +}); + +it('merges structured content into base array', function (): void { + $object = new class + { + use HasStructuredContent; + }; + + $object->setStructuredContent(['temperature' => 22.5, 'humidity' => 65]); + + $result = $object->mergeStructuredContent([ + 'content' => [['type' => 'text', 'text' => 'Weather data']], + 'isError' => false, + ]); + + expect($result)->toEqual([ + 'content' => [['type' => 'text', 'text' => 'Weather data']], + 'isError' => false, + 'structuredContent' => [ + 'temperature' => 22.5, + 'humidity' => 65, + ], + ]); +}); + +it('returns base array when structured content is null', function (): void { + $object = new class + { + use HasStructuredContent; + }; + + $result = $object->mergeStructuredContent([ + 'content' => [['type' => 'text', 'text' => 'Weather data']], + 'isError' => false, + ]); + + expect($result)->toEqual([ + 'content' => [['type' => 'text', 'text' => 'Weather data']], + 'isError' => false, + ])->not->toHaveKey('structuredContent'); +}); + +it('merges multiple setStructuredContent calls with arrays', function (): void { + $object = new class + { + use HasStructuredContent; + + public function getStructuredContent(): ?array + { + return $this->structuredContent; + } + }; + + $object->setStructuredContent(['key1' => 'value1']); + $object->setStructuredContent(['key2' => 'value2', 'key3' => 'value3']); + + expect($object->getStructuredContent())->toEqual([ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3', + ]); +}); + +it('overwrites existing keys when setting structured content', function (): void { + $object = new class + { + use HasStructuredContent; + + public function getStructuredContent(): ?array + { + return $this->structuredContent; + } + }; + + $object->setStructuredContent(['key1' => 'value1']); + $object->setStructuredContent(['key1' => 'value2']); + + expect($object->getStructuredContent())->toEqual([ + 'key1' => 'value2', + ]); +}); diff --git a/tests/Unit/Tools/ToolTest.php b/tests/Unit/Tools/ToolTest.php index c83cd31..19358dc 100644 --- a/tests/Unit/Tools/ToolTest.php +++ b/tests/Unit/Tools/ToolTest.php @@ -99,6 +99,43 @@ expect($tool->toArray()['_meta'])->toEqual(['key' => 'value']); }); +it('default outputSchema returns empty array', function (): void { + $tool = new ToolWithoutOutputSchema; + $array = $tool->toArray(); + + expect($array)->not->toHaveKey('outputSchema'); +}); + +it('outputSchema can be overridden to return custom schema', function (): void { + $tool = new ToolWithOutputSchema; + $array = $tool->toArray(); + + expect($array)->toHaveKey('outputSchema') + ->and($array['outputSchema']['properties'])->toHaveKey('result') + ->and($array['outputSchema']['properties'])->toHaveKey('count'); +}); + +it('toArray includes outputSchema when defined', function (): void { + $tool = new ToolWithOutputSchema; + $array = $tool->toArray(); + + expect($array)->toHaveKey('outputSchema') + ->and($array['outputSchema'])->toHaveKey('type') + ->and($array['outputSchema']['type'])->toBe('object') + ->and($array['outputSchema'])->toHaveKey('properties') + ->and($array['outputSchema']['properties'])->toHaveKey('result') + ->and($array['outputSchema']['properties'])->toHaveKey('count') + ->and($array['outputSchema'])->toHaveKey('required') + ->and($array['outputSchema']['required'])->toEqual(['result', 'count']); +}); + +it('toArray excludes outputSchema when empty or default', function (): void { + $tool = new ToolWithoutOutputSchema; + $array = $tool->toArray(); + + expect($array)->not->toHaveKey('outputSchema'); +}); + class TestTool extends Tool { public function description(): string @@ -167,3 +204,16 @@ class CustomMetaTool extends TestTool 'key' => 'value', ]; } + +class ToolWithOutputSchema extends TestTool +{ + public function outputSchema(\Illuminate\JsonSchema\JsonSchema $schema): array + { + return [ + 'result' => $schema->string()->description('The result value')->required(), + 'count' => $schema->integer()->description('The count value')->required(), + ]; + } +} + +class ToolWithoutOutputSchema extends TestTool {}