Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1569,7 +1569,8 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat

- **get_global_security_advisory** - Get a global security advisory
- **OAuth Challenge Scopes**: `security_events`
- `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, required)
- `cveId`: CVE ID of the advisory (format: CVE-YYYY-NNNNN). Provide this or ghsaId. (string, optional)
- `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). Provide this or cveId. (string, optional)

- **list_global_security_advisories** - List global security advisories
- **OAuth Challenge Scopes**: `security_events`
Expand Down
11 changes: 6 additions & 5 deletions pkg/github/__toolsnaps__/get_global_security_advisory.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@
"readOnlyHint": true,
"title": "Get a global security advisory"
},
"description": "Get a global security advisory",
"description": "Get a global security advisory by GHSA ID or CVE ID",
"inputSchema": {
"properties": {
"cveId": {
"description": "CVE ID of the advisory (format: CVE-YYYY-NNNNN). Provide this or ghsaId.",
"type": "string"
},
"ghsaId": {
"description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).",
"description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). Provide this or cveId.",
"type": "string"
}
},
"required": [
"ghsaId"
],
"type": "object"
},
"name": "get_global_security_advisory",
Expand Down
92 changes: 77 additions & 15 deletions pkg/github/security_advisories.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"strings"

ghErrors "github.com/github/github-mcp-server/v2/pkg/errors"
"github.com/github/github-mcp-server/v2/pkg/ifc"
Expand Down Expand Up @@ -268,7 +269,7 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S
ToolsetMetadataSecurityAdvisories,
mcp.Tool{
Name: "get_global_security_advisory",
Description: t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_DESCRIPTION", "Get a global security advisory"),
Description: t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_DESCRIPTION", "Get a global security advisory by GHSA ID or CVE ID"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_USER_TITLE", "Get a global security advisory"),
ReadOnlyHint: true,
Expand All @@ -278,10 +279,13 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S
Properties: map[string]*jsonschema.Schema{
"ghsaId": {
Type: "string",
Description: "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).",
Description: "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). Provide this or cveId.",
},
"cveId": {
Type: "string",
Description: "CVE ID of the advisory (format: CVE-YYYY-NNNNN). Provide this or ghsaId.",
},
},
Required: []string{"ghsaId"},
},
},
scopes.RequireAll(scopes.SecurityEvents),
Expand All @@ -291,22 +295,19 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
}

if args.GHSAID == "" {
return utils.NewToolResultError("invalid ghsaId: missing required parameter: ghsaId"), nil, nil
switch {
case args.GHSAID == "" && args.CVEID == "":
return utils.NewToolResultError("missing required parameter: provide either ghsaId or cveId"), nil, nil
case args.GHSAID != "" && args.CVEID != "":
return utils.NewToolResultError("provide only one of ghsaId or cveId, not both"), nil, nil
}

advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, args.GHSAID)
advisory, errResult, err := findGlobalSecurityAdvisory(ctx, client, args)
if err != nil {
return nil, nil, fmt.Errorf("failed to get advisory: %w", err)
return nil, nil, err
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get advisory", resp, body), nil, nil
if errResult != nil {
return errResult, nil, nil
}

r, err := json.Marshal(advisory)
Expand All @@ -323,6 +324,67 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S
)
}

// findGlobalSecurityAdvisory resolves a GHSA ID directly and a CVE ID through the
// advisory list filter GitHub exposes for it. A CVE that maps to several advisories
// is reported instead of returning an arbitrary one, since the tool returns a single
// advisory and the caller needs the GHSA ID to pick.
func findGlobalSecurityAdvisory(
ctx context.Context,
client *github.Client,
args GetGlobalSecurityAdvisoryInput,
) (*github.GlobalSecurityAdvisory, *mcp.CallToolResult, error) {
if args.GHSAID != "" {
advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, args.GHSAID)
if err != nil {
return nil, nil, fmt.Errorf("failed to get advisory: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return nil, ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get advisory", resp, body), nil
}
return advisory, nil, nil
}

