Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions pkg/github/__toolsnaps__/get_release_by_tag.snap
Original file line number Diff line number Diff line change
@@ -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"
}
67 changes: 67 additions & 0 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
165 changes: 165 additions & 0 deletions pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Loading