diff --git a/src/Response.php b/src/Response.php index a64457e..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; @@ -58,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); 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 e65bad3..60b825c 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,13 +62,39 @@ 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()), - ]; + 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(); + + if ($structuredContent?->isNotEmpty()) { + return [ + 'content' => [ + [ + 'type' => 'text', + 'text' => $structuredContent->toJson(), + ], + ], + 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), + 'structuredContent' => $structuredContent->all(), + ]; + } + + return [ + 'content' => $content?->all(), + 'isError' => $responses->contains(fn (Response $response): bool => $response->isError()), + ]; + }; } } 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/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), + ]); +}); diff --git a/tests/Unit/Resources/CallToolTest.php b/tests/Unit/Resources/CallToolTest.php index 0656697..97bf17e 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; @@ -102,6 +103,58 @@ ]); }); +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/ResponseTest.php b/tests/Unit/ResponseTest.php index d9484d7..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']); @@ -122,3 +124,26 @@ $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::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); +});