Skip to content

Commit 6f06cba

Browse files
List releases (#862)
* docs: add required GitHub token permissions per action (#128) * Feat: Add support for GitHub Releases (list and latest) tools * Update repositories.go Update params * Update README.md Add only new tool to the readme * Revise GitHub PAT instructions and permissions section * Readme formatting --------- Co-authored-by: Arya Soni <aryasoni98@gmail.com> Co-authored-by: Arya Soni <18515597+aryasoni98@users.noreply.github.com>
1 parent 02c8629 commit 6f06cba

File tree

4 files changed

+306
-1
lines changed

4 files changed

+306
-1
lines changed

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,10 @@ The following sets of tools are available (all are on by default):
829829
- `repo`: Repository name (string, required)
830830
- `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional)
831831

832+
- **get_latest_release** - Get latest release
833+
- `owner`: Repository owner (string, required)
834+
- `repo`: Repository name (string, required)
835+
832836
- **get_tag** - Get tag details
833837
- `owner`: Repository owner (string, required)
834838
- `repo`: Repository name (string, required)
@@ -848,6 +852,12 @@ The following sets of tools are available (all are on by default):
848852
- `repo`: Repository name (string, required)
849853
- `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)
850854

855+
- **list_releases** - List releases
856+
- `owner`: Repository owner (string, required)
857+
- `page`: Page number for pagination (min 1) (number, optional)
858+
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
859+
- `repo`: Repository name (string, required)
860+
851861
- **list_tags** - List tags
852862
- `owner`: Repository owner (string, required)
853863
- `page`: Page number for pagination (min 1) (number, optional)
@@ -1075,4 +1085,4 @@ The exported Go API of this module should currently be considered unstable, and
10751085

10761086
## License
10771087

1078-
This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE) for the full terms.
1088+
This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE) for the full terms.

pkg/github/repositories.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,6 +1321,126 @@ func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool m
13211321
}
13221322
}
13231323

1324+
// ListReleases creates a tool to list releases in a GitHub repository.
1325+
func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
1326+
return mcp.NewTool("list_releases",
1327+
mcp.WithDescription(t("TOOL_LIST_RELEASES_DESCRIPTION", "List releases in a GitHub repository")),
1328+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
1329+
Title: t("TOOL_LIST_RELEASES_USER_TITLE", "List releases"),
1330+
ReadOnlyHint: ToBoolPtr(true),
1331+
}),
1332+
mcp.WithString("owner",
1333+
mcp.Required(),
1334+
mcp.Description("Repository owner"),
1335+
),
1336+
mcp.WithString("repo",
1337+
mcp.Required(),
1338+
mcp.Description("Repository name"),
1339+
),
1340+
WithPagination(),
1341+
),
1342+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
1343+
owner, err := RequiredParam[string](request, "owner")
1344+
if err != nil {
1345+
return mcp.NewToolResultError(err.Error()), nil
1346+
}
1347+
repo, err := RequiredParam[string](request, "repo")
1348+
if err != nil {
1349+
return mcp.NewToolResultError(err.Error()), nil
1350+
}
1351+
pagination, err := OptionalPaginationParams(request)
1352+
if err != nil {
1353+
return mcp.NewToolResultError(err.Error()), nil
1354+
}
1355+
1356+
opts := &github.ListOptions{
1357+
Page: pagination.Page,
1358+
PerPage: pagination.PerPage,
1359+
}
1360+
1361+
client, err := getClient(ctx)
1362+
if err != nil {
1363+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
1364+
}
1365+
1366+
releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opts)
1367+
if err != nil {
1368+
return nil, fmt.Errorf("failed to list releases: %w", err)
1369+
}
1370+
defer func() { _ = resp.Body.Close() }()
1371+
1372+
if resp.StatusCode != http.StatusOK {
1373+
body, err := io.ReadAll(resp.Body)
1374+
if err != nil {
1375+
return nil, fmt.Errorf("failed to read response body: %w", err)
1376+
}
1377+
return mcp.NewToolResultError(fmt.Sprintf("failed to list releases: %s", string(body))), nil
1378+
}
1379+
1380+
r, err := json.Marshal(releases)
1381+
if err != nil {
1382+
return nil, fmt.Errorf("failed to marshal response: %w", err)
1383+
}
1384+
1385+
return mcp.NewToolResultText(string(r)), nil
1386+
}
1387+
}
1388+
1389+
// GetLatestRelease creates a tool to get the latest release in a GitHub repository.
1390+
func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
1391+
return mcp.NewTool("get_latest_release",
1392+
mcp.WithDescription(t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository")),
1393+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
1394+
Title: t("TOOL_GET_LATEST_RELEASE_USER_TITLE", "Get latest release"),
1395+
ReadOnlyHint: ToBoolPtr(true),
1396+
}),
1397+
mcp.WithString("owner",
1398+
mcp.Required(),
1399+
mcp.Description("Repository owner"),
1400+
),
1401+
mcp.WithString("repo",
1402+
mcp.Required(),
1403+
mcp.Description("Repository name"),
1404+
),
1405+
),
1406+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
1407+
owner, err := RequiredParam[string](request, "owner")
1408+
if err != nil {
1409+
return mcp.NewToolResultError(err.Error()), nil
1410+
}
1411+
repo, err := RequiredParam[string](request, "repo")
1412+
if err != nil {
1413+
return mcp.NewToolResultError(err.Error()), nil
1414+
}
1415+
1416+
client, err := getClient(ctx)
1417+
if err != nil {
1418+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
1419+
}
1420+
1421+
release, resp, err := client.Repositories.GetLatestRelease(ctx, owner, repo)
1422+
if err != nil {
1423+
return nil, fmt.Errorf("failed to get latest release: %w", err)
1424+
}
1425+
defer func() { _ = resp.Body.Close() }()
1426+
1427+
if resp.StatusCode != http.StatusOK {
1428+
body, err := io.ReadAll(resp.Body)
1429+
if err != nil {
1430+
return nil, fmt.Errorf("failed to read response body: %w", err)
1431+
}
1432+
return mcp.NewToolResultError(fmt.Sprintf("failed to get latest release: %s", string(body))), nil
1433+
}
1434+
1435+
r, err := json.Marshal(release)
1436+
if err != nil {
1437+
return nil, fmt.Errorf("failed to marshal response: %w", err)
1438+
}
1439+
1440+
return mcp.NewToolResultText(string(r)), nil
1441+
}
1442+
}
1443+
13241444
// filterPaths filters the entries in a GitHub tree to find paths that
13251445
// match the given suffix.
13261446
// maxResults limits the number of results returned to first maxResults entries,

