Skip to content

feat: add list repository contributors and update readme #893

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

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ For other MCP host applications, please refer to our installation guides:
- **[Cursor](docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE
- **[Windsurf](docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE

For a complete overview of all installation options, see our **[Installation Guides Index](docs/installation-guides/installation-guides.md)**.
For a complete overview of all installation options, see our **[Installation Guides Index](docs/installation-guides)**.

> **Note:** Any host application that supports local MCP servers should be able to access the local GitHub MCP server. However, the specific configuration process, syntax and stability of the integration will vary by host application. While many may follow a similar format to the examples above, this is not guaranteed. Please refer to your host application's documentation for the correct MCP configuration syntax and setup process.

Expand Down Expand Up @@ -865,6 +865,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 @@ -183,6 +183,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",
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 @@ -2629,3 +2629,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 .
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy