Skip to content

Commit c7fcdf2

Browse files
committed
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
1 parent 7d13a7a commit c7fcdf2

6 files changed

Lines changed: 259 additions & 37 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1036,8 +1036,9 @@ The following sets of tools are available:
10361036
- `owner`: Optional repository owner. If provided with repo, only issues for this repository are listed. (string, optional)
10371037
- `page`: Page number for pagination (min 1) (number, optional)
10381038
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
1039-
- `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)
1039+
- `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)
10401040
- `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional)
1041+
- `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)
10411042
- `sort`: Sort field by number of matches of categories, defaults to best match (string, optional)
10421043

10431044
- **sub_issue_write** - Change sub-issue

pkg/github/__toolsnaps__/search_issues.snap

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"readOnlyHint": true,
55
"title": "Search issues"
66
},
7-
"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.",
7+
"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.",
88
"inputSchema": {
99
"properties": {
1010
"fields": {
@@ -64,13 +64,21 @@
6464
"type": "number"
6565
},
6666
"query": {
67-
"description": "The search query, as natural language. When the user gives alternative wordings, include them as plain words rather than joining them with OR.",
67+
"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.",
6868
"type": "string"
6969
},
7070
"repo": {
7171
"description": "Optional repository name. If provided with owner, only issues for this repository are listed.",
7272
"type": "string"
7373
},
74+
"search_type": {
75+
"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.",
76+
"enum": [
77+
"lexical",
78+
"semantic"
79+
],
80+
"type": "string"
81+
},
7482
"sort": {
7583
"description": "Sort field by number of matches of categories, defaults to best match",
7684
"enum": [

pkg/github/issues.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1808,10 +1808,10 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri
18081808
// caller's literal keywords and handles OR fine. The description has to describe
18091809
// the engine the host will actually use.
18101810
const (
1811-
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."
1811+
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."
18121812
searchIssuesLexicalDescription = "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue"
18131813

1814-
searchIssuesSemanticQueryDescription = "The search query, as natural language. When the user gives alternative wordings, include them as plain words rather than joining them with OR."
1814+
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."
18151815
searchIssuesLexicalQueryDescription = "Search query using GitHub issues search syntax"
18161816
)
18171817

@@ -1870,6 +1870,11 @@ func SearchIssues(t translations.TranslationHelperFunc, opts ...ToolOption) inve
18701870
Description: "Sort order",
18711871
Enum: []any{"asc", "desc"},
18721872
},
1873+
"search_type": {
1874+
Type: "string",
1875+
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.",
1876+
Enum: []any{"lexical", "semantic"},
1877+
},
18731878
},
18741879
Required: []string{"query"},
18751880
}
@@ -1892,13 +1897,17 @@ func SearchIssues(t translations.TranslationHelperFunc, opts ...ToolOption) inve
18921897
},
18931898
scopes.PublicRead(scopes.Repo),
18941899
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
1900+
resolvedMode, err := resolveIssuesSearchMode(mode, args)
1901+
if err != nil {
1902+
return utils.NewToolResultError(err.Error()), nil, nil
1903+
}
18951904
options := []searchOption{ifcSearchPostProcessOption(ctx, deps)}
18961905
fields, err := OptionalStringArrayParam(args, "fields")
18971906
if err != nil {
18981907
return utils.NewToolResultError(err.Error()), nil, nil
18991908
}
19001909
options = append(options, withFieldsFiltering(deps, "search_issues", fields))
1901-
result, err := searchIssuesHandler(ctx, deps, args, mode, options...)
1910+
result, err := searchIssuesHandler(ctx, deps, args, resolvedMode, options...)
19021911
return result, nil, err
19031912
})
19041913
}

pkg/github/issues_test.go

Lines changed: 90 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,7 @@ func Test_SearchIssues(t *testing.T) {
10891089
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage")
10901090
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "page")
10911091
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "fields")
1092+
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "search_type")
10921093
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"query"})
10931094

