Skip to content

Commit 73dcb46

Browse files
authored
Add get_release_by_tag tool (#938)
* add get_release_by_tag tool * add tool * add tests * autogen * remove comment
1 parent 2621dbe commit 73dcb46

File tree

5 files changed

+267
-0
lines changed

5 files changed

+267
-0
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,11 @@ The following sets of tools are available (all are on by default):
846846
- `owner`: Repository owner (string, required)
847847
- `repo`: Repository name (string, required)
848848

849+
- **get_release_by_tag** - Get a release by tag name
850+
- `owner`: Repository owner (string, required)
851+
- `repo`: Repository name (string, required)
852+
- `tag`: Tag name (e.g., 'v1.0.0') (string, required)
853+
849854
- **get_tag** - Get tag details
850855
- `owner`: Repository owner (string, required)
851856
- `repo`: Repository name (string, required)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"annotations": {
3+
"title": "Get a release by tag name",
4+
"readOnlyHint": true
5+
},
6+
"description": "Get a specific release by its tag name in a GitHub repository",
7+
"inputSchema": {
8+
"properties": {
9+
"owner": {
10+
"description": "Repository owner",
11+
"type": "string"
12+
},
13+
"repo": {
14+
"description": "Repository name",
15+
"type": "string"
16+
},
17+
"tag": {
18+
"description": "Tag name (e.g., 'v1.0.0')",
19+
"type": "string"
20+
}
21+
},
22+
"required": [
23+
"owner",
24+
"repo",
25+
"tag"
26+
],
27+
"type": "object"
28+
},
29+
"name": "get_release_by_tag"
30+
}

pkg/github/repositories.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,6 +1441,72 @@ func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFun
14411441
}
14421442
}
14431443

1444+
func GetReleaseByTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
1445+
return mcp.NewTool("get_release_by_tag",
1446+
mcp.WithDescription(t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository")),
1447+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
1448+
Title: t("TOOL_GET_RELEASE_BY_TAG_USER_TITLE", "Get a release by tag name"),
1449+
ReadOnlyHint: ToBoolPtr(true),
1450+
}),
1451+
mcp.WithString("owner",
1452+
mcp.Required(),
1453+
mcp.Description("Repository owner"),
1454+
),
1455+
mcp.WithString("repo",
1456+
mcp.Required(),
1457+
mcp.Description("Repository name"),
1458+
),
1459+
mcp.WithString("tag",
1460+
mcp.Required(),
1461+
mcp.Description("Tag name (e.g., 'v1.0.0')"),
1462+
),
1463+
),
1464+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
1465+
owner, err := RequiredParam[string](request, "owner")
1466+
if err != nil {
1467+
return mcp.NewToolResultError(err.Error()), nil
1468+
}
1469+
repo, err := RequiredParam[string](request, "repo")
1470+
if err != nil {
1471+
return mcp.NewToolResultError(err.Error()), nil
1472+
}
1473+
tag, err := RequiredParam[string](request, "tag")
1474+
if err != nil {
1475+
return mcp.NewToolResultError(err.Error()), nil
1476+
}
1477+
1478+
client, err := getClient(ctx)
1479+
if err != nil {
1480+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
1481+
}
1482+
1483+
release, resp, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, tag)
1484+
if err != nil {
1485+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
1486+
fmt.Sprintf("failed to get release by tag: %s", tag),
1487+
resp,
1488+
err,
1489+
), nil
1490+
}
1491+
defer func() { _ = resp.Body.Close() }()
1492+
1493+
if resp.StatusCode != http.StatusOK {
1494+
body, err := io.ReadAll(resp.Body)
1495+
if err != nil {
1496+
return nil, fmt.Errorf("failed to read response body: %w", err)
1497+
}
1498+
return mcp.NewToolResultError(fmt.Sprintf("failed to get release by tag: %s", string(body))), nil
1499+
}
1500+
1501+
r, err := json.Marshal(release)
1502+
if err != nil {
1503+
return nil, fmt.Errorf("failed to marshal response: %w", err)
1504+
}
1505+
1506+
return mcp.NewToolResultText(string(r)), nil
1507+
}
1508+
}
1509+
14441510
// filterPaths filters the entries in a GitHub tree to find paths that
14451511
// match the given suffix.
14461512
// maxResults limits the number of results returned to first maxResults entries,

pkg/github/repositories_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2287,6 +2287,171 @@ func Test_GetLatestRelease(t *testing.T) {
22872287
}
22882288
}
22892289

