Skip to content

Commit 76c06d0

Browse files
authored
ROX-31478: Add tool to get CVE related deployments (#14)
Assisted-by: Claude Code
1 parent c877be3 commit 76c06d0

File tree

6 files changed

+516
-48
lines changed

6 files changed

+516
-48
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
# Claude Code
77
.claude/
8+
/.mcp.json
89

910
# Test output
1011
/*.out

cmd/stackrox-mcp/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import (
2222
func getToolsets(cfg *config.Config, c *client.Client) []toolsets.Toolset {
2323
return []toolsets.Toolset{
2424
toolsetConfig.NewToolset(cfg, c),
25-
toolsetVulnerability.NewToolset(cfg),
25+
toolsetVulnerability.NewToolset(cfg, c),
2626
}
2727
}
2828

internal/toolsets/vulnerability/tools.go

Lines changed: 175 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,63 +2,209 @@ package vulnerability
22

33
import (
44
"context"
5-
"errors"
5+
"fmt"
6+
"strings"
67

8+
"github.com/google/jsonschema-go/jsonschema"
79
"github.com/modelcontextprotocol/go-sdk/mcp"
10+
"github.com/pkg/errors"
11+
v1 "github.com/stackrox/rox/generated/api/v1"
12+
"github.com/stackrox/stackrox-mcp/internal/client"
13+
"github.com/stackrox/stackrox-mcp/internal/client/auth"
14+
"github.com/stackrox/stackrox-mcp/internal/logging"
815
"github.com/stackrox/stackrox-mcp/internal/toolsets"
916
)
1017

11-
// listClusterCVEsInput defines the input parameters for list_cluster_cves tool.
12-
type listClusterCVEsInput struct {
13-
ClusterID string `json:"clusterId,omitempty"`
18+
const (
19+
defaultLimit = 50
20+
maximumLimit = 200.0
21+
)
22+
23+
type filterPlatformType string
24+
25+
const (
26+
filterPlatformNoFilter filterPlatformType = "NO_FILTER"
27+
filterPlatformUserWorkload filterPlatformType = "USER_WORKLOAD"
28+
filterPlatformPlatform filterPlatformType = "PLATFORM"
29+
)
30+
31+
// getDeploymentsForCVEInput defines the input parameters for get_deployments_for_cve tool.
32+
type getDeploymentsForCVEInput struct {
33+
CVEName string `json:"cveName"`
34+
FilterClusterID string `json:"filterClusterId,omitempty"`
35+
FilterNamespace string `json:"filterNamespace,omitempty"`
36+
FilterPlatform filterPlatformType `json:"filterPlatform,omitempty"`
37+
Offset int32 `json:"offset,omitempty"`
38+
Limit int32 `json:"limit,omitempty"`
39+
}
40+
41+
func (input *getDeploymentsForCVEInput) validate() error {
42+
if input.CVEName == "" {
43+
return errors.New("CVE name is required")
44+
}
45+
46+
return nil
47+
}
48+
49+
// DeploymentResult contains deployment information.
50+
type DeploymentResult struct {
51+
Name string `json:"name"`
52+
Namespace string `json:"namespace"`
53+
ClusterID string `json:"clusterId"`
54+
ClusterName string `json:"clusterName"`
1455
}
1556

16-
// listClusterCVEsOutput defines the output structure for list_cluster_cves tool.
17-
type listClusterCVEsOutput struct {
18-
CVEs []string `json:"cves"`
57+
// getDeploymentsForCVEOutput defines the output structure for get_deployments_for_cve tool.
58+
type getDeploymentsForCVEOutput struct {
59+
Deployments []DeploymentResult `json:"deployments"`
1960
}
2061

21-
// listClusterCVEsTool implements the list_cluster_cves tool.
22-
type listClusterCVEsTool struct {
23-
name string
62+
// getDeploymentsForCVETool implements the get_deployments_for_cve tool.
63+
type getDeploymentsForCVETool struct {
64+
name string
65+
client *client.Client
2466
}
2567

26-
// NewListClusterCVEsTool creates a new list_cluster_cves tool.
27-
func NewListClusterCVEsTool() toolsets.Tool {
28-
return &listClusterCVEsTool{
29-
name: "list_cluster_cves",
68+
// NewGetDeploymentsForCVETool creates a new get_deployments_for_cve tool.
69+
func NewGetDeploymentsForCVETool(c *client.Client) toolsets.Tool {
70+
return &getDeploymentsForCVETool{
71+
name: "get_deployments_for_cve",
72+
client: c,
3073
}
3174
}
3275

3376
// IsReadOnly returns true as this tool only reads data.
34-
func (t *listClusterCVEsTool) IsReadOnly() bool {
77+
func (t *getDeploymentsForCVETool) IsReadOnly() bool {
3578
return true
3679
}
3780

3881
// GetName returns the tool name.
39-
func (t *listClusterCVEsTool) GetName() string {
82+
func (t *getDeploymentsForCVETool) GetName() string {
4083
return t.name
4184
}
4285

4386
// GetTool returns the MCP Tool definition.
44-
func (t *listClusterCVEsTool) GetTool() *mcp.Tool {
87+
func (t *getDeploymentsForCVETool) GetTool() *mcp.Tool {
4588
return &mcp.Tool{
46-
Name: t.name,
47-
//nolint:lll
48-
Description: "List CVEs affecting a specific cluster or all clusters in StackRox Central with CVE names, scores, affected images, and deployments",
89+
Name: t.name,
90+
Description: "Get list of deployments affected by a specific CVE",
91+
InputSchema: getDeploymentsForCVEInputSchema(),
4992
}
5093
}
5194

52-
// RegisterWith registers the list_cluster_cves tool handler with the MCP server.
53-
func (t *listClusterCVEsTool) RegisterWith(server *mcp.Server) {
95+
// getDeploymentsForCVEInputSchema returns the JSON schema for input validation.
96+
func getDeploymentsForCVEInputSchema() *jsonschema.Schema {
97+
schema, err := jsonschema.For[getDeploymentsForCVEInput](nil)
98+
if err != nil {
99+
logging.Fatal("Could not get jsonschema for get_deployments_for_cve input", err)
100+
101+
return nil
102+
}
103+
104+
// CVE name is required.
105+
schema.Required = []string{"cveName"}
106+
107+
schema.Properties["cveName"].Description = "CVE name to filter deployments (e.g., CVE-2021-44228)"
108+
schema.Properties["filterClusterId"].Description = "Optional cluster ID to filter deployments"
109+
schema.Properties["filterNamespace"].Description = "Optional namespace to filter deployments"
110+
111+
schema.Properties["filterPlatform"].Description =
112+
fmt.Sprintf("Optional platform filter: %s=no filter, %s=user workload deployments, %s=platform deployments",
113+
filterPlatformNoFilter, filterPlatformUserWorkload, filterPlatformPlatform)
114+
schema.Properties["filterPlatform"].Default = toolsets.MustJSONMarshal(filterPlatformNoFilter)
115+
schema.Properties["filterPlatform"].Enum = []any{
116+
filterPlatformNoFilter,
117+
filterPlatformUserWorkload,
118+
filterPlatformPlatform,
119+
}
120+
121+
schema.Properties["offset"].Description = "Pagination offset (default: 0)"
122+
schema.Properties["offset"].Default = toolsets.MustJSONMarshal(0)
123+
schema.Properties["limit"].Minimum = jsonschema.Ptr(0.0)
124+
125+
schema.Properties["limit"].Description = "Pagination limit: minimum: 1, maximum: 200 (default: 50)"
126+
schema.Properties["limit"].Default = toolsets.MustJSONMarshal(defaultLimit)
127+
schema.Properties["limit"].Minimum = jsonschema.Ptr(1.0)
128+
schema.Properties["limit"].Maximum = jsonschema.Ptr(maximumLimit)
129+
130+
return schema
131+
}
132+
133+
// RegisterWith registers the get_deployments_for_cve tool handler with the MCP server.
134+
func (t *getDeploymentsForCVETool) RegisterWith(server *mcp.Server) {
54135
mcp.AddTool(server, t.GetTool(), t.handle)
55136
}
56137

57-
// handle is the placeholder handler for list_cluster_cves tool.
58-
func (t *listClusterCVEsTool) handle(
59-
_ context.Context,
60-
_ *mcp.CallToolRequest,
61-
_ listClusterCVEsInput,
62-
) (*mcp.CallToolResult, *listClusterCVEsOutput, error) {
63-
return nil, nil, errors.New("list_cluster_cves tool is not yet implemented")
138+
// buildQuery builds query used to search deployments in StackRox Central.
139+
// We will quote values to have strict match. Without quote: CVE-2025-10, would match CVE-2025-101.
140+
func buildQuery(input getDeploymentsForCVEInput) string {
141+
queryParts := []string{fmt.Sprintf("CVE:%q", input.CVEName)}
142+
143+
if input.FilterClusterID != "" {
144+
queryParts = append(queryParts, fmt.Sprintf("Cluster ID:%q", input.FilterClusterID))
145+
}
146+
147+
if input.FilterNamespace != "" {
148+
queryParts = append(queryParts, fmt.Sprintf("Namespace:%q", input.FilterNamespace))
149+
}
150+
151+
// Add platform filter if provided.
152+
switch input.FilterPlatform {
153+
case filterPlatformUserWorkload:
154+
queryParts = append(queryParts, "Platform Component:0")
155+
case filterPlatformPlatform:
156+
queryParts = append(queryParts, "Platform Component:1")
157+
case filterPlatformNoFilter:
158+
}
159+
160+
return strings.Join(queryParts, "+")
161+
}
162+
163+
// handle is the handler for get_deployments_for_cve tool.
164+
func (t *getDeploymentsForCVETool) handle(
165+
ctx context.Context,
166+
req *mcp.CallToolRequest,
167+
input getDeploymentsForCVEInput,
168+
) (*mcp.CallToolResult, *getDeploymentsForCVEOutput, error) {
169+
err := input.validate()
170+
if err != nil {
171+
return nil, nil, err
172+
}
173+
174+
conn, err := t.client.ReadyConn(ctx)
175+
if err != nil {
176+
return nil, nil, errors.Wrap(err, "unable to connect to server")
177+
}
178+
179+
callCtx := auth.WithMCPRequestContext(ctx, req)
180+
deploymentClient := v1.NewDeploymentServiceClient(conn)
181+
182+
listReq := &v1.RawQuery{
183+
Query: buildQuery(input),
184+
Pagination: &v1.Pagination{
185+
Offset: input.Offset,
186+
Limit: input.Limit,
187+
},
188+
}
189+
190+
resp, err := deploymentClient.ListDeployments(callCtx, listReq)
191+
if err != nil {
192+
return nil, nil, client.NewError(err, "ListDeployments")
193+
}
194+
195+
deployments := make([]DeploymentResult, 0, len(resp.GetDeployments()))
196+
for _, deployment := range resp.GetDeployments() {
197+
deployments = append(deployments, DeploymentResult{
198+
Name: deployment.GetName(),
199+
Namespace: deployment.GetNamespace(),
200+
ClusterID: deployment.GetClusterId(),
201+
ClusterName: deployment.GetCluster(),
202+
})
203+
}
204+
205+
output := &getDeploymentsForCVEOutput{
206+
Deployments: deployments,
207+
}
208+
209+
return nil, output, nil
64210
}

0 commit comments

Comments
 (0)