Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,12 @@ The following sets of tools are available (all on by default):

<!-- AVAILABLE-TOOLSETS-START -->

| Toolset | Description |
|---------|-------------------------------------------------------------------------------------|
| config | View and manage the current local Kubernetes configuration (kubeconfig) |
| core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) |
| helm | Tools for managing Helm charts and releases |
| Toolset | Description |
|----------------|-------------------------------------------------------------------------------------|
| config | View and manage the current local Kubernetes configuration (kubeconfig) |
| core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) |
| helm | Tools for managing Helm charts and releases |
| openshift-core | Core OpenShift-specific tools (Node debugging, etc.) |

<!-- AVAILABLE-TOOLSETS-END -->

Expand Down Expand Up @@ -336,6 +337,19 @@ In case multi-cluster support is enabled (default) and you have access to multip

</details>

<details>

<summary>openshift-core</summary>

- **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). The host filesystem is mounted at /host, allowing commands to chroot /host if needed to access node-level resources. Output is truncated to the most recent 100 lines, so prefer filters like grep when expecting large logs.
- `command` (`array`) **(required)** - Command to execute on the node. All standard debugging utilities from the UBI9 toolbox are available. The host filesystem is mounted at /host - use 'chroot /host <command>' to access node-level resources, or run commands directly in the toolbox environment. Provide each argument as a separate array item (e.g. ['chroot', '/host', 'systemctl', 'status', 'kubelet'] or ['journalctl', '-u', 'kubelet', '--since', '1 hour ago']).
- `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.
- `namespace` (`string`) - Namespace to create the temporary debug pod in (optional, defaults to the current namespace or 'default').
- `node` (`string`) **(required)** - Name of the node to debug (e.g. worker-0).
- `timeout_seconds` (`integer`) - Maximum time to wait for the command to complete before timing out (optional, defaults to 60 seconds).

</details>


<!-- AVAILABLE-TOOLSETS-TOOLS-END -->

Expand Down
1 change: 1 addition & 0 deletions internal/tools/update-readme/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/openshift"
)

type OpenShift struct{}
Expand Down
3 changes: 2 additions & 1 deletion pkg/config/config_default_overrides.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
func defaultOverrides() StaticConfig {
return StaticConfig{
// IMPORTANT: this file is used to override default config values in downstream builds.
// This is intentionally left blank.
// OpenShift-specific defaults: add openshift-core toolset
Toolsets: []string{"core", "config", "helm", "openshift-core"},
}
}
4 changes: 2 additions & 2 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
s.Equalf("table", config.ListOutput, "Expected ListOutput to be table, got %s", config.ListOutput)
})
s.Run("toolsets defaulted correctly", func() {
s.Require().Lenf(config.Toolsets, 3, "Expected 3 toolsets, got %d", len(config.Toolsets))
for _, toolset := range []string{"core", "config", "helm"} {
s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets))
for _, toolset := range []string{"core", "config", "helm", "openshift-core"} {
s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
}
})
Expand Down
4 changes: 2 additions & 2 deletions pkg/kubernetes-mcp-server/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,15 @@ func TestToolsets(t *testing.T) {
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--help"})
o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout
if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") {
if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm, openshift-core).") {
t.Fatalf("Expected all available toolsets, got %s %v", o, err)
}
})
t.Run("default", func(t *testing.T) {
ioStreams, out := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm") {
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm, openshift-core") {
t.Fatalf("Expected toolsets 'full', got %s %v", out, err)
}
})
Expand Down
253 changes: 253 additions & 0 deletions pkg/mcp/nodes_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
package mcp

import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"testing"

"github.com/BurntSushi/toml"
"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/suite"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
)

type NodesSuite struct {
Expand Down Expand Up @@ -334,3 +341,249 @@ func (s *NodesSuite) TestNodesStatsSummaryDenied() {
func TestNodes(t *testing.T) {
suite.Run(t, new(NodesSuite))
}

// Tests below are for the nodes_debug_exec tool (OpenShift-specific)

type NodesDebugExecSuite struct {
BaseMcpSuite
mockServer *test.MockServer
}

func (s *NodesDebugExecSuite) SetupTest() {
s.BaseMcpSuite.SetupTest()
s.mockServer = test.NewMockServer()
s.Cfg.KubeConfig = s.mockServer.KubeconfigFile(s.T())
}

func (s *NodesDebugExecSuite) TearDownTest() {
s.BaseMcpSuite.TearDownTest()
if s.mockServer != nil {
s.mockServer.Close()
}
}

func (s *NodesDebugExecSuite) TestNodesDebugExecTool() {
s.Run("nodes_debug_exec with successful execution", func() {

var (
createdPod v1.Pod
deleteCalled bool
)
const namespace = "debug"
const logOutput = "filesystem repaired"

scheme := runtime.NewScheme()
_ = v1.AddToScheme(scheme)
codec := serializer.NewCodecFactory(scheme).UniversalDeserializer()

s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case req.URL.Path == "/api":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
case req.URL.Path == "/apis":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
case req.URL.Path == "/api/v1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"Pod","verbs":["get","list","watch","create","update","patch","delete"]}]}`))
case req.Method == http.MethodPatch && strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/"):
// Handle server-side apply (PATCH with fieldManager query param)
body, err := io.ReadAll(req.Body)
if err != nil {
s.T().Fatalf("failed to read apply body: %v", err)
}
created := &v1.Pod{}
if _, _, err = codec.Decode(body, nil, created); err != nil {
s.T().Fatalf("failed to decode apply body: %v", err)
}
createdPod = *created
// Keep the name from the request URL if it was provided
pathParts := strings.Split(req.URL.Path, "/")
if len(pathParts) > 0 {
createdPod.Name = pathParts[len(pathParts)-1]
}
createdPod.Namespace = namespace
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(&createdPod)
case req.Method == http.MethodPost && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods":
body, err := io.ReadAll(req.Body)
if err != nil {
s.T().Fatalf("failed to read create body: %v", err)
}
created := &v1.Pod{}
if _, _, err = codec.Decode(body, nil, created); err != nil {
s.T().Fatalf("failed to decode create body: %v", err)
}
createdPod = *created
createdPod.ObjectMeta = metav1.ObjectMeta{
Namespace: namespace,
Name: createdPod.GenerateName + "abc",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(&createdPod)
case req.Method == http.MethodGet && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name:
podStatus := createdPod.DeepCopy()
podStatus.Status = v1.PodStatus{
Phase: v1.PodSucceeded,
ContainerStatuses: []v1.ContainerStatus{{
Name: "debug",
State: v1.ContainerState{Terminated: &v1.ContainerStateTerminated{
ExitCode: 0,
}},
}},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(podStatus)
case req.Method == http.MethodDelete && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name:
deleteCalled = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(&metav1.Status{Status: "Success"})
case req.Method == http.MethodGet && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name+"/log":
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(logOutput))
}
}))