2290+
func Test_GetReleaseByTag(t *testing.T) {
2291+
mockClient := github.NewClient(nil)
2292+
tool, _ := GetReleaseByTag(stubGetClientFn(mockClient), translations.NullTranslationHelper)
2293+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
2294+
2295+
assert.Equal(t, "get_release_by_tag", tool.Name)
2296+
assert.NotEmpty(t, tool.Description)
2297+
assert.Contains(t, tool.InputSchema.Properties, "owner")
2298+
assert.Contains(t, tool.InputSchema.Properties, "repo")
2299+
assert.Contains(t, tool.InputSchema.Properties, "tag")
2300+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"})
2301+
2302+
mockRelease := &github.RepositoryRelease{
2303+
ID: github.Ptr(int64(1)),
2304+
TagName: github.Ptr("v1.0.0"),
2305+
Name: github.Ptr("Release v1.0.0"),
2306+
Body: github.Ptr("This is the first stable release."),
2307+
Assets: []*github.ReleaseAsset{
2308+
{
2309+
ID: github.Ptr(int64(1)),
2310+
Name: github.Ptr("release-v1.0.0.tar.gz"),
2311+
},
2312+
},
2313+
}
2314+
2315+
tests := []struct {
2316+
name string
2317+
mockedClient *http.Client
2318+
requestArgs map[string]interface{}
2319+
expectError bool
2320+
expectedResult *github.RepositoryRelease
2321+
expectedErrMsg string
2322+
}{
2323+
{
2324+
name: "successful release by tag fetch",
2325+
mockedClient: mock.NewMockedHTTPClient(
2326+
mock.WithRequestMatch(
2327+
mock.GetReposReleasesTagsByOwnerByRepoByTag,
2328+
mockRelease,
2329+
),
2330+
),
2331+
requestArgs: map[string]interface{}{
2332+
"owner": "owner",
2333+
"repo": "repo",
2334+
"tag": "v1.0.0",
2335+
},
2336+
expectError: false,
2337+
expectedResult: mockRelease,
2338+
},
2339+
{
2340+
name: "missing owner parameter",
2341+
mockedClient: mock.NewMockedHTTPClient(),
2342+
requestArgs: map[string]interface{}{
2343+
"repo": "repo",
2344+
"tag": "v1.0.0",
2345+
},
2346+
expectError: false, // Returns tool error, not Go error
2347+
expectedErrMsg: "missing required parameter: owner",
2348+
},
2349+
{
2350+
name: "missing repo parameter",
2351+
mockedClient: mock.NewMockedHTTPClient(),
2352+
requestArgs: map[string]interface{}{
2353+
"owner": "owner",
2354+
"tag": "v1.0.0",
2355+
},
2356+
expectError: false, // Returns tool error, not Go error
2357+
expectedErrMsg: "missing required parameter: repo",
2358+
},
2359+
{
2360+
name: "missing tag parameter",
2361+
mockedClient: mock.NewMockedHTTPClient(),
2362+
requestArgs: map[string]interface{}{
2363+
"owner": "owner",
2364+
"repo": "repo",
2365+
},
2366+
expectError: false, // Returns tool error, not Go error
2367+
expectedErrMsg: "missing required parameter: tag",
2368+
},
2369+
{
2370+
name: "release by tag not found",
2371+
mockedClient: mock.NewMockedHTTPClient(
2372+
mock.WithRequestMatchHandler(
2373+
mock.GetReposReleasesTagsByOwnerByRepoByTag,
2374+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2375+
w.WriteHeader(http.StatusNotFound)
2376+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
2377+
}),
2378+
),
2379+
),
2380+
requestArgs: map[string]interface{}{
2381+
"owner": "owner",
2382+
"repo": "repo",
2383+
"tag": "v999.0.0",
2384+
},
2385+
expectError: false, // API errors return tool errors, not Go errors
2386+
expectedErrMsg: "failed to get release by tag: v999.0.0",
2387+
},
2388+
{
2389+
name: "server error",
2390+
mockedClient: mock.NewMockedHTTPClient(
2391+
mock.WithRequestMatchHandler(
2392+
mock.GetReposReleasesTagsByOwnerByRepoByTag,
2393+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2394+
w.WriteHeader(http.StatusInternalServerError)
2395+
_, _ = w.Write([]byte(`{"message": "Internal Server Error"}`))
2396+
}),
2397+
),
2398+
),
2399+
requestArgs: map[string]interface{}{
2400+
"owner": "owner",
2401+
"repo": "repo",
2402+
"tag": "v1.0.0",
2403+
},
2404+
expectError: false, // API errors return tool errors, not Go errors
2405+
expectedErrMsg: "failed to get release by tag: v1.0.0",
2406+
},
2407+
}
2408+
2409+
for _, tc := range tests {
2410+
t.Run(tc.name, func(t *testing.T) {
2411+
client := github.NewClient(tc.mockedClient)
2412+
_, handler := GetReleaseByTag(stubGetClientFn(client), translations.NullTranslationHelper)
2413+
2414+
request := createMCPRequest(tc.requestArgs)
2415+
2416+
result, err := handler(context.Background(), request)
2417+
2418+
if tc.expectError {
2419+
require.Error(t, err)
2420+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
2421+
return
2422+
}
2423+
2424+
require.NoError(t, err)
2425+
2426+
if tc.expectedErrMsg != "" {
2427+
require.True(t, result.IsError)
2428+
errorContent := getErrorResult(t, result)
2429+
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
2430+
return
2431+
}
2432+
2433+
require.False(t, result.IsError)
2434+
2435+
textContent := getTextResult(t, result)
2436+
2437+
var returnedRelease github.RepositoryRelease
2438+
err = json.Unmarshal([]byte(textContent.Text), &returnedRelease)
2439+
require.NoError(t, err)
2440+
2441+
assert.Equal(t, *tc.expectedResult.ID, *returnedRelease.ID)
2442+
assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName)
2443+
assert.Equal(t, *tc.expectedResult.Name, *returnedRelease.Name)
2444+
if tc.expectedResult.Body != nil {
2445+
assert.Equal(t, *tc.expectedResult.Body, *returnedRelease.Body)
2446+
}
2447+
if len(tc.expectedResult.Assets) > 0 {
2448+
require.Len(t, returnedRelease.Assets, len(tc.expectedResult.Assets))
2449+
assert.Equal(t, *tc.expectedResult.Assets[0].Name, *returnedRelease.Assets[0].Name)
2450+
}
2451+
})
2452+
}
2453+
}
2454+
22902455
func Test_filterPaths(t *testing.T) {
22912456
tests := []struct {
22922457
name string

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
3333
toolsets.NewServerTool(GetTag(getClient, t)),
3434
toolsets.NewServerTool(ListReleases(getClient, t)),
3535
toolsets.NewServerTool(GetLatestRelease(getClient, t)),
36+
toolsets.NewServerTool(GetReleaseByTag(getClient, t)),
3637
).
3738
AddWriteTools(
3839
toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)),

0 commit comments

Comments
 (0)