Content-Length: 15645 | pFad | https://github.com/github/github-mcp-server/pull/815.diff

thub.com diff --git a/README.md b/README.md index b40974e2..eb1ae6fc 100644 --- a/README.md +++ b/README.md @@ -848,6 +848,13 @@ 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_starred_repositories** - List starred repositories + - `direction`: Direction to sort repositories. Can be 'asc' or 'desc'. Default is 'desc'. (string, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `sort`: Sort order for repositories. Can be 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to). Default is 'created'. (string, optional) + - `username`: GitHub username of the user whose starred repositories to list (string, required) + - **list_tags** - List tags - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) diff --git a/pkg/github/__toolsnaps__/list_starred_repositories.snap b/pkg/github/__toolsnaps__/list_starred_repositories.snap new file mode 100644 index 00000000..3c4b107c --- /dev/null +++ b/pkg/github/__toolsnaps__/list_starred_repositories.snap @@ -0,0 +1,47 @@ +{ + "annotations": { + "title": "List starred repositories", + "readOnlyHint": true + }, + "description": "List repositories that a user has starred on GitHub. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).", + "inputSchema": { + "properties": { + "direction": { + "description": "Direction to sort repositories. Can be 'asc' or 'desc'. Default is 'desc'.", + "enum": [ + "asc", + "desc" + ], + "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" + }, + "sort": { + "description": "Sort order for repositories. Can be 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to). Default is 'created'.", + "enum": [ + "created", + "updated" + ], + "type": "string" + }, + "username": { + "description": "GitHub username of the user whose starred repositories to list", + "type": "string" + } + }, + "required": [ + "username" + ], + "type": "object" + }, + "name": "list_starred_repositories" +} \ No newline at end of file diff --git a/pkg/github/starred_repos.go b/pkg/github/starred_repos.go new file mode 100644 index 00000000..ab158e6b --- /dev/null +++ b/pkg/github/starred_repos.go @@ -0,0 +1,91 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v73/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// ListStarredRepositories creates a tool to list repositories that a user has starred. +func ListStarredRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_starred_repositories", + mcp.WithDescription(t("TOOL_LIST_STARRED_REPOSITORIES_DESCRIPTION", "List repositories that a user has starred on GitHub. 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_STARRED_REPOSITORIES_USER_TITLE", "List starred repositories"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("username", + mcp.Required(), + mcp.Description("GitHub username of the user whose starred repositories to list"), + ), + mcp.WithString("sort", + mcp.Description("Sort order for repositories. Can be 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to). Default is 'created'."), + mcp.Enum("created", "updated"), + ), + mcp.WithString("direction", + mcp.Description("Direction to sort repositories. Can be 'asc' or 'desc'. Default is 'desc'."), + mcp.Enum("asc", "desc"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + username, err := RequiredParam[string](request, "username") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + sort, err := OptionalParam[string](request, "sort") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + perPage := pagination.PerPage + if perPage == 0 { + perPage = 30 + } + + page := pagination.Page + if page == 0 { + page = 1 + } + + opts := &github.ActivityListStarredOptions{ + Sort: sort, + Direction: direction, + ListOptions: github.ListOptions{ + Page: page, + PerPage: perPage, + }, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + starredRepos, resp, err := client.Activity.ListStarred(ctx, username, opts) + if err != nil { + return nil, fmt.Errorf("failed to list starred repositories for user: %s: %w", username, err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(starredRepos) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/starred_repos_test.go b/pkg/github/starred_repos_test.go new file mode 100644 index 00000000..fbe1eb38 --- /dev/null +++ b/pkg/github/starred_repos_test.go @@ -0,0 +1,261 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v73/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListStarredRepositories(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListStarredRepositories(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_starred_repositories", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "username") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "direction") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"username"}) + + // Setup mock starred repositories for success case + mockStarredRepos := []*github.StarredRepository{ + { + Repository: &github.Repository{ + ID: github.Ptr(int64(1)), + Name: github.Ptr("awesome-repo"), + FullName: github.Ptr("owner/awesome-repo"), + Owner: &github.User{ + Login: github.Ptr("owner"), + }, + Description: github.Ptr("An awesome repository"), + HTMLURL: github.Ptr("https://github.com/owner/awesome-repo"), + StargazersCount: github.Ptr(100), + Language: github.Ptr("Go"), + Fork: github.Ptr(false), + Private: github.Ptr(false), + }, + StarredAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, + }, + { + Repository: &github.Repository{ + ID: github.Ptr(int64(2)), + Name: github.Ptr("cool-project"), + FullName: github.Ptr("another/cool-project"), + Owner: &github.User{ + Login: github.Ptr("another"), + }, + Description: github.Ptr("A cool project"), + HTMLURL: github.Ptr("https://github.com/another/cool-project"), + StargazersCount: github.Ptr(250), + Language: github.Ptr("JavaScript"), + Fork: github.Ptr(true), + Private: github.Ptr(false), + }, + StarredAt: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)}, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedStarredRepos []*github.StarredRepository + expectedErrMsg string + }{ + { + name: "successful starred repositories retrieval with minimal parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUsersStarredByUsername, + mockStarredRepos, + ), + ), + requestArgs: map[string]interface{}{ + "username": "testuser", + }, + expectError: false, + expectedStarredRepos: mockStarredRepos, + }, + { + name: "successful starred repositories retrieval with all parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUsersStarredByUsername, + expectQueryParams(t, map[string]string{ + "sort": "created", + "direction": "desc", + "page": "2", + "per_page": "50", + }).andThen( + mockResponse(t, http.StatusOK, mockStarredRepos), + ), + ), + ), + requestArgs: map[string]interface{}{ + "username": "testuser", + "sort": "created", + "direction": "desc", + "page": float64(2), + "perPage": float64(50), + }, + expectError: false, + expectedStarredRepos: mockStarredRepos, + }, + { + name: "successful starred repositories retrieval with sort updated", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUsersStarredByUsername, + expectQueryParams(t, map[string]string{ + "sort": "updated", + "direction": "asc", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockStarredRepos), + ), + ), + ), + requestArgs: map[string]interface{}{ + "username": "testuser", + "sort": "updated", + "direction": "asc", + }, + expectError: false, + expectedStarredRepos: mockStarredRepos, + }, + { + name: "successful starred repositories retrieval with default perPage", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUsersStarredByUsername, + expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", // Default perPage should be 30 + }).andThen( + mockResponse(t, http.StatusOK, mockStarredRepos), + ), + ), + ), + requestArgs: map[string]interface{}{ + "username": "testuser", + "page": float64(1), + }, + expectError: false, + expectedStarredRepos: mockStarredRepos, + }, + { + name: "empty starred repositories list", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUsersStarredByUsername, + []*github.StarredRepository{}, + ), + ), + requestArgs: map[string]interface{}{ + "username": "userwithnorepos", + }, + expectError: false, + expectedStarredRepos: []*github.StarredRepository{}, + }, + { + name: "user not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUsersStarredByUsername, + mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "username": "nonexistentuser", + }, + expectError: true, + expectedErrMsg: "failed to list starred repositories for user", + }, + { + name: "rate limit exceeded", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUsersStarredByUsername, + mockResponse(t, http.StatusForbidden, `{"message": "API rate limit exceeded"}`), + ), + ), + requestArgs: map[string]interface{}{ + "username": "testuser", + }, + expectError: true, + expectedErrMsg: "failed to list starred repositories for user", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListStarredRepositories(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.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + if tc.expectedErrMsg != "" { + require.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedStarredRepos []*github.StarredRepository + err = json.Unmarshal([]byte(textContent.Text), &returnedStarredRepos) + require.NoError(t, err) + + assert.Len(t, returnedStarredRepos, len(tc.expectedStarredRepos)) + for i, starredRepo := range returnedStarredRepos { + if i < len(tc.expectedStarredRepos) { + assert.Equal(t, *tc.expectedStarredRepos[i].Repository.ID, *starredRepo.Repository.ID) + assert.Equal(t, *tc.expectedStarredRepos[i].Repository.Name, *starredRepo.Repository.Name) + assert.Equal(t, *tc.expectedStarredRepos[i].Repository.FullName, *starredRepo.Repository.FullName) + assert.Equal(t, *tc.expectedStarredRepos[i].Repository.Owner.Login, *starredRepo.Repository.Owner.Login) + assert.Equal(t, *tc.expectedStarredRepos[i].Repository.HTMLURL, *starredRepo.Repository.HTMLURL) + assert.NotNil(t, starredRepo.StarredAt) + + if tc.expectedStarredRepos[i].Repository.Description != nil { + assert.Equal(t, *tc.expectedStarredRepos[i].Repository.Description, *starredRepo.Repository.Description) + } + if tc.expectedStarredRepos[i].Repository.Language != nil { + assert.Equal(t, *tc.expectedStarredRepos[i].Repository.Language, *starredRepo.Repository.Language) + } + } + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 7fb1d39c..cb0e53ed 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -31,6 +31,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ListBranches(getClient, t)), toolsets.NewServerTool(ListTags(getClient, t)), toolsets.NewServerTool(GetTag(getClient, t)), + toolsets.NewServerTool(ListStarredRepositories(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)),








ApplySandwichStrip

pFad - (p)hone/(F)rame/(a)nonymizer/(d)eclutterfier!      Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

Fetched URL: https://github.com/github/github-mcp-server/pull/815.diff

Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy