Skip to content

Commit 69f13b5

Browse files
authored
feat: add kubernetes_list_nodes tool (#32)
1 parent 089cfe0 commit 69f13b5

File tree

5 files changed

+227
-1
lines changed

5 files changed

+227
-1
lines changed

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ The handler filters tools dynamically based on `GetMyPermissions` from Sysdig Se
4949
| `get_event_process_tree` | `tool_get_event_process_tree.go` | Retrieve the process tree for an event when available. | `policy-events.read` | “Show the process tree behind event `abc123`.” |
5050
| `run_sysql` | `tool_run_sysql.go` | Execute caller-supplied Sysdig SysQL queries safely. | `sage.exec`, `risks.read` | “Run the following SysQL…”. |
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.” |
52-
| `kubernetes_list_clusters` | `tool_kubernetes_list_clusters.go` | Lists Kubernetes cluster information. | None | "List all Kubernetes clusters" |
52+
| `kubernetes_list_clusters` | `tool_kubernetes_list_clusters.go` | Lists Kubernetes cluster information. | `promql.exec` | "List all Kubernetes clusters" |
53+
| `kubernetes_list_nodes` | `tool_kubernetes_list_nodes.go` | Lists Kubernetes node information. | `promql.exec` | "List all Kubernetes nodes in the cluster 'production-gke'" |
5354

5455
Every tool has a companion `_test.go` file that exercises request validation, permission metadata, and Sysdig client calls through mocks.
5556
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
@@ -113,6 +113,11 @@ The server dynamically filters the available tools based on the permissions asso
113113
- **Required Permission**: `promql.exec`
114114
- **Sample Prompt**: "List all kubernetes clusters" or "Show me info for cluster 'production-gke'"
115115

116+
- **`kubernetes_list_nodes`**
117+
- **Description**: Lists the node information for all nodes, all nodes from a cluster or just the node specified.
118+
- **Required Permission**: `promql.exec`
119+
- **Sample Prompt**: "List all kubernetes nodes in the cluster 'production-gke'" or "Show me info for node 'node-123'"
120+
116121
## Requirements
117122

118123
- [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
@@ -96,6 +96,7 @@ func setupHandler(sysdigClient sysdig.ExtendedClientWithResponsesInterface) *mcp
9696
tools.NewToolGenerateSysql(sysdigClient),
9797

9898
tools.NewKubernetesListClusters(sysdigClient),
99+
tools.NewKubernetesListNodes(sysdigClient),
99100
)
100101
return handler
101102
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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 KubernetesListNodes struct {
16+
SysdigClient sysdig.ExtendedClientWithResponsesInterface
17+
}
18+
19+
func NewKubernetesListNodes(sysdigClient sysdig.ExtendedClientWithResponsesInterface) *KubernetesListNodes {
20+
return &KubernetesListNodes{
21+
SysdigClient: sysdigClient,
22+
}
23+
}
24+
25+
func (t *KubernetesListNodes) RegisterInServer(s *server.MCPServer) {
26+
tool := mcp.NewTool("kubernetes_list_nodes",
27+
mcp.WithDescription("Lists the information from all nodes, all nodes from a cluster or a specific node with some name."),
28+
mcp.WithString("cluster_name", mcp.Description("The name of the cluster to filter by.")),
29+
mcp.WithString("node_name", mcp.Description("The name of the node to filter by.")),
30+
mcp.WithNumber("limit",
31+
mcp.Description("Maximum number of nodes to return."),
32+
mcp.DefaultNumber(10),
33+
),
34+
mcp.WithOutputSchema[map[string]any](),
35+
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.
36+
)
37+
s.AddTool(tool, t.handle)
38+
}
39+
40+
func (t *KubernetesListNodes) handle(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
41+
clusterName := mcp.ParseString(request, "cluster_name", "")
42+
nodeName := mcp.ParseString(request, "node_name", "")
43+
limit := mcp.ParseInt(request, "limit", 10)
44+
45+
query := buildKubeNodeInfoQuery(clusterName, nodeName)
46+
47+
limitQuery := sysdig.LimitQuery(limit)
48+
params := &sysdig.GetQueryV1Params{
49+
Query: query,
50+
Limit: &limitQuery,
51+
}
52+
53+
httpResp, err := t.SysdigClient.GetQueryV1(ctx, params)
54+
if err != nil {
55+
return mcp.NewToolResultErrorFromErr("failed to get node list", err), nil
56+
}
57+
58+
if httpResp.StatusCode != 200 {
59+
bodyBytes, _ := io.ReadAll(httpResp.Body)
60+
return mcp.NewToolResultErrorf("failed to get node list: status code %d, body: %s", httpResp.StatusCode, string(bodyBytes)), nil
61+
}
62+
63+
var queryResponse sysdig.QueryResponseV1
64+
if err := json.NewDecoder(httpResp.Body).Decode(&queryResponse); err != nil {
65+
return mcp.NewToolResultErrorFromErr("failed to decode response", err), nil
66+
}
67+
68+
return mcp.NewToolResultJSON(queryResponse)
69+
}
70+
71+
func buildKubeNodeInfoQuery(clusterName, nodeName string) string {
72+
filters := []string{}
73+
if clusterName != "" {
74+
filters = append(filters, fmt.Sprintf("cluster=\"%s\"", clusterName))
75+
}
76+
if nodeName != "" {
77+
filters = append(filters, fmt.Sprintf("kube_node_name=\"%s\"", nodeName))
78+
}
79+
80+
if len(filters) == 0 {
81+
return "kube_node_info"
82+
}
83+
84+
return fmt.Sprintf("kube_node_info{%s}", strings.Join(filters, ","))
85+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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("KubernetesListNodes Tool", func() {
20+
var (
21+
tool *tools.KubernetesListNodes
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.NewKubernetesListNodes(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_nodes")).NotTo(BeNil())
37+
})
38+
39+
When("listing nodes", 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_nodes",
56+
mcp.CallToolRequest{
57+
Params: mcp.CallToolParams{
58+
Name: "kubernetes_list_nodes",
59+
Arguments: map[string]any{},
60+
},
61+
},
62+
sysdig.GetQueryV1Params{
63+
Query: `kube_node_info`,
64+
Limit: asPtr(sysdig.LimitQuery(10)),
65+
},
66+
),
67+
Entry(nil,
68+
"kubernetes_list_nodes",
69+
mcp.CallToolRequest{
70+
Params: mcp.CallToolParams{
71+
Name: "kubernetes_list_nodes",
72+
Arguments: map[string]any{"limit": "20"},
73+
},
74+
},
75+
sysdig.GetQueryV1Params{
76+
Query: `kube_node_info`,
77+
Limit: asPtr(sysdig.LimitQuery(20)),
78+
},
79+
),
80+
Entry(nil,
81+
"kubernetes_list_nodes",
82+
mcp.CallToolRequest{
83+
Params: mcp.CallToolParams{
84+
Name: "kubernetes_list_nodes",
85+
Arguments: map[string]any{"cluster_name": "my_cluster"},
86+
},
87+
},
88+
sysdig.GetQueryV1Params{
89+
Query: `kube_node_info{cluster="my_cluster"}`,
90+
Limit: asPtr(sysdig.LimitQuery(10)),
91+
},
92+
),
93+
Entry(nil,
94+
"kubernetes_list_nodes",
95+
mcp.CallToolRequest{
96+
Params: mcp.CallToolParams{
97+
Name: "kubernetes_list_nodes",
98+
Arguments: map[string]any{"node_name": "my_node"},
99+
},
100+
},
101+
sysdig.GetQueryV1Params{
102+
Query: `kube_node_info{kube_node_name="my_node"}`,
103+
Limit: asPtr(sysdig.LimitQuery(10)),
104+
},
105+
),
106+
Entry(nil,
107+
"kubernetes_list_nodes",
108+
mcp.CallToolRequest{
109+
Params: mcp.CallToolParams{
110+
Name: "kubernetes_list_nodes",
111+
Arguments: map[string]any{"cluster_name": "my_cluster", "node_name": "my_node"},
112+
},
113+
},
114+
sysdig.GetQueryV1Params{
115+
Query: `kube_node_info{cluster="my_cluster",kube_node_name="my_node"}`,
116+
Limit: asPtr(sysdig.LimitQuery(10)),
117+
},
118+
),
119+
Entry(nil,
120+
"kubernetes_list_nodes",
121+
mcp.CallToolRequest{
122+
Params: mcp.CallToolParams{
123+
Name: "kubernetes_list_nodes",
124+
Arguments: map[string]any{"cluster_name": "my_cluster", "limit": "20"},
125+
},
126+
},
127+
sysdig.GetQueryV1Params{
128+
Query: `kube_node_info{cluster="my_cluster"}`,
129+
Limit: asPtr(sysdig.LimitQuery(20)),
130+
},
131+
),
132+
)
133+
})
134+
})

0 commit comments

Comments
 (0)