From c7fcdf2b6082951a3fa0f13d6e423822da7aec9a Mon Sep 17 00:00:00 2001 From: Amidwestnoob Date: Sat, 12 Sep 2026 03:03:00 +0000 Subject: [PATCH 1/2] fix: use lexical search_issues for scoped and syntax queries On github.com, search_issues defaulted to semantic search, so keyword and GitHub search-syntax queries silently returned near-empty or unrelated hits. Choose lexical when owner/repo scope is set, when the raw query looks like issues search syntax, or when search_type is lexical. Keep semantic for open-ended natural-language queries, with an explicit search_type override. Closes #3188 --- README.md | 3 +- pkg/github/__toolsnaps__/search_issues.snap | 12 +- pkg/github/issues.go | 15 ++- pkg/github/issues_test.go | 121 +++++++++++++++----- pkg/github/search_utils.go | 57 +++++++++ pkg/github/search_utils_test.go | 88 ++++++++++++++ 6 files changed, 259 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5b90f64a58..be577e952d 100644 --- a/README.md +++ b/README.md @@ -1036,8 +1036,9 @@ The following sets of tools are available: - `owner`: Optional repository owner. If provided with repo, only issues for this repository are listed. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `query`: The search query, as natural language. When the user gives alternative wordings, include them as plain words rather than joining them with OR. (string, required) + - `query`: Search query. Prefer GitHub issues search syntax for keywords and filters. For open-ended conceptual questions, plain natural language is fine. Pass search_type=lexical to force keyword search. (string, required) - `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional) + - `search_type`: Search engine. lexical matches GitHub issues search keywords and filters. semantic uses natural-language matching. When omitted, scoped or search-syntax queries use lexical; open-ended conceptual queries use semantic on github.com. (string, optional) - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) - **sub_issue_write** - Change sub-issue diff --git a/pkg/github/__toolsnaps__/search_issues.snap b/pkg/github/__toolsnaps__/search_issues.snap index bbba9b0b95..e5f50dd302 100644 --- a/pkg/github/__toolsnaps__/search_issues.snap +++ b/pkg/github/__toolsnaps__/search_issues.snap @@ -4,7 +4,7 @@ "readOnlyHint": true, "title": "Search issues" }, - "description": "Search issues using natural-language semantic matching. Best for conceptual or paraphrased queries (e.g. \"login fails after password reset\"). Already scoped to is:issue.", + "description": "Search issues on GitHub. Uses lexical GitHub issues search for keyword or search-syntax queries, and when owner/repo scope is set. Uses natural-language semantic matching for open-ended conceptual queries. Already scoped to is:issue. Pass search_type to force lexical or semantic.", "inputSchema": { "properties": { "fields": { @@ -64,13 +64,21 @@ "type": "number" }, "query": { - "description": "The search query, as natural language. When the user gives alternative wordings, include them as plain words rather than joining them with OR.", + "description": "Search query. Prefer GitHub issues search syntax for keywords and filters. For open-ended conceptual questions, plain natural language is fine. Pass search_type=lexical to force keyword search.", "type": "string" }, "repo": { "description": "Optional repository name. If provided with owner, only issues for this repository are listed.", "type": "string" }, + "search_type": { + "description": "Search engine. lexical matches GitHub issues search keywords and filters. semantic uses natural-language matching. When omitted, scoped or search-syntax queries use lexical; open-ended conceptual queries use semantic on github.com.", + "enum": [ + "lexical", + "semantic" + ], + "type": "string" + }, "sort": { "description": "Sort field by number of matches of categories, defaults to best match", "enum": [ diff --git a/pkg/github/issues.go b/pkg/github/issues.go index cc8bc599a1..dea3685215 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1808,10 +1808,10 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri // caller's literal keywords and handles OR fine. The description has to describe // the engine the host will actually use. const ( - searchIssuesSemanticDescription = "Search issues using natural-language semantic matching. Best for conceptual or paraphrased queries (e.g. \"login fails after password reset\"). Already scoped to is:issue." + searchIssuesSemanticDescription = "Search issues on GitHub. Uses lexical GitHub issues search for keyword or search-syntax queries, and when owner/repo scope is set. Uses natural-language semantic matching for open-ended conceptual queries. Already scoped to is:issue. Pass search_type to force lexical or semantic." searchIssuesLexicalDescription = "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue" - searchIssuesSemanticQueryDescription = "The search query, as natural language. When the user gives alternative wordings, include them as plain words rather than joining them with OR." + searchIssuesSemanticQueryDescription = "Search query. Prefer GitHub issues search syntax for keywords and filters. For open-ended conceptual questions, plain natural language is fine. Pass search_type=lexical to force keyword search." searchIssuesLexicalQueryDescription = "Search query using GitHub issues search syntax" ) @@ -1870,6 +1870,11 @@ func SearchIssues(t translations.TranslationHelperFunc, opts ...ToolOption) inve Description: "Sort order", Enum: []any{"asc", "desc"}, }, + "search_type": { + Type: "string", + Description: "Search engine. lexical matches GitHub issues search keywords and filters. semantic uses natural-language matching. When omitted, scoped or search-syntax queries use lexical; open-ended conceptual queries use semantic on github.com.", + Enum: []any{"lexical", "semantic"}, + }, }, Required: []string{"query"}, } @@ -1892,13 +1897,17 @@ func SearchIssues(t translations.TranslationHelperFunc, opts ...ToolOption) inve }, scopes.PublicRead(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + resolvedMode, err := resolveIssuesSearchMode(mode, args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } options := []searchOption{ifcSearchPostProcessOption(ctx, deps)} fields, err := OptionalStringArrayParam(args, "fields") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } options = append(options, withFieldsFiltering(deps, "search_issues", fields)) - result, err := searchIssuesHandler(ctx, deps, args, mode, options...) + result, err := searchIssuesHandler(ctx, deps, args, resolvedMode, options...) return result, nil, err }) } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 83bedc5b54..577b37de09 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1089,6 +1089,7 @@ func Test_SearchIssues(t *testing.T) { assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "page") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "fields") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "search_type") assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"query"}) // Setup mock search results @@ -1135,12 +1136,11 @@ func Test_SearchIssues(t *testing.T) { GetSearchIssues: expectQueryParams( t, map[string]string{ - "q": "is:issue repo:owner/repo is:open", - "sort": "created", - "order": "desc", - "page": "1", - "per_page": "30", - "search_type": "semantic", + "q": "is:issue repo:owner/repo is:open", + "sort": "created", + "order": "desc", + "page": "1", + "per_page": "30", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -1162,12 +1162,11 @@ func Test_SearchIssues(t *testing.T) { GetSearchIssues: expectQueryParams( t, map[string]string{ - "q": "repo:test-owner/test-repo is:issue is:open", - "sort": "created", - "order": "asc", - "page": "1", - "per_page": "30", - "search_type": "semantic", + "q": "repo:test-owner/test-repo is:issue is:open", + "sort": "created", + "order": "asc", + "page": "1", + "per_page": "30", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -1244,10 +1243,9 @@ func Test_SearchIssues(t *testing.T) { GetSearchIssues: expectQueryParams( t, map[string]string{ - "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", - "page": "1", - "per_page": "30", - "search_type": "semantic", + "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", + "page": "1", + "per_page": "30", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -1265,10 +1263,9 @@ func Test_SearchIssues(t *testing.T) { GetSearchIssues: expectQueryParams( t, map[string]string{ - "q": "is:issue repo:github/github-mcp-server critical", - "page": "1", - "per_page": "30", - "search_type": "semantic", + "q": "is:issue repo:github/github-mcp-server critical", + "page": "1", + "per_page": "30", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -1288,10 +1285,9 @@ func Test_SearchIssues(t *testing.T) { GetSearchIssues: expectQueryParams( t, map[string]string{ - "q": "is:issue repo:octocat/Hello-World bug", - "page": "1", - "per_page": "30", - "search_type": "semantic", + "q": "is:issue repo:octocat/Hello-World bug", + "page": "1", + "per_page": "30", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -1309,10 +1305,9 @@ func Test_SearchIssues(t *testing.T) { GetSearchIssues: expectQueryParams( t, map[string]string{ - "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", - "page": "1", - "per_page": "30", - "search_type": "semantic", + "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", + "page": "1", + "per_page": "30", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -1333,7 +1328,6 @@ func Test_SearchIssues(t *testing.T) { "q": "is:issue field.priority:P1", "page": "1", "per_page": "30", - "search_type": "semantic", "advanced_search": "true", }, ).andThen( @@ -1352,7 +1346,7 @@ func Test_SearchIssues(t *testing.T) { GetSearchIssues: expectQueryParams( t, map[string]string{ - "q": "is:issue is:open", + "q": "is:issue login fails after password reset", "page": "1", "per_page": "30", "search_type": "semantic", @@ -1362,7 +1356,72 @@ func Test_SearchIssues(t *testing.T) { ), }), requestArgs: map[string]any{ - "query": "is:open", + "query": "login fails after password reset", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "owner and repo scope forces lexical keyword search", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "repo:modelcontextprotocol/python-sdk is:issue transport", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + }), + requestArgs: map[string]any{ + "query": "transport", + "owner": "modelcontextprotocol", + "repo": "python-sdk", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "explicit search_type semantic overrides syntax heuristic", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue label:bug", + "page": "1", + "per_page": "30", + "search_type": "semantic", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + }), + requestArgs: map[string]any{ + "query": "label:bug", + "search_type": "semantic", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "explicit search_type lexical forces keyword search", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue sticky sidebar", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + }), + requestArgs: map[string]any{ + "query": "sticky sidebar", + "search_type": "lexical", }, expectError: false, expectedResult: mockSearchResult, diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 52d735ca0b..99c6ab4188 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -84,6 +84,63 @@ const ( searchModeSemantic ) +// booleanSearchOpPattern matches OR / AND / NOT as free-standing operators. +var booleanSearchOpPattern = regexp.MustCompile(`(?i)(^|\s)(OR|AND|NOT)(\s|$)`) + +// qualifierPattern matches GitHub search qualifiers such as label:bug or -author:octocat. +var qualifierPattern = regexp.MustCompile(`(^|\s|\W)-?[\w.]+:\S+`) + +// looksLikeLexicalIssueSearch reports whether the raw caller query uses GitHub +// issues search syntax that semantic search mishandles. +func looksLikeLexicalIssueSearch(query string) bool { + return qualifierPattern.MatchString(query) || booleanSearchOpPattern.MatchString(query) +} + +// resolveIssuesSearchMode chooses lexical vs semantic for search_issues. +// An explicit search_type wins. Otherwise GHES stays lexical, and Dotcom uses +// lexical for scoped or syntax-like queries so keyword search matches REST. +func resolveIssuesSearchMode(defaultMode searchMode, args map[string]any) (searchMode, error) { + searchType, err := OptionalParam[string](args, "search_type") + if err != nil { + return 0, err + } + switch strings.ToLower(strings.TrimSpace(searchType)) { + case "": + // fall through to heuristics + case "lexical": + return searchModeLexical, nil + case "semantic": + return searchModeSemantic, nil + default: + return 0, fmt.Errorf(`invalid search_type %q: must be "lexical" or "semantic"`, searchType) + } + + if defaultMode == searchModeLexical { + return searchModeLexical, nil + } + + owner, err := OptionalParam[string](args, "owner") + if err != nil { + return 0, err + } + repo, err := OptionalParam[string](args, "repo") + if err != nil { + return 0, err + } + if owner != "" && repo != "" { + return searchModeLexical, nil + } + + query, err := RequiredParam[string](args, "query") + if err != nil { + return 0, err + } + if looksLikeLexicalIssueSearch(query) { + return searchModeLexical, nil + } + return searchModeSemantic, nil +} + // 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. diff --git a/pkg/github/search_utils_test.go b/pkg/github/search_utils_test.go index 85f953eed4..501eeed59c 100644 --- a/pkg/github/search_utils_test.go +++ b/pkg/github/search_utils_test.go @@ -350,3 +350,91 @@ func Test_hasTypeFilter(t *testing.T) { }) } } + +func Test_looksLikeLexicalIssueSearch(t *testing.T) { + t.Parallel() + tests := []struct { + name string + query string + expected bool + }{ + {name: "plain keywords", query: "transport", expected: false}, + {name: "natural language", query: "login fails after password reset", expected: false}, + {name: "repo qualifier", query: "repo:modelcontextprotocol/python-sdk transport", expected: true}, + {name: "label qualifier", query: "label:bug", expected: true}, + {name: "boolean OR", query: "hooks OR plugins", expected: true}, + {name: "is open filter", query: "is:open", expected: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, looksLikeLexicalIssueSearch(tt.query)) + }) + } +} + +func Test_resolveIssuesSearchMode(t *testing.T) { + t.Parallel() + tests := []struct { + name string + defaultMode searchMode + args map[string]any + expected searchMode + expectErr bool + }{ + { + name: "GHES stays lexical", + defaultMode: searchModeLexical, + args: map[string]any{"query": "login fails after password reset"}, + expected: searchModeLexical, + }, + { + name: "Dotcom natural language stays semantic", + defaultMode: searchModeSemantic, + args: map[string]any{"query": "login fails after password reset"}, + expected: searchModeSemantic, + }, + { + name: "Dotcom repo syntax uses lexical", + defaultMode: searchModeSemantic, + args: map[string]any{"query": "repo:owner/repo transport"}, + expected: searchModeLexical, + }, + { + name: "Dotcom owner repo scope uses lexical", + defaultMode: searchModeSemantic, + args: map[string]any{"query": "transport", "owner": "o", "repo": "r"}, + expected: searchModeLexical, + }, + { + name: "explicit semantic wins over syntax", + defaultMode: searchModeSemantic, + args: map[string]any{"query": "label:bug", "search_type": "semantic"}, + expected: searchModeSemantic, + }, + { + name: "explicit lexical wins over natural language", + defaultMode: searchModeSemantic, + args: map[string]any{"query": "sticky sidebar", "search_type": "lexical"}, + expected: searchModeLexical, + }, + { + name: "invalid search_type errors", + defaultMode: searchModeSemantic, + args: map[string]any{"query": "x", "search_type": "fuzzy"}, + expectErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := resolveIssuesSearchMode(tt.defaultMode, tt.args) + if tt.expectErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} From b07fb532bc3824485f358d204219eae685bbb09c Mon Sep 17 00:00:00 2001 From: Amidwestnoob Date: Sat, 12 Sep 2026 09:07:32 -0500 Subject: [PATCH 2/2] fix: address reviewed regressions for PR #3265 --- pkg/github/issues.go | 4 ++ pkg/github/search_mode_regression_test.go | 49 +++++++++++++++++++++++ pkg/github/search_utils.go | 12 +++++- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 pkg/github/search_mode_regression_test.go diff --git a/pkg/github/issues.go b/pkg/github/issues.go index dea3685215..a50c27a5f0 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1878,6 +1878,10 @@ func SearchIssues(t translations.TranslationHelperFunc, opts ...ToolOption) inve }, Required: []string{"query"}, } + if mode == searchModeLexical { + schema.Properties["search_type"].Enum = []any{"lexical"} + schema.Properties["search_type"].Description = "Search engine. Only lexical search is supported on this host." + } schema.Properties["fields"] = fieldsSchemaProperty( "Subset of fields to return for each issue result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data.", searchIssuesItemFieldEnum, diff --git a/pkg/github/search_mode_regression_test.go b/pkg/github/search_mode_regression_test.go new file mode 100644 index 0000000000..fb61da1ade --- /dev/null +++ b/pkg/github/search_mode_regression_test.go @@ -0,0 +1,49 @@ +package github + +import ( + "context" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/require" +) + +func Test_SearchModeNaturalLanguageAndSyntax(t *testing.T) { + for _, q := range []string{ + "login does not work after password reset", "connecting to postgres and redis", "should I use hooks or plugins", + `why does "this AND that" fail`, `explain "foo OR bar"`, `why "NOT ready" appears`, + `explain "label:bug" text`, `why "escaped \" AND operator" fails`, + } { + t.Run(q, func(t *testing.T) { + got, err := resolveIssuesSearchMode(searchModeSemantic, map[string]any{"query": q}) + require.NoError(t, err) + require.Equal(t, searchModeSemantic, got) + }) + } + for _, q := range []string{"foo AND bar", "foo OR bar", "foo NOT bar", "foo AND(bar OR baz)", `label:"needs triage"`, `repo:owner/repo`, `-author:bot`, `(label:bug OR label:critical)`} { + t.Run(q, func(t *testing.T) { + got, err := resolveIssuesSearchMode(searchModeSemantic, map[string]any{"query": q}) + require.NoError(t, err) + require.Equal(t, searchModeLexical, got) + got, err = resolveIssuesSearchMode(searchModeSemantic, map[string]any{"query": q, "search_type": "semantic"}) + require.NoError(t, err) + require.Equal(t, searchModeSemantic, got) + }) + } +} + +func Test_SearchIssuesGHESRejectsSemanticOverride(t *testing.T) { + got, err := resolveIssuesSearchMode(searchModeLexical, map[string]any{"query": "question", "search_type": "semantic"}) + require.ErrorContains(t, err, "not supported") + require.NotEqual(t, searchModeSemantic, got) + tool := SearchIssues(translations.NullTranslationHelper, WithHost(utils.HostTypeGHES)) + require.Equal(t, []any{"lexical"}, tool.Tool.InputSchema.(*jsonschema.Schema).Properties["search_type"].Enum) + // No client: the capability error must be returned before attempting a request. + deps := &BaseDeps{} + request := createMCPRequest(map[string]any{"query": "question", "search_type": "semantic"}) + result, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) +} diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 99c6ab4188..db8c80589e 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -85,7 +85,11 @@ const ( ) // booleanSearchOpPattern matches OR / AND / NOT as free-standing operators. -var booleanSearchOpPattern = regexp.MustCompile(`(?i)(^|\s)(OR|AND|NOT)(\s|$)`) +var booleanSearchOpPattern = regexp.MustCompile(`(^|[\s(])(OR|AND|NOT)([\s()]|$)`) + +// quotedIssueSearchText masks literal contents while retaining a nonempty +// qualifier value (label:"needs triage"). Escaped quotes stay inside the literal. +var quotedIssueSearchText = regexp.MustCompile(`"(?:\\.|[^"\\])*(?:"|$)`) // qualifierPattern matches GitHub search qualifiers such as label:bug or -author:octocat. var qualifierPattern = regexp.MustCompile(`(^|\s|\W)-?[\w.]+:\S+`) @@ -93,11 +97,12 @@ var qualifierPattern = regexp.MustCompile(`(^|\s|\W)-?[\w.]+:\S+`) // looksLikeLexicalIssueSearch reports whether the raw caller query uses GitHub // issues search syntax that semantic search mishandles. func looksLikeLexicalIssueSearch(query string) bool { + query = quotedIssueSearchText.ReplaceAllString(query, `""`) return qualifierPattern.MatchString(query) || booleanSearchOpPattern.MatchString(query) } // resolveIssuesSearchMode chooses lexical vs semantic for search_issues. -// An explicit search_type wins. Otherwise GHES stays lexical, and Dotcom uses +// An explicit search_type wins within the host capability boundary. Dotcom uses // lexical for scoped or syntax-like queries so keyword search matches REST. func resolveIssuesSearchMode(defaultMode searchMode, args map[string]any) (searchMode, error) { searchType, err := OptionalParam[string](args, "search_type") @@ -110,6 +115,9 @@ func resolveIssuesSearchMode(defaultMode searchMode, args map[string]any) (searc case "lexical": return searchModeLexical, nil case "semantic": + if defaultMode == searchModeLexical { + return 0, fmt.Errorf("semantic issue search is not supported on this host") + } return searchModeSemantic, nil default: return 0, fmt.Errorf(`invalid search_type %q: must be "lexical" or "semantic"`, searchType)