diff --git a/README.md b/README.md index 8f0eba5ad..138a8cc16 100644 --- a/README.md +++ b/README.md @@ -873,6 +873,12 @@ The following sets of tools are available (all are on by default): - `repo`: Repository name (string, required) - `sha`: Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA. (string, optional) +- **list_repository_contributors** - List repository contributors + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - **list_releases** - List releases - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) diff --git a/github-mcp-server b/github-mcp-server index 864242c24..5c4a5291d 100755 Binary files a/github-mcp-server and b/github-mcp-server differ diff --git a/pkg/github/__toolsnaps__/list_repository_contributors.snap b/pkg/github/__toolsnaps__/list_repository_contributors.snap new file mode 100644 index 000000000..9e59f9d87 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_repository_contributors.snap @@ -0,0 +1,36 @@ +{ + "annotations": { + "title": "List repository contributors", + "readOnlyHint": true + }, + "description": "Get list of contributors for a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "list_repository_contributors" +} \ No newline at end of file diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index cef227ba5..1a4430102 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -200,6 +200,76 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t } } +// ListRepositoryContributors creates a tool to get contributors of a repository. +func ListRepositoryContributors(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_repository_contributors", + mcp.WithDescription(t("TOOL_LIST_REPOSITORY_CONTRIBUTORS_DESCRIPTION", "Get list of contributors for a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_REPOSITORY_CONTRIBUTORS_USER_TITLE", "List repository contributors"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.ListContributorsOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + contributors, resp, err := client.Repositories.ListContributors(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list contributors for repository: %s/%s", owner, repo), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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 list contributors: %s", string(body))), nil + } + + r, err := json.Marshal(contributors) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // ListBranches creates a tool to list branches in a GitHub repository. func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_branches", diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 468d7c29b..d7d858caf 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2866,3 +2866,186 @@ func Test_resolveGitReference(t *testing.T) { }) } } + +func Test_ListRepositoryContributors(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListRepositoryContributors(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_repository_contributors", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock contributors for success case + mockContributors := []*github.Contributor{ + { + Login: github.Ptr("user1"), + ID: github.Int64(1), + NodeID: github.Ptr("MDQ6VXNlcjE="), + AvatarURL: github.Ptr("https://github.com/images/error/user1_happy.gif"), + GravatarID: github.Ptr(""), + URL: github.Ptr("https://api.github.com/users/user1"), + HTMLURL: github.Ptr("https://github.com/user1"), + FollowersURL: github.Ptr("https://api.github.com/users/user1/followers"), + FollowingURL: github.Ptr("https://api.github.com/users/user1/following{/other_user}"), + GistsURL: github.Ptr("https://api.github.com/users/user1/gists{/gist_id}"), + StarredURL: github.Ptr("https://api.github.com/users/user1/starred{/owner}{/repo}"), + SubscriptionsURL: github.Ptr("https://api.github.com/users/user1/subscriptions"), + OrganizationsURL: github.Ptr("https://api.github.com/users/user1/orgs"), + ReposURL: github.Ptr("https://api.github.com/users/user1/repos"), + EventsURL: github.Ptr("https://api.github.com/users/user1/events{/privacy}"), + ReceivedEventsURL: github.Ptr("https://api.github.com/users/user1/received_events"), + Type: github.Ptr("User"), + SiteAdmin: github.Bool(false), + Contributions: github.Int(42), + }, + { + Login: github.Ptr("user2"), + ID: github.Int64(2), + NodeID: github.Ptr("MDQ6VXNlcjI="), + AvatarURL: github.Ptr("https://github.com/images/error/user2_happy.gif"), + GravatarID: github.Ptr(""), + URL: github.Ptr("https://api.github.com/users/user2"), + HTMLURL: github.Ptr("https://github.com/user2"), + FollowersURL: github.Ptr("https://api.github.com/users/user2/followers"), + FollowingURL: github.Ptr("https://api.github.com/users/user2/following{/other_user}"), + GistsURL: github.Ptr("https://api.github.com/users/user2/gists{/gist_id}"), + StarredURL: github.Ptr("https://api.github.com/users/user2/starred{/owner}{/repo}"), + SubscriptionsURL: github.Ptr("https://api.github.com/users/user2/subscriptions"), + OrganizationsURL: github.Ptr("https://api.github.com/users/user2/orgs"), + ReposURL: github.Ptr("https://api.github.com/users/user2/repos"), + EventsURL: github.Ptr("https://api.github.com/users/user2/events{/privacy}"), + ReceivedEventsURL: github.Ptr("https://api.github.com/users/user2/received_events"), + Type: github.Ptr("User"), + SiteAdmin: github.Bool(false), + Contributions: github.Int(15), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedContributors []*github.Contributor + expectedErrMsg string + }{ + { + name: "successful contributors fetch with default params", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposContributorsByOwnerByRepo, + mockContributors, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedContributors: mockContributors, + }, + { + name: "successful contributors fetch with pagination", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposContributorsByOwnerByRepo, + expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "50", + }).andThen( + mockResponse(t, http.StatusOK, mockContributors), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "page": float64(2), + "perPage": float64(50), + }, + expectError: false, + expectedContributors: mockContributors, + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter repo", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + }, + expectError: true, + expectedErrMsg: "missing required parameter: repo", + }, + { + name: "GitHub API error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposContributorsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list contributors for repository: owner/repo", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListRepositoryContributors(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.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 returnedContributors []*github.Contributor + err = json.Unmarshal([]byte(textContent.Text), &returnedContributors) + require.NoError(t, err) + assert.Len(t, returnedContributors, len(tc.expectedContributors)) + for i, contributor := range returnedContributors { + assert.Equal(t, tc.expectedContributors[i].GetLogin(), contributor.GetLogin()) + assert.Equal(t, tc.expectedContributors[i].GetContributions(), contributor.GetContributions()) + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 728d78097..222471cd2 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -26,6 +26,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(SearchRepositories(getClient, t)), toolsets.NewServerTool(GetFileContents(getClient, getRawClient, t)), toolsets.NewServerTool(ListCommits(getClient, t)), + toolsets.NewServerTool(ListRepositoryContributors(getClient, t)), toolsets.NewServerTool(SearchCode(getClient, t)), toolsets.NewServerTool(GetCommit(getClient, t)), toolsets.NewServerTool(ListBranches(getClient, t)), diff --git a/script/list-repository-contributors b/script/list-repository-contributors new file mode 100755 index 000000000..cb9490a46 --- /dev/null +++ b/script/list-repository-contributors @@ -0,0 +1,17 @@ +#!/bin/bash + +# Test script for list_repository_contributors function +# Usage: ./script/list-repository-contributors + +if [ $# -ne 2 ]; then + echo "Usage: $0 " + echo "Example: $0 octocat Hello-World" + exit 1 +fi + +OWNER=$1 +REPO=$2 + +echo "Testing list_repository_contributors for $OWNER/$REPO" + +echo "{\"jsonrpc\":\"2.0\",\"id\":1,\"params\":{\"name\":\"list_repository_contributors\",\"arguments\":{\"owner\":\"$OWNER\",\"repo\":\"$REPO\"}},\"method\":\"tools/call\"}" | go run cmd/github-mcp-server/main.go stdio | jq .