Skip to content

Commit ef58f37

Browse files
committed
Add tabular response helpers for tool outputs
1 parent 7fb139c commit ef58f37

File tree

7 files changed

+561
-2
lines changed

7 files changed

+561
-2
lines changed

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,6 +956,41 @@ if ($validator->fails()) {
956956
// Proceed with validated $arguments['userId'] and $arguments['includeDetails']
957957
```
958958

959+
#### Formatting flat tool results as CSV or Markdown (v1.5.0+)
960+
961+
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.
962+
963+
```php
964+
use OPGG\LaravelMcpServer\Services\ToolService\Concerns\FormatsTabularToolResponses;
965+
use OPGG\LaravelMcpServer\Services\ToolService\ToolInterface;
966+
use OPGG\LaravelMcpServer\Services\ToolService\ToolResponse;
967+
968+
class ChampionDirectoryTool implements ToolInterface
969+
{
970+
use FormatsTabularToolResponses;
971+
972+
public function name(): string { return 'champion-directory'; }
973+
public function description(): string { return 'Return champion metadata as tabular data.'; }
974+
public function inputSchema(): array { return []; }
975+
public function annotations(): array { return []; }
976+
977+
public function execute(array $arguments): ToolResponse
978+
{
979+
$rows = [
980+
['champion_id' => '1', 'key' => 'Annie', 'name' => 'Annie'],
981+
['champion_id' => '2', 'key' => 'Olaf', 'name' => 'Olaf'],
982+
];
983+
984+
return match ($arguments['format'] ?? 'csv') {
985+
'markdown' => $this->toolMarkdownTableResponse($rows),
986+
default => $this->toolCsvResponse($rows),
987+
};
988+
}
989+
}
990+
```
991+
992+
Under the hood, `ToolResponse::text($string, $mime)` builds the response payload and sets the correct MIME type for `tools/call` responses (`text/csv`, `text/markdown`, etc.). The trait also exposes `toCsv()` and `toMarkdownTable()` helper methods if you prefer to work with raw strings or need to attach custom metadata via `toolTextResponse()`.
993+
959994
**`annotations(): array`**
960995

961996
This method provides metadata about your tool's behavior and characteristics, following the official [MCP Tool Annotations specification](https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations). Annotations help MCP clients categorize tools, make informed decisions about tool approval, and provide appropriate user interfaces.

src/Server/Request/ToolsCallHandler.php

Lines changed: 15 additions & 2 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\Services\ToolService\ToolResponse;
1011

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

67+
$preparedResult = $result instanceof ToolResponse
68+
? $result->toArray()
69+
: $result;
70+
6671
if ($method === 'tools/call') {
72+
if ($result instanceof ToolResponse) {
73+
return $preparedResult;
74+
}
75+
76+
if (is_array($preparedResult) && array_key_exists('content', $preparedResult)) {
77+
return $preparedResult;
78+
}
79+
6780
return [
6881
'content' => [
6982
[
7083
'type' => 'text',
71-
'text' => is_string($result) ? $result : json_encode($result, JSON_UNESCAPED_UNICODE),
84+
'text' => is_string($preparedResult) ? $preparedResult : json_encode($preparedResult, JSON_UNESCAPED_UNICODE),
7285
],
7386
],
7487
];
7588
} else {
7689
return [
77-
'result' => $result,
90+
'result' => $preparedResult,
7891
];
7992
}
8093
}
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
<?php
2+
3+
namespace OPGG\LaravelMcpServer\Services\ToolService\Concerns;
4+
5+
use InvalidArgumentException;
6+
use OPGG\LaravelMcpServer\Services\ToolService\ToolResponse;
7+
use Stringable;
8+
9+
/**
10+
* Helper utilities for converting flat tool data into tabular formats.
11+
*
12+
* Tools may opt-in to these helpers by using the trait in their implementation.
13+
*/
14+
trait FormatsTabularToolResponses
15+
{
16+
/**
17+
* Default column name used when normalising a list of scalar values.
18+
*/
19+
protected string $tabularScalarColumnName = 'value';
20+
21+
/**
22+
* CSV delimiter used when building CSV strings.
23+
*/
24+
protected string $csvDelimiter = ',';
25+
26+
/**
27+
* CSV enclosure used when building CSV strings.
28+
*/
29+
protected string $csvEnclosure = '"';
30+
31+
/**
32+
* CSV escape character used when building CSV strings.
33+
*/
34+
protected string $csvEscapeCharacter = '\\';
35+
36+
/**
37+
* Create a text-based tool response with a custom MIME type.
38+
*/
39+
protected function toolTextResponse(string $text, string $type = 'text', array $metadata = []): ToolResponse
40+
{
41+
return ToolResponse::text($text, $type, $metadata);
42+
}
43+
44+
/**
45+
* Convert the provided data into a CSV formatted tool response.
46+
*
47+
* @param array<int|string, mixed> $rows
48+
* @param array<int, string>|null $columns
49+
*/
50+
protected function toolCsvResponse(array $rows, ?array $columns = null, array $metadata = []): ToolResponse
51+
{
52+
return $this->toolTextResponse($this->toCsv($rows, $columns), 'text/csv', $metadata);
53+
}
54+
55+
/**
56+
* Convert the provided data into a Markdown table tool response.
57+
*
58+
* @param array<int|string, mixed> $rows
59+
* @param array<int, string>|null $columns
60+
*/
61+
protected function toolMarkdownTableResponse(array $rows, ?array $columns = null, array $metadata = []): ToolResponse
62+
{
63+
return $this->toolTextResponse($this->toMarkdownTable($rows, $columns), 'text/markdown', $metadata);
64+
}
65+
66+
/**
67+
* Generate a CSV string from the provided tabular data.
68+
*
69+
* @param array<int|string, mixed> $rows
70+
* @param array<int, string>|null $columns
71+
*/
72+
protected function toCsv(array $rows, ?array $columns = null): string
73+
{
74+
[$normalisedRows, $resolvedColumns] = $this->normaliseTabularRows($rows, $columns);
75+
76+
$handle = fopen('php://temp', 'r+');
77+
if ($handle === false) {
78+
throw new InvalidArgumentException('Unable to create temporary stream for CSV generation.');
79+
}
80+
81+
fputcsv($handle, $resolvedColumns, $this->csvDelimiter, $this->csvEnclosure, $this->csvEscapeCharacter);
82+
83+
foreach ($normalisedRows as $row) {
84+
$line = [];
85+
foreach ($resolvedColumns as $column) {
86+
$line[] = $row[$column] ?? '';
87+
}
88+
89+
fputcsv($handle, $line, $this->csvDelimiter, $this->csvEnclosure, $this->csvEscapeCharacter);
90+
}
91+
92+
rewind($handle);
93+
$csv = stream_get_contents($handle) ?: '';
94+
fclose($handle);
95+
96+
return $csv;
97+
}
98+
99+
/**
100+
* Generate a Markdown table from the provided tabular data.
101+
*
102+
* @param array<int|string, mixed> $rows
103+
* @param array<int, string>|null $columns
104+
*/
105+
protected function toMarkdownTable(array $rows, ?array $columns = null): string
106+
{
107+
[$normalisedRows, $resolvedColumns] = $this->normaliseTabularRows($rows, $columns);
108+
109+
$headerCells = array_map(fn (string $column) => $this->escapeMarkdownCell($column), $resolvedColumns);
110+
$header = '| '.implode(' | ', $headerCells)." |\n";
111+
$separator = '| '.implode(' | ', array_fill(0, count($resolvedColumns), '---'))." |\n";
112+
113+
$body = '';
114+
foreach ($normalisedRows as $row) {
115+
$cells = [];
116+
foreach ($resolvedColumns as $column) {
117+
$cells[] = $this->escapeMarkdownCell($row[$column] ?? '');
118+
}
119+
120+
$body .= '| '.implode(' | ', $cells)." |\n";
121+
}
122+
123+
return $header.$separator.$body;
124+
}
125+
126+
/**
127+
* Normalise arbitrary flat data into rows and columns suitable for tabular formats.
128+
*
129+
* @param array<int|string, mixed> $rows
130+
* @param array<int, string>|null $columns
131+
* @return array{0: list<array<string, string>>, 1: list<string>}
132+
*/
133+
private function normaliseTabularRows(array $rows, ?array $columns = null): array
134+
{
135+
$listOfRows = [];
136+
137+
if ($rows === []) {
138+
$listOfRows = [];
139+
} elseif (array_is_list($rows) && $rows !== []) {
140+
$listOfRows = $rows;
141+
} else {
142+
$listOfRows = [$rows];
143+
}
144+
145+
$resolvedColumns = $columns !== null ? array_values(array_map('strval', $columns)) : [];
146+
$normalisedRows = [];
147+
148+
foreach ($listOfRows as $row) {
149+
if (is_array($row)) {
150+
$normalisedRow = [];
151+
foreach ($row as $key => $value) {
152+
if (! $this->isTabularScalar($value)) {
153+
throw new InvalidArgumentException('Nested arrays or objects cannot be converted to tabular data.');
154+
}
155+
156+
$column = (string) $key;
157+
$normalisedRow[$column] = $this->stringifyTabularValue($value);
158+
159+
if ($columns === null && ! in_array($column, $resolvedColumns, true)) {
160+
$resolvedColumns[] = $column;
161+
}
162+
}
163+
$normalisedRows[] = $normalisedRow;
164+
} elseif ($this->isTabularScalar($row)) {
165+
$column = $columns[0] ?? $resolvedColumns[0] ?? $this->tabularScalarColumnName;
166+
if ($columns === null && $resolvedColumns === []) {
167+
$resolvedColumns[] = $column;
168+
}
169+
170+
$normalisedRows[] = [$column => $this->stringifyTabularValue($row)];
171+
} else {
172+
throw new InvalidArgumentException('Tabular conversion requires scalar values or flat associative arrays.');
173+
}
174+
}
175+
176+
if ($columns !== null && $columns === []) {
177+
throw new InvalidArgumentException('Columns cannot be an empty array.');
178+
}
179+
180+
if ($resolvedColumns === []) {
181+
$resolvedColumns[] = $this->tabularScalarColumnName;
182+
}
183+
184+
return [$normalisedRows, $resolvedColumns];
185+
}
186+
187+
/**
188+
* Determine if the provided value can be represented in a table cell.
189+
*/
190+
private function isTabularScalar(mixed $value): bool
191+
{
192+
return $value === null
193+
|| is_scalar($value)
194+
|| $value instanceof Stringable;
195+
}
196+
197+
/**
198+
* Convert scalar-like values into their string representation.
199+
*/
200+
private function stringifyTabularValue(mixed $value): string
201+
{
202+
if ($value instanceof Stringable) {
203+
return (string) $value;
204+
}
205+
206+
if (is_bool($value)) {
207+
return $value ? 'true' : 'false';
208+
}
209+
210+
if ($value === null) {
211+
return '';
212+
}
213+
214+
return (string) $value;
215+
}
216+
217+
/**
218+
* Escape Markdown control characters for table cells.
219+
*/
220+
private function escapeMarkdownCell(string $value): string
221+
{
222+
$escaped = str_replace('|', '\\|', $value);
223+
$escaped = str_replace(["\r\n", "\r", "\n"], '<br />', $escaped);
224+
225+
return $escaped;
226+
}
227+
}

0 commit comments

Comments
 (0)