Files
github--github-mcp-server/pkg/github/search_test.go
Ross Tarrant f929c58c6b feat: Add CSV output format for default list tools under insiders mode (#2450)
* Add CSV output for list tools under insiders mode

* fix: resolve rebase feature flag conflicts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Simplify feature-flag handling: collapse CSV dual-variant + skip filtering when no checker (#2516)

* refactor: generic toolset+name sort, clarify feature flag intent

Address review feedback on #2450:

- Collapse the three near-identical sort helpers in pkg/inventory/filters.go
  into a generic sortByToolsetThenName so adding new inventory item types
  doesn't require copying the comparator.
- Expand the doc comments on the three *WithoutFeatureFiltering helpers to
  spell out why they exist: HTTP mode builds a static (process-wide)
  inventory as an upper bound, but per-request feature flags from headers
  (X-MCP-Features, X-MCP-Insiders) are evaluated later, so feature-flagged
  variants must be preserved here.
- Strengthen the doc comment on ResolveFeatureFlags to make the contract
  explicit: user-supplied flags are validated against AllowedFeatureFlags,
  but insiders expansion deliberately is not — InsidersFeatureFlags may
  include server-controlled flags that are not user-toggleable.

CORS comments are intentionally left for the PR author.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(feature-flags): clarify allowed and insiders sets are independent

Also add tests covering:
- a user-toggleable flag (FeatureFlagIssuesGranular) that insiders does
  not turn on automatically
- insiders mode not turning on user-only allowed flags

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(inventory): collapse three *WithoutFeatureFiltering helpers into StaticUpperBound

The three parallel methods (AvailableToolsWithoutFeatureFiltering,
AvailableResourceTemplatesWithoutFeatureFiltering,
AvailablePromptsWithoutFeatureFiltering) were always called as a triple
in exactly two places: HTTP buildStaticInventory and its test mirror.
They exist because the dual-variant pattern (sibling tools with mirrored
FeatureFlagEnable / FeatureFlagDisable on the same name, e.g. CSV output)
makes feature filtering at static-build time impossible — both variants
must be kept and resolved per-request.

Replace the three with one method, Inventory.StaticUpperBound(ctx), that
returns (tools, resources, prompts) and carries the rationale in its
doc comment. Reduces API surface, eliminates the triplication, and makes
the single "skip feature filtering" concept obvious to readers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor: simplify feature-flag handling

Two related simplifications, both about treating insiders as a meta flag
that expands once at startup and then stops mattering:

- Collapse CSV's dual-variant pattern into a single tool whose handler
  performs a runtime feature-flag check via deps.IsFeatureEnabled. CSV
  is a pure response-format toggle, not a schema change, so it does not
  need the dual-name pattern that genuine schema variants (granular
  issues/PRs) still use.

- When no feature checker is installed, skip feature-flag filtering and
  return the full upper bound. The static HTTP inventory now uses plain
  AvailableTools/Resources/Prompts; the per-request inventory always
  installs a checker, so MCP registration (which serves a tool name once)
  always sees a deduplicated set. The bespoke StaticUpperBound helper and
  the isToolEnabledWithFeatureFlags split go away.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* ci(mcp-diff): add insiders + per-feature configs

The mcp-diff matrix now includes:
  - --insiders (and --insiders --read-only)
  - one config per github.AllowedFeatureFlags entry, generated by
    script/print-mcp-diff-configs so new user-controllable flags get
    diffed automatically without editing the workflow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(insiders): explain feature-flag resolution for contributors

Adds a 'How feature flags are resolved' section covering:
  - Insiders is a meta flag, like 'all'/'default' for toolsets
  - User input -> allowlist filter -> insiders expansion ->
    server-side fallback (remote only)
  - AllowedFeatureFlags vs InsidersFeatureFlags are independent
  - How to add a new feature flag, including the
    TestGitHubPackageDoesNotReadInsidersMode guard

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(inventory): make feature-flag gating a regular ToolFilter

Move tool feature-flag evaluation out of isToolEnabled and into a
ToolFilter installed at the head of the pipeline by Build() when
WithFeatureChecker received a non-nil checker. The 'no checker = no
filtering' contract is now expressed structurally (the filter isn't
installed) instead of by a runtime nil check inside the helper.

Resources and prompts have no filter pipeline, so they call the now-pure
featureFlagAllowed helper behind an explicit r.featureChecker != nil
guard at the iteration site.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* perf(inventory): cache extracted toolset IDs in sort comparator

Avoid evaluating the extractor closures up to three times per comparison.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: correct MCP features header in cors

* docs: regenerate README for CSV output toolset

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: remove duplicate MCPFeaturesHeader from CORS headers

* ci(mcp-diff): add streamable-http job with header-based configs

Adds a sibling mcp-diff-http job that exercises the streamable-http
transport against a shared HTTP server, with per-config settings supplied
via X-MCP-* request headers — mirroring how the remote server is invoked
in production (server-side defaults + per-user header overrides).

The config generator gains a -transport flag:
- stdio (default, unchanged behaviour)
- http-headers (emits headers-only configs targeting a shared server)

Two new combined entries layer multiple headers together as a smoke test
for header-merging regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: regenerate after merging main

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Sam Morrow <info@sam-morrow.com>
Co-authored-by: sammorrowdrums <sammorrowdrums@github.com>
2026-05-21 16:50:55 +02:00

1016 lines
32 KiB
Go

package github
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v87/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_SearchRepositories(t *testing.T) {
// Verify tool definition once
serverTool := SearchRepositories(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "search_repositories", tool.Name)
assert.NotEmpty(t, tool.Description)
schema, ok := tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
assert.Contains(t, schema.Properties, "query")
assert.Contains(t, schema.Properties, "sort")
assert.Contains(t, schema.Properties, "order")
assert.Contains(t, schema.Properties, "page")
assert.Contains(t, schema.Properties, "perPage")
assert.ElementsMatch(t, schema.Required, []string{"query"})
// Setup mock search results
mockSearchResult := &github.RepositoriesSearchResult{
Total: github.Ptr(2),
IncompleteResults: github.Ptr(false),
Repositories: []*github.Repository{
{
ID: github.Ptr(int64(12345)),
Name: github.Ptr("repo-1"),
FullName: github.Ptr("owner/repo-1"),
HTMLURL: github.Ptr("https://github.com/owner/repo-1"),
Description: github.Ptr("Test repository 1"),
StargazersCount: github.Ptr(100),
},
{
ID: github.Ptr(int64(67890)),
Name: github.Ptr("repo-2"),
FullName: github.Ptr("owner/repo-2"),
HTMLURL: github.Ptr("https://github.com/owner/repo-2"),
Description: github.Ptr("Test repository 2"),
StargazersCount: github.Ptr(50),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedResult *github.RepositoriesSearchResult
expectedErrMsg string
}{
{
name: "successful repository search",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchRepositories: expectQueryParams(t, map[string]string{
"q": "golang test",
"sort": "stars",
"order": "desc",
"page": "2",
"per_page": "10",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "golang test",
"sort": "stars",
"order": "desc",
"page": float64(2),
"perPage": float64(10),
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "repository search with default pagination",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchRepositories: expectQueryParams(t, map[string]string{
"q": "golang test",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "golang test",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchRepositories: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message": "Invalid query"}`))
}),
}),
requestArgs: map[string]any{
"query": "invalid:query",
},
expectError: true,
expectedErrMsg: "failed to search repositories",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedResult MinimalSearchRepositoriesResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)
assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount)
assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults)
assert.Len(t, returnedResult.Items, len(tc.expectedResult.Repositories))
for i, repo := range returnedResult.Items {
assert.Equal(t, *tc.expectedResult.Repositories[i].ID, repo.ID)
assert.Equal(t, *tc.expectedResult.Repositories[i].Name, repo.Name)
assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, repo.FullName)
assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, repo.HTMLURL)
}
})
}
}
func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) {
t.Parallel()
serverTool := SearchRepositories(translations.NullTranslationHelper)
type repoFixture struct {
owner string
name string
isPrivate bool
}
makeRepo := func(r repoFixture) *github.Repository {
return &github.Repository{
ID: github.Ptr(int64(1)),
Name: github.Ptr(r.name),
FullName: github.Ptr(r.owner + "/" + r.name),
Private: github.Ptr(r.isPrivate),
Owner: &github.User{Login: github.Ptr(r.owner)},
}
}
makeMockClient := func(repos []repoFixture) *http.Client {
searchResult := &github.RepositoriesSearchResult{
Total: github.Ptr(len(repos)),
IncompleteResults: github.Ptr(false),
}
for _, r := range repos {
searchResult.Repositories = append(searchResult.Repositories, makeRepo(r))
}
return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchRepositories: mockResponse(t, http.StatusOK, searchResult),
})
}
reqParams := map[string]any{"query": "octocat"}
t.Run("insiders mode disabled omits ifc label", func(t *testing.T) {
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient([]repoFixture{{owner: "octocat", name: "public-repo"}})),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
assert.Nil(t, result.Meta)
})
t.Run("insiders mode all public emits public untrusted", func(t *testing.T) {
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient([]repoFixture{
{owner: "octocat", name: "public-a"},
{owner: "octocat", name: "public-b"},
})),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "public", ifcMap["confidentiality"])
})
t.Run("insiders mode any private match emits private untrusted", func(t *testing.T) {
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient([]repoFixture{
{owner: "octocat", name: "private-repo", isPrivate: true},
{owner: "octocat", name: "public-repo"},
})),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "private", ifcMap["confidentiality"])
})
t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) {
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient(nil)),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "public", ifcMap["confidentiality"])
})
}
func Test_SearchRepositories_FullOutput(t *testing.T) {
mockSearchResult := &github.RepositoriesSearchResult{
Total: github.Ptr(1),
IncompleteResults: github.Ptr(false),
Repositories: []*github.Repository{
{
ID: github.Ptr(int64(12345)),
Name: github.Ptr("test-repo"),
FullName: github.Ptr("owner/test-repo"),
HTMLURL: github.Ptr("https://github.com/owner/test-repo"),
Description: github.Ptr("Test repository"),
StargazersCount: github.Ptr(100),
},
},
}
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchRepositories: expectQueryParams(t, map[string]string{
"q": "golang test",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
})
client := mustNewGHClient(t, mockedClient)
serverTool := SearchRepositories(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
args := map[string]any{
"query": "golang test",
"minimal_output": false,
}
request := createMCPRequest(args)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
// Unmarshal as full GitHub API response
var returnedResult github.RepositoriesSearchResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)
// Verify it's the full API response, not minimal
assert.Equal(t, *mockSearchResult.Total, *returnedResult.Total)
assert.Equal(t, *mockSearchResult.IncompleteResults, *returnedResult.IncompleteResults)
assert.Len(t, returnedResult.Repositories, 1)
assert.Equal(t, *mockSearchResult.Repositories[0].ID, *returnedResult.Repositories[0].ID)
assert.Equal(t, *mockSearchResult.Repositories[0].Name, *returnedResult.Repositories[0].Name)
}
func Test_SearchCode(t *testing.T) {
// Verify tool definition once
serverTool := SearchCode(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "search_code", tool.Name)
assert.NotEmpty(t, tool.Description)
schema, ok := tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
assert.Contains(t, schema.Properties, "query")
assert.Contains(t, schema.Properties, "sort")
assert.Contains(t, schema.Properties, "order")
assert.Contains(t, schema.Properties, "perPage")
assert.Contains(t, schema.Properties, "page")
assert.ElementsMatch(t, schema.Required, []string{"query"})
// Setup mock search results
mockSearchResult := &github.CodeSearchResult{
Total: github.Ptr(2),
IncompleteResults: github.Ptr(false),
CodeResults: []*github.CodeResult{
{
Name: github.Ptr("file1.go"),
Path: github.Ptr("path/to/file1.go"),
SHA: github.Ptr("abc123def456"),
Repository: &github.Repository{
Name: github.Ptr("repo"),
FullName: github.Ptr("owner/repo"),
},
TextMatches: []*github.TextMatch{
{
Fragment: github.Ptr("func main() { fmt.Println(\"hello\") }"),
},
},
},
{
Name: github.Ptr("file2.go"),
Path: github.Ptr("path/to/file2.go"),
SHA: github.Ptr("def456abc123"),
Repository: &github.Repository{
Name: github.Ptr("repo"),
FullName: github.Ptr("owner/repo"),
},
},
},
}
textMatchAcceptHeader := map[string]string{
"Accept": "text-match",
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedResult *github.CodeSearchResult
expectedErrMsg string
}{
{
name: "successful code search with all parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchCode: expectQueryParams(t, map[string]string{
"q": "fmt.Println language:go",
"sort": "indexed",
"order": "desc",
"page": "1",
"per_page": "30",
}).withHeaders(textMatchAcceptHeader).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "fmt.Println language:go",
"sort": "indexed",
"order": "desc",
"page": float64(1),
"perPage": float64(30),
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "code search with minimal parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchCode: expectQueryParams(t, map[string]string{
"q": "fmt.Println language:go",
"page": "1",
"per_page": "30",
}).withHeaders(textMatchAcceptHeader).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "fmt.Println language:go",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search code fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchCode: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
}),
}),
requestArgs: map[string]any{
"query": "invalid:query",
},
expectError: true,
expectedErrMsg: "failed to search code",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
var returnedResult MinimalCodeSearchResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)
assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount)
assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults)
assert.Len(t, returnedResult.Items, len(tc.expectedResult.CodeResults))
for i, code := range returnedResult.Items {
assert.Equal(t, tc.expectedResult.CodeResults[i].GetName(), code.Name)
assert.Equal(t, tc.expectedResult.CodeResults[i].GetPath(), code.Path)
assert.Equal(t, tc.expectedResult.CodeResults[i].GetSHA(), code.SHA)
assert.Equal(t, tc.expectedResult.CodeResults[i].Repository.GetFullName(), code.Repository)
}
// Verify text matches are included when present
if len(tc.expectedResult.CodeResults[0].TextMatches) > 0 {
require.NotEmpty(t, returnedResult.Items[0].TextMatches)
assert.Equal(t,
tc.expectedResult.CodeResults[0].TextMatches[0].GetFragment(),
returnedResult.Items[0].TextMatches[0].GetFragment(),
)
}
})
}
}
func Test_SearchUsers(t *testing.T) {
// Verify tool definition once
serverTool := SearchUsers(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "search_users", tool.Name)
assert.NotEmpty(t, tool.Description)
schema, ok := tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
assert.Contains(t, schema.Properties, "query")
assert.Contains(t, schema.Properties, "sort")
assert.Contains(t, schema.Properties, "order")
assert.Contains(t, schema.Properties, "perPage")
assert.Contains(t, schema.Properties, "page")
assert.ElementsMatch(t, schema.Required, []string{"query"})
// Setup mock search results
mockSearchResult := &github.UsersSearchResult{
Total: github.Ptr(2),
IncompleteResults: github.Ptr(false),
Users: []*github.User{
{
Login: github.Ptr("user1"),
ID: github.Ptr(int64(1001)),
HTMLURL: github.Ptr("https://github.com/user1"),
AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1001"),
},
{
Login: github.Ptr("user2"),
ID: github.Ptr(int64(1002)),
HTMLURL: github.Ptr("https://github.com/user2"),
AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1002"),
Type: github.Ptr("User"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedResult *github.UsersSearchResult
expectedErrMsg string
}{
{
name: "successful users search with all parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchUsers: expectQueryParams(t, map[string]string{
"q": "type:user location:finland language:go",
"sort": "followers",
"order": "desc",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "location:finland language:go",
"sort": "followers",
"order": "desc",
"page": float64(1),
"perPage": float64(30),
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "users search with minimal parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchUsers: expectQueryParams(t, map[string]string{
"q": "type:user location:finland language:go",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "location:finland language:go",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with existing type:user filter - no duplication",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchUsers: expectQueryParams(t, map[string]string{
"q": "type:user location:seattle followers:>100",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "type:user location:seattle followers:>100",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "complex query with existing type:user filter and OR operators",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchUsers: expectQueryParams(t, map[string]string{
"q": "type:user (location:seattle OR location:california) followers:>50",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "type:user (location:seattle OR location:california) followers:>50",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search users fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchUsers: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
}),
}),
requestArgs: map[string]any{
"query": "invalid:query",
},
expectError: true,
expectedErrMsg: "failed to search users",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
// Parse the result and get the text content if no error
require.NotNil(t, result)
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedResult MinimalSearchUsersResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)
assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount)
assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults)
assert.Len(t, returnedResult.Items, len(tc.expectedResult.Users))
for i, user := range returnedResult.Items {
assert.Equal(t, *tc.expectedResult.Users[i].Login, user.Login)
assert.Equal(t, *tc.expectedResult.Users[i].ID, user.ID)
assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, user.ProfileURL)
assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, user.AvatarURL)
}
})
}
}
func Test_SearchOrgs(t *testing.T) {
// Verify tool definition once
serverTool := SearchOrgs(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "search_orgs", tool.Name)
assert.NotEmpty(t, tool.Description)
schema, ok := tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
assert.Contains(t, schema.Properties, "query")
assert.Contains(t, schema.Properties, "sort")
assert.Contains(t, schema.Properties, "order")
assert.Contains(t, schema.Properties, "perPage")
assert.Contains(t, schema.Properties, "page")
assert.ElementsMatch(t, schema.Required, []string{"query"})
// Setup mock search results
mockSearchResult := &github.UsersSearchResult{
Total: github.Ptr(int(2)),
IncompleteResults: github.Ptr(false),
Users: []*github.User{
{
Login: github.Ptr("org-1"),
ID: github.Ptr(int64(111)),
HTMLURL: github.Ptr("https://github.com/org-1"),
AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/111?v=4"),
},
{
Login: github.Ptr("org-2"),
ID: github.Ptr(int64(222)),
HTMLURL: github.Ptr("https://github.com/org-2"),
AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/222?v=4"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedResult *github.UsersSearchResult
expectedErrMsg string
}{
{
name: "successful org search",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchUsers: expectQueryParams(t, map[string]string{
"q": "type:org github",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "github",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with existing type:org filter - no duplication",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchUsers: expectQueryParams(t, map[string]string{
"q": "type:org location:california followers:>1000",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "type:org location:california followers:>1000",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "complex query with existing type:org filter and OR operators",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchUsers: expectQueryParams(t, map[string]string{
"q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "type:org (location:seattle OR location:california OR location:newyork) repos:>10",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "org search fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchUsers: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
}),
}),
requestArgs: map[string]any{
"query": "invalid:query",
},
expectError: true,
expectedErrMsg: "failed to search orgs",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.NotNil(t, result)
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedResult MinimalSearchUsersResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)
assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount)
assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults)
assert.Len(t, returnedResult.Items, len(tc.expectedResult.Users))
for i, org := range returnedResult.Items {
assert.Equal(t, *tc.expectedResult.Users[i].Login, org.Login)
assert.Equal(t, *tc.expectedResult.Users[i].ID, org.ID)
assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, org.ProfileURL)
assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, org.AvatarURL)
}
})
}
}
func Test_SearchCommits(t *testing.T) {
serverTool := SearchCommits(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "search_commits", tool.Name)
assert.NotEmpty(t, tool.Description)
schema, ok := tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
assert.Contains(t, schema.Properties, "query")
assert.Contains(t, schema.Properties, "sort")
assert.Contains(t, schema.Properties, "order")
assert.Contains(t, schema.Properties, "page")
assert.Contains(t, schema.Properties, "perPage")
assert.ElementsMatch(t, schema.Required, []string{"query"})
now := time.Now().Truncate(time.Second)
mockSearchResult := &github.CommitsSearchResult{
Total: github.Ptr(2),
IncompleteResults: github.Ptr(false),
Commits: []*github.CommitResult{
{
SHA: github.Ptr("abc123commit"),
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123commit"),
Commit: &github.Commit{
Message: github.Ptr("Initial commit"),
Author: &github.CommitAuthor{
Name: github.Ptr("Author Name"),
Email: github.Ptr("author@example.com"),
Date: &github.Timestamp{Time: now},
},
},
Author: &github.User{
Login: github.Ptr("author"),
ID: github.Ptr(int64(1)),
HTMLURL: github.Ptr("https://github.com/author"),
},
Repository: &github.Repository{
FullName: github.Ptr("owner/repo"),
HTMLURL: github.Ptr("https://github.com/owner/repo"),
Private: github.Ptr(false),
},
},
{
// Commit with no resolved GitHub user for author or committer
// (common when the commit email isn't linked to an account).
SHA: github.Ptr("def456commit"),
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456commit"),
Commit: &github.Commit{
Message: github.Ptr("Unlinked author"),
},
Repository: &github.Repository{
FullName: github.Ptr("owner/repo"),
},
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedResult *github.CommitsSearchResult
expectedErrMsg string
}{
{
name: "successful commit search",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchCommits: expectQueryParams(t, map[string]string{
"q": "fix bug in:message repo:owner/repo",
"sort": "author-date",
"order": "desc",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "fix bug in:message repo:owner/repo",
"sort": "author-date",
"order": "desc",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchCommits: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
}),
}),
requestArgs: map[string]any{
"query": "invalid:syntax",
},
expectError: true,
expectedErrMsg: "failed to search commits",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := mustNewGHClient(t, tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
var returnedResult MinimalSearchCommitsResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)
assert.Equal(t, tc.expectedResult.GetTotal(), returnedResult.TotalCount)
assert.Len(t, returnedResult.Items, len(tc.expectedResult.Commits))
assert.Equal(t, *tc.expectedResult.Commits[0].SHA, returnedResult.Items[0].SHA)
assert.Equal(t, *tc.expectedResult.Commits[0].Commit.Message, returnedResult.Items[0].Commit.Message)
assert.Equal(t, *tc.expectedResult.Commits[0].Commit.Author.Name, returnedResult.Items[0].Commit.Author.Name)
assert.Equal(t, now.Format(time.RFC3339), returnedResult.Items[0].Commit.Author.Date)
assert.Equal(t, *tc.expectedResult.Commits[0].Author.Login, returnedResult.Items[0].Author.Login)
// Repository info is required so callers can identify which repo
// each cross-repo search result belongs to.
require.NotNil(t, returnedResult.Items[0].Repository)
assert.Equal(t, "owner/repo", returnedResult.Items[0].Repository.FullName)
assert.Equal(t, "https://github.com/owner/repo", returnedResult.Items[0].Repository.HTMLURL)
// Second commit has no resolved GitHub user for author/committer
// and no commit-level author block — the handler must not panic
// and must omit those fields cleanly.
require.Len(t, returnedResult.Items, 2)
assert.Equal(t, "def456commit", returnedResult.Items[1].SHA)
assert.Nil(t, returnedResult.Items[1].Author)
assert.Nil(t, returnedResult.Items[1].Committer)
require.NotNil(t, returnedResult.Items[1].Commit)
assert.Nil(t, returnedResult.Items[1].Commit.Author)
assert.Nil(t, returnedResult.Items[1].Commit.Committer)
})
}
}