Skip to content

Commit 2a8189e

Browse files
authored
Make search_issues semantic by default
1 parent eb088df commit 2a8189e

13 files changed

Lines changed: 313 additions & 69 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -974,7 +974,7 @@ The following sets of tools are available:
974974
- `owner`: Optional repository owner. If provided with repo, only issues for this repository are listed. (string, optional)
975975
- `page`: Page number for pagination (min 1) (number, optional)
976976
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
977-
- `query`: Search query using GitHub issues search syntax (string, required)
977+
- `query`: The search query. Write it as natural language. Avoid boolean OR operators, which fall back to lexical search. (string, required)
978978
- `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional)
979979
- `sort`: Sort field by number of matches of categories, defaults to best match (string, optional)
980980

docs/feature-flags.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ runtime behavior (such as output formatting) won't appear here.
413413
- `owner`: Optional repository owner. If provided with repo, only issues for this repository are listed. (string, optional)
414414
- `page`: Page number for pagination (min 1) (number, optional)
415415
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
416-
- `query`: Search query using GitHub issues search syntax (string, required)
416+
- `query`: The search query. Write it as natural language. Avoid boolean OR operators, which fall back to lexical search. (string, required)
417417
- `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional)
418418
- `sort`: Sort field by number of matches of categories, defaults to best match (string, optional)
419419