advisories, resp, err := client.SecurityAdvisories.ListGlobalSecurityAdvisories(ctx, &github.ListGlobalSecurityAdvisoriesOptions{
CVEID: &args.CVEID,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to list global security advisories: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return nil, ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get advisory", resp, body), nil
}

switch len(advisories) {
case 0:
return nil, utils.NewToolResultError(fmt.Sprintf("no global security advisory found for %s", args.CVEID)), nil
case 1:
return advisories[0], nil, nil
default:
ghsaIDs := make([]string, 0, len(advisories))
for _, advisory := range advisories {
if advisory != nil && advisory.GHSAID != nil {
ghsaIDs = append(ghsaIDs, *advisory.GHSAID)
}
}
return nil, utils.NewToolResultError(fmt.Sprintf(
"%s matches %d global security advisories (%s); call get_global_security_advisory with the ghsaId you need",
args.CVEID, len(advisories), strings.Join(ghsaIDs, ", "),
)), nil
}
}

func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool[ListOrgRepositorySecurityAdvisoriesInput, []*SecurityAdvisoryOutput](
ToolsetMetadataSecurityAdvisories,
Expand Down
67 changes: 66 additions & 1 deletion pkg/github/security_advisories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,13 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) {
schema, ok := tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "InputSchema should be of type *jsonschema.Schema")
assert.Contains(t, schema.Properties, "ghsaId")
assert.ElementsMatch(t, schema.Required, []string{"ghsaId"})
assert.Contains(t, schema.Properties, "cveId")
assert.Empty(t, schema.Required, "ghsaId and cveId are alternatives, so neither is required on its own")

// Setup mock advisory for success case
mockAdvisory := &github.GlobalSecurityAdvisory{
GHSAID: new("GHSA-xxxx-xxxx-xxxx"),
CVEID: new("CVE-2026-0001"),
Summary: new("Test advisory"),
Description: new("This is a test advisory."),
Severity: new("high"),
Expand All @@ -153,6 +155,7 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) {
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectToolError bool
expectedAdvisory *github.GlobalSecurityAdvisory
expectedErrMsg string
}{
Expand Down Expand Up @@ -195,6 +198,62 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) {
expectError: true,
expectedErrMsg: "failed to get advisory",
},
{
name: "successful advisory fetch by cveId",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetAdvisories: func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "CVE-2026-0001", r.URL.Query().Get("cve_id"), "the CVE ID must reach the API as a filter")
mockResponse(t, http.StatusOK, []*github.GlobalSecurityAdvisory{mockAdvisory})(w, r)
},
}),
requestArgs: map[string]any{
"cveId": "CVE-2026-0001",
},
expectError: false,
expectedAdvisory: mockAdvisory,
},
{
name: "cveId matching no advisory",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetAdvisories: mockResponse(t, http.StatusOK, []*github.GlobalSecurityAdvisory{}),
}),
requestArgs: map[string]any{
"cveId": "CVE-2026-9999",
},
expectToolError: true,
expectedErrMsg: "no global security advisory found for CVE-2026-9999",
},
{
name: "cveId matching several advisories",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetAdvisories: mockResponse(t, http.StatusOK, []*github.GlobalSecurityAdvisory{
mockAdvisory,
{GHSAID: new("GHSA-yyyy-yyyy-yyyy"), CVEID: new("CVE-2026-0001")},
}),
}),
requestArgs: map[string]any{
"cveId": "CVE-2026-0001",
},
expectToolError: true,
expectedErrMsg: "matches 2 global security advisories (GHSA-xxxx-xxxx-xxxx, GHSA-yyyy-yyyy-yyyy)",
},
{
name: "neither identifier provided",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{},
expectToolError: true,
expectedErrMsg: "provide either ghsaId or cveId",
},
{
name: "both identifiers provided",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"ghsaId": "GHSA-xxxx-xxxx-xxxx",
"cveId": "CVE-2026-0001",
},
expectToolError: true,
expectedErrMsg: "provide only one of ghsaId or cveId",
},
}

for _, tc := range tests {
Expand All @@ -219,6 +278,12 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) {

require.NoError(t, err)

if tc.expectToolError {
textContent := getErrorResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}

// Parse the result and get the text content if no error
textContent := getTextResult(t, result)

Expand Down
3 changes: 2 additions & 1 deletion pkg/github/security_outputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ type ListRepositorySecurityAdvisoriesInput struct {
}

type GetGlobalSecurityAdvisoryInput struct {
GHSAID string `json:"ghsaId"`
GHSAID string `json:"ghsaId,omitempty"`
CVEID string `json:"cveId,omitempty"`
}

type ListOrgRepositorySecurityAdvisoriesInput struct {
Expand Down