pkg/github/repositories_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2114,6 +2114,179 @@ func Test_GetTag(t *testing.T) {
21142114
}
21152115
}
21162116

2117+
func Test_ListReleases(t *testing.T) {
2118+
mockClient := github.NewClient(nil)
2119+
tool, _ := ListReleases(stubGetClientFn(mockClient), translations.NullTranslationHelper)
2120+
2121+
assert.Equal(t, "list_releases", tool.Name)
2122+
assert.NotEmpty(t, tool.Description)
2123+
assert.Contains(t, tool.InputSchema.Properties, "owner")
2124+
assert.Contains(t, tool.InputSchema.Properties, "repo")
2125+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
2126+
2127+
mockReleases := []*github.RepositoryRelease{
2128+
{
2129+
ID: github.Ptr(int64(1)),
2130+
TagName: github.Ptr("v1.0.0"),
2131+
Name: github.Ptr("First Release"),
2132+
},
2133+
{
2134+
ID: github.Ptr(int64(2)),
2135+
TagName: github.Ptr("v0.9.0"),
2136+
Name: github.Ptr("Beta Release"),
2137+
},
2138+
}
2139+
2140+
tests := []struct {
2141+
name string
2142+
mockedClient *http.Client
2143+
requestArgs map[string]interface{}
2144+
expectError bool
2145+
expectedResult []*github.RepositoryRelease
2146+
expectedErrMsg string
2147+
}{
2148+
{
2149+
name: "successful releases list",
2150+
mockedClient: mock.NewMockedHTTPClient(
2151+
mock.WithRequestMatch(
2152+
mock.GetReposReleasesByOwnerByRepo,
2153+
mockReleases,
2154+
),
2155+
),
2156+
requestArgs: map[string]interface{}{
2157+
"owner": "owner",
2158+
"repo": "repo",
2159+
},
2160+
expectError: false,
2161+
expectedResult: mockReleases,
2162+
},
2163+
{
2164+
name: "releases list fails",
2165+
mockedClient: mock.NewMockedHTTPClient(
2166+
mock.WithRequestMatchHandler(
2167+
mock.GetReposReleasesByOwnerByRepo,
2168+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2169+
w.WriteHeader(http.StatusNotFound)
2170+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
2171+
}),
2172+
),
2173+
),
2174+
requestArgs: map[string]interface{}{
2175+
"owner": "owner",
2176+
"repo": "repo",
2177+
},
2178+
expectError: true,
2179+
expectedErrMsg: "failed to list releases",
2180+
},
2181+
}
2182+
2183+
for _, tc := range tests {
2184+
t.Run(tc.name, func(t *testing.T) {
2185+
client := github.NewClient(tc.mockedClient)
2186+
_, handler := ListReleases(stubGetClientFn(client), translations.NullTranslationHelper)
2187+
request := createMCPRequest(tc.requestArgs)
2188+
result, err := handler(context.Background(), request)
2189+
2190+
if tc.expectError {
2191+
require.Error(t, err)
2192+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
2193+
return
2194+
}
2195+
2196+
require.NoError(t, err)
2197+
textContent := getTextResult(t, result)
2198+
var returnedReleases []*github.RepositoryRelease
2199+
err = json.Unmarshal([]byte(textContent.Text), &returnedReleases)
2200+
require.NoError(t, err)
2201+
assert.Len(t, returnedReleases, len(tc.expectedResult))
2202+
for i, rel := range returnedReleases {
2203+
assert.Equal(t, *tc.expectedResult[i].TagName, *rel.TagName)
2204+
}
2205+
})
2206+
}
2207+
}
2208+
func Test_GetLatestRelease(t *testing.T) {
2209+
mockClient := github.NewClient(nil)
2210+
tool, _ := GetLatestRelease(stubGetClientFn(mockClient), translations.NullTranslationHelper)
2211+
2212+
assert.Equal(t, "get_latest_release", tool.Name)
2213+
assert.NotEmpty(t, tool.Description)
2214+
assert.Contains(t, tool.InputSchema.Properties, "owner")
2215+
assert.Contains(t, tool.InputSchema.Properties, "repo")
2216+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
2217+
2218+
mockRelease := &github.RepositoryRelease{
2219+
ID: github.Ptr(int64(1)),
2220+
TagName: github.Ptr("v1.0.0"),
2221+
Name: github.Ptr("First Release"),
2222+
}
2223+
2224+
tests := []struct {
2225+
name string
2226+
mockedClient *http.Client
2227+
requestArgs map[string]interface{}
2228+
expectError bool
2229+
expectedResult *github.RepositoryRelease
2230+
expectedErrMsg string
2231+
}{
2232+
{
2233+
name: "successful latest release fetch",
2234+
mockedClient: mock.NewMockedHTTPClient(
2235+
mock.WithRequestMatch(
2236+
mock.GetReposReleasesLatestByOwnerByRepo,
2237+
mockRelease,
2238+
),
2239+
),
2240+
requestArgs: map[string]interface{}{
2241+
"owner": "owner",
2242+
"repo": "repo",
2243+
},
2244+
expectError: false,
2245+
expectedResult: mockRelease,
2246+
},
2247+
{
2248+
name: "latest release fetch fails",
2249+
mockedClient: mock.NewMockedHTTPClient(
2250+
mock.WithRequestMatchHandler(
2251+
mock.GetReposReleasesLatestByOwnerByRepo,
2252+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2253+
w.WriteHeader(http.StatusNotFound)
2254+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
2255+
}),
2256+
),
2257+
),
2258+
requestArgs: map[string]interface{}{
2259+
"owner": "owner",
2260+
"repo": "repo",
2261+
},
2262+
expectError: true,
2263+
expectedErrMsg: "failed to get latest release",
2264+
},
2265+
}
2266+
2267+
for _, tc := range tests {
2268+
t.Run(tc.name, func(t *testing.T) {
2269+
client := github.NewClient(tc.mockedClient)
2270+
_, handler := GetLatestRelease(stubGetClientFn(client), translations.NullTranslationHelper)
2271+
request := createMCPRequest(tc.requestArgs)
2272+
result, err := handler(context.Background(), request)
2273+
2274+
if tc.expectError {
2275+
require.Error(t, err)
2276+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
2277+
return
2278+
}
2279+
2280+
require.NoError(t, err)
2281+
textContent := getTextResult(t, result)
2282+
var returnedRelease github.RepositoryRelease
2283+
err = json.Unmarshal([]byte(textContent.Text), &returnedRelease)
2284+
require.NoError(t, err)
2285+
assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName)
2286+
})
2287+
}
2288+
}
2289+
21172290
func Test_filterPaths(t *testing.T) {
21182291
tests := []struct {
21192292
name string

pkg/github/tools.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
3131
toolsets.NewServerTool(ListBranches(getClient, t)),
3232
toolsets.NewServerTool(ListTags(getClient, t)),
3333
toolsets.NewServerTool(GetTag(getClient, t)),
34+
toolsets.NewServerTool(ListReleases(getClient, t)),
35+
toolsets.NewServerTool(GetLatestRelease(getClient, t)),
3436
).
3537
AddWriteTools(
3638
toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)),

0 commit comments

Comments
 (0)