412 lines
9.7 KiB
Go
412 lines
9.7 KiB
Go
package github
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/github/github-mcp-server/pkg/inventory"
|
|
"github.com/github/github-mcp-server/pkg/utils"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// Ordered by preference when a response wrapper contains multiple arrays.
|
|
var primaryCSVRowKeys = []string{
|
|
"items",
|
|
"issues",
|
|
"discussions",
|
|
"categories",
|
|
"labels",
|
|
"alerts",
|
|
"advisories",
|
|
"notifications",
|
|
"gists",
|
|
"repositories",
|
|
"commits",
|
|
"branches",
|
|
"tags",
|
|
"releases",
|
|
"users",
|
|
"teams",
|
|
"members",
|
|
"projects",
|
|
"nodes",
|
|
}
|
|
|
|
type csvOutputDocument struct {
|
|
metadata map[string]string
|
|
rows []map[string]string
|
|
}
|
|
|
|
// withCSVOutput wraps the handler of every default-toolset list_* tool so that,
|
|
// at request time, it checks the csv_output feature flag and converts the JSON
|
|
// text response to CSV when enabled. The tool's schema, name, and scope are
|
|
// unchanged — only the response payload format differs.
|
|
func withCSVOutput(tools []inventory.ServerTool) []inventory.ServerTool {
|
|
for i := range tools {
|
|
if !isCSVOutputTool(tools[i]) {
|
|
continue
|
|
}
|
|
tools[i].HandlerFunc = wrapHandlerWithCSVOutput(tools[i].HandlerFunc)
|
|
}
|
|
return tools
|
|
}
|
|
|
|
// isCSVOutputTool reports whether the given tool should have its handler
|
|
// wrapped to honor the csv_output feature flag. Wrapping happens at slice
|
|
// construction time, before the per-request feature-flag filter chooses which
|
|
// variant of a flag-gated tool to register, so flag-gated list_* tools are
|
|
// included on equal footing — only the live variant ever runs at request time.
|
|
func isCSVOutputTool(tool inventory.ServerTool) bool {
|
|
if !tool.Toolset.Default {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(tool.Tool.Name, "list_")
|
|
}
|
|
|
|
func wrapHandlerWithCSVOutput(next inventory.HandlerFunc) inventory.HandlerFunc {
|
|
return func(deps any) mcp.ToolHandler {
|
|
handler := next(deps)
|
|
csvDeps, _ := deps.(ToolDependencies)
|
|
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
result, err := handler(ctx, req)
|
|
if err != nil || result == nil || result.IsError {
|
|
return result, err
|
|
}
|
|
if csvDeps == nil || !csvDeps.IsFeatureEnabled(ctx, FeatureFlagCSVOutput) {
|
|
return result, nil
|
|
}
|
|
return convertJSONTextResultToCSV(result), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func convertJSONTextResultToCSV(result *mcp.CallToolResult) *mcp.CallToolResult {
|
|
if len(result.Content) != 1 {
|
|
return utils.NewToolResultError("failed to convert response to CSV: expected a single text content response")
|
|
}
|
|
|
|
text, ok := result.Content[0].(*mcp.TextContent)
|
|
if !ok {
|
|
return utils.NewToolResultError("failed to convert response to CSV: expected a text content response")
|
|
}
|
|
|
|
csvText, err := jsonTextToCSV(text.Text)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to convert response to CSV", err)
|
|
}
|
|
|
|
result.Content = []mcp.Content{&mcp.TextContent{Text: csvText}}
|
|
result.StructuredContent = nil
|
|
return result
|
|
}
|
|
|
|
func jsonTextToCSV(text string) (string, error) {
|
|
decoder := json.NewDecoder(strings.NewReader(text))
|
|
decoder.UseNumber()
|
|
|
|
var value any
|
|
if err := decoder.Decode(&value); err != nil {
|
|
return "", fmt.Errorf("failed to unmarshal JSON text: %w", err)
|
|
}
|
|
|
|
doc := csvDocument(value)
|
|
if len(doc.metadata) == 0 && len(doc.rows) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
writeCSVMetadata(&buf, doc.metadata)
|
|
if len(doc.rows) == 0 {
|
|
return buf.String(), nil
|
|
}
|
|
|
|
headers := csvHeaders(doc.rows)
|
|
if len(headers) == 0 {
|
|
return buf.String(), nil
|
|
}
|
|
|
|
writer := csv.NewWriter(&buf)
|
|
if err := writer.Write(headers); err != nil {
|
|
return "", fmt.Errorf("failed to write CSV header: %w", err)
|
|
}
|
|
|
|
for _, row := range doc.rows {
|
|
record := make([]string, len(headers))
|
|
for i, header := range headers {
|
|
record[i] = row[header]
|
|
}
|
|
if err := writer.Write(record); err != nil {
|
|
return "", fmt.Errorf("failed to write CSV row: %w", err)
|
|
}
|
|
}
|
|
|
|
writer.Flush()
|
|
if err := writer.Error(); err != nil {
|
|
return "", fmt.Errorf("failed to flush CSV: %w", err)
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
func csvDocument(value any) csvOutputDocument {
|
|
switch v := value.(type) {
|
|
case []any:
|
|
return csvOutputDocument{rows: csvRowsFromArray(v)}
|
|
case map[string]any:
|
|
if rows, metadata, ok := primaryRowsFromMap(v); ok {
|
|
return csvOutputDocument{
|
|
metadata: newFlattenedCSVRow(metadata),
|
|
rows: csvRowsFromArray(rows),
|
|
}
|
|
}
|
|
return csvOutputDocument{rows: []map[string]string{newFlattenedCSVRow(v)}}
|
|
default:
|
|
return csvOutputDocument{rows: []map[string]string{scalarCSVRow(v)}}
|
|
}
|
|
}
|
|
|
|
func primaryRowsFromMap(value map[string]any) ([]any, map[string]any, bool) {
|
|
if rows, path, ok := primaryRowsAtCurrentLevel(value); ok {
|
|
return rows, metadataWithoutPath(value, path), true
|
|
}
|
|
if rows, path, ok := primaryRowsOneLevelDown(value); ok {
|
|
return rows, metadataWithoutPath(value, path), true
|
|
}
|
|
return nil, nil, false
|
|
}
|
|
|
|
func primaryRowsAtCurrentLevel(value map[string]any) ([]any, []string, bool) {
|
|
if key, ok := preferredPrimaryRowKey(value); ok {
|
|
rows, _ := value[key].([]any)
|
|
return rows, []string{key}, true
|
|
}
|
|
if key, ok := singleArrayKey(value); ok {
|
|
rows, _ := value[key].([]any)
|
|
return rows, []string{key}, true
|
|
}
|
|
return nil, nil, false
|
|
}
|
|
|
|
func primaryRowsOneLevelDown(value map[string]any) ([]any, []string, bool) {
|
|
var matchedRows []any
|
|
var matchedPath []string
|
|
for key, raw := range value {
|
|
child, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
rows, path, ok := primaryRowsAtCurrentLevel(child)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if matchedPath != nil {
|
|
return nil, nil, false
|
|
}
|
|
matchedRows = rows
|
|
matchedPath = append([]string{key}, path...)
|
|
}
|
|
if matchedPath == nil {
|
|
return nil, nil, false
|
|
}
|
|
return matchedRows, matchedPath, true
|
|
}
|
|
|
|
func metadataWithoutPath(value map[string]any, path []string) map[string]any {
|
|
metadata := make(map[string]any, len(value))
|
|
for key, raw := range value {
|
|
if key != path[0] {
|
|
metadata[key] = raw
|
|
continue
|
|
}
|
|
|
|
if len(path) == 1 {
|
|
continue
|
|
}
|
|
child, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
childMetadata := metadataWithoutPath(child, path[1:])
|
|
if len(childMetadata) > 0 {
|
|
metadata[key] = childMetadata
|
|
}
|
|
}
|
|
return metadata
|
|
}
|
|
|
|
func csvRowsFromArray(values []any) []map[string]string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
|
|
rows := make([]map[string]string, 0, len(values))
|
|
for _, value := range values {
|
|
var row map[string]string
|
|
switch v := value.(type) {
|
|
case map[string]any:
|
|
row = make(map[string]string)
|
|
appendFlattenedCSVFields(row, v, "")
|
|
default:
|
|
row = scalarCSVRow(v)
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func writeCSVMetadata(buf *bytes.Buffer, metadata map[string]string) {
|
|
if len(metadata) == 0 {
|
|
return
|
|
}
|
|
|
|
headers := make([]string, 0, len(metadata))
|
|
for header := range metadata {
|
|
headers = append(headers, header)
|
|
}
|
|
sort.Strings(headers)
|
|
|
|
for _, header := range headers {
|
|
fmt.Fprintf(buf, "# %s: %s\n", header, normalizeCSVWhitespace(metadata[header]))
|
|
}
|
|
buf.WriteByte('\n')
|
|
}
|
|
|
|
func newFlattenedCSVRow(value map[string]any) map[string]string {
|
|
row := make(map[string]string)
|
|
appendFlattenedCSVFields(row, value, "")
|
|
return row
|
|
}
|
|
|
|
func appendFlattenedCSVFields(row map[string]string, value map[string]any, prefix string) {
|
|
if value == nil {
|
|
return
|
|
}
|
|
|
|
for key, raw := range value {
|
|
column := csvColumnName(prefix, key)
|
|
switch v := raw.(type) {
|
|
case map[string]any:
|
|
appendFlattenedCSVFields(row, v, column)
|
|
case []any:
|
|
row[column] = csvArrayValue(v)
|
|
default:
|
|
row[column] = csvColumnValue(column, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func csvHeaders(rows []map[string]string) []string {
|
|
headerSet := make(map[string]struct{})
|
|
for _, row := range rows {
|
|
for header := range row {
|
|
headerSet[header] = struct{}{}
|
|
}
|
|
}
|
|
|
|
headers := make([]string, 0, len(headerSet))
|
|
for header := range headerSet {
|
|
headers = append(headers, header)
|
|
}
|
|
sort.Strings(headers)
|
|
return headers
|
|
}
|
|
|
|
func csvColumnName(prefix, key string) string {
|
|
if prefix == "" {
|
|
return key
|
|
}
|
|
return prefix + "." + key
|
|
}
|
|
|
|
func preferredPrimaryRowKey(value map[string]any) (string, bool) {
|
|
for _, key := range primaryCSVRowKeys {
|
|
if _, ok := value[key].([]any); ok {
|
|
return key, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func singleArrayKey(value map[string]any) (string, bool) {
|
|
var arrayKey string
|
|
for key, raw := range value {
|
|
if _, ok := raw.([]any); !ok {
|
|
continue
|
|
}
|
|
if arrayKey != "" {
|
|
return "", false
|
|
}
|
|
arrayKey = key
|
|
}
|
|
if arrayKey == "" {
|
|
return "", false
|
|
}
|
|
return arrayKey, true
|
|
}
|
|
|
|
func csvColumnValue(column string, value any) string {
|
|
str := scalarCSVValue(value)
|
|
if isBodyColumn(column) {
|
|
return normalizeCSVWhitespace(str)
|
|
}
|
|
return str
|
|
}
|
|
|
|
func csvArrayValue(values []any) string {
|
|
if len(values) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Scalar arrays use semicolons for compactness. This is lossy if an
|
|
// element contains a semicolon; use JSON mode when exact reconstruction matters.
|
|
parts := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
switch value.(type) {
|
|
case map[string]any, []any:
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
parts = append(parts, scalarCSVValue(value))
|
|
} else {
|
|
parts = append(parts, string(encoded))
|
|
}
|
|
default:
|
|
parts = append(parts, scalarCSVValue(value))
|
|
}
|
|
}
|
|
return strings.Join(parts, ";")
|
|
}
|
|
|
|
func scalarCSVRow(value any) map[string]string {
|
|
return map[string]string{"value": scalarCSVValue(value)}
|
|
}
|
|
|
|
func scalarCSVValue(value any) string {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return v
|
|
case json.Number:
|
|
return v.String()
|
|
case bool:
|
|
if v {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
default:
|
|
return fmt.Sprint(v)
|
|
}
|
|
}
|
|
|
|
func isBodyColumn(column string) bool {
|
|
return column == "body" || strings.HasSuffix(column, ".body")
|
|
}
|
|
|
|
func normalizeCSVWhitespace(value string) string {
|
|
return strings.Join(strings.Fields(value), " ")
|
|
}
|