From 738f88a45643a37bc7a0a577b17f61b3b92eddfb Mon Sep 17 00:00:00 2001 From: Neeraj Krishna Gopalakrishna Date: Mon, 3 Nov 2025 09:08:50 +0530 Subject: [PATCH 1/2] Support to put,get and list files on k8s node Signed-off-by: Neeraj Krishna Gopalakrishna --- pkg/kubernetes/nodes.go | 255 ++++++++++++++++ pkg/mcp/nodes_test.go | 280 ++++++++++++++++++ pkg/mcp/testdata/toolsets-core-tools.json | 57 ++++ ...toolsets-full-tools-multicluster-enum.json | 69 ++++- .../toolsets-full-tools-multicluster.json | 65 +++- .../toolsets-full-tools-openshift.json | 61 +++- pkg/mcp/testdata/toolsets-full-tools.json | 61 +++- pkg/toolsets/core/nodes.go | 105 +++++++ 8 files changed, 945 insertions(+), 8 deletions(-) diff --git a/pkg/kubernetes/nodes.go b/pkg/kubernetes/nodes.go index a4321a9f..6c417dce 100644 --- a/pkg/kubernetes/nodes.go +++ b/pkg/kubernetes/nodes.go @@ -4,10 +4,21 @@ import ( "context" "errors" "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/client-go/tools/remotecommand" "k8s.io/metrics/pkg/apis/metrics" metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/version" ) func (k *Kubernetes) NodesLog(ctx context.Context, name string, query string, tailLines int64) (string, error) { @@ -77,3 +88,247 @@ func (k *Kubernetes) NodesTop(ctx context.Context, options NodesTopOptions) (*me } return k.manager.accessControlClientSet.NodesMetricses(ctx, options.Name, options.ListOptions) } + +// NodeFilesOptions contains options for node file operations +type NodeFilesOptions struct { + NodeName string + Operation string // "put", "get", "list" + SourcePath string + DestPath string + Namespace string + Image string + Privileged bool +} + +// NodesFiles handles file operations on a node filesystem by creating a privileged pod +func (k *Kubernetes) NodesFiles(ctx context.Context, opts NodeFilesOptions) (string, error) { + // Set defaults + if opts.Namespace == "" { + opts.Namespace = "default" + } + if opts.Image == "" { + opts.Image = "busybox" + } + + // Create privileged pod for accessing node filesystem + podName := fmt.Sprintf("node-files-%s", rand.String(5)) + pod := &v1.Pod{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"}, + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: opts.Namespace, + Labels: map[string]string{ + AppKubernetesName: podName, + AppKubernetesComponent: "node-files", + AppKubernetesManagedBy: version.BinaryName, + }, + }, + Spec: v1.PodSpec{ + NodeName: opts.NodeName, + RestartPolicy: v1.RestartPolicyNever, + Containers: []v1.Container{{ + Name: "node-files", + Image: opts.Image, + Command: []string{"/bin/sh", "-c", "sleep 3600"}, + SecurityContext: &v1.SecurityContext{ + Privileged: ptr.To(opts.Privileged), + }, + VolumeMounts: []v1.VolumeMount{{ + Name: "node-root", + MountPath: "/host", + }}, + }}, + Volumes: []v1.Volume{{ + Name: "node-root", + VolumeSource: v1.VolumeSource{ + HostPath: &v1.HostPathVolumeSource{ + Path: "/", + }, + }, + }}, + }, + } + + // Create the pod + pods, err := k.manager.accessControlClientSet.Pods(opts.Namespace) + if err != nil { + return "", fmt.Errorf("failed to get pods client: %w", err) + } + + createdPod, err := pods.Create(ctx, pod, metav1.CreateOptions{}) + if err != nil { + return "", fmt.Errorf("failed to create pod: %w", err) + } + + // Ensure pod is deleted after operation + defer func() { + deleteCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = pods.Delete(deleteCtx, podName, metav1.DeleteOptions{}) + }() + + // Wait for pod to be ready + if err := k.waitForPodReady(ctx, opts.Namespace, podName, 2*time.Minute); err != nil { + return "", fmt.Errorf("pod failed to become ready: %w", err) + } + + // Perform the requested operation + var result string + var opErr error + switch opts.Operation { + case "put": + result, opErr = k.nodeFilesPut(ctx, opts.Namespace, podName, opts.SourcePath, opts.DestPath) + case "get": + result, opErr = k.nodeFilesGet(ctx, opts.Namespace, podName, opts.SourcePath, opts.DestPath) + case "list": + result, opErr = k.nodeFilesList(ctx, opts.Namespace, podName, opts.SourcePath) + default: + return "", fmt.Errorf("unknown operation: %s", opts.Operation) + } + + _ = createdPod + return result, opErr +} + +// nodeFilesPut copies a file from local filesystem to node filesystem +func (k *Kubernetes) nodeFilesPut(ctx context.Context, namespace, podName, sourcePath, destPath string) (string, error) { + // Read local file content + content, err := os.ReadFile(sourcePath) + if err != nil { + return "", fmt.Errorf("failed to read source file: %w", err) + } + + // Create destination directory if needed + destDir := filepath.Dir(destPath) + if destDir != "." && destDir != "/" { + mkdirCmd := []string{"/bin/sh", "-c", fmt.Sprintf("mkdir -p /host%s", destDir)} + if _, err := k.execInPod(ctx, namespace, podName, mkdirCmd); err != nil { + return "", fmt.Errorf("failed to create destination directory: %w", err) + } + } + + // Write content using cat command + escapedContent := strings.ReplaceAll(string(content), "'", "'\\''") + writeCmd := []string{"/bin/sh", "-c", fmt.Sprintf("cat > /host%s << 'EOF'\n%s\nEOF", destPath, escapedContent)} + + if _, err := k.execInPod(ctx, namespace, podName, writeCmd); err != nil { + return "", fmt.Errorf("failed to write file to node: %w", err) + } + + return fmt.Sprintf("File successfully copied from %s to node:%s", sourcePath, destPath), nil +} + +// nodeFilesGet copies a file from node filesystem to local filesystem +func (k *Kubernetes) nodeFilesGet(ctx context.Context, namespace, podName, sourcePath, destPath string) (string, error) { + // Read file content from node using cat + readCmd := []string{"/bin/sh", "-c", fmt.Sprintf("cat /host%s", sourcePath)} + content, err := k.execInPod(ctx, namespace, podName, readCmd) + if err != nil { + return "", fmt.Errorf("failed to read file from node: %w", err) + } + + // Determine destination path + if destPath == "" { + destPath = filepath.Base(sourcePath) + } + + // Create local destination directory if needed + destDir := filepath.Dir(destPath) + if destDir != "." && destDir != "" { + if err := os.MkdirAll(destDir, 0755); err != nil { + return "", fmt.Errorf("failed to create local directory: %w", err) + } + } + + // Write to local file + if err := os.WriteFile(destPath, []byte(content), 0644); err != nil { + return "", fmt.Errorf("failed to write local file: %w", err) + } + + return fmt.Sprintf("File successfully copied from node:%s to %s", sourcePath, destPath), nil +} + +// nodeFilesList lists files in a directory on node filesystem +func (k *Kubernetes) nodeFilesList(ctx context.Context, namespace, podName, path string) (string, error) { + // List directory contents using ls + listCmd := []string{"/bin/sh", "-c", fmt.Sprintf("ls -la /host%s", path)} + output, err := k.execInPod(ctx, namespace, podName, listCmd) + if err != nil { + return "", fmt.Errorf("failed to list directory: %w", err) + } + + return output, nil +} + +// execInPod executes a command in the pod and returns the output +func (k *Kubernetes) execInPod(ctx context.Context, namespace, podName string, command []string) (string, error) { + podExecOptions := &v1.PodExecOptions{ + Container: "node-files", + Command: command, + Stdout: true, + Stderr: true, + } + + executor, err := k.manager.accessControlClientSet.PodsExec(namespace, podName, podExecOptions) + if err != nil { + return "", err + } + + stdout := &strings.Builder{} + stderr := &strings.Builder{} + + if err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdout: stdout, + Stderr: stderr, + Tty: false, + }); err != nil { + if stderr.Len() > 0 { + return "", fmt.Errorf("exec error: %s: %w", stderr.String(), err) + } + return "", err + } + + if stderr.Len() > 0 && stdout.Len() == 0 { + return stderr.String(), nil + } + + return stdout.String(), nil +} + +// waitForPodReady waits for a pod to be ready +func (k *Kubernetes) waitForPodReady(ctx context.Context, namespace, podName string, timeout time.Duration) error { + pods, err := k.manager.accessControlClientSet.Pods(namespace) + if err != nil { + return err + } + + deadline := time.Now().Add(timeout) + for { + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for pod to be ready") + } + + pod, err := pods.Get(ctx, podName, metav1.GetOptions{}) + if err != nil { + return err + } + + // Check if pod is ready + if pod.Status.Phase == v1.PodRunning { + for _, condition := range pod.Status.Conditions { + if condition.Type == v1.PodReady && condition.Status == v1.ConditionTrue { + return nil + } + } + } + + if pod.Status.Phase == v1.PodFailed { + return fmt.Errorf("pod failed") + } + + time.Sleep(2 * time.Second) + } +} + +// Ensure io package is used (if not already imported elsewhere) +var _ = io.Copy diff --git a/pkg/mcp/nodes_test.go b/pkg/mcp/nodes_test.go index 62ac55e9..2404fdc4 100644 --- a/pkg/mcp/nodes_test.go +++ b/pkg/mcp/nodes_test.go @@ -331,6 +331,286 @@ func (s *NodesSuite) TestNodesStatsSummaryDenied() { }) } +func (s *NodesSuite) TestNodeFiles() { + // Setup test files and directories + s.T().Run("prepare test environment", func(t *testing.T) { + // This ensures we have a node in the cluster for testing + s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Get Node response + if req.URL.Path == "/api/v1/nodes/test-node" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "apiVersion": "v1", + "kind": "Node", + "metadata": { + "name": "test-node" + } + }`)) + return + } + // Handle pod creation + if req.URL.Path == "/api/v1/namespaces/default/pods" && req.Method == "POST" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "node-files-test", + "namespace": "default" + }, + "status": { + "phase": "Running", + "conditions": [{ + "type": "Ready", + "status": "True" + }] + } + }`)) + return + } + // Handle pod get (for wait) + if req.URL.Path == "/api/v1/namespaces/default/pods/node-files-test" && req.Method == "GET" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "node-files-test", + "namespace": "default" + }, + "status": { + "phase": "Running", + "conditions": [{ + "type": "Ready", + "status": "True" + }] + } + }`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + }) + + s.InitMcpClient() + + // Test missing node_name parameter + s.Run("node_files(node_name=nil)", func() { + toolResult, err := s.CallTool("node_files", map[string]interface{}{ + "operation": "list", + "source_path": "/tmp", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes missing node_name", func() { + expectedMessage := "missing required argument: node_name" + s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) + + // Test missing operation parameter + s.Run("node_files(operation=nil)", func() { + toolResult, err := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "source_path": "/tmp", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes missing operation", func() { + expectedMessage := "missing required argument: operation" + s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) + + // Test missing source_path parameter + s.Run("node_files(source_path=nil)", func() { + toolResult, err := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes missing source_path", func() { + expectedMessage := "missing required argument: source_path" + s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) + + // Test invalid operation + s.Run("node_files(operation=invalid)", func() { + toolResult, err := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "invalid", + "source_path": "/tmp", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes invalid operation", func() { + content := toolResult.Content[0].(mcp.TextContent).Text + s.Containsf(content, "failed to perform node file operation", "expected error to mention failed operation, got %v", content) + }) + }) + + // Test with non-existent node + s.Run("node_files(node_name=non-existent-node)", func() { + toolResult, err := s.CallTool("node_files", map[string]interface{}{ + "node_name": "non-existent-node", + "operation": "list", + "source_path": "/tmp", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes missing node", func() { + content := toolResult.Content[0].(mcp.TextContent).Text + s.Containsf(content, "failed to perform node file operation", "expected error to mention failed operation, got %v", content) + }) + }) + + // Test with default namespace and image + s.Run("node_files with defaults", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + "source_path": "/tmp", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // Note: This will fail in the mock environment, but we're testing parameter handling + s.Run("attempts operation", func() { + // The tool should attempt the operation even if it fails in mock environment + s.NotNil(toolResult, "toolResult should not be nil") + }) + }) + + // Test with custom namespace + s.Run("node_files with custom namespace", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + "source_path": "/tmp", + "namespace": "custom-ns", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // The operation will fail in mock environment, but we're verifying parameters are passed + }) + + // Test with custom image + s.Run("node_files with custom image", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + "source_path": "/tmp", + "image": "alpine", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // The operation will fail in mock environment, but we're verifying parameters are passed + s.NotNil(toolResult) + }) + + // Test with privileged=false + s.Run("node_files with privileged=false", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + "source_path": "/tmp", + "privileged": false, + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // The operation will fail in mock environment, but we're verifying parameters are passed + s.NotNil(toolResult) + }) + + // Test list operation + s.Run("node_files operation=list", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + "source_path": "/proc", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // Will fail in mock environment but tests the operation type + }) + + // Test get operation + s.Run("node_files operation=get", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "get", + "source_path": "/proc/cpuinfo", + "dest_path": "/tmp/cpuinfo", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // Will fail in mock environment but tests the operation type + }) + + // Test get operation without dest_path + s.Run("node_files operation=get without dest_path", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "get", + "source_path": "/proc/meminfo", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // Will fail in mock environment but tests the operation type + }) + + // Test put operation + s.Run("node_files operation=put", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "put", + "source_path": "/tmp/local-file", + "dest_path": "/tmp/node-file", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // Will fail in mock environment but tests the operation type + }) +} + +func (s *NodesSuite) TestNodeFilesDenied() { + s.Require().NoError(toml.Unmarshal([]byte(` + denied_resources = [ { version = "v1", kind = "Pod" } ] + `), s.Cfg), "Expected to parse denied resources config") + s.InitMcpClient() + s.Run("node_files (denied)", func() { + toolResult, err := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + "source_path": "/tmp", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes denial", func() { + expectedMessage := "failed to perform node file operation: resource not allowed: /v1, Kind=Pod" + s.Containsf(toolResult.Content[0].(mcp.TextContent).Text, "resource not allowed", + "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) +} + func TestNodes(t *testing.T) { suite.Run(t, new(NodesSuite)) } diff --git a/pkg/mcp/testdata/toolsets-core-tools.json b/pkg/mcp/testdata/toolsets-core-tools.json index e8753758..cd2b1252 100644 --- a/pkg/mcp/testdata/toolsets-core-tools.json +++ b/pkg/mcp/testdata/toolsets-core-tools.json @@ -33,6 +33,63 @@ }, "name": "namespaces_list" }, + { + "annotations": { + "title": "Node: Files", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Perform file operations (put, get, list) on a Kubernetes node filesystem by creating a privileged pod. WARNING: Requires privileged access to the node. This tool creates a temporary privileged pod that mounts the node's root filesystem to perform file operations. The pod is automatically deleted after the operation completes.", + "inputSchema": { + "type": "object", + "properties": { + "dest_path": { + "description": "Destination path for the operation. For 'put': node file path. For 'get': local file path (optional, defaults to current directory). Not used for 'list'.", + "type": "string" + }, + "image": { + "default": "busybox", + "description": "Container image to use for the privileged pod (optional, defaults to 'busybox')", + "type": "string" + }, + "namespace": { + "default": "default", + "description": "Namespace to create the temporary pod in (optional, defaults to 'default')", + "type": "string" + }, + "node_name": { + "description": "Name of the node to access", + "type": "string" + }, + "operation": { + "description": "Operation to perform: 'put' (copy from local to node), 'get' (copy from node to local), or 'list' (list files in a directory)", + "enum": [ + "put", + "get", + "list" + ], + "type": "string" + }, + "privileged": { + "default": true, + "description": "Whether to run the container as privileged. Required for accessing node files. Set to false only if your use case doesn't require privileged access (default: true)", + "type": "boolean" + }, + "source_path": { + "description": "Source path for the operation. For 'put': local file path. For 'get': node file path. For 'list': node directory path", + "type": "string" + } + }, + "required": [ + "node_name", + "operation", + "source_path" + ] + }, + "name": "node_files" + }, { "annotations": { "title": "Node: Log", diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json index 08181078..6a6e0e0d 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json @@ -195,6 +195,71 @@ }, "name": "namespaces_list" }, + { + "annotations": { + "title": "Node: Files", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Perform file operations (put, get, list) on a Kubernetes node filesystem by creating a privileged pod. WARNING: Requires privileged access to the node. This tool creates a temporary privileged pod that mounts the node's root filesystem to perform file operations. The pod is automatically deleted after the operation completes.", + "inputSchema": { + "type": "object", + "properties": { + "context": { + "description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set", + "enum": [ + "extra-cluster", + "fake-context" + ], + "type": "string" + }, + "dest_path": { + "description": "Destination path for the operation. For 'put': node file path. For 'get': local file path (optional, defaults to current directory). Not used for 'list'.", + "type": "string" + }, + "image": { + "default": "busybox", + "description": "Container image to use for the privileged pod (optional, defaults to 'busybox')", + "type": "string" + }, + "namespace": { + "default": "default", + "description": "Namespace to create the temporary pod in (optional, defaults to 'default')", + "type": "string" + }, + "node_name": { + "description": "Name of the node to access", + "type": "string" + }, + "operation": { + "description": "Operation to perform: 'put' (copy from local to node), 'get' (copy from node to local), or 'list' (list files in a directory)", + "enum": [ + "put", + "get", + "list" + ], + "type": "string" + }, + "privileged": { + "default": true, + "description": "Whether to run the container as privileged. Required for accessing node files. Set to false only if your use case doesn't require privileged access (default: true)", + "type": "boolean" + }, + "source_path": { + "description": "Source path for the operation. For 'put': local file path. For 'get': node file path. For 'list': node directory path", + "type": "string" + } + }, + "required": [ + "node_name", + "operation", + "source_path" + ] + }, + "name": "node_files" + }, { "annotations": { "title": "Node: Log", @@ -220,7 +285,7 @@ "type": "string" }, "query": { - "description": "query specifies services(s) or files from which to return logs (required). Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"/var/log/kubelet.log\" or \"/var/log/kube-proxy.log\")", + "description": "query specifies services(s) or files from which to return logs (required). Example: \"kubelet\" to fetch kubelet logs, \"/\" to fetch a specific log file from the node (e.g., \"/var/log/kubelet.log\" or \"/var/log/kube-proxy.log\")", "type": "string" }, "tailLines": { @@ -783,4 +848,4 @@ }, "name": "resources_list" } -] +] \ No newline at end of file diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json index 74a48d56..03bf42b9 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json @@ -175,6 +175,67 @@ }, "name": "namespaces_list" }, + { + "annotations": { + "title": "Node: Files", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Perform file operations (put, get, list) on a Kubernetes node filesystem by creating a privileged pod. WARNING: Requires privileged access to the node. This tool creates a temporary privileged pod that mounts the node's root filesystem to perform file operations. The pod is automatically deleted after the operation completes.", + "inputSchema": { + "type": "object", + "properties": { + "context": { + "description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set", + "type": "string" + }, + "dest_path": { + "description": "Destination path for the operation. For 'put': node file path. For 'get': local file path (optional, defaults to current directory). Not used for 'list'.", + "type": "string" + }, + "image": { + "default": "busybox", + "description": "Container image to use for the privileged pod (optional, defaults to 'busybox')", + "type": "string" + }, + "namespace": { + "default": "default", + "description": "Namespace to create the temporary pod in (optional, defaults to 'default')", + "type": "string" + }, + "node_name": { + "description": "Name of the node to access", + "type": "string" + }, + "operation": { + "description": "Operation to perform: 'put' (copy from local to node), 'get' (copy from node to local), or 'list' (list files in a directory)", + "enum": [ + "put", + "get", + "list" + ], + "type": "string" + }, + "privileged": { + "default": true, + "description": "Whether to run the container as privileged. Required for accessing node files. Set to false only if your use case doesn't require privileged access (default: true)", + "type": "boolean" + }, + "source_path": { + "description": "Source path for the operation. For 'put': local file path. For 'get': node file path. For 'list': node directory path", + "type": "string" + } + }, + "required": [ + "node_name", + "operation", + "source_path" + ] + }, + "name": "node_files" + }, { "annotations": { "title": "Node: Log", @@ -196,7 +257,7 @@ "type": "string" }, "query": { - "description": "query specifies services(s) or files from which to return logs (required). Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"/var/log/kubelet.log\" or \"/var/log/kube-proxy.log\")", + "description": "query specifies services(s) or files from which to return logs (required). Example: \"kubelet\" to fetch kubelet logs, \"/\" to fetch a specific log file from the node (e.g., \"/var/log/kubelet.log\" or \"/var/log/kube-proxy.log\")", "type": "string" }, "tailLines": { @@ -703,4 +764,4 @@ }, "name": "resources_list" } -] +] \ No newline at end of file diff --git a/pkg/mcp/testdata/toolsets-full-tools-openshift.json b/pkg/mcp/testdata/toolsets-full-tools-openshift.json index 041c8671..27835ccb 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-openshift.json +++ b/pkg/mcp/testdata/toolsets-full-tools-openshift.json @@ -139,6 +139,63 @@ }, "name": "namespaces_list" }, + { + "annotations": { + "title": "Node: Files", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Perform file operations (put, get, list) on a Kubernetes node filesystem by creating a privileged pod. WARNING: Requires privileged access to the node. This tool creates a temporary privileged pod that mounts the node's root filesystem to perform file operations. The pod is automatically deleted after the operation completes.", + "inputSchema": { + "type": "object", + "properties": { + "dest_path": { + "description": "Destination path for the operation. For 'put': node file path. For 'get': local file path (optional, defaults to current directory). Not used for 'list'.", + "type": "string" + }, + "image": { + "default": "busybox", + "description": "Container image to use for the privileged pod (optional, defaults to 'busybox')", + "type": "string" + }, + "namespace": { + "default": "default", + "description": "Namespace to create the temporary pod in (optional, defaults to 'default')", + "type": "string" + }, + "node_name": { + "description": "Name of the node to access", + "type": "string" + }, + "operation": { + "description": "Operation to perform: 'put' (copy from local to node), 'get' (copy from node to local), or 'list' (list files in a directory)", + "enum": [ + "put", + "get", + "list" + ], + "type": "string" + }, + "privileged": { + "default": true, + "description": "Whether to run the container as privileged. Required for accessing node files. Set to false only if your use case doesn't require privileged access (default: true)", + "type": "boolean" + }, + "source_path": { + "description": "Source path for the operation. For 'put': local file path. For 'get': node file path. For 'list': node directory path", + "type": "string" + } + }, + "required": [ + "node_name", + "operation", + "source_path" + ] + }, + "name": "node_files" + }, { "annotations": { "title": "Node: Log", @@ -156,7 +213,7 @@ "type": "string" }, "query": { - "description": "query specifies services(s) or files from which to return logs (required). Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"/var/log/kubelet.log\" or \"/var/log/kube-proxy.log\")", + "description": "query specifies services(s) or files from which to return logs (required). Example: \"kubelet\" to fetch kubelet logs, \"/\" to fetch a specific log file from the node (e.g., \"/var/log/kubelet.log\" or \"/var/log/kube-proxy.log\")", "type": "string" }, "tailLines": { @@ -621,4 +678,4 @@ }, "name": "resources_list" } -] +] \ No newline at end of file diff --git a/pkg/mcp/testdata/toolsets-full-tools.json b/pkg/mcp/testdata/toolsets-full-tools.json index 2f314aec..3c8074ef 100644 --- a/pkg/mcp/testdata/toolsets-full-tools.json +++ b/pkg/mcp/testdata/toolsets-full-tools.json @@ -139,6 +139,63 @@ }, "name": "namespaces_list" }, + { + "annotations": { + "title": "Node: Files", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Perform file operations (put, get, list) on a Kubernetes node filesystem by creating a privileged pod. WARNING: Requires privileged access to the node. This tool creates a temporary privileged pod that mounts the node's root filesystem to perform file operations. The pod is automatically deleted after the operation completes.", + "inputSchema": { + "type": "object", + "properties": { + "dest_path": { + "description": "Destination path for the operation. For 'put': node file path. For 'get': local file path (optional, defaults to current directory). Not used for 'list'.", + "type": "string" + }, + "image": { + "default": "busybox", + "description": "Container image to use for the privileged pod (optional, defaults to 'busybox')", + "type": "string" + }, + "namespace": { + "default": "default", + "description": "Namespace to create the temporary pod in (optional, defaults to 'default')", + "type": "string" + }, + "node_name": { + "description": "Name of the node to access", + "type": "string" + }, + "operation": { + "description": "Operation to perform: 'put' (copy from local to node), 'get' (copy from node to local), or 'list' (list files in a directory)", + "enum": [ + "put", + "get", + "list" + ], + "type": "string" + }, + "privileged": { + "default": true, + "description": "Whether to run the container as privileged. Required for accessing node files. Set to false only if your use case doesn't require privileged access (default: true)", + "type": "boolean" + }, + "source_path": { + "description": "Source path for the operation. For 'put': local file path. For 'get': node file path. For 'list': node directory path", + "type": "string" + } + }, + "required": [ + "node_name", + "operation", + "source_path" + ] + }, + "name": "node_files" + }, { "annotations": { "title": "Node: Log", @@ -156,7 +213,7 @@ "type": "string" }, "query": { - "description": "query specifies services(s) or files from which to return logs (required). Example: \"kubelet\" to fetch kubelet logs, \"/\u003clog-file-name\u003e\" to fetch a specific log file from the node (e.g., \"/var/log/kubelet.log\" or \"/var/log/kube-proxy.log\")", + "description": "query specifies services(s) or files from which to return logs (required). Example: \"kubelet\" to fetch kubelet logs, \"/\" to fetch a specific log file from the node (e.g., \"/var/log/kubelet.log\" or \"/var/log/kube-proxy.log\")", "type": "string" }, "tailLines": { @@ -607,4 +664,4 @@ }, "name": "resources_list" } -] +] \ No newline at end of file diff --git a/pkg/toolsets/core/nodes.go b/pkg/toolsets/core/nodes.go index 04798d0d..9dc51430 100644 --- a/pkg/toolsets/core/nodes.go +++ b/pkg/toolsets/core/nodes.go @@ -95,6 +95,55 @@ func initNodes() []api.ServerTool { OpenWorldHint: ptr.To(true), }, }, Handler: nodesTop}, + {Tool: api.Tool{ + Name: "node_files", + Description: "Perform file operations (put, get, list) on a Kubernetes node filesystem by creating a privileged pod. WARNING: Requires privileged access to the node. This tool creates a temporary privileged pod that mounts the node's root filesystem to perform file operations. The pod is automatically deleted after the operation completes.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "node_name": { + Type: "string", + Description: "Name of the node to access", + }, + "operation": { + Type: "string", + Description: "Operation to perform: 'put' (copy from local to node), 'get' (copy from node to local), or 'list' (list files in a directory)", + Enum: []any{"put", "get", "list"}, + }, + "source_path": { + Type: "string", + Description: "Source path for the operation. For 'put': local file path. For 'get': node file path. For 'list': node directory path", + }, + "dest_path": { + Type: "string", + Description: "Destination path for the operation. For 'put': node file path. For 'get': local file path (optional, defaults to current directory). Not used for 'list'.", + }, + "namespace": { + Type: "string", + Description: "Namespace to create the temporary pod in (optional, defaults to 'default')", + Default: api.ToRawMessage("default"), + }, + "image": { + Type: "string", + Description: "Container image to use for the privileged pod (optional, defaults to 'busybox')", + Default: api.ToRawMessage("busybox"), + }, + "privileged": { + Type: "boolean", + Description: "Whether to run the container as privileged. Required for accessing node files. Set to false only if your use case doesn't require privileged access (default: true)", + Default: api.ToRawMessage(true), + }, + }, + Required: []string{"node_name", "operation", "source_path"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Node: Files", + ReadOnlyHint: ptr.To(false), + DestructiveHint: ptr.To(true), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, Handler: nodeFiles}, } } @@ -191,3 +240,59 @@ func nodesTop(params api.ToolHandlerParams) (*api.ToolCallResult, error) { return api.NewToolCallResult(buf.String(), nil), nil } + +func nodeFiles(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + args := params.GetArguments() + + // Extract required parameters + nodeName, ok := args["node_name"].(string) + if !ok || nodeName == "" { + return api.NewToolCallResult("", errors.New("missing required argument: node_name")), nil + } + + operation, ok := args["operation"].(string) + if !ok || operation == "" { + return api.NewToolCallResult("", errors.New("missing required argument: operation")), nil + } + + sourcePath, ok := args["source_path"].(string) + if !ok || sourcePath == "" { + return api.NewToolCallResult("", errors.New("missing required argument: source_path")), nil + } + + // Extract optional parameters with defaults + destPath, _ := args["dest_path"].(string) + namespace, _ := args["namespace"].(string) + if namespace == "" { + namespace = "default" + } + + image, _ := args["image"].(string) + if image == "" { + image = "busybox" + } + + privileged := true + if privArg, ok := args["privileged"].(bool); ok { + privileged = privArg + } + + // Create NodeFilesOptions + opts := kubernetes.NodeFilesOptions{ + NodeName: nodeName, + Operation: operation, + SourcePath: sourcePath, + DestPath: destPath, + Namespace: namespace, + Image: image, + Privileged: privileged, + } + + // Call the NodesFiles function + ret, err := params.NodesFiles(params.Context, opts) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to perform node file operation: %v", err)), nil + } + + return api.NewToolCallResult(ret, nil), nil +} From eb6eb1ce0d3935fe218cb3381f75ebe586e3c967 Mon Sep 17 00:00:00 2001 From: Neeraj Krishna Gopalakrishna Date: Wed, 5 Nov 2025 08:24:11 +0530 Subject: [PATCH 2/2] Rebase node_files and move the test to different file Signed-off-by: Neeraj Krishna Gopalakrishna --- pkg/mcp/nodes_files_test.go | 321 ++++++++++++++++++++++++++++++++++++ pkg/mcp/nodes_test.go | 280 ------------------------------- 2 files changed, 321 insertions(+), 280 deletions(-) create mode 100644 pkg/mcp/nodes_files_test.go diff --git a/pkg/mcp/nodes_files_test.go b/pkg/mcp/nodes_files_test.go new file mode 100644 index 00000000..f2dc09b7 --- /dev/null +++ b/pkg/mcp/nodes_files_test.go @@ -0,0 +1,321 @@ +package mcp + +import ( + "net/http" + "testing" + + "github.com/BurntSushi/toml" + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/suite" +) + +type NodeFilesSuite struct { + BaseMcpSuite + mockServer *test.MockServer +} + +func (s *NodeFilesSuite) SetupTest() { + s.BaseMcpSuite.SetupTest() + s.mockServer = test.NewMockServer() + s.Cfg.KubeConfig = s.mockServer.KubeconfigFile(s.T()) + s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-) + if req.URL.Path == "/api" { + _, _ = w.Write([]byte(`{"kind":"APIVersions","versions":[],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`)) + return + } + })) +} + +func (s *NodeFilesSuite) TearDownTest() { + s.BaseMcpSuite.TearDownTest() + if s.mockServer != nil { + s.mockServer.Close() + } +} + +func (s *NodeFilesSuite) TestNodeFiles() { + // Setup test files and directories + s.T().Run("prepare test environment", func(t *testing.T) { + // This ensures we have a node in the cluster for testing + s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Get Node response + if req.URL.Path == "/api/v1/nodes/test-node" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "apiVersion": "v1", + "kind": "Node", + "metadata": { + "name": "test-node" + } + }`)) + return + } + // Handle pod creation + if req.URL.Path == "/api/v1/namespaces/default/pods" && req.Method == "POST" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "node-files-test", + "namespace": "default" + }, + "status": { + "phase": "Running", + "conditions": [{ + "type": "Ready", + "status": "True" + }] + } + }`)) + return + } + // Handle pod get (for wait) + if req.URL.Path == "/api/v1/namespaces/default/pods/node-files-test" && req.Method == "GET" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "node-files-test", + "namespace": "default" + }, + "status": { + "phase": "Running", + "conditions": [{ + "type": "Ready", + "status": "True" + }] + } + }`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + }) + + s.InitMcpClient() + + // Test missing node_name parameter + s.Run("node_files(node_name=nil)", func() { + toolResult, err := s.CallTool("node_files", map[string]interface{}{ + "operation": "list", + "source_path": "/tmp", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes missing node_name", func() { + expectedMessage := "missing required argument: node_name" + s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) + + // Test missing operation parameter + s.Run("node_files(operation=nil)", func() { + toolResult, err := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "source_path": "/tmp", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes missing operation", func() { + expectedMessage := "missing required argument: operation" + s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) + + // Test missing source_path parameter + s.Run("node_files(source_path=nil)", func() { + toolResult, err := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes missing source_path", func() { + expectedMessage := "missing required argument: source_path" + s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) + + // Test invalid operation + s.Run("node_files(operation=invalid)", func() { + toolResult, err := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "invalid", + "source_path": "/tmp", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes invalid operation", func() { + content := toolResult.Content[0].(mcp.TextContent).Text + s.Containsf(content, "failed to perform node file operation", "expected error to mention failed operation, got %v", content) + }) + }) + + // Test with non-existent node + s.Run("node_files(node_name=non-existent-node)", func() { + toolResult, err := s.CallTool("node_files", map[string]interface{}{ + "node_name": "non-existent-node", + "operation": "list", + "source_path": "/tmp", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes missing node", func() { + content := toolResult.Content[0].(mcp.TextContent).Text + s.Containsf(content, "failed to perform node file operation", "expected error to mention failed operation, got %v", content) + }) + }) + + // Test with default namespace and image + s.Run("node_files with defaults", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + "source_path": "/tmp", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // Note: This will fail in the mock environment, but we're testing parameter handling + s.Run("attempts operation", func() { + // The tool should attempt the operation even if it fails in mock environment + s.NotNil(toolResult, "toolResult should not be nil") + }) + }) + + // Test with custom namespace + s.Run("node_files with custom namespace", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + "source_path": "/tmp", + "namespace": "custom-ns", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // The operation will fail in mock environment, but we're verifying parameters are passed + }) + + // Test with custom image + s.Run("node_files with custom image", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + "source_path": "/tmp", + "image": "alpine", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // The operation will fail in mock environment, but we're verifying parameters are passed + s.NotNil(toolResult) + }) + + // Test with privileged=false + s.Run("node_files with privileged=false", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + "source_path": "/tmp", + "privileged": false, + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // The operation will fail in mock environment, but we're verifying parameters are passed + s.NotNil(toolResult) + }) + + // Test list operation + s.Run("node_files operation=list", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + "source_path": "/proc", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // Will fail in mock environment but tests the operation type + }) + + // Test get operation + s.Run("node_files operation=get", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "get", + "source_path": "/proc/cpuinfo", + "dest_path": "/tmp/cpuinfo", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // Will fail in mock environment but tests the operation type + }) + + // Test get operation without dest_path + s.Run("node_files operation=get without dest_path", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "get", + "source_path": "/proc/meminfo", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // Will fail in mock environment but tests the operation type + }) + + // Test put operation + s.Run("node_files operation=put", func() { + toolResult, _ := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "put", + "source_path": "/tmp/local-file", + "dest_path": "/tmp/node-file", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + // Will fail in mock environment but tests the operation type + }) +} + +func (s *NodeFilesSuite) TestNodeFilesDenied() { + s.Require().NoError(toml.Unmarshal([]byte(` + denied_resources = [ { version = "v1", kind = "Pod" } ] + `), s.Cfg), "Expected to parse denied resources config") + s.InitMcpClient() + s.Run("node_files (denied)", func() { + toolResult, err := s.CallTool("node_files", map[string]interface{}{ + "node_name": "test-node", + "operation": "list", + "source_path": "/tmp", + }) + s.Require().NotNil(toolResult, "toolResult should not be nil") + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes denial", func() { + expectedMessage := "failed to perform node file operation: resource not allowed: /v1, Kind=Pod" + s.Containsf(toolResult.Content[0].(mcp.TextContent).Text, "resource not allowed", + "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) +} + +func TestNodeFiles(t *testing.T) { + suite.Run(t, new(NodeFilesSuite)) +} diff --git a/pkg/mcp/nodes_test.go b/pkg/mcp/nodes_test.go index 2404fdc4..62ac55e9 100644 --- a/pkg/mcp/nodes_test.go +++ b/pkg/mcp/nodes_test.go @@ -331,286 +331,6 @@ func (s *NodesSuite) TestNodesStatsSummaryDenied() { }) } -func (s *NodesSuite) TestNodeFiles() { - // Setup test files and directories - s.T().Run("prepare test environment", func(t *testing.T) { - // This ensures we have a node in the cluster for testing - s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - // Get Node response - if req.URL.Path == "/api/v1/nodes/test-node" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{ - "apiVersion": "v1", - "kind": "Node", - "metadata": { - "name": "test-node" - } - }`)) - return - } - // Handle pod creation - if req.URL.Path == "/api/v1/namespaces/default/pods" && req.Method == "POST" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{ - "apiVersion": "v1", - "kind": "Pod", - "metadata": { - "name": "node-files-test", - "namespace": "default" - }, - "status": { - "phase": "Running", - "conditions": [{ - "type": "Ready", - "status": "True" - }] - } - }`)) - return - } - // Handle pod get (for wait) - if req.URL.Path == "/api/v1/namespaces/default/pods/node-files-test" && req.Method == "GET" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{ - "apiVersion": "v1", - "kind": "Pod", - "metadata": { - "name": "node-files-test", - "namespace": "default" - }, - "status": { - "phase": "Running", - "conditions": [{ - "type": "Ready", - "status": "True" - }] - } - }`)) - return - } - w.WriteHeader(http.StatusNotFound) - })) - }) - - s.InitMcpClient() - - // Test missing node_name parameter - s.Run("node_files(node_name=nil)", func() { - toolResult, err := s.CallTool("node_files", map[string]interface{}{ - "operation": "list", - "source_path": "/tmp", - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - s.Run("has error", func() { - s.Truef(toolResult.IsError, "call tool should fail") - s.Nilf(err, "call tool should not return error object") - }) - s.Run("describes missing node_name", func() { - expectedMessage := "missing required argument: node_name" - s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, - "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) - }) - }) - - // Test missing operation parameter - s.Run("node_files(operation=nil)", func() { - toolResult, err := s.CallTool("node_files", map[string]interface{}{ - "node_name": "test-node", - "source_path": "/tmp", - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - s.Run("has error", func() { - s.Truef(toolResult.IsError, "call tool should fail") - s.Nilf(err, "call tool should not return error object") - }) - s.Run("describes missing operation", func() { - expectedMessage := "missing required argument: operation" - s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, - "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) - }) - }) - - // Test missing source_path parameter - s.Run("node_files(source_path=nil)", func() { - toolResult, err := s.CallTool("node_files", map[string]interface{}{ - "node_name": "test-node", - "operation": "list", - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - s.Run("has error", func() { - s.Truef(toolResult.IsError, "call tool should fail") - s.Nilf(err, "call tool should not return error object") - }) - s.Run("describes missing source_path", func() { - expectedMessage := "missing required argument: source_path" - s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, - "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) - }) - }) - - // Test invalid operation - s.Run("node_files(operation=invalid)", func() { - toolResult, err := s.CallTool("node_files", map[string]interface{}{ - "node_name": "test-node", - "operation": "invalid", - "source_path": "/tmp", - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - s.Run("has error", func() { - s.Truef(toolResult.IsError, "call tool should fail") - s.Nilf(err, "call tool should not return error object") - }) - s.Run("describes invalid operation", func() { - content := toolResult.Content[0].(mcp.TextContent).Text - s.Containsf(content, "failed to perform node file operation", "expected error to mention failed operation, got %v", content) - }) - }) - - // Test with non-existent node - s.Run("node_files(node_name=non-existent-node)", func() { - toolResult, err := s.CallTool("node_files", map[string]interface{}{ - "node_name": "non-existent-node", - "operation": "list", - "source_path": "/tmp", - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - s.Run("has error", func() { - s.Truef(toolResult.IsError, "call tool should fail") - s.Nilf(err, "call tool should not return error object") - }) - s.Run("describes missing node", func() { - content := toolResult.Content[0].(mcp.TextContent).Text - s.Containsf(content, "failed to perform node file operation", "expected error to mention failed operation, got %v", content) - }) - }) - - // Test with default namespace and image - s.Run("node_files with defaults", func() { - toolResult, _ := s.CallTool("node_files", map[string]interface{}{ - "node_name": "test-node", - "operation": "list", - "source_path": "/tmp", - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - // Note: This will fail in the mock environment, but we're testing parameter handling - s.Run("attempts operation", func() { - // The tool should attempt the operation even if it fails in mock environment - s.NotNil(toolResult, "toolResult should not be nil") - }) - }) - - // Test with custom namespace - s.Run("node_files with custom namespace", func() { - toolResult, _ := s.CallTool("node_files", map[string]interface{}{ - "node_name": "test-node", - "operation": "list", - "source_path": "/tmp", - "namespace": "custom-ns", - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - // The operation will fail in mock environment, but we're verifying parameters are passed - }) - - // Test with custom image - s.Run("node_files with custom image", func() { - toolResult, _ := s.CallTool("node_files", map[string]interface{}{ - "node_name": "test-node", - "operation": "list", - "source_path": "/tmp", - "image": "alpine", - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - // The operation will fail in mock environment, but we're verifying parameters are passed - s.NotNil(toolResult) - }) - - // Test with privileged=false - s.Run("node_files with privileged=false", func() { - toolResult, _ := s.CallTool("node_files", map[string]interface{}{ - "node_name": "test-node", - "operation": "list", - "source_path": "/tmp", - "privileged": false, - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - // The operation will fail in mock environment, but we're verifying parameters are passed - s.NotNil(toolResult) - }) - - // Test list operation - s.Run("node_files operation=list", func() { - toolResult, _ := s.CallTool("node_files", map[string]interface{}{ - "node_name": "test-node", - "operation": "list", - "source_path": "/proc", - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - // Will fail in mock environment but tests the operation type - }) - - // Test get operation - s.Run("node_files operation=get", func() { - toolResult, _ := s.CallTool("node_files", map[string]interface{}{ - "node_name": "test-node", - "operation": "get", - "source_path": "/proc/cpuinfo", - "dest_path": "/tmp/cpuinfo", - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - // Will fail in mock environment but tests the operation type - }) - - // Test get operation without dest_path - s.Run("node_files operation=get without dest_path", func() { - toolResult, _ := s.CallTool("node_files", map[string]interface{}{ - "node_name": "test-node", - "operation": "get", - "source_path": "/proc/meminfo", - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - // Will fail in mock environment but tests the operation type - }) - - // Test put operation - s.Run("node_files operation=put", func() { - toolResult, _ := s.CallTool("node_files", map[string]interface{}{ - "node_name": "test-node", - "operation": "put", - "source_path": "/tmp/local-file", - "dest_path": "/tmp/node-file", - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - // Will fail in mock environment but tests the operation type - }) -} - -func (s *NodesSuite) TestNodeFilesDenied() { - s.Require().NoError(toml.Unmarshal([]byte(` - denied_resources = [ { version = "v1", kind = "Pod" } ] - `), s.Cfg), "Expected to parse denied resources config") - s.InitMcpClient() - s.Run("node_files (denied)", func() { - toolResult, err := s.CallTool("node_files", map[string]interface{}{ - "node_name": "test-node", - "operation": "list", - "source_path": "/tmp", - }) - s.Require().NotNil(toolResult, "toolResult should not be nil") - s.Run("has error", func() { - s.Truef(toolResult.IsError, "call tool should fail") - s.Nilf(err, "call tool should not return error object") - }) - s.Run("describes denial", func() { - expectedMessage := "failed to perform node file operation: resource not allowed: /v1, Kind=Pod" - s.Containsf(toolResult.Content[0].(mcp.TextContent).Text, "resource not allowed", - "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) - }) - }) -} - func TestNodes(t *testing.T) { suite.Run(t, new(NodesSuite)) }