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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -976,7 +976,7 @@ 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`: Search query using GitHub issues search syntax (string, required)
- `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)
- `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional)
- `sort`: Sort field by number of matches of categories, defaults to best match (string, optional)

Expand Down
7 changes: 6 additions & 1 deletion internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
return nil, fmt.Errorf("failed to parse API host: %w", err)
}

hostType, err := utils.ParseHostType(cfg.Host)
if err != nil {
return nil, fmt.Errorf("failed to classify API host: %w", err)
}

clients, err := createGitHubClients(cfg, apiHost)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
Expand Down Expand Up @@ -165,7 +170,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
obs,
)
// Build and register the tool/resource/prompt inventory
inventoryBuilder := github.NewInventory(cfg.Translator).
inventoryBuilder := github.NewInventory(cfg.Translator, github.WithHost(hostType)).
WithDeprecatedAliases(github.DeprecatedToolAliases).
WithReadOnly(cfg.ReadOnly).
WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)).
Expand Down
4 changes: 2 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 for issues in GitHub repositories using issues search syntax already scoped to is:issue",
"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.",
"inputSchema": {
"properties": {
"fields": {
Expand Down Expand Up @@ -64,7 +64,7 @@
"type": "number"
},
"query": {
"description": "Search query using GitHub issues search syntax",
"description": "The search query, as natural language. When the user gives alternative wordings, include them as plain words rather than joining them with OR.",
"type": "string"
},
"repo": {
Expand Down
4 changes: 2 additions & 2 deletions pkg/github/inventory.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import (
// This function is stateless - no dependencies are captured.
// Handlers are generated on-demand during registration via RegisterAll(ctx, server, deps).
// The "default" keyword in WithToolsets will expand to toolsets marked with Default: true.
func NewInventory(t translations.TranslationHelperFunc) *inventory.Builder {
func NewInventory(t translations.TranslationHelperFunc, opts ...ToolOption) *inventory.Builder {
return inventory.NewBuilder().
SetTools(AllTools(t)).
SetTools(AllTools(t, opts...)).
SetResources(AllResources(t)).
SetPrompts(AllPrompts(t))
}
41 changes: 35 additions & 6 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -1595,14 +1595,43 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri
return utils.NewToolResultText(string(r)), nil
}

// The two search engines want opposite things from a caller, so steering advice
// for one is counterproductive for the other: semantic rewards paraphrased
// natural language and degrades on boolean operators, while lexical needs the
// 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."
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."
searchIssuesLexicalQueryDescription = "Search query using GitHub issues search syntax"
)

// SearchIssues creates a tool to search for issues.
func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
func SearchIssues(t translations.TranslationHelperFunc, opts ...ToolOption) inventory.ServerTool {
cfg := newToolConfig(opts)

// Semantic is the default; however as it is not available on GHES, we fall back to
// lexical search for that host type.
mode := searchModeSemantic
if cfg.hostType == utils.HostTypeGHES {
mode = searchModeLexical
}

toolDescription := searchIssuesSemanticDescription
queryDescription := searchIssuesSemanticQueryDescription
if mode == searchModeLexical {
toolDescription = searchIssuesLexicalDescription
queryDescription = searchIssuesLexicalQueryDescription
}

schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"query": {
Type: "string",
Description: "Search query using GitHub issues search syntax",
Description: queryDescription,
},
"owner": {
Type: "string",
Expand Down Expand Up @@ -1647,7 +1676,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
ToolsetMetadataIssues,
mcp.Tool{
Name: "search_issues",
Description: t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue"),
Description: t("TOOL_SEARCH_ISSUES_DESCRIPTION", toolDescription),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_SEARCH_ISSUES_USER_TITLE", "Search issues"),
ReadOnlyHint: true,
Expand All @@ -1662,7 +1691,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultError(err.Error()), nil, nil
}
options = append(options, withFieldsFiltering(deps, "search_issues", fields))
result, err := searchIssuesHandler(ctx, deps, args, options...)
result, err := searchIssuesHandler(ctx, deps, args, mode, options...)
return result, nil, err
})
}
Expand Down Expand Up @@ -1930,10 +1959,10 @@ func fetchIssueReadEnrichment(ctx context.Context, gqlClient *githubv4.Client, n
// searchIssuesHandler runs the REST issues search, enriches each hit with custom field values
// fetched via a single follow-up GraphQL nodes() query, and applies any post-process options
// (e.g. IFC labelling).
func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, options ...searchOption) (*mcp.CallToolResult, error) {
func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, mode searchMode, options ...searchOption) (*mcp.CallToolResult, error) {
const errorPrefix = "failed to search issues"

query, opts, err := prepareSearchArgs(args, "issue")
query, opts, err := prepareSearchArgs(args, "issue", mode)
if err != nil {
return utils.NewToolResultError(err.Error()), nil
}
Expand Down
74 changes: 42 additions & 32 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -870,11 +870,12 @@ 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",
"q": "is:issue repo:owner/repo is:open",
"sort": "created",
"order": "desc",
"page": "1",
"per_page": "30",
"search_type": "semantic",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
Expand All @@ -896,11 +897,12 @@ 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",
"q": "repo:test-owner/test-repo is:issue is:open",
"sort": "created",
"order": "asc",
"page": "1",
"per_page": "30",
"search_type": "semantic",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
Expand All @@ -922,9 +924,10 @@ func Test_SearchIssues(t *testing.T) {
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue bug",
"page": "1",
"per_page": "30",
"q": "is:issue bug",
"page": "1",
"per_page": "30",
"search_type": "semantic",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
Expand All @@ -943,9 +946,10 @@ func Test_SearchIssues(t *testing.T) {
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue feature",
"page": "1",
"per_page": "30",
"q": "is:issue feature",
"page": "1",
"per_page": "30",
"search_type": "semantic",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
Expand Down Expand Up @@ -975,9 +979,10 @@ 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",
"q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
"page": "1",
"per_page": "30",
"search_type": "semantic",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
Expand All @@ -995,9 +1000,10 @@ 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",
"q": "is:issue repo:github/github-mcp-server critical",
"page": "1",
"per_page": "30",
"search_type": "semantic",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
Expand All @@ -1017,9 +1023,10 @@ 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",
"q": "is:issue repo:octocat/Hello-World bug",
"page": "1",
"per_page": "30",
"search_type": "semantic",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
Expand All @@ -1037,9 +1044,10 @@ 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",
"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",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
Expand All @@ -1060,6 +1068,7 @@ 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 @@ -1073,14 +1082,15 @@ func Test_SearchIssues(t *testing.T) {
expectedResult: mockSearchResult,
},
{
name: "query without field. qualifier does not set advanced_search",
name: "semantic search sets search_type",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue is:open",
"page": "1",
"per_page": "30",
"q": "is:issue is:open",
"page": "1",
"per_page": "30",
"search_type": "semantic",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
Expand Down
87 changes: 87 additions & 0 deletions pkg/github/search_semantic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package github

import (
"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/assert"
"github.com/stretchr/testify/require"
)

func Test_stripFreeTextQuotes(t *testing.T) {
t.Parallel()

tests := []struct {
name string
query string
expected string
}{
{
name: "leaves an unquoted query alone",
query: "is:issue sticky sidebar",
expected: "is:issue sticky sidebar",
},
{
name: "strips quotes around free text",
query: `is:issue "sticky sidebar"`,
expected: "is:issue sticky sidebar",
},
{
name: "preserves quotes around a multi-word qualifier value",
query: `is:issue label:"needs triage"`,
expected: `is:issue label:"needs triage"`,
},
{
name: "strips free text but preserves the qualifier alongside it",
query: `is:issue label:"needs triage" "sticky sidebar"`,
expected: `is:issue label:"needs triage" sticky sidebar`,
},
{
name: "preserves quotes on a hyphenated qualifier",
query: `is:issue state-reason:"not planned"`,
expected: `is:issue state-reason:"not planned"`,
},
{
name: "preserves quotes on a dotted custom field qualifier",
query: `is:issue field.priority:"P1 urgent"`,
expected: `is:issue field.priority:"P1 urgent"`,
},
{
name: "preserves quotes on a negated qualifier",
query: `is:issue -label:"wont fix"`,
expected: `is:issue -label:"wont fix"`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, stripFreeTextQuotes(tt.query))
})
}
}

func Test_searchIssuesTool_descriptionMatchesEngine(t *testing.T) {
t.Parallel()

// The description has to describe the engine the host will actually use.
// Steering a lexical-only host toward paraphrased natural language is actively misleading.
semantic := SearchIssues(translations.NullTranslationHelper, WithHost(utils.HostTypeDotcom))
lexical := SearchIssues(translations.NullTranslationHelper, WithHost(utils.HostTypeGHES))

require.Equal(t, "search_issues", semantic.Tool.Name)
require.Equal(t, "search_issues", lexical.Tool.Name)

assert.Equal(t, searchIssuesSemanticDescription, semantic.Tool.Description)
assert.Equal(t, searchIssuesLexicalDescription, lexical.Tool.Description)

semanticSchema, ok := semantic.Tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok)
lexicalSchema, ok := lexical.Tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok)

assert.Equal(t, searchIssuesSemanticQueryDescription, semanticSchema.Properties["query"].Description)
assert.Equal(t, searchIssuesLexicalQueryDescription, lexicalSchema.Properties["query"].Description)
}
Loading
Loading