Skip to content

Commit 3bf8a1b

Browse files
committed
Add tabular response helpers for MCP tools
1 parent 7fb139c commit 3bf8a1b

File tree

7 files changed

+380
-7
lines changed

7 files changed

+380
-7
lines changed

README.md

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

1049+
### Returning Tabular Tool Output as CSV and Markdown
1050+
1051+
Some MCP tools naturally return flat tabular data (e.g. `lol_list_champions`).
1052+
From v1.x you can attach lightweight metadata to your tool response and the
1053+
server will automatically add CSV and Markdown table representations for the
1054+
client. This works out-of-the-box via the
1055+
`OPGG\LaravelMcpServer\Services\ToolService\Concerns\ProvidesTabularResponses`
1056+
trait.
1057+
1058+
```php
1059+
use OPGG\LaravelMcpServer\Services\ToolService\Concerns\ProvidesTabularResponses;
1060+
1061+
class ChampionListTool implements ToolInterface
1062+
{
1063+
use ProvidesTabularResponses;
1064+
1065+
public function execute(array $arguments): array
1066+
{
1067+
$rows = [
1068+
['id' => 1, 'name' => 'Garen', 'role' => 'Fighter'],
1069+
['id' => 2, 'name' => 'Ahri', 'role' => 'Mage'],
1070+
];
1071+
1072+
$payload = ['items' => $rows];
1073+
1074+
// Adds __mcp_tabular metadata so the MCP response includes:
1075+
// 1. JSON (original payload)
1076+
// 2. CSV (with mimeType text/csv)
1077+
// 3. Markdown table (with mimeType text/markdown)
1078+
return $this->withTabularResponse($payload, $rows);
1079+
}
1080+
}
1081+
```
1082+
1083+
The trait also offers helpers like `tabularToCsv()` and `tabularToMarkdown()` in
1084+
case you need direct access to the generated strings. For advanced scenarios you
1085+
can customise the delimiter (`$tabularCsvDelimiter`) or disable Markdown output
1086+
(`$tabularIncludeMarkdownTable = false`).
1087+
10491088
### Working with Resources
10501089

10511090
Resources expose data from your server that can be read by MCP clients. They are

src/Server/Request/ToolsCallHandler.php

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use OPGG\LaravelMcpServer\Exceptions\JsonRpcErrorException;
88
use OPGG\LaravelMcpServer\Protocol\Handlers\RequestHandler;
99
use OPGG\LaravelMcpServer\Services\ToolService\ToolRepository;
10+
use OPGG\LaravelMcpServer\Utils\TabularDataFormatter;
1011