10941095
// Setup mock search results
@@ -1135,12 +1136,11 @@ func Test_SearchIssues(t *testing.T) {
11351136
GetSearchIssues: expectQueryParams(
11361137
t,
11371138
map[string]string{
1138-
"q": "is:issue repo:owner/repo is:open",
1139-
"sort": "created",
1140-
"order": "desc",
1141-
"page": "1",
1142-
"per_page": "30",
1143-
"search_type": "semantic",
1139+
"q": "is:issue repo:owner/repo is:open",
1140+
"sort": "created",
1141+
"order": "desc",
1142+
"page": "1",
1143+
"per_page": "30",
11441144
},
11451145
).andThen(
11461146
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1162,12 +1162,11 @@ func Test_SearchIssues(t *testing.T) {
11621162
GetSearchIssues: expectQueryParams(
11631163
t,
11641164
map[string]string{
1165-
"q": "repo:test-owner/test-repo is:issue is:open",
1166-
"sort": "created",
1167-
"order": "asc",
1168-
"page": "1",
1169-
"per_page": "30",
1170-
"search_type": "semantic",
1165+
"q": "repo:test-owner/test-repo is:issue is:open",
1166+
"sort": "created",
1167+
"order": "asc",
1168+
"page": "1",
1169+
"per_page": "30",
11711170
},
11721171
).andThen(
11731172
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1244,10 +1243,9 @@ func Test_SearchIssues(t *testing.T) {
12441243
GetSearchIssues: expectQueryParams(
12451244
t,
12461245
map[string]string{
1247-
"q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
1248-
"page": "1",
1249-
"per_page": "30",
1250-
"search_type": "semantic",
1246+
"q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
1247+
"page": "1",
1248+
"per_page": "30",
12511249
},
12521250
).andThen(
12531251
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1265,10 +1263,9 @@ func Test_SearchIssues(t *testing.T) {
12651263
GetSearchIssues: expectQueryParams(
12661264
t,
12671265
map[string]string{
1268-
"q": "is:issue repo:github/github-mcp-server critical",
1269-
"page": "1",
1270-
"per_page": "30",
1271-
"search_type": "semantic",
1266+
"q": "is:issue repo:github/github-mcp-server critical",
1267+
"page": "1",
1268+
"per_page": "30",
12721269
},
12731270
).andThen(
12741271
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1288,10 +1285,9 @@ func Test_SearchIssues(t *testing.T) {
12881285
GetSearchIssues: expectQueryParams(
12891286
t,
12901287
map[string]string{
1291-
"q": "is:issue repo:octocat/Hello-World bug",
1292-
"page": "1",
1293-
"per_page": "30",
1294-
"search_type": "semantic",
1288+
"q": "is:issue repo:octocat/Hello-World bug",
1289+
"page": "1",
1290+
"per_page": "30",
12951291
},
12961292
).andThen(
12971293
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1309,10 +1305,9 @@ func Test_SearchIssues(t *testing.T) {
13091305
GetSearchIssues: expectQueryParams(
13101306
t,
13111307
map[string]string{
1312-
"q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
1313-
"page": "1",
1314-
"per_page": "30",
1315-
"search_type": "semantic",
1308+
"q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
1309+
"page": "1",
1310+
"per_page": "30",
13161311
},
13171312
).andThen(
13181313
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1333,7 +1328,6 @@ func Test_SearchIssues(t *testing.T) {
13331328
"q": "is:issue field.priority:P1",
13341329
"page": "1",
13351330
"per_page": "30",
1336-
"search_type": "semantic",
13371331
"advanced_search": "true",
13381332
},
13391333
).andThen(
@@ -1352,7 +1346,7 @@ func Test_SearchIssues(t *testing.T) {
13521346
GetSearchIssues: expectQueryParams(
13531347
t,
13541348
map[string]string{
1355-
"q": "is:issue is:open",
1349+
"q": "is:issue login fails after password reset",
13561350
"page": "1",
13571351
"per_page": "30",
13581352
"search_type": "semantic",
@@ -1362,7 +1356,72 @@ func Test_SearchIssues(t *testing.T) {
13621356
),
13631357
}),
13641358
requestArgs: map[string]any{
1365-
"query": "is:open",
1359+
"query": "login fails after password reset",
1360+
},
1361+
expectError: false,
1362+
expectedResult: mockSearchResult,
1363+
},
1364+
{
1365+
name: "owner and repo scope forces lexical keyword search",
1366+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1367+
GetSearchIssues: expectQueryParams(
1368+
t,
1369+
map[string]string{
1370+
"q": "repo:modelcontextprotocol/python-sdk is:issue transport",
1371+
"page": "1",
1372+
"per_page": "30",
1373+
},
1374+
).andThen(
1375+
mockResponse(t, http.StatusOK, mockSearchResult),
1376+
),
1377+
}),
1378+
requestArgs: map[string]any{
1379+
"query": "transport",
1380+
"owner": "modelcontextprotocol",
1381+
"repo": "python-sdk",
1382+
},
1383+
expectError: false,
1384+
expectedResult: mockSearchResult,
1385+
},
1386+
{
1387+
name: "explicit search_type semantic overrides syntax heuristic",
1388+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1389+
GetSearchIssues: expectQueryParams(
1390+
t,
1391+
map[string]string{
1392+
"q": "is:issue label:bug",
1393+
"page": "1",
1394+
"per_page": "30",
1395+
"search_type": "semantic",
1396+
},
1397+
).andThen(
1398+
mockResponse(t, http.StatusOK, mockSearchResult),
1399+
),
1400+
}),
1401+
requestArgs: map[string]any{
1402+
"query": "label:bug",
1403+
"search_type": "semantic",
1404+
},
1405+
expectError: false,
1406+
expectedResult: mockSearchResult,
1407+
},
1408+
{
1409+
name: "explicit search_type lexical forces keyword search",
1410+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1411+
GetSearchIssues: expectQueryParams(
1412+
t,
1413+
map[string]string{
1414+
"q": "is:issue sticky sidebar",
1415+
"page": "1",
1416+
"per_page": "30",
1417+
},
1418+
).andThen(
1419+
mockResponse(t, http.StatusOK, mockSearchResult),
1420+
),
1421+
}),
1422+
requestArgs: map[string]any{
1423+
"query": "sticky sidebar",
1424+
"search_type": "lexical",
13661425
},
13671426
expectError: false,
13681427
expectedResult: mockSearchResult,

pkg/github/search_utils.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,63 @@ const (
8484
searchModeSemantic
8585
)
8686

87+
// booleanSearchOpPattern matches OR / AND / NOT as free-standing operators.
88+
var booleanSearchOpPattern = regexp.MustCompile(`(?i)(^|\s)(OR|AND|NOT)(\s|$)`)
89+
90+
// qualifierPattern matches GitHub search qualifiers such as label:bug or -author:octocat.
91+
var qualifierPattern = regexp.MustCompile(`(^|\s|\W)-?[\w.]+:\S+`)
92+
93+
// looksLikeLexicalIssueSearch reports whether the raw caller query uses GitHub
94+
// issues search syntax that semantic search mishandles.
95+
func looksLikeLexicalIssueSearch(query string) bool {
96+
return qualifierPattern.MatchString(query) || booleanSearchOpPattern.MatchString(query)
97+
}
98+
99+
// resolveIssuesSearchMode chooses lexical vs semantic for search_issues.
100+
// An explicit search_type wins. Otherwise GHES stays lexical, and Dotcom uses
101+
// lexical for scoped or syntax-like queries so keyword search matches REST.
102+
func resolveIssuesSearchMode(defaultMode searchMode, args map[string]any) (searchMode, error) {
103+
searchType, err := OptionalParam[string](args, "search_type")
104+
if err != nil {
105+
return 0, err
106+
}
107+
switch strings.ToLower(strings.TrimSpace(searchType)) {
108+
case "":
109+
// fall through to heuristics
110+
case "lexical":
111+
return searchModeLexical, nil
112+
case "semantic":
113+
return searchModeSemantic, nil
114+
default:
115+
return 0, fmt.Errorf(`invalid search_type %q: must be "lexical" or "semantic"`, searchType)
116+
}
117+
118+
if defaultMode == searchModeLexical {
119+
return searchModeLexical, nil
120+
}
121+
122+
owner, err := OptionalParam[string](args, "owner")
123+
if err != nil {
124+
return 0, err
125+
}
126+
repo, err := OptionalParam[string](args, "repo")
127+
if err != nil {
128+
return 0, err
129+
}
130+
if owner != "" && repo != "" {
131+
return searchModeLexical, nil
132+
}
133+
134+
query, err := RequiredParam[string](args, "query")
135+
if err != nil {
136+
return 0, err
137+
}
138+
if looksLikeLexicalIssueSearch(query) {
139+
return searchModeLexical, nil
140+
}
141+
return searchModeSemantic, nil
142+
}
143+
87144
// prepareSearchArgs resolves the search query string and REST search options from the tool args,
88145
// applying the standard is:<type> / repo:<owner>/<repo> munging shared by search_issues and
89146
// search_pull_requests.

0 commit comments

Comments
 (0)