Skip to content

Commit 2a34dfe

Browse files
committed
Add list_clusters tool
1 parent e5a4d8d commit 2a34dfe

File tree

5 files changed

+542
-31
lines changed

5 files changed

+542
-31
lines changed

internal/client/client.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"crypto/tls"
77
"fmt"
88
"sync"
9+
"testing"
910
"time"
1011

1112
"github.com/pkg/errors"
@@ -143,6 +144,18 @@ func (c *Client) ReadyConn(ctx context.Context) (*grpc.ClientConn, error) {
143144
return c.conn, nil
144145
}
145146

147+
// SetConnForTesting sets a gRPC connection for testing purposes.
148+
// This should only be used in tests.
149+
func (c *Client) SetConnForTesting(t *testing.T, conn *grpc.ClientConn) {
150+
t.Helper()
151+
152+
c.mu.Lock()
153+
defer c.mu.Unlock()
154+
155+
c.conn = conn
156+
c.connected = true
157+
}
158+
146159
func (c *Client) shouldRedialNoLock() bool {
147160
if !c.connected || c.conn == nil {
148161
return true

internal/toolsets/config/tools.go

Lines changed: 96 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,43 @@ package config
22

33
import (
44
"context"
5-
"fmt"
65

6+
"github.com/google/jsonschema-go/jsonschema"
77
"github.com/modelcontextprotocol/go-sdk/mcp"
88
"github.com/pkg/errors"
99
v1 "github.com/stackrox/rox/generated/api/v1"
1010
"github.com/stackrox/stackrox-mcp/internal/client"
1111
"github.com/stackrox/stackrox-mcp/internal/client/auth"
12+
"github.com/stackrox/stackrox-mcp/internal/logging"
1213
"github.com/stackrox/stackrox-mcp/internal/toolsets"
1314
)
1415

16+
const (
17+
defaultOffset = 0
18+
19+
// 0 = no limit.
20+
defaultLimit = 0
21+
)
22+
1523
// listClustersInput defines the input parameters for list_clusters tool.
16-
type listClustersInput struct{}
24+
type listClustersInput struct {
25+
Offset int `json:"offset,omitempty"`
26+
Limit int `json:"limit,omitempty"`
27+
}
28+
29+
// ClusterInfo represents information about a single cluster.
30+
type ClusterInfo struct {
31+
ID string `json:"id"`
32+
Name string `json:"name"`
33+
Type string `json:"type"`
34+
}
1735

1836
// listClustersOutput defines the output structure for list_clusters tool.
1937
type listClustersOutput struct {
20-
Clusters []string `json:"clusters"`
38+
Clusters []ClusterInfo `json:"clusters"`
39+
TotalCount int `json:"totalCount"`
40+
Offset int `json:"offset"`
41+
Limit int `json:"limit"`
2142
}
2243

2344
// listClustersTool implements the list_clusters tool.
@@ -48,63 +69,107 @@ func (t *listClustersTool) GetName() string {
4869
func (t *listClustersTool) GetTool() *mcp.Tool {
4970
return &mcp.Tool{
5071
Name: t.name,
51-
Description: "List all clusters managed by StackRox Central with their IDs, names, and types",
72+
Description: "List all clusters managed by StackRox with their IDs, names, and types",
73+
InputSchema: listClustersInputSchema(),
74+
}
75+
}
76+
77+
func listClustersInputSchema() *jsonschema.Schema {
78+
schema, err := jsonschema.For[listClustersInput](nil)
79+
if err != nil {
80+
logging.Fatal("Could not get jsonschema for list_clusters input", err)
81+
82+
return nil
5283
}
84+
85+
schema.Properties["offset"].Minimum = jsonschema.Ptr(0.0)
86+
schema.Properties["offset"].Default = toolsets.MustJSONMarshal(defaultOffset)
87+
schema.Properties["offset"].Description = "Starting index for pagination (0-based)"
88+
89+
schema.Properties["limit"].Minimum = jsonschema.Ptr(0.0)
90+
schema.Properties["limit"].Default = toolsets.MustJSONMarshal(defaultLimit)
91+
schema.Properties["limit"].Description = "Maximum number of clusters to return (default: 0 - unlimited)"
92+
93+
return schema
5394
}
5495

5596
// RegisterWith registers the list_clusters tool handler with the MCP server.
5697
func (t *listClustersTool) RegisterWith(server *mcp.Server) {
5798
mcp.AddTool(server, t.GetTool(), t.handle)
5899
}
59100

60-
// handle is the placeholder handler for list_clusters tool.
61-
func (t *listClustersTool) handle(
62-
ctx context.Context,
63-
req *mcp.CallToolRequest,
64-
_ listClustersInput,
65-
) (*mcp.CallToolResult, *listClustersOutput, error) {
101+
func (t *listClustersTool) getClusters(ctx context.Context, req *mcp.CallToolRequest) ([]ClusterInfo, error) {
66102
conn, err := t.client.ReadyConn(ctx)
67103
if err != nil {
68-
return nil, nil, errors.Wrap(err, "unable to connect to server")
104+
return nil, errors.Wrap(err, "unable to connect to server")
69105
}
70106

71107
callCtx := auth.WithMCPRequestContext(ctx, req)
72108

73109
// Create ClustersService client
74110
clustersClient := v1.NewClustersServiceClient(conn)
75111

76-
// Call GetClusters
112+
// Call GetClusters to fetch all clusters
77113
resp, err := clustersClient.GetClusters(callCtx, &v1.GetClustersRequest{})
78114
if err != nil {
79115
// Convert gRPC error to client error
80116
clientErr := client.NewError(err, "GetClusters")
81117

82-
return nil, nil, clientErr
118+
return nil, clientErr
83119
}
84120

85-
// Extract cluster information
86-
clusters := make([]string, 0, len(resp.GetClusters()))
121+
// Convert all clusters to ClusterInfo objects
122+
allClusters := make([]ClusterInfo, 0, len(resp.GetClusters()))
87123
for _, cluster := range resp.GetClusters() {
88-
// Format: "ID: <id>, Name: <name>, Type: <type>"
89-
clusterInfo := fmt.Sprintf("ID: %s, Name: %s, Type: %s",
90-
cluster.GetId(),
91-
cluster.GetName(),
92-
cluster.GetType().String())
93-
clusters = append(clusters, clusterInfo)
124+
clusterInfo := ClusterInfo{
125+
ID: cluster.GetId(),
126+
Name: cluster.GetName(),
127+
Type: cluster.GetType().String(),
128+
}
129+
allClusters = append(allClusters, clusterInfo)
94130
}
95131

96-
output := &listClustersOutput{
97-
Clusters: clusters,
132+
return allClusters, nil
133+
}
134+
135+
// handle is the handler for list_clusters tool.
136+
func (t *listClustersTool) handle(
137+
ctx context.Context,
138+
req *mcp.CallToolRequest,
139+
input listClustersInput,
140+
) (*mcp.CallToolResult, *listClustersOutput, error) {
141+
clusters, err := t.getClusters(ctx, req)
142+
if err != nil {
143+
return nil, nil, err
144+
}
145+
146+
totalCount := len(clusters)
147+
148+
// 0 = unlimited.
149+
limit := input.Limit
150+
if limit == 0 {
151+
limit = totalCount
152+
}
153+
154+
// Apply client-side pagination.
155+
var paginatedClusters []ClusterInfo
156+
if input.Offset >= totalCount {
157+
paginatedClusters = []ClusterInfo{}
158+
} else {
159+
end := min(input.Offset+limit, totalCount)
160+
if end < 0 {
161+
end = totalCount
162+
}
163+
164+
paginatedClusters = clusters[input.Offset:end]
98165
}
99166

100-
// Return result with text content
101-
result := &mcp.CallToolResult{
102-
Content: []mcp.Content{
103-
&mcp.TextContent{
104-
Text: fmt.Sprintf("Found %d cluster(s)", len(clusters)),
105-
},
106-
},
167+
output := &listClustersOutput{
168+
Clusters: paginatedClusters,
169+
TotalCount: totalCount,
170+
Offset: input.Offset,
171+
Limit: input.Limit,
107172
}
108173

109-
return result, output, nil
174+
return nil, output, nil
110175
}

0 commit comments

Comments
 (0)