Skip to content

Commit ae1f748

Browse files
authored
Make search_issues semantic by default
1 parent 456fae9 commit ae1f748

12 files changed

Lines changed: 357 additions & 58 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -976,7 +976,7 @@ The following sets of tools are available:
976976
- `owner`: Optional repository owner. If provided with repo, only issues for this repository are listed. (string, optional)
977977
- `page`: Page number for pagination (min 1) (number, optional)
978978
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
979-
- `query`: Search query using GitHub issues search syntax (string, required)
979+
- `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)
980980
- `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional)
981981
- `sort`: Sort field by number of matches of categories, defaults to best match (string, optional)
982982

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
"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: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1595,14 +1595,43 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri
15951595
return utils.NewToolResultText(string(r)), nil
15961596
}
15971597

1598+
// The two search engines want opposite things from a caller, so steering advice
1599+
// for one is counterproductive for the other: semantic rewards paraphrased
1600+
// natural language and degrades on boolean operators, while lexical needs the
1601+
// caller's literal keywords and handles OR fine. The description has to describe
1602+
// the engine the host will actually use.
1603+
const (
1604+
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."
1605+
searchIssuesLexicalDescription = "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue"
1606+
1607+
searchIssuesSemanticQueryDescription = "The search query, as natural language. When the user gives alternative wordings, include them as plain words rather than joining them with OR."
1608+
searchIssuesLexicalQueryDescription = "Search query using GitHub issues search syntax"
1609+
)
1610+
15981611
// SearchIssues creates a tool to search for issues.
1599-
func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
1612+
func SearchIssues(t translations.TranslationHelperFunc, opts ...ToolOption) inventory.ServerTool {
1613+
cfg := newToolConfig(opts)
1614+
1615+
// Semantic is the default; however as it is not available on GHES, we fall back to
1616+
// lexical search for that host type.
1617+
mode := searchModeSemantic
1618+
if cfg.hostType == utils.HostTypeGHES {
1619+
mode = searchModeLexical
1620+
}
1621+
1622+
toolDescription := searchIssuesSemanticDescription
1623+
queryDescription := searchIssuesSemanticQueryDescription
1624+
if mode == searchModeLexical {
1625+
toolDescription = searchIssuesLexicalDescription
1626+
queryDescription = searchIssuesLexicalQueryDescription
1627+
}
1628+
16001629
schema := &jsonschema.Schema{
16011630
Type: "object",
16021631
Properties: map[string]*jsonschema.Schema{
16031632
"query": {
16041633
Type: "string",
1605-
Description: "Search query using GitHub issues search syntax",
1634+
Description: queryDescription,
16061635
},
16071636
"owner": {
16081637
Type: "string",
@@ -1647,7 +1676,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
16471676
ToolsetMetadataIssues,
16481677
mcp.Tool{
16491678
Name: "search_issues",
1650-
Description: t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue"),
1679+
Description: t("TOOL_SEARCH_ISSUES_DESCRIPTION", toolDescription),
16511680
Annotations: &mcp.ToolAnnotations{
16521681
Title: t("TOOL_SEARCH_ISSUES_USER_TITLE", "Search issues"),
16531682
ReadOnlyHint: true,
@@ -1662,7 +1691,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
16621691
return utils.NewToolResultError(err.Error()), nil, nil
16631692
}
16641693
options = append(options, withFieldsFiltering(deps, "search_issues", fields))
1665-
result, err := searchIssuesHandler(ctx, deps, args, options...)
1694+
result, err := searchIssuesHandler(ctx, deps, args, mode, options...)
16661695
return result, nil, err
16671696
})
16681697
}
@@ -1930,10 +1959,10 @@ func fetchIssueReadEnrichment(ctx context.Context, gqlClient *githubv4.Client, n
19301959
// searchIssuesHandler runs the REST issues search, enriches each hit with custom field values
19311960
// fetched via a single follow-up GraphQL nodes() query, and applies any post-process options
19321961
// (e.g. IFC labelling).
1933-
func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, options ...searchOption) (*mcp.CallToolResult, error) {
1962+
func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, mode searchMode, options ...searchOption) (*mcp.CallToolResult, error) {
19341963
const errorPrefix = "failed to search issues"
19351964

1936-
query, opts, err := prepareSearchArgs(args, "issue")
1965+
query, opts, err := prepareSearchArgs(args, "issue", mode)
19371966
if err != nil {
19381967
return utils.NewToolResultError(err.Error()), nil
19391968
}

pkg/github/issues_test.go

Lines changed: 42 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -870,11 +870,12 @@ func Test_SearchIssues(t *testing.T) {
870870
GetSearchIssues: expectQueryParams(
871871
t,
872872
map[string]string{
873-
"q": "is:issue repo:owner/repo is:open",
874-
"sort": "created",
875-
"order": "desc",
876-
"page": "1",
877-
"per_page": "30",
873+
"q": "is:issue repo:owner/repo is:open",
874+
"sort": "created",
875+
"order": "desc",
876+
"page": "1",
877+
"per_page": "30",
878+
"search_type": "semantic",
878879
},
879880
).andThen(
880881
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -896,11 +897,12 @@ func Test_SearchIssues(t *testing.T) {
896897
GetSearchIssues: expectQueryParams(
897898
t,
898899
map[string]string{
899-
"q": "repo:test-owner/test-repo is:issue is:open",
900-
"sort": "created",
901-
"order": "asc",
902-
"page": "1",
903-
"per_page": "30",
900+
"q": "repo:test-owner/test-repo is:issue is:open",
901+
"sort": "created",
902+
"order": "asc",
903+
"page": "1",
904+
"per_page": "30",
905+
"search_type": "semantic",
904906
},
905907
).andThen(
906908
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -922,9 +924,10 @@ func Test_SearchIssues(t *testing.T) {
922924
GetSearchIssues: expectQueryParams(
923925
t,
924926
map[string]string{
925-
"q": "is:issue bug",
926-
"page": "1",
927-
"per_page": "30",
927+
"q": "is:issue bug",
928+
"page": "1",
929+
"per_page": "30",
930+
"search_type": "semantic",
928931
},
929932
).andThen(
930933
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -943,9 +946,10 @@ func Test_SearchIssues(t *testing.T) {
943946
GetSearchIssues: expectQueryParams(
944947
t,
945948
map[string]string{
946-
"q": "is:issue feature",
947-
"page": "1",
948-
"per_page": "30",
949+
"q": "is:issue feature",
950+
"page": "1",
951+
"per_page": "30",
952+
"search_type": "semantic",
949953
},
950954
).andThen(
951955
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -975,9 +979,10 @@ func Test_SearchIssues(t *testing.T) {
975979
GetSearchIssues: expectQueryParams(
976980
t,
977981
map[string]string{
978-
"q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
979-
"page": "1",
980-
"per_page": "30",
982+
"q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
983+
"page": "1",
984+
"per_page": "30",
985+
"search_type": "semantic",
981986
},
982987
).andThen(
983988
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -995,9 +1000,10 @@ func Test_SearchIssues(t *testing.T) {
9951000
GetSearchIssues: expectQueryParams(
9961001
t,
9971002
map[string]string{
998-
"q": "is:issue repo:github/github-mcp-server critical",
999-
"page": "1",
1000-
"per_page": "30",
1003+
"q": "is:issue repo:github/github-mcp-server critical",
1004+
"page": "1",
1005+
"per_page": "30",
1006+
"search_type": "semantic",
10011007
},
10021008
).andThen(
10031009
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1017,9 +1023,10 @@ func Test_SearchIssues(t *testing.T) {
10171023
GetSearchIssues: expectQueryParams(
10181024
t,
10191025
map[string]string{
1020-
"q": "is:issue repo:octocat/Hello-World bug",
1021-
"page": "1",
1022-
"per_page": "30",
1026+
"q": "is:issue repo:octocat/Hello-World bug",
1027+
"page": "1",
1028+
"per_page": "30",
1029+
"search_type": "semantic",
10231030
},
10241031
).andThen(
10251032
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1037,9 +1044,10 @@ func Test_SearchIssues(t *testing.T) {
10371044
GetSearchIssues: expectQueryParams(
10381045
t,
10391046
map[string]string{
1040-
"q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
1041-
"page": "1",
1042-
"per_page": "30",
1047+
"q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
1048+
"page": "1",
1049+
"per_page": "30",
1050+
"search_type": "semantic",
10431051
},
10441052
).andThen(
10451053
mockResponse(t, http.StatusOK, mockSearchResult),
@@ -1060,6 +1068,7 @@ func Test_SearchIssues(t *testing.T) {
10601068
"q": "is:issue field.priority:P1",
10611069
"page": "1",
10621070
"per_page": "30",
1071+
"search_type": "semantic",
10631072
"advanced_search": "true",
10641073
},
10651074
).andThen(
@@ -1073,14 +1082,15 @@ func Test_SearchIssues(t *testing.T) {
10731082
expectedResult: mockSearchResult,
10741083
},
10751084
{
1076-
name: "query without field. qualifier does not set advanced_search",
1085+
name: "semantic search sets search_type",
10771086
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
10781087
GetSearchIssues: expectQueryParams(
10791088
t,
10801089
map[string]string{
1081-
"q": "is:issue is:open",
1082-
"page": "1",
1083-
"per_page": "30",
1090+
"q": "is:issue is:open",
1091+
"page": "1",
1092+
"per_page": "30",
1093+
"search_type": "semantic",
10841094
},
10851095
).andThen(
10861096
mockResponse(t, http.StatusOK, mockSearchResult),

pkg/github/search_semantic_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package github
2+
3+
import (
4+
"testing"
5+
6+
"github.com/github/github-mcp-server/pkg/translations"
7+
"github.com/github/github-mcp-server/pkg/utils"
8+
"github.com/google/jsonschema-go/jsonschema"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func Test_stripFreeTextQuotes(t *testing.T) {
14+
t.Parallel()
15+
16+
tests := []struct {
17+
name string
18+
query string
19+
expected string
20+
}{
21+
{
22+
name: "leaves an unquoted query alone",
23+
query: "is:issue sticky sidebar",
24+
expected: "is:issue sticky sidebar",
25+
},
26+
{
27+
name: "strips quotes around free text",
28+
query: `is:issue "sticky sidebar"`,
29+
expected: "is:issue sticky sidebar",
30+
},
31+
{
32+
name: "preserves quotes around a multi-word qualifier value",
33+
query: `is:issue label:"needs triage"`,
34+
expected: `is:issue label:"needs triage"`,
35+
},
36+
{
37+
name: "strips free text but preserves the qualifier alongside it",
38+
query: `is:issue label:"needs triage" "sticky sidebar"`,
39+
expected: `is:issue label:"needs triage" sticky sidebar`,
40+
},
41+
{
42+
name: "preserves quotes on a hyphenated qualifier",
43+
query: `is:issue state-reason:"not planned"`,
44+
expected: `is:issue state-reason:"not planned"`,
45+
},
46+
{
47+
name: "preserves quotes on a dotted custom field qualifier",
48+
query: `is:issue field.priority:"P1 urgent"`,
49+
expected: `is:issue field.priority:"P1 urgent"`,
50+
},
51+
{
52+
name: "preserves quotes on a negated qualifier",
53+
query: `is:issue -label:"wont fix"`,
54+
expected: `is:issue -label:"wont fix"`,
55+
},
56+
}
57+
58+
for _, tt := range tests {
59+
t.Run(tt.name, func(t *testing.T) {
60+
t.Parallel()
61+
assert.Equal(t, tt.expected, stripFreeTextQuotes(tt.query))
62+
})
63+
}
64+
}
65+
66+
func Test_searchIssuesTool_descriptionMatchesEngine(t *testing.T) {
67+
t.Parallel()
68+
69+
// The description has to describe the engine the host will actually use.
70+
// Steering a lexical-only host toward paraphrased natural language is actively misleading.
71+
semantic := SearchIssues(translations.NullTranslationHelper, WithHost(utils.HostTypeDotcom))
72+
lexical := SearchIssues(translations.NullTranslationHelper, WithHost(utils.HostTypeGHES))
73+
74+
require.Equal(t, "search_issues", semantic.Tool.Name)
75+
require.Equal(t, "search_issues", lexical.Tool.Name)
76+
77+
assert.Equal(t, searchIssuesSemanticDescription, semantic.Tool.Description)
78+
assert.Equal(t, searchIssuesLexicalDescription, lexical.Tool.Description)
79+
80+
semanticSchema, ok := semantic.Tool.InputSchema.(*jsonschema.Schema)
81+
require.True(t, ok)
82+
lexicalSchema, ok := lexical.Tool.InputSchema.(*jsonschema.Schema)
83+
require.True(t, ok)
84+
85+
assert.Equal(t, searchIssuesSemanticQueryDescription, semanticSchema.Properties["query"].Description)
86+
assert.Equal(t, searchIssuesLexicalQueryDescription, lexicalSchema.Properties["query"].Description)
87+
}

0 commit comments

Comments
 (0)