chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
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), " ")
|
||||
}
|
||||
Reference in New Issue
Block a user