Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions pkg/github/__toolsnaps__/search_issues.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": [
Expand Down
19 changes: 16 additions & 3 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -1870,9 +1870,18 @@ 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"},
}
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,
Expand All @@ -1892,13 +1901,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
})
}
Expand Down
121 changes: 90 additions & 31 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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(
Expand All @@ -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",
Expand All @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions pkg/github/search_mode_regression_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading