Skip to content

Commit b831aad

Browse files
committed
Add support for listing repo level security advisories
1 parent ea3f02b commit b831aad

File tree

4 files changed

+241
-0
lines changed

4 files changed

+241
-0
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,13 @@ The following sets of tools are available (all are on by default):
937937
- `type`: Advisory type. (string, required)
938938
- `updated`: Filter by update date or date range (ISO 8601 date or range). (string, optional)
939939

940+
- **list_repository_security_advisories** - List repository security advisories
941+
- `direction`: Sort direction. (string, optional)
942+
- `owner`: The owner of the repository. (string, required)
943+
- `repo`: The name of the repository. (string, required)
944+
- `sort`: Sort field. (string, optional)
945+
- `state`: Filter by advisory state. (string, optional)
946+
940947
</details>
941948

942949
<details>

pkg/github/security_advisories.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,95 @@ func ListGlobalSecurityAdvisories(getClient GetClientFn, t translations.Translat
183183
}
184184
}
185185

186+
func ListRepositorySecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
187+
return mcp.NewTool("list_repository_security_advisories",
188+
mcp.WithDescription(t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub repository.")),
189+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
190+
Title: t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_USER_TITLE", "List repository security advisories"),
191+
ReadOnlyHint: ToBoolPtr(true),
192+
}),
193+
mcp.WithString("owner",
194+
mcp.Required(),
195+
mcp.Description("The owner of the repository."),
196+
),
197+
mcp.WithString("repo",
198+
mcp.Required(),
199+
mcp.Description("The name of the repository."),
200+
),
201+
mcp.WithString("direction",
202+
mcp.Description("Sort direction."),
203+
mcp.Enum("asc", "desc"),
204+
),
205+
mcp.WithString("sort",
206+
mcp.Description("Sort field."),
207+
mcp.Enum("created", "updated", "published"),
208+
),
209+
mcp.WithString("state",
210+
mcp.Description("Filter by advisory state."),
211+
mcp.Enum("triage", "draft", "published", "closed"),
212+
),
213+
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
214+
owner, err := RequiredParam[string](request, "owner")
215+
if err != nil {
216+
return mcp.NewToolResultError(err.Error()), nil
217+
}
218+
repo, err := RequiredParam[string](request, "repo")
219+
if err != nil {
220+
return mcp.NewToolResultError(err.Error()), nil
221+
}
222+
223+
direction, err := OptionalParam[string](request, "direction")
224+
if err != nil {
225+
return mcp.NewToolResultError(err.Error()), nil
226+
}
227+
sortField, err := OptionalParam[string](request, "sort")
228+
if err != nil {
229+
return mcp.NewToolResultError(err.Error()), nil
230+
}
231+
state, err := OptionalParam[string](request, "state")
232+
if err != nil {
233+
return mcp.NewToolResultError(err.Error()), nil
234+
}
235+
236+
client, err := getClient(ctx)
237+
if err != nil {
238+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
239+
}
240+
241+
opts := &github.ListRepositorySecurityAdvisoriesOptions{}
242+
if direction != "" {
243+
opts.Direction = direction
244+
}
245+
if sortField != "" {
246+
opts.Sort = sortField
247+
}
248+
if state != "" {
249+
opts.State = state
250+
}
251+
252+
advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(ctx, owner, repo, opts)
253+
if err != nil {
254+
return nil, fmt.Errorf("failed to list repository security advisories: %w", err)
255+
}
256+
defer func() { _ = resp.Body.Close() }()
257+
258+
if resp.StatusCode != http.StatusOK {
259+
body, err := io.ReadAll(resp.Body)
260+
if err != nil {
261+
return nil, fmt.Errorf("failed to read response body: %w", err)
262+
}
263+
return mcp.NewToolResultError(fmt.Sprintf("failed to list repository advisories: %s", string(body))), nil
264+
}
265+
266+
r, err := json.Marshal(advisories)
267+
if err != nil {
268+
return nil, fmt.Errorf("failed to marshal advisories: %w", err)
269+
}
270+
271+
return mcp.NewToolResultText(string(r)), nil
272+
}
273+
}
274+
186275
func GetGlobalSecurityAdvisory(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
187276
return mcp.NewTool("get_global_security_advisory",
188277
mcp.WithDescription(t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_DESCRIPTION", "Get a global security advisory")),

pkg/github/security_advisories_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,147 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) {
241241
})
242242
}
243243
}
244+
245+
func Test_ListRepositorySecurityAdvisories(t *testing.T) {
246+
// Verify tool definition once
247+
mockClient := github.NewClient(nil)
248+
tool, _ := ListRepositorySecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper)
249+
250+
assert.Equal(t, "list_repository_security_advisories", tool.Name)
251+
assert.NotEmpty(t, tool.Description)
252+
assert.Contains(t, tool.InputSchema.Properties, "owner")
253+
assert.Contains(t, tool.InputSchema.Properties, "repo")
254+
assert.Contains(t, tool.InputSchema.Properties, "direction")
255+
assert.Contains(t, tool.InputSchema.Properties, "sort")
256+
assert.Contains(t, tool.InputSchema.Properties, "state")
257+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
258+
259+
// Local endpoint pattern for repository security advisories
260+
var GetReposSecurityAdvisoriesByOwnerByRepo = mock.EndpointPattern{
261+
Pattern: "/repos/{owner}/{repo}/security-advisories",
262+
Method: "GET",
263+
}
264+
265+
// Setup mock advisories for success cases
266+
adv1 := &github.SecurityAdvisory{
267+
GHSAID: github.Ptr("GHSA-1111-1111-1111"),
268+
Summary: github.Ptr("Repo advisory one"),
269+
Description: github.Ptr("First repo advisory."),
270+
Severity: github.Ptr("high"),
271+
}
272+
adv2 := &github.SecurityAdvisory{
273+
GHSAID: github.Ptr("GHSA-2222-2222-2222"),
274+
Summary: github.Ptr("Repo advisory two"),
275+
Description: github.Ptr("Second repo advisory."),
276+
Severity: github.Ptr("medium"),
277+
}
278+
279+
tests := []struct {
280+
name string
281+
mockedClient *http.Client
282+
requestArgs map[string]interface{}
283+
expectError bool
284+
expectedAdvisories []*github.SecurityAdvisory
285+
expectedErrMsg string
286+
}{
287+
{
288+
name: "successful advisories listing (no filters)",
289+
mockedClient: mock.NewMockedHTTPClient(
290+
mock.WithRequestMatchHandler(
291+
GetReposSecurityAdvisoriesByOwnerByRepo,
292+
expect(t, expectations{
293+
path: "/repos/owner/repo/security-advisories",
294+
queryParams: map[string]string{},
295+
}).andThen(
296+
mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}),
297+
),
298+
),
299+
),
300+
requestArgs: map[string]interface{}{
301+
"owner": "owner",
302+
"repo": "repo",
303+
},
304+
expectError: false,
305+
expectedAdvisories: []*github.SecurityAdvisory{adv1, adv2},
306+
},
307+
{
308+
name: "successful advisories listing with filters",
309+
mockedClient: mock.NewMockedHTTPClient(
310+
mock.WithRequestMatchHandler(
311+
GetReposSecurityAdvisoriesByOwnerByRepo,
312+
expect(t, expectations{
313+
path: "/repos/octo/hello-world/security-advisories",
314+
queryParams: map[string]string{
315+
"direction": "desc",
316+
"sort": "updated",
317+
"state": "published",
318+
},
319+
}).andThen(
320+
mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}),
321+
),
322+
),
323+
),
324+
requestArgs: map[string]interface{}{
325+
"owner": "octo",
326+
"repo": "hello-world",
327+
"direction": "desc",
328+
"sort": "updated",
329+
"state": "published",
330+
},
331+
expectError: false,
332+
expectedAdvisories: []*github.SecurityAdvisory{adv1},
333+
},
334+
{
335+
name: "advisories listing fails",
336+
mockedClient: mock.NewMockedHTTPClient(
337+
mock.WithRequestMatchHandler(
338+
GetReposSecurityAdvisoriesByOwnerByRepo,
339+
expect(t, expectations{
340+
path: "/repos/owner/repo/security-advisories",
341+
queryParams: map[string]string{},
342+
}).andThen(
343+
mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "Internal Server Error"}),
344+
),
345+
),
346+
),
347+
requestArgs: map[string]interface{}{
348+
"owner": "owner",
349+
"repo": "repo",
350+
},
351+
expectError: true,
352+
expectedErrMsg: "failed to list repository security advisories",
353+
},
354+
}
355+
356+
for _, tc := range tests {
357+
t.Run(tc.name, func(t *testing.T) {
358+
client := github.NewClient(tc.mockedClient)
359+
_, handler := ListRepositorySecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper)
360+
361+
request := createMCPRequest(tc.requestArgs)
362+
363+
result, err := handler(context.Background(), request)
364+
365+
if tc.expectError {
366+
require.Error(t, err)
367+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
368+
return
369+
}
370+
371+
require.NoError(t, err)
372+
373+
textContent := getTextResult(t, result)
374+
375+
var returnedAdvisories []*github.SecurityAdvisory
376+
err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories)
377+
assert.NoError(t, err)
378+
assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories))
379+
for i, advisory := range returnedAdvisories {
380+
assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID)
381+
assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary)
382+
assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description)
383+
assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity)
384+
}
385+
})
386+
}
387+
}

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
163163
AddReadTools(
164164
toolsets.NewServerTool(ListGlobalSecurityAdvisories(getClient, t)),
165165
toolsets.NewServerTool(GetGlobalSecurityAdvisory(getClient, t)),
166+
toolsets.NewServerTool(ListRepositorySecurityAdvisories(getClient, t)),
166167
)
167168

168169
// Keep experiments alive so the system doesn't error out when it's always enabled

0 commit comments

Comments
 (0)