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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Binary file modified github-mcp-server
Binary file not shown.
36 changes: 36 additions & 0 deletions pkg/github/__toolsnaps__/list_repository_contributors.snap
Original file line number Diff line number Diff line change
@@ -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"
}
70 changes: 70 additions & 0 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Comment on lines +256 to +263
Copy link
Preview

Copilot AI Sep 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The status code check and error handling is redundant since the GitHub client API call already handles HTTP errors. This pattern differs from other similar functions in the codebase like ListCommits and ListBranches where the status check is performed after confirming no API error occurred. Consider removing lines 256-262 to maintain consistency.

Suggested change
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
}

Copilot uses AI. Check for mistakes.

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",
Expand Down
183 changes: 183 additions & 0 deletions pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
})
}
}
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
17 changes: 17 additions & 0 deletions script/list-repository-contributors
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/bin/bash

# Test script for list_repository_contributors function
# Usage: ./script/list-repository-contributors <owner> <repo>

if [ $# -ne 2 ]; then
echo "Usage: $0 <owner> <repo>"
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 .