From e382dc2925cb36948451256ef1f72f058db13eff Mon Sep 17 00:00:00 2001 From: Andrew Minion Date: Fri, 10 Oct 2025 13:12:26 -0500 Subject: [PATCH 1/5] 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 a64457e..4fbbd47 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 e65bad3..4b54c79 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 d9484d7..7939b01 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 2/5] 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 4b54c79..6440a86 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 3f55746..be9e0d4 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 d8c72c4..06dc1f3 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 0656697..75b6118 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 3/5] 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 4fbbd47..4f76a82 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 0000000..7e39bfd --- /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 6440a86..cb84dcd 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 0000000..f35fd49 --- /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 be9e0d4..3f55746 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 06dc1f3..d8c72c4 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 75b6118..f708a22 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 7939b01..c1f487f 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 4/5] 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 0000000..bac5194 --- /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 5/5] 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 cb84dcd..60b825c 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 f708a22..97bf17e 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',