Skip to content

Commit f90c545

Browse files
committed
feat(contributors): add list_repository_contributors tool
1 parent fb3b435 commit f90c545

File tree

7 files changed

+313
-0
lines changed

7 files changed

+313
-0
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,12 @@ The following sets of tools are available (all are on by default):
865865
- `repo`: Repository name (string, required)
866866
- `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)
867867

868+
- **list_repository_contributors** - List repository contributors
869+
- `owner`: Repository owner (string, required)
870+
- `repo`: Repository name (string, required)
871+
- `page`: Page number for pagination (min 1) (number, optional)
872+
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
873+
868874
- **list_releases** - List releases
869875
- `owner`: Repository owner (string, required)
870876
- `page`: Page number for pagination (min 1) (number, optional)

github-mcp-server

-449 KB
Binary file not shown.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"annotations": {
3+
"title": "List repository contributors",
4+
"readOnlyHint": true
5+
},
6+
"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).",
7+
"inputSchema": {
8+
"properties": {
9+
"owner": {
10+
"description": "Repository owner",
11+
"type": "string"
12+
},
13+
"page": {
14+
"description": "Page number for pagination (min 1)",
15+
"minimum": 1,
16+
"type": "number"
17+
},
18+
"perPage": {
19+
"description": "Results per page for pagination (min 1, max 100)",
20+
"maximum": 100,
21+
"minimum": 1,
22+
"type": "number"
23+
},
24+
"repo": {
25+
"description": "Repository name",
26+
"type": "string"
27+
}
28+
},
29+
"required": [
30+
"owner",
31+
"repo"
32+
],
33+
"type": "object"
34+
},
35+
"name": "list_repository_contributors"
36+
}

pkg/github/repositories.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,76 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
183183
}
184184
}
185185

186+
// ListRepositoryContributors creates a tool to get contributors of a repository.
187+
func ListRepositoryContributors(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
188+
return mcp.NewTool("list_repository_contributors",
189+
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).")),
190+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
191+
Title: t("TOOL_LIST_REPOSITORY_CONTRIBUTORS_USER_TITLE", "List repository contributors"),
192+
ReadOnlyHint: ToBoolPtr(true),
193+
}),
194+
mcp.WithString("owner",
195+
mcp.Required(),
196+
mcp.Description("Repository owner"),
197+
),
198+
mcp.WithString("repo",
199+
mcp.Required(),
200+
mcp.Description("Repository name"),
201+
),
202+
WithPagination(),
203+
),
204+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
205+
owner, err := RequiredParam[string](request, "owner")
206+
if err != nil {
207+
return mcp.NewToolResultError(err.Error()), nil
208+
}
209+
repo, err := RequiredParam[string](request, "repo")
210+
if err != nil {
211+
return mcp.NewToolResultError(err.Error()), nil
212+
}
213+
pagination, err := OptionalPaginationParams(request)
214+
if err != nil {
215+
return mcp.NewToolResultError(err.Error()), nil
216+
}
217+
218+
opts := &github.ListContributorsOptions{
219+
ListOptions: github.ListOptions{
220+
Page: pagination.Page,
221+
PerPage: pagination.PerPage,
222+
},
223+
}
224+
225+
client, err := getClient(ctx)
226+
if err != nil {
227+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
228+
}
229+
contributors, resp, err := client.Repositories.ListContributors(ctx, owner, repo, opts)
230+
if err != nil {
231+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
232+
fmt.Sprintf("failed to list contributors for repository: %s/%s", owner, repo),
233+
resp,
234+
err,
235+
), nil
236+
}
237+
defer func() { _ = resp.Body.Close() }()
238+
239+
if resp.StatusCode != 200 {
240+
body, err := io.ReadAll(resp.Body)
241+
if err != nil {
242+
return nil, fmt.Errorf("failed to read response body: %w", err)
243+
}
244+
return mcp.NewToolResultError(fmt.Sprintf("failed to list contributors: %s", string(body))), nil
245+
}
246+
247+
r, err := json.Marshal(contributors)
248+
if err != nil {
249+
return nil, fmt.Errorf("failed to marshal response: %w", err)
250+
}
251+
252+
return mcp.NewToolResultText(string(r)), nil
253+
}
254+
}
255+
186256
// ListBranches creates a tool to list branches in a GitHub repository.
187257
func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
188258
return mcp.NewTool("list_branches",

pkg/github/repositories_test.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2629,3 +2629,186 @@ func Test_resolveGitReference(t *testing.T) {
26292629
})
26302630
}
26312631
}
2632+
2633+
func Test_ListRepositoryContributors(t *testing.T) {
2634+
// Verify tool definition once
2635+
mockClient := github.NewClient(nil)
2636+
tool, _ := ListRepositoryContributors(stubGetClientFn(mockClient), translations.NullTranslationHelper)
2637+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
2638+
2639+
assert.Equal(t, "list_repository_contributors", tool.Name)
2640+
assert.NotEmpty(t, tool.Description)
2641+
assert.Contains(t, tool.InputSchema.Properties, "owner")
2642+
assert.Contains(t, tool.InputSchema.Properties, "repo")
2643+
assert.Contains(t, tool.InputSchema.Properties, "page")
2644+
assert.Contains(t, tool.InputSchema.Properties, "perPage")
2645+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
2646+
2647+
// Setup mock contributors for success case
2648+
mockContributors := []*github.Contributor{
2649+
{
2650+
Login: github.Ptr("user1"),
2651+
ID: github.Int64(1),
2652+
NodeID: github.Ptr("MDQ6VXNlcjE="),
2653+
AvatarURL: github.Ptr("https://github.com/images/error/user1_happy.gif"),
2654+
GravatarID: github.Ptr(""),
2655+
URL: github.Ptr("https://api.github.com/users/user1"),
2656+
HTMLURL: github.Ptr("https://github.com/user1"),
2657+
FollowersURL: github.Ptr("https://api.github.com/users/user1/followers"),
2658+
FollowingURL: github.Ptr("https://api.github.com/users/user1/following{/other_user}"),
2659+
GistsURL: github.Ptr("https://api.github.com/users/user1/gists{/gist_id}"),
2660+
StarredURL: github.Ptr("https://api.github.com/users/user1/starred{/owner}{/repo}"),
2661+
SubscriptionsURL: github.Ptr("https://api.github.com/users/user1/subscriptions"),
2662+
OrganizationsURL: github.Ptr("https://api.github.com/users/user1/orgs"),
2663+
ReposURL: github.Ptr("https://api.github.com/users/user1/repos"),
2664+
EventsURL: github.Ptr("https://api.github.com/users/user1/events{/privacy}"),
2665+
ReceivedEventsURL: github.Ptr("https://api.github.com/users/user1/received_events"),
2666+
Type: github.Ptr("User"),
2667+
SiteAdmin: github.Bool(false),
2668+
Contributions: github.Int(42),
2669+
},
2670+
{
2671+
Login: github.Ptr("user2"),
2672+
ID: github.Int64(2),
2673+
NodeID: github.Ptr("MDQ6VXNlcjI="),
2674+
AvatarURL: github.Ptr("https://github.com/images/error/user2_happy.gif"),
2675+
GravatarID: github.Ptr(""),
2676+
URL: github.Ptr("https://api.github.com/users/user2"),
2677+
HTMLURL: github.Ptr("https://github.com/user2"),
2678+
FollowersURL: github.Ptr("https://api.github.com/users/user2/followers"),
2679+
FollowingURL: github.Ptr("https://api.github.com/users/user2/following{/other_user}"),
2680+
GistsURL: github.Ptr("https://api.github.com/users/user2/gists{/gist_id}"),
2681+
StarredURL: github.Ptr("https://api.github.com/users/user2/starred{/owner}{/repo}"),
2682+
SubscriptionsURL: github.Ptr("https://api.github.com/users/user2/subscriptions"),
2683+
OrganizationsURL: github.Ptr("https://api.github.com/users/user2/orgs"),
2684+
ReposURL: github.Ptr("https://api.github.com/users/user2/repos"),
2685+
EventsURL: github.Ptr("https://api.github.com/users/user2/events{/privacy}"),
2686+
ReceivedEventsURL: github.Ptr("https://api.github.com/users/user2/received_events"),
2687+
Type: github.Ptr("User"),
2688+
SiteAdmin: github.Bool(false),
2689+
Contributions: github.Int(15),
2690+
},
2691+
}
2692+
2693+
tests := []struct {
2694+
name string
2695+
mockedClient *http.Client
2696+
requestArgs map[string]interface{}
2697+
expectError bool
2698+
expectedContributors []*github.Contributor
2699+
expectedErrMsg string
2700+
}{
2701+
{
2702+
name: "successful contributors fetch with default params",
2703+
mockedClient: mock.NewMockedHTTPClient(
2704+
mock.WithRequestMatch(
2705+
mock.GetReposContributorsByOwnerByRepo,
2706+
mockContributors,
2707+
),
2708+
),
2709+
requestArgs: map[string]interface{}{
2710+
"owner": "owner",
2711+
"repo": "repo",
2712+
},
2713+
expectError: false,
2714+
expectedContributors: mockContributors,
2715+
},
2716+
{
2717+
name: "successful contributors fetch with pagination",
2718+
mockedClient: mock.NewMockedHTTPClient(
2719+
mock.WithRequestMatchHandler(
2720+
mock.GetReposContributorsByOwnerByRepo,
2721+
expectQueryParams(t, map[string]string{
2722+
"page": "2",
2723+
"per_page": "50",
2724+
}).andThen(
2725+
mockResponse(t, http.StatusOK, mockContributors),
2726+
),
2727+
),
2728+
),
2729+
requestArgs: map[string]interface{}{
2730+
"owner": "owner",
2731+
"repo": "repo",
2732+
"page": float64(2),
2733+
"perPage": float64(50),
2734+
},
2735+
expectError: false,
2736+
expectedContributors: mockContributors,
2737+
},
2738+
{
2739+
name: "missing required parameter owner",
2740+
mockedClient: mock.NewMockedHTTPClient(),
2741+
requestArgs: map[string]interface{}{
2742+
"repo": "repo",
2743+
},
2744+
expectError: true,
2745+
expectedErrMsg: "missing required parameter: owner",
2746+
},
2747+
{
2748+
name: "missing required parameter repo",
2749+
mockedClient: mock.NewMockedHTTPClient(),
2750+
requestArgs: map[string]interface{}{
2751+
"owner": "owner",
2752+
},
2753+
expectError: true,
2754+
expectedErrMsg: "missing required parameter: repo",
2755+
},
2756+
{
2757+
name: "GitHub API error",
2758+
mockedClient: mock.NewMockedHTTPClient(
2759+
mock.WithRequestMatchHandler(
2760+
mock.GetReposContributorsByOwnerByRepo,
2761+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2762+
w.WriteHeader(http.StatusNotFound)
2763+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
2764+
}),
2765+
),
2766+
),
2767+
requestArgs: map[string]interface{}{
2768+
"owner": "owner",
2769+
"repo": "repo",
2770+
},
2771+
expectError: true,
2772+
expectedErrMsg: "failed to list contributors for repository: owner/repo",
2773+
},
2774+
}
2775+
2776+
for _, tc := range tests {
2777+
t.Run(tc.name, func(t *testing.T) {
2778+
// Setup client with mock
2779+
client := github.NewClient(tc.mockedClient)
2780+
_, handler := ListRepositoryContributors(stubGetClientFn(client), translations.NullTranslationHelper)
2781+
2782+
// Create call request
2783+
request := createMCPRequest(tc.requestArgs)
2784+
2785+
// Call handler
2786+
result, err := handler(context.Background(), request)
2787+
2788+
// Verify results
2789+
if tc.expectError {
2790+
require.NoError(t, err)
2791+
require.True(t, result.IsError)
2792+
errorContent := getErrorResult(t, result)
2793+
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
2794+
return
2795+
}
2796+
2797+
require.NoError(t, err)
2798+
require.False(t, result.IsError)
2799+
2800+
// Parse the result and get the text content if no error
2801+
textContent := getTextResult(t, result)
2802+
2803+
// Unmarshal and verify the result
2804+
var returnedContributors []*github.Contributor
2805+
err = json.Unmarshal([]byte(textContent.Text), &returnedContributors)
2806+
require.NoError(t, err)
2807+
assert.Len(t, returnedContributors, len(tc.expectedContributors))
2808+
for i, contributor := range returnedContributors {
2809+
assert.Equal(t, tc.expectedContributors[i].GetLogin(), contributor.GetLogin())
2810+
assert.Equal(t, tc.expectedContributors[i].GetContributions(), contributor.GetContributions())
2811+
}
2812+
})
2813+
}
2814+
}

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
2626
toolsets.NewServerTool(SearchRepositories(getClient, t)),
2727
toolsets.NewServerTool(GetFileContents(getClient, getRawClient, t)),
2828
toolsets.NewServerTool(ListCommits(getClient, t)),
29+
toolsets.NewServerTool(ListRepositoryContributors(getClient, t)),
2930
toolsets.NewServerTool(SearchCode(getClient, t)),
3031
toolsets.NewServerTool(GetCommit(getClient, t)),
3132
toolsets.NewServerTool(ListBranches(getClient, t)),

script/list-repository-contributors

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/bin/bash
2+
3+
# Test script for list_repository_contributors function
4+
# Usage: ./script/list-repository-contributors <owner> <repo>
5+
6+
if [ $# -ne 2 ]; then
7+
echo "Usage: $0 <owner> <repo>"
8+
echo "Example: $0 octocat Hello-World"
9+
exit 1
10+
fi
11+
12+
OWNER=$1
13+
REPO=$2
14+
15+
echo "Testing list_repository_contributors for $OWNER/$REPO"
16+
17+
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 .

0 commit comments

Comments
 (0)