Skip to content

Separate org and user search #486

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 26, 2025
Merged
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
8 changes: 4 additions & 4 deletions pkg/github/__toolsnaps__/search_users.snap
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@
"title": "Search users",
"readOnlyHint": true
},
"description": "Search for GitHub users",
"description": "Search for GitHub users exclusively",
"inputSchema": {
"properties": {
"order": {
@@ -25,8 +25,8 @@
"minimum": 1,
"type": "number"
},
"q": {
"description": "Search query using GitHub users search syntax",
"query": {
"description": "Search query using GitHub users search syntax scoped to type:user",
"type": "string"
},
"sort": {
@@ -40,7 +40,7 @@
}
},
"required": [
"q"
"query"
],
"type": "object"
},
207 changes: 123 additions & 84 deletions pkg/github/search.go
Original file line number Diff line number Diff line change
@@ -168,100 +168,139 @@ type MinimalSearchUsersResult struct {
Items []MinimalUser `json:"items"`
}

// SearchUsers creates a tool to search for GitHub users.
func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_users",
mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Search for GitHub users")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"),
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("q",
mcp.Required(),
mcp.Description("Search query using GitHub users search syntax"),
),
mcp.WithString("sort",
mcp.Description("Sort field by category"),
mcp.Enum("followers", "repositories", "joined"),
),
mcp.WithString("order",
mcp.Description("Sort order"),
mcp.Enum("asc", "desc"),
),
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
query, err := RequiredParam[string](request, "q")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
sort, err := OptionalParam[string](request, "sort")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
order, err := OptionalParam[string](request, "order")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pagination, err := OptionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
query, err := RequiredParam[string](request, "query")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
sort, err := OptionalParam[string](request, "sort")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
order, err := OptionalParam[string](request, "order")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pagination, err := OptionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

opts := &github.SearchOptions{
Sort: sort,
Order: order,
ListOptions: github.ListOptions{
PerPage: pagination.perPage,
Page: pagination.page,
},
}
opts := &github.SearchOptions{
Sort: sort,
Order: order,
ListOptions: github.ListOptions{
PerPage: pagination.perPage,
Page: pagination.page,
},
}

client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}

result, resp, err := client.Search.Users(ctx, "type:user "+query, opts)
searchQuery := "type:" + accountType + " " + query
result, resp, err := client.Search.Users(ctx, searchQuery, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
fmt.Sprintf("failed to search %ss with query '%s'", accountType, query),
resp,
err,
), nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != 200 {
body, err := io.ReadAll(resp.Body)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
fmt.Sprintf("failed to search users with query '%s'", query),
resp,
err,
), nil
return nil, fmt.Errorf("failed to read response body: %w", err)
}
defer func() { _ = resp.Body.Close() }()
return mcp.NewToolResultError(fmt.Sprintf("failed to search %ss: %s", accountType, string(body))), nil
}

if resp.StatusCode != 200 {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to search users: %s", string(body))), nil
}
minimalUsers := make([]MinimalUser, 0, len(result.Users))

minimalUsers := make([]MinimalUser, 0, len(result.Users))
for _, user := range result.Users {
mu := MinimalUser{
Login: user.GetLogin(),
ID: user.GetID(),
ProfileURL: user.GetHTMLURL(),
AvatarURL: user.GetAvatarURL(),
for _, user := range result.Users {
if user.Login != nil {
mu := MinimalUser{Login: *user.Login}
if user.ID != nil {
mu.ID = *user.ID
}
if user.HTMLURL != nil {
mu.ProfileURL = *user.HTMLURL
}
if user.AvatarURL != nil {
mu.AvatarURL = *user.AvatarURL
}

minimalUsers = append(minimalUsers, mu)
}
}
minimalResp := &MinimalSearchUsersResult{
TotalCount: result.GetTotal(),
IncompleteResults: result.GetIncompleteResults(),
Items: minimalUsers,
}
if result.Total != nil {
minimalResp.TotalCount = *result.Total
}
if result.IncompleteResults != nil {
minimalResp.IncompleteResults = *result.IncompleteResults
}

minimalResp := MinimalSearchUsersResult{
TotalCount: result.GetTotal(),
IncompleteResults: result.GetIncompleteResults(),
Items: minimalUsers,
}

r, err := json.Marshal(minimalResp)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
return mcp.NewToolResultText(string(r)), nil
r, err := json.Marshal(minimalResp)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
return mcp.NewToolResultText(string(r)), nil
}
}

