Skip to content

Commit d2ab3c4

Browse files
committed
Add CSV output for list tools under insiders mode
1 parent 2dab994 commit d2ab3c4

8 files changed

Lines changed: 615 additions & 11 deletions

File tree

cmd/github-mcp-server/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@ var (
127127
}
128128
}
129129

130+
var enabledFeatures []string
131+
if viper.IsSet("features") {
132+
if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil {
133+
return fmt.Errorf("failed to unmarshal features: %w", err)
134+
}
135+
}
136+
130137
ttl := viper.GetDuration("repo-access-cache-ttl")
131138
httpConfig := ghhttp.ServerConfig{
132139
Version: version,
@@ -146,6 +153,7 @@ var (
146153
EnabledTools: enabledTools,
147154
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
148155
ExcludeTools: excludeTools,
156+
EnabledFeatures: enabledFeatures,
149157
InsidersMode: viper.GetBool("insiders"),
150158
}
151159

docs/insiders-features.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,27 @@ MCP Apps requires a host that supports the [MCP Apps extension](https://modelcon
4242

4343
- **VS Code Insiders** — enable via the `chat.mcp.apps.enabled` setting
4444
- **Visual Studio Code** — enable via the `chat.mcp.apps.enabled` setting
45+
46+
---
47+
48+
## CSV output for list tools
49+
50+
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.
51+
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.
53+
54+
### Format
55+
56+
- Nested objects are flattened into dot-notation columns, for example `user.login`, `category.name`, or `head.ref`.
57+
- Arrays are represented as compact single-cell values joined with `;`.
58+
- `body` fields are whitespace-normalized so multiline Markdown does not expand a list response into many output lines.
59+
60+
### Enabling CSV output
61+
62+
CSV output is enabled by Insiders Mode. For local development, it can also be enabled explicitly with the `csv_output` feature flag:
63+
64+
```bash
65+
github-mcp-server stdio --features csv_output
66+
```
67+
68+
Because this changes list tool response shape, clients that require JSON list responses should avoid enabling this feature.

pkg/github/csv_output.go

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
package github
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/csv"
7+
"encoding/json"
8+
"fmt"
9+
"maps"
10+
"sort"
11+
"strings"
12+
13+
"github.com/github/github-mcp-server/pkg/inventory"
14+
"github.com/github/github-mcp-server/pkg/utils"
15+
"github.com/modelcontextprotocol/go-sdk/mcp"
16+
)
17+
18+
var primaryCSVRowKeys = []string{
19+
"items",
20+
"issues",
21+
"discussions",
22+
"categories",
23+
"labels",
24+
"alerts",
25+
"advisories",
26+
"notifications",
27+
"gists",
28+
"repositories",
29+
"commits",
30+
"branches",
31+
"tags",
32+
"releases",
33+
"users",
34+
"teams",
35+
"members",
36+
"projects",
37+
}
38+
39+
func withCSVOutputVariants(tools []inventory.ServerTool) []inventory.ServerTool {
40+
result := make([]inventory.ServerTool, 0, len(tools))
41+
for _, tool := range tools {
42+
if !isCSVOutputTool(tool) {
43+
result = append(result, tool)
44+
continue
45+
}
46+
47+
jsonOnly := tool
48+
jsonOnly.FeatureFlagDisable = FeatureFlagCSVOutput
49+
result = append(result, jsonOnly)
50+
51+
csvCapable := tool
52+
csvCapable.FeatureFlagEnable = FeatureFlagCSVOutput
53+
csvCapable.HandlerFunc = wrapHandlerWithCSVOutput(tool.HandlerFunc)
54+
result = append(result, csvCapable)
55+
}
56+
return result
57+
}
58+
59+
func isCSVOutputTool(tool inventory.ServerTool) bool {
60+
if !strings.HasPrefix(tool.Tool.Name, "list_") {
61+
return false
62+
}
63+
return tool.FeatureFlagEnable == "" && tool.FeatureFlagDisable == ""
64+
}
65+
66+
func wrapHandlerWithCSVOutput(next inventory.HandlerFunc) inventory.HandlerFunc {
67+
return func(deps any) mcp.ToolHandler {
68+
handler := next(deps)
69+
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
70+
result, err := handler(ctx, req)
71+
if err != nil || result == nil || result.IsError {
72+
return result, err
73+
}
74+
75+
return convertJSONTextResultToCSV(result), nil
76+
}
77+
}
78+
}
79+
80+
func convertJSONTextResultToCSV(result *mcp.CallToolResult) *mcp.CallToolResult {
81+
if len(result.Content) != 1 {
82+
return utils.NewToolResultError("failed to convert response to CSV: expected a single text content response")
83+
}
84+
85+
text, ok := result.Content[0].(*mcp.TextContent)
86+
if !ok {
87+
return utils.NewToolResultError("failed to convert response to CSV: expected a text content response")
88+
}
89+
90+
csvText, err := jsonTextToCSV(text.Text)
91+
if err != nil {
92+
return utils.NewToolResultErrorFromErr("failed to convert response to CSV", err)
93+
}
94+
95+
resultCopy := *result
96+
resultCopy.Content = []mcp.Content{&mcp.TextContent{Text: csvText}}
97+
resultCopy.StructuredContent = nil
98+
return &resultCopy
99+
}
100+
101+
func jsonTextToCSV(text string) (string, error) {
102+
decoder := json.NewDecoder(strings.NewReader(text))
103+
decoder.UseNumber()
104+
105+
var value any
106+
if err := decoder.Decode(&value); err != nil {
107+
return "", fmt.Errorf("failed to unmarshal JSON text: %w", err)
108+
}
109+
110+
rows := csvRows(value)
111+
if len(rows) == 0 {
112+
return "", nil
113+
}
114+
115+
headers := csvHeaders(rows)
116+
var buf bytes.Buffer
117+
writer := csv.NewWriter(&buf)
118+
if err := writer.Write(headers); err != nil {
119+
return "", fmt.Errorf("failed to write CSV header: %w", err)
120+
}
121+
122+
for _, row := range rows {
123+
record := make([]string, len(headers))
124+
for i, header := range headers {
125+
record[i] = row[header]
126+
}
127+
if err := writer.Write(record); err != nil {
128+
return "", fmt.Errorf("failed to write CSV row: %w", err)
129+
}
130+
}
131+
132+
writer.Flush()
133+
if err := writer.Error(); err != nil {
134+
return "", fmt.Errorf("failed to flush CSV: %w", err)
135+
}
136+
return buf.String(), nil
137+
}
138+
139+
func csvRows(value any) []map[string]string {
140+
switch v := value.(type) {
141+
case []any:
142+
return csvRowsFromArray(v, nil)
143+
case map[string]any:
144+
if rows, metadata, ok := primaryRowsFromMap(v); ok {
145+
return csvRowsFromArray(rows, metadata)
146+
}
147+
return []map[string]string{newFlattenedCSVRow(v)}
148+
default:
149+
return []map[string]string{{"value": scalarCSVValue(v)}}
150+
}
151+
}
152+
153+
func primaryRowsFromMap(value map[string]any) ([]any, map[string]any, bool) {
154+
if key, ok := preferredPrimaryRowKey(value); ok {
155+
rows, _ := value[key].([]any)
156+
return rows, metadataWithoutKey(value, key), true
157+
}
158+
159+
var arrayKeys []string
160+
for key, raw := range value {
161+
if _, ok := raw.([]any); ok {
162+
arrayKeys = append(arrayKeys, key)
163+
}
164+
}
165+
if len(arrayKeys) != 1 {
166+
return nil, nil, false
167+
}
168+
169+
key := arrayKeys[0]
170+
rows, _ := value[key].([]any)
171+
return rows, metadataWithoutKey(value, key), true
172+
}
173+
174+
func preferredPrimaryRowKey(value map[string]any) (string, bool) {
175+
for _, key := range primaryCSVRowKeys {
176+
if _, ok := value[key].([]any); ok {
177+
return key, true
178+
}
179+
}
180+
return "", false
181+
}
182+
183+
func metadataWithoutKey(value map[string]any, exclude string) map[string]any {
184+
metadata := make(map[string]any, len(value)-1)
185+
for key, raw := range value {
186+
if key != exclude {
187+
metadata[key] = raw
188+
}
189+
}
190+
return metadata
191+
}
192+
193+
func csvRowsFromArray(values []any, metadata map[string]any) []map[string]string {
194+
if len(values) == 0 {
195+
return nil
196+
}
197+
198+
rows := make([]map[string]string, 0, len(values))
199+
metadataRow := newFlattenedCSVRow(metadata)
200+
for _, value := range values {
201+
row := make(map[string]string, len(metadataRow))
202+
maps.Copy(row, metadataRow)
203+
204+
switch v := value.(type) {
205+
case map[string]any:
206+
appendFlattenedCSVFields(row, v, "")
207+
default:
208+
row["value"] = scalarCSVValue(v)
209+
}
210+
rows = append(rows, row)
211+
}
212+
return rows
213+
}
214+
215+
func newFlattenedCSVRow(value map[string]any) map[string]string {
216+
row := make(map[string]string)
217+
appendFlattenedCSVFields(row, value, "")
218+
return row
219+
}
220+
221+
func appendFlattenedCSVFields(row map[string]string, value map[string]any, prefix string) {
222+
if value == nil {
223+
return
224+
}
225+
226+
keys := make([]string, 0, len(value))
227+
for key := range value {
228+
keys = append(keys, key)
229+
}
230+
sort.Strings(keys)
231+
232+
for _, key := range keys {
233+
column := csvColumnName(prefix, key)
234+
raw := value[key]
235+
switch v := raw.(type) {
236+
case map[string]any:
237+
appendFlattenedCSVFields(row, v, column)
238+
case []any:
239+
row[column] = csvArrayValue(v)
240+
default:
241+
row[column] = csvColumnValue(column, v)
242+
}
243+
}
244+
}
245+
246+
func csvHeaders(rows []map[string]string) []string {
247+
headerSet := make(map[string]struct{})
248+
for _, row := range rows {
249+
for header := range row {
250+
headerSet[header] = struct{}{}
251+
}
252+
}
253+
254+
headers := make([]string, 0, len(headerSet))
255+
for header := range headerSet {
256+
headers = append(headers, header)
257+
}
258+
sort.Strings(headers)
259+
return headers
260+
}
261+
262+
func csvColumnName(prefix, key string) string {
263+
if prefix == "" {
264+
return key
265+
}
266+
return prefix + "." + key
267+
}
268+
269+
func csvColumnValue(column string, value any) string {
270+
str := scalarCSVValue(value)
271+
if isBodyColumn(column) {
272+
return normalizeCSVWhitespace(str)
273+
}
274+
return str
275+
}
276+
277+
func csvArrayValue(values []any) string {
278+
if len(values) == 0 {
279+
return ""
280+
}
281+
282+
// Scalar arrays use semicolons for compactness. This is lossy if an
283+
// element contains a semicolon; use JSON mode when exact reconstruction matters.
284+
parts := make([]string, 0, len(values))
285+
for _, value := range values {
286+
switch value.(type) {
287+
case map[string]any, []any:
288+
encoded, err := json.Marshal(value)
289+
if err != nil {
290+
parts = append(parts, scalarCSVValue(value))
291+
} else {
292+
parts = append(parts, string(encoded))
293+
}
294+
default:
295+
parts = append(parts, scalarCSVValue(value))
296+
}
297+
}
298+
return strings.Join(parts, ";")
299+
}
300+
301+
func scalarCSVValue(value any) string {
302+
switch v := value.(type) {
303+
case nil:
304+
return ""
305+
case string:
306+
return v
307+
case json.Number:
308+
return v.String()
309+
case bool:
310+
if v {
311+
return "true"
312+
}
313+
return "false"
314+
default:
315+
return fmt.Sprint(v)
316+
}
317+
}
318+
319+
func isBodyColumn(column string) bool {
320+
return column == "body" || strings.HasSuffix(column, ".body")
321+
}
322+
323+
func normalizeCSVWhitespace(value string) string {
324+
return strings.Join(strings.Fields(value), " ")
325+
}

0 commit comments

Comments
 (0)