Skip to content

Commit 32ae2f1

Browse files
authored
Restore opt-in structured content handling for tools (#87)
* feat: add opt-in structured content flag for tools * Fix styling * docs(stubs): Update tool.stub documentation for $autoStructuredOutput history and behavior * refactor(server): Extract JSON encoding logic into a private helper method in ToolsCallHandler --------- Co-authored-by: kargnas <[email protected]>
1 parent c8c85f5 commit 32ae2f1

File tree

6 files changed

+245
-17
lines changed

6 files changed

+245
-17
lines changed

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636

3737
Version 1.5.0 focuses on structured tool output, richer prompt support, and improved discoverability across the MCP protocol:
3838

39-
- **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.
39+
- **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.
4040
- **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.
4141
- **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.
4242
- **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()) {
802802
// Proceed with validated $arguments['userId'] and $arguments['includeDetails']
803803
```
804804

805+
#### Automatic structuredContent opt-in for array responses (v1.5+)
806+
807+
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.
808+
809+
To enable the new behaviour on an existing tool, declare the property on your class:
810+
811+
```php
812+
class OrderLookupTool implements ToolInterface
813+
{
814+
protected bool $autoStructuredOutput = true;
815+
816+
public function execute(array $arguments): array
817+
{
818+
// Returning an array now fills the `structuredContent` field automatically.
819+
return [
820+
'orderId' => $arguments['id'],
821+
'status' => 'shipped',
822+
];
823+
}
824+
}
825+
```
826+
827+
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.
828+
805829
#### Formatting flat tool results as CSV or Markdown (v1.5.0+)
806830

807831
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.

src/Server/Request/ToolsCallHandler.php

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ public function execute(string $method, ?array $params = null): array
6565
$arguments = $params['arguments'] ?? [];
6666
$result = $tool->execute($arguments);
6767

68+
$autoStructuredOutput = false;
69+
if (property_exists($tool, 'autoStructuredOutput')) {
70+
$autoStructuredOutput = (bool) (function () {
71+
return $this->autoStructuredOutput;
72+
})->call($tool);
73+
}
74+
6875
$preparedResult = $result instanceof ToolResponse
6976
? $result->toArray()
7077
: $result;
@@ -81,17 +88,23 @@ public function execute(string $method, ?array $params = null): array
8188
return $preparedResult;
8289
}
8390

84-
try {
85-
json_encode($preparedResult, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
86-
} catch (JsonException $exception) {
87-
throw new JsonRpcErrorException(
88-
message: 'Failed to encode tool result as JSON: '.$exception->getMessage(),
89-
code: JsonRpcErrorCode::INTERNAL_ERROR
90-
);
91+
if ($autoStructuredOutput) {
92+
$this->encodeJson($preparedResult);
93+
94+
return [
95+
'structuredContent' => $preparedResult,
96+
];
9197
}
9298

99+
$text = $this->encodeJson($preparedResult);
100+
93101
return [
94-
'structuredContent' => $preparedResult,
102+
'content' => [
103+
[
104+
'type' => 'text',
105+
'text' => $text,
106+
],
107+
],
95108
];
96109
}
97110

@@ -106,14 +119,7 @@ public function execute(string $method, ?array $params = null): array
106119
];
107120
}
108121

109-
try {
110-
$text = json_encode($preparedResult, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
111-
} catch (JsonException $exception) {
112-
throw new JsonRpcErrorException(
113-
message: 'Failed to encode tool result as JSON: '.$exception->getMessage(),
114-
code: JsonRpcErrorCode::INTERNAL_ERROR
115-
);
116-
}
122+
$text = $this->encodeJson($preparedResult);
117123

118124
return [
119125
'content' => [
@@ -129,4 +135,19 @@ public function execute(string $method, ?array $params = null): array
129135
];
130136
}
131137
}
138+
139+
/**
140+
* Ensure results remain JSON serializable while providing consistent error handling.
141+
*/
142+
private function encodeJson(mixed $value): string
143+
{
144+
try {
145+
return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
146+
} catch (JsonException $exception) {
147+
throw new JsonRpcErrorException(
148+
message: 'Failed to encode tool result as JSON: '.$exception->getMessage(),
149+
code: JsonRpcErrorCode::INTERNAL_ERROR
150+
);
151+
}
152+
}
132153
}

src/stubs/tool.stub

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,21 @@ use OPGG\LaravelMcpServer\Services\ToolService\ToolInterface;
4343
*/
4444
class {{ className }} implements ToolInterface
4545
{
46+
/**
47+
* Opt into automatic structuredContent conversion for array responses.
48+
*
49+
* HISTORY:
50+
* - Introduced in v1.5.0 alongside the MCP 2025-06-18 structured payload spec.
51+
* - Tools created before v1.5 omitted this property and kept legacy JSON-string outputs.
52+
*
53+
* BEHAVIOR:
54+
* - true (stub default): associative arrays returned from execute() are emitted via `structuredContent`-key.
55+
* - false: arrays are JSON-encoded and wrapped under the text-based `content`-key list for backwards compatibility.
56+
*
57+
* Returning a ToolResponse bypasses the flag entirely, so you can mix structured payloads with rich text manually.
58+
*/
59+
protected bool $autoStructuredOutput = true;
60+
4661
/**
4762
* OPTIONAL: Determines if this tool requires streaming (SSE) instead of standard HTTP.
4863
*
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
namespace OPGG\LaravelMcpServer\Tests\Fixtures\Tools;
4+
5+
use OPGG\LaravelMcpServer\Services\ToolService\ToolInterface;
6+
7+
class AutoStructuredArrayTool implements ToolInterface
8+
{
9+
/**
10+
* Opt into automatic structuredContent detection for array payloads.
11+
*/
12+
protected bool $autoStructuredOutput = true;
13+
14+
public function name(): string
15+
{
16+
return 'auto-structured-array-tool';
17+
}
18+
19+
public function description(): string
20+
{
21+
return 'Returns an array that should be emitted via structuredContent.';
22+
}
23+
24+
public function inputSchema(): array
25+
{
26+
return [
27+
'type' => 'object',
28+
'properties' => [],
29+
];
30+
}
31+
32+
public function annotations(): array
33+
{
34+
return [];
35+
}
36+
37+
public function execute(array $arguments): array
38+
{
39+
return [
40+
'status' => 'ok',
41+
'echo' => $arguments,
42+
];
43+
}
44+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace OPGG\LaravelMcpServer\Tests\Fixtures\Tools;
4+
5+
use OPGG\LaravelMcpServer\Services\ToolService\ToolInterface;
6+
7+
class LegacyArrayTool implements ToolInterface
8+
{
9+
public function name(): string
10+
{
11+
return 'legacy-array-tool';
12+
}
13+
14+
public function description(): string
15+
{
16+
return 'Returns a simple associative array for backward compatibility tests.';
17+
}
18+
19+
public function inputSchema(): array
20+
{
21+
return [
22+
'type' => 'object',
23+
'properties' => [],
24+
];
25+
}
26+
27+
public function annotations(): array
28+
{
29+
return [];
30+
}
31+
32+
public function execute(array $arguments): array
33+
{
34+
return [
35+
'status' => 'ok',
36+
'echo' => $arguments,
37+
];
38+
}
39+
}

tests/Http/StreamableHttpTest.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
use OPGG\LaravelMcpServer\Server\MCPServer;
44
use OPGG\LaravelMcpServer\Services\ToolService\ToolRepository;
5+
use OPGG\LaravelMcpServer\Tests\Fixtures\Tools\AutoStructuredArrayTool;
6+
use OPGG\LaravelMcpServer\Tests\Fixtures\Tools\LegacyArrayTool;
57
use OPGG\LaravelMcpServer\Tests\Fixtures\Tools\TabularChampionsTool;
68

79
test('streamable http GET returns method not allowed', function () {
@@ -43,6 +45,89 @@
4345
->toContain('HelloWorld `Tester` developer');
4446
});
4547

48+
test('legacy array tool keeps payload in content by default', function () {
49+
$originalTools = config('mcp-server.tools');
50+
$tools = $originalTools;
51+
$tools[] = LegacyArrayTool::class;
52+
config()->set('mcp-server.tools', array_values(array_unique($tools)));
53+
54+
app()->forgetInstance(ToolRepository::class);
55+
app()->forgetInstance(MCPServer::class);
56+
57+
$payload = [
58+
'jsonrpc' => '2.0',
59+
'id' => 12,
60+
'method' => 'tools/call',
61+
'params' => [
62+
'name' => 'legacy-array-tool',
63+
'arguments' => [
64+
'foo' => 'bar',
65+
],
66+
],
67+
];
68+
69+
$response = $this->postJson('/mcp', $payload);
70+
71+
$response->assertStatus(200);
72+
$data = $response->json('result');
73+
74+
expect($data)->toHaveKey('content');
75+
expect($data)->not->toHaveKey('structuredContent');
76+
expect($data['content'][0]['type'])->toBe('text');
77+
78+
$decoded = json_decode($data['content'][0]['text'], true, 512, JSON_THROW_ON_ERROR);
79+
expect($decoded)->toBe([
80+
'status' => 'ok',
81+
'echo' => [
82+
'foo' => 'bar',
83+
],
84+
]);
85+
86+
config()->set('mcp-server.tools', $originalTools);
87+
app()->forgetInstance(ToolRepository::class);
88+
app()->forgetInstance(MCPServer::class);
89+
});
90+
91+
test('tools can opt into automatic structuredContent detection', function () {
92+
$originalTools = config('mcp-server.tools');
93+
$tools = $originalTools;
94+
$tools[] = AutoStructuredArrayTool::class;
95+
config()->set('mcp-server.tools', array_values(array_unique($tools)));
96+
97+
app()->forgetInstance(ToolRepository::class);
98+
app()->forgetInstance(MCPServer::class);
99+
100+
$payload = [
101+
'jsonrpc' => '2.0',
102+
'id' => 13,
103+
'method' => 'tools/call',
104+
'params' => [
105+
'name' => 'auto-structured-array-tool',
106+
'arguments' => [
107+
'alpha' => 'beta',
108+
],
109+
],
110+
];
111+
112+
$response = $this->postJson('/mcp', $payload);
113+
114+
$response->assertStatus(200);
115+
$data = $response->json('result');
116+
117+
expect($data)->not->toHaveKey('content');
118+
expect($data)->toHaveKey('structuredContent');
119+
expect($data['structuredContent'])->toBe([
120+
'status' => 'ok',
121+
'echo' => [
122+
'alpha' => 'beta',
123+
],
124+
]);
125+
126+
config()->set('mcp-server.tools', $originalTools);
127+
app()->forgetInstance(ToolRepository::class);
128+
app()->forgetInstance(MCPServer::class);
129+
});
130+
46131
test('notification returns HTTP 202 with no body', function () {
47132
$payload = [
48133
'jsonrpc' => '2.0',

0 commit comments

Comments
 (0)