s.InitMcpClient()
toolResult, err := s.CallTool("nodes_debug_exec", map[string]interface{}{
"node": "worker-0",
"namespace": namespace,
"command": []interface{}{"uname", "-a"},
})

s.Run("call succeeds", func() {
s.Nilf(err, "call tool should not error: %v", err)
s.Falsef(toolResult.IsError, "tool should not return error: %v", toolResult.Content)
s.NotEmpty(toolResult.Content, "expected output content")
text := toolResult.Content[0].(mcp.TextContent).Text
s.Equalf(logOutput, text, "unexpected tool output %q", text)
})

s.Run("debug pod shaped correctly", func() {
s.Require().NotNil(createdPod.Spec.Containers, "expected containers in debug pod")
s.Require().Len(createdPod.Spec.Containers, 1, "expected single container in debug pod")
container := createdPod.Spec.Containers[0]
expectedCommand := []string{"uname", "-a"}
s.Truef(equalStringSlices(container.Command, expectedCommand),
"unexpected debug command: %v", container.Command)
s.Require().NotNil(container.SecurityContext, "expected security context")
s.Require().NotNil(container.SecurityContext.Privileged, "expected privileged field")
s.Truef(*container.SecurityContext.Privileged, "expected privileged container")
s.Require().NotEmpty(createdPod.Spec.Volumes, "expected volumes on debug pod")
s.Require().NotNil(createdPod.Spec.Volumes[0].HostPath, "expected hostPath volume")
s.Truef(deleteCalled, "expected debug pod to be deleted")
})
})
}

