From 2a1584fb4fbc20072291de234723cdb21451bc3d Mon Sep 17 00:00:00 2001 From: root Date: Sat, 6 Jun 2026 21:59:02 +0000 Subject: [PATCH 1/3] Add MCP tools for repository security advisory lifecycle Expose create, update, and CVE request operations in the security_advisories toolset so security teams can manage advisories without leaving MCP workflows. Closes #2506 --- README.md | 40 +- .../create_repository_security_advisory.snap | 155 +++++ ..._cve_for_repository_security_advisory.snap | 29 + .../update_repository_security_advisory.snap | 163 +++++ pkg/github/helper_test.go | 11 +- pkg/github/security_advisories_write.go | 594 ++++++++++++++++++ pkg/github/security_advisories_write_test.go | 407 ++++++++++++ pkg/github/tools.go | 3 + 8 files changed, 1397 insertions(+), 5 deletions(-) create mode 100644 pkg/github/__toolsnaps__/create_repository_security_advisory.snap create mode 100644 pkg/github/__toolsnaps__/request_cve_for_repository_security_advisory.snap create mode 100644 pkg/github/__toolsnaps__/update_repository_security_advisory.snap create mode 100644 pkg/github/security_advisories_write.go create mode 100644 pkg/github/security_advisories_write_test.go diff --git a/README.md b/README.md index dff62321b8..5aed8e3196 100644 --- a/README.md +++ b/README.md @@ -1122,7 +1122,7 @@ The following sets of tools are available: 2. get_diff - Get the diff of a pull request. 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. - 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. + 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Review comments include structured code suggestions when available, including Copilot-generated "Suggest" changesets (via thread partial) and human-authored suggestion code blocks in the comment body. Use cursor-based pagination (perPage, after) to control results. 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. @@ -1357,6 +1357,21 @@ The following sets of tools are available: shield Security Advisories +- **create_repository_security_advisory** - Create repository security advisory + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` + - `credits`: Users credited for the advisory. (object[], optional) + - `cveId`: The CVE ID to assign to the advisory. (string, optional) + - `cvssVectorString`: The CVSS vector string for the advisory. (string, optional) + - `cweIds`: Common Weakness Enumeration IDs (for example, ["CWE-79"]). (string[], optional) + - `description`: A detailed description of the security advisory. (string, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + - `severity`: The severity of the advisory. (string, optional) + - `startPrivateFork`: Whether to create a temporary private fork for collaborating on a fix. (boolean, optional) + - `summary`: A short summary of the security advisory. (string, required) + - `vulnerabilities`: Affected products and version ranges. (object[], required) + - **get_global_security_advisory** - Get a global security advisory - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` @@ -1394,6 +1409,29 @@ The following sets of tools are available: - `sort`: Sort field. (string, optional) - `state`: Filter by advisory state. (string, optional) +- **request_cve_for_repository_security_advisory** - Request CVE for repository security advisory + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` + - `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + +- **update_repository_security_advisory** - Update repository security advisory + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` + - `credits`: Users credited for the advisory. (object[], optional) + - `cveId`: The CVE ID to assign to the advisory. (string, optional) + - `cvssVectorString`: The CVSS vector string for the advisory. (string, optional) + - `cweIds`: Common Weakness Enumeration IDs (for example, ["CWE-79"]). (string[], optional) + - `description`: A detailed description of the security advisory. (string, optional) + - `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + - `severity`: The severity of the advisory. (string, optional) + - `state`: The advisory state. Set to "published" to publish the advisory. (string, optional) + - `summary`: A short summary of the security advisory. (string, optional) + - `vulnerabilities`: Affected products and version ranges. (object[], optional) +
diff --git a/pkg/github/__toolsnaps__/create_repository_security_advisory.snap b/pkg/github/__toolsnaps__/create_repository_security_advisory.snap new file mode 100644 index 0000000000..8e8c981146 --- /dev/null +++ b/pkg/github/__toolsnaps__/create_repository_security_advisory.snap @@ -0,0 +1,155 @@ +{ + "annotations": { + "title": "Create repository security advisory" + }, + "description": "Create a draft repository security advisory.", + "inputSchema": { + "properties": { + "credits": { + "description": "Users credited for the advisory.", + "items": { + "properties": { + "login": { + "description": "The GitHub username of the credited user.", + "type": "string" + }, + "type": { + "description": "The credit type.", + "enum": [ + "analyst", + "finder", + "reporter", + "coordinator", + "remediation_developer", + "remediation_reviewer", + "remediation_verifier", + "tool", + "sponsor", + "other" + ], + "type": "string" + } + }, + "required": [ + "login", + "type" + ], + "type": "object" + }, + "type": "array" + }, + "cveId": { + "description": "The CVE ID to assign to the advisory.", + "type": "string" + }, + "cvssVectorString": { + "description": "The CVSS vector string for the advisory.", + "type": "string" + }, + "cweIds": { + "description": "Common Weakness Enumeration IDs (for example, [\"CWE-79\"]).", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "A detailed description of the security advisory.", + "type": "string" + }, + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + }, + "severity": { + "description": "The severity of the advisory.", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + "startPrivateFork": { + "description": "Whether to create a temporary private fork for collaborating on a fix.", + "type": "boolean" + }, + "summary": { + "description": "A short summary of the security advisory.", + "type": "string" + }, + "vulnerabilities": { + "description": "Affected products and version ranges.", + "items": { + "properties": { + "package": { + "properties": { + "ecosystem": { + "description": "The package ecosystem.", + "enum": [ + "actions", + "composer", + "erlang", + "go", + "maven", + "npm", + "nuget", + "other", + "pip", + "pub", + "rubygems", + "rust", + "swift" + ], + "type": "string" + }, + "name": { + "description": "The package name.", + "type": "string" + } + }, + "required": [ + "ecosystem" + ], + "type": "object" + }, + "patched_versions": { + "description": "The version that patches the vulnerability.", + "type": "string" + }, + "vulnerable_functions": { + "description": "Functions in the package that are affected.", + "items": { + "type": "string" + }, + "type": "array" + }, + "vulnerable_version_range": { + "description": "The range of affected versions (for example, \"\u003c 2.0.0\").", + "type": "string" + } + }, + "required": [ + "package" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "owner", + "repo", + "summary", + "description", + "vulnerabilities" + ], + "type": "object" + }, + "name": "create_repository_security_advisory" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/request_cve_for_repository_security_advisory.snap b/pkg/github/__toolsnaps__/request_cve_for_repository_security_advisory.snap new file mode 100644 index 0000000000..5d069b8259 --- /dev/null +++ b/pkg/github/__toolsnaps__/request_cve_for_repository_security_advisory.snap @@ -0,0 +1,29 @@ +{ + "annotations": { + "title": "Request CVE for repository security advisory" + }, + "description": "Request a CVE ID from GitHub for a draft repository security advisory.", + "inputSchema": { + "properties": { + "ghsaId": { + "description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + "type": "string" + }, + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "ghsaId" + ], + "type": "object" + }, + "name": "request_cve_for_repository_security_advisory" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_repository_security_advisory.snap b/pkg/github/__toolsnaps__/update_repository_security_advisory.snap new file mode 100644 index 0000000000..97057570aa --- /dev/null +++ b/pkg/github/__toolsnaps__/update_repository_security_advisory.snap @@ -0,0 +1,163 @@ +{ + "annotations": { + "title": "Update repository security advisory" + }, + "description": "Update a repository security advisory, including publishing it.", + "inputSchema": { + "properties": { + "credits": { + "description": "Users credited for the advisory.", + "items": { + "properties": { + "login": { + "description": "The GitHub username of the credited user.", + "type": "string" + }, + "type": { + "description": "The credit type.", + "enum": [ + "analyst", + "finder", + "reporter", + "coordinator", + "remediation_developer", + "remediation_reviewer", + "remediation_verifier", + "tool", + "sponsor", + "other" + ], + "type": "string" + } + }, + "required": [ + "login", + "type" + ], + "type": "object" + }, + "type": "array" + }, + "cveId": { + "description": "The CVE ID to assign to the advisory.", + "type": "string" + }, + "cvssVectorString": { + "description": "The CVSS vector string for the advisory.", + "type": "string" + }, + "cweIds": { + "description": "Common Weakness Enumeration IDs (for example, [\"CWE-79\"]).", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "A detailed description of the security advisory.", + "type": "string" + }, + "ghsaId": { + "description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + "type": "string" + }, + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + }, + "severity": { + "description": "The severity of the advisory.", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + "state": { + "description": "The advisory state. Set to \"published\" to publish the advisory.", + "enum": [ + "draft", + "published", + "closed", + "triage" + ], + "type": "string" + }, + "summary": { + "description": "A short summary of the security advisory.", + "type": "string" + }, + "vulnerabilities": { + "description": "Affected products and version ranges.", + "items": { + "properties": { + "package": { + "properties": { + "ecosystem": { + "description": "The package ecosystem.", + "enum": [ + "actions", + "composer", + "erlang", + "go", + "maven", + "npm", + "nuget", + "other", + "pip", + "pub", + "rubygems", + "rust", + "swift" + ], + "type": "string" + }, + "name": { + "description": "The package name.", + "type": "string" + } + }, + "required": [ + "ecosystem" + ], + "type": "object" + }, + "patched_versions": { + "description": "The version that patches the vulnerability.", + "type": "string" + }, + "vulnerable_functions": { + "description": "Functions in the package that are affected.", + "items": { + "type": "string" + }, + "type": "array" + }, + "vulnerable_version_range": { + "description": "The range of affected versions (for example, \"\u003c 2.0.0\").", + "type": "string" + } + }, + "required": [ + "package" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "owner", + "repo", + "ghsaId" + ], + "type": "object" + }, + "name": "update_repository_security_advisory" +} \ No newline at end of file diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index fdac78ce3f..06103196ab 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -113,10 +113,13 @@ const ( GetReposDependabotAlertsByOwnerByRepoByAlertNumber = "GET /repos/{owner}/{repo}/dependabot/alerts/{alert_number}" // Security advisories endpoints - GetAdvisories = "GET /advisories" - GetAdvisoriesByGhsaID = "GET /advisories/{ghsa_id}" - GetReposSecurityAdvisoriesByOwnerByRepo = "GET /repos/{owner}/{repo}/security-advisories" - GetOrgsSecurityAdvisoriesByOrg = "GET /orgs/{org}/security-advisories" + GetAdvisories = "GET /advisories" + GetAdvisoriesByGhsaID = "GET /advisories/{ghsa_id}" + GetReposSecurityAdvisoriesByOwnerByRepo = "GET /repos/{owner}/{repo}/security-advisories" + PostReposSecurityAdvisoriesByOwnerByRepo = "POST /repos/{owner}/{repo}/security-advisories" + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID = "PATCH /repos/{owner}/{repo}/security-advisories/{ghsa_id}" + PostReposSecurityAdvisoriesCveByOwnerByRepoByGhsaID = "POST /repos/{owner}/{repo}/security-advisories/{ghsa_id}/cve" + GetOrgsSecurityAdvisoriesByOrg = "GET /orgs/{org}/security-advisories" // Actions endpoints GetReposActionsWorkflowsByOwnerByRepo = "GET /repos/{owner}/{repo}/actions/workflows" diff --git a/pkg/github/security_advisories_write.go b/pkg/github/security_advisories_write.go new file mode 100644 index 0000000000..62ee6b7242 --- /dev/null +++ b/pkg/github/security_advisories_write.go @@ -0,0 +1,594 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var securityAdvisoryPackageSchema = &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "ecosystem": { + Type: "string", + Description: "The package ecosystem.", + Enum: []any{"actions", "composer", "erlang", "go", "maven", "npm", "nuget", "other", "pip", "pub", "rubygems", "rust", "swift"}, + }, + "name": { + Type: "string", + Description: "The package name.", + }, + }, + Required: []string{"ecosystem"}, +} + +var securityAdvisoryVulnerabilitySchema = &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "package": securityAdvisoryPackageSchema, + "vulnerable_version_range": { + Type: "string", + Description: "The range of affected versions (for example, \"< 2.0.0\").", + }, + "patched_versions": { + Type: "string", + Description: "The version that patches the vulnerability.", + }, + "vulnerable_functions": { + Type: "array", + Description: "Functions in the package that are affected.", + Items: &jsonschema.Schema{Type: "string"}, + }, + }, + Required: []string{"package"}, +} + +var securityAdvisoryCreditSchema = &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "login": { + Type: "string", + Description: "The GitHub username of the credited user.", + }, + "type": { + Type: "string", + Description: "The credit type.", + Enum: []any{"analyst", "finder", "reporter", "coordinator", "remediation_developer", "remediation_reviewer", "remediation_verifier", "tool", "sponsor", "other"}, + }, + }, + Required: []string{"login", "type"}, +} + +type advisoryPackageRequest struct { + Ecosystem string `json:"ecosystem"` + Name *string `json:"name,omitempty"` +} + +type advisoryVulnerabilityRequest struct { + Package advisoryPackageRequest `json:"package"` + VulnerableVersionRange *string `json:"vulnerable_version_range,omitempty"` + PatchedVersions *string `json:"patched_versions,omitempty"` + VulnerableFunctions []string `json:"vulnerable_functions,omitempty"` +} + +type advisoryCreditRequest struct { + Login string `json:"login"` + Type string `json:"type"` +} + +type createRepositorySecurityAdvisoryRequest struct { + Summary string `json:"summary"` + Description string `json:"description"` + CVEID *string `json:"cve_id,omitempty"` + CWEIDs []string `json:"cwe_ids,omitempty"` + Severity *string `json:"severity,omitempty"` + CVSSVectorString *string `json:"cvss_vector_string,omitempty"` + Vulnerabilities []advisoryVulnerabilityRequest `json:"vulnerabilities"` + Credits []advisoryCreditRequest `json:"credits,omitempty"` + StartPrivateFork *bool `json:"start_private_fork,omitempty"` +} + +type updateRepositorySecurityAdvisoryRequest struct { + Summary *string `json:"summary,omitempty"` + Description *string `json:"description,omitempty"` + CVEID *string `json:"cve_id,omitempty"` + CWEIDs []string `json:"cwe_ids,omitempty"` + Severity *string `json:"severity,omitempty"` + CVSSVectorString *string `json:"cvss_vector_string,omitempty"` + Vulnerabilities []advisoryVulnerabilityRequest `json:"vulnerabilities,omitempty"` + Credits []advisoryCreditRequest `json:"credits,omitempty"` + State *string `json:"state,omitempty"` +} + +func parseAdvisoryVulnerabilities(args map[string]any, param string, required bool) ([]advisoryVulnerabilityRequest, error) { + raw, ok := args[param] + if !ok || raw == nil { + if required { + return nil, fmt.Errorf("missing required parameter: %s", param) + } + return nil, nil + } + + data, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", param, err) + } + + var vulns []advisoryVulnerabilityRequest + if err := json.Unmarshal(data, &vulns); err != nil { + return nil, fmt.Errorf("invalid %s: %w", param, err) + } + if required && len(vulns) == 0 { + return nil, fmt.Errorf("missing required parameter: %s", param) + } + + return vulns, nil +} + +func parseAdvisoryCredits(args map[string]any, param string) ([]advisoryCreditRequest, error) { + raw, ok := args[param] + if !ok || raw == nil { + return nil, nil + } + + data, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", param, err) + } + + var credits []advisoryCreditRequest + if err := json.Unmarshal(data, &credits); err != nil { + return nil, fmt.Errorf("invalid %s: %w", param, err) + } + + return credits, nil +} + +func optionalStringPtr(value string) *string { + if value == "" { + return nil + } + return &value +} + +func marshalRepositorySecurityAdvisoryResponse(advisory *github.SecurityAdvisory) (*mcp.CallToolResult, error) { + r, err := json.Marshal(advisory) + if err != nil { + return nil, fmt.Errorf("failed to marshal advisory: %w", err) + } + return utils.NewToolResultText(string(r)), nil +} + +func repositorySecurityAdvisoryRequest(ctx context.Context, client *github.Client, method, owner, repo, ghsaID string, body any) (*github.SecurityAdvisory, *github.Response, error) { + url := fmt.Sprintf("repos/%s/%s/security-advisories", owner, repo) + if ghsaID != "" { + url = fmt.Sprintf("%s/%s", url, ghsaID) + } + + req, err := client.NewRequest(ctx, method, url, body) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + + advisory := &github.SecurityAdvisory{} + resp, err := client.Do(req, advisory) + if err != nil { + return nil, resp, err + } + + return advisory, resp, nil +} + +func CreateRepositorySecurityAdvisory(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataSecurityAdvisories, + mcp.Tool{ + Name: "create_repository_security_advisory", + Description: t("TOOL_CREATE_REPOSITORY_SECURITY_ADVISORY_DESCRIPTION", "Create a draft repository security advisory."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_REPOSITORY_SECURITY_ADVISORY_USER_TITLE", "Create repository security advisory"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "summary": { + Type: "string", + Description: "A short summary of the security advisory.", + }, + "description": { + Type: "string", + Description: "A detailed description of the security advisory.", + }, + "vulnerabilities": { + Type: "array", + Description: "Affected products and version ranges.", + Items: securityAdvisoryVulnerabilitySchema, + }, + "cveId": { + Type: "string", + Description: "The CVE ID to assign to the advisory.", + }, + "cweIds": { + Type: "array", + Description: "Common Weakness Enumeration IDs (for example, [\"CWE-79\"]).", + Items: &jsonschema.Schema{Type: "string"}, + }, + "severity": { + Type: "string", + Description: "The severity of the advisory.", + Enum: []any{"low", "medium", "high", "critical"}, + }, + "cvssVectorString": { + Type: "string", + Description: "The CVSS vector string for the advisory.", + }, + "credits": { + Type: "array", + Description: "Users credited for the advisory.", + Items: securityAdvisoryCreditSchema, + }, + "startPrivateFork": { + Type: "boolean", + Description: "Whether to create a temporary private fork for collaborating on a fix.", + }, + }, + Required: []string{"owner", "repo", "summary", "description", "vulnerabilities"}, + }, + }, + []scopes.Scope{scopes.SecurityEvents}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + summary, err := RequiredParam[string](args, "summary") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + description, err := RequiredParam[string](args, "description") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + vulnerabilities, err := parseAdvisoryVulnerabilities(args, "vulnerabilities", true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + cveID, err := OptionalParam[string](args, "cveId") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + cweIDs, err := OptionalStringArrayParam(args, "cweIds") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + severity, err := OptionalParam[string](args, "severity") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + cvssVectorString, err := OptionalParam[string](args, "cvssVectorString") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + credits, err := parseAdvisoryCredits(args, "credits") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + startPrivateFork, err := OptionalParam[bool](args, "startPrivateFork") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + requestBody := createRepositorySecurityAdvisoryRequest{ + Summary: summary, + Description: description, + CVEID: optionalStringPtr(cveID), + CWEIDs: cweIDs, + Severity: optionalStringPtr(severity), + CVSSVectorString: optionalStringPtr(cvssVectorString), + Vulnerabilities: vulnerabilities, + Credits: credits, + } + if _, ok := args["startPrivateFork"]; ok { + requestBody.StartPrivateFork = &startPrivateFork + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + advisory, resp, err := repositorySecurityAdvisoryRequest(ctx, client, http.MethodPost, owner, repo, "", requestBody) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create repository security advisory", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + 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 create repository security advisory", resp, body), nil, nil + } + + result, err := marshalRepositorySecurityAdvisoryResponse(advisory) + if err != nil { + return nil, nil, err + } + return result, nil, nil + }, + ) +} + +func UpdateRepositorySecurityAdvisory(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataSecurityAdvisories, + mcp.Tool{ + Name: "update_repository_security_advisory", + Description: t("TOOL_UPDATE_REPOSITORY_SECURITY_ADVISORY_DESCRIPTION", "Update a repository security advisory, including publishing it."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_REPOSITORY_SECURITY_ADVISORY_USER_TITLE", "Update repository security advisory"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "ghsaId": { + Type: "string", + Description: "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + }, + "summary": { + Type: "string", + Description: "A short summary of the security advisory.", + }, + "description": { + Type: "string", + Description: "A detailed description of the security advisory.", + }, + "vulnerabilities": { + Type: "array", + Description: "Affected products and version ranges.", + Items: securityAdvisoryVulnerabilitySchema, + }, + "cveId": { + Type: "string", + Description: "The CVE ID to assign to the advisory.", + }, + "cweIds": { + Type: "array", + Description: "Common Weakness Enumeration IDs (for example, [\"CWE-79\"]).", + Items: &jsonschema.Schema{Type: "string"}, + }, + "severity": { + Type: "string", + Description: "The severity of the advisory.", + Enum: []any{"low", "medium", "high", "critical"}, + }, + "cvssVectorString": { + Type: "string", + Description: "The CVSS vector string for the advisory.", + }, + "credits": { + Type: "array", + Description: "Users credited for the advisory.", + Items: securityAdvisoryCreditSchema, + }, + "state": { + Type: "string", + Description: "The advisory state. Set to \"published\" to publish the advisory.", + Enum: []any{"draft", "published", "closed", "triage"}, + }, + }, + Required: []string{"owner", "repo", "ghsaId"}, + }, + }, + []scopes.Scope{scopes.SecurityEvents}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ghsaID, err := RequiredParam[string](args, "ghsaId") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + summary, err := OptionalParam[string](args, "summary") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + description, err := OptionalParam[string](args, "description") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + vulnerabilities, err := parseAdvisoryVulnerabilities(args, "vulnerabilities", false) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + cveID, err := OptionalParam[string](args, "cveId") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + cweIDs, err := OptionalStringArrayParam(args, "cweIds") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + severity, err := OptionalParam[string](args, "severity") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + cvssVectorString, err := OptionalParam[string](args, "cvssVectorString") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + credits, err := parseAdvisoryCredits(args, "credits") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + state, err := OptionalParam[string](args, "state") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + requestBody := updateRepositorySecurityAdvisoryRequest{} + if summary != "" { + requestBody.Summary = &summary + } + if description != "" { + requestBody.Description = &description + } + if len(vulnerabilities) > 0 { + requestBody.Vulnerabilities = vulnerabilities + } + if cveID != "" { + requestBody.CVEID = &cveID + } + if len(cweIDs) > 0 { + requestBody.CWEIDs = cweIDs + } + if severity != "" { + requestBody.Severity = &severity + } + if cvssVectorString != "" { + requestBody.CVSSVectorString = &cvssVectorString + } + if len(credits) > 0 { + requestBody.Credits = credits + } + if state != "" { + requestBody.State = &state + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + advisory, resp, err := repositorySecurityAdvisoryRequest(ctx, client, http.MethodPatch, owner, repo, ghsaID, requestBody) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update repository security advisory", resp, err), nil, nil + } + 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 update repository security advisory", resp, body), nil, nil + } + + result, err := marshalRepositorySecurityAdvisoryResponse(advisory) + if err != nil { + return nil, nil, err + } + return result, nil, nil + }, + ) +} + +func RequestCVEForRepositorySecurityAdvisory(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataSecurityAdvisories, + mcp.Tool{ + Name: "request_cve_for_repository_security_advisory", + Description: t("TOOL_REQUEST_CVE_FOR_REPOSITORY_SECURITY_ADVISORY_DESCRIPTION", "Request a CVE ID from GitHub for a draft repository security advisory."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_REQUEST_CVE_FOR_REPOSITORY_SECURITY_ADVISORY_USER_TITLE", "Request CVE for repository security advisory"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "ghsaId": { + Type: "string", + Description: "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + }, + }, + Required: []string{"owner", "repo", "ghsaId"}, + }, + }, + []scopes.Scope{scopes.SecurityEvents}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ghsaID, err := RequiredParam[string](args, "ghsaId") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.SecurityAdvisories.RequestCVE(ctx, owner, repo, ghsaID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { + return utils.NewToolResultText("CVE request accepted and is being processed"), nil, nil + } + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to request CVE for repository security advisory", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + 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 request CVE for repository security advisory", resp, body), nil, nil + } + + return utils.NewToolResultText("CVE request submitted successfully"), nil, nil + }, + ) +} diff --git a/pkg/github/security_advisories_write_test.go b/pkg/github/security_advisories_write_test.go new file mode 100644 index 0000000000..d09017509a --- /dev/null +++ b/pkg/github/security_advisories_write_test.go @@ -0,0 +1,407 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleAdvisoryVulnerabilities() []any { + return []any{ + map[string]any{ + "package": map[string]any{ + "ecosystem": "npm", + "name": "example-package", + }, + "vulnerable_version_range": "< 2.0.0", + "patched_versions": "2.0.0", + }, + } +} + +func mockRepositorySecurityAdvisory() *github.SecurityAdvisory { + return &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), + Summary: github.Ptr("Stored XSS in Core"), + Description: github.Ptr("A stored XSS vulnerability in Core."), + Severity: github.Ptr("high"), + State: github.Ptr("draft"), + } +} + +func Test_CreateRepositorySecurityAdvisory(t *testing.T) { + toolDef := CreateRepositorySecurityAdvisory(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "create_repository_security_advisory", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be of type *jsonschema.Schema") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "summary", "description", "vulnerabilities"}) + + mockAdvisory := mockRepositorySecurityAdvisory() + expectedRequestBody := map[string]any{ + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "severity": "high", + "vulnerabilities": []any{ + map[string]any{ + "package": map[string]any{ + "ecosystem": "npm", + "name": "example-package", + }, + "vulnerable_version_range": "< 2.0.0", + "patched_versions": "2.0.0", + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedAdvisory *github.SecurityAdvisory + expectedErrMsg string + }{ + { + name: "successful advisory creation", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesByOwnerByRepo: expectRequestBody(t, expectedRequestBody).andThen( + mockResponse(t, http.StatusCreated, mockAdvisory), + ), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "severity": "high", + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "missing required summary", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "description": "A stored XSS vulnerability in Core.", + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, + expectError: true, + expectedErrMsg: "missing required parameter: summary", + }, + { + name: "API error handling", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) + }, + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "summary": "Stored XSS in Core", + "description": "A stored XSS vulnerability in Core.", + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, + expectError: true, + expectedErrMsg: "failed to create repository security advisory", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectedErrMsg != "" { + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, tc.expectedErrMsg) + return + } + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + + var returnedAdvisory github.SecurityAdvisory + err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisory) + require.NoError(t, err) + assert.Equal(t, *tc.expectedAdvisory.GHSAID, *returnedAdvisory.GHSAID) + assert.Equal(t, *tc.expectedAdvisory.Summary, *returnedAdvisory.Summary) + assert.Equal(t, *tc.expectedAdvisory.Description, *returnedAdvisory.Description) + assert.Equal(t, *tc.expectedAdvisory.Severity, *returnedAdvisory.Severity) + }) + } +} + +func Test_UpdateRepositorySecurityAdvisory(t *testing.T) { + toolDef := UpdateRepositorySecurityAdvisory(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "update_repository_security_advisory", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be of type *jsonschema.Schema") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "ghsaId"}) + + mockAdvisory := mockRepositorySecurityAdvisory() + mockAdvisory.State = github.Ptr("published") + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedAdvisory *github.SecurityAdvisory + expectedErrMsg string + }{ + { + name: "successful advisory update", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: expect(t, expectations{ + path: "/repos/octo/hello-world/security-advisories/GHSA-xxxx-xxxx-xxxx", + requestBody: map[string]any{"state": "published", "severity": "high"}, + }).andThen(mockResponse(t, http.StatusOK, mockAdvisory)), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + "state": "published", + "severity": "high", + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "missing required ghsaId", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "state": "published", + }, + expectError: true, + expectedErrMsg: "missing required parameter: ghsaId", + }, + { + name: "API error handling", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }, + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + "state": "published", + }, + expectError: true, + expectedErrMsg: "failed to update repository security advisory", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectedErrMsg != "" { + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, tc.expectedErrMsg) + return + } + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + + var returnedAdvisory github.SecurityAdvisory + err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisory) + require.NoError(t, err) + assert.Equal(t, *tc.expectedAdvisory.GHSAID, *returnedAdvisory.GHSAID) + assert.Equal(t, *tc.expectedAdvisory.State, *returnedAdvisory.State) + }) + } +} + +func Test_RequestCVEForRepositorySecurityAdvisory(t *testing.T) { + toolDef := RequestCVEForRepositorySecurityAdvisory(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "request_cve_for_repository_security_advisory", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be of type *jsonschema.Schema") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "ghsaId"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedText string + expectedErrMsg string + }{ + { + name: "successful CVE request", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesCveByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, nil), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + }, + expectError: false, + expectedText: "CVE request submitted successfully", + }, + { + name: "successful CVE request with accepted status", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesCveByOwnerByRepoByGhsaID: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + }, + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + }, + expectError: false, + expectedText: "CVE request submitted successfully", + }, + { + name: "missing required ghsaId", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesCveByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, nil), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + }, + expectError: true, + expectedErrMsg: "missing required parameter: ghsaId", + }, + { + name: "API error handling", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposSecurityAdvisoriesCveByOwnerByRepoByGhsaID: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) + }, + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + }, + expectError: true, + expectedErrMsg: "failed to request CVE for repository security advisory", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectedErrMsg != "" { + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, tc.expectedErrMsg) + return + } + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + assert.Equal(t, tc.expectedText, textContent.Text) + }) + } +} + +func Test_ParseAdvisoryVulnerabilities(t *testing.T) { + t.Run("required missing parameter", func(t *testing.T) { + _, err := parseAdvisoryVulnerabilities(map[string]any{}, "vulnerabilities", true) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing required parameter: vulnerabilities") + }) + + t.Run("invalid parameter type", func(t *testing.T) { + _, err := parseAdvisoryVulnerabilities(map[string]any{ + "vulnerabilities": "not-an-array", + }, "vulnerabilities", true) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid vulnerabilities") + }) + + t.Run("valid vulnerabilities", func(t *testing.T) { + vulns, err := parseAdvisoryVulnerabilities(map[string]any{ + "vulnerabilities": sampleAdvisoryVulnerabilities(), + }, "vulnerabilities", true) + require.NoError(t, err) + require.Len(t, vulns, 1) + assert.Equal(t, "npm", vulns[0].Package.Ecosystem) + require.NotNil(t, vulns[0].Package.Name) + assert.Equal(t, "example-package", *vulns[0].Package.Name) + }) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d1d585b3fa..a17f3bf084 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -272,6 +272,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetGlobalSecurityAdvisory(t), ListRepositorySecurityAdvisories(t), ListOrgRepositorySecurityAdvisories(t), + CreateRepositorySecurityAdvisory(t), + UpdateRepositorySecurityAdvisory(t), + RequestCVEForRepositorySecurityAdvisory(t), // Gist tools ListGists(t), From 71725a7f3fcb7280a62fa664419bed57f315ea6d Mon Sep 17 00:00:00 2001 From: root Date: Sun, 7 Jun 2026 00:00:24 +0000 Subject: [PATCH 2/3] fix: require at least one field for security advisory updates Reject update_repository_security_advisory calls that only provide owner, repo, and ghsaId to avoid sending empty PATCH requests. --- pkg/github/security_advisories_write.go | 6 ++++++ pkg/github/security_advisories_write_test.go | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/pkg/github/security_advisories_write.go b/pkg/github/security_advisories_write.go index 62ee6b7242..a65a40e6ff 100644 --- a/pkg/github/security_advisories_write.go +++ b/pkg/github/security_advisories_write.go @@ -494,6 +494,12 @@ func UpdateRepositorySecurityAdvisory(t translations.TranslationHelperFunc) inve requestBody.State = &state } + if requestBody.Summary == nil && requestBody.Description == nil && len(requestBody.Vulnerabilities) == 0 && + requestBody.CVEID == nil && len(requestBody.CWEIDs) == 0 && requestBody.Severity == nil && + requestBody.CVSSVectorString == nil && len(requestBody.Credits) == 0 && requestBody.State == nil { + return utils.NewToolResultError("at least one of summary, description, vulnerabilities, cveId, cweIds, severity, cvssVectorString, credits, or state must be provided for update"), nil, nil + } + client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) diff --git a/pkg/github/security_advisories_write_test.go b/pkg/github/security_advisories_write_test.go index d09017509a..9c51fea1bb 100644 --- a/pkg/github/security_advisories_write_test.go +++ b/pkg/github/security_advisories_write_test.go @@ -218,6 +218,19 @@ func Test_UpdateRepositorySecurityAdvisory(t *testing.T) { expectError: true, expectedErrMsg: "missing required parameter: ghsaId", }, + { + name: "no update fields provided", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposSecurityAdvisoriesByOwnerByRepoByGhsaID: mockResponse(t, http.StatusOK, mockAdvisory), + }), + requestArgs: map[string]any{ + "owner": "octo", + "repo": "hello-world", + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + }, + expectError: true, + expectedErrMsg: "at least one of summary, description, vulnerabilities, cveId, cweIds, severity, cvssVectorString, credits, or state must be provided for update", + }, { name: "API error handling", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ From c332b581b3df3920b00df4239ab7b42bed5eccae Mon Sep 17 00:00:00 2001 From: root Date: Sun, 7 Jun 2026 00:20:44 +0000 Subject: [PATCH 3/3] docs: remove unrelated get_review_comments README change Regenerate docs so the security advisory PR only updates the security_advisories toolset section. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5aed8e3196..2d54358558 100644 --- a/README.md +++ b/README.md @@ -1122,7 +1122,7 @@ The following sets of tools are available: 2. get_diff - Get the diff of a pull request. 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. - 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Review comments include structured code suggestions when available, including Copilot-generated "Suggest" changesets (via thread partial) and human-authored suggestion code blocks in the comment body. Use cursor-based pagination (perPage, after) to control results. + 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.