Skip to content

Commit 92e7355

Browse files
authored
feat: add kubernetes_list_workloads tool (#33)
1 parent 69f13b5 commit 92e7355

File tree

5 files changed

+266
-0
lines changed

5 files changed

+266
-0
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ The handler filters tools dynamically based on `GetMyPermissions` from Sysdig Se
5151
| `generate_sysql` | `tool_generate_sysql.go` | Convert natural language to SysQL via Sysdig Sage. | `sage.exec` (does not work with Service Accounts) | “Create a SysQL to list S3 buckets.” |
5252
| `kubernetes_list_clusters` | `tool_kubernetes_list_clusters.go` | Lists Kubernetes cluster information. | `promql.exec` | "List all Kubernetes clusters" |
5353
| `kubernetes_list_nodes` | `tool_kubernetes_list_nodes.go` | Lists Kubernetes node information. | `promql.exec` | "List all Kubernetes nodes in the cluster 'production-gke'" |
54+
| `kubernetes_list_workloads` | `tool_kubernetes_list_workloads.go` | Lists Kubernetes workload information. | `promql.exec` | "List all desired workloads in the cluster 'production-gke' and namespace 'default'" |
5455

5556
Every tool has a companion `_test.go` file that exercises request validation, permission metadata, and Sysdig client calls through mocks.
5657
Note that if you add more tools you need to also update this file to reflect that.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ The server dynamically filters the available tools based on the permissions asso
118118
- **Required Permission**: `promql.exec`
119119
- **Sample Prompt**: "List all kubernetes nodes in the cluster 'production-gke'" or "Show me info for node 'node-123'"
120120

121+
- **`kubernetes_list_workloads`**
122+
- **Description**: Lists all the workloads that are in a particular state, desired, ready, running or unavailable. The LLM can filter by cluster, namespace, workload name or type.
123+
- **Required Permission**: `promql.exec`
124+
- **Sample Prompt**: "List all desired workloads in the cluster 'production-gke' and namespace 'default'"
125+
121126
## Requirements
122127

123128
- [Go](https://go.dev/doc/install) 1.25 or higher (if running without Docker).

cmd/server/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ func setupHandler(sysdigClient sysdig.ExtendedClientWithResponsesInterface) *mcp
9797

9898
tools.NewKubernetesListClusters(sysdigClient),
9999
tools.NewKubernetesListNodes(sysdigClient),
100+
tools.NewKubernetesListWorkloads(sysdigClient),
100101
)
101102
return handler
102103
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"strings"
9+
10+
"github.com/mark3labs/mcp-go/mcp"
11+
"github.com/mark3labs/mcp-go/server"
12+
"github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig"
13+
)
14+
15+
type KubernetesListWorkloads struct {
16+
SysdigClient sysdig.ExtendedClientWithResponsesInterface
17+
}
18+
19+
func NewKubernetesListWorkloads(sysdigClient sysdig.ExtendedClientWithResponsesInterface) *KubernetesListWorkloads {
20+
return &KubernetesListWorkloads{
21+
SysdigClient: sysdigClient,
22+
}
23+
}
24+
25+
func (t *KubernetesListWorkloads) RegisterInServer(s *server.MCPServer) {
26+
tool := mcp.NewTool("kubernetes_list_workloads",
27+
mcp.WithDescription("Lists all the workloads that are in a particular state, desired, ready, running or unavailable. The LLM can filter by cluster, namespace, workload name or type."),
28+
mcp.WithString("status",
29+
mcp.Description("The status of the workload."),
30+
mcp.Enum("desired", "ready", "running", "unavailable"),
31+
mcp.Required(),
32+
),
33+
mcp.WithString("cluster_name", mcp.Description("The name of the cluster to filter by.")),
34+
mcp.WithString("namespace_name", mcp.Description("The name of the namespace to filter by.")),
35+
mcp.WithString("workload_name", mcp.Description("The name of the workload to filter by.")),
36+
mcp.WithString("workload_type",
37+
mcp.Description("The type of the workload."),
38+
mcp.Enum("deployment", "daemonset", "statefulset"),
39+
),
40+
mcp.WithNumber("limit",
41+
mcp.Description("Maximum number of workloads to return."),
42+
mcp.DefaultNumber(10),
43+
),
44+
mcp.WithOutputSchema[map[string]any](),
45+
WithRequiredPermissions(), // FIXME(fede): Add the required permissions. It should be `promql.exec` but somehow the token does not have that permission even if you are able to execute queries.
46+
)
47+
s.AddTool(tool, t.handle)
48+
}
49+
50+
func (t *KubernetesListWorkloads) handle(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
51+
status := mcp.ParseString(request, "status", "")
52+
clusterName := mcp.ParseString(request, "cluster_name", "")
53+
namespaceName := mcp.ParseString(request, "namespace_name", "")
54+
workloadName := mcp.ParseString(request, "workload_name", "")
55+
workloadType := mcp.ParseString(request, "workload_type", "")
56+
limit := mcp.ParseInt(request, "limit", 10)
57+
58+
query := buildKubeWorkloadInfoQuery(status, clusterName, namespaceName, workloadName, workloadType)
59+
60+
limitQuery := sysdig.LimitQuery(limit)
61+
params := &sysdig.GetQueryV1Params{
62+
Query: query,
63+
Limit: &limitQuery,
64+
}
65+
66+
httpResp, err := t.SysdigClient.GetQueryV1(ctx, params)
67+
if err != nil {
68+
return mcp.NewToolResultErrorFromErr("failed to get workload list", err), nil
69+
}
70+
71+
if httpResp.StatusCode != 200 {
72+
bodyBytes, _ := io.ReadAll(httpResp.Body)
73+
return mcp.NewToolResultErrorf("failed to get workload list: status code %d, body: %s", httpResp.StatusCode, string(bodyBytes)), nil
74+
}
75+
76+
var queryResponse sysdig.QueryResponseV1
77+
if err := json.NewDecoder(httpResp.Body).Decode(&queryResponse); err != nil {
78+
return mcp.NewToolResultErrorFromErr("failed to decode response", err), nil
79+
}
80+
81+
return mcp.NewToolResultJSON(queryResponse)
82+
}
83+
84+
func buildKubeWorkloadInfoQuery(status, clusterName, namespaceName, workloadName, workloadType string) string {
85+
filters := []string{}
86+
if clusterName != "" {
87+
filters = append(filters, fmt.Sprintf("kube_cluster_name=\"%s\"", clusterName))
88+
}
89+
if namespaceName != "" {
90+
filters = append(filters, fmt.Sprintf("kube_namespace_name=\"%s\"", namespaceName))
91+
}
92+
if workloadName != "" {
93+
filters = append(filters, fmt.Sprintf("kube_workload_name=\"%s\"", workloadName))
94+
}
95+
if workloadType != "" {
96+
filters = append(filters, fmt.Sprintf("kube_workload_type=\"%s\"", workloadType))
97+
}
98+
99+
metric := fmt.Sprintf("kube_workload_status_%s", status)
100+
101+
if len(filters) == 0 {
102+
return metric
103+
}
104+
105+
return fmt.Sprintf("%s{%s}", metric, strings.Join(filters, ","))
106+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package tools_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"io"
7+
"net/http"
8+
9+
"github.com/mark3labs/mcp-go/mcp"
10+
"github.com/mark3labs/mcp-go/server"
11+
. "github.com/onsi/ginkgo/v2"
12+
. "github.com/onsi/gomega"
13+
"github.com/sysdiglabs/sysdig-mcp-server/internal/infra/mcp/tools"
14+
"github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig"
15+
"github.com/sysdiglabs/sysdig-mcp-server/internal/infra/sysdig/mocks"
16+
"go.uber.org/mock/gomock"
17+
)
18+
19+
var _ = Describe("KubernetesListWorkloads Tool", func() {
20+
var (
21+
tool *tools.KubernetesListWorkloads
22+
mockSysdig *mocks.MockExtendedClientWithResponsesInterface
23+
mcpServer *server.MCPServer
24+
ctrl *gomock.Controller
25+
)
26+
27+
BeforeEach(func() {
28+
ctrl = gomock.NewController(GinkgoT())
29+
mockSysdig = mocks.NewMockExtendedClientWithResponsesInterface(ctrl)
30+
tool = tools.NewKubernetesListWorkloads(mockSysdig)
31+
mcpServer = server.NewMCPServer("test", "test")
32+
tool.RegisterInServer(mcpServer)
33+
})
34+
35+
It("should register successfully in the server", func() {
36+
Expect(mcpServer.GetTool("kubernetes_list_workloads")).NotTo(BeNil())
37+
})
38+
39+
When("listing workloads", func() {
40+
DescribeTable("it succeeds", func(ctx context.Context, toolName string, request mcp.CallToolRequest, expectedParamsRequested sysdig.GetQueryV1Params) {
41+
mockSysdig.EXPECT().GetQueryV1(gomock.Any(), &expectedParamsRequested).Return(&http.Response{
42+
StatusCode: http.StatusOK,
43+
Body: io.NopCloser(bytes.NewBufferString(`{"status":"success"}`)),
44+
}, nil)
45+
46+
serverTool := mcpServer.GetTool(toolName)
47+
result, err := serverTool.Handler(ctx, request)
48+
Expect(err).NotTo(HaveOccurred())
49+
50+
resultData, ok := result.Content[0].(mcp.TextContent)
51+
Expect(ok).To(BeTrue())
52+
Expect(resultData.Text).To(MatchJSON(`{"status":"success"}`))
53+
},
54+
Entry(nil,
55+
"kubernetes_list_workloads",
56+
mcp.CallToolRequest{
57+
Params: mcp.CallToolParams{
58+
Name: "kubernetes_list_workloads",
59+
Arguments: map[string]any{"status": "desired"},
60+
},
61+
},
62+
sysdig.GetQueryV1Params{
63+
Query: `kube_workload_status_desired`,
64+
Limit: asPtr(sysdig.LimitQuery(10)),
65+
},
66+
),
67+
Entry(nil,
68+
"kubernetes_list_workloads",
69+
mcp.CallToolRequest{
70+
Params: mcp.CallToolParams{
71+
Name: "kubernetes_list_workloads",
72+
Arguments: map[string]any{"status": "ready", "limit": "20"},
73+
},
74+
},
75+
sysdig.GetQueryV1Params{
76+
Query: `kube_workload_status_ready`,
77+
Limit: asPtr(sysdig.LimitQuery(20)),
78+
},
79+
),
80+
Entry(nil,
81+
"kubernetes_list_workloads",
82+
mcp.CallToolRequest{
83+
Params: mcp.CallToolParams{
84+
Name: "kubernetes_list_workloads",
85+
Arguments: map[string]any{"status": "running", "cluster_name": "my_cluster"},
86+
},
87+
},
88+
sysdig.GetQueryV1Params{
89+
Query: `kube_workload_status_running{kube_cluster_name="my_cluster"}`,
90+
Limit: asPtr(sysdig.LimitQuery(10)),
91+
},
92+
),
93+
Entry(nil,
94+
"kubernetes_list_workloads",
95+
mcp.CallToolRequest{
96+
Params: mcp.CallToolParams{
97+
Name: "kubernetes_list_workloads",
98+
Arguments: map[string]any{"status": "unavailable", "namespace_name": "my_namespace"},
99+
},
100+
},
101+
sysdig.GetQueryV1Params{
102+
Query: `kube_workload_status_unavailable{kube_namespace_name="my_namespace"}`,
103+
Limit: asPtr(sysdig.LimitQuery(10)),
104+
},
105+
),
106+
Entry(nil,
107+
"kubernetes_list_workloads",
108+
mcp.CallToolRequest{
109+
Params: mcp.CallToolParams{
110+
Name: "kubernetes_list_workloads",
111+
Arguments: map[string]any{"status": "desired", "workload_name": "my_workload"},
112+
},
113+
},
114+
sysdig.GetQueryV1Params{
115+
Query: `kube_workload_status_desired{kube_workload_name="my_workload"}`,
116+
Limit: asPtr(sysdig.LimitQuery(10)),
117+
},
118+
),
119+
Entry(nil,
120+
"kubernetes_list_workloads",
121+
mcp.CallToolRequest{
122+
Params: mcp.CallToolParams{
123+
Name: "kubernetes_list_workloads",
124+
Arguments: map[string]any{"status": "ready", "workload_type": "deployment"},
125+
},
126+
},
127+
sysdig.GetQueryV1Params{
128+
Query: `kube_workload_status_ready{kube_workload_type="deployment"}`,
129+
Limit: asPtr(sysdig.LimitQuery(10)),
130+
},
131+
),
132+
Entry(nil,
133+
"kubernetes_list_workloads",
134+
mcp.CallToolRequest{
135+
Params: mcp.CallToolParams{
136+
Name: "kubernetes_list_workloads",
137+
Arguments: map[string]any{
138+
"status": "running",
139+
"cluster_name": "my_cluster",
140+
"namespace_name": "my_namespace",
141+
"workload_name": "my_workload",
142+
"workload_type": "statefulset",
143+
},
144+
},
145+
},
146+
sysdig.GetQueryV1Params{
147+
Query: `kube_workload_status_running{kube_cluster_name="my_cluster",kube_namespace_name="my_namespace",kube_workload_name="my_workload",kube_workload_type="statefulset"}`,
148+
Limit: asPtr(sysdig.LimitQuery(10)),
149+
},
150+
),
151+
)
152+
})
153+
})

0 commit comments

Comments
 (0)