Skip to content

Commit 9183526

Browse files
committed
Add nodes_debug_exec tool in pkg/ocp package
1 parent 82a4ef2 commit 9183526

13 files changed

+1855
-154
lines changed

README.md

Lines changed: 238 additions & 152 deletions
Large diffs are not rendered by default.

pkg/mcp/nodes_test.go

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
10+
"github.com/mark3labs/mcp-go/mcp"
11+
v1 "k8s.io/api/core/v1"
12+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
13+
"k8s.io/apimachinery/pkg/runtime"
14+
"k8s.io/apimachinery/pkg/runtime/serializer"
15+
16+
"github.com/containers/kubernetes-mcp-server/internal/test"
17+
)
18+
19+
func TestNodesDebugExecTool(t *testing.T) {
20+
testCase(t, func(c *mcpContext) {
21+
mockServer := test.NewMockServer()
22+
defer mockServer.Close()
23+
c.withKubeConfig(mockServer.Config())
24+
25+
var (
26+
createdPod v1.Pod
27+
deleteCalled bool
28+
)
29+
const namespace = "debug"
30+
const logOutput = "filesystem repaired"
31+
32+
scheme := runtime.NewScheme()
33+
_ = v1.AddToScheme(scheme)
34+
codec := serializer.NewCodecFactory(scheme).UniversalDeserializer()
35+
36+
mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
37+
switch {
38+
case req.URL.Path == "/api":
39+
w.Header().Set("Content-Type", "application/json")
40+
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
41+
case req.URL.Path == "/apis":
42+
w.Header().Set("Content-Type", "application/json")
43+
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
44+
case req.URL.Path == "/api/v1":
45+
w.Header().Set("Content-Type", "application/json")
46+
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"Pod","verbs":["get","list","watch","create","update","patch","delete"]}]}`))
47+
case req.Method == http.MethodPatch && strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/"):
48+
// Handle server-side apply (PATCH with fieldManager query param)
49+
body, err := io.ReadAll(req.Body)
50+
if err != nil {
51+
t.Fatalf("failed to read apply body: %v", err)
52+
}
53+
created := &v1.Pod{}
54+
if _, _, err = codec.Decode(body, nil, created); err != nil {
55+
t.Fatalf("failed to decode apply body: %v", err)
56+
}
57+
createdPod = *created
58+
// Keep the name from the request URL if it was provided
59+
pathParts := strings.Split(req.URL.Path, "/")
60+
if len(pathParts) > 0 {
61+
createdPod.Name = pathParts[len(pathParts)-1]
62+
}
63+
createdPod.Namespace = namespace
64+
w.Header().Set("Content-Type", "application/json")
65+
_ = json.NewEncoder(w).Encode(&createdPod)
66+
case req.Method == http.MethodPost && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods":
67+
body, err := io.ReadAll(req.Body)
68+
if err != nil {
69+
t.Fatalf("failed to read create body: %v", err)
70+
}
71+
created := &v1.Pod{}
72+
if _, _, err = codec.Decode(body, nil, created); err != nil {
73+
t.Fatalf("failed to decode create body: %v", err)
74+
}
75+
createdPod = *created
76+
createdPod.ObjectMeta = metav1.ObjectMeta{
77+
Namespace: namespace,
78+
Name: createdPod.GenerateName + "abc",
79+
}
80+
w.Header().Set("Content-Type", "application/json")
81+
_ = json.NewEncoder(w).Encode(&createdPod)
82+
case req.Method == http.MethodGet && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name:
83+
podStatus := createdPod.DeepCopy()
84+
podStatus.Status = v1.PodStatus{
85+
Phase: v1.PodSucceeded,
86+
ContainerStatuses: []v1.ContainerStatus{{
87+
Name: "debug",
88+
State: v1.ContainerState{Terminated: &v1.ContainerStateTerminated{
89+
ExitCode: 0,
90+
}},
91+
}},
92+
}
93+
w.Header().Set("Content-Type", "application/json")
94+
_ = json.NewEncoder(w).Encode(podStatus)
95+
case req.Method == http.MethodDelete && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name:
96+
deleteCalled = true
97+
w.Header().Set("Content-Type", "application/json")
98+
_ = json.NewEncoder(w).Encode(&metav1.Status{Status: "Success"})
99+
case req.Method == http.MethodGet && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name+"/log":
100+
w.Header().Set("Content-Type", "text/plain")
101+
_, _ = w.Write([]byte(logOutput))
102+
}
103+
}))
104+
105+
toolResult, err := c.callTool("nodes_debug_exec", map[string]any{
106+
"node": "worker-0",
107+
"namespace": namespace,
108+
"command": []any{"uname", "-a"},
109+
})
110+
111+
t.Run("call succeeds", func(t *testing.T) {
112+
if err != nil {
113+
t.Fatalf("call tool failed: %v", err)
114+
}
115+
if toolResult.IsError {
116+
t.Fatalf("tool returned error: %v", toolResult.Content)
117+
}
118+
if len(toolResult.Content) == 0 {
119+
t.Fatalf("expected output content")
120+
}
121+
text := toolResult.Content[0].(mcp.TextContent).Text
122+
if text != logOutput {
123+
t.Fatalf("unexpected tool output %q", text)
124+
}
125+
})
126+
127+
t.Run("debug pod shaped correctly", func(t *testing.T) {
128+
if createdPod.Spec.Containers == nil || len(createdPod.Spec.Containers) != 1 {
129+
t.Fatalf("expected single container in debug pod")
130+
}
131+
container := createdPod.Spec.Containers[0]
132+
expectedPrefix := []string{"chroot", "/host", "uname", "-a"}
133+
if !equalStringSlices(container.Command, expectedPrefix) {
134+
t.Fatalf("unexpected debug command: %v", container.Command)
135+
}
136+
if container.SecurityContext == nil || container.SecurityContext.Privileged == nil || !*container.SecurityContext.Privileged {
137+
t.Fatalf("expected privileged container")
138+
}
139+
if len(createdPod.Spec.Volumes) == 0 || createdPod.Spec.Volumes[0].HostPath == nil {
140+
t.Fatalf("expected hostPath volume on debug pod")
141+
}
142+
if !deleteCalled {
143+
t.Fatalf("expected debug pod to be deleted")
144+
}
145+
})
146+
})
147+
}
148+
149+
func equalStringSlices(a, b []string) bool {
150+
if len(a) != len(b) {
151+
return false
152+
}
153+
for i := range a {
154+
if a[i] != b[i] {
155+
return false
156+
}
157+
}
158+
return true
159+
}
160+
161+
func TestNodesDebugExecToolNonZeroExit(t *testing.T) {
162+
testCase(t, func(c *mcpContext) {
163+
mockServer := test.NewMockServer()
164+
defer mockServer.Close()
165+
c.withKubeConfig(mockServer.Config())
166+
167+
const namespace = "default"
168+
const errorMessage = "failed"
169+
170+
scheme := runtime.NewScheme()
171+
_ = v1.AddToScheme(scheme)
172+
codec := serializer.NewCodecFactory(scheme).UniversalDeserializer()
173+
174+
mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
175+
switch {
176+
case req.URL.Path == "/api":
177+
w.Header().Set("Content-Type", "application/json")
178+
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
179+
case req.URL.Path == "/apis":
180+
w.Header().Set("Content-Type", "application/json")
181+
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
182+
case req.URL.Path == "/api/v1":
183+
w.Header().Set("Content-Type", "application/json")
184+
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"Pod","verbs":["get","list","watch","create","update","patch","delete"]}]}`))
185+
case req.Method == http.MethodPatch && strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/"):
186+
// Handle server-side apply (PATCH with fieldManager query param)
187+
body, err := io.ReadAll(req.Body)
188+
if err != nil {
189+
t.Fatalf("failed to read apply body: %v", err)
190+
}
191+
pod := &v1.Pod{}
192+
if _, _, err = codec.Decode(body, nil, pod); err != nil {
193+
t.Fatalf("failed to decode apply body: %v", err)
194+
}
195+
// Keep the name from the request URL if it was provided
196+
pathParts := strings.Split(req.URL.Path, "/")
197+
if len(pathParts) > 0 {
198+
pod.Name = pathParts[len(pathParts)-1]
199+
}
200+
pod.Namespace = namespace
201+
w.Header().Set("Content-Type", "application/json")
202+
_ = json.NewEncoder(w).Encode(pod)
203+
case req.Method == http.MethodPost && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods":
204+
body, err := io.ReadAll(req.Body)
205+
if err != nil {
206+
t.Fatalf("failed to read create body: %v", err)
207+
}
208+
pod := &v1.Pod{}
209+
if _, _, err = codec.Decode(body, nil, pod); err != nil {
210+
t.Fatalf("failed to decode create body: %v", err)
211+
}
212+
pod.ObjectMeta = metav1.ObjectMeta{Name: pod.GenerateName + "xyz", Namespace: namespace}
213+
w.Header().Set("Content-Type", "application/json")
214+
_ = json.NewEncoder(w).Encode(pod)
215+
case strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/") && strings.HasSuffix(req.URL.Path, "/log"):
216+
w.Header().Set("Content-Type", "text/plain")
217+
_, _ = w.Write([]byte(errorMessage))
218+
case req.Method == http.MethodGet && strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/"):
219+
pathParts := strings.Split(req.URL.Path, "/")
220+
podName := pathParts[len(pathParts)-1]
221+
pod := &v1.Pod{
222+
TypeMeta: metav1.TypeMeta{
223+
APIVersion: "v1",
224+
Kind: "Pod",
225+
},
226+
ObjectMeta: metav1.ObjectMeta{
227+
Name: podName,
228+
Namespace: namespace,
229+
},
230+
}
231+
pod.Status = v1.PodStatus{
232+
Phase: v1.PodSucceeded,
233+
ContainerStatuses: []v1.ContainerStatus{{
234+
Name: "debug",
235+
State: v1.ContainerState{Terminated: &v1.ContainerStateTerminated{
236+
ExitCode: 2,
237+
Reason: "Error",
238+
}},
239+
}},
240+
}
241+
w.Header().Set("Content-Type", "application/json")
242+
_ = json.NewEncoder(w).Encode(pod)
243+
}
244+
}))
245+
246+
toolResult, err := c.callTool("nodes_debug_exec", map[string]any{
247+
"node": "infra-1",
248+
"command": []any{"journalctl"},
249+
})
250+
251+
if err != nil {
252+
t.Fatalf("call tool failed: %v", err)
253+
}
254+
if !toolResult.IsError {
255+
t.Fatalf("expected tool to return error")
256+
}
257+
text := toolResult.Content[0].(mcp.TextContent).Text
258+
if !strings.Contains(text, "command exited with code 2") {
259+
t.Fatalf("expected exit code message, got %q", text)
260+
}
261+
if !strings.Contains(text, "Error") {
262+
t.Fatalf("expected error reason included, got %q", text)
263+
}
264+
})
265+
}

