Skip to content

Commit 9a1fa46

Browse files
committed
Add pagination metadata to CSV output
1 parent 7b91e11 commit 9a1fa46

3 files changed

Lines changed: 308 additions & 55 deletions

File tree

docs/insiders-features.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,14 @@ MCP Apps requires a host that supports the [MCP Apps extension](https://modelcon
4949

5050
CSV output mode returns supported list tool responses as CSV instead of JSON. This is intended to reduce response context for agents when scanning or summarising lists of GitHub data.
5151

52-
CSV output applies only to default tools whose names start with `list_`, such as `list_issues`, `list_pull_requests`, `list_discussions`, and `list_commits`. It does not add new tools or expose a tool argument for selecting the format; the server controls the response format through the Insiders feature flag.
52+
CSV output applies only to tools in default toolsets whose names start with `list_`, such as `list_issues`, `list_pull_requests`, `list_commits`, and `list_branches`. It does not add new tools or expose a tool argument for selecting the format; the server controls the response format through the Insiders feature flag.
5353

5454
### Format
5555

5656
- Nested objects are flattened into dot-notation columns, for example `user.login`, `category.name`, or `head.ref`.
5757
- Arrays are represented as compact single-cell values joined with `;`.
5858
- `body` fields are whitespace-normalized so multiline Markdown does not expand a list response into many output lines.
59+
- Response metadata present in wrapped responses, such as `pageInfo.*` and `totalCount`, is emitted as `#`-prefixed lines before the CSV rows, followed by a blank line. Tools that return a root JSON array do not include metadata preamble lines.
5960

6061
### Enabling CSV output
6162

pkg/github/csv_output.go

Lines changed: 129 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"encoding/csv"
77
"encoding/json"
88
"fmt"
9-
"maps"
109
"sort"
1110
"strings"
1211

@@ -15,6 +14,7 @@ import (
1514
"github.com/modelcontextprotocol/go-sdk/mcp"
1615
)
1716

17+
// Ordered by preference when a response wrapper contains multiple arrays.
1818
var primaryCSVRowKeys = []string{
1919
"items",
2020
"issues",
@@ -34,6 +34,12 @@ var primaryCSVRowKeys = []string{
3434
"teams",
3535
"members",
3636
"projects",
37+
"nodes",
38+
}
39+
40+
type csvOutputDocument struct {
41+
metadata map[string]string
42+
rows []map[string]string
3743
}
3844

3945
func withCSVOutputVariants(tools []inventory.ServerTool) []inventory.ServerTool {
@@ -95,10 +101,9 @@ func convertJSONTextResultToCSV(result *mcp.CallToolResult) *mcp.CallToolResult
95101
return utils.NewToolResultErrorFromErr("failed to convert response to CSV", err)
96102
}
97103

98-
resultCopy := *result
99-
resultCopy.Content = []mcp.Content{&mcp.TextContent{Text: csvText}}
100-
resultCopy.StructuredContent = nil
101-
return &resultCopy
104+
result.Content = []mcp.Content{&mcp.TextContent{Text: csvText}}
105+
result.StructuredContent = nil
106+
return result
102107
}
103108

104109
func jsonTextToCSV(text string) (string, error) {
@@ -110,19 +115,24 @@ func jsonTextToCSV(text string) (string, error) {
110115
return "", fmt.Errorf("failed to unmarshal JSON text: %w", err)
111116
}
112117

113-
rows := csvRows(value)
114-
if len(rows) == 0 {
118+
doc := csvDocument(value)
119+
if len(doc.metadata) == 0 && len(doc.rows) == 0 {
115120
return "", nil
116121
}
117122

118-
headers := csvHeaders(rows)
119123
var buf bytes.Buffer
124+
writeCSVMetadata(&buf, doc.metadata)
125+
if len(doc.rows) == 0 {
126+
return buf.String(), nil
127+
}
128+
129+
headers := csvHeaders(doc.rows)
120130
writer := csv.NewWriter(&buf)
121131
if err := writer.Write(headers); err != nil {
122132
return "", fmt.Errorf("failed to write CSV header: %w", err)
123133
}
124134

125-
for _, row := range rows {
135+
for _, row := range doc.rows {
126136
record := make([]string, len(headers))
127137
for i, header := range headers {
128138
record[i] = row[header]
@@ -139,82 +149,129 @@ func jsonTextToCSV(text string) (string, error) {
139149
return buf.String(), nil
140150
}
141151

142-
func csvRows(value any) []map[string]string {
152+
func csvDocument(value any) csvOutputDocument {
143153
switch v := value.(type) {
144154
case []any:
145-
return csvRowsFromArray(v, nil)
155+
return csvOutputDocument{rows: csvRowsFromArray(v)}
146156
case map[string]any:
147157
if rows, metadata, ok := primaryRowsFromMap(v); ok {
148-
return csvRowsFromArray(rows, metadata)
158+
return csvOutputDocument{
159+
metadata: newFlattenedCSVRow(metadata),
160+
rows: csvRowsFromArray(rows),
161+
}
149162
}
150-
return []map[string]string{newFlattenedCSVRow(v)}
163+
return csvOutputDocument{rows: []map[string]string{newFlattenedCSVRow(v)}}
151164
default:
152-
return []map[string]string{{"value": scalarCSVValue(v)}}
165+
return csvOutputDocument{rows: []map[string]string{scalarCSVRow(v)}}
153166
}
154167
}
155168

156169
func primaryRowsFromMap(value map[string]any) ([]any, map[string]any, bool) {
170+
if rows, path, ok := primaryRowsAtCurrentLevel(value); ok {
171+
return rows, metadataWithoutPath(value, path), true
172+
}
173+
if rows, path, ok := primaryRowsOneLevelDown(value); ok {
174+
return rows, metadataWithoutPath(value, path), true
175+
}
176+
return nil, nil, false
177+
}
178+
179+
func primaryRowsAtCurrentLevel(value map[string]any) ([]any, []string, bool) {
157180
if key, ok := preferredPrimaryRowKey(value); ok {
158181
rows, _ := value[key].([]any)
159-
return rows, metadataWithoutKey(value, key), true
182+
return rows, []string{key}, true
183+
}
184+
if key, ok := singleArrayKey(value); ok {
185+
rows, _ := value[key].([]any)
186+
return rows, []string{key}, true
160187
}
188+
return nil, nil, false
189+
}
161190

162-
var arrayKeys []string
191+
func primaryRowsOneLevelDown(value map[string]any) ([]any, []string, bool) {
192+
var matchedRows []any
193+
var matchedPath []string
163194
for key, raw := range value {
164-
if _, ok := raw.([]any); ok {
165-
arrayKeys = append(arrayKeys, key)
195+
child, ok := raw.(map[string]any)
196+
if !ok {
197+
continue
198+
}
199+
rows, path, ok := primaryRowsAtCurrentLevel(child)
200+
if !ok {
201+
continue
166202
}
203+
if matchedPath != nil {
204+
return nil, nil, false
205+
}
206+
matchedRows = rows
207+
matchedPath = append([]string{key}, path...)
167208
}
168-
if len(arrayKeys) != 1 {
209+
if matchedPath == nil {
169210
return nil, nil, false
170211
}
171-
172-
key := arrayKeys[0]
173-
rows, _ := value[key].([]any)
174-
return rows, metadataWithoutKey(value, key), true
212+
return matchedRows, matchedPath, true
175213
}
176214

177-
func preferredPrimaryRowKey(value map[string]any) (string, bool) {
178-
for _, key := range primaryCSVRowKeys {
179-
if _, ok := value[key].([]any); ok {
180-
return key, true
181-
}
182-
}
183-
return "", false
184-
}
185-
186-
func metadataWithoutKey(value map[string]any, exclude string) map[string]any {
187-
metadata := make(map[string]any, len(value)-1)
215+
func metadataWithoutPath(value map[string]any, path []string) map[string]any {
216+
metadata := make(map[string]any, len(value))
188217
for key, raw := range value {
189-
if key != exclude {
218+
if key != path[0] {
190219
metadata[key] = raw
220+
continue
221+
}
222+
223+
if len(path) == 1 {
224+
continue
225+
}
226+
child, ok := raw.(map[string]any)
227+
if !ok {
228+
continue
229+
}
230+
childMetadata := metadataWithoutPath(child, path[1:])
231+
if len(childMetadata) > 0 {
232+
metadata[key] = childMetadata
191233
}
192234
}
193235
return metadata
194236
}
195237

196-
func csvRowsFromArray(values []any, metadata map[string]any) []map[string]string {
238+
func csvRowsFromArray(values []any) []map[string]string {
197239
if len(values) == 0 {
198240
return nil
199241
}
200242

201243
rows := make([]map[string]string, 0, len(values))
202-
metadataRow := newFlattenedCSVRow(metadata)
203244
for _, value := range values {
204-
row := make(map[string]string, len(metadataRow))
205-
maps.Copy(row, metadataRow)
206-
245+
var row map[string]string
207246
switch v := value.(type) {
208247
case map[string]any:
248+
row = make(map[string]string)
209249
appendFlattenedCSVFields(row, v, "")
210250
default:
211-
row["value"] = scalarCSVValue(v)
251+
row = scalarCSVRow(v)
212252
}
213253
rows = append(rows, row)
214254
}
215255
return rows
216256
}
217257

258+
func writeCSVMetadata(buf *bytes.Buffer, metadata map[string]string) {
259+
if len(metadata) == 0 {
260+
return
261+
}
262+
263+
headers := make([]string, 0, len(metadata))
264+
for header := range metadata {
265+
headers = append(headers, header)
266+
}
267+
sort.Strings(headers)
268+
269+
for _, header := range headers {
270+
fmt.Fprintf(buf, "# %s: %s\n", header, normalizeCSVWhitespace(metadata[header]))
271+
}
272+
buf.WriteByte('\n')
273+
}
274+
218275
func newFlattenedCSVRow(value map[string]any) map[string]string {
219276
row := make(map[string]string)
220277
appendFlattenedCSVFields(row, value, "")
@@ -226,15 +283,8 @@ func appendFlattenedCSVFields(row map[string]string, value map[string]any, prefi
226283
return
227284
}
228285

229-
keys := make([]string, 0, len(value))
230-
for key := range value {
231-
keys = append(keys, key)
232-
}
233-
sort.Strings(keys)
234-
235-
for _, key := range keys {
286+
for key, raw := range value {
236287
column := csvColumnName(prefix, key)
237-
raw := value[key]
238288
switch v := raw.(type) {
239289
case map[string]any:
240290
appendFlattenedCSVFields(row, v, column)
@@ -269,6 +319,32 @@ func csvColumnName(prefix, key string) string {
269319
return prefix + "." + key
270320
}
271321

322+
func preferredPrimaryRowKey(value map[string]any) (string, bool) {
323+
for _, key := range primaryCSVRowKeys {
324+
if _, ok := value[key].([]any); ok {
325+
return key, true
326+
}
327+
}
328+
return "", false
329+
}
330+
331+
func singleArrayKey(value map[string]any) (string, bool) {
332+
var arrayKey string
333+
for key, raw := range value {
334+
if _, ok := raw.([]any); !ok {
335+
continue
336+
}
337+
if arrayKey != "" {
338+
return "", false
339+
}
340+
arrayKey = key
341+
}
342+
if arrayKey == "" {
343+
return "", false
344+
}
345+
return arrayKey, true
346+
}
347+
272348
func csvColumnValue(column string, value any) string {
273349
str := scalarCSVValue(value)
274350
if isBodyColumn(column) {
@@ -301,6 +377,10 @@ func csvArrayValue(values []any) string {
301377
return strings.Join(parts, ";")
302378
}
303379

380+
func scalarCSVRow(value any) map[string]string {
381+
return map[string]string{"value": scalarCSVValue(value)}
382+
}
383+
304384
func scalarCSVValue(value any) string {
305385
switch v := value.(type) {
306386
case nil:

0 commit comments

Comments
 (0)