func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

func (s *NodesDebugExecSuite) TestNodesDebugExecToolNonZeroExit() {
s.Run("nodes_debug_exec with non-zero exit code", func() {
const namespace = "default"
const errorMessage = "failed"

scheme := runtime.NewScheme()
_ = v1.AddToScheme(scheme)
codec := serializer.NewCodecFactory(scheme).UniversalDeserializer()

s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case req.URL.Path == "/api":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
case req.URL.Path == "/apis":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
case req.URL.Path == "/api/v1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"Pod","verbs":["get","list","watch","create","update","patch","delete"]}]}`))
case req.Method == http.MethodPatch && strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/"):
// Handle server-side apply (PATCH with fieldManager query param)
body, err := io.ReadAll(req.Body)
if err != nil {
s.T().Fatalf("failed to read apply body: %v", err)
}
pod := &v1.Pod{}
if _, _, err = codec.Decode(body, nil, pod); err != nil {
s.T().Fatalf("failed to decode apply body: %v", err)
}
// Keep the name from the request URL if it was provided
pathParts := strings.Split(req.URL.Path, "/")
if len(pathParts) > 0 {
pod.Name = pathParts[len(pathParts)-1]
}
pod.Namespace = namespace
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(pod)
case req.Method == http.MethodPost && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods":
body, err := io.ReadAll(req.Body)
if err != nil {
s.T().Fatalf("failed to read create body: %v", err)
}
pod := &v1.Pod{}
if _, _, err = codec.Decode(body, nil, pod); err != nil {
s.T().Fatalf("failed to decode create body: %v", err)
}
pod.ObjectMeta = metav1.ObjectMeta{Name: pod.GenerateName + "xyz", Namespace: namespace}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(pod)
case strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/") && strings.HasSuffix(req.URL.Path, "/log"):
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(errorMessage))
case req.Method == http.MethodGet && strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/"):
pathParts := strings.Split(req.URL.Path, "/")
podName := pathParts[len(pathParts)-1]
pod := &v1.Pod{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Pod",
},
ObjectMeta: metav1.ObjectMeta{
Name: podName,
Namespace: namespace,
},
}
pod.Status = v1.PodStatus{
Phase: v1.PodSucceeded,
ContainerStatuses: []v1.ContainerStatus{{
Name: "debug",
State: v1.ContainerState{Terminated: &v1.ContainerStateTerminated{
ExitCode: 2,
Reason: "Error",
}},
}},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(pod)
}
}))

s.InitMcpClient()
toolResult, err := s.CallTool("nodes_debug_exec", map[string]interface{}{
"node": "infra-1",
"command": []interface{}{"journalctl"},
})

s.Nilf(err, "call tool should not error: %v", err)
s.Truef(toolResult.IsError, "expected tool to return error")
text := toolResult.Content[0].(mcp.TextContent).Text
s.Containsf(text, "command exited with code 2", "expected exit code message, got %q", text)
s.Containsf(text, "Error", "expected error reason included, got %q", text)
})
}

func TestNodesDebugExec(t *testing.T) {
suite.Run(t, new(NodesDebugExecSuite))
}
3 changes: 3 additions & 0 deletions pkg/mcp/openshift_modules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package mcp

import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/openshift"
Loading