From 3f57f75f151829b73a55e9c8fee3fa411142ae25 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 21 Aug 2025 09:14:14 +0100 Subject: [PATCH 1/5] add get_release_by_tag tool --- pkg/github/repositories.go | 67 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 0925829a1..13d284ef2 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1441,6 +1441,73 @@ func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFun } } +// GetReleaseByTag creates a tool to get a specific release by its tag name in a GitHub repository. +func GetReleaseByTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_release_by_tag", + mcp.WithDescription(t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_RELEASE_BY_TAG_USER_TITLE", "Get a release by tag name"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("tag", + mcp.Required(), + mcp.Description("Tag name (e.g., 'v1.0.0')"), + ), + ), + 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 + } + tag, err := RequiredParam[string](request, "tag") + 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) + } + + release, resp, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, tag) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get release by tag: %s", tag), + 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 get release by tag: %s", string(body))), nil + } + + r, err := json.Marshal(release) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // filterPaths filters the entries in a GitHub tree to find paths that // match the given suffix. // maxResults limits the number of results returned to first maxResults entries, From cd51444c3e045f76b410bd4dd0264a637f87aea3 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 21 Aug 2025 09:14:18 +0100 Subject: [PATCH 2/5] add tool --- pkg/github/tools.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index b50499650..513b93e42 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -33,6 +33,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetTag(getClient, t)), toolsets.NewServerTool(ListReleases(getClient, t)), toolsets.NewServerTool(GetLatestRelease(getClient, t)), + toolsets.NewServerTool(GetReleaseByTag(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)), From 8770426767477e0fc8691e13d43c4ac09512f863 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 21 Aug 2025 09:24:57 +0100 Subject: [PATCH 3/5] add tests --- pkg/github/repositories_test.go | 165 ++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 63e577600..f5ebfd32b 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2287,6 +2287,171 @@ func Test_GetLatestRelease(t *testing.T) { } } +func Test_GetReleaseByTag(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := GetReleaseByTag(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_release_by_tag", 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, "tag") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"}) + + mockRelease := &github.RepositoryRelease{ + ID: github.Ptr(int64(1)), + TagName: github.Ptr("v1.0.0"), + Name: github.Ptr("Release v1.0.0"), + Body: github.Ptr("This is the first stable release."), + Assets: []*github.ReleaseAsset{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("release-v1.0.0.tar.gz"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult *github.RepositoryRelease + expectedErrMsg string + }{ + { + name: "successful release by tag fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposReleasesTagsByOwnerByRepoByTag, + mockRelease, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: false, + expectedResult: mockRelease, + }, + { + name: "missing owner parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: false, // Returns tool error, not Go error + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing repo parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + "tag": "v1.0.0", + }, + expectError: false, // Returns tool error, not Go error + expectedErrMsg: "missing required parameter: repo", + }, + { + name: "missing tag parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, // Returns tool error, not Go error + expectedErrMsg: "missing required parameter: tag", + }, + { + name: "release by tag not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesTagsByOwnerByRepoByTag, + 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", + "tag": "v999.0.0", + }, + expectError: false, // API errors return tool errors, not Go errors + expectedErrMsg: "failed to get release by tag: v999.0.0", + }, + { + name: "server error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesTagsByOwnerByRepoByTag, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: false, // API errors return tool errors, not Go errors + expectedErrMsg: "failed to get release by tag: v1.0.0", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := GetReleaseByTag(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + if tc.expectedErrMsg != "" { + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + var returnedRelease github.RepositoryRelease + err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) + require.NoError(t, err) + + assert.Equal(t, *tc.expectedResult.ID, *returnedRelease.ID) + assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) + assert.Equal(t, *tc.expectedResult.Name, *returnedRelease.Name) + if tc.expectedResult.Body != nil { + assert.Equal(t, *tc.expectedResult.Body, *returnedRelease.Body) + } + if len(tc.expectedResult.Assets) > 0 { + require.Len(t, returnedRelease.Assets, len(tc.expectedResult.Assets)) + assert.Equal(t, *tc.expectedResult.Assets[0].Name, *returnedRelease.Assets[0].Name) + } + }) + } +} + func Test_filterPaths(t *testing.T) { tests := []struct { name string From 8750d996f2d97d65cb4aaf8f9a038b6e5d8405d3 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 21 Aug 2025 09:25:04 +0100 Subject: [PATCH 4/5] autogen --- README.md | 5 ++++ .../__toolsnaps__/get_release_by_tag.snap | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 pkg/github/__toolsnaps__/get_release_by_tag.snap diff --git a/README.md b/README.md index e4543ecf5..b4168a136 100644 --- a/README.md +++ b/README.md @@ -846,6 +846,11 @@ The following sets of tools are available (all are on by default): - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) +- **get_release_by_tag** - Get a release by tag name + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `tag`: Tag name (e.g., 'v1.0.0') (string, required) + - **get_tag** - Get tag details - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) diff --git a/pkg/github/__toolsnaps__/get_release_by_tag.snap b/pkg/github/__toolsnaps__/get_release_by_tag.snap new file mode 100644 index 000000000..c96d3c30a --- /dev/null +++ b/pkg/github/__toolsnaps__/get_release_by_tag.snap @@ -0,0 +1,30 @@ +{ + "annotations": { + "title": "Get a release by tag name", + "readOnlyHint": true + }, + "description": "Get a specific release by its tag name in a GitHub repository", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "tag": { + "description": "Tag name (e.g., 'v1.0.0')", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "tag" + ], + "type": "object" + }, + "name": "get_release_by_tag" +} \ No newline at end of file From 4b980a7f222f97c5aa98cd451d3884450d1ec0d4 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 21 Aug 2025 09:38:04 +0100 Subject: [PATCH 5/5] remove comment --- pkg/github/repositories.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 13d284ef2..de2c6d01f 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1441,7 +1441,6 @@ func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFun } } -// GetReleaseByTag creates a tool to get a specific release by its tag name in a GitHub repository. func GetReleaseByTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_release_by_tag", mcp.WithDescription(t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository")),