// SearchUsers creates a tool to search for GitHub users.
func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_users",
mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Search for GitHub users exclusively")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"),
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("query",
mcp.Required(),
mcp.Description("Search query using GitHub users search syntax scoped to type:user"),
),
mcp.WithString("sort",
mcp.Description("Sort field by category"),
mcp.Enum("followers", "repositories", "joined"),
),
mcp.WithString("order",
mcp.Description("Sort order"),
mcp.Enum("asc", "desc"),
),
WithPagination(),
), userOrOrgHandler("user", getClient)
}

// SearchOrgs creates a tool to search for GitHub organizations.
func SearchOrgs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_orgs",
mcp.WithDescription(t("TOOL_SEARCH_ORGS_DESCRIPTION", "Search for GitHub organizations exclusively")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_SEARCH_ORGS_USER_TITLE", "Search organizations"),
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("query",
mcp.Required(),
mcp.Description("Search query using GitHub organizations search syntax scoped to type:org"),
),
mcp.WithString("sort",
mcp.Description("Sort field by category"),
mcp.Enum("followers", "repositories", "joined"),
),
mcp.WithString("order",
mcp.Description("Sort order"),
mcp.Enum("asc", "desc"),
),
WithPagination(),
), userOrOrgHandler("org", getClient)
}
134 changes: 129 additions & 5 deletions pkg/github/search_test.go
Original file line number Diff line number Diff line change
@@ -328,12 +328,12 @@ func Test_SearchUsers(t *testing.T) {

assert.Equal(t, "search_users", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "q")
assert.Contains(t, tool.InputSchema.Properties, "query")
assert.Contains(t, tool.InputSchema.Properties, "sort")
assert.Contains(t, tool.InputSchema.Properties, "order")
assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.Contains(t, tool.InputSchema.Properties, "page")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"q"})
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"})

// Setup mock search results
mockSearchResult := &github.UsersSearchResult{
@@ -381,7 +381,7 @@ func Test_SearchUsers(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
"q": "location:finland language:go",
"query": "location:finland language:go",
"sort": "followers",
"order": "desc",
"page": float64(1),
@@ -405,7 +405,7 @@ func Test_SearchUsers(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
"q": "location:finland language:go",
"query": "location:finland language:go",
},
expectError: false,
expectedResult: mockSearchResult,
@@ -422,7 +422,7 @@ func Test_SearchUsers(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
"q": "invalid:query",
"query": "invalid:query",
},
expectError: true,
expectedErrMsg: "failed to search users",
@@ -474,3 +474,127 @@ func Test_SearchUsers(t *testing.T) {
})
}
}

func Test_SearchOrgs(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := SearchOrgs(stubGetClientFn(mockClient), translations.NullTranslationHelper)

assert.Equal(t, "search_orgs", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "query")
assert.Contains(t, tool.InputSchema.Properties, "sort")
assert.Contains(t, tool.InputSchema.Properties, "order")
assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.Contains(t, tool.InputSchema.Properties, "page")
assert.ElementsMatch(t, tool.InputSchema.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]interface{}
expectError bool
expectedResult *github.UsersSearchResult
expectedErrMsg string
}{
{
name: "successful org search",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchUsers,
expectQueryParams(t, map[string]string{
"q": "type:org github",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "github",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "org search fails",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchUsers,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
}),
),
),
requestArgs: map[string]interface{}{
"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 := github.NewClient(tc.mockedClient)
_, handler := SearchOrgs(stubGetClientFn(client), translations.NullTranslationHelper)

// Create call request
request := createMCPRequest(tc.requestArgs)

// Call handler
result, err := handler(context.Background(), 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)
}
})
}
}
5 changes: 5 additions & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
@@ -64,6 +64,10 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
AddReadTools(
toolsets.NewServerTool(SearchUsers(getClient, t)),
)
orgs := toolsets.NewToolset("orgs", "GitHub Organization related tools").
AddReadTools(
toolsets.NewServerTool(SearchOrgs(getClient, t)),
)
pullRequests := toolsets.NewToolset("pull_requests", "GitHub Pull Request related tools").
AddReadTools(
toolsets.NewServerTool(GetPullRequest(getClient, t)),
@@ -143,6 +147,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
tsg.AddToolset(contextTools)
tsg.AddToolset(repos)
tsg.AddToolset(issues)
tsg.AddToolset(orgs)
tsg.AddToolset(users)
tsg.AddToolset(pullRequests)
tsg.AddToolset(actions)