diff --git a/src/index.ts b/src/index.ts index 5205e7c..c38be48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,7 @@ import { deletePod, deletePodSchema } from "./tools/delete_pod.js"; import { describePod, describePodSchema } from "./tools/describe_pod.js"; import { getLogs, getLogsSchema } from "./tools/get_logs.js"; import { getEvents, getEventsSchema } from "./tools/get_events.js"; +import { execInPod, execInPodSchema } from "./tools/exec_in_pod.js"; import { getResourceHandlers } from "./resources/handlers.js"; import { ListResourcesRequestSchema, @@ -165,6 +166,7 @@ const allTools = [ DeleteCronJobSchema, CreateConfigMapSchema, updateServiceSchema, + execInPodSchema, ]; const k8sManager = new KubernetesManager(); @@ -664,6 +666,18 @@ server.setRequestHandler( ); } + case "exec_in_pod": { + return await execInPod( + k8sManager, + input as { + name: string; + namespace?: string; + command: string | string[]; + container?: string; + } + ); + } + default: throw new McpError(ErrorCode.InvalidRequest, `Unknown tool: ${name}`); } diff --git a/src/models/response-schemas.ts b/src/models/response-schemas.ts index c0f08df..d76b10c 100644 --- a/src/models/response-schemas.ts +++ b/src/models/response-schemas.ts @@ -160,3 +160,7 @@ export const SetCurrentContextResponseSchema = z.object({ export const DescribeNodeResponseSchema = z.object({ content: z.array(ToolResponseContent), }); + +export const ExecInPodResponseSchema = z.object({ + content: z.array(ToolResponseContent), +}); diff --git a/src/tools/exec_in_pod.ts b/src/tools/exec_in_pod.ts new file mode 100644 index 0000000..6a9a754 --- /dev/null +++ b/src/tools/exec_in_pod.ts @@ -0,0 +1,203 @@ +/** + * Tool: exec_in_pod + * Execute a command in a Kubernetes pod or container and return the output. + * Uses the official Kubernetes client-node Exec API for native execution. + * Supports both string and array command formats, and optional container targeting. + */ + +import * as k8s from "@kubernetes/client-node"; +import { KubernetesManager } from "../types.js"; +import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { Writable } from "stream"; + +/** + * Schema for exec_in_pod tool. + * - name: Pod name + * - namespace: Namespace (default: "default") + * - command: Command to execute (string or array of args) + * - container: (Optional) Container name + */ +export const execInPodSchema = { + name: "exec_in_pod", + description: "Execute a command in a Kubernetes pod or container and return the output", + inputSchema: { + type: "object", + properties: { + name: { + type: "string", + description: "Name of the pod to execute the command in", + }, + namespace: { + type: "string", + description: "Kubernetes namespace where the pod is located", + default: "default", + }, + command: { + anyOf: [ + { type: "string" }, + { type: "array", items: { type: "string" } } + ], + description: "Command to execute in the pod (string or array of args)", + }, + container: { + type: "string", + description: "Container name (required when pod has multiple containers)", + optional: true, + }, + shell: { + type: "string", + description: "Shell to use for command execution (e.g. '/bin/sh', '/bin/bash'). If not provided, will use command as-is.", + optional: true, + }, + timeout: { + type: "number", + description: "Timeout for command - 60000 milliseconds if not specified", + optional: true, + }, + }, + required: ["name", "command"], + }, +}; + +/** + * Execute a command in a Kubernetes pod or container using the Kubernetes client-node Exec API. + * Returns the stdout output as a text response. + * Throws McpError on failure. + */ +export async function execInPod( + k8sManager: KubernetesManager, + input: { + name: string; + namespace?: string; + command: string | string[]; + container?: string; + shell?: string; + timeout?: number; + } +): Promise<{ content: { type: string; text: string }[] }> { + const namespace = input.namespace || "default"; + // Convert command to array of strings for the Exec API + let commandArr: string[]; + if (Array.isArray(input.command)) { + commandArr = input.command; + } else { + // Always wrap string commands in a shell for correct parsing + const shell = input.shell || "/bin/sh"; + commandArr = [shell, "-c", input.command]; + console.log("[exec_in_pod] Using shell:", shell, "Command array:", commandArr); + } + + // Prepare buffers to capture stdout and stderr + let stdout = ""; + let stderr = ""; + + // Use Node.js Writable streams to collect output + const stdoutStream = new Writable({ + write(chunk, _encoding, callback) { + stdout += chunk.toString(); + callback(); + } + }); + const stderrStream = new Writable({ + write(chunk, _encoding, callback) { + stderr += chunk.toString(); + callback(); + } + }); + // Add a dummy stdin stream + const stdinStream = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + } + }); + + try { + // Use the Kubernetes client-node Exec API for native exec + const kc = k8sManager.getKubeConfig(); + const exec = new k8s.Exec(kc); + + // Add a timeout to avoid hanging forever if exec never returns + await new Promise((resolve, reject) => { + let finished = false; + const timeoutMs = input.timeout || 60000; + const timeout = setTimeout(() => { + if (!finished) { + finished = true; + reject( + new McpError( + ErrorCode.InternalError, + "Exec operation timed out (possible networking, RBAC, or cluster issue)" + ) + ); + } + }, timeoutMs); + + console.log("[exec_in_pod] Calling exec.exec with params:", { + namespace, + pod: input.name, + container: input.container ?? "", + commandArr, + stdoutStreamType: typeof stdoutStream, + stderrStreamType: typeof stderrStream, + }); + + exec.exec( + namespace, + input.name, + input.container ?? "", + commandArr, + stdoutStream as any, + stderrStream as any, + stdinStream as any, // use dummy stdin + true, // set tty to true + (status: any) => { + console.log("[exec_in_pod] exec.exec callback called. Status:", status); + if (finished) return; + finished = true; + clearTimeout(timeout); + // Always resolve; handle errors based on stderr or thrown errors + resolve(); + } + ).catch((err: any) => { + console.log("[exec_in_pod] exec.exec threw error:", err); + if (!finished) { + finished = true; + clearTimeout(timeout); + reject( + new McpError( + ErrorCode.InternalError, + `Exec threw error: ${err?.message || err}` + ) + ); + } + }); + }); + + // Return the collected stdout as the result + // If there is stderr output or no output at all, treat as error + if (stderr || (!stdout && !stderr)) { + throw new McpError( + ErrorCode.InternalError, + `Failed to execute command in pod: ${stderr || "No output"}` + ); + } + return { + content: [ + { + type: "text", + text: stdout, + }, + ], + }; + } catch (error: any) { + // Collect error message and stderr output if available + let message = error.message || "Unknown error"; + if (stderr) { + message += "\n" + stderr; + } + throw new McpError( + ErrorCode.InternalError, + `Failed to execute command in pod: ${message}` + ); + } +} diff --git a/tests/exec_in_pod.test.ts b/tests/exec_in_pod.test.ts new file mode 100644 index 0000000..a8d9c06 --- /dev/null +++ b/tests/exec_in_pod.test.ts @@ -0,0 +1,244 @@ +import { describe, test, expect, vi } from "vitest"; +import { execInPodSchema, execInPod } from "../src/tools/exec_in_pod.js"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; + +describe("exec_in_pod tool", () => { + // Test the schema definition + test("schema is properly defined", () => { + expect(execInPodSchema).toBeDefined(); + expect(execInPodSchema.name).toBe("exec_in_pod"); + expect(execInPodSchema.description).toContain("Execute a command in a Kubernetes pod"); + + // Check input schema + expect(execInPodSchema.inputSchema).toBeDefined(); + expect(execInPodSchema.inputSchema.properties).toBeDefined(); + + // Check required properties + expect(execInPodSchema.inputSchema.required).toContain("name"); + expect(execInPodSchema.inputSchema.required).toContain("command"); + + // Check for our newly added properties + expect(execInPodSchema.inputSchema.properties.shell).toBeDefined(); + expect(execInPodSchema.inputSchema.properties.shell.description).toContain("Shell to use"); + expect(execInPodSchema.inputSchema.properties.shell.optional).toBe(true); + + expect(execInPodSchema.inputSchema.properties.timeout).toBeDefined(); + expect(execInPodSchema.inputSchema.properties.timeout.description).toContain("Timeout for command"); + expect(execInPodSchema.inputSchema.properties.timeout.type).toBe("number"); + expect(execInPodSchema.inputSchema.properties.timeout.optional).toBe(true); + + // Check command can be string or array + expect(execInPodSchema.inputSchema.properties.command.anyOf).toHaveLength(2); + expect(execInPodSchema.inputSchema.properties.command.anyOf[0].type).toBe("string"); + expect(execInPodSchema.inputSchema.properties.command.anyOf[1].type).toBe("array"); + }); + + // Test parameter handling - equivalent to kubectl exec command string handling + describe("command handling", () => { + // Simple test to verify command string/array handling + test("command parameter can be string or array", () => { + // Test string command - should wrap in shell (kubectl exec pod-name -- echo hello) + let commandArr = Array.isArray("echo hello") + ? "echo hello" + : ["/bin/sh", "-c", "echo hello"]; + expect(commandArr).toEqual(["/bin/sh", "-c", "echo hello"]); + + // Test array command - should pass through as-is (kubectl exec pod-name -- echo hello) + commandArr = Array.isArray(["echo", "hello"]) + ? ["echo", "hello"] + : ["/bin/sh", "-c", ["echo", "hello"].join(" ")]; + expect(commandArr).toEqual(["echo", "hello"]); + }); + + // Test complex commands + test("handles complex command strings", () => { + // Test command with quotes (kubectl exec pod-name -- sh -c 'echo "hello world"') + let command = 'echo "hello world"'; + let commandArr = ["/bin/sh", "-c", command]; + expect(commandArr).toEqual(["/bin/sh", "-c", 'echo "hello world"']); + + // Test command with pipe (kubectl exec pod-name -- sh -c 'ls | grep file') + command = "ls | grep file"; + commandArr = ["/bin/sh", "-c", command]; + expect(commandArr).toEqual(["/bin/sh", "-c", "ls | grep file"]); + + // Test command with multiple statements (kubectl exec pod-name -- sh -c 'cd /tmp && ls') + command = "cd /tmp && ls"; + commandArr = ["/bin/sh", "-c", command]; + expect(commandArr).toEqual(["/bin/sh", "-c", "cd /tmp && ls"]); + }); + }); + + // Test shell parameter handling + describe("shell parameter", () => { + test("shell parameter changes default shell", () => { + // Test with default shell (kubectl exec pod-name -- sh -c 'command') + let shell: string | undefined = undefined; + let commandArr = [shell || "/bin/sh", "-c", "echo hello"]; + expect(commandArr).toEqual(["/bin/sh", "-c", "echo hello"]); + + // Test with bash shell (kubectl exec pod-name -- bash -c 'command') + shell = "/bin/bash"; + commandArr = [shell || "/bin/sh", "-c", "echo hello"]; + expect(commandArr).toEqual(["/bin/bash", "-c", "echo hello"]); + + // Test with zsh shell (kubectl exec pod-name -- zsh -c 'command') + shell = "/bin/zsh"; + commandArr = [shell || "/bin/sh", "-c", "echo hello"]; + expect(commandArr).toEqual(["/bin/zsh", "-c", "echo hello"]); + }); + + test("shell parameter not used with array commands", () => { + // Array commands should pass through regardless of shell + const command = ["echo", "hello"]; + const shell = "/bin/bash"; + + // With array commands, the shell should be ignored + if (Array.isArray(command)) { + expect(command).toEqual(["echo", "hello"]); + } else { + const shellCmd = [shell || "/bin/sh", "-c", command]; + expect(shellCmd).toEqual(["/bin/bash", "-c", "command-that-should-not-be-used"]); + } + }); + }); + + // Test timeout parameter + describe("timeout parameter", () => { + test("timeout parameter changes default timeout", () => { + // Function to simulate how timeout is used in execInPod + function getTimeoutValue(inputTimeout: number | undefined): number { + return inputTimeout !== undefined ? inputTimeout : 60000; + } + + // Test with default timeout (kubectl exec has no built-in timeout) + let timeout: number | undefined = undefined; + let timeoutMs = getTimeoutValue(timeout); + expect(timeoutMs).toBe(60000); + + // Test with custom timeout + timeout = 30000; + timeoutMs = getTimeoutValue(timeout); + expect(timeoutMs).toBe(30000); + + // Test with zero timeout (should be honored, not use default) + timeout = 0; + timeoutMs = getTimeoutValue(timeout); + expect(timeoutMs).toBe(0); + }); + + test("timeout value represents milliseconds", () => { + // Convert common timeouts to human-readable form + function formatTimeout(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${ms/1000} seconds`; + return `${ms/60000} minutes`; + } + + // Default timeout is 1 minute + expect(formatTimeout(60000)).toBe("1 minutes"); + + // 30 second timeout + expect(formatTimeout(30000)).toBe("30 seconds"); + + // 5 minute timeout + expect(formatTimeout(300000)).toBe("5 minutes"); + }); + }); + + // Test container parameter + describe("container parameter", () => { + test("container parameter sets target container", () => { + // Function to simulate how container param is used in kubectl exec + function buildExecCommand(podName: string, containerName?: string, command?: string[]): string { + let cmd = `kubectl exec ${podName}`; + if (containerName) { + cmd += ` -c ${containerName}`; + } + if (command) { + cmd += ` -- ${command.join(" ")}`; + } + return cmd; + } + + // Test without container (kubectl exec pod-name -- command) + let execCmd = buildExecCommand("test-pod", undefined, ["echo", "hello"]); + expect(execCmd).toBe("kubectl exec test-pod -- echo hello"); + + // Test with container (kubectl exec -c container-name pod-name -- command) + execCmd = buildExecCommand("test-pod", "main-container", ["echo", "hello"]); + expect(execCmd).toBe("kubectl exec test-pod -c main-container -- echo hello"); + }); + }); + + // Test namespace parameter + describe("namespace parameter", () => { + test("namespace parameter sets target namespace", () => { + // Function to simulate how namespace param is used in kubectl exec + function buildExecCommand(podName: string, namespace?: string, containerName?: string): string { + let cmd = `kubectl exec ${podName}`; + if (namespace) { + cmd += ` -n ${namespace}`; + } + if (containerName) { + cmd += ` -c ${containerName}`; + } + return cmd + " -- command"; + } + + // Test with default namespace (kubectl exec pod-name -- command) + let execCmd = buildExecCommand("test-pod"); + expect(execCmd).toBe("kubectl exec test-pod -- command"); + + // Test with custom namespace (kubectl exec -n custom-ns pod-name -- command) + execCmd = buildExecCommand("test-pod", "custom-ns"); + expect(execCmd).toBe("kubectl exec test-pod -n custom-ns -- command"); + + // Test with namespace and container + execCmd = buildExecCommand("test-pod", "custom-ns", "main-container"); + expect(execCmd).toBe("kubectl exec test-pod -n custom-ns -c main-container -- command"); + }); + }); + + // Test error handling + describe("error handling", () => { + test("handles stderr output", () => { + // Simulate stderr output in execInPod + function processExecOutput(stdout: string, stderr: string): { success: boolean, message?: string, output?: string } { + if (stderr) { + return { + success: false, + message: `Failed to execute command in pod: ${stderr}` + }; + } + + if (!stdout && !stderr) { + return { + success: false, + message: "Failed to execute command in pod: No output" + }; + } + + return { + success: true, + output: stdout + }; + } + + // Test successful execution + let result = processExecOutput("command output", ""); + expect(result.success).toBe(true); + expect(result.output).toBe("command output"); + + // Test stderr error + result = processExecOutput("", "command not found"); + expect(result.success).toBe(false); + expect(result.message).toContain("command not found"); + + // Test no output + result = processExecOutput("", ""); + expect(result.success).toBe(false); + expect(result.message).toContain("No output"); + }); + }); +});