Skip to content

Commit ccd71b0

Browse files
authored
Make search_issues semantic by default
1 parent 22b6d74 commit ccd71b0

15 files changed

Lines changed: 369 additions & 72 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, as natural language. When the user gives alternative wordings, include them as plain words rather than joining them with OR. (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, as natural language. When the user gives alternative wordings, include them as plain words rather than joining them with OR. (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, as natural language. When the user gives alternative wordings, include them as plain words rather than joining them with OR. (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, as natural language. When the user gives alternative wordings, include them as plain words rather than joining them with OR.",
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, as natural language. When the user gives alternative wordings, include them as plain words rather than joining them with OR.",
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, as natural language. When the user gives alternative wordings, include them as plain words rather than joining them with OR."
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: 42 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -874,11 +874,12 @@ 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",
882883
},
883884
).andThen(
884885
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -900,11 +901,12 @@ func Test_SearchIssues(t *testing.T) {
900901
GetSearchIssues: expectQueryParams(
901902
t,
902903
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",
904+
"q": "repo:test-owner/test-repo is:issue is:open",
905+
"sort": "created",
906+
"order": "asc",
907+
"page": "1",
908+
"per_page": "30",
909+
"search_type": "semantic",
908910
},
909911
).andThen(
910912
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -926,9 +928,10 @@ func Test_SearchIssues(t *testing.T) {
926928
GetSearchIssues: expectQueryParams(
927929
t,
928930
map[string]string{
929-
"q": "is:issue bug",
930-
"page": "1",
931-
"per_page": "30",
931+
"q": "is:issue bug",
932+
"page": "1",
933+
"per_page": "30",
934+
"search_type": "semantic",
932935
},
933936
).andThen(
934937
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -947,9 +950,10 @@ func Test_SearchIssues(t *testing.T) {
947950
GetSearchIssues: expectQueryParams(
948951
t,
949952
map[string]string{
950-
"q": "is:issue feature",
951-
"page": "1",
952-
"per_page": "30",
953+
"q": "is:issue feature",
954+
"page": "1",
955+
"per_page": "30",
956+
"search_type": "semantic",
953957
},
954958
).andThen(
955959
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -979,9 +983,10 @@ func Test_SearchIssues(t *testing.T) {
979983
GetSearchIssues: expectQueryParams(
980984
t,
981985
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",
986+
"q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
987+
"page": "1",
988+
"per_page": "30",
989+
"search_type": "semantic",
985990
},
986991
).andThen(
987992
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -999,9 +1004,10 @@ func Test_SearchIssues(t *testing.T) {
9991004
GetSearchIssues: expectQueryParams(
10001005
t,
10011006
map[string]string{
1002-
"q": "is:issue repo:github/github-mcp-server critical",
1003-
"page": "1",
1004-
"per_page": "30",
1007+
"q": "is:issue repo:github/github-mcp-server critical",
1008+
"page": "1",
1009+
"per_page": "30",
1010+
"search_type": "semantic",
10051011
},
10061012
).andThen(
10071013
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1021,9 +1027,10 @@ func Test_SearchIssues(t *testing.T) {
10211027
GetSearchIssues: expectQueryParams(
10221028
t,
10231029
map[string]string{
1024-
"q": "is:issue repo:octocat/Hello-World bug",
1025-
"page": "1",
1026-
"per_page": "30",
1030+
"q": "is:issue repo:octocat/Hello-World bug",
1031+
"page": "1",
1032+
"per_page": "30",
1033+
"search_type": "semantic",
10271034
},
10281035
).andThen(
10291036
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1041,9 +1048,10 @@ func Test_SearchIssues(t *testing.T) {
10411048
GetSearchIssues: expectQueryParams(
10421049
t,
10431050
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",
1051+
"q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
1052+
"page": "1",
1053+
"per_page": "30",
1054+
"search_type": "semantic",
10471055
},
10481056
).andThen(
10491057
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1064,6 +1072,7 @@ func Test_SearchIssues(t *testing.T) {
10641072
"q": "is:issue field.priority:P1",
10651073
"page": "1",
10661074
"per_page": "30",
1075+
"search_type": "semantic",
10671076
"advanced_search": "true",
10681077
},
10691078
).andThen(
@@ -1077,14 +1086,15 @@ func Test_SearchIssues(t *testing.T) {
10771086
expectedResult: mockSearchResult,
10781087
},
10791088
{
1080-
name: "query without field. qualifier does not set advanced_search",
1089+
name: "semantic search sets search_type",
10811090
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
10821091
GetSearchIssues: expectQueryParams(
10831092
t,
10841093
map[string]string{
1085-
"q": "is:issue is:open",
1086-
"page": "1",
1087-
"per_page": "30",
1094+
"q": "is:issue is:open",
1095+
"page": "1",
1096+
"per_page": "30",
1097+
"search_type": "semantic",
10881098
},
10891099
).andThen(
10901100
mockResponse(t, http.StatusOK, mockSearchResult),

0 commit comments

Comments
 (0)