Skip to content

Commit ece480e

Browse files
committed
feat(contributors): add list_repository_contributors tool
1 parent e6109f9 commit ece480e

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)
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