From 33eb82cc8abe6f3e6262fd406a9ca08256df6abd Mon Sep 17 00:00:00 2001 From: LukasPoque Date: Wed, 21 May 2025 16:47:59 +0200 Subject: [PATCH 1/6] Add tool to star or unstar a repository for the user --- README.md | 5 ++ pkg/github/repositories.go | 72 +++++++++++++++++ pkg/github/repositories_test.go | 136 ++++++++++++++++++++++++++++++++ pkg/github/tools.go | 1 + 4 files changed, 214 insertions(+) diff --git a/README.md b/README.md index 352bb50eb..a0a107000 100644 --- a/README.md +++ b/README.md @@ -496,6 +496,11 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `page`: Page number (number, optional) - `perPage`: Results per page (number, optional) +- **toggle_repository_star** - Star or unstar a repository for the authenticated user + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `star`: True to star, false to unstar the repository (boolean, required) + - **create_repository** - Create a new GitHub repository - `name`: Repository name (string, required) - `description`: Repository description (string, optional) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 4403e2a19..5e1a58573 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -556,6 +556,78 @@ func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) } } +// ToggleRepositoryStar creates a tool to star or unstar a repository. +func ToggleRepositoryStar(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("toggle_repository_star", + mcp.WithDescription(t("TOOL_TOGGLE_REPOSITORY_STAR_DESCRIPTION", "Star or unstar a GitHub repository with your account")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_TOGGLE_REPOSITORY_STAR_USER_TITLE", "Star/unstar repository"), + ReadOnlyHint: toBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithBoolean("star", + mcp.Required(), + mcp.Description("True to star, false to unstar the repository"), + ), + ), + 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 + } + star, err := requiredParam[bool](request, "star") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var resp *github.Response + var action string + + if star { + resp, err = client.Activity.Star(ctx, owner, repo) + action = "star" + } else { + resp, err = client.Activity.Unstar(ctx, owner, repo) + action = "unstar" + } + + if err != nil { + return nil, fmt.Errorf("failed to %s repository: %w", action, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + 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 %s repository: %s", action, string(body))), nil + } + + resultAction := "starred" + if !star { + resultAction = "unstarred" + } + return mcp.NewToolResultText(fmt.Sprintf("Successfully %s repository %s/%s", resultAction, owner, repo)), nil + } +} + // DeleteFile creates a tool to delete a file in a GitHub repository. // This tool uses a more roundabout way of deleting a file than just using the client.Repositories.DeleteFile. // This is because REST file deletion endpoint (and client.Repositories.DeleteFile) don't add commit signing to the deletion commit, diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index e4edeee88..675a19e3d 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1963,3 +1963,139 @@ func Test_GetTag(t *testing.T) { }) } } + +func Test_ToggleRepositoryStar(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := ToggleRepositoryStar(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "toggle_repository_star", 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, "star") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "star"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + expectedResult string + }{ + { + name: "successfully star repository", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/user/starred/owner/repo", + Method: "PUT", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "star": true, + }, + expectError: false, + expectedResult: "Successfully starred repository owner/repo", + }, + { + name: "successfully unstar repository", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/user/starred/owner/repo", + Method: "DELETE", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "star": false, + }, + expectError: false, + expectedResult: "Successfully unstarred repository owner/repo", + }, + { + name: "star repository fails with unauthorized", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/user/starred/owner/repo", + Method: "PUT", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "star": true, + }, + expectError: true, + expectedErrMsg: "failed to star repository", + }, + { + name: "unstar repository fails with unauthorized", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/user/starred/owner/repo", + Method: "DELETE", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "star": false, + }, + expectError: true, + expectedErrMsg: "failed to unstar repository", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ToggleRepositoryStar(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 + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + assert.Equal(t, tc.expectedResult, textContent.Text) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index a04e7336b..6e4124bd4 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -39,6 +39,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(CreateBranch(getClient, t)), toolsets.NewServerTool(PushFiles(getClient, t)), toolsets.NewServerTool(DeleteFile(getClient, t)), + toolsets.NewServerTool(ToggleRepositoryStar(getClient, t)), ) issues := toolsets.NewToolset("issues", "GitHub Issues related tools"). AddReadTools( From badd5da749e58d0180183386595bc6a63730d674 Mon Sep 17 00:00:00 2001 From: LukasPoque Date: Wed, 21 May 2025 16:48:41 +0200 Subject: [PATCH 2/6] Add a tool to check if a repository is stared or not --- README.md | 4 ++ pkg/github/repositories.go | 44 ++++++++++++ pkg/github/repositories_test.go | 118 ++++++++++++++++++++++++++++++++ pkg/github/tools.go | 1 + 4 files changed, 167 insertions(+) diff --git a/README.md b/README.md index a0a107000..9a7534990 100644 --- a/README.md +++ b/README.md @@ -496,6 +496,10 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `page`: Page number (number, optional) - `perPage`: Results per page (number, optional) +- **is_repository_starred** - Check if a repository is starred by the authenticated user + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - **toggle_repository_star** - Star or unstar a repository for the authenticated user - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 5e1a58573..be2307f79 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -556,6 +556,50 @@ func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) } } +// IsRepositoryStarred creates a tool to check if a repository is starred by the authenticated user. +func IsRepositoryStarred(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("is_repository_starred", + mcp.WithDescription(t("TOOL_IS_REPOSITORY_STARRED_DESCRIPTION", "Check if a GitHub repository is starred by the authenticated user")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_IS_REPOSITORY_STARRED_USER_TITLE", "Check if repository is starred"), + ReadOnlyHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + ), + 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 + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + isStarred, resp, err := client.Activity.IsStarred(ctx, owner, repo) + if err != nil && resp == nil { + return nil, fmt.Errorf("failed to check if repository is starred: %w", err) + } + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + + return mcp.NewToolResultText(fmt.Sprintf(`{"is_starred": %t}`, isStarred)), nil + } +} + // ToggleRepositoryStar creates a tool to star or unstar a repository. func ToggleRepositoryStar(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("toggle_repository_star", diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 675a19e3d..a31a6ae46 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1964,6 +1964,124 @@ func Test_GetTag(t *testing.T) { } } +func Test_IsRepositoryStarred(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := IsRepositoryStarred(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "is_repository_starred", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + isStarred bool + }{ + { + name: "repository is starred", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/user/starred/owner/repo", + Method: "GET", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) // Status code for "is starred" = true + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + isStarred: true, + }, + { + name: "repository is not starred", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/user/starred/owner/repo", + Method: "GET", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) // Status code for "is starred" = false + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + isStarred: false, + }, + { + name: "check starred fails with unauthorized", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/user/starred/owner/repo", + Method: "GET", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, // The GitHub API returns false for not authenticated, not an error + isStarred: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := IsRepositoryStarred(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 + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Check if the result contains the correct starred status + var expectedResult string + if tc.isStarred { + expectedResult = `{"is_starred": true}` + } else { + expectedResult = `{"is_starred": false}` + } + assert.Contains(t, textContent.Text, expectedResult) + }) + } +} + func Test_ToggleRepositoryStar(t *testing.T) { // Verify tool definition mockClient := github.NewClient(nil) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 6e4124bd4..52c829a6d 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -31,6 +31,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(ListBranches(getClient, t)), toolsets.NewServerTool(ListTags(getClient, t)), toolsets.NewServerTool(GetTag(getClient, t)), + toolsets.NewServerTool(IsRepositoryStarred(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)), From 719f30adf1d67baec78a177a6e62c3fcaa7fecbe Mon Sep 17 00:00:00 2001 From: LukasPoque Date: Wed, 21 May 2025 17:55:04 +0200 Subject: [PATCH 3/6] Change 'star' param-type from bool to string in ToggleRepositoryStar function since'false' bool parameters are treated as missing by the server --- pkg/github/repositories.go | 16 +++++++++++++--- pkg/github/repositories_test.go | 19 +++++++++++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index be2307f79..018cf68eb 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -616,9 +616,9 @@ func ToggleRepositoryStar(getClient GetClientFn, t translations.TranslationHelpe mcp.Required(), mcp.Description("Repository name"), ), - mcp.WithBoolean("star", + mcp.WithString("star", mcp.Required(), - mcp.Description("True to star, false to unstar the repository"), + mcp.Description("'true' to star, 'false' to unstar the repository"), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -630,11 +630,21 @@ func ToggleRepositoryStar(getClient GetClientFn, t translations.TranslationHelpe if err != nil { return mcp.NewToolResultError(err.Error()), nil } - star, err := requiredParam[bool](request, "star") + starStr, err := requiredParam[string](request, "star") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + var star bool + switch starStr { + case "true": + star = true + case "false": + star = false + default: + return mcp.NewToolResultError("parameter 'star' must be exactly 'true' or 'false'"), nil + } + client, err := getClient(ctx) if err != nil { return nil, fmt.Errorf("failed to get GitHub client: %w", err) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index a31a6ae46..1f72ca90b 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2118,7 +2118,7 @@ func Test_ToggleRepositoryStar(t *testing.T) { requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", - "star": true, + "star": "true", }, expectError: false, expectedResult: "Successfully starred repository owner/repo", @@ -2139,7 +2139,7 @@ func Test_ToggleRepositoryStar(t *testing.T) { requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", - "star": false, + "star": "false", }, expectError: false, expectedResult: "Successfully unstarred repository owner/repo", @@ -2161,7 +2161,7 @@ func Test_ToggleRepositoryStar(t *testing.T) { requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", - "star": true, + "star": "true", }, expectError: true, expectedErrMsg: "failed to star repository", @@ -2183,11 +2183,22 @@ func Test_ToggleRepositoryStar(t *testing.T) { requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", - "star": false, + "star": "false", }, expectError: true, expectedErrMsg: "failed to unstar repository", }, + { + name: "invalid star parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "star": "invalid", + }, + expectError: false, // We expect a tool error, not a Go error + expectedResult: "parameter 'star' must be exactly 'true' or 'false'", + }, } for _, tc := range tests { From 37d06f8e37d93bab27471e026930cf40552cbcdb Mon Sep 17 00:00:00 2001 From: LukasPoque Date: Wed, 21 May 2025 18:39:32 +0200 Subject: [PATCH 4/6] Add tool to list repositories starred by the authenticated user --- README.md | 6 ++ pkg/github/search.go | 70 +++++++++++++++ pkg/github/search_test.go | 173 ++++++++++++++++++++++++++++++++++++++ pkg/github/tools.go | 1 + 4 files changed, 250 insertions(+) diff --git a/README.md b/README.md index 9a7534990..ff8226502 100644 --- a/README.md +++ b/README.md @@ -559,6 +559,12 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `page`: Page number (number, optional) - `perPage`: Results per page (number, optional) +- **list_starred_repositories** - List repositories starred by the authenticated user + - `sort`: How to sort the results ('created' or 'updated') (string, optional) + - `direction`: Direction to sort ('asc' or 'desc') (string, optional) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + ### Code Scanning - **get_code_scanning_alert** - Get a code scanning alert diff --git a/pkg/github/search.go b/pkg/github/search.go index ac5e2994c..369f5d338 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -222,3 +222,73 @@ func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (t return mcp.NewToolResultText(string(r)), nil } } + +// ListStarredRepositories creates a tool to list repositories starred by the authenticated user. +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 starred by the authenticated user")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_STARRED_REPOSITORIES_USER_TITLE", "List starred repositories"), + ReadOnlyHint: toBoolPtr(true), + }), + mcp.WithString("sort", + mcp.Description("How to sort the results ('created' or 'updated')"), + mcp.Enum("created", "updated"), + ), + mcp.WithString("direction", + mcp.Description("Direction to sort ('asc' or 'desc')"), + mcp.Enum("asc", "desc"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + 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 + } + + opts := &github.ActivityListStarredOptions{ + Sort: sort, + Direction: direction, + 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) + } + + // Empty string for user parameter means the authenticated user + starredRepos, resp, err := client.Activity.ListStarred(ctx, "", opts) + if err != nil { + return nil, fmt.Errorf("failed to list starred repositories: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 200 { + 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 starred repositories: %s", string(body))), nil + } + + 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/search_test.go b/pkg/github/search_test.go index b61518e47..bebbd4014 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -311,6 +311,179 @@ func Test_SearchCode(t *testing.T) { } } +func Test_ListStarredRepositories(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := ListStarredRepositories(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_starred_repositories", tool.Name) + assert.NotEmpty(t, tool.Description) + 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.Empty(t, tool.InputSchema.Required) // No required parameters + + // Setup mock starred repositories results + mockStarredRepos := []*github.StarredRepository{ + { + StarredAt: &github.Timestamp{}, + Repository: &github.Repository{ + ID: github.Ptr(int64(12345)), + Name: github.Ptr("repo-1"), + FullName: github.Ptr("owner/repo-1"), + HTMLURL: github.Ptr("https://github.com/owner/repo-1"), + Description: github.Ptr("Test repository 1"), + StargazersCount: github.Ptr(100), + Language: github.Ptr("Go"), + Fork: github.Ptr(false), + }, + }, + { + StarredAt: &github.Timestamp{}, + Repository: &github.Repository{ + ID: github.Ptr(int64(67890)), + Name: github.Ptr("repo-2"), + FullName: github.Ptr("owner/repo-2"), + HTMLURL: github.Ptr("https://github.com/owner/repo-2"), + Description: github.Ptr("Test repository 2"), + StargazersCount: github.Ptr(50), + Language: github.Ptr("JavaScript"), + Fork: github.Ptr(true), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult []*github.StarredRepository + expectedErrMsg string + }{ + { + name: "successful starred repositories list with all parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUserStarred, + expectQueryParams(t, map[string]string{ + "sort": "created", + "direction": "desc", + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockStarredRepos), + ), + ), + ), + requestArgs: map[string]interface{}{ + "sort": "created", + "direction": "desc", + "page": float64(2), + "perPage": float64(10), + }, + expectError: false, + expectedResult: mockStarredRepos, + }, + { + name: "list starred repositories with default parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUserStarred, + expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockStarredRepos), + ), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: false, + expectedResult: mockStarredRepos, + }, + { + name: "list starred repositories with sort only", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUserStarred, + expectQueryParams(t, map[string]string{ + "sort": "updated", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockStarredRepos), + ), + ), + ), + requestArgs: map[string]interface{}{ + "sort": "updated", + }, + expectError: false, + expectedResult: mockStarredRepos, + }, + { + name: "list starred repositories fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUserStarred, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: true, + expectedErrMsg: "failed to list starred repositories", + }, + } + + 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 + } + + 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 returnedResult []*github.StarredRepository + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) + require.NoError(t, err) + assert.Len(t, returnedResult, len(tc.expectedResult)) + + for i, repo := range returnedResult { + assert.Equal(t, *tc.expectedResult[i].Repository.ID, *repo.Repository.ID) + assert.Equal(t, *tc.expectedResult[i].Repository.Name, *repo.Repository.Name) + assert.Equal(t, *tc.expectedResult[i].Repository.FullName, *repo.Repository.FullName) + assert.Equal(t, *tc.expectedResult[i].Repository.HTMLURL, *repo.Repository.HTMLURL) + assert.Equal(t, *tc.expectedResult[i].Repository.Description, *repo.Repository.Description) + assert.Equal(t, *tc.expectedResult[i].Repository.StargazersCount, *repo.Repository.StargazersCount) + assert.Equal(t, *tc.expectedResult[i].Repository.Language, *repo.Repository.Language) + assert.Equal(t, *tc.expectedResult[i].Repository.Fork, *repo.Repository.Fork) + } + }) + } +} + func Test_SearchUsers(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 52c829a6d..49288277d 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -57,6 +57,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, users := toolsets.NewToolset("users", "GitHub User related tools"). AddReadTools( toolsets.NewServerTool(SearchUsers(getClient, t)), + toolsets.NewServerTool(ListStarredRepositories(getClient, t)), ) pullRequests := toolsets.NewToolset("pull_requests", "GitHub Pull Request related tools"). AddReadTools( From 75d0781c90f2981cb551a1efa072e03720921a78 Mon Sep 17 00:00:00 2001 From: LukasPoque Date: Thu, 22 May 2025 10:06:22 +0200 Subject: [PATCH 5/6] Simplify parameter description for ListStarredRepositories --- pkg/github/search.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/search.go b/pkg/github/search.go index 369f5d338..c99bb101f 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -232,11 +232,11 @@ func ListStarredRepositories(getClient GetClientFn, t translations.TranslationHe ReadOnlyHint: toBoolPtr(true), }), mcp.WithString("sort", - mcp.Description("How to sort the results ('created' or 'updated')"), + mcp.Description("How to sort the results"), mcp.Enum("created", "updated"), ), mcp.WithString("direction", - mcp.Description("Direction to sort ('asc' or 'desc')"), + mcp.Description("Direction to sort"), mcp.Enum("asc", "desc"), ), WithPagination(), From 4c07dafe52d26c9cf5b283e1448471610b6edf83 Mon Sep 17 00:00:00 2001 From: LukasPoque Date: Thu, 22 May 2025 10:07:42 +0200 Subject: [PATCH 6/6] Refactor ListStarredRepositories to return simplified repository structure limited to important repo details --- pkg/github/search.go | 45 ++++++++++++++++++++++++++++- pkg/github/search_test.go | 59 ++++++++++++++++++++++++++++++--------- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/pkg/github/search.go b/pkg/github/search.go index c99bb101f..7159137e8 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -284,7 +284,50 @@ func ListStarredRepositories(getClient GetClientFn, t translations.TranslationHe return mcp.NewToolResultError(fmt.Sprintf("failed to list starred repositories: %s", string(body))), nil } - r, err := json.Marshal(starredRepos) + // Filter results to only include starred_at, repo.full_name, repo.html_url, repo.description, repo.stargazers_count, repo.language + // This saves context tokens, further information can be requested via repository_info tool + type SimplifiedRepo struct { + FullName string `json:"full_name,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Description string `json:"description,omitempty"` + StargazersCount int `json:"stargazers_count,omitempty"` + Language string `json:"language,omitempty"` + } + + type SimplifiedStarredRepo struct { + StarredAt *github.Timestamp `json:"starred_at,omitempty"` + Repository SimplifiedRepo `json:"repository,omitempty"` + } + + filteredRepos := make([]SimplifiedStarredRepo, 0, len(starredRepos)) + for _, repo := range starredRepos { + simplifiedRepo := SimplifiedStarredRepo{ + StarredAt: repo.StarredAt, + Repository: SimplifiedRepo{}, + } + + if repo.Repository != nil { + if repo.Repository.FullName != nil { + simplifiedRepo.Repository.FullName = *repo.Repository.FullName + } + if repo.Repository.HTMLURL != nil { + simplifiedRepo.Repository.HTMLURL = *repo.Repository.HTMLURL + } + if repo.Repository.Description != nil { + simplifiedRepo.Repository.Description = *repo.Repository.Description + } + if repo.Repository.StargazersCount != nil { + simplifiedRepo.Repository.StargazersCount = *repo.Repository.StargazersCount + } + if repo.Repository.Language != nil { + simplifiedRepo.Repository.Language = *repo.Repository.Language + } + } + + filteredRepos = append(filteredRepos, simplifiedRepo) + } + + r, err := json.Marshal(filteredRepos) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index bebbd4014..ba353fa5a 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -354,12 +354,48 @@ func Test_ListStarredRepositories(t *testing.T) { }, } + type SimplifiedRepo struct { + FullName string `json:"full_name,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Description string `json:"description,omitempty"` + StargazersCount int `json:"stargazers_count,omitempty"` + Language string `json:"language,omitempty"` + } + + type SimplifiedStarredRepo struct { + StarredAt *github.Timestamp `json:"starred_at,omitempty"` + Repository SimplifiedRepo `json:"repository,omitempty"` + } + + expectedFilteredRepos := []SimplifiedStarredRepo{ + { + StarredAt: &github.Timestamp{}, + Repository: SimplifiedRepo{ + FullName: "owner/repo-1", + HTMLURL: "https://github.com/owner/repo-1", + Description: "Test repository 1", + StargazersCount: 100, + Language: "Go", + }, + }, + { + StarredAt: &github.Timestamp{}, + Repository: SimplifiedRepo{ + FullName: "owner/repo-2", + HTMLURL: "https://github.com/owner/repo-2", + Description: "Test repository 2", + StargazersCount: 50, + Language: "JavaScript", + }, + }, + } + tests := []struct { name string mockedClient *http.Client requestArgs map[string]interface{} expectError bool - expectedResult []*github.StarredRepository + expectedResult []SimplifiedStarredRepo expectedErrMsg string }{ { @@ -384,7 +420,7 @@ func Test_ListStarredRepositories(t *testing.T) { "perPage": float64(10), }, expectError: false, - expectedResult: mockStarredRepos, + expectedResult: expectedFilteredRepos, }, { name: "list starred repositories with default parameters", @@ -401,7 +437,7 @@ func Test_ListStarredRepositories(t *testing.T) { ), requestArgs: map[string]interface{}{}, expectError: false, - expectedResult: mockStarredRepos, + expectedResult: expectedFilteredRepos, }, { name: "list starred repositories with sort only", @@ -421,7 +457,7 @@ func Test_ListStarredRepositories(t *testing.T) { "sort": "updated", }, expectError: false, - expectedResult: mockStarredRepos, + expectedResult: expectedFilteredRepos, }, { name: "list starred repositories fails", @@ -465,20 +501,17 @@ func Test_ListStarredRepositories(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedResult []*github.StarredRepository + var returnedResult []SimplifiedStarredRepo err = json.Unmarshal([]byte(textContent.Text), &returnedResult) require.NoError(t, err) assert.Len(t, returnedResult, len(tc.expectedResult)) for i, repo := range returnedResult { - assert.Equal(t, *tc.expectedResult[i].Repository.ID, *repo.Repository.ID) - assert.Equal(t, *tc.expectedResult[i].Repository.Name, *repo.Repository.Name) - assert.Equal(t, *tc.expectedResult[i].Repository.FullName, *repo.Repository.FullName) - assert.Equal(t, *tc.expectedResult[i].Repository.HTMLURL, *repo.Repository.HTMLURL) - assert.Equal(t, *tc.expectedResult[i].Repository.Description, *repo.Repository.Description) - assert.Equal(t, *tc.expectedResult[i].Repository.StargazersCount, *repo.Repository.StargazersCount) - assert.Equal(t, *tc.expectedResult[i].Repository.Language, *repo.Repository.Language) - assert.Equal(t, *tc.expectedResult[i].Repository.Fork, *repo.Repository.Fork) + assert.Equal(t, tc.expectedResult[i].Repository.FullName, repo.Repository.FullName) + assert.Equal(t, tc.expectedResult[i].Repository.HTMLURL, repo.Repository.HTMLURL) + assert.Equal(t, tc.expectedResult[i].Repository.Description, repo.Repository.Description) + assert.Equal(t, tc.expectedResult[i].Repository.StargazersCount, repo.Repository.StargazersCount) + assert.Equal(t, tc.expectedResult[i].Repository.Language, repo.Repository.Language) } }) } 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