1112
class ToolsCallHandler extends RequestHandler
1213
{
@@ -63,15 +64,55 @@ public function execute(string $method, ?array $params = null): array
6364
$arguments = $params['arguments'] ?? [];
6465
$result = $tool->execute($arguments);
6566

67+
$tabularMeta = null;
68+
if (is_array($result) && array_key_exists(TabularDataFormatter::META_KEY, $result)) {
69+
$tabularMeta = $result[TabularDataFormatter::META_KEY] ?? null;
70+
unset($result[TabularDataFormatter::META_KEY]);
71+
}
72+
6673
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-
],
74+
$content = [
75+
[
76+
'type' => 'text',
77+
'text' => is_string($result) ? $result : json_encode($result, JSON_UNESCAPED_UNICODE),
7378
],
7479
];
80+
81+
if (is_array($tabularMeta)) {
82+
$rows = $tabularMeta['rows'] ?? [];
83+
$headers = $tabularMeta['headers'] ?? null;
84+
$delimiter = is_string($tabularMeta['delimiter'] ?? null)
85+
? $tabularMeta['delimiter']
86+
: ',';
87+
$includeMarkdown = (bool) ($tabularMeta['include_markdown'] ?? true);
88+
89+
if (TabularDataFormatter::isTabular($rows, $headers)) {
90+
$resolvedHeaders = TabularDataFormatter::resolveHeaders($rows, $headers);
91+
$normalizedRows = TabularDataFormatter::normalizeRows($rows, $resolvedHeaders);
92+
93+
$content[] = [
94+
'type' => 'text',
95+
'text' => TabularDataFormatter::toCsv($normalizedRows, $resolvedHeaders, $delimiter),
96+
'annotations' => [
97+
'mimeType' => 'text/csv',
98+
],
99+
];
100+
101+
if ($includeMarkdown) {
102+
$content[] = [
103+
'type' => 'text',
104+
'text' => TabularDataFormatter::toMarkdown($normalizedRows, $resolvedHeaders),
105+
'annotations' => [
106+
'mimeType' => 'text/markdown',
107+
],
108+
];
109+
}
110+
}
111+
}
112+
113+
return [
114+
'content' => $content,
115+
];
75116
} else {
76117
return [
77118
'result' => $result,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace OPGG\LaravelMcpServer\Services\ToolService\Concerns;
4+
5+
use OPGG\LaravelMcpServer\Utils\TabularDataFormatter;
6+
7+
/**
8+
* Helper trait for MCP tools that need to expose flat tabular data.
9+
*
10+
* This trait centralises helper methods and configuration flags so tools can
11+
* easily opt-in to CSV/Markdown conversions without re-implementing the
12+
* formatting logic.
13+
*/
14+
trait ProvidesTabularResponses
15+
{
16+
/**
17+
* Array key added to tool responses to signal the presence of tabular data.
18+
*/
19+
protected string $tabularMetaKey = TabularDataFormatter::META_KEY;
20+
21+
/**
22+
* Default delimiter used when creating CSV output.
23+
*/
24+
protected string $tabularCsvDelimiter = ',';
25+
26+
/**
27+
* Controls whether a Markdown table should be generated alongside the CSV.
28+
*/
29+
protected bool $tabularIncludeMarkdownTable = true;
30+
31+
/**
32+
* Attach tabular metadata to an existing response payload.
33+
*/
34+
protected function withTabularResponse(
35+
array $response,
36+
array $rows,
37+
?array $headers = null,
38+
?string $delimiter = null,
39+
?bool $includeMarkdown = null,
40+
): array {
41+
return array_merge($response, $this->tabularMeta($rows, $headers, $delimiter, $includeMarkdown));
42+
}
43+
44+
/**
45+
* Build only the tabular metadata array so it can be merged manually when required.
46+
*/
47+
protected function tabularMeta(
48+
array $rows,
49+
?array $headers = null,
50+
?string $delimiter = null,
51+
?bool $includeMarkdown = null,
52+
): array {
53+
return [
54+
$this->tabularMetaKey => [
55+
'rows' => $rows,
56+
'headers' => $headers,
57+
'delimiter' => $delimiter ?? $this->tabularCsvDelimiter,
58+
'include_markdown' => $includeMarkdown ?? $this->tabularIncludeMarkdownTable,
59+
],
60+
];
61+
}
62+
63+
/**
64+
* Convert the provided rows to a CSV string using the configured delimiter.
65+
*/
66+
protected function tabularToCsv(array $rows, ?array $headers = null, ?string $delimiter = null): string
67+
{
68+
$resolvedHeaders = TabularDataFormatter::resolveHeaders($rows, $headers);
69+
$normalizedRows = TabularDataFormatter::normalizeRows($rows, $resolvedHeaders);
70+
71+
return TabularDataFormatter::toCsv($normalizedRows, $resolvedHeaders, $delimiter ?? $this->tabularCsvDelimiter);
72+
}
73+
74+
/**
75+
* Convert the provided rows to a Markdown table string.
76+
*/
77+
protected function tabularToMarkdown(array $rows, ?array $headers = null): string
78+
{
79+
$resolvedHeaders = TabularDataFormatter::resolveHeaders($rows, $headers);
80+
$normalizedRows = TabularDataFormatter::normalizeRows($rows, $resolvedHeaders);
81+
82+
return TabularDataFormatter::toMarkdown($normalizedRows, $resolvedHeaders);
83+
}
84+
}

src/Services/ToolService/Examples/HelloWorldTool.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
use OPGG\LaravelMcpServer\Exceptions\Enums\JsonRpcErrorCode;
77
use OPGG\LaravelMcpServer\Exceptions\JsonRpcErrorException;
88
use OPGG\LaravelMcpServer\Services\ToolService\ToolInterface;
9+
use OPGG\LaravelMcpServer\Services\ToolService\Concerns\ProvidesTabularResponses;
910

1011
class HelloWorldTool implements ToolInterface
1112
{
13+
use ProvidesTabularResponses;
14+
1215
public function isStreaming(): bool
1316
{
1417
return false;
@@ -54,9 +57,11 @@ public function execute(array $arguments): array
5457

5558
$name = $arguments['name'] ?? 'MCP';
5659

57-
return [
60+
$payload = [
5861
'name' => $name,
5962
'message' => "Hello, HelloWorld `{$name}` developer.",
6063
];
64+
65+
return $this->withTabularResponse($payload, [$payload]);
6166
}
6267
}

src/Utils/TabularDataFormatter.php

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
3+
namespace OPGG\LaravelMcpServer\Utils;
4+
5+
use DateTimeInterface;
6+
use InvalidArgumentException;
7+
8+
class TabularDataFormatter
9+
{
10+
public const META_KEY = '__mcp_tabular';
11+
12+
/**
13+
* Determine if the supplied rows represent flat tabular data.
14+
*/
15+
public static function isTabular(array $rows, ?array $headers = null): bool
16+
{
17+
if ($headers !== null) {
18+
foreach ($headers as $header) {
19+
if (! is_string($header)) {
20+
return false;
21+
}
22+
}
23+
}
24+
25+
foreach ($rows as $row) {
26+
if (! is_array($row)) {
27+
return false;
28+
}
29+
30+
foreach ($row as $value) {
31+
if (is_array($value) || is_object($value)) {
32+
return false;
33+
}
34+
}
35+
}
36+
37+
return true;
38+
}
39+
40+
/**
41+
* Resolve headers from provided arguments or derive them from the first row.
42+
*/
43+
public static function resolveHeaders(array $rows, ?array $headers = null): array
44+
{
45+
if ($headers !== null) {
46+
return array_map(static fn ($header) => (string) $header, array_values($headers));
47+
}
48+
49+
$firstRow = $rows[0] ?? null;
50+
if ($firstRow === null) {
51+
return [];
52+
}
53+
54+
if (! is_array($firstRow)) {
55+
throw new InvalidArgumentException('Tabular rows must be arrays.');
56+
}
57+
58+
if (array_is_list($firstRow)) {
59+
return array_map(static fn (int $index) => 'column_'.($index + 1), array_keys($firstRow));
60+
}
61+
62+
return array_map(static fn ($header) => (string) $header, array_keys($firstRow));
63+
}
64+
65+
/**
66+
* Normalize rows to contain values for every header and ensure scalar output.
67+
*/
68+
public static function normalizeRows(array $rows, array $headers): array
69+
{
70+
return array_map(static function (array $row) use ($headers): array {
71+
$normalized = [];
72+
foreach ($headers as $header) {
73+
$normalized[$header] = self::stringifyValue($row[$header] ?? null);
74+
}
75+
76+
return $normalized;
77+
}, $rows);
78+
}
79+
80+
/**
81+
* Convert normalized rows to a CSV string.
82+
*/
83+
public static function toCsv(array $rows, array $headers, string $delimiter = ','): string
84+
{
85+
$stream = fopen('php://temp', 'r+');
86+
if ($stream === false) {
87+
throw new InvalidArgumentException('Unable to open temporary stream for CSV generation.');
88+
}
89+
90+
if ($headers !== []) {
91+
fputcsv($stream, $headers, $delimiter);
92+
}
93+
94+
foreach ($rows as $row) {
95+
fputcsv($stream, array_values($row), $delimiter);
96+
}
97+
98+
rewind($stream);
99+
100+
$csv = stream_get_contents($stream) ?: '';
101+
fclose($stream);
102+
103+
return $csv;
104+
}
105+
106+
/**
107+
* Convert normalized rows to a Markdown table.
108+
*/
109+
public static function toMarkdown(array $rows, array $headers): string
110+
{
111+
if ($headers === []) {
112+
return '';
113+
}
114+
115+
$lines = [];
116+
$lines[] = '| '.implode(' | ', array_map([self::class, 'escapeMarkdownCell'], $headers)).' |';
117+
$lines[] = '| '.implode(' | ', array_fill(0, count($headers), '---')).' |';
118+
119+
foreach ($rows as $row) {
120+
$cells = [];
121+
foreach ($headers as $header) {
122+
$cells[] = self::escapeMarkdownCell($row[$header] ?? '');
123+
}
124+
125+
$lines[] = '| '.implode(' | ', $cells).' |';
126+
}
127+
128+
return implode(PHP_EOL, $lines);
129+
}
130+
131+
protected static function escapeMarkdownCell(string $value): string
132+
{
133+
$escaped = str_replace(['|', PHP_EOL, "\r"], ['\\|', '<br>', ''], $value);
134+
135+
return trim($escaped);
136+
}
137+
138+
protected static function stringifyValue(mixed $value): string
139+
{
140+
if ($value === null) {
141+
return '';
142+
}
143+
144+
if (is_bool($value)) {
145+
return $value ? 'true' : 'false';
146+
}
147+
148+
if ($value instanceof DateTimeInterface) {
149+
return $value->format(DATE_ATOM);
150+
}
151+
152+
if (is_scalar($value)) {
153+
return (string) $value;
154+
}
155+
156+
return json_encode($value, JSON_UNESCAPED_UNICODE) ?: '';
157+
}
158+
}

0 commit comments

Comments
 (0)