Skip to content

Commit af40446

Browse files
committed
Add tabular tool response formatting utilities
1 parent 7fb139c commit af40446

File tree

5 files changed

+420
-9
lines changed

5 files changed

+420
-9
lines changed

README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,65 @@ public function annotations(): array
10461046
}
10471047
```
10481048

1049+
### Automatic CSV & Markdown outputs for flat tool responses
1050+
1051+
When a tool returns a one-dimensional list of associative arrays (for example, the
1052+
`lol_list_champions` style payload that contains champion metadata), the MCP server now
1053+
automatically augments the `tools/call` response with CSV and Markdown table variants.
1054+
This makes it effortless for LLM clients to consume tabular data without additional
1055+
post-processing.
1056+
1057+
The behaviour is powered by the new `OPGG\\LaravelMcpServer\\Concerns\\FormatsTabularData`
1058+
trait. It normalises rows, guards against nested structures, and produces two extra
1059+
entries in the `content` array:
1060+
1061+
- `text/csv` containing the tabular export
1062+
- `text/markdown` with a ready-to-render table
1063+
1064+
For example, a tool that returns champion data like this:
1065+
1066+
```php
1067+
return [
1068+
['champion_id' => 266, 'champion_key' => 'Aatrox', 'champion_name' => 'Aatrox'],
1069+
['champion_id' => 103, 'champion_key' => 'Ahri', 'champion_name' => 'Ahri'],
1070+
];
1071+
```
1072+
1073+
Will produce the following additional response fragments:
1074+
1075+
- CSV header + rows (`champion_id,champion_key,champion_name,...`)
1076+
- Markdown table:
1077+
1078+
```markdown
1079+
| champion_id | champion_key | champion_name |
1080+
| --- | --- | --- |
1081+
| 266 | Aatrox | Aatrox |
1082+
| 103 | Ahri | Ahri |
1083+
```
1084+
1085+
Developers can also reuse the trait inside their own classes to customise behaviour or
1086+
generate tabular strings on demand:
1087+
1088+
```php
1089+
use OPGG\LaravelMcpServer\Concerns\FormatsTabularData;
1090+
1091+
class ChampionTool implements ToolInterface
1092+
{
1093+
use FormatsTabularData;
1094+
1095+
public function execute(array $arguments): array
1096+
{
1097+
$rows = ...; // fetch champions
1098+
1099+
// Access helper methods/overrides like $this->tabularCsvDelimiter here
1100+
return $rows;
1101+
}
1102+
}
1103+
```
1104+
1105+
You can override `$tabularCsvDelimiter`, `$tabularCsvEnclosure`, or even inspect
1106+
`tabularContentFormats()` if you need to adjust MIME types.
1107+
10491108
### Working with Resources
10501109

10511110
Resources expose data from your server that can be read by MCP clients. They are
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
<?php
2+
3+
namespace OPGG\LaravelMcpServer\Concerns;
4+
5+
use JsonSerializable;
6+
7+
/**
8+
* Helper utilities for converting flat tabular tool output into
9+
* multiple LLM friendly representations (JSON, CSV, Markdown).
10+
*/
11+
trait FormatsTabularData
12+
{
13+
/**
14+
* CSV delimiter character.
15+
*/
16+
protected string $tabularCsvDelimiter = ',';
17+
18+
/**
19+
* CSV enclosure character.
20+
*/
21+
protected string $tabularCsvEnclosure = '"';
22+
23+
/**
24+
* MIME type used for CSV responses.
25+
*/
26+
protected string $tabularCsvMimeType = 'text/csv';
27+
28+
/**
29+
* MIME type used for Markdown responses.
30+
*/
31+
protected string $tabularMarkdownMimeType = 'text/markdown';
32+
33+
/**
34+
* Returns the MIME types registered for tabular helper outputs.
35+
*
36+
* @return array<string, string>
37+
*/
38+
protected function tabularContentFormats(): array
39+
{
40+
return [
41+
'csv' => $this->tabularCsvMimeType,
42+
'markdown' => $this->tabularMarkdownMimeType,
43+
];
44+
}
45+
46+
/**
47+
* Builds additional content payload entries (CSV + Markdown) when a result
48+
* contains a 1-depth list of associative rows.
49+
*
50+
* @param mixed $data
51+
* @return array<int, array{type: string, text: string, mimeType: string}>
52+
*/
53+
protected function buildTabularContent(mixed $data): array
54+
{
55+
$rows = $this->resolveTabularRows($data);
56+
57+
if ($rows === null) {
58+
return [];
59+
}
60+
61+
return [
62+
[
63+
'type' => 'text',
64+
'text' => $this->convertTabularRowsToCsv($rows),
65+
'mimeType' => $this->tabularCsvMimeType,
66+
],
67+
[
68+
'type' => 'text',
69+
'text' => $this->convertTabularRowsToMarkdown($rows),
70+
'mimeType' => $this->tabularMarkdownMimeType,
71+
],
72+
];
73+
}
74+
75+
/**
76+
* Detects and normalises a flat list of tabular rows.
77+
*
78+
* @param mixed $data
79+
* @return array<int, array<string, scalar|null>>|null
80+
*/
81+
protected function resolveTabularRows(mixed $data): ?array
82+
{
83+
if (! is_array($data) || $data === []) {
84+
return null;
85+
}
86+
87+
if (! array_is_list($data)) {
88+
return null;
89+
}
90+
91+
$normalizedRows = [];
92+
$allKeys = [];
93+
94+
foreach ($data as $row) {
95+
$rowArray = $this->convertRowToArray($row);
96+
97+
if ($rowArray === null) {
98+
return null;
99+
}
100+
101+
foreach ($rowArray as $value) {
102+
if (is_array($value) || is_object($value)) {
103+
return null;
104+
}
105+
}
106+
107+
$normalizedRows[] = $rowArray;
108+
$allKeys = array_merge($allKeys, array_keys($rowArray));
109+
}
110+
111+
if ($normalizedRows === []) {
112+
return null;
113+
}
114+
115+
$orderedKeys = array_values(array_unique($allKeys));
116+
117+
if ($orderedKeys === []) {
118+
return null;
119+
}
120+
121+
return array_map(function (array $row) use ($orderedKeys): array {
122+
$ordered = [];
123+
foreach ($orderedKeys as $key) {
124+
$ordered[$key] = $row[$key] ?? null;
125+
}
126+
127+
return $ordered;
128+
}, $normalizedRows);
129+
}
130+
131+
/**
132+
* Converts an individual row into an associative array.
133+
*
134+
* @param mixed $row
135+
* @return array<string, mixed>|null
136+
*/
137+
protected function convertRowToArray(mixed $row): ?array
138+
{
139+
if (is_array($row)) {
140+
return $row;
141+
}
142+
143+
if (is_object($row)) {
144+
if ($row instanceof JsonSerializable) {
145+
$serialized = $row->jsonSerialize();
146+
147+
if (is_array($serialized)) {
148+
return $serialized;
149+
}
150+
}
151+
152+
$vars = get_object_vars($row);
153+
154+
if (! empty($vars)) {
155+
return $vars;
156+
}
157+
}
158+
159+
return null;
160+
}
161+
162+
/**
163+
* Converts a list of rows into CSV text.
164+
*
165+
* @param array<int, array<string, scalar|null>> $rows
166+
*/
167+
protected function convertTabularRowsToCsv(array $rows): string
168+
{
169+
$stream = fopen('php://temp', 'r+');
170+
171+
if ($stream === false) {
172+
return '';
173+
}
174+
175+
$headers = array_keys($rows[0]);
176+
fputcsv($stream, $headers, $this->tabularCsvDelimiter, $this->tabularCsvEnclosure);
177+
178+
foreach ($rows as $row) {
179+
$values = array_map(fn ($value) => $this->stringifyTabularValue($value), $row);
180+
fputcsv($stream, $values, $this->tabularCsvDelimiter, $this->tabularCsvEnclosure);
181+
}
182+
183+
rewind($stream);
184+
$csv = stream_get_contents($stream);
185+
fclose($stream);
186+
187+
if ($csv === false) {
188+
return '';
189+
}
190+
191+
return rtrim($csv, "\r\n");
192+
}
193+
194+
/**
195+
* Converts a list of rows into a Markdown table string.
196+
*
197+
* @param array<int, array<string, scalar|null>> $rows
198+
*/
199+
protected function convertTabularRowsToMarkdown(array $rows): string
200+
{
201+
$headers = array_keys($rows[0]);
202+
$headerLine = '| '.implode(' | ', array_map([$this, 'escapeMarkdownValue'], $headers)).' |';
203+
$separatorLine = '| '.implode(' | ', array_fill(0, count($headers), '---')).' |';
204+
205+
$lines = [$headerLine, $separatorLine];
206+
207+
foreach ($rows as $row) {
208+
$cells = array_map(function ($value) {
209+
return $this->escapeMarkdownValue($this->stringifyTabularValue($value));
210+
}, $row);
211+
212+
$lines[] = '| '.implode(' | ', $cells).' |';
213+
}
214+
215+
return implode(PHP_EOL, $lines);
216+
}
217+
218+
/**
219+
* Converts scalar/null values to their string representations.
220+
*/
221+
protected function stringifyTabularValue(mixed $value): string
222+
{
223+
if ($value === null) {
224+
return '';
225+
}
226+
227+
if (is_bool($value)) {
228+
return $value ? 'true' : 'false';
229+
}
230+
231+
if (is_scalar($value)) {
232+
return (string) $value;
233+
}
234+
235+
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
236+
237+
return $encoded === false ? '' : $encoded;
238+
}
239+
240+
/**
241+
* Escapes Markdown table control characters.
242+
*/
243+
protected function escapeMarkdownValue(string $value): string
244+
{
245+
$escaped = str_replace('|', '\\|', $value);
246+
$escaped = str_replace(["\r\n", "\r", "\n"], '<br />', $escaped);
247+
248+
return $escaped;
249+
}
250+
}

src/Server/Request/ToolsCallHandler.php

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace OPGG\LaravelMcpServer\Server\Request;
44

5+
use OPGG\LaravelMcpServer\Concerns\FormatsTabularData;
56
use OPGG\LaravelMcpServer\Enums\ProcessMessageType;
67
use OPGG\LaravelMcpServer\Exceptions\Enums\JsonRpcErrorCode;
78
use OPGG\LaravelMcpServer\Exceptions\JsonRpcErrorException;
@@ -10,6 +11,8 @@
1011

1112
class ToolsCallHandler extends RequestHandler
1213
{
14+
use FormatsTabularData;
15+
1316
protected const MESSAGE_TYPE = ProcessMessageType::HTTP;
1417

1518
protected const HANDLE_METHOD = ['tools/call', 'tools/execute'];
@@ -64,18 +67,36 @@ public function execute(string $method, ?array $params = null): array
6467
$result = $tool->execute($arguments);
6568

6669
if ($method === 'tools/call') {
67-
return [
68-
'content' => [
69-
[
70-
'type' => 'text',
71-
'text' => is_string($result) ? $result : json_encode($result, JSON_UNESCAPED_UNICODE),
72-
],
73-
],
70+
$text = is_string($result) ? $result : json_encode($result, JSON_UNESCAPED_UNICODE);
71+
if ($text === false) {
72+
$text = '';
73+
}
74+
75+
$primaryContent = [
76+
'type' => 'text',
77+
'text' => $text,
7478
];
75-
} else {
79+
80+
if (! is_string($result)) {
81+
$primaryContent['mimeType'] = 'application/json';
82+
} else {
83+
$primaryContent['mimeType'] = 'text/plain';
84+
}
85+
86+
$content = [$primaryContent];
87+
88+
$tabularContent = $this->buildTabularContent($result);
89+
if ($tabularContent !== []) {
90+
$content = array_merge($content, $tabularContent);
91+
}
92+
7693
return [
77-
'result' => $result,
94+
'content' => $content,
7895
];
7996
}
97+
98+
return [
99+
'result' => $result,
100+
];
80101
}
81102
}

0 commit comments

Comments
 (0)