Skip to content

Commit 165786d

Browse files
committed
Feat: Add support for GitHub Releases (list and latest) tools
1 parent 7fc0586 commit 165786d

File tree

3 files changed

+296
-0
lines changed

3 files changed

+296
-0
lines changed

pkg/github/repositories.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,3 +1100,123 @@ func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool m
11001100
return mcp.NewToolResultText(string(r)), nil
11011101
}
11021102
}
1103+
1104+
// ListReleases creates a tool to list releases in a GitHub repository.
1105+
func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
1106+
return mcp.NewTool("list_releases",
1107+
mcp.WithDescription(t("TOOL_LIST_RELEASES_DESCRIPTION", "List releases in a GitHub repository")),
1108+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
1109+
Title: t("TOOL_LIST_RELEASES_USER_TITLE", "List releases"),
1110+
ReadOnlyHint: toBoolPtr(true),
1111+
}),
1112+
mcp.WithString("owner",
1113+
mcp.Required(),
1114+
mcp.Description("Repository owner"),
1115+
),
1116+
mcp.WithString("repo",
1117+
mcp.Required(),
1118+
mcp.Description("Repository name"),
1119+
),
1120+
WithPagination(),
1121+
),
1122+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
1123+
owner, err := requiredParam[string](request, "owner")
1124+
if err != nil {
1125+
return mcp.NewToolResultError(err.Error()), nil
1126+
}
1127+
repo, err := requiredParam[string](request, "repo")
1128+
if err != nil {
1129+
return mcp.NewToolResultError(err.Error()), nil
1130+
}
1131+
pagination, err := OptionalPaginationParams(request)
1132+
if err != nil {
1133+
return mcp.NewToolResultError(err.Error()), nil
1134+
}
1135+
1136+
opts := &github.ListOptions{
1137+
Page: pagination.page,
1138+
PerPage: pagination.perPage,
1139+
}
1140+
1141+
client, err := getClient(ctx)
1142+
if err != nil {
1143+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
1144+
}
1145+
1146+
releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opts)
1147+
if err != nil {
1148+
return nil, fmt.Errorf("failed to list releases: %w", err)
1149+
}
1150+
defer func() { _ = resp.Body.Close() }()
1151+
1152+
if resp.StatusCode != http.StatusOK {
1153+
body, err := io.ReadAll(resp.Body)
1154+
if err != nil {
1155+
return nil, fmt.Errorf("failed to read response body: %w", err)
1156+
}
1157+
return mcp.NewToolResultError(fmt.Sprintf("failed to list releases: %s", string(body))), nil
1158+
}
1159+
1160+
r, err := json.Marshal(releases)
1161+
if err != nil {
1162+
return nil, fmt.Errorf("failed to marshal response: %w", err)
1163+
}
1164+
1165+
return mcp.NewToolResultText(string(r)), nil
1166+
}
1167+
}
1168+
1169+
// GetLatestRelease creates a tool to get the latest release in a GitHub repository.
1170+
func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
1171+
return mcp.NewTool("get_latest_release",
1172+
mcp.WithDescription(t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository")),
1173+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
1174+
Title: t("TOOL_GET_LATEST_RELEASE_USER_TITLE", "Get latest release"),
1175+
ReadOnlyHint: toBoolPtr(true),
1176+
}),
1177+
mcp.WithString("owner",
1178+
mcp.Required(),
1179+
mcp.Description("Repository owner"),
1180+
),
1181+
mcp.WithString("repo",
1182+
mcp.Required(),
1183+
mcp.Description("Repository name"),
1184+
),
1185+
),
1186+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
1187+
owner, err := requiredParam[string](request, "owner")
1188+
if err != nil {
1189+
return mcp.NewToolResultError(err.Error()), nil
1190+
}
1191+
repo, err := requiredParam[string](request, "repo")
1192+
if err != nil {
1193+
return mcp.NewToolResultError(err.Error()), nil
1194+
}
1195+
1196+
client, err := getClient(ctx)
1197+
if err != nil {
1198+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
1199+
}
1200+
1201+
release, resp, err := client.Repositories.GetLatestRelease(ctx, owner, repo)
1202+
if err != nil {
1203+
return nil, fmt.Errorf("failed to get latest release: %w", err)
1204+
}
1205+
defer func() { _ = resp.Body.Close() }()
1206+
1207+
if resp.StatusCode != http.StatusOK {
1208+
body, err := io.ReadAll(resp.Body)
1209+
if err != nil {
1210+
return nil, fmt.Errorf("failed to read response body: %w", err)
1211+
}
1212+
return mcp.NewToolResultError(fmt.Sprintf("failed to get latest release: %s", string(body))), nil
1213+
}
1214+
1215+
r, err := json.Marshal(release)
1216+
if err != nil {
1217+
return nil, fmt.Errorf("failed to marshal response: %w", err)
1218+
}
1219+
1220+
return mcp.NewToolResultText(string(r)), nil
1221+
}
1222+
}

