From dca2621789c74a3db92ff6f9e179f034806ca2aa Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Tue, 19 May 2026 08:46:19 -0700 Subject: [PATCH 1/3] Add field_values to search_issues results --- pkg/github/issues.go | 138 ++++++++++++++++++++++++++++++++++++- pkg/github/issues_test.go | 94 +++++++++++++++++++++++++ pkg/github/search_utils.go | 49 +++++++------ 3 files changed, 260 insertions(+), 21 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index d7f6f31d0a..3aca133e60 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1047,7 +1047,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { if deps.GetFlags(ctx).InsidersMode { options = append(options, withSearchPostProcess(searchIssuesIFCPostProcess(deps))) } - result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues", options...) + result, err := searchIssuesHandler(ctx, deps, args, options...) return result, nil, err }) } @@ -1134,6 +1134,142 @@ func parseRepositoryURL(repoURL string) (string, string, bool) { return parts[0], parts[1], true } +// SearchIssueResult wraps a REST search hit with its custom issue field values, fetched in a follow-up GraphQL nodes() query. +type SearchIssueResult struct { + *github.Issue + FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"` +} + +// SearchIssuesResponse mirrors the REST IssuesSearchResult JSON shape and adds field_values +// per item, sourced from a single GraphQL nodes() round-trip. +type SearchIssuesResponse struct { + Total *int `json:"total_count,omitempty"` + IncompleteResults *bool `json:"incomplete_results,omitempty"` + Items []SearchIssueResult `json:"items"` +} + +// searchIssuesNodesQuery batches a nodes(ids:) lookup over the REST search results to retrieve +// each issue's custom field values in a single GraphQL request. +type searchIssuesNodesQuery struct { + Nodes []struct { + Issue struct { + ID githubv4.ID + IssueFieldValues struct { + Nodes []IssueFieldValueFragment + } `graphql:"issueFieldValues(first: 25)"` + } `graphql:"... on Issue"` + } `graphql:"nodes(ids: $ids)"` +} + +// fetchIssueFieldValuesByNodeID runs one GraphQL nodes() query for the given REST issues and +// returns a map of node_id -> flattened field values. Issues without a node_id are skipped, and +// an empty result set short-circuits the round-trip. +func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, issues []*github.Issue) (map[string][]MinimalIssueFieldValue, error) { + ids := make([]githubv4.ID, 0, len(issues)) + for _, iss := range issues { + if iss == nil || iss.NodeID == nil || *iss.NodeID == "" { + continue + } + ids = append(ids, githubv4.ID(*iss.NodeID)) + } + if len(ids) == 0 { + return nil, nil + } + + var q searchIssuesNodesQuery + if err := gqlClient.Query(ctx, &q, map[string]any{"ids": ids}); err != nil { + return nil, err + } + + result := make(map[string][]MinimalIssueFieldValue, len(q.Nodes)) + for _, n := range q.Nodes { + idStr, ok := n.Issue.ID.(string) + if !ok || idStr == "" { + continue + } + vals := make([]MinimalIssueFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes)) + for _, fv := range n.Issue.IssueFieldValues.Nodes { + if m, ok := fragmentToMinimalIssueFieldValue(fv); ok { + vals = append(vals, m) + } + } + result[idStr] = vals + } + return result, nil +} + +// searchIssuesHandler runs the REST issues search, enriches each hit with custom field values +// fetched via a single follow-up GraphQL nodes() query, and applies any post-process options +// (e.g. IFC labelling). +func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, options ...searchOption) (*mcp.CallToolResult, error) { + const errorPrefix = "failed to search issues" + + query, opts, err := prepareSearchArgs(args, "issue") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil + } + result, resp, err := client.Search.Issues(ctx, query, opts) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix, err), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to read response body", err), nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil + } + + var fieldValuesByID map[string][]MinimalIssueFieldValue + if len(result.Issues) > 0 { + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), nil + } + fieldValuesByID, err = fetchIssueFieldValuesByNodeID(ctx, gqlClient, result.Issues) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, errorPrefix+": failed to fetch issue field values", err), nil + } + } + + items := make([]SearchIssueResult, 0, len(result.Issues)) + for _, iss := range result.Issues { + hit := SearchIssueResult{Issue: iss} + if iss != nil && iss.NodeID != nil { + hit.FieldValues = fieldValuesByID[*iss.NodeID] + } + items = append(items, hit) + } + + response := SearchIssuesResponse{ + Total: result.Total, + IncompleteResults: result.IncompleteResults, + Items: items, + } + + r, err := json.Marshal(response) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil + } + + callResult := utils.NewToolResultText(string(r)) + cfg := searchConfig{} + for _, opt := range options { + opt(&cfg) + } + if cfg.postProcess != nil { + cfg.postProcess(ctx, result, callResult) + } + return callResult, nil +} + // IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository. // IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource. const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write" diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index c89aefb8c7..e1d1f43948 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -975,6 +975,100 @@ func unmarshalIFC(t *testing.T, ifcLabel any) map[string]any { return ifcMap } +func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { + serverTool := SearchIssues(translations.NullTranslationHelper) + + mockSearchResult := &github.IssuesSearchResult{ + Total: github.Ptr(2), + IncompleteResults: github.Ptr(false), + Issues: []*github.Issue{ + { + Number: github.Ptr(42), + Title: github.Ptr("Bug: Something is broken"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + NodeID: github.Ptr("I_node_42"), + User: &github.User{Login: github.Ptr("user1")}, + }, + { + Number: github.Ptr(43), + Title: github.Ptr("Feature request"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"), + NodeID: github.Ptr("I_node_43"), + User: &github.User{Login: github.Ptr("user2")}, + }, + }, + } + + restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), + }) + + gqlVars := map[string]any{ + "ids": []any{"I_node_42", "I_node_43"}, + } + gqlResponse := githubv4mock.DataResponse(map[string]any{ + "nodes": []map[string]any{ + { + "id": "I_node_42", + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{ + { + "__typename": "IssueFieldSingleSelectValue", + "field": map[string]any{"name": "priority"}, + "value": "P1", + }, + { + "__typename": "IssueFieldNumberValue", + "field": map[string]any{"name": "estimate"}, + "valueNumber": 2.5, + }, + }, + }, + }, + { + "id": "I_node_43", + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{}, + }, + }, + }, + }) + + const nodesQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value}}}}}}" + matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + + deps := BaseDeps{ + Client: mustNewGHClient(t, restClient), + GQLClient: gqlClient, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "query": "repo:owner/repo is:open", + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "expected result to not be an error") + + textContent := getTextResult(t, result) + + var response SearchIssuesResponse + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response)) + require.Equal(t, 2, *response.Total) + require.Len(t, response.Items, 2) + assert.Equal(t, 42, *response.Items[0].Number) + assert.Equal(t, []MinimalIssueFieldValue{ + {Field: "priority", Value: "P1"}, + {Field: "estimate", Value: "2.5"}, + }, response.Items[0].FieldValues) + assert.Equal(t, 43, *response.Items[1].Number) + assert.Empty(t, response.Items[1].FieldValues) +} + func Test_CreateIssue(t *testing.T) { // Verify tool definition once serverTool := IssueWrite(translations.NullTranslationHelper) diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index ac3aec90c9..ef3069c76c 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -54,21 +54,13 @@ func withSearchPostProcess(fn searchPostProcessFn) searchOption { return func(c *searchConfig) { c.postProcess = fn } } -func searchHandler( - ctx context.Context, - getClient GetClientFn, - args map[string]any, - searchType string, - errorPrefix string, - options ...searchOption, -) (*mcp.CallToolResult, error) { - cfg := searchConfig{} - for _, opt := range options { - opt(&cfg) - } +// prepareSearchArgs resolves the search query string and REST search options from the tool args, +// applying the standard is: / repo:/ munging shared by search_issues and +// search_pull_requests. +func prepareSearchArgs(args map[string]any, searchType string) (string, *github.SearchOptions, error) { query, err := RequiredParam[string](args, "query") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } if !hasSpecificFilter(query, "is", searchType) { @@ -77,12 +69,12 @@ func searchHandler( owner, err := OptionalParam[string](args, "owner") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } repo, err := OptionalParam[string](args, "repo") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } if owner != "" && repo != "" && !hasRepoFilter(query) { @@ -91,25 +83,42 @@ func searchHandler( sort, err := OptionalParam[string](args, "sort") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } order, err := OptionalParam[string](args, "order") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } pagination, err := OptionalPaginationParams(args) if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } - opts := &github.SearchOptions{ - // Default to "created" if no sort is provided, as it's a common use case. + return query, &github.SearchOptions{ Sort: sort, Order: order, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, }, + }, nil +} + +func searchHandler( + ctx context.Context, + getClient GetClientFn, + args map[string]any, + searchType string, + errorPrefix string, + options ...searchOption, +) (*mcp.CallToolResult, error) { + cfg := searchConfig{} + for _, opt := range options { + opt(&cfg) + } + query, opts, err := prepareSearchArgs(args, searchType) + if err != nil { + return utils.NewToolResultError(err.Error()), nil } client, err := getClient(ctx) From f94057c53902e92c140a187b3a739e11cee42f6f Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Wed, 20 May 2026 03:02:35 -0700 Subject: [PATCH 2/3] remove dupe keys --- pkg/github/issues.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 3aca133e60..fe1e7b5011 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1140,6 +1140,28 @@ type SearchIssueResult struct { FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"` } +// MarshalJSON serializes SearchIssueResult, suppressing the raw issue_field_values from the +// embedded REST response in favour of the normalized field_values populated via GraphQL enrichment. +func (r SearchIssueResult) MarshalJSON() ([]byte, error) { + issueBytes, err := json.Marshal(r.Issue) + if err != nil { + return nil, err + } + var m map[string]json.RawMessage + if err := json.Unmarshal(issueBytes, &m); err != nil { + return nil, err + } + delete(m, "issue_field_values") + if r.FieldValues != nil { + fv, err := json.Marshal(r.FieldValues) + if err != nil { + return nil, err + } + m["field_values"] = fv + } + return json.Marshal(m) +} + // SearchIssuesResponse mirrors the REST IssuesSearchResult JSON shape and adds field_values // per item, sourced from a single GraphQL nodes() round-trip. type SearchIssuesResponse struct { From db11c5b65a33a53bedfc4b3cb1ae4b295ee2beb2 Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Wed, 20 May 2026 03:12:25 -0700 Subject: [PATCH 3/3] Fix advanced search not enabled for fields --- pkg/github/issues_test.go | 41 ++++++++++++++++++++++++++++++++++++++ pkg/github/search_utils.go | 12 +++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index e1d1f43948..ff4cb93a16 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -744,6 +744,47 @@ func Test_SearchIssues(t *testing.T) { expectError: false, expectedResult: mockSearchResult, }, + { + name: "query with field. qualifier enables advanced_search", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue field.priority:P1", + "page": "1", + "per_page": "30", + "advanced_search": "true", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + }), + requestArgs: map[string]any{ + "query": "field.priority:P1", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "query without field. qualifier does not set advanced_search", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue is:open", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + }), + requestArgs: map[string]any{ + "query": "is:open", + }, + expectError: false, + expectedResult: mockSearchResult, + }, { name: "search issues fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index ef3069c76c..54213a2407 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "regexp" + "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/utils" @@ -94,14 +95,21 @@ func prepareSearchArgs(args map[string]any, searchType string) (string, *github. return "", nil, err } - return query, &github.SearchOptions{ + opts := &github.SearchOptions{ Sort: sort, Order: order, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, }, - }, nil + } + + // field.: qualifiers require the advanced search API. + if strings.Contains(query, "field.") { + opts.AdvancedSearch = github.Ptr(true) + } + + return query, opts, nil } func searchHandler(