diff --git a/README.md b/README.md
index dff62321b8..2d54358558 100644
--- a/README.md
+++ b/README.md
@@ -1357,6 +1357,21 @@ The following sets of tools are available:
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..a65a40e6ff
--- /dev/null
+++ b/pkg/github/security_advisories_write.go
@@ -0,0 +1,600 @@
+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
+ }
+
+ 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)
+ }
+
+ 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..9c51fea1bb
--- /dev/null
+++ b/pkg/github/security_advisories_write_test.go
@@ -0,0 +1,420 @@
+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: "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{
+ 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),