diff --git a/README.md b/README.md index e4543ecf5..19467b5ee 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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) 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 0925829a1..c1600d1c1 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -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", diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 63e577600..e1e162898 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -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()) + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 3fb39ada7..37583f525 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 . 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