pkg/github/repositories_test.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1963,3 +1963,177 @@ func Test_GetTag(t *testing.T) {
19631963
})
19641964
}
19651965
}
1966+
1967+
func Test_ListReleases(t *testing.T) {
1968+
mockClient := github.NewClient(nil)
1969+
tool, _ := ListReleases(stubGetClientFn(mockClient), translations.NullTranslationHelper)
1970+
1971+
assert.Equal(t, "list_releases", tool.Name)
1972+
assert.NotEmpty(t, tool.Description)
1973+
assert.Contains(t, tool.InputSchema.Properties, "owner")
1974+
assert.Contains(t, tool.InputSchema.Properties, "repo")
1975+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
1976+
1977+
mockReleases := []*github.RepositoryRelease{
1978+
{
1979+
ID: github.Ptr(int64(1)),
1980+
TagName: github.Ptr("v1.0.0"),
1981+
Name: github.Ptr("First Release"),
1982+
},
1983+
{
1984+
ID: github.Ptr(int64(2)),
1985+
TagName: github.Ptr("v0.9.0"),
1986+
Name: github.Ptr("Beta Release"),
1987+
},
1988+
}
1989+
1990+
tests := []struct {
1991+
name string
1992+
mockedClient *http.Client
1993+
requestArgs map[string]interface{}
1994+
expectError bool
1995+
expectedResult []*github.RepositoryRelease
1996+
expectedErrMsg string
1997+
}{
1998+
{
1999+
name: "successful releases list",
2000+
mockedClient: mock.NewMockedHTTPClient(
2001+
mock.WithRequestMatch(
2002+
mock.GetReposReleasesByOwnerByRepo,
2003+
mockReleases,
2004+
),
2005+
),
2006+
requestArgs: map[string]interface{}{
2007+
"owner": "owner",
2008+
"repo": "repo",
2009+
},
2010+
expectError: false,
2011+
expectedResult: mockReleases,
2012+
},
2013+
{
2014+
name: "releases list fails",
2015+
mockedClient: mock.NewMockedHTTPClient(
2016+
mock.WithRequestMatchHandler(
2017+
mock.GetReposReleasesByOwnerByRepo,
2018+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2019+
w.WriteHeader(http.StatusNotFound)
2020+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
2021+
}),
2022+
),
2023+
),
2024+
requestArgs: map[string]interface{}{
2025+
"owner": "owner",
2026+
"repo": "repo",
2027+
},
2028+
expectError: true,
2029+
expectedErrMsg: "failed to list releases",
2030+
},
2031+
}
2032+
2033+
for _, tc := range tests {
2034+
t.Run(tc.name, func(t *testing.T) {
2035+
client := github.NewClient(tc.mockedClient)
2036+
_, handler := ListReleases(stubGetClientFn(client), translations.NullTranslationHelper)
2037+
request := createMCPRequest(tc.requestArgs)
2038+
result, err := handler(context.Background(), request)
2039+
2040+
if tc.expectError {
2041+
require.Error(t, err)
2042+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
2043+
return
2044+
}
2045+
2046+
require.NoError(t, err)
2047+
textContent := getTextResult(t, result)
2048+
var returnedReleases []*github.RepositoryRelease
2049+
err = json.Unmarshal([]byte(textContent.Text), &returnedReleases)
2050+
require.NoError(t, err)
2051+
assert.Len(t, returnedReleases, len(tc.expectedResult))
2052+
for i, rel := range returnedReleases {
2053+
assert.Equal(t, *tc.expectedResult[i].TagName, *rel.TagName)
2054+
}
2055+
})
2056+
}
2057+
}
2058+
2059+
func Test_GetLatestRelease(t *testing.T) {
2060+
mockClient := github.NewClient(nil)
2061+
tool, _ := GetLatestRelease(stubGetClientFn(mockClient), translations.NullTranslationHelper)
2062+
2063+
assert.Equal(t, "get_latest_release", tool.Name)
2064+
assert.NotEmpty(t, tool.Description)
2065+
assert.Contains(t, tool.InputSchema.Properties, "owner")
2066+
assert.Contains(t, tool.InputSchema.Properties, "repo")
2067+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
2068+
2069+
mockRelease := &github.RepositoryRelease{
2070+
ID: github.Ptr(int64(1)),
2071+
TagName: github.Ptr("v1.0.0"),
2072+
Name: github.Ptr("First Release"),
2073+
}
2074+
2075+
tests := []struct {
2076+
name string
2077+
mockedClient *http.Client
2078+
requestArgs map[string]interface{}
2079+
expectError bool
2080+
expectedResult *github.RepositoryRelease
2081+
expectedErrMsg string
2082+
}{
2083+
{
2084+
name: "successful latest release fetch",
2085+
mockedClient: mock.NewMockedHTTPClient(
2086+
mock.WithRequestMatch(
2087+
mock.GetReposReleasesLatestByOwnerByRepo,
2088+
mockRelease,
2089+
),
2090+
),
2091+
requestArgs: map[string]interface{}{
2092+
"owner": "owner",
2093+
"repo": "repo",
2094+
},
2095+
expectError: false,
2096+
expectedResult: mockRelease,
2097+
},
2098+
{
2099+
name: "latest release fetch fails",
2100+
mockedClient: mock.NewMockedHTTPClient(
2101+
mock.WithRequestMatchHandler(
2102+
mock.GetReposReleasesLatestByOwnerByRepo,
2103+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2104+
w.WriteHeader(http.StatusNotFound)
2105+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
2106+
}),
2107+
),
2108+
),
2109+
requestArgs: map[string]interface{}{
2110+
"owner": "owner",
2111+
"repo": "repo",
2112+
},
2113+
expectError: true,
2114+
expectedErrMsg: "failed to get latest release",
2115+
},
2116+
}
2117+
2118+
for _, tc := range tests {
2119+
t.Run(tc.name, func(t *testing.T) {
2120+
client := github.NewClient(tc.mockedClient)
2121+
_, handler := GetLatestRelease(stubGetClientFn(client), translations.NullTranslationHelper)
2122+
request := createMCPRequest(tc.requestArgs)
2123+
result, err := handler(context.Background(), request)
2124+
2125+
if tc.expectError {
2126+
require.Error(t, err)
2127+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
2128+
return
2129+
}
2130+
2131+
require.NoError(t, err)
2132+
textContent := getTextResult(t, result)
2133+
var returnedRelease github.RepositoryRelease
2134+
err = json.Unmarshal([]byte(textContent.Text), &returnedRelease)
2135+
require.NoError(t, err)
2136+
assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName)
2137+
})
2138+
}
2139+
}

pkg/github/tools.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn,
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)