pkg/mcp/testdata/toolsets-core-tools.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,50 @@
3333
},
3434
"name": "namespaces_list"
3535
},
36+
{
37+
"annotations": {
38+
"title": "Nodes: Debug Exec",
39+
"readOnlyHint": false,
40+
"destructiveHint": true,
41+
"idempotentHint": false,
42+
"openWorldHint": true
43+
},
44+
"description": "Run commands on an OpenShift node using a privileged debug pod (output is truncated to the most recent 100 lines, so prefer filters like grep when expecting large logs).",
45+
"inputSchema": {
46+
"type": "object",
47+
"properties": {
48+
"node": {
49+
"description": "Name of the node to debug (e.g. worker-0).",
50+
"type": "string"
51+
},
52+
"command": {
53+
"description": "Command to execute on the node via chroot. Provide each argument as a separate array item (e.g. ['systemctl', 'status', 'kubelet']).",
54+
"items": {
55+
"type": "string"
56+
},
57+
"type": "array"
58+
},
59+
"namespace": {
60+
"description": "Namespace to create the temporary debug pod in (optional, defaults to the current namespace or 'default').",
61+
"type": "string"
62+
},
63+
"image": {
64+
"description": "Container image to use for the debug pod (optional). Defaults to a Fedora-based utility image that includes chroot.",
65+
"type": "string"
66+
},
67+
"timeout_seconds": {
68+
"description": "Maximum time to wait for the command to complete before timing out (optional, defaults to 300 seconds).",
69+
"minimum": 1,
70+
"type": "integer"
71+
}
72+
},
73+
"required": [
74+
"node",
75+
"command"
76+
]
77+
},
78+
"name": "nodes_debug_exec"
79+
},
3680
{
3781
"annotations": {
3882
"title": "Pods: Delete",

pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,58 @@
195195
},
196196
"name": "namespaces_list"
197197
},
198+
{
199+
"annotations": {
200+
"title": "Nodes: Debug Exec",
201+
"readOnlyHint": false,
202+
"destructiveHint": true,
203+
"idempotentHint": false,
204+
"openWorldHint": true
205+
},
206+
"description": "Run commands on an OpenShift node using a privileged debug pod (output is truncated to the most recent 100 lines, so prefer filters like grep when expecting large logs).",
207+
"inputSchema": {
208+
"type": "object",
209+
"properties": {
210+
"node": {
211+
"description": "Name of the node to debug (e.g. worker-0).",
212+
"type": "string"
213+
},
214+
"command": {
215+
"description": "Command to execute on the node via chroot. Provide each argument as a separate array item (e.g. ['systemctl', 'status', 'kubelet']).",
216+
"items": {
217+
"type": "string"
218+
},
219+
"type": "array"
220+
},
221+
"context": {
222+
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
223+
"enum": [
224+
"extra-cluster",
225+
"fake-context"
226+
],
227+
"type": "string"
228+
},
229+
"namespace": {
230+
"description": "Namespace to create the temporary debug pod in (optional, defaults to the current namespace or 'default').",
231+
"type": "string"
232+
},
233+
"image": {
234+
"description": "Container image to use for the debug pod (optional). Defaults to a Fedora-based utility image that includes chroot.",
235+
"type": "string"
236+
},
237+
"timeout_seconds": {
238+
"description": "Maximum time to wait for the command to complete before timing out (optional, defaults to 300 seconds).",
239+
"minimum": 1,
240+
"type": "integer"
241+
}
242+
},
243+
"required": [
244+
"node",
245+
"command"
246+
]
247+
},
248+
"name": "nodes_debug_exec"
249+
},
198250
{
199251
"annotations": {
200252
"title": "Pods: Delete",
@@ -677,4 +729,4 @@
677729
},
678730
"name": "resources_list"
679731
}
680-
]
732+
]

0 commit comments

Comments
 (0)