docs/insiders-features.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
208208
- `owner`: Optional repository owner. If provided with repo, only issues for this repository are listed. (string, optional)
209209
- `page`: Page number for pagination (min 1) (number, optional)
210210
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
211-
- `query`: Search query using GitHub issues search syntax (string, required)
211+
- `query`: The search query. Write it as natural language. Avoid boolean OR operators, which fall back to lexical search. (string, required)
212212
- `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional)
213213
- `sort`: Sort field by number of matches of categories, defaults to best match (string, optional)
214214

internal/ghmcp/server.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
138138
return nil, fmt.Errorf("failed to parse API host: %w", err)
139139
}
140140

141+
hostType, err := utils.ParseHostType(cfg.Host)
142+
if err != nil {
143+
return nil, fmt.Errorf("failed to classify API host: %w", err)
144+
}
145+
141146
clients, err := createGitHubClients(cfg, apiHost)
142147
if err != nil {
143148
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
@@ -165,7 +170,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
165170
obs,
166171
)
167172
// Build and register the tool/resource/prompt inventory
168-
inventoryBuilder := github.NewInventory(cfg.Translator).
173+
inventoryBuilder := github.NewInventory(cfg.Translator, github.WithHost(hostType)).
169174
WithDeprecatedAliases(github.DeprecatedToolAliases).
170175
WithReadOnly(cfg.ReadOnly).
171176
WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)).

pkg/github/__toolsnaps__/search_issues.snap

Lines changed: 2 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 for issues in GitHub repositories using issues search syntax already scoped to is:issue",
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.",
88
"inputSchema": {
99
"properties": {
1010
"order": {
@@ -31,7 +31,7 @@
3131
"type": "number"
3232
},
3333
"query": {
34-
"description": "Search query using GitHub issues search syntax",
34+
"description": "The search query. Write it as natural language. Avoid boolean OR operators, which fall back to lexical search.",
3535
"type": "string"
3636
},
3737
"repo": {

pkg/github/__toolsnaps__/search_issues_ff_fields_param.snap

Lines changed: 2 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 for issues in GitHub repositories using issues search syntax already scoped to is:issue",
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.",
88
"inputSchema": {
99
"properties": {
1010
"fields": {
@@ -64,7 +64,7 @@
6464
"type": "number"
6565
},
6666
"query": {
67-
"description": "Search query using GitHub issues search syntax",
67+
"description": "The search query. Write it as natural language. Avoid boolean OR operators, which fall back to lexical search.",
6868
"type": "string"
6969
},
7070
"repo": {

pkg/github/inventory.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import (
1010
// This function is stateless - no dependencies are captured.
1111
// Handlers are generated on-demand during registration via RegisterAll(ctx, server, deps).
1212
// The "default" keyword in WithToolsets will expand to toolsets marked with Default: true.
13-
func NewInventory(t translations.TranslationHelperFunc) *inventory.Builder {
13+
func NewInventory(t translations.TranslationHelperFunc, opts ...ToolOption) *inventory.Builder {
1414
return inventory.NewBuilder().
15-
SetTools(AllTools(t)).
15+
SetTools(AllTools(t, opts...)).
1616
SetResources(AllResources(t)).
1717
SetPrompts(AllPrompts(t))
1818
}

pkg/github/issues.go

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1601,8 +1601,8 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri
16011601
// LegacySearchIssues register under the tool name "search_issues"; exactly one is
16021602
// active for any given request thanks to mutually exclusive FeatureFlagEnable /
16031603
// FeatureFlagDisable annotations.
1604-
func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
1605-
st := searchIssuesTool(t, true)
1604+
func SearchIssues(t translations.TranslationHelperFunc, opts ...ToolOption) inventory.ServerTool {
1605+
st := searchIssuesTool(t, true, newToolConfig(opts))
16061606
st.FeatureFlagEnable = FeatureFlagFieldsParam
16071607
return st
16081608
}
@@ -1612,23 +1612,50 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
16121612
// filters results, so it acts as the kill switch when the flag is off. It owns
16131613
// the canonical search_issues.snap; the flag-enabled variant owns
16141614
// search_issues_ff_<flag>.snap. Delete this function when the flag is removed.
1615-
func LegacySearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
1616-
st := searchIssuesTool(t, false)
1615+
func LegacySearchIssues(t translations.TranslationHelperFunc, opts ...ToolOption) inventory.ServerTool {
1616+
st := searchIssuesTool(t, false, newToolConfig(opts))
16171617
st.FeatureFlagDisable = []string{FeatureFlagFieldsParam}
16181618
return st
16191619
}
16201620

1621+
// The two search engines want opposite things from a caller, so steering advice
1622+
// for one is counterproductive for the other: semantic rewards paraphrased
1623+
// natural language and degrades on boolean operators, while lexical needs the
1624+
// caller's literal keywords and handles OR fine. The description has to describe
1625+
// the engine the host will actually use.
1626+
const (
1627+
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."
1628+
searchIssuesLexicalDescription = "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue"
1629+
1630+
searchIssuesSemanticQueryDescription = "The search query. Write it as natural language. Avoid boolean OR operators, which fall back to lexical search."
1631+
searchIssuesLexicalQueryDescription = "Search query using GitHub issues search syntax"
1632+
)
1633+
16211634
// searchIssuesTool builds the search_issues tool. When includeFields is true the
16221635
// tool advertises the optional `fields` parameter, filters each result to the
16231636
// requested subset, and emits fields telemetry. When false it is the original
16241637
// tool with no fields parameter and no filtering.
1625-
func searchIssuesTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool {
1638+
func searchIssuesTool(t translations.TranslationHelperFunc, includeFields bool, cfg toolConfig) inventory.ServerTool {
1639+
// Semantic is the default; however as it is not available on GHES, we fall back to
1640+
// lexical search for that host type.
1641+
mode := searchModeSemantic
1642+
if cfg.hostType == utils.HostTypeGHES {
1643+
mode = searchModeLexical
1644+
}
1645+
1646+
toolDescription := searchIssuesSemanticDescription
1647+
queryDescription := searchIssuesSemanticQueryDescription
1648+
if mode == searchModeLexical {
1649+
toolDescription = searchIssuesLexicalDescription
1650+
queryDescription = searchIssuesLexicalQueryDescription
1651+
}
1652+
16261653
schema := &jsonschema.Schema{
16271654
Type: "object",
16281655
Properties: map[string]*jsonschema.Schema{
16291656
"query": {
16301657
Type: "string",
1631-
Description: "Search query using GitHub issues search syntax",
1658+
Description: queryDescription,
16321659
},
16331660
"owner": {
16341661
Type: "string",
@@ -1675,7 +1702,7 @@ func searchIssuesTool(t translations.TranslationHelperFunc, includeFields bool)
16751702
ToolsetMetadataIssues,
16761703
mcp.Tool{
16771704
Name: "search_issues",
1678-
Description: t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue"),
1705+
Description: t("TOOL_SEARCH_ISSUES_DESCRIPTION", toolDescription),
16791706
Annotations: &mcp.ToolAnnotations{
16801707
Title: t("TOOL_SEARCH_ISSUES_USER_TITLE", "Search issues"),
16811708
ReadOnlyHint: true,
@@ -1692,7 +1719,7 @@ func searchIssuesTool(t translations.TranslationHelperFunc, includeFields bool)
16921719
}
16931720
options = append(options, withFieldsFiltering(deps, "search_issues", fields))
16941721
}
1695-
result, err := searchIssuesHandler(ctx, deps, args, options...)
1722+
result, err := searchIssuesHandler(ctx, deps, args, mode, options...)
16961723
return result, nil, err
16971724
})
16981725
}
@@ -1960,10 +1987,15 @@ func fetchIssueReadEnrichment(ctx context.Context, gqlClient *githubv4.Client, n
19601987
// searchIssuesHandler runs the REST issues search, enriches each hit with custom field values
19611988
// fetched via a single follow-up GraphQL nodes() query, and applies any post-process options
19621989
// (e.g. IFC labelling).
1963-
func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, options ...searchOption) (*mcp.CallToolResult, error) {
1990+
func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, mode searchMode, options ...searchOption) (*mcp.CallToolResult, error) {
19641991
const errorPrefix = "failed to search issues"
19651992

1966-
query, opts, err := prepareSearchArgs(args, "issue")
1993+
cfg := searchConfig{}
1994+
for _, opt := range options {
1995+
opt(&cfg)
1996+
}
1997+
1998+
query, opts, err := prepareSearchArgs(args, "issue", mode)
19671999
if err != nil {
19682000
return utils.NewToolResultError(err.Error()), nil
19692001
}
@@ -2013,11 +2045,6 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st
20132045
Items: items,
20142046
}
20152047

2016-
cfg := searchConfig{}
2017-
for _, opt := range options {
2018-
opt(&cfg)
2019-
}
2020-
20212048
filtered := false
20222049
var payload any = response
20232050
if len(cfg.fields) > 0 {

pkg/github/issues_test.go

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -874,11 +874,13 @@ func Test_SearchIssues(t *testing.T) {
874874
GetSearchIssues: expectQueryParams(
875875
t,
876876
map[string]string{
877-
"q": "is:issue repo:owner/repo is:open",
878-
"sort": "created",
879-
"order": "desc",
880-
"page": "1",
881-
"per_page": "30",
877+
"q": "is:issue repo:owner/repo is:open",
878+
"sort": "created",
879+
"order": "desc",
880+
"page": "1",
881+
"per_page": "30",
882+
"search_type": "semantic",
883+
"advanced_search": "true",
882884
},
883885
).andThen(
884886
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -900,11 +902,13 @@ func Test_SearchIssues(t *testing.T) {
900902
GetSearchIssues: expectQueryParams(
901903
t,
902904
map[string]string{
903-
"q": "repo:test-owner/test-repo is:issue is:open",
904-
"sort": "created",
905-
"order": "asc",
906-
"page": "1",
907-
"per_page": "30",
905+
"q": "repo:test-owner/test-repo is:issue is:open",
906+
"sort": "created",
907+
"order": "asc",
908+
"page": "1",
909+
"per_page": "30",
910+
"search_type": "semantic",
911+
"advanced_search": "true",
908912
},
909913
).andThen(
910914
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -926,9 +930,11 @@ func Test_SearchIssues(t *testing.T) {
926930
GetSearchIssues: expectQueryParams(
927931
t,
928932
map[string]string{
929-
"q": "is:issue bug",
930-
"page": "1",
931-
"per_page": "30",
933+
"q": "is:issue bug",
934+
"page": "1",
935+
"per_page": "30",
936+
"search_type": "semantic",
937+
"advanced_search": "true",
932938
},
933939
).andThen(
934940
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -947,9 +953,11 @@ func Test_SearchIssues(t *testing.T) {
947953
GetSearchIssues: expectQueryParams(
948954
t,
949955
map[string]string{
950-
"q": "is:issue feature",
951-
"page": "1",
952-
"per_page": "30",
956+
"q": "is:issue feature",
957+
"page": "1",
958+
"per_page": "30",
959+
"search_type": "semantic",
960+
"advanced_search": "true",
953961
},
954962
).andThen(
955963
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -979,9 +987,11 @@ func Test_SearchIssues(t *testing.T) {
979987
GetSearchIssues: expectQueryParams(
980988
t,
981989
map[string]string{
982-
"q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
983-
"page": "1",
984-
"per_page": "30",
990+
"q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
991+
"page": "1",
992+
"per_page": "30",
993+
"search_type": "semantic",
994+
"advanced_search": "true",
985995
},
986996
).andThen(
987997
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -999,9 +1009,11 @@ func Test_SearchIssues(t *testing.T) {
9991009
GetSearchIssues: expectQueryParams(
10001010
t,
10011011
map[string]string{
1002-
"q": "is:issue repo:github/github-mcp-server critical",
1003-
"page": "1",
1004-
"per_page": "30",
1012+
"q": "is:issue repo:github/github-mcp-server critical",
1013+
"page": "1",
1014+
"per_page": "30",
1015+
"search_type": "semantic",
1016+
"advanced_search": "true",
10051017
},
10061018
).andThen(
10071019
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1021,9 +1033,11 @@ func Test_SearchIssues(t *testing.T) {
10211033
GetSearchIssues: expectQueryParams(
10221034
t,
10231035
map[string]string{
1024-
"q": "is:issue repo:octocat/Hello-World bug",
1025-
"page": "1",
1026-
"per_page": "30",
1036+
"q": "is:issue repo:octocat/Hello-World bug",
1037+
"page": "1",
1038+
"per_page": "30",
1039+
"search_type": "semantic",
1040+
"advanced_search": "true",
10271041
},
10281042
).andThen(
10291043
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1041,9 +1055,11 @@ func Test_SearchIssues(t *testing.T) {
10411055
GetSearchIssues: expectQueryParams(
10421056
t,
10431057
map[string]string{
1044-
"q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
1045-
"page": "1",
1046-
"per_page": "30",
1058+
"q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
1059+
"page": "1",
1060+
"per_page": "30",
1061+
"search_type": "semantic",
1062+
"advanced_search": "true",
10471063
},
10481064
).andThen(
10491065
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1064,6 +1080,7 @@ func Test_SearchIssues(t *testing.T) {
10641080
"q": "is:issue field.priority:P1",
10651081
"page": "1",
10661082
"per_page": "30",
1083+
"search_type": "semantic",
10671084
"advanced_search": "true",
10681085
},
10691086
).andThen(
@@ -1077,14 +1094,16 @@ func Test_SearchIssues(t *testing.T) {
10771094
expectedResult: mockSearchResult,
10781095
},
10791096
{
1080-
name: "query without field. qualifier does not set advanced_search",
1097+
name: "semantic search sets search_type and advanced_search",
10811098
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
10821099
GetSearchIssues: expectQueryParams(
10831100
t,
10841101
map[string]string{
1085-
"q": "is:issue is:open",
1086-
"page": "1",
1087-
"per_page": "30",
1102+
"q": "is:issue is:open",
1103+
"page": "1",
1104+
"per_page": "30",
1105+
"search_type": "semantic",
1106+
"advanced_search": "true",
10881107
},
10891108
).andThen(
10901109
mockResponse(t, http.StatusOK, mockSearchResult),

0 commit comments

Comments
 (0)