package github import ( "context" "encoding/csv" "encoding/json" "strings" "testing" "github.com/github/github-mcp-server/pkg/inventory" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCSVOutputAppliedToDefaultListTools(t *testing.T) { listTool := testCSVOutputTool("list_things", `[{"number":1}]`) getTool := testCSVOutputTool("get_thing", `{"number":1}`) tools := withCSVOutput([]inventory.ServerTool{listTool, getTool}) require.Len(t, tools, 2) // CSV mode does not introduce variants or change tool gating; both tools // remain visible regardless of feature flag state. for _, csvOutputEnabled := range []bool{false, true} { inv := buildCSVOutputInventory(t, tools, csvOutputEnabled) available := inv.AvailableTools(context.Background()) require.Len(t, available, 2) listing := requireToolByName(t, available, "list_things") assert.Empty(t, listing.FeatureFlagEnable) assert.Empty(t, listing.FeatureFlagDisable) getting := requireToolByName(t, available, "get_thing") assert.Empty(t, getting.FeatureFlagEnable) assert.Empty(t, getting.FeatureFlagDisable) } } func TestCSVOutputAppliesToFlagGatedListTools(t *testing.T) { enabledOnly := testCSVOutputTool("list_things", `[{"number":1}]`) enabledOnly.FeatureFlagEnable = FeatureFlagFileBlame disabledOnly := testCSVOutputTool("list_legacy_things", `[{"number":2}]`) disabledOnly.FeatureFlagDisable = []string{FeatureFlagFileBlame} tools := withCSVOutput([]inventory.ServerTool{enabledOnly, disabledOnly}) require.Len(t, tools, 2) // Both flag-gated variants get the CSV wrapper; the per-request flag filter // decides which one actually registers, and the runtime csv_output check // decides whether the wrapper converts the response. deps := newCSVOutputTestDeps(true) for _, tool := range tools { result, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest()) require.NoError(t, err) assert.Contains(t, textResult(t, result), "number\n") } } func TestCSVOutputOnlyAppliesToDefaultToolsets(t *testing.T) { nonDefaultListTool := testCSVOutputToolWithToolset("list_discussions", `[{"number":1}]`, ToolsetMetadataDiscussions) tools := withCSVOutput([]inventory.ServerTool{nonDefaultListTool}) require.Len(t, tools, 1) // Non-default toolset list tools are not wrapped: even with the flag on, // the response stays in JSON form. deps := newCSVOutputTestDeps(true) result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest()) require.NoError(t, err) assert.JSONEq(t, `[{"number":1}]`, textResult(t, result)) } func TestCSVOutputDoesNotExposeFormatParameter(t *testing.T) { tools := withCSVOutput([]inventory.ServerTool{testCSVOutputTool("list_things", `[{"number":1}]`)}) require.Len(t, tools, 1) schema, ok := tools[0].Tool.InputSchema.(*jsonschema.Schema) require.True(t, ok) assert.NotContains(t, schema.Properties, "output_format") } func TestCSVOutputConvertsJSONTextToCSVWhenFlagOn(t *testing.T) { tools := withCSVOutput([]inventory.ServerTool{ testCSVOutputTool("list_things", `[ { "number": 1, "body": "first line\n\tsecond line", "labels": ["bug", "help wanted"], "user": {"login": "octocat"} } ]`), }) require.Len(t, tools, 1) deps := newCSVOutputTestDeps(true) result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest()) require.NoError(t, err) require.NotNil(t, result) require.False(t, result.IsError) assert.NotContains(t, textResult(t, result), "#") records := readCSVResult(t, result) require.Len(t, records, 2) row := csvRow(t, records[0], records[1]) assert.Equal(t, "first line second line", row["body"]) assert.Equal(t, "bug;help wanted", row["labels"]) assert.Equal(t, "1", row["number"]) assert.Equal(t, "octocat", row["user.login"]) } func TestCSVOutputPreservesOriginalJSONWhenFlagOff(t *testing.T) { const jsonResponse = `[{"number":1,"user":{"login":"octocat"}}]` tools := withCSVOutput([]inventory.ServerTool{testCSVOutputTool("list_things", jsonResponse)}) require.Len(t, tools, 1) deps := newCSVOutputTestDeps(false) result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest()) require.NoError(t, err) require.NotNil(t, result) require.Len(t, result.Content, 1) text, ok := result.Content[0].(*mcp.TextContent) require.True(t, ok) assert.JSONEq(t, jsonResponse, text.Text) } func TestCSVOutputVariantMovesMetadataToPreamble(t *testing.T) { csvText, err := jsonTextToCSV(`{ "issues": [ {"number": 1, "title": "First issue"} ], "pageInfo": { "endCursor": "cursor-1", "hasNextPage": true }, "totalCount": 2 }`) require.NoError(t, err) assert.Contains(t, csvText, "# pageInfo.endCursor: cursor-1\n") assert.Contains(t, csvText, "# pageInfo.hasNextPage: true\n") assert.Contains(t, csvText, "# totalCount: 2\n\n") records := readCSVText(t, csvText) require.Len(t, records, 2) row := csvRow(t, records[0], records[1]) assert.Equal(t, "1", row["number"]) assert.Equal(t, "First issue", row["title"]) assert.NotContains(t, row, "pageInfo.endCursor") assert.NotContains(t, row, "totalCount") } func TestJSONTextToCSVFlattensPrimaryRows(t *testing.T) { csvText, err := jsonTextToCSV(`{ "discussions": [ { "number": 5, "title": "Discussion tools testing", "category": {"name": "Q&A"}, "user": {"login": "octocat"} } ] }`) require.NoError(t, err) records := readCSVText(t, csvText) require.Len(t, records, 2) row := csvRow(t, records[0], records[1]) assert.Equal(t, "Q&A", row["category.name"]) assert.Equal(t, "5", row["number"]) assert.Equal(t, "Discussion tools testing", row["title"]) assert.Equal(t, "octocat", row["user.login"]) } func TestJSONTextToCSVFindsPrimaryRowsOneLevelDeeper(t *testing.T) { csvText, err := jsonTextToCSV(`{ "issues": { "nodes": [ {"number": 5, "title": "Nested issue"} ], "pageInfo": {"hasNextPage": false}, "totalCount": 1 } }`) require.NoError(t, err) assert.Contains(t, csvText, "# issues.pageInfo.hasNextPage: false\n") assert.Contains(t, csvText, "# issues.totalCount: 1\n\n") records := readCSVText(t, csvText) require.Len(t, records, 2) row := csvRow(t, records[0], records[1]) assert.Equal(t, "5", row["number"]) assert.Equal(t, "Nested issue", row["title"]) } func TestJSONTextToCSVUsesSingleArrayAsPrimaryRows(t *testing.T) { csvText, err := jsonTextToCSV(`{ "results": [ {"number": 1, "title": "Single array result"} ], "pageInfo": {"hasNextPage": true} }`) require.NoError(t, err) assert.Contains(t, csvText, "# pageInfo.hasNextPage: true\n\n") records := readCSVText(t, csvText) require.Len(t, records, 2) row := csvRow(t, records[0], records[1]) assert.Equal(t, "1", row["number"]) assert.Equal(t, "Single array result", row["title"]) assert.NotContains(t, row, "pageInfo.hasNextPage") } func TestJSONTextToCSVFlattensRootObjectWithoutPrimaryRows(t *testing.T) { csvText, err := jsonTextToCSV(`{ "name": "summary", "pageInfo": {"hasNextPage": false}, "totalCount": 2 }`) require.NoError(t, err) assert.NotContains(t, csvText, "#") records := readCSVText(t, csvText) require.Len(t, records, 2) row := csvRow(t, records[0], records[1]) assert.Equal(t, "summary", row["name"]) assert.Equal(t, "false", row["pageInfo.hasNextPage"]) assert.Equal(t, "2", row["totalCount"]) } func TestJSONTextToCSVConvertsScalarToValueRow(t *testing.T) { csvText, err := jsonTextToCSV(`"plain value"`) require.NoError(t, err) records := readCSVText(t, csvText) require.Len(t, records, 2) row := csvRow(t, records[0], records[1]) assert.Equal(t, "plain value", row["value"]) } func TestJSONTextToCSVReturnsEmptyForEmptyArray(t *testing.T) { csvText, err := jsonTextToCSV(`[]`) require.NoError(t, err) assert.Empty(t, csvText) } func TestJSONTextToCSVReturnsEmptyForEmptyObject(t *testing.T) { csvText, err := jsonTextToCSV(`{}`) require.NoError(t, err) assert.Empty(t, csvText) } func TestJSONTextToCSVReturnsEmptyForOnlyEmptyNestedObjects(t *testing.T) { csvText, err := jsonTextToCSV(`{ "repository": { "owner": {} } }`) require.NoError(t, err) assert.Empty(t, csvText) } func TestJSONTextToCSVReturnsMetadataOnlyWhenRowsHaveNoColumns(t *testing.T) { csvText, err := jsonTextToCSV(`{ "items": [ {} ], "totalCount": 1 }`) require.NoError(t, err) assert.Equal(t, "# totalCount: 1\n\n", csvText) } func TestJSONTextToCSVFlattensAmbiguousArraysAsSingleRow(t *testing.T) { csvText, err := jsonTextToCSV(`{ "foo": ["a", "b"], "bar": ["c"] }`) require.NoError(t, err) assert.NotContains(t, csvText, "#") records := readCSVText(t, csvText) require.Len(t, records, 2) row := csvRow(t, records[0], records[1]) assert.Equal(t, "c", row["bar"]) assert.Equal(t, "a;b", row["foo"]) } func TestJSONTextToCSVUsesPreferredArrayWhenMultipleArraysExist(t *testing.T) { csvText, err := jsonTextToCSV(`{ "items": [ {"id": 1} ], "other": [ {"id": 2} ], "totalCount": 1 }`) require.NoError(t, err) assert.Contains(t, csvText, "# other: {\"id\":2}\n") assert.Contains(t, csvText, "# totalCount: 1\n\n") records := readCSVText(t, csvText) require.Len(t, records, 2) row := csvRow(t, records[0], records[1]) assert.Equal(t, "1", row["id"]) } func testCSVOutputTool(name string, response string) inventory.ServerTool { return testCSVOutputToolWithToolset(name, response, ToolsetMetadataRepos) } func testCSVOutputToolWithToolset(name string, response string, toolset inventory.ToolsetMetadata) inventory.ServerTool { return inventory.ServerTool{ Tool: mcp.Tool{ Name: name, Annotations: &mcp.ToolAnnotations{ ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{}, }, }, Toolset: toolset, HandlerFunc: func(_ any) mcp.ToolHandler { return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: response}, }, }, nil } }, } } func buildCSVOutputInventory(t *testing.T, tools []inventory.ServerTool, _ bool) *inventory.Inventory { t.Helper() inv, err := inventory.NewBuilder(). SetTools(tools). Build() require.NoError(t, err) return inv } func newCSVOutputTestDeps(csvOutputEnabled bool) ToolDependencies { return csvOutputTestDeps{stubDeps: stubDeps{obsv: stubExporters()}, csvOn: csvOutputEnabled} } type csvOutputTestDeps struct { stubDeps csvOn bool } func (d csvOutputTestDeps) IsFeatureEnabled(_ context.Context, flag string) bool { return flag == FeatureFlagCSVOutput && d.csvOn } func requireToolByName(t *testing.T, tools []inventory.ServerTool, name string) inventory.ServerTool { t.Helper() for _, tool := range tools { if tool.Tool.Name == name { return tool } } require.Failf(t, "tool not found", "tool %q not found", name) return inventory.ServerTool{} } func testCSVOutputRequest() *mcp.CallToolRequest { return &mcp.CallToolRequest{ Params: &mcp.CallToolParamsRaw{ Arguments: json.RawMessage(`{}`), }, } } func readCSVResult(t *testing.T, result *mcp.CallToolResult) [][]string { t.Helper() require.Len(t, result.Content, 1) text, ok := result.Content[0].(*mcp.TextContent) require.True(t, ok) return readCSVText(t, text.Text) } func textResult(t *testing.T, result *mcp.CallToolResult) string { t.Helper() require.Len(t, result.Content, 1) text, ok := result.Content[0].(*mcp.TextContent) require.True(t, ok) return text.Text } func readCSVText(t *testing.T, text string) [][]string { t.Helper() reader := csv.NewReader(strings.NewReader(text)) reader.Comment = '#' records, err := reader.ReadAll() require.NoError(t, err) return records } func csvRow(t *testing.T, headers []string, record []string) map[string]string { t.Helper() require.Len(t, record, len(headers)) row := make(map[string]string, len(headers)) for i, header := range headers { row[header] = record[i] } return row }