From e382dc2925cb36948451256ef1f72f058db13eff Mon Sep 17 00:00:00 2001 From: Andrew Minion Date: Fri, 10 Oct 2025 13:12:26 -0500 Subject: [PATCH 01/15] Add structuredContent responses --- src/Response.php | 13 +++- src/Server/Methods/CallTool.php | 4 ++ tests/Unit/ResponseTest.php | 114 ++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/src/Response.php b/src/Response.php index a64457e8..4fbbd470 100644 --- a/src/Response.php +++ b/src/Response.php @@ -23,6 +23,7 @@ protected function __construct( protected Content $content, protected Role $role = Role::USER, protected bool $isError = false, + protected mixed $structuredContent = null, ) { // } @@ -86,7 +87,17 @@ public static function image(): Content public function asAssistant(): static { - return new static($this->content, Role::ASSISTANT, $this->isError); + return new static($this->content, Role::ASSISTANT, $this->isError, $this->structuredContent); + } + + public function withStructuredContent(mixed $content): static + { + return new static($this->content, $this->role, $this->isError, $content); + } + + public function structuredContent(): mixed + { + return $this->structuredContent; } public function isNotification(): bool diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index e65bad3a..4b54c796 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -68,6 +68,10 @@ protected function serializable(Tool $tool): callable return fn (Collection $responses): array => [ 'content' => $responses->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), + 'structuredContent' => $responses + ->map(fn (Response $response): mixed => $response->structuredContent()) + ->filter(fn (mixed $content): bool => ! is_null($content)) + ->values(), ]; } } diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index d9484d7a..7939b01d 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -122,3 +122,117 @@ $content = $response->content(); expect((string) $content)->toBe(json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); }); + +it('creates a response with structured content', function (): void { + $structuredData = ['type' => 'user_profile', 'data' => ['name' => 'John', 'age' => 30]]; + $response = Response::text('User profile data') + ->withStructuredContent($structuredData); + + expect($response->content())->toBeInstanceOf(Text::class); + expect($response->structuredContent())->toBe($structuredData); + expect($response->isNotification())->toBeFalse(); + expect($response->isError())->toBeFalse(); + expect($response->role())->toBe(Role::USER); +}); + +it('preserves structured content when converting to assistant role', function (): void { + $structuredData = ['type' => 'analysis', 'results' => ['score' => 95]]; + $response = Response::text('Analysis complete') + ->withStructuredContent($structuredData) + ->asAssistant(); + + expect($response->content())->toBeInstanceOf(Text::class); + expect($response->structuredContent())->toBe($structuredData); + expect($response->role())->toBe(Role::ASSISTANT); + expect($response->isError())->toBeFalse(); +}); + +it('preserves structured content in error responses', function (): void { + $structuredData = ['type' => 'error_details', 'code' => 'VALIDATION_FAILED']; + $response = Response::error('Validation failed') + ->withStructuredContent($structuredData); + + expect($response->content())->toBeInstanceOf(Text::class); + expect($response->structuredContent())->toBe($structuredData); + expect($response->isError())->toBeTrue(); + expect($response->role())->toBe(Role::USER); +}); + +it('handles null structured content', function (): void { + $response = Response::text('Simple message') + ->withStructuredContent(null); + + expect($response->content())->toBeInstanceOf(Text::class); + expect($response->structuredContent())->toBeNull(); + expect($response->isNotification())->toBeFalse(); + expect($response->isError())->toBeFalse(); +}); + +it('handles complex structured content with nested arrays', function (): void { + $structuredData = [ + 'type' => 'search_results', + 'query' => 'Laravel MCP', + 'results' => [ + ['title' => 'Laravel MCP Documentation', 'url' => 'https://example.com/docs'], + ['title' => 'Laravel MCP GitHub', 'url' => 'https://github.com/example/laravel-mcp'], + ], + 'metadata' => [ + 'total' => 2, + 'page' => 1, + 'per_page' => 10, + ], + ]; + $response = Response::text('Search completed') + ->withStructuredContent($structuredData); + + expect($response->content())->toBeInstanceOf(Text::class); + expect($response->structuredContent())->toBe($structuredData); + expect($response->structuredContent()['type'])->toBe('search_results'); + expect($response->structuredContent()['results'])->toHaveCount(2); +}); + +it('handles structured content with different data types', function (): void { + $structuredData = [ + 'string' => 'test', + 'integer' => 42, + 'float' => 3.14, + 'boolean' => true, + 'array' => [1, 2, 3], + 'object' => (object) ['key' => 'value'], + ]; + $response = Response::text('Mixed data types') + ->withStructuredContent($structuredData); + + expect($response->content())->toBeInstanceOf(Text::class); + expect($response->structuredContent())->toBe($structuredData); + expect($response->structuredContent()['string'])->toBe('test'); + expect($response->structuredContent()['integer'])->toBe(42); + expect($response->structuredContent()['float'])->toBe(3.14); + expect($response->structuredContent()['boolean'])->toBeTrue(); + expect($response->structuredContent()['array'])->toBe([1, 2, 3]); + expect($response->structuredContent()['object'])->toBeInstanceOf(stdClass::class); +}); + +it('can chain withStructuredContent with other methods', function (): void { + $structuredData = ['type' => 'chained_response']; + $response = Response::text('Chained response') + ->withStructuredContent($structuredData) + ->asAssistant(); + + expect($response->content())->toBeInstanceOf(Text::class); + expect($response->structuredContent())->toBe($structuredData); + expect($response->role())->toBe(Role::ASSISTANT); + expect($response->isError())->toBeFalse(); +}); + +it('overwrites structured content when called multiple times', function (): void { + $firstData = ['type' => 'first']; + $secondData = ['type' => 'second', 'updated' => true]; + + $response = Response::text('Multiple structured content calls') + ->withStructuredContent($firstData) + ->withStructuredContent($secondData); + + expect($response->structuredContent())->toBe($secondData); + expect($response->structuredContent())->not->toBe($firstData); +}); From 6dda2a832b70bdccea4e03123a5a1abec4fcfbef Mon Sep 17 00:00:00 2001 From: Andrew Minion Date: Fri, 10 Oct 2025 14:08:33 -0500 Subject: [PATCH 02/15] fix tests --- src/Server/Methods/CallTool.php | 3 ++- tests/Pest.php | 2 ++ tests/Unit/Methods/CallToolTest.php | 3 +++ tests/Unit/Resources/CallToolTest.php | 3 +++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index 4b54c796..6440a860 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -71,7 +71,8 @@ protected function serializable(Tool $tool): callable 'structuredContent' => $responses ->map(fn (Response $response): mixed => $response->structuredContent()) ->filter(fn (mixed $content): bool => ! is_null($content)) - ->values(), + ->values() + ->all(), ]; } } diff --git a/tests/Pest.php b/tests/Pest.php index 3f557463..be9e0d45 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -238,6 +238,7 @@ function expectedCallToolResponse(): array 'text' => 'Hello, John Doe!', ]], 'isError' => false, + 'structuredContent' => [], ], ]; } @@ -293,6 +294,7 @@ function expectedStreamingToolResponse(int $count = 2): array 'result' => [ 'content' => [['type' => 'text', 'text' => "Finished streaming {$count} messages."]], 'isError' => false, + 'structuredContent' => [], ], ]; diff --git a/tests/Unit/Methods/CallToolTest.php b/tests/Unit/Methods/CallToolTest.php index d8c72c47..06dc1f3f 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -50,6 +50,7 @@ ], ], 'isError' => false, + 'structuredContent' => [], ]); }); @@ -99,6 +100,7 @@ ], ], 'isError' => false, + 'structuredContent' => [], ]); }); @@ -142,6 +144,7 @@ ], ], 'isError' => true, + 'structuredContent' => [], ]); }); diff --git a/tests/Unit/Resources/CallToolTest.php b/tests/Unit/Resources/CallToolTest.php index 06566979..75b6118a 100644 --- a/tests/Unit/Resources/CallToolTest.php +++ b/tests/Unit/Resources/CallToolTest.php @@ -50,6 +50,7 @@ ], ], 'isError' => false, + 'structuredContent' => [], ]); }); @@ -99,6 +100,7 @@ ], ], 'isError' => false, + 'structuredContent' => [], ]); }); @@ -141,6 +143,7 @@ ], ], 'isError' => true, + 'structuredContent' => [], ]); }); From eb776d0e393d662f5a9fda39daab85048bcd921d Mon Sep 17 00:00:00 2001 From: Andrew Minion Date: Thu, 16 Oct 2025 18:24:32 -0500 Subject: [PATCH 03/15] add Response::structured() --- src/Response.php | 24 ++-- src/Server/Content/StructuredContent.php | 74 +++++++++++ src/Server/Methods/CallTool.php | 34 +++-- .../Fixtures/ReturnStructuredContentTool.php | 41 ++++++ tests/Pest.php | 2 - tests/Unit/Methods/CallToolTest.php | 3 - tests/Unit/Resources/CallToolTest.php | 51 ++++++- tests/Unit/ResponseTest.php | 125 +++--------------- 8 files changed, 217 insertions(+), 137 deletions(-) create mode 100644 src/Server/Content/StructuredContent.php create mode 100644 tests/Fixtures/ReturnStructuredContentTool.php diff --git a/src/Response.php b/src/Response.php index 4fbbd470..4f76a828 100644 --- a/src/Response.php +++ b/src/Response.php @@ -11,6 +11,7 @@ use Laravel\Mcp\Exceptions\NotImplementedException; use Laravel\Mcp\Server\Content\Blob; use Laravel\Mcp\Server\Content\Notification; +use Laravel\Mcp\Server\Content\StructuredContent; use Laravel\Mcp\Server\Content\Text; use Laravel\Mcp\Server\Contracts\Content; @@ -23,7 +24,6 @@ protected function __construct( protected Content $content, protected Role $role = Role::USER, protected bool $isError = false, - protected mixed $structuredContent = null, ) { // } @@ -59,6 +59,16 @@ public static function blob(string $content): static return new static(new Blob($content)); } + /** + * Return structured content. Note that multiple structured content responses will be merged into a single object. + * + * @param array|object $content Must be an associative array or object. + */ + public static function structured(array|object $content): static + { + return new static(new StructuredContent($content)); + } + public static function error(string $text): static { return new static(new Text($text), isError: true); @@ -87,17 +97,7 @@ public static function image(): Content public function asAssistant(): static { - return new static($this->content, Role::ASSISTANT, $this->isError, $this->structuredContent); - } - - public function withStructuredContent(mixed $content): static - { - return new static($this->content, $this->role, $this->isError, $content); - } - - public function structuredContent(): mixed - { - return $this->structuredContent; + return new static($this->content, Role::ASSISTANT, $this->isError); } public function isNotification(): bool diff --git a/src/Server/Content/StructuredContent.php b/src/Server/Content/StructuredContent.php new file mode 100644 index 00000000..7e39bfd6 --- /dev/null +++ b/src/Server/Content/StructuredContent.php @@ -0,0 +1,74 @@ +|object $structuredContent + */ + public function __construct(protected array|object $structuredContent = []) + { + // + } + + /** + * @return array + */ + public function toTool(Tool $tool): array + { + return json_decode($this->toJsonString(), true); + } + + /** + * @return array + */ + public function toPrompt(Prompt $prompt): array + { + return $this->toArray(); + } + + /** + * @return array + */ + public function toResource(Resource $resource): array + { + return [ + 'json' => $this->toJsonString(), + 'uri' => $resource->uri(), + 'name' => $resource->name(), + 'title' => $resource->title(), + 'mimeType' => $resource->mimeType() === 'text/plain' + ? 'application/json' + : $resource->mimeType(), + ]; + } + + public function __toString(): string + { + return $this->toJsonString(); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'type' => 'text', + 'text' => $this->toJsonString(), + ]; + } + + private function toJsonString(): string + { + return json_encode($this->structuredContent, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + } +} diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index 6440a860..cb84dcd7 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -9,6 +9,7 @@ use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Content\StructuredContent; use Laravel\Mcp\Server\Contracts\Errable; use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Exceptions\JsonRpcException; @@ -61,18 +62,31 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat } /** - * @return callable(Collection): array{content: array>, isError: bool} + * @return callable(Collection): array{content: array>, isError: bool, ?structuredContent: array} */ protected function serializable(Tool $tool): callable { - return fn (Collection $responses): array => [ - 'content' => $responses->map(fn (Response $response): array => $response->content()->toTool($tool))->all(), - 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), - 'structuredContent' => $responses - ->map(fn (Response $response): mixed => $response->structuredContent()) - ->filter(fn (mixed $content): bool => ! is_null($content)) - ->values() - ->all(), - ]; + return function (Collection $responses) use ($tool): array { + $groups = $responses->groupBy(fn (Response $response): string => $response->content() instanceof StructuredContent ? 'structuredContent' : 'content'); + + $content = $groups + ->get('content') + ?->map(fn (Response $response): array => $response->content()->toTool($tool)); + + $structuredContent = $groups + ->get('structuredContent') + ?->map(fn (Response $response): array => $response->content()->toTool($tool)) + ->collapse(); + + return [ + 'content' => $structuredContent?->isNotEmpty() + ? $structuredContent->toJson() + : $content?->all(), + 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), + ...$structuredContent?->isNotEmpty() + ? ['structuredContent' => $structuredContent->all()] + : [], + ]; + }; } } diff --git a/tests/Fixtures/ReturnStructuredContentTool.php b/tests/Fixtures/ReturnStructuredContentTool.php new file mode 100644 index 00000000..f35fd49f --- /dev/null +++ b/tests/Fixtures/ReturnStructuredContentTool.php @@ -0,0 +1,41 @@ +validate([ + 'name' => 'required|string', + 'age' => 'required|integer', + ]); + + $name = $request->get('name'); + $age = $request->get('age'); + + return [ + Response::structured(['name' => $name]), + Response::structured(['age' => $age]), + ]; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string() + ->description('The name of the person to greet') + ->required(), + 'age' => $schema->integer() + ->description('The age of the person') + ->required(), + ]; + } +} diff --git a/tests/Pest.php b/tests/Pest.php index be9e0d45..3f557463 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -238,7 +238,6 @@ function expectedCallToolResponse(): array 'text' => 'Hello, John Doe!', ]], 'isError' => false, - 'structuredContent' => [], ], ]; } @@ -294,7 +293,6 @@ function expectedStreamingToolResponse(int $count = 2): array 'result' => [ 'content' => [['type' => 'text', 'text' => "Finished streaming {$count} messages."]], 'isError' => false, - 'structuredContent' => [], ], ]; diff --git a/tests/Unit/Methods/CallToolTest.php b/tests/Unit/Methods/CallToolTest.php index 06dc1f3f..d8c72c47 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -50,7 +50,6 @@ ], ], 'isError' => false, - 'structuredContent' => [], ]); }); @@ -100,7 +99,6 @@ ], ], 'isError' => false, - 'structuredContent' => [], ]); }); @@ -144,7 +142,6 @@ ], ], 'isError' => true, - 'structuredContent' => [], ]); }); diff --git a/tests/Unit/Resources/CallToolTest.php b/tests/Unit/Resources/CallToolTest.php index 75b6118a..f708a22e 100644 --- a/tests/Unit/Resources/CallToolTest.php +++ b/tests/Unit/Resources/CallToolTest.php @@ -5,6 +5,7 @@ use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; use Tests\Fixtures\CurrentTimeTool; +use Tests\Fixtures\ReturnStructuredContentTool; use Tests\Fixtures\SayHiTool; use Tests\Fixtures\SayHiTwiceTool; @@ -50,7 +51,6 @@ ], ], 'isError' => false, - 'structuredContent' => [], ]); }); @@ -100,7 +100,53 @@ ], ], 'isError' => false, - 'structuredContent' => [], + ]); +}); + +it('returns a valid call tool response that merges structured content', function (): void { + $request = JsonRpcRequest::from([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'return-structured-content-tool', + 'arguments' => ['name' => 'John Doe', 'age' => 30], + ], + ]); + + $context = new ServerContext( + supportedProtocolVersions: ['2025-03-26'], + serverCapabilities: [], + serverName: 'Test Server', + serverVersion: '1.0.0', + instructions: 'Test instructions', + maxPaginationLength: 50, + defaultPaginationLength: 10, + tools: [ReturnStructuredContentTool::class], + resources: [], + prompts: [], + ); + + $method = new CallTool; + + $this->instance('mcp.request', $request->toRequest()); + $responses = $method->handle($request, $context); + + [$response] = iterator_to_array($responses); + + $payload = $response->toArray(); + + expect($payload['id'])->toEqual(1) + ->and($payload['result'])->toEqual([ + 'content' => json_encode([ + 'name' => 'John Doe', + 'age' => 30, + ]), + 'isError' => false, + 'structuredContent' => [ + 'name' => 'John Doe', + 'age' => 30, + ], ]); }); @@ -143,7 +189,6 @@ ], ], 'isError' => true, - 'structuredContent' => [], ]); }); diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index 7939b01d..c1f487fa 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -7,7 +7,9 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Content\Blob; use Laravel\Mcp\Server\Content\Notification; +use Laravel\Mcp\Server\Content\StructuredContent; use Laravel\Mcp\Server\Content\Text; +use Laravel\Mcp\Server\Tool; it('creates a notification response', function (): void { $response = Response::notification('test.method', ['key' => 'value']); @@ -125,114 +127,23 @@ it('creates a response with structured content', function (): void { $structuredData = ['type' => 'user_profile', 'data' => ['name' => 'John', 'age' => 30]]; - $response = Response::text('User profile data') - ->withStructuredContent($structuredData); - - expect($response->content())->toBeInstanceOf(Text::class); - expect($response->structuredContent())->toBe($structuredData); + $response = Response::structured($structuredData); + + $genericTool = new class extends Tool + { + public function name(): string + { + return 'generic_tool'; + } + }; + + expect($response->content())->toBeInstanceOf(StructuredContent::class); + expect($response->content()->toTool($genericTool))->toBe($structuredData); + expect($response->content()->toArray())->toBe([ + 'type' => 'text', + 'text' => json_encode($structuredData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), + ]); expect($response->isNotification())->toBeFalse(); expect($response->isError())->toBeFalse(); expect($response->role())->toBe(Role::USER); }); - -it('preserves structured content when converting to assistant role', function (): void { - $structuredData = ['type' => 'analysis', 'results' => ['score' => 95]]; - $response = Response::text('Analysis complete') - ->withStructuredContent($structuredData) - ->asAssistant(); - - expect($response->content())->toBeInstanceOf(Text::class); - expect($response->structuredContent())->toBe($structuredData); - expect($response->role())->toBe(Role::ASSISTANT); - expect($response->isError())->toBeFalse(); -}); - -it('preserves structured content in error responses', function (): void { - $structuredData = ['type' => 'error_details', 'code' => 'VALIDATION_FAILED']; - $response = Response::error('Validation failed') - ->withStructuredContent($structuredData); - - expect($response->content())->toBeInstanceOf(Text::class); - expect($response->structuredContent())->toBe($structuredData); - expect($response->isError())->toBeTrue(); - expect($response->role())->toBe(Role::USER); -}); - -it('handles null structured content', function (): void { - $response = Response::text('Simple message') - ->withStructuredContent(null); - - expect($response->content())->toBeInstanceOf(Text::class); - expect($response->structuredContent())->toBeNull(); - expect($response->isNotification())->toBeFalse(); - expect($response->isError())->toBeFalse(); -}); - -it('handles complex structured content with nested arrays', function (): void { - $structuredData = [ - 'type' => 'search_results', - 'query' => 'Laravel MCP', - 'results' => [ - ['title' => 'Laravel MCP Documentation', 'url' => 'https://example.com/docs'], - ['title' => 'Laravel MCP GitHub', 'url' => 'https://github.com/example/laravel-mcp'], - ], - 'metadata' => [ - 'total' => 2, - 'page' => 1, - 'per_page' => 10, - ], - ]; - $response = Response::text('Search completed') - ->withStructuredContent($structuredData); - - expect($response->content())->toBeInstanceOf(Text::class); - expect($response->structuredContent())->toBe($structuredData); - expect($response->structuredContent()['type'])->toBe('search_results'); - expect($response->structuredContent()['results'])->toHaveCount(2); -}); - -it('handles structured content with different data types', function (): void { - $structuredData = [ - 'string' => 'test', - 'integer' => 42, - 'float' => 3.14, - 'boolean' => true, - 'array' => [1, 2, 3], - 'object' => (object) ['key' => 'value'], - ]; - $response = Response::text('Mixed data types') - ->withStructuredContent($structuredData); - - expect($response->content())->toBeInstanceOf(Text::class); - expect($response->structuredContent())->toBe($structuredData); - expect($response->structuredContent()['string'])->toBe('test'); - expect($response->structuredContent()['integer'])->toBe(42); - expect($response->structuredContent()['float'])->toBe(3.14); - expect($response->structuredContent()['boolean'])->toBeTrue(); - expect($response->structuredContent()['array'])->toBe([1, 2, 3]); - expect($response->structuredContent()['object'])->toBeInstanceOf(stdClass::class); -}); - -it('can chain withStructuredContent with other methods', function (): void { - $structuredData = ['type' => 'chained_response']; - $response = Response::text('Chained response') - ->withStructuredContent($structuredData) - ->asAssistant(); - - expect($response->content())->toBeInstanceOf(Text::class); - expect($response->structuredContent())->toBe($structuredData); - expect($response->role())->toBe(Role::ASSISTANT); - expect($response->isError())->toBeFalse(); -}); - -it('overwrites structured content when called multiple times', function (): void { - $firstData = ['type' => 'first']; - $secondData = ['type' => 'second', 'updated' => true]; - - $response = Response::text('Multiple structured content calls') - ->withStructuredContent($firstData) - ->withStructuredContent($secondData); - - expect($response->structuredContent())->toBe($secondData); - expect($response->structuredContent())->not->toBe($firstData); -}); From fadef3fd13fcb22368cfb4115c26129b20542e6a Mon Sep 17 00:00:00 2001 From: Andrew Minion Date: Thu, 16 Oct 2025 20:20:12 -0500 Subject: [PATCH 04/15] add another test --- tests/Unit/Content/StructuredContentTest.php | 67 ++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/Unit/Content/StructuredContentTest.php diff --git a/tests/Unit/Content/StructuredContentTest.php b/tests/Unit/Content/StructuredContentTest.php new file mode 100644 index 00000000..bac5194d --- /dev/null +++ b/tests/Unit/Content/StructuredContentTest.php @@ -0,0 +1,67 @@ + 'John', 'age' => 30]); + $resource = new class extends Resource + { + protected string $uri = 'file://readme.txt'; + + protected string $name = 'readme'; + + protected string $title = 'Readme File'; + + protected string $mimeType = 'application/json'; + }; + + $payload = $structuredContent->toResource($resource); + + expect($payload)->toEqual([ + 'json' => json_encode(['name' => 'John', 'age' => 30], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), + 'uri' => 'file://readme.txt', + 'name' => 'readme', + 'title' => 'Readme File', + 'mimeType' => 'application/json', + ]); +}); + +it('may be used in tools', function (): void { + $structuredContent = new StructuredContent(['name' => 'John', 'age' => 30]); + + $payload = $structuredContent->toTool(new class extends Tool {}); + + expect($payload)->toEqual([ + 'name' => 'John', + 'age' => 30, + ]); +}); + +it('may be used in prompts', function (): void { + $structuredContent = new StructuredContent(['name' => 'John', 'age' => 30]); + + $payload = $structuredContent->toPrompt(new class extends Prompt {}); + + expect($payload)->toEqual([ + 'type' => 'text', + 'text' => json_encode(['name' => 'John', 'age' => 30], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), + ]); +}); + +it('casts to string as raw text', function (): void { + $structuredContent = new StructuredContent(['name' => 'John', 'age' => 30]); + + expect((string) $structuredContent)->toBe(json_encode(['name' => 'John', 'age' => 30], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); +}); + +it('converts to array with type and text', function (): void { + $structuredContent = new StructuredContent(['name' => 'John', 'age' => 30]); + + expect($structuredContent->toArray())->toEqual([ + 'type' => 'text', + 'text' => json_encode(['name' => 'John', 'age' => 30], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), + ]); +}); From 8b4d409c7c609ee7b4eb56736c33832257e56acc Mon Sep 17 00:00:00 2001 From: Andrew Minion Date: Fri, 17 Oct 2025 16:29:14 -0500 Subject: [PATCH 05/15] fix structure --- src/Server/Methods/CallTool.php | 20 ++++++++++++++------ tests/Unit/Resources/CallToolTest.php | 13 +++++++++---- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index cb84dcd7..60b825ce 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -78,14 +78,22 @@ protected function serializable(Tool $tool): callable ?->map(fn (Response $response): array => $response->content()->toTool($tool)) ->collapse(); + if ($structuredContent?->isNotEmpty()) { + return [ + 'content' => [ + [ + 'type' => 'text', + 'text' => $structuredContent->toJson(), + ], + ], + 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), + 'structuredContent' => $structuredContent->all(), + ]; + } + return [ - 'content' => $structuredContent?->isNotEmpty() - ? $structuredContent->toJson() - : $content?->all(), + 'content' => $content?->all(), 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), - ...$structuredContent?->isNotEmpty() - ? ['structuredContent' => $structuredContent->all()] - : [], ]; }; } diff --git a/tests/Unit/Resources/CallToolTest.php b/tests/Unit/Resources/CallToolTest.php index f708a22e..97bf17ee 100644 --- a/tests/Unit/Resources/CallToolTest.php +++ b/tests/Unit/Resources/CallToolTest.php @@ -138,10 +138,15 @@ expect($payload['id'])->toEqual(1) ->and($payload['result'])->toEqual([ - 'content' => json_encode([ - 'name' => 'John Doe', - 'age' => 30, - ]), + 'content' => [ + [ + 'type' => 'text', + 'text' => json_encode([ + 'name' => 'John Doe', + 'age' => 30, + ]), + ], + ], 'isError' => false, 'structuredContent' => [ 'name' => 'John Doe', From c059663f3d40411b15c8ae79bee46f16380ecc5a Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sun, 23 Nov 2025 17:55:17 +0530 Subject: [PATCH 06/15] Refactor --- src/Response.php | 14 +- src/ResponseFactory.php | 20 +++ src/Server/Concerns/HasStructuredContent.php | 48 ++++++ src/Server/Content/StructuredContent.php | 77 ---------- src/Server/Methods/CallTool.php | 37 +---- src/Server/Tool.php | 13 ++ ...sponseFactoryWithStructuredContentTool.php | 31 ++++ .../Fixtures/ReturnStructuredContentTool.php | 41 ------ tests/Fixtures/StructuredContentTool.php | 30 ++++ .../StructuredContentWithMetaTool.php | 28 ++++ tests/Unit/Methods/CallToolTest.php | 138 ++++++++++++++++++ tests/Unit/Resources/CallToolTest.php | 53 ------- tests/Unit/ResponseFactoryTest.php | 71 +++++++++ tests/Unit/ResponseTest.php | 25 ---- 14 files changed, 394 insertions(+), 232 deletions(-) create mode 100644 src/Server/Concerns/HasStructuredContent.php delete mode 100644 src/Server/Content/StructuredContent.php create mode 100644 tests/Fixtures/ResponseFactoryWithStructuredContentTool.php delete mode 100644 tests/Fixtures/ReturnStructuredContentTool.php create mode 100644 tests/Fixtures/StructuredContentTool.php create mode 100644 tests/Fixtures/StructuredContentWithMetaTool.php diff --git a/src/Response.php b/src/Response.php index be6e4730..afb5ad4f 100644 --- a/src/Response.php +++ b/src/Response.php @@ -11,7 +11,6 @@ use Laravel\Mcp\Exceptions\NotImplementedException; use Laravel\Mcp\Server\Content\Blob; use Laravel\Mcp\Server\Content\Notification; -use Laravel\Mcp\Server\Content\StructuredContent; use Laravel\Mcp\Server\Content\Text; use Laravel\Mcp\Server\Contracts\Content; @@ -60,13 +59,18 @@ public static function blob(string $content): static } /** - * Return structured content. Note that multiple structured content responses will be merged into a single object. + * @param array $response * - * @param array|object $content Must be an associative array or object. + * @throws JsonException */ - public static function structured(array|object $content): static + public static function structured(array $response): ResponseFactory { - return new static(new StructuredContent($content)); + $content = Response::text(json_encode( + $response, + JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT, + )); + + return (new ResponseFactory($content))->withStructuredContent($response); } public static function error(string $text): static diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php index 12b89b29..2f740600 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 string|array $structuredContent + */ + public function withStructuredContent(string|array $structuredContent, mixed $value = null): static + { + $this->setStructuredContent($structuredContent, $value); + + 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 00000000..c8ce07bb --- /dev/null +++ b/src/Server/Concerns/HasStructuredContent.php @@ -0,0 +1,48 @@ +|null + */ + protected ?array $structuredContent = null; + + /** + * @param array|string $structuredContent + */ + public function setStructuredContent(array|string $structuredContent, mixed $value = null): void + { + $this->structuredContent ??= []; + + if (! is_array($structuredContent)) { + if (is_null($value)) { + throw new InvalidArgumentException('Value is required when using key-value signature.'); + } + + $this->structuredContent[$structuredContent] = $value; + + return; + } + + $this->structuredContent = array_merge($this->structuredContent, $structuredContent); + } + + /** + * @template T of array + * + * @param T $baseArray + * @return T&array{structuredContent?: array} + */ + public function mergeStructuredContent(array $baseArray): array + { + return ($structuredContent = $this->structuredContent) + ? [...$baseArray, 'structuredContent' => $structuredContent] + : $baseArray; + } +} diff --git a/src/Server/Content/StructuredContent.php b/src/Server/Content/StructuredContent.php deleted file mode 100644 index 628514c1..00000000 --- a/src/Server/Content/StructuredContent.php +++ /dev/null @@ -1,77 +0,0 @@ -|object $structuredContent - */ - public function __construct(protected array|object $structuredContent = []) - { - // - } - - /** - * @return array - */ - public function toTool(Tool $tool): array - { - return json_decode($this->toJsonString(), true); - } - - /** - * @return array - */ - public function toPrompt(Prompt $prompt): array - { - return $this->toArray(); - } - - /** - * @return array - */ - public function toResource(Resource $resource): array - { - return [ - 'json' => $this->toJsonString(), - 'uri' => $resource->uri(), - 'name' => $resource->name(), - 'title' => $resource->title(), - 'mimeType' => $resource->mimeType() === 'text/plain' - ? 'application/json' - : $resource->mimeType(), - ]; - } - - public function __toString(): string - { - return $this->toJsonString(); - } - - /** - * @return array - */ - public function toArray(): array - { - return [ - 'type' => 'text', - 'text' => $this->toJsonString(), - ]; - } - - private function toJsonString(): string - { - return json_encode($this->structuredContent, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); - } -} diff --git a/src/Server/Methods/CallTool.php b/src/Server/Methods/CallTool.php index 70712bc2..a4ba16e3 100644 --- a/src/Server/Methods/CallTool.php +++ b/src/Server/Methods/CallTool.php @@ -9,7 +9,6 @@ use Illuminate\Validation\ValidationException; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; -use Laravel\Mcp\Server\Content\StructuredContent; use Laravel\Mcp\Server\Contracts\Errable; use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Exceptions\JsonRpcException; @@ -62,39 +61,15 @@ public function handle(JsonRpcRequest $request, ServerContext $context): Generat } /** - * @return callable(ResponseFactor): array{content: array>, isError: bool, ?structuredContent: array} + * @return callable(ResponseFactory): array */ protected function serializable(Tool $tool): callable { - return function (ResponseFactory $factory) use ($tool): array { - $groups = $factory->responses()->groupBy(fn (Response $response): string => $response->content() instanceof StructuredContent ? 'structuredContent' : 'content'); - - $content = $groups - ->get('content') - ?->map(fn (Response $response): array => $response->content()->toTool($tool)); - - $structuredContent = $groups - ->get('structuredContent') - ?->map(fn (Response $response): array => $response->content()->toTool($tool)) - ->collapse(); - - if ($structuredContent?->isNotEmpty()) { - return $factory->mergeMeta([ - 'content' => [ - [ - 'type' => 'text', - 'text' => $structuredContent->toJson(), - ], - ], - 'isError' => $factory->responses()->contains(fn (Response $response): bool => $response->isError()), - 'structuredContent' => $structuredContent->all(), - ]); - } - - return $factory->mergeMeta([ - 'content' => $content?->all(), + 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 0019841e..57e6f2e2 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -19,6 +19,14 @@ public function schema(JsonSchema $schema): array return []; } + /** + * @return array + */ + public function outputSchema(JsonSchema $schema): array + { + return []; + } + /** * @return array */ @@ -63,6 +71,10 @@ public function toArray(): array $this->schema(...), )->toArray(); + $outputSchema = JsonSchema::object( + $this->outputSchema(...), + )->toArray(); + $schema['properties'] ??= (object) []; // @phpstan-ignore return.type @@ -71,6 +83,7 @@ public function toArray(): array 'title' => $this->title(), 'description' => $this->description(), 'inputSchema' => $schema, + 'outputSchema' => $outputSchema === [] ? null : $outputSchema, 'annotations' => $annotations === [] ? (object) [] : $annotations, ]); diff --git a/tests/Fixtures/ResponseFactoryWithStructuredContentTool.php b/tests/Fixtures/ResponseFactoryWithStructuredContentTool.php new file mode 100644 index 00000000..a3ec1bc1 --- /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/ReturnStructuredContentTool.php b/tests/Fixtures/ReturnStructuredContentTool.php deleted file mode 100644 index f35fd49f..00000000 --- a/tests/Fixtures/ReturnStructuredContentTool.php +++ /dev/null @@ -1,41 +0,0 @@ -validate([ - 'name' => 'required|string', - 'age' => 'required|integer', - ]); - - $name = $request->get('name'); - $age = $request->get('age'); - - return [ - Response::structured(['name' => $name]), - Response::structured(['age' => $age]), - ]; - } - - public function schema(JsonSchema $schema): array - { - return [ - 'name' => $schema->string() - ->description('The name of the person to greet') - ->required(), - 'age' => $schema->integer() - ->description('The age of the person') - ->required(), - ]; - } -} diff --git a/tests/Fixtures/StructuredContentTool.php b/tests/Fixtures/StructuredContentTool.php new file mode 100644 index 00000000..30fa63c8 --- /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 00000000..278ae858 --- /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/Unit/Methods/CallToolTest.php b/tests/Unit/Methods/CallToolTest.php index 0536e3d2..6a75d7b0 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -5,9 +5,12 @@ 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; @@ -344,3 +347,138 @@ ], ]); }); + +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(); +}); diff --git a/tests/Unit/Resources/CallToolTest.php b/tests/Unit/Resources/CallToolTest.php index 97bf17ee..06566979 100644 --- a/tests/Unit/Resources/CallToolTest.php +++ b/tests/Unit/Resources/CallToolTest.php @@ -5,7 +5,6 @@ use Laravel\Mcp\Server\Transport\JsonRpcRequest; use Laravel\Mcp\Server\Transport\JsonRpcResponse; use Tests\Fixtures\CurrentTimeTool; -use Tests\Fixtures\ReturnStructuredContentTool; use Tests\Fixtures\SayHiTool; use Tests\Fixtures\SayHiTwiceTool; @@ -103,58 +102,6 @@ ]); }); -it('returns a valid call tool response that merges structured content', function (): void { - $request = JsonRpcRequest::from([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'tools/call', - 'params' => [ - 'name' => 'return-structured-content-tool', - 'arguments' => ['name' => 'John Doe', 'age' => 30], - ], - ]); - - $context = new ServerContext( - supportedProtocolVersions: ['2025-03-26'], - serverCapabilities: [], - serverName: 'Test Server', - serverVersion: '1.0.0', - instructions: 'Test instructions', - maxPaginationLength: 50, - defaultPaginationLength: 10, - tools: [ReturnStructuredContentTool::class], - resources: [], - prompts: [], - ); - - $method = new CallTool; - - $this->instance('mcp.request', $request->toRequest()); - $responses = $method->handle($request, $context); - - [$response] = iterator_to_array($responses); - - $payload = $response->toArray(); - - expect($payload['id'])->toEqual(1) - ->and($payload['result'])->toEqual([ - 'content' => [ - [ - 'type' => 'text', - 'text' => json_encode([ - 'name' => 'John Doe', - 'age' => 30, - ]), - ], - ], - 'isError' => false, - 'structuredContent' => [ - 'name' => 'John Doe', - 'age' => 30, - ], - ]); -}); - it('returns a valid call tool response with validation error', function (): void { $request = JsonRpcRequest::from([ 'jsonrpc' => '2.0', diff --git a/tests/Unit/ResponseFactoryTest.php b/tests/Unit/ResponseFactoryTest.php index 55b67407..9ececec7 100644 --- a/tests/Unit/ResponseFactoryTest.php +++ b/tests/Unit/ResponseFactoryTest.php @@ -71,3 +71,74 @@ 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('supports withStructuredContent with key-value signature', function (): void { + $factory = (new ResponseFactory(Response::text('Hello'))) + ->withStructuredContent('key1', 'value1') + ->withStructuredContent('key2', 'value2'); + + expect($factory->getStructuredContent())->toEqual(['key1' => 'value1', 'key2' => 'value2']); +}); + +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/ResponseTest.php b/tests/Unit/ResponseTest.php index e146ea85..6f206248 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -8,9 +8,7 @@ use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Content\Blob; use Laravel\Mcp\Server\Content\Notification; -use Laravel\Mcp\Server\Content\StructuredContent; use Laravel\Mcp\Server\Content\Text; -use Laravel\Mcp\Server\Tool; it('creates a notification response', function (): void { $response = Response::notification('test.method', ['key' => 'value']); @@ -179,26 +177,3 @@ InvalidArgumentException::class, ); }); - -it('creates a response with structured content', function (): void { - $structuredData = ['type' => 'user_profile', 'data' => ['name' => 'John', 'age' => 30]]; - $response = Response::structured($structuredData); - - $genericTool = new class extends Tool - { - public function name(): string - { - return 'generic_tool'; - } - }; - - expect($response->content())->toBeInstanceOf(StructuredContent::class); - expect($response->content()->toTool($genericTool))->toBe($structuredData); - expect($response->content()->toArray())->toBe([ - 'type' => 'text', - 'text' => json_encode($structuredData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), - ]); - expect($response->isNotification())->toBeFalse(); - expect($response->isError())->toBeFalse(); - expect($response->role())->toBe(Role::USER); -}); From fea12ea729123912dda0206219b55ccba67dade7 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sun, 23 Nov 2025 18:37:25 +0530 Subject: [PATCH 07/15] Fix Test --- src/Server/Tool.php | 13 ++-- tests/Unit/Content/StructuredContentTest.php | 67 -------------------- 2 files changed, 9 insertions(+), 71 deletions(-) delete mode 100644 tests/Unit/Content/StructuredContentTest.php diff --git a/src/Server/Tool.php b/src/Server/Tool.php index 57e6f2e2..dbea6a55 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -77,15 +77,20 @@ public function toArray(): array $schema['properties'] ??= (object) []; - // @phpstan-ignore return.type - return $this->mergeMeta([ + $result = [ 'name' => $this->name(), 'title' => $this->title(), 'description' => $this->description(), 'inputSchema' => $schema, - 'outputSchema' => $outputSchema === [] ? null : $outputSchema, 'annotations' => $annotations === [] ? (object) [] : $annotations, - ]); + ]; + + if ($outputSchema !== [] && $outputSchema !== ['type' => 'object']) { + $result['outputSchema'] = $outputSchema; + } + + // @phpstan-ignore return.type + return $this->mergeMeta($result); } } diff --git a/tests/Unit/Content/StructuredContentTest.php b/tests/Unit/Content/StructuredContentTest.php deleted file mode 100644 index bac5194d..00000000 --- a/tests/Unit/Content/StructuredContentTest.php +++ /dev/null @@ -1,67 +0,0 @@ - 'John', 'age' => 30]); - $resource = new class extends Resource - { - protected string $uri = 'file://readme.txt'; - - protected string $name = 'readme'; - - protected string $title = 'Readme File'; - - protected string $mimeType = 'application/json'; - }; - - $payload = $structuredContent->toResource($resource); - - expect($payload)->toEqual([ - 'json' => json_encode(['name' => 'John', 'age' => 30], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), - 'uri' => 'file://readme.txt', - 'name' => 'readme', - 'title' => 'Readme File', - 'mimeType' => 'application/json', - ]); -}); - -it('may be used in tools', function (): void { - $structuredContent = new StructuredContent(['name' => 'John', 'age' => 30]); - - $payload = $structuredContent->toTool(new class extends Tool {}); - - expect($payload)->toEqual([ - 'name' => 'John', - 'age' => 30, - ]); -}); - -it('may be used in prompts', function (): void { - $structuredContent = new StructuredContent(['name' => 'John', 'age' => 30]); - - $payload = $structuredContent->toPrompt(new class extends Prompt {}); - - expect($payload)->toEqual([ - 'type' => 'text', - 'text' => json_encode(['name' => 'John', 'age' => 30], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), - ]); -}); - -it('casts to string as raw text', function (): void { - $structuredContent = new StructuredContent(['name' => 'John', 'age' => 30]); - - expect((string) $structuredContent)->toBe(json_encode(['name' => 'John', 'age' => 30], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); -}); - -it('converts to array with type and text', function (): void { - $structuredContent = new StructuredContent(['name' => 'John', 'age' => 30]); - - expect($structuredContent->toArray())->toEqual([ - 'type' => 'text', - 'text' => json_encode(['name' => 'John', 'age' => 30], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), - ]); -}); From aedb25128fb85dbe9ebc1e1792f2f76981a0b244 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sun, 23 Nov 2025 19:54:55 +0530 Subject: [PATCH 08/15] Add Tests --- src/Response.php | 16 +- src/Server/Concerns/HasStructuredContent.php | 14 +- src/Server/Tool.php | 4 +- tests/Fixtures/ToolWithOutputSchema.php | 39 ++++ tests/Fixtures/ToolWithoutOutputSchema.php | 25 +++ tests/Fixtures/WeatherTool.php | 47 +++++ tests/Unit/Methods/CallToolTest.php | 95 ++++++++++ tests/Unit/Methods/ListToolsTest.php | 180 +++++++++++++++++++ tests/Unit/Tools/ToolTest.php | 50 ++++++ 9 files changed, 458 insertions(+), 12 deletions(-) create mode 100644 tests/Fixtures/ToolWithOutputSchema.php create mode 100644 tests/Fixtures/ToolWithoutOutputSchema.php create mode 100644 tests/Fixtures/WeatherTool.php diff --git a/src/Response.php b/src/Response.php index afb5ad4f..e5e2878a 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; @@ -65,10 +66,17 @@ public static function blob(string $content): static */ public static function structured(array $response): ResponseFactory { - $content = Response::text(json_encode( - $response, - JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT, - )); + 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); } diff --git a/src/Server/Concerns/HasStructuredContent.php b/src/Server/Concerns/HasStructuredContent.php index c8ce07bb..3113c36c 100644 --- a/src/Server/Concerns/HasStructuredContent.php +++ b/src/Server/Concerns/HasStructuredContent.php @@ -34,15 +34,15 @@ public function setStructuredContent(array|string $structuredContent, mixed $val } /** - * @template T of array - * - * @param T $baseArray - * @return T&array{structuredContent?: array} + * @param array $baseArray + * @return array */ public function mergeStructuredContent(array $baseArray): array { - return ($structuredContent = $this->structuredContent) - ? [...$baseArray, 'structuredContent' => $structuredContent] - : $baseArray; + if ($this->structuredContent === null) { + return $baseArray; + } + + return array_merge($baseArray, ['structuredContent' => $this->structuredContent]); } } diff --git a/src/Server/Tool.php b/src/Server/Tool.php index dbea6a55..f79cf2f6 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -20,6 +20,8 @@ public function schema(JsonSchema $schema): array } /** + * Define the output schema for this tool's results. + * * @return array */ public function outputSchema(JsonSchema $schema): array @@ -59,6 +61,7 @@ public function toMethodCall(): array * title?: string|null, * description?: string|null, * inputSchema?: array, + * outputSchema?: array, * annotations?: array|object, * _meta?: array * } @@ -91,6 +94,5 @@ public function toArray(): array // @phpstan-ignore return.type return $this->mergeMeta($result); - } } diff --git a/tests/Fixtures/ToolWithOutputSchema.php b/tests/Fixtures/ToolWithOutputSchema.php new file mode 100644 index 00000000..c4550d67 --- /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 00000000..5a2b2503 --- /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 6a75d7b0..7bdfc202 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -13,6 +13,7 @@ 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([ @@ -482,3 +483,97 @@ ->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, + ]); +}); diff --git a/tests/Unit/Methods/ListToolsTest.php b/tests/Unit/Methods/ListToolsTest.php index 9c55e7f6..41d9ac6e 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/Tools/ToolTest.php b/tests/Unit/Tools/ToolTest.php index c83cd313..19358dca 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 {} From 37e942fb08e681c1c949c0986f31933d77a143e1 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sun, 23 Nov 2025 20:27:53 +0530 Subject: [PATCH 09/15] Add Tests --- tests/Unit/Server/Concerns/HasMetaTest.php | 131 +++++++++++++++++ .../Concerns/HasStructuredContentTest.php | 132 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 tests/Unit/Server/Concerns/HasMetaTest.php create mode 100644 tests/Unit/Server/Concerns/HasStructuredContentTest.php diff --git a/tests/Unit/Server/Concerns/HasMetaTest.php b/tests/Unit/Server/Concerns/HasMetaTest.php new file mode 100644 index 00000000..4e0d4acd --- /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 00000000..b6849074 --- /dev/null +++ b/tests/Unit/Server/Concerns/HasStructuredContentTest.php @@ -0,0 +1,132 @@ +structuredContent; + } + }; + + $object->setStructuredContent(['key1' => 'value1', 'key2' => 'value2']); + + expect($object->getStructuredContent())->toEqual([ + 'key1' => 'value1', + 'key2' => 'value2', + ]); +}); + +it('can set structured content with key-value signature', function (): void { + $object = new class + { + use HasStructuredContent; + + public function getStructuredContent(): ?array + { + return $this->structuredContent; + } + }; + + $object->setStructuredContent('key1', 'value1'); + $object->setStructuredContent('key2', 'value2'); + + expect($object->getStructuredContent())->toEqual([ + 'key1' => 'value1', + 'key2' => 'value2', + ]); +}); + +it('throws exception when using key-value signature without value', function (): void { + $object = new class + { + use HasStructuredContent; + }; + + expect(fn () => $object->setStructuredContent('key1')) + ->toThrow(InvalidArgumentException::class, 'Value is required when using key-value signature.'); +}); + +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', + ]); +}); From b45e1f338ca317c47d7e1f6f6f7232b6e075efc6 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sun, 23 Nov 2025 20:28:39 +0530 Subject: [PATCH 10/15] Add Tests --- tests/Unit/Methods/CallToolTest.php | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/Unit/Methods/CallToolTest.php b/tests/Unit/Methods/CallToolTest.php index 7bdfc202..eebc2f2a 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -577,3 +577,68 @@ '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.' + ); +}); From 3c3b9a9fb97ba7c2930ccbd2eeb29682a6459003 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sun, 23 Nov 2025 20:51:06 +0530 Subject: [PATCH 11/15] Refactor this --- src/ResponseFactory.php | 6 ++-- src/Server/Concerns/HasStructuredContent.php | 14 ++------- tests/Unit/ResponseFactoryTest.php | 8 ----- .../Concerns/HasStructuredContentTest.php | 30 ------------------- 4 files changed, 5 insertions(+), 53 deletions(-) diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php index 2f740600..7cc25178 100644 --- a/src/ResponseFactory.php +++ b/src/ResponseFactory.php @@ -53,11 +53,11 @@ public function withMeta(string|array $meta, mixed $value = null): static } /** - * @param string|array $structuredContent + * @param array $structuredContent */ - public function withStructuredContent(string|array $structuredContent, mixed $value = null): static + public function withStructuredContent(array $structuredContent): static { - $this->setStructuredContent($structuredContent, $value); + $this->setStructuredContent($structuredContent); return $this; } diff --git a/src/Server/Concerns/HasStructuredContent.php b/src/Server/Concerns/HasStructuredContent.php index 3113c36c..aeab7490 100644 --- a/src/Server/Concerns/HasStructuredContent.php +++ b/src/Server/Concerns/HasStructuredContent.php @@ -14,22 +14,12 @@ trait HasStructuredContent protected ?array $structuredContent = null; /** - * @param array|string $structuredContent + * @param array $structuredContent */ - public function setStructuredContent(array|string $structuredContent, mixed $value = null): void + public function setStructuredContent(array $structuredContent): void { $this->structuredContent ??= []; - if (! is_array($structuredContent)) { - if (is_null($value)) { - throw new InvalidArgumentException('Value is required when using key-value signature.'); - } - - $this->structuredContent[$structuredContent] = $value; - - return; - } - $this->structuredContent = array_merge($this->structuredContent, $structuredContent); } diff --git a/tests/Unit/ResponseFactoryTest.php b/tests/Unit/ResponseFactoryTest.php index 9ececec7..dfa26d3f 100644 --- a/tests/Unit/ResponseFactoryTest.php +++ b/tests/Unit/ResponseFactoryTest.php @@ -118,14 +118,6 @@ ->and($factory->responses())->toHaveCount(1); }); -it('supports withStructuredContent with key-value signature', function (): void { - $factory = (new ResponseFactory(Response::text('Hello'))) - ->withStructuredContent('key1', 'value1') - ->withStructuredContent('key2', 'value2'); - - expect($factory->getStructuredContent())->toEqual(['key1' => 'value1', 'key2' => 'value2']); -}); - it('merges multiple withStructuredContent calls', function (): void { $factory = (new ResponseFactory(Response::text('Hello'))) ->withStructuredContent(['key1' => 'value1']) diff --git a/tests/Unit/Server/Concerns/HasStructuredContentTest.php b/tests/Unit/Server/Concerns/HasStructuredContentTest.php index b6849074..5602802c 100644 --- a/tests/Unit/Server/Concerns/HasStructuredContentTest.php +++ b/tests/Unit/Server/Concerns/HasStructuredContentTest.php @@ -21,36 +21,6 @@ public function getStructuredContent(): ?array ]); }); -it('can set structured content with key-value signature', function (): void { - $object = new class - { - use HasStructuredContent; - - public function getStructuredContent(): ?array - { - return $this->structuredContent; - } - }; - - $object->setStructuredContent('key1', 'value1'); - $object->setStructuredContent('key2', 'value2'); - - expect($object->getStructuredContent())->toEqual([ - 'key1' => 'value1', - 'key2' => 'value2', - ]); -}); - -it('throws exception when using key-value signature without value', function (): void { - $object = new class - { - use HasStructuredContent; - }; - - expect(fn () => $object->setStructuredContent('key1')) - ->toThrow(InvalidArgumentException::class, 'Value is required when using key-value signature.'); -}); - it('merges structured content into base array', function (): void { $object = new class { From 43c2c0bb69fc8702216b957c1ec71b1ee457a8bf Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sun, 23 Nov 2025 20:57:58 +0530 Subject: [PATCH 12/15] Fix CI --- src/Server/Concerns/HasStructuredContent.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Server/Concerns/HasStructuredContent.php b/src/Server/Concerns/HasStructuredContent.php index aeab7490..54e5cd07 100644 --- a/src/Server/Concerns/HasStructuredContent.php +++ b/src/Server/Concerns/HasStructuredContent.php @@ -4,8 +4,6 @@ namespace Laravel\Mcp\Server\Concerns; -use InvalidArgumentException; - trait HasStructuredContent { /** From c7fabe14f67efbf66d0e7b9051cbeaf2d0a60cc1 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 24 Nov 2025 16:10:22 +0530 Subject: [PATCH 13/15] Update output schema handling to check for 'properties' key --- src/Server/Tool.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Server/Tool.php b/src/Server/Tool.php index f79cf2f6..c18385a6 100644 --- a/src/Server/Tool.php +++ b/src/Server/Tool.php @@ -88,7 +88,7 @@ public function toArray(): array 'annotations' => $annotations === [] ? (object) [] : $annotations, ]; - if ($outputSchema !== [] && $outputSchema !== ['type' => 'object']) { + if (isset($outputSchema['properties'])) { $result['outputSchema'] = $outputSchema; } From b65410c054197281c06c3cca6151d6919cf835e4 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 24 Nov 2025 16:38:57 +0530 Subject: [PATCH 14/15] Fix Test --- tests/Fixtures/ToolWithOutputSchema.php | 2 +- tests/Fixtures/WeatherTool.php | 47 ----------- tests/Unit/Methods/CallToolTest.php | 102 +----------------------- tests/Unit/Methods/ListToolsTest.php | 27 +++---- 4 files changed, 18 insertions(+), 160 deletions(-) delete mode 100644 tests/Fixtures/WeatherTool.php diff --git a/tests/Fixtures/ToolWithOutputSchema.php b/tests/Fixtures/ToolWithOutputSchema.php index c4550d67..b63677fd 100644 --- a/tests/Fixtures/ToolWithOutputSchema.php +++ b/tests/Fixtures/ToolWithOutputSchema.php @@ -33,7 +33,7 @@ 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(), + 'email' => $schema->string()->description('User email'), ]; } } diff --git a/tests/Fixtures/WeatherTool.php b/tests/Fixtures/WeatherTool.php deleted file mode 100644 index 075c4d06..00000000 --- a/tests/Fixtures/WeatherTool.php +++ /dev/null @@ -1,47 +0,0 @@ -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 eebc2f2a..4427b065 100644 --- a/tests/Unit/Methods/CallToolTest.php +++ b/tests/Unit/Methods/CallToolTest.php @@ -1,5 +1,6 @@ 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', @@ -605,12 +511,12 @@ expect(fn (): Generator|JsonRpcResponse => $method->handle($request, $context)) ->toThrow( - Laravel\Mcp\Server\Exceptions\JsonRpcException::class, + JsonRpcException::class, 'Missing [name] parameter.' ); }); -it('throws exception when tool is not found', function (): void { +it('throws an exception when the tool is not found', function (): void { $request = JsonRpcRequest::from([ 'jsonrpc' => '2.0', 'id' => 1, @@ -638,7 +544,7 @@ expect(fn (): Generator|JsonRpcResponse => $method->handle($request, $context)) ->toThrow( - Laravel\Mcp\Server\Exceptions\JsonRpcException::class, + JsonRpcException::class, 'Tool [non-existent-tool] not found.' ); }); diff --git a/tests/Unit/Methods/ListToolsTest.php b/tests/Unit/Methods/ListToolsTest.php index 41d9ac6e..1b42061b 100644 --- a/tests/Unit/Methods/ListToolsTest.php +++ b/tests/Unit/Methods/ListToolsTest.php @@ -9,7 +9,6 @@ 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++) { @@ -409,7 +408,7 @@ public function shouldRegister(Request $request): bool ]); }); -it('includes outputSchema when tool defines it', function (): void { +it('includes outputSchema when the tool defines it', function (): void { $request = JsonRpcRequest::from([ 'jsonrpc' => '2.0', 'id' => 1, @@ -425,7 +424,7 @@ public function shouldRegister(Request $request): bool instructions: 'Test instructions', maxPaginationLength: 50, defaultPaginationLength: 5, - tools: [WeatherTool::class], + tools: [ToolWithOutputSchema::class], resources: [], prompts: [], ); @@ -444,24 +443,24 @@ public function shouldRegister(Request $request): bool ->and($tool['outputSchema'])->toMatchArray([ 'type' => 'object', 'properties' => [ - 'temperature' => [ - 'type' => 'number', - 'description' => 'Temperature in celsius', + 'id' => [ + 'type' => 'integer', + 'description' => 'User ID', ], - 'conditions' => [ + 'name' => [ 'type' => 'string', - 'description' => 'Weather conditions description', + 'description' => 'User name', ], - 'humidity' => [ - 'type' => 'number', - 'description' => 'Humidity percentage', + 'email' => [ + 'type' => 'string', + 'description' => 'User email', ], ], - 'required' => ['temperature', 'conditions', 'humidity'], + 'required' => ['id', 'name'], ]); }); -it('excludes outputSchema when tool returns empty schema', function (): void { +it('excludes outputSchema when the tool returns empty schema', function (): void { $request = JsonRpcRequest::from([ 'jsonrpc' => '2.0', 'id' => 1, @@ -570,7 +569,7 @@ public function outputSchema(\Illuminate\JsonSchema\JsonSchema $schema): array ->toHaveKeys(['type', 'properties', 'required']) ->and($outputSchema['type'])->toBe('object') ->and($outputSchema['required'])->toBeArray() - ->toContain('id', 'name', 'email') + ->toContain('id', 'name') ->and($outputSchema['properties'])->toHaveKeys(['id', 'name', 'email']) ->and($outputSchema['properties']['id'])->toMatchArray([ 'type' => 'integer', From 0c0fa7860f87bd5fec2cc7ea5f5c65dd7e57ca04 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 24 Nov 2025 16:47:15 +0530 Subject: [PATCH 15/15] Fix Test --- tests/Unit/Methods/ListToolsTest.php | 51 ---------------------------- tests/Unit/Tools/ToolTest.php | 21 ------------ 2 files changed, 72 deletions(-) diff --git a/tests/Unit/Methods/ListToolsTest.php b/tests/Unit/Methods/ListToolsTest.php index 1b42061b..607cfbdc 100644 --- a/tests/Unit/Methods/ListToolsTest.php +++ b/tests/Unit/Methods/ListToolsTest.php @@ -533,54 +533,3 @@ public function outputSchema(\Illuminate\JsonSchema\JsonSchema $schema): array ->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') - ->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/Tools/ToolTest.php b/tests/Unit/Tools/ToolTest.php index 19358dca..262ef458 100644 --- a/tests/Unit/Tools/ToolTest.php +++ b/tests/Unit/Tools/ToolTest.php @@ -115,27 +115,6 @@ ->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