Skip to content

Commit 9f4a546

Browse files
committed
Add nodes_debug_exec tool in pkg/ocp package
1 parent 80d6c70 commit 9f4a546

18 files changed

+1638
-5
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,13 @@ In case multi-cluster support is enabled (default) and you have access to multip
235235

236236
- **projects_list** - List all the OpenShift projects in the current cluster
237237

238+
- **nodes_debug_exec** - Run commands on an OpenShift node using a privileged debug pod with comprehensive troubleshooting utilities. The debug pod uses the UBI9 toolbox image which includes: systemd tools (systemctl, journalctl), networking tools (ss, ip, ping, traceroute, nmap), process tools (ps, top, lsof, strace), file system tools (find, tar, rsync), and debugging tools (gdb). Commands execute in a chroot of the host filesystem, providing full access to node-level diagnostics. Output is truncated to the most recent 100 lines, so prefer filters like grep when expecting large logs.
239+
- `command` (`array`) **(required)** - Command to execute on the node via chroot. All standard debugging utilities are available including systemctl, journalctl, ss, ip, ping, traceroute, nmap, ps, top, lsof, strace, find, tar, rsync, gdb, and more. Provide each argument as a separate array item (e.g. ['systemctl', 'status', 'kubelet'] or ['journalctl', '-u', 'kubelet', '--since', '1 hour ago']).
240+
- `image` (`string`) - Container image to use for the debug pod (optional). Defaults to registry.access.redhat.com/ubi9/toolbox:latest which provides comprehensive debugging and troubleshooting utilities.
241+
- `namespace` (`string`) - Namespace to create the temporary debug pod in (optional, defaults to the current namespace or 'default').
242+
- `node` (`string`) **(required)** - Name of the node to debug (e.g. worker-0).
243+
- `timeout_seconds` (`integer`) - Maximum time to wait for the command to complete before timing out (optional, defaults to 300 seconds).
244+
238245
- **pods_list** - List all the Kubernetes pods in the current cluster from all namespaces
239246
- `labelSelector` (`string`) - Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label
240247

pkg/config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ type StaticConfig struct {
6666
func Default() *StaticConfig {
6767
return &StaticConfig{
6868
ListOutput: "table",
69-
Toolsets: []string{"core", "config", "helm"},
69+
Toolsets: []string{"core", "config", "helm", "openshift-core"},
7070
}
7171
}
7272

pkg/config/config_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
152152
s.Equalf("table", config.ListOutput, "Expected ListOutput to be table, got %s", config.ListOutput)
153153
})
154154
s.Run("toolsets defaulted correctly", func() {
155-
s.Require().Lenf(config.Toolsets, 3, "Expected 3 toolsets, got %d", len(config.Toolsets))
156-
for _, toolset := range []string{"core", "config", "helm"} {
155+
s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets))
156+
for _, toolset := range []string{"core", "config", "helm", "openshift-core"} {
157157
s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
158158
}
159159
})

pkg/kubernetes-mcp-server/cmd/root_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,15 @@ func TestToolsets(t *testing.T) {
137137
rootCmd := NewMCPServer(ioStreams)
138138
rootCmd.SetArgs([]string{"--help"})
139139
o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout
140-
if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") {
140+
if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm, openshift-core).") {
141141
t.Fatalf("Expected all available toolsets, got %s %v", o, err)
142142
}
143143
})
144144
t.Run("default", func(t *testing.T) {
145145
ioStreams, out := testStream()
146146
rootCmd := NewMCPServer(ioStreams)
147147
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
148-
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm") {
148+
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm, openshift-core") {
149149
t.Fatalf("Expected toolsets 'full', got %s %v", out, err)
150150
}
151151
})

pkg/mcp/modules.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ package mcp
33
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
44
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
55
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
6+
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/openshift"

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-full-tools-multicluster-enum.json

Lines changed: 52 additions & 0 deletions
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 with comprehensive troubleshooting utilities. The debug pod uses the UBI9 toolbox image which includes: systemd tools (systemctl, journalctl), networking tools (ss, ip, ping, traceroute, nmap), process tools (ps, top, lsof, strace), file system tools (find, tar, rsync), and debugging tools (gdb). Commands execute in a chroot of the host filesystem, providing full access to node-level diagnostics. 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. All standard debugging utilities are available including systemctl, journalctl, ss, ip, ping, traceroute, nmap, ps, top, lsof, strace, find, tar, rsync, gdb, and more. Provide each argument as a separate array item (e.g. ['systemctl', 'status', 'kubelet'] or ['journalctl', '-u', 'kubelet', '--since', '1 hour ago']).",
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 registry.access.redhat.com/ubi9/toolbox:latest which provides comprehensive debugging and troubleshooting utilities.",
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",

0 commit comments

Comments
 (0)