From 75c4e691039dde01268d594543c8c54e94e1603e Mon Sep 17 00:00:00 2001 From: Sangrak Choi Date: Sun, 12 Oct 2025 17:37:46 +0900 Subject: [PATCH 1/4] feat: add opt-in structured content flag for tools --- README.md | 26 +++++- src/Server/Request/ToolsCallHandler.php | 31 ++++++- src/stubs/tool.stub | 5 ++ .../Tools/AutoStructuredArrayTool.php | 46 ++++++++++ tests/Fixtures/Tools/LegacyArrayTool.php | 39 +++++++++ tests/Http/StreamableHttpTest.php | 85 +++++++++++++++++++ 6 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 tests/Fixtures/Tools/AutoStructuredArrayTool.php create mode 100644 tests/Fixtures/Tools/LegacyArrayTool.php diff --git a/README.md b/README.md index 6356de9..5b84700 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Version 1.5.0 focuses on structured tool output, richer prompt support, and improved discoverability across the MCP protocol: -- **Structured tool responses** – Use `ToolResponse::structured()` to emit plain text and JSON payloads simultaneously. The server automatically publishes `structuredContent`, and `tools/call` now attaches structured metadata when returning arrays to satisfy the MCP 2025-06-18 specification. Tool interfaces optionally expose `title()` and `outputSchema()` so schema-aware clients can display richer results. +- **Structured tool responses** – Use `ToolResponse::structured()` to emit plain text and JSON payloads simultaneously. Existing tools keep returning JSON strings inside the `content` array for backwards compatibility, while new stubs expose a `$autoStructuredOutput = true` flag so array responses automatically populate `structuredContent` per the MCP 2025-06-18 specification. Tool interfaces optionally expose `title()` and `outputSchema()` so schema-aware clients can display richer results. - **Tabular response helpers** – The new `FormatsTabularToolResponses` trait converts array data into CSV or Markdown tables with consistent MIME typing. Example tools and Pest tests demonstrate column normalization, validation, and multi-format output generation for data-heavy workflows. - **Enhanced tool pagination & metadata** – Cursor-based pagination for `tools/list` scales to large catalogs, configurable via the `MCP_TOOLS_PAGE_SIZE` environment variable. The server advertises schema awareness and `listChanged` hints during capability negotiation, with integration tests covering `nextCursor` behavior. - **Prompt registry & generator** – A full prompt registry backed by configuration files powers the new `prompts/list` and `prompts/get` handlers. Developers can scaffold prompts using `php artisan make:mcp-prompt`, while the service provider surfaces prompt schemas inside the MCP handshake for immediate client discovery. @@ -802,6 +802,30 @@ if ($validator->fails()) { // Proceed with validated $arguments['userId'] and $arguments['includeDetails'] ``` +#### Automatic structuredContent opt-in for array responses (v1.5+) + +Laravel MCP Server 1.5 keeps backwards compatibility with legacy tools by leaving associative-array results as JSON strings under the `content` field. New installations created from the `make:mcp-tool` stub expose a `$autoStructuredOutput = true` property so array payloads are promoted into the `structuredContent` field automatically. + +To enable the new behaviour on an existing tool, declare the property on your class: + +```php +class OrderLookupTool implements ToolInterface +{ + protected bool $autoStructuredOutput = true; + + public function execute(array $arguments): array + { + // Returning an array now fills the `structuredContent` field automatically. + return [ + 'orderId' => $arguments['id'], + 'status' => 'shipped', + ]; + } +} +``` + +You can always bypass the flag by returning a `ToolResponse` instance directly—use `ToolResponse::structured()` when you need full control over both human-readable text and machine-readable metadata. + #### Formatting flat tool results as CSV or Markdown (v1.5.0+) When your tool needs to return structured tabular data—like the `lol_list_champions` example—you can opt into richer response formats by returning a `ToolResponse`. The new helper trait `OPGG\LaravelMcpServer\Services\ToolService\Concerns\FormatsTabularToolResponses` provides convenience methods to turn flat arrays into CSV strings or Markdown tables. Nothing is automatic: simply `use` the trait in tools that need it. diff --git a/src/Server/Request/ToolsCallHandler.php b/src/Server/Request/ToolsCallHandler.php index 2ffa605..b97b677 100644 --- a/src/Server/Request/ToolsCallHandler.php +++ b/src/Server/Request/ToolsCallHandler.php @@ -65,6 +65,13 @@ public function execute(string $method, ?array $params = null): array $arguments = $params['arguments'] ?? []; $result = $tool->execute($arguments); + $autoStructuredOutput = false; + if (property_exists($tool, 'autoStructuredOutput')) { + $autoStructuredOutput = (bool) (function () { + return $this->autoStructuredOutput; + })->call($tool); + } + $preparedResult = $result instanceof ToolResponse ? $result->toArray() : $result; @@ -81,8 +88,23 @@ public function execute(string $method, ?array $params = null): array return $preparedResult; } + if ($autoStructuredOutput) { + try { + json_encode($preparedResult, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + } catch (JsonException $exception) { + throw new JsonRpcErrorException( + message: 'Failed to encode tool result as JSON: '.$exception->getMessage(), + code: JsonRpcErrorCode::INTERNAL_ERROR + ); + } + + return [ + 'structuredContent' => $preparedResult, + ]; + } + try { - json_encode($preparedResult, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + $text = json_encode($preparedResult, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); } catch (JsonException $exception) { throw new JsonRpcErrorException( message: 'Failed to encode tool result as JSON: '.$exception->getMessage(), @@ -91,7 +113,12 @@ public function execute(string $method, ?array $params = null): array } return [ - 'structuredContent' => $preparedResult, + 'content' => [ + [ + 'type' => 'text', + 'text' => $text, + ], + ], ]; } diff --git a/src/stubs/tool.stub b/src/stubs/tool.stub index 5abea94..ba00bd5 100644 --- a/src/stubs/tool.stub +++ b/src/stubs/tool.stub @@ -43,6 +43,11 @@ use OPGG\LaravelMcpServer\Services\ToolService\ToolInterface; */ class {{ className }} implements ToolInterface { + /** + * Opt into automatic structuredContent conversion for array responses (v1.5+ default). + */ + protected bool $autoStructuredOutput = true; + /** * OPTIONAL: Determines if this tool requires streaming (SSE) instead of standard HTTP. * diff --git a/tests/Fixtures/Tools/AutoStructuredArrayTool.php b/tests/Fixtures/Tools/AutoStructuredArrayTool.php new file mode 100644 index 0000000..416a4d0 --- /dev/null +++ b/tests/Fixtures/Tools/AutoStructuredArrayTool.php @@ -0,0 +1,46 @@ + 'object', + 'properties' => [], + ]; + } + + public function annotations(): array + { + return []; + } + + public function execute(array $arguments): array + { + return [ + 'status' => 'ok', + 'echo' => $arguments, + ]; + } +} diff --git a/tests/Fixtures/Tools/LegacyArrayTool.php b/tests/Fixtures/Tools/LegacyArrayTool.php new file mode 100644 index 0000000..914eb7f --- /dev/null +++ b/tests/Fixtures/Tools/LegacyArrayTool.php @@ -0,0 +1,39 @@ + 'object', + 'properties' => [], + ]; + } + + public function annotations(): array + { + return []; + } + + public function execute(array $arguments): array + { + return [ + 'status' => 'ok', + 'echo' => $arguments, + ]; + } +} diff --git a/tests/Http/StreamableHttpTest.php b/tests/Http/StreamableHttpTest.php index 9982c7e..4107c9f 100644 --- a/tests/Http/StreamableHttpTest.php +++ b/tests/Http/StreamableHttpTest.php @@ -2,6 +2,8 @@ use OPGG\LaravelMcpServer\Server\MCPServer; use OPGG\LaravelMcpServer\Services\ToolService\ToolRepository; +use OPGG\LaravelMcpServer\Tests\Fixtures\Tools\AutoStructuredArrayTool; +use OPGG\LaravelMcpServer\Tests\Fixtures\Tools\LegacyArrayTool; use OPGG\LaravelMcpServer\Tests\Fixtures\Tools\TabularChampionsTool; test('streamable http GET returns method not allowed', function () { @@ -43,6 +45,89 @@ ->toContain('HelloWorld `Tester` developer'); }); +test('legacy array tool keeps payload in content by default', function () { + $originalTools = config('mcp-server.tools'); + $tools = $originalTools; + $tools[] = LegacyArrayTool::class; + config()->set('mcp-server.tools', array_values(array_unique($tools))); + + app()->forgetInstance(ToolRepository::class); + app()->forgetInstance(MCPServer::class); + + $payload = [ + 'jsonrpc' => '2.0', + 'id' => 12, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'legacy-array-tool', + 'arguments' => [ + 'foo' => 'bar', + ], + ], + ]; + + $response = $this->postJson('/mcp', $payload); + + $response->assertStatus(200); + $data = $response->json('result'); + + expect($data)->toHaveKey('content'); + expect($data)->not->toHaveKey('structuredContent'); + expect($data['content'][0]['type'])->toBe('text'); + + $decoded = json_decode($data['content'][0]['text'], true, 512, JSON_THROW_ON_ERROR); + expect($decoded)->toBe([ + 'status' => 'ok', + 'echo' => [ + 'foo' => 'bar', + ], + ]); + + config()->set('mcp-server.tools', $originalTools); + app()->forgetInstance(ToolRepository::class); + app()->forgetInstance(MCPServer::class); +}); + +test('tools can opt into automatic structuredContent detection', function () { + $originalTools = config('mcp-server.tools'); + $tools = $originalTools; + $tools[] = AutoStructuredArrayTool::class; + config()->set('mcp-server.tools', array_values(array_unique($tools))); + + app()->forgetInstance(ToolRepository::class); + app()->forgetInstance(MCPServer::class); + + $payload = [ + 'jsonrpc' => '2.0', + 'id' => 13, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'auto-structured-array-tool', + 'arguments' => [ + 'alpha' => 'beta', + ], + ], + ]; + + $response = $this->postJson('/mcp', $payload); + + $response->assertStatus(200); + $data = $response->json('result'); + + expect($data)->not->toHaveKey('content'); + expect($data)->toHaveKey('structuredContent'); + expect($data['structuredContent'])->toBe([ + 'status' => 'ok', + 'echo' => [ + 'alpha' => 'beta', + ], + ]); + + config()->set('mcp-server.tools', $originalTools); + app()->forgetInstance(ToolRepository::class); + app()->forgetInstance(MCPServer::class); +}); + test('notification returns HTTP 202 with no body', function () { $payload = [ 'jsonrpc' => '2.0', From b191618a6fd8eb136da4dbcd5779bd42cf400b98 Mon Sep 17 00:00:00 2001 From: kargnas <1438533+kargnas@users.noreply.github.com> Date: Sun, 12 Oct 2025 08:38:08 +0000 Subject: [PATCH 2/4] Fix styling --- tests/Fixtures/Tools/AutoStructuredArrayTool.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/Fixtures/Tools/AutoStructuredArrayTool.php b/tests/Fixtures/Tools/AutoStructuredArrayTool.php index 416a4d0..32dd17f 100644 --- a/tests/Fixtures/Tools/AutoStructuredArrayTool.php +++ b/tests/Fixtures/Tools/AutoStructuredArrayTool.php @@ -8,8 +8,6 @@ class AutoStructuredArrayTool implements ToolInterface { /** * Opt into automatic structuredContent detection for array payloads. - * - * @var bool */ protected bool $autoStructuredOutput = true; From e8477d220acfd7d28d55f03ae81d87b391b4c858 Mon Sep 17 00:00:00 2001 From: Sangrak Choi Date: Sun, 12 Oct 2025 17:47:44 +0900 Subject: [PATCH 3/4] docs(stubs): Update tool.stub documentation for $autoStructuredOutput history and behavior --- src/stubs/tool.stub | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/stubs/tool.stub b/src/stubs/tool.stub index ba00bd5..45d84c9 100644 --- a/src/stubs/tool.stub +++ b/src/stubs/tool.stub @@ -44,7 +44,17 @@ use OPGG\LaravelMcpServer\Services\ToolService\ToolInterface; class {{ className }} implements ToolInterface { /** - * Opt into automatic structuredContent conversion for array responses (v1.5+ default). + * Opt into automatic structuredContent conversion for array responses. + * + * HISTORY: + * - Introduced in v1.5.0 alongside the MCP 2025-06-18 structured payload spec. + * - Tools created before v1.5 omitted this property and kept legacy JSON-string outputs. + * + * BEHAVIOR: + * - true (stub default): associative arrays returned from execute() are emitted via `structuredContent`-key. + * - false: arrays are JSON-encoded and wrapped under the text-based `content`-key list for backwards compatibility. + * + * Returning a ToolResponse bypasses the flag entirely, so you can mix structured payloads with rich text manually. */ protected bool $autoStructuredOutput = true; From d041477cb3303a54b9306c4eab8da6d67407bd7a Mon Sep 17 00:00:00 2001 From: Sangrak Choi Date: Sun, 12 Oct 2025 17:47:52 +0900 Subject: [PATCH 4/4] refactor(server): Extract JSON encoding logic into a private helper method in ToolsCallHandler --- src/Server/Request/ToolsCallHandler.php | 42 +++++++++++-------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/src/Server/Request/ToolsCallHandler.php b/src/Server/Request/ToolsCallHandler.php index b97b677..01502a4 100644 --- a/src/Server/Request/ToolsCallHandler.php +++ b/src/Server/Request/ToolsCallHandler.php @@ -89,28 +89,14 @@ public function execute(string $method, ?array $params = null): array } if ($autoStructuredOutput) { - try { - json_encode($preparedResult, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); - } catch (JsonException $exception) { - throw new JsonRpcErrorException( - message: 'Failed to encode tool result as JSON: '.$exception->getMessage(), - code: JsonRpcErrorCode::INTERNAL_ERROR - ); - } + $this->encodeJson($preparedResult); return [ 'structuredContent' => $preparedResult, ]; } - try { - $text = json_encode($preparedResult, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); - } catch (JsonException $exception) { - throw new JsonRpcErrorException( - message: 'Failed to encode tool result as JSON: '.$exception->getMessage(), - code: JsonRpcErrorCode::INTERNAL_ERROR - ); - } + $text = $this->encodeJson($preparedResult); return [ 'content' => [ @@ -133,14 +119,7 @@ public function execute(string $method, ?array $params = null): array ]; } - try { - $text = json_encode($preparedResult, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); - } catch (JsonException $exception) { - throw new JsonRpcErrorException( - message: 'Failed to encode tool result as JSON: '.$exception->getMessage(), - code: JsonRpcErrorCode::INTERNAL_ERROR - ); - } + $text = $this->encodeJson($preparedResult); return [ 'content' => [ @@ -156,4 +135,19 @@ public function execute(string $method, ?array $params = null): array ]; } } + + /** + * Ensure results remain JSON serializable while providing consistent error handling. + */ + private function encodeJson(mixed $value): string + { + try { + return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + } catch (JsonException $exception) { + throw new JsonRpcErrorException( + message: 'Failed to encode tool result as JSON: '.$exception->getMessage(), + code: JsonRpcErrorCode::INTERNAL_ERROR + ); + } + } }