diff --git a/.gitignore b/.gitignore index 8f5147b..c696be6 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,7 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# Ignore editor-specific directories and files +.cursor/ +.vscode/ diff --git a/ADVANCED_README.md b/ADVANCED_README.md index 5259bea..08003ee 100644 --- a/ADVANCED_README.md +++ b/ADVANCED_README.md @@ -200,7 +200,83 @@ Complete Example Assuming your image name is flux159/mcp-server-kubernetes and you need to map ports and set environment parameters, you can run: ```shell -docker run --rm -it -p 3001:3001 -e ENABLE_UNSAFE_SSE_TRANSPORT=1 -e PORT=3001 -v ~/.kube/config:/home/appuser/.kube/config flux159/mcp-server-kubernetes +docker run --rm -it -p 3001:3001 -e ENABLE_UNSAFE_SSE_TRANSPORT=1 -e PORT=3001 -v ~/.kube/config:/home/appuser/.kube/config flux159/mcp-server-kubernetes:latest +``` + +## Advance Docker Usage + +### Connect to AWS EKS Cluster + +```json +{ + "mcpServers": { + "kubernetes": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-v", + "~/.kube:/home/appuser/.kube:ro", + "-v", + "~/.aws:/home/appuser/.aws:ro", + "-e", + "AWS_PROFILE=default", + "-e", + "AWS_REGION=us-west-2", + "flux159/mcp-server-kubernetes:latest" + ] + } + } +} +``` + +### Connect to Google GKE Clusters + +```json +{ + "mcpServers": { + "kubernetes": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-v", + "~/.kube:/home/appuser/.kube:ro", + "-v", + "~/.config/gcloud:/home/appuser/.config/gcloud:ro", + "-e", + "CLOUDSDK_CORE_PROJECT=my-gcp-project", + "-e", + "CLOUDSDK_COMPUTE_REGION=us-central1", + "flux159/mcp-server-kubernetes:latest" + ] + } + } +} +``` + +### Connect to Azure AKS Clusters + +```json +{ + "mcpServers": { + "kubernetes": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-v", + "~/.kube:/home/appuser/.kube:ro", + "-e", + "AZURE_SUBSCRIPTION=my-subscription-id", + "flux159/mcp-server-kubernetes:latest" + ] + } + } +} ``` ⚠️ Key safety considerations diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..18462de --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Build and Development + +- `bun run build` - Compile TypeScript to dist/ and make executables +- `bun run dev` - Start TypeScript compiler in watch mode for development +- `bun run start` - Run the compiled server from dist/index.js +- `bun run test` - Run all tests using Vitest + +### Testing and Quality + +- `bun run test` - Execute the complete test suite with custom sequencer (kubectl tests run last) +- Tests have 120s timeout and 60s hook timeout due to Kubernetes operations +- Use `npx @modelcontextprotocol/inspector node dist/index.js` for local testing with Inspector +- Always run single test based on with area you are working on. running all tests will take a long time. + +### Local Development Testing + +- `bun run chat` - Test locally with mcp-chat CLI client +- For Claude Desktop testing, point to local `dist/index.js` build + +## Architecture Overview + +This is an MCP (Model Context Protocol) server that provides Kubernetes cluster management capabilities. The server connects to Kubernetes clusters via kubectl and offers both read-only and destructive operations. + +### Core Components + +**KubernetesManager** (`src/utils/kubernetes-manager.ts`): Central class managing Kubernetes API connections, resource tracking, port forwards, and watches. Handles kubeconfig loading from multiple sources in priority order. + +**Tool Structure**: Each Kubernetes operation is implemented as a separate tool in `src/tools/`, with corresponding Zod schemas for validation. Tools are divided into: + +- kubectl operations (get, describe, apply, delete, create, etc.) +- Helm operations (install, upgrade, uninstall charts) +- Specialized operations (port forwarding, scaling, rollouts) + +**Resource Handlers** (`src/resources/handlers.ts`): Manage MCP resource endpoints for dynamic data retrieval. + +**Configuration System** (`src/config/`): Contains schemas and templates for deployments, namespaces, containers, and cleanup operations. + +### Key Architecture Patterns + +- **Tool Filtering**: Non-destructive mode dynamically removes destructive tools based on `ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS` environment variable +- **Unified kubectl API**: Consistent interface across all kubectl operations with standardized error handling +- **Resource Tracking**: All created resources are tracked for cleanup capabilities +- **Transport Flexibility**: Supports both StdioTransport and SSE transport for different integration scenarios + +### Request Flow + +1. Client sends MCP request via transport layer +2. Server filters available tools based on destructive/non-destructive mode +3. Request routed to appropriate handler (tools/resources) +4. KubernetesManager executes Kubernetes API calls +5. Responses formatted and returned through transport + +## Development Guidelines + +### Adding New Tools + +- Create new tool file in `src/tools/` with Zod schema export +- Import and register in `src/index.ts` main server setup +- Add to destructive/non-destructive filtering logic as appropriate +- Include comprehensive error handling for Kubernetes API failures + +### Testing Strategy + +- Unit tests focus on tool functionality and schema validation +- Integration tests verify actual Kubernetes operations +- Custom test sequencer ensures kubectl tests run last (they modify cluster state) +- Tests require active Kubernetes cluster connection + +### Configuration Handling + +- Server loads kubeconfig from multiple sources: KUBECONFIG_YAML env var, KUBECONFIG path, or ~/.kube/config +- Supports multiple kubectl contexts with context switching capabilities +- Environment variables control server behavior (non-destructive mode, custom kubeconfig paths) + +## Kubernetes Integration Details + +The server requires: + +- kubectl installed and accessible in PATH +- Valid kubeconfig with configured contexts +- Active Kubernetes cluster connection +- Helm v3 for chart operations (optional) + +**Non-destructive mode** disables: kubectl_delete, uninstall_helm_chart, cleanup operations, and kubectl_generic (which could contain destructive commands). diff --git a/README.md b/README.md index b73aef3..9f7feae 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ npx mcp-chat --config "%APPDATA%\Claude\claude_desktop_config.json" - [x] Unified kubectl API for managing resources - Get or list resources with `kubectl_get` - Describe resources with `kubectl_describe` - - List resources with `kubectl_list` + - List resources with `kubectl_get` - Create resources with `kubectl_create` - Apply YAML manifests with `kubectl_apply` - Delete resources with `kubectl_delete` @@ -89,15 +89,15 @@ npx mcp-chat --config "%APPDATA%\Claude\claude_desktop_config.json" - Run Helm operations - Install, upgrade, and uninstall charts - Support for custom values, repositories, and versions -- [x] Troubleshooting Prompt (`k8s-troubleshoot`) +- [x] Troubleshooting Prompt (`k8s-diagnose`) - Guides through a systematic Kubernetes troubleshooting flow for pods based on a keyword and optional namespace. - [x] Non-destructive mode for read and create/update-only access to clusters ## Prompts -The MCP Kubernetes server includes specialized prompts to assist with common operations. +The MCP Kubernetes server includes specialized prompts to assist with common diagnostic operations. -### k8s-troubleshoot Prompt +### k8s-diagnose Prompt This prompt provides a systematic troubleshooting flow for Kubernetes pods. It accepts a `keyword` to identify relevant pods and an optional `namespace` to narrow the search. The prompt's output will guide you through an autonomous troubleshooting flow, providing instructions for identifying issues, collecting evidence, and suggesting remediation steps. @@ -192,7 +192,7 @@ For Claude Desktop configuration with non-destructive mode: All read-only and resource creation/update operations remain available: -- Resource Information: `kubectl_get`, `kubectl_describe`, `kubectl_list`, `kubectl_logs`, `explain_resource`, `list_api_resources` +- Resource Information: `kubectl_get`, `kubectl_describe`, `kubectl_logs`, `explain_resource`, `list_api_resources` - Resource Creation/Modification: `kubectl_apply`, `kubectl_create`, `kubectl_scale`, `kubectl_patch`, `kubectl_rollout` - Helm Operations: `install_helm_chart`, `upgrade_helm_chart` - Connectivity: `port_forward`, `stop_port_forward` diff --git a/src/index.ts b/src/index.ts index 5cea24d..1cdd4d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { listApiResources, listApiResourcesSchema, } from "./tools/kubectl-operations.js"; +import { execInPod, execInPodSchema } from "./tools/exec_in_pod.js"; import { getResourceHandlers } from "./resources/handlers.js"; import { ListResourcesRequestSchema, @@ -44,7 +45,6 @@ import { kubectlDescribe, kubectlDescribeSchema, } from "./tools/kubectl-describe.js"; -import { kubectlList, kubectlListSchema } from "./tools/kubectl-list.js"; import { kubectlApply, kubectlApplySchema } from "./tools/kubectl-apply.js"; import { kubectlDelete, kubectlDeleteSchema } from "./tools/kubectl-delete.js"; import { kubectlCreate, kubectlCreateSchema } from "./tools/kubectl-create.js"; @@ -80,7 +80,6 @@ const allTools = [ // Unified kubectl-style tools - these replace many specific tools kubectlGetSchema, kubectlDescribeSchema, - kubectlListSchema, kubectlApplySchema, kubectlDeleteSchema, kubectlCreateSchema, @@ -103,10 +102,11 @@ const allTools = [ // Port forwarding PortForwardSchema, StopPortForwardSchema, + execInPodSchema, + // API resource operations listApiResourcesSchema, - // Generic kubectl command kubectlGenericSchema, ]; @@ -187,6 +187,7 @@ server.setRequestHandler( allNamespaces?: boolean; labelSelector?: string; fieldSelector?: string; + sortBy?: string; } ); } @@ -203,20 +204,6 @@ server.setRequestHandler( ); } - if (name === "kubectl_list") { - return await kubectlList( - k8sManager, - input as { - resourceType: string; - namespace?: string; - output?: string; - allNamespaces?: boolean; - labelSelector?: string; - fieldSelector?: string; - } - ); - } - if (name === "kubectl_apply") { return await kubectlApply( k8sManager, @@ -451,6 +438,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/prompts/index.ts b/src/prompts/index.ts index 1d48ed1..d38de69 100644 --- a/src/prompts/index.ts +++ b/src/prompts/index.ts @@ -1,9 +1,6 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { KubernetesManager } from "../types.js"; import { ListPromptsRequestSchema, GetPromptRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import { kubectlList } from "../tools/kubectl-list.js"; -import { kubectlDescribe } from "../tools/kubectl-describe.js"; -import { kubectlLogs } from "../tools/kubectl-logs.js"; export function registerPromptHandlers(server: Server, k8sManager: KubernetesManager) { // Register prompts list handler @@ -11,8 +8,8 @@ export function registerPromptHandlers(server: Server, k8sManager: KubernetesMan return { prompts: [ { - name: "k8s-troubleshoot", - description: "Troubleshoot Kubernetes Resources.", + name: "k8s-diagnose", + description: "Diagnose Kubernetes Resources.", arguments: [ { name: "keyword", @@ -35,18 +32,18 @@ export function registerPromptHandlers(server: Server, k8sManager: KubernetesMan server.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name, arguments: args } = request.params; - if (name === "k8s-troubleshoot") { + if (name === "k8s-diagnose") { const keyword = args?.keyword as string; const namespace = args?.namespace as string; if (!keyword) { - throw new Error("Keyword parameter is required for k8s-troubleshoot prompt"); + throw new Error("Keyword parameter is required for k8s-diagnose prompt"); } const actualNamespace = namespace || "all"; - const message = `Troubleshooting for resources (pods, nodes, etc.) containing keyword "${keyword}" in their names within namespace "${actualNamespace}" (or across all namespaces if specified) for this investigation: + const message = `Diagnose Kubernetes resources (pods, nodes, etc.) containing keyword "${keyword}" in their names within namespace "${actualNamespace}" (or across all namespaces if specified) for this investigation: -**Autonomous Kubernetes Troubleshooting Flow** +**Autonomous Kubernetes Diagnosis Flow** 0. **Perform Quick Health Checks / Golden Signals Analysis** - Assess latency, errors, and resource utilization. If a clear issue is identified (e.g., node not ready, network partition), streamline or deprioritize subsequent detailed steps. 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/src/tools/kubectl-list.ts b/src/tools/kubectl-list.ts deleted file mode 100644 index f3b1e90..0000000 --- a/src/tools/kubectl-list.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { KubernetesManager } from "../types.js"; -import { kubectlGet } from "./kubectl-get.js"; -import { execSync } from "child_process"; -import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; - -export const kubectlListSchema = { - name: "kubectl_list", - description: "List Kubernetes resources by resource type and optionally namespace", - inputSchema: { - type: "object", - properties: { - resourceType: { - type: "string", - description: "Type of resource to list (e.g., pods, deployments, services, configmaps, etc.)" - }, - namespace: { - type: "string", - description: "Namespace of the resources (optional - defaults to 'default' for namespaced resources)", - default: "default" - }, - output: { - type: "string", - enum: ["json", "yaml", "wide", "name", "custom", "formatted"], - description: "Output format - 'formatted' uses a resource-specific format with key information", - default: "formatted" - }, - allNamespaces: { - type: "boolean", - description: "If true, list resources across all namespaces", - default: false - }, - labelSelector: { - type: "string", - description: "Filter resources by label selector (e.g. 'app=nginx')" - }, - fieldSelector: { - type: "string", - description: "Filter resources by field selector (e.g. 'metadata.name=my-pod')" - } - }, - required: ["resourceType", "namespace"], - }, -} as const; - -export async function kubectlList( - k8sManager: KubernetesManager, - input: { - resourceType: string; - namespace?: string; - output?: string; - allNamespaces?: boolean; - labelSelector?: string; - fieldSelector?: string; - } -) { - try { - const resourceType = input.resourceType.toLowerCase(); - const namespace = input.namespace || "default"; - const output = input.output || "formatted"; - const allNamespaces = input.allNamespaces || false; - const labelSelector = input.labelSelector || ""; - const fieldSelector = input.fieldSelector || ""; - - // If not using formatted output, delegate to kubectl_get - if (output !== "formatted") { - return await kubectlGet(k8sManager, { - resourceType: input.resourceType, - namespace: input.namespace, - output: input.output, - allNamespaces: input.allNamespaces, - labelSelector: input.labelSelector, - fieldSelector: input.fieldSelector - }); - } - - // For formatted output, we'll use resource-specific custom columns - let customColumns = ""; - - switch (resourceType) { - case "pods": - case "pod": - case "po": - customColumns = "NAME:.metadata.name,NAMESPACE:.metadata.namespace,STATUS:.status.phase,NODE:.spec.nodeName,IP:.status.podIP,AGE:.metadata.creationTimestamp"; - break; - - case "deployments": - case "deployment": - case "deploy": - customColumns = "NAME:.metadata.name,NAMESPACE:.metadata.namespace,READY:.status.readyReplicas/.status.replicas,UP-TO-DATE:.status.updatedReplicas,AVAILABLE:.status.availableReplicas,AGE:.metadata.creationTimestamp"; - break; - - case "services": - case "service": - case "svc": - customColumns = "NAME:.metadata.name,NAMESPACE:.metadata.namespace,TYPE:.spec.type,CLUSTER-IP:.spec.clusterIP,EXTERNAL-IP:.status.loadBalancer.ingress[0].ip,PORTS:.spec.ports[*].port,AGE:.metadata.creationTimestamp"; - break; - - case "nodes": - case "node": - case "no": - customColumns = "NAME:.metadata.name,STATUS:.status.conditions[?(@.type==\"Ready\")].status,ROLES:.metadata.labels.kubernetes\\.io/role,VERSION:.status.nodeInfo.kubeletVersion,INTERNAL-IP:.status.addresses[?(@.type==\"InternalIP\")].address,OS-IMAGE:.status.nodeInfo.osImage,KERNEL-VERSION:.status.nodeInfo.kernelVersion,CONTAINER-RUNTIME:.status.nodeInfo.containerRuntimeVersion"; - break; - - case "namespaces": - case "namespace": - case "ns": - customColumns = "NAME:.metadata.name,STATUS:.status.phase,AGE:.metadata.creationTimestamp"; - break; - - case "persistentvolumes": - case "pv": - customColumns = "NAME:.metadata.name,CAPACITY:.spec.capacity.storage,ACCESS_MODES:.spec.accessModes,RECLAIM_POLICY:.spec.persistentVolumeReclaimPolicy,STATUS:.status.phase,CLAIM:.spec.claimRef.name,STORAGECLASS:.spec.storageClassName,AGE:.metadata.creationTimestamp"; - break; - - case "persistentvolumeclaims": - case "pvc": - customColumns = "NAME:.metadata.name,NAMESPACE:.metadata.namespace,STATUS:.status.phase,VOLUME:.spec.volumeName,CAPACITY:.status.capacity.storage,ACCESS_MODES:.spec.accessModes,STORAGECLASS:.spec.storageClassName,AGE:.metadata.creationTimestamp"; - break; - - case "configmaps": - case "configmap": - case "cm": - customColumns = "NAME:.metadata.name,NAMESPACE:.metadata.namespace,DATA:.data,AGE:.metadata.creationTimestamp"; - break; - - case "secrets": - case "secret": - customColumns = "NAME:.metadata.name,NAMESPACE:.metadata.namespace,TYPE:.type,DATA:.data,AGE:.metadata.creationTimestamp"; - break; - - case "jobs": - case "job": - customColumns = "NAME:.metadata.name,NAMESPACE:.metadata.namespace,COMPLETIONS:.status.succeeded/.spec.completions,DURATION:.status.completionTime-(.status.startTime),AGE:.metadata.creationTimestamp"; - break; - - case "cronjobs": - case "cronjob": - case "cj": - customColumns = "NAME:.metadata.name,NAMESPACE:.metadata.namespace,SCHEDULE:.spec.schedule,SUSPEND:.spec.suspend,ACTIVE:.status.active,LAST_SCHEDULE:.status.lastScheduleTime,AGE:.metadata.creationTimestamp"; - break; - - default: - // For unknown resource types, fall back to a generic format - customColumns = "NAME:.metadata.name,NAMESPACE:.metadata.namespace,KIND:.kind,AGE:.metadata.creationTimestamp"; - break; - } - - // Build the kubectl command - let command = "kubectl get "; - - // Add resource type - command += resourceType; - - // Add namespace flag unless all namespaces is specified - if (allNamespaces) { - command += " --all-namespaces"; - } else if (namespace && !isNonNamespacedResource(resourceType)) { - command += ` -n ${namespace}`; - } - - // Add label selector if provided - if (labelSelector) { - command += ` -l ${labelSelector}`; - } - - // Add field selector if provided - if (fieldSelector) { - command += ` --field-selector=${fieldSelector}`; - } - - // Add custom columns format - command += ` -o custom-columns="${customColumns}"`; - - // Execute the command - try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - - return { - content: [ - { - type: "text", - text: result, - }, - ], - }; - } catch (error: any) { - if (error.status === 404 || error.message.includes("not found")) { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - error: `Resource type ${resourceType} not found or no resources exist`, - status: "not_found", - }, - null, - 2 - ), - }, - ], - isError: true, - }; - } - - throw new McpError( - ErrorCode.InternalError, - `Failed to list resources: ${error.message}` - ); - } - } catch (error: any) { - throw new McpError( - ErrorCode.InternalError, - `Failed to execute kubectl list command: ${error.message}` - ); - } -} - -// Helper function to determine if a resource is non-namespaced -function isNonNamespacedResource(resourceType: string): boolean { - const nonNamespacedResources = [ - "nodes", "node", "no", - "namespaces", "namespace", "ns", - "persistentvolumes", "pv", - "storageclasses", "sc", - "clusterroles", - "clusterrolebindings", - "customresourcedefinitions", "crd", "crds" - ]; - - return nonNamespacedResources.includes(resourceType.toLowerCase()); -} diff --git a/tests/cronjob.test.ts b/tests/cronjob.test.ts index 0064449..83a6f5e 100644 --- a/tests/cronjob.test.ts +++ b/tests/cronjob.test.ts @@ -127,12 +127,12 @@ describe("kubernetes cronjob operations with kubectl commands", () => { * Test case: Verify CronJob listing functionality */ test("list cronjobs in namespace", async () => { - // List CronJobs using kubectl_list + // List CronJobs using kubectl_get const listResult = await client.request( { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { resourceType: "cronjobs", namespace: testNamespace, @@ -308,7 +308,7 @@ spec: { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { resourceType: "jobs", namespace: testNamespace, diff --git a/tests/exec_in_pod.test.ts b/tests/exec_in_pod.test.ts new file mode 100644 index 0000000..2bc1ec1 --- /dev/null +++ b/tests/exec_in_pod.test.ts @@ -0,0 +1,243 @@ +import { describe, test, expect, vi } from "vitest"; +import { execInPodSchema } from "../src/tools/exec_in_pod.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"); + }); + }); +}); diff --git a/tests/helm.test.ts b/tests/helm.test.ts index d54c602..24de782 100644 --- a/tests/helm.test.ts +++ b/tests/helm.test.ts @@ -25,7 +25,7 @@ async function waitForClusterReadiness( { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { resourceType: "namespaces", output: "json" @@ -40,7 +40,7 @@ async function waitForClusterReadiness( { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { resourceType: "services", namespace: namespace, @@ -242,7 +242,7 @@ describe("helm operations", () => { { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { resourceType: "deployments", namespace: testNamespace, @@ -307,7 +307,7 @@ describe("helm operations", () => { { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { resourceType: "deployments", namespace: testNamespace, @@ -367,7 +367,7 @@ describe("helm operations", () => { { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { resourceType: "deployments", namespace: testNamespace, @@ -427,7 +427,7 @@ describe("helm operations", () => { { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { resourceType: "deployments", namespace: testNamespace, diff --git a/tests/kubectl-unified.test.ts b/tests/kubectl-unified.test.ts index 5fe6529..dd8eb96 100644 --- a/tests/kubectl-unified.test.ts +++ b/tests/kubectl-unified.test.ts @@ -456,36 +456,125 @@ metadata: } }); - // Test kubectl_list command - test("kubectl_list lists deployments", async () => { - // Use kubectl_list to get deployments in the kube-system namespace - const listResult = await client.request( - { - method: "tools/call", - params: { - name: "kubectl_list", - arguments: { - resourceType: "deployments", - namespace: "kube-system" + // Test kubectl_get command + test("kubectl_get lists deployments", async () => { + // Use kubectl_get to get deployments in the kube-system namespace + const result = await retry(async () => { + const response = await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "deployments", + namespace: "kube-system", + output: "json", + }, }, }, - }, - // @ts-ignore - Ignoring type error for now to get tests running - z.any() - ) as KubectlResponse; - - expect(listResult.content[0].type).toBe("text"); - - // Verify the list output - const listOutput = listResult.content[0].text; - console.log("List output excerpt:", listOutput.substring(0, 300) + "..."); - - // Check for typically available deployments in kube-system namespace - // Common deployments include coredns, kube-proxy, metrics-server, etc. - // This test should pass even if the specific deployments vary - expect(listOutput).toContain("kube-system"); - expect(listOutput).toContain("NAME"); - expect(listOutput).toContain("READY"); + // @ts-ignore - Ignoring type error for now to get tests running + z.any() + ) as KubectlResponse; + return response; + }); + + expect(result.content[0].type).toBe("text"); + const deployments = JSON.parse(result.content[0].text); + expect(deployments.items).toBeDefined(); + expect(Array.isArray(deployments.items)).toBe(true); + expect(deployments.items.length).toBeGreaterThan(0); + expect(deployments.items[0]).toBeDefined(); + expect(deployments.items[0].name).toBeDefined(); + }); + + test("kubectl_get lists nodes", async () => { + // Use kubectl_get to get nodes + const result = await retry(async () => { + const response = await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "nodes", + output: "json", + }, + }, + }, + // @ts-ignore - Ignoring type error for now to get tests running + z.any() + ) as KubectlResponse; + return response; + }); + + expect(result.content[0].type).toBe("text"); + const nodes = JSON.parse(result.content[0].text); + expect(nodes.items).toBeDefined(); + expect(Array.isArray(nodes.items)).toBe(true); + expect(nodes.items.length).toBeGreaterThan(0); + expect(nodes.items[0]).toBeDefined(); + expect(nodes.items[0].name).toBeDefined(); + }); + + test("kubectl_get lists events in default namespace", async () => { + // Use kubectl_get to get events in default namespace + const result = await retry(async () => { + const response = await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "events", + namespace: "default", + output: "json", + }, + }, + }, + // @ts-ignore - Ignoring type error for now to get tests running + z.any() + ) as KubectlResponse; + return response; + }); + + expect(result.content[0].type).toBe("text"); + const events = JSON.parse(result.content[0].text); + expect(Array.isArray(events.events)).toBe(true); + }); + + test("kubectl_get lists all namespaces", async () => { + // Use kubectl_get to get all namespaces + const result = await retry(async () => { + const response = await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "namespaces", + allNamespaces: true, + output: "json", + }, + }, + }, + // @ts-ignore - Ignoring type error for now to get tests running + z.any() + ) as KubectlResponse; + return response; + }); + + expect(result.content[0].type).toBe("text"); + const namespaces = JSON.parse(result.content[0].text); + expect(namespaces.items).toBeDefined(); + expect(Array.isArray(namespaces.items)).toBe(true); + expect(namespaces.items.length).toBeGreaterThan(0); + // Explicitly check for the first item's existence before accessing its properties + expect(namespaces.items[0]).toBeDefined(); + expect(namespaces.items[0].name).toBeDefined(); + + // Verify common namespaces are present + expect(namespaces.items.some((ns: any) => ns.name === "default")).toBe(true); + expect(namespaces.items.some((ns: any) => ns.name === "kube-system")).toBe(true); }); // Test kubectl_delete command with label selector diff --git a/tests/port_forward.test.ts b/tests/port_forward.test.ts index bd36aa3..d9031ab 100644 --- a/tests/port_forward.test.ts +++ b/tests/port_forward.test.ts @@ -116,7 +116,7 @@ describe("kubectl operations", () => { { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { resourceType: "pods", namespace: testNamespace, diff --git a/tests/prompts.test.ts b/tests/prompts.test.ts index 00c9394..9111ba3 100644 --- a/tests/prompts.test.ts +++ b/tests/prompts.test.ts @@ -56,8 +56,8 @@ describe("kubernetes prompts", () => { expect(promptsList.prompts).toBeDefined(); expect(promptsList.prompts.length).toBeGreaterThan(0); expect(promptsList.prompts).toContainEqual({ - name: "k8s-troubleshoot", - description: "Troubleshoot Kubernetes Resources.", + name: "k8s-diagnose", + description: "Diagnose Kubernetes Resources.", arguments: [ { name: "keyword", diff --git a/tests/service.test.ts b/tests/service.test.ts index abcc7ce..847b310 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -5,9 +5,28 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { CreateNamespaceResponseSchema } from "../src/types"; import { KubernetesManager } from "../src/types"; import { z } from "zod"; +import { asResponseSchema } from "./context-helper"; -// Define the schema for the Service response -const ServiceResponseSchema = z.any(); +// Define the proper response schema +const KubectlResponseSchema = z.object({ + content: z.array( + z.object({ + type: z.literal("text"), + text: z.string(), + }) + ), +}); + +// Define error response schema +const ErrorResponseSchema = z.object({ + content: z.array( + z.object({ + type: z.literal("text"), + text: z.string(), + }) + ), + isError: z.boolean().optional(), +}); // Interface for service response type interface ServiceResponse { @@ -55,14 +74,6 @@ interface DeleteServiceResponse { status: string; } -// Define the response type for easier use in tests -type KubectlResponse = { - content: Array<{ - type: "text"; - text: string; - }>; -}; - // Utility function: Sleep for a specified number of milliseconds async function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -169,8 +180,7 @@ describe("test kubernetes service", () => { }, }, }, - // @ts-ignore - Ignoring type error to get tests running - z.any() + asResponseSchema(KubectlResponseSchema) ); // Wait for the namespace to be fully created @@ -198,8 +208,7 @@ describe("test kubernetes service", () => { }, }, }, - // @ts-ignore - Ignoring type error to get tests running - z.any() + asResponseSchema(KubectlResponseSchema) ); // Close the client connection @@ -210,7 +219,7 @@ describe("test kubernetes service", () => { } }); - // Test case: Create ClusterIP service + // Test case 1: Create ClusterIP service test("create ClusterIP service", async () => { // Define test data const testPorts = [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http" }]; @@ -240,28 +249,26 @@ describe("test kubernetes service", () => { const response = await client.request( { method: "tools/call", - params: { - name: "kubectl_create", + params: { + name: "kubectl_create", arguments: { resourceType: "service", name: testServiceName, namespace: testNamespace, manifest: JSON.stringify(serviceManifest) } - }, + } }, - // @ts-ignore - Ignoring type error to get tests running - z.any() - ) as KubectlResponse; - - await sleep(1000); - - // Verify response - expect(response.content[0].type).toBe("text"); + asResponseSchema(KubectlResponseSchema) + ); + + // Verify the service was created successfully expect(response.content[0].text).toContain(testServiceName); - expect(response.content[0].text).toContain("Service"); - - // Verify service was created correctly using kubectl_get + + // Wait for service to be created + await sleep(1000); + + // Get the created service using kubectl_get and verify const getResponse = await client.request( { method: "tools/call", @@ -271,225 +278,264 @@ describe("test kubernetes service", () => { resourceType: "service", name: testServiceName, namespace: testNamespace, - output: "json" - } - } + output: "json", + }, + }, }, - // @ts-ignore - Ignoring type error to get tests running - z.any() - ) as KubectlResponse; - - const serviceJson = JSON.parse(getResponse.content[0].text); - - // Assert service properties - expect(serviceJson.metadata.name).toBe(testServiceName); - expect(serviceJson.metadata.namespace).toBe(testNamespace); - expect(serviceJson.spec.type).toBe("ClusterIP"); - - // Assert port configuration - expect(serviceJson.spec.ports).toHaveLength(1); - expect(serviceJson.spec.ports[0].port).toBe(80); - expect(serviceJson.spec.ports[0].targetPort).toBe(8080); - }); + asResponseSchema(KubectlResponseSchema) + ); + + const serviceData = JSON.parse(getResponse.content[0].text); + expect(serviceData.metadata.name).toBe(testServiceName); + expect(serviceData.metadata.namespace).toBe(testNamespace); + expect(serviceData.spec.type).toBe("ClusterIP"); + expect(serviceData.spec.ports[0].port).toBe(80); + expect(serviceData.spec.ports[0].targetPort).toBe(8080); + expect(serviceData.spec.ports[0].protocol).toBe("TCP"); + + // Clean up the created service + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "service", + name: testServiceName, + namespace: testNamespace, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }, 30000); // 30 second timeout - // Test case: List services + // Test case 2: List services test("list services", async () => { - // Define test data - const testPorts = [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http" }]; + // Create multiple test services + const service1Name = `service1-${generateRandomSHA()}`; + const service2Name = `service2-${generateRandomSHA()}`; - // First create a service to list using kubectl_create - const serviceManifest = { + const serviceManifest1 = { apiVersion: "v1", kind: "Service", metadata: { - name: testServiceName, + name: service1Name, namespace: testNamespace, }, spec: { - selector: { app: "test-app" }, - ports: testPorts.map(p => ({ - port: p.port, - targetPort: p.targetPort, - protocol: p.protocol, - name: p.name - })), + selector: { app: "app1" }, + ports: [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http" }], type: "ClusterIP" } }; - + + const serviceManifest2 = { + apiVersion: "v1", + kind: "Service", + metadata: { + name: service2Name, + namespace: testNamespace, + }, + spec: { + selector: { app: "app2" }, + ports: [{ port: 81, targetPort: 8181, protocol: "TCP", name: "http" }], + type: "NodePort" + } + }; + + // Create both services + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_create", + arguments: { + resourceType: "service", + name: service1Name, + namespace: testNamespace, + manifest: JSON.stringify(serviceManifest1) + } + } + }, + asResponseSchema(KubectlResponseSchema) + ); + await client.request( - { - method: "tools/call", - params: { - name: "kubectl_create", + { + method: "tools/call", + params: { + name: "kubectl_create", arguments: { resourceType: "service", - name: testServiceName, + name: service2Name, namespace: testNamespace, - manifest: JSON.stringify(serviceManifest) + manifest: JSON.stringify(serviceManifest2) } - } - }, - // @ts-ignore - Ignoring type error to get tests running - z.any() + } + }, + asResponseSchema(KubectlResponseSchema) ); - - await sleep(1000); - - // List the services using kubectl_list - const response = await client.request( - { - method: "tools/call", - params: { - name: "kubectl_list", - arguments: { + + // Wait for services to be created + await sleep(2000); + + // List all services in the namespace + const listResponse = await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { resourceType: "services", namespace: testNamespace, - output: "formatted" - } - } - }, - ServiceResponseSchema + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) ); + + const listResponseText = listResponse.content[0].text; + console.log("Service list response:", listResponseText); - // Verify response - const responseText = response.content[0].text; - console.log("Services list response:", responseText); + // Parse and verify the response + let servicesList: any; + try { + servicesList = JSON.parse(listResponseText); + } catch (e) { + // If it's not JSON, check that both service names are present + expect(listResponseText).toContain(service1Name); + expect(listResponseText).toContain(service2Name); + return; + } - // Assert service is in the list - expect(responseText).toContain(testServiceName); - expect(responseText).toContain(testNamespace); - expect(responseText).toContain("ClusterIP"); // Assuming default type is ClusterIP - expect(responseText).toContain("80"); // The port we defined - }); + // Check if it's a Kubernetes API response with items array + if (servicesList.items) { + expect(servicesList.items.length).toBeGreaterThanOrEqual(2); + const serviceNames = servicesList.items.map((item: any) => item.name || item.metadata?.name); + expect(serviceNames).toContain(service1Name); + expect(serviceNames).toContain(service2Name); + } else { + // If it's a different format, just check that both services are mentioned + expect(listResponseText).toContain(service1Name); + expect(listResponseText).toContain(service2Name); + } + + // Clean up + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "service", + name: service1Name, + namespace: testNamespace, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "service", + name: service2Name, + namespace: testNamespace, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }, 40000); // 40 second timeout - // Test case: Describe service + // Test case 3: Describe service test("describe service", async () => { - // Define test data - const testPorts = [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http" }]; - const serviceSelector = { app: "test-app", component: "api" }; - - // First create a service to describe using kubectl_create + // Create a test service const serviceManifest = { apiVersion: "v1", kind: "Service", metadata: { name: testServiceName, namespace: testNamespace, - labels: { app: testServiceName, "mcp-managed": "true" } + labels: { app: "test-app", version: "v1" } }, spec: { - selector: serviceSelector, - ports: testPorts.map(p => ({ - port: p.port, - targetPort: p.targetPort, - protocol: p.protocol, - name: p.name - })), + selector: { app: "test-app" }, + ports: [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http" }], type: "ClusterIP" } }; - - const createResponse = await client.request( - { - method: "tools/call", - params: { - name: "kubectl_create", + + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_create", arguments: { resourceType: "service", name: testServiceName, namespace: testNamespace, manifest: JSON.stringify(serviceManifest) } - } - }, - // @ts-ignore - Ignoring type error to get tests running - z.any() + } + }, + asResponseSchema(KubectlResponseSchema) ); - await sleep(1000); - - // List all services in the namespace using kubectl_list - const listResponse = await client.request( + + await sleep(2000); + + // Describe the service + const describeResponse = await client.request( { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_describe", arguments: { - resourceType: "services", + resourceType: "service", + name: testServiceName, namespace: testNamespace, - output: "formatted" - } - } + }, + }, }, - ServiceResponseSchema + asResponseSchema(KubectlResponseSchema) ); - console.log("Services list:", listResponse.content[0].text); - - // Get the service using kubectl_get - const getResponse = await client.request( + + const describeText = describeResponse.content[0].text; + console.log("Service describe response:", describeText); + + // Verify the describe output contains expected information + expect(describeText).toContain(testServiceName); + expect(describeText).toContain(testNamespace); + expect(describeText).toContain("ClusterIP"); + expect(describeText).toContain("80/TCP"); + + // Clean up + await client.request( { method: "tools/call", params: { - name: "kubectl_get", + name: "kubectl_delete", arguments: { resourceType: "service", name: testServiceName, namespace: testNamespace, - output: "json" - } - } + }, + }, }, - ServiceResponseSchema - ); - - const getServiceJson = JSON.parse(getResponse.content[0].text); - console.log("Service GET response:", getServiceJson); - - // Describe the service using kubectl_describe - const describeResponse = await client.request( - { - method: "tools/call", - params: { - name: "kubectl_describe", - arguments: { - resourceType: "service", - name: testServiceName, - namespace: testNamespace - } - } - }, - ServiceResponseSchema + asResponseSchema(KubectlResponseSchema) ); - - // Log the first part of the describe output - console.log("Service describe output (first 150 chars):", describeResponse.content[0].text.substring(0, 150) + "..."); - - // Verify service details from get response - expect(getServiceJson).not.toBeNull(); - expect(getServiceJson.metadata.name).toBe(testServiceName); - expect(getServiceJson.metadata.namespace).toBe(testNamespace); - expect(getServiceJson.spec.ports).toHaveLength(1); - expect(getServiceJson.spec.ports[0].port).toBe(80); - expect(getServiceJson.spec.selector).toEqual(serviceSelector); - - // Verify the describe output contains key service information - const describeOutput = describeResponse.content[0].text; - expect(describeOutput).toContain(testServiceName); - expect(describeOutput).toContain(testNamespace); - expect(describeOutput).toContain("80"); - expect(describeOutput).toContain("ClusterIP"); - }); + }, 30000); // 30 second timeout - // Test case: Update service + // Test case 4: Update service test("update service", async () => { - // Define test data - const initialPorts = [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http" }]; - const updatedPorts = [{ port: 90, targetPort: 9090, protocol: "TCP", name: "http-updated" }]; - const serviceSelector = { app: "test-app", tier: "backend" }; - const testLabels = { environment: "test", managed: "mcp" }; - - // First create a service to update - use kubectl_create - const serviceManifest = { + // Create initial service + const initialManifest = { apiVersion: "v1", kind: "Service", metadata: { @@ -497,55 +543,63 @@ describe("test kubernetes service", () => { namespace: testNamespace, }, spec: { - selector: serviceSelector, - ports: initialPorts.map(p => ({ - port: p.port, - targetPort: p.targetPort, - protocol: p.protocol, - name: p.name - })), + selector: { app: "test-app" }, + ports: [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http" }], type: "ClusterIP" } }; - + await client.request( - { - method: "tools/call", - params: { - name: "kubectl_create", + { + method: "tools/call", + params: { + name: "kubectl_create", arguments: { resourceType: "service", name: testServiceName, namespace: testNamespace, - manifest: JSON.stringify(serviceManifest) + manifest: JSON.stringify(initialManifest) } - } - }, - // @ts-ignore - Ignoring type error to get tests running - z.any() + } + }, + asResponseSchema(KubectlResponseSchema) ); - - await sleep(1000); - - // List all services in the namespace using kubectl_list - const listBeforeResponse = await client.request( + + await sleep(2000); + + // Update the service using kubectl_patch + const patchData = { + spec: { + ports: [ + { port: 80, targetPort: 8080, protocol: "TCP", name: "http" }, + { port: 443, targetPort: 8443, protocol: "TCP", name: "https" } + ] + } + }; + + const patchResponse = await client.request( { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_patch", arguments: { - resourceType: "services", + resourceType: "service", + name: testServiceName, namespace: testNamespace, - output: "formatted" + patchData: patchData, + patchType: "merge" } } }, - ServiceResponseSchema + asResponseSchema(KubectlResponseSchema) ); - console.log("Services before update:", listBeforeResponse.content[0].text); - - // Get the service using kubectl_get - const getResponse = await client.request( + + expect(patchResponse.content[0].text).toContain("patched"); + + await sleep(2000); + + // Verify the update + const getResponse = await client.request( { method: "tools/call", params: { @@ -554,116 +608,71 @@ describe("test kubernetes service", () => { resourceType: "service", name: testServiceName, namespace: testNamespace, - output: "json" - } - } + output: "json", + }, + }, }, - ServiceResponseSchema + asResponseSchema(KubectlResponseSchema) ); - - const initialService = JSON.parse(getResponse.content[0].text); - console.log("Initial service GET response:", initialService); - - // Verify initial service properties - expect(initialService.spec.ports[0].port).toBe(80); - expect(initialService.spec.ports[0].targetPort).toBe(8080); - - // Describe the service using kubectl_describe - const describeResponse = await client.request( + + const serviceData = JSON.parse(getResponse.content[0].text); + expect(serviceData.spec.ports).toHaveLength(2); + expect(serviceData.spec.ports.some((p: any) => p.port === 443)).toBe(true); + + // Clean up + await client.request( { method: "tools/call", params: { - name: "kubectl_describe", + name: "kubectl_delete", arguments: { resourceType: "service", name: testServiceName, - namespace: testNamespace - } - } + namespace: testNamespace, + }, + }, }, - ServiceResponseSchema + asResponseSchema(KubectlResponseSchema) ); - console.log("Service DESCRIBE output:", describeResponse.content[0].text.substring(0, 150) + "..."); - - // Use kubectl apply to modify the service with yaml - const currentSpec = initialService.spec; - const modifiedService = { + }, 40000); // 40 second timeout + + // Test case 5: Delete service + test("delete service", async () => { + // Create a service to delete + const serviceManifest = { apiVersion: "v1", kind: "Service", metadata: { name: testServiceName, namespace: testNamespace, - labels: testLabels }, spec: { - ...currentSpec, - ports: updatedPorts, - selector: { ...serviceSelector, updated: "true" } + selector: { app: "test-app" }, + ports: [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http" }], + type: "ClusterIP" } }; - - // Apply the modified service using kubectl_apply - const applyResponse = await client.request( + + await client.request( { method: "tools/call", params: { - name: "kubectl_apply", + name: "kubectl_create", arguments: { - manifest: JSON.stringify(modifiedService), - namespace: testNamespace + resourceType: "service", + name: testServiceName, + namespace: testNamespace, + manifest: JSON.stringify(serviceManifest) } } }, - // @ts-ignore - Ignoring type error to get tests running - z.any() - ) as KubectlResponse; - console.log("Apply response:", applyResponse.content[0].text); - await sleep(1000); - - // Update the service using kubectl_apply instead of update_service - const updatedServiceManifest = { - apiVersion: "v1", - kind: "Service", - metadata: { - name: testServiceName, - namespace: testNamespace, - }, - spec: { - selector: { ...serviceSelector, updated: "true" }, - ports: updatedPorts.map(p => ({ - port: p.port, - targetPort: p.targetPort, - protocol: p.protocol, - name: p.name - })), - type: "ClusterIP" - } - }; - - const updateResponse = await client.request( - { - method: "tools/call", - params: { - name: "kubectl_apply", - arguments: { - manifest: JSON.stringify(updatedServiceManifest), - namespace: testNamespace - } - } - }, - // @ts-ignore - Ignoring type error to get tests running - z.any() - ) as KubectlResponse; - - await sleep(1000); - - // Verify response - expect(updateResponse.content[0].type).toBe("text"); - expect(updateResponse.content[0].text).toContain(testServiceName); - expect(updateResponse.content[0].text).toContain("configured"); - - // Verify updated properties using kubectl_get - const getUpdatedResponse = await client.request( + asResponseSchema(KubectlResponseSchema) + ); + + await sleep(2000); + + // Verify service exists + const getBeforeResponse = await client.request( { method: "tools/call", params: { @@ -672,470 +681,442 @@ describe("test kubernetes service", () => { resourceType: "service", name: testServiceName, namespace: testNamespace, - output: "json" - } - } + output: "json", + }, + }, }, - // @ts-ignore - Ignoring type error to get tests running - z.any() - ) as KubectlResponse; - - const updatedService = JSON.parse(getUpdatedResponse.content[0].text); - - // Comprehensive verification of the updated service - expect(updatedService.spec.ports[0].port).toBe(90); - expect(updatedService.spec.ports[0].targetPort).toBe(9090); - expect(updatedService.spec.ports[0].name).toBe("http-updated"); - expect(updatedService.spec.selector.updated).toBe("true"); - expect(updatedService.spec.type).toBe("ClusterIP"); - }); + asResponseSchema(KubectlResponseSchema) + ); - // Test case: Delete service - test("delete service", async () => { - // Define test data - const testPorts = [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http" }]; - const serviceSelector = { app: "test-app", component: "backend" }; + const beforeData = JSON.parse(getBeforeResponse.content[0].text); + expect(beforeData.metadata.name).toBe(testServiceName); + + // Delete the service + const deleteResponse = await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "service", + name: testServiceName, + namespace: testNamespace, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + + expect(deleteResponse.content[0].text).toContain(`service "${testServiceName}" deleted`); + + await sleep(2000); + + // Verify service is deleted + let serviceDeleted = false; + let getAfterDeleteResponse: any; + try { + getAfterDeleteResponse = await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "service", + name: testServiceName, + namespace: testNamespace, + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + + // If we get here, check if the response indicates the service doesn't exist + const responseText = getAfterDeleteResponse.content[0].text; + if (responseText.includes("not found") || responseText.includes("NotFound")) { + serviceDeleted = true; + } + } catch (e: any) { + serviceDeleted = true; + expect(e.message).toContain("not found"); + } + + // If neither exception nor "not found" response, the test should fail + expect(serviceDeleted).toBe(true); + }, 35000); // 35 second timeout + + // Test case 6: Create NodePort service + test("create NodePort service", async () => { + const nodePortServiceName = `nodeport-service-${generateRandomSHA()}`; - // First create a service to delete using kubectl_create const serviceManifest = { apiVersion: "v1", kind: "Service", metadata: { - name: testServiceName, + name: nodePortServiceName, namespace: testNamespace, }, spec: { - selector: serviceSelector, - ports: testPorts.map(p => ({ - port: p.port, - targetPort: p.targetPort, - protocol: p.protocol, - name: p.name - })), - type: "ClusterIP" + selector: { app: "nodeport-app" }, + ports: [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http" }], + type: "NodePort" } }; - - await client.request( - { - method: "tools/call", - params: { - name: "kubectl_create", + + const response = await client.request( + { + method: "tools/call", + params: { + name: "kubectl_create", arguments: { resourceType: "service", - name: testServiceName, + name: nodePortServiceName, namespace: testNamespace, manifest: JSON.stringify(serviceManifest) } - } - }, - // @ts-ignore - Ignoring type error to get tests running - z.any() + } + }, + asResponseSchema(KubectlResponseSchema) ); - - await sleep(1000); - - // List services to verify creation using kubectl_list - const listBeforeResponse = await client.request( + + expect(response.content[0].text).toContain(nodePortServiceName); + + await sleep(2000); + + // Verify the service + const getResponse = await client.request( { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { - resourceType: "services", + resourceType: "service", + name: nodePortServiceName, namespace: testNamespace, - output: "formatted" - } - } + output: "json", + }, + }, }, - ServiceResponseSchema + asResponseSchema(KubectlResponseSchema) ); - console.log("Services before deletion:", listBeforeResponse.content[0].text); - - // Get the service details using kubectl_get - const getResponse = await client.request( + + const serviceData = JSON.parse(getResponse.content[0].text); + expect(serviceData.spec.type).toBe("NodePort"); + expect(serviceData.spec.ports[0].nodePort).toBeDefined(); + expect(serviceData.spec.ports[0].nodePort).toBeGreaterThan(30000); + + // Clean up + await client.request( { method: "tools/call", params: { - name: "kubectl_get", + name: "kubectl_delete", arguments: { resourceType: "service", - name: testServiceName, + name: nodePortServiceName, namespace: testNamespace, - output: "json" - } - } + }, + }, }, - ServiceResponseSchema + asResponseSchema(KubectlResponseSchema) ); + }, 30000); // 30 second timeout + + // Test case 7: Create LoadBalancer service (SKIPPED - requires cloud provider) + test("create LoadBalancer service", async () => { + const lbServiceName = `lb-service-${generateRandomSHA()}`; - const serviceJson = JSON.parse(getResponse.content[0].text); - console.log("Service before deletion:", serviceJson); - - // Verify service exists before deletion - expect(serviceJson.metadata.name).toBe(testServiceName); - expect(serviceJson.metadata.namespace).toBe(testNamespace); - - // Delete the service using kubectl_delete - const deleteResponse = await client.request( - { - method: "tools/call", - params: { - name: "kubectl_delete", - arguments: { - resourceType: "service", - name: testServiceName, - namespace: testNamespace - } - } - }, - // @ts-ignore - Ignoring type error to get tests running - z.any() - ) as KubectlResponse; - - await sleep(1000); - - // Verify delete response - expect(deleteResponse.content[0].type).toBe("text"); - expect(deleteResponse.content[0].text).toContain(testServiceName); - expect(deleteResponse.content[0].text).toContain("deleted"); - - // Create another service to demonstrate kubectl_delete instead of delete_service - const secondServiceName = `${testServiceName}-second`; - - // Use kubectl_create to create the second service - const secondServiceManifest = { + const serviceManifest = { apiVersion: "v1", kind: "Service", metadata: { - name: secondServiceName, + name: lbServiceName, namespace: testNamespace, - labels: { "test": "true" } }, spec: { - selector: serviceSelector, - ports: testPorts.map(p => ({ - protocol: p.protocol, - port: p.port, - targetPort: p.targetPort, - name: p.name - })), - type: "ClusterIP" + selector: { app: "lb-app" }, + ports: [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http" }], + type: "LoadBalancer" } }; - - const createSecondResponse = await client.request( + + const response = await client.request( { method: "tools/call", params: { name: "kubectl_create", arguments: { resourceType: "service", - name: secondServiceName, + name: lbServiceName, namespace: testNamespace, - manifest: JSON.stringify(secondServiceManifest) + manifest: JSON.stringify(serviceManifest) } } }, - ServiceResponseSchema + asResponseSchema(KubectlResponseSchema) ); - console.log("Second service creation response:", createSecondResponse.content[0].text); - await sleep(1000); - - // Delete the second service using kubectl_delete instead of delete_service - const deleteSecondResponse = await client.request( - { - method: "tools/call", - params: { - name: "kubectl_delete", - arguments: { - resourceType: "service", - name: secondServiceName, - namespace: testNamespace - } - } - }, - // @ts-ignore - Ignoring type error to get tests running - z.any() - ) as KubectlResponse; - - await sleep(1000); - - // Verify delete response - expect(deleteSecondResponse.content[0].type).toBe("text"); - expect(deleteSecondResponse.content[0].text).toContain(secondServiceName); - expect(deleteSecondResponse.content[0].text).toContain("deleted"); - - // List services to verify deletion using kubectl_list - const listAfterResponse = await client.request( + + expect(response.content[0].text).toContain(lbServiceName); + + await sleep(2000); + + // Verify the service + const getResponse = await client.request( { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { - resourceType: "services", + resourceType: "service", + name: lbServiceName, namespace: testNamespace, - output: "formatted" - } - } + output: "json", + }, + }, }, - ServiceResponseSchema + asResponseSchema(KubectlResponseSchema) ); - - const listAfterText = listAfterResponse.content[0].text; - console.log("Services list after deletion:", listAfterText); - - // Verify services are deleted by checking the list output - expect(listAfterText).not.toContain(testServiceName); - expect(listAfterText).not.toContain(secondServiceName); - - // Get all services to double check - const getAllResponse = await client.request( + + const serviceData = JSON.parse(getResponse.content[0].text); + expect(serviceData.spec.type).toBe("LoadBalancer"); + + // Clean up + await client.request( { method: "tools/call", params: { - name: "kubectl_get", + name: "kubectl_delete", arguments: { - resourceType: "services", + resourceType: "service", + name: lbServiceName, namespace: testNamespace, - output: "json" - } - } + }, + }, }, - ServiceResponseSchema + asResponseSchema(KubectlResponseSchema) ); - - // Parse the response and verify the service list is empty or doesn't contain our services - const getAllJson = JSON.parse(getAllResponse.content[0].text); - console.log("All services after deletion:", getAllJson); - - // Check if the items array is empty or doesn't contain our services - if (getAllJson.items && getAllJson.items.length > 0) { - const serviceNames = getAllJson.items.map((item: any) => item.metadata.name); - expect(serviceNames).not.toContain(testServiceName); - expect(serviceNames).not.toContain(secondServiceName); - } - }); + }, 25000); // 25 second timeout - // Test case: Create NodePort service - test("create NodePort service", async () => { + // Test case 8: Create ClusterIP service with existing name should fail + test("create ClusterIP service with existing name should fail", async () => { // Define test data - const testPorts = [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http", nodePort: 30080 }]; - const nodePortSelector = { app: "nodeport-app", tier: "frontend" }; - const nodePortServiceName = `${testServiceName}-nodeport`; + const testPorts = [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http" }]; + const testSelector = { app: "test-app", tier: "backend" }; - // Create service using kubectl_create with manifest - const nodePortServiceManifest = { + // Create the service manifest + const serviceManifest = { apiVersion: "v1", kind: "Service", metadata: { - name: nodePortServiceName, + name: testServiceName, namespace: testNamespace, - labels: { "service-type": "nodeport", "test-case": "true" } }, spec: { - selector: nodePortSelector, - type: "NodePort", - ports: [ - { - port: testPorts[0].port, - targetPort: testPorts[0].targetPort, - nodePort: testPorts[0].nodePort, - protocol: testPorts[0].protocol, - name: testPorts[0].name - } - ] + selector: testSelector, + ports: testPorts.map(p => ({ + port: p.port, + targetPort: p.targetPort, + protocol: p.protocol, + name: p.name + })), + type: "ClusterIP" } }; - - // Create using kubectl_create - const createResponse = await client.request( + + await client.request( { method: "tools/call", params: { name: "kubectl_create", arguments: { resourceType: "service", - name: nodePortServiceName, + name: testServiceName, namespace: testNamespace, - manifest: JSON.stringify(nodePortServiceManifest) + manifest: JSON.stringify(serviceManifest) } } }, - ServiceResponseSchema + asResponseSchema(KubectlResponseSchema) ); - console.log("NodePort service creation response:", createResponse.content[0].text); + + // Wait for the first service to be created await sleep(1000); - - // List services to verify creation - const listResponse = await client.request( - { - method: "tools/call", - params: { - name: "kubectl_list", - arguments: { - resourceType: "services", - namespace: testNamespace, - output: "formatted" - } - } - }, - ServiceResponseSchema - ); - console.log("Services after NodePort creation:", listResponse.content[0].text); - - // Get the service details using kubectl_get - const getResponse = await client.request( - { - method: "tools/call", - params: { - name: "kubectl_get", - arguments: { - resourceType: "service", - name: nodePortServiceName, - namespace: testNamespace, - output: "json" - } - } + + // Attempt to create a second service with the same name + const serviceManifest2 = { + apiVersion: "v1", + kind: "Service", + metadata: { + name: testServiceName, + namespace: testNamespace, }, - ServiceResponseSchema - ); - - const serviceJson = JSON.parse(getResponse.content[0].text); - console.log("NodePort service details:", serviceJson); - - // Describe the service using kubectl_describe - const describeResponse = await client.request( - { - method: "tools/call", - params: { - name: "kubectl_describe", - arguments: { - resourceType: "service", - name: nodePortServiceName, - namespace: testNamespace + spec: { + selector: { app: "another-app" }, + ports: [{ port: 81, targetPort: 8181, protocol: "TCP", name: "http" }], + type: "ClusterIP" + } + }; + + let errorOccurred = false; + let errorMessage = ""; + try { + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_create", + arguments: { + resourceType: "service", + name: testServiceName, + manifest: JSON.stringify(serviceManifest2), + namespace: testNamespace, + } } - } - }, - ServiceResponseSchema - ); - console.log("NodePort service describe (first 150 chars):", describeResponse.content[0].text.substring(0, 150) + "..."); - - // Comprehensive assertions on the service - expect(serviceJson.metadata.name).toBe(nodePortServiceName); - expect(serviceJson.metadata.namespace).toBe(testNamespace); - expect(serviceJson.metadata.labels["service-type"]).toBe("nodeport"); - expect(serviceJson.spec.type).toBe("NodePort"); - expect(serviceJson.spec.selector).toEqual(nodePortSelector); - - // Verify port configuration - expect(serviceJson.spec.ports).toHaveLength(1); - expect(serviceJson.spec.ports[0].port).toBe(80); - expect(serviceJson.spec.ports[0].targetPort).toBe(8080); - expect(serviceJson.spec.ports[0].nodePort).toBe(30080); + }, + asResponseSchema(KubectlResponseSchema) + ); + } catch (e: any) { + errorOccurred = true; + errorMessage = e.message; + } - // Get the service in wide format to see exposed ports - const getWideResponse = await client.request( + expect(errorOccurred).toBe(true); + expect(errorMessage).toContain("already exists"); + + // Clean up the created service + await client.request( { method: "tools/call", params: { - name: "kubectl_get", + name: "kubectl_delete", arguments: { resourceType: "service", - name: nodePortServiceName, + name: testServiceName, namespace: testNamespace, - output: "wide" - } - } + }, + }, }, - ServiceResponseSchema + asResponseSchema(KubectlResponseSchema) ); - console.log("NodePort service wide format:", getWideResponse.content[0].text); + }, 30000); // 30 second timeout + + // Test case 9: Delete non-existent service should return not_found status + test("delete non-existent service should return not_found status", async () => { + const nonExistentServiceName = `non-existent-service-${generateRandomSHA()}`; + + // Attempt to delete a non-existent service + let deleteResponse: any; + let errorOccurred = false; - // Verify the service description contains NodePort information - const describeOutput = describeResponse.content[0].text; - expect(describeOutput).toContain("NodePort"); - expect(describeOutput).toContain("30080"); - }); + try { + deleteResponse = await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "service", + name: nonExistentServiceName, + namespace: testNamespace, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + } catch (e: any) { + errorOccurred = true; + // Check if the error message contains not found information + expect(e.message).toContain("not found"); + } - // Test case: Create LoadBalancer service - test("create LoadBalancer service", async () => { + // If no exception was thrown, check if it's an error response + if (!errorOccurred && deleteResponse) { + // Check if the response indicates not found + const responseText = deleteResponse.content[0].text; + expect(responseText).toContain("not found"); + } + }, 15000); // 15 second timeout + + // Test case 10: kubectl_get service with output 'name' returns resource name + test("kubectl_get service with output 'name' returns resource name", async () => { // Define test data const testPorts = [{ port: 80, targetPort: 8080, protocol: "TCP", name: "http" }]; - const lbServiceName = `${testServiceName}-lb`; - const serviceSelector = { app: "lb-app", component: "frontend" }; - - // Create LoadBalancer service using kubectl_create instead of create_service - const lbServiceManifest = { + const testSelector = { app: "test-app-name", tier: "backend" }; + + // Create the service + const serviceName = `service-name-test-${generateRandomSHA()}`; + const serviceManifest = { apiVersion: "v1", kind: "Service", metadata: { - name: lbServiceName, + name: serviceName, namespace: testNamespace, }, spec: { - selector: serviceSelector, + selector: testSelector, ports: testPorts.map(p => ({ port: p.port, targetPort: p.targetPort, protocol: p.protocol, name: p.name })), - type: "LoadBalancer" + type: "ClusterIP" } }; - - const response = await client.request( - { - method: "tools/call", - params: { - name: "kubectl_create", + + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_create", arguments: { resourceType: "service", - name: lbServiceName, + name: serviceName, namespace: testNamespace, - manifest: JSON.stringify(lbServiceManifest) + manifest: JSON.stringify(serviceManifest) } - } + } }, - // @ts-ignore - Ignoring type error to get tests running - z.any() - ) as KubectlResponse; - + asResponseSchema(KubectlResponseSchema) + ); + await sleep(1000); - - // Verify response - expect(response.content[0].type).toBe("text"); - expect(response.content[0].text).toContain(lbServiceName); - - // Verify service using kubectl_get - const getResponse = await client.request( + + // Use kubectl_get with output 'name' + const response = await client.request( { method: "tools/call", params: { name: "kubectl_get", arguments: { resourceType: "service", - name: lbServiceName, + name: serviceName, namespace: testNamespace, - output: "json" - } - } + output: "name", + }, + }, }, - // @ts-ignore - Ignoring type error to get tests running - z.any() - ) as KubectlResponse; - - const serviceJson = JSON.parse(getResponse.content[0].text); - - // Assert service properties - expect(serviceJson.metadata.name).toBe(lbServiceName); - expect(serviceJson.metadata.namespace).toBe(testNamespace); - expect(serviceJson.spec.type).toBe("LoadBalancer"); - - // Assert port configuration - expect(serviceJson.spec.ports).toHaveLength(1); - expect(serviceJson.spec.ports[0].port).toBe(80); - expect(serviceJson.spec.ports[0].targetPort).toBe(8080); - }, 120000); // Set timeout to 120 seconds + asResponseSchema(KubectlResponseSchema) + ); + + // The output format for 'name' should be just 'service/serviceName' + expect(response.content[0].text.trim()).toBe(`service/${serviceName}`); + + // Cleanup + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "service", + name: serviceName, + namespace: testNamespace, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + await sleep(1000); + }, 20000); // 20 second timeout }); \ No newline at end of file diff --git a/tests/sse.test.ts b/tests/sse.test.ts index 177da95..4cc98a6 100644 --- a/tests/sse.test.ts +++ b/tests/sse.test.ts @@ -6,7 +6,7 @@ import { ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { KubernetesManager } from "../src/utils/kubernetes-manager.js"; -import { kubectlListSchema, kubectlList } from "../src/tools/kubectl-list.js"; +import { kubectlGetSchema, kubectlGet } from "../src/tools/kubectl-get.js"; import express from "express"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import net from "net"; @@ -75,7 +75,7 @@ describe("SSE transport", () => { beforeAll(async () => { const k8sManager = new KubernetesManager(); - // Create a minimal server with just the kubectl_list tool + // Create a minimal server with just the kubectl_get tool server = new Server( { name: "test-server", @@ -91,7 +91,7 @@ describe("SSE transport", () => { // Set up the kubectl_list tool server.setRequestHandler(ListToolsRequestSchema, async () => { return { - tools: [kubectlListSchema], + tools: [kubectlGetSchema], }; }); @@ -99,14 +99,16 @@ describe("SSE transport", () => { const { name, arguments: input = {} } = request.params; switch (name) { - case "kubectl_list": - return await kubectlList(k8sManager, input as { + case "kubectl_get": + return await kubectlGet(k8sManager, input as { resourceType: string; + name?: string; namespace?: string; output?: string; allNamespaces?: boolean; labelSelector?: string; fieldSelector?: string; + sortBy?: string; }); default: throw new Error(`Unknown tool: ${name}`); @@ -180,7 +182,7 @@ describe("SSE transport", () => { id: 1234, method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { resourceType: "pods", namespace: "default", diff --git a/tests/unit.test.ts b/tests/unit.test.ts index efefcaf..3fd5be4 100644 --- a/tests/unit.test.ts +++ b/tests/unit.test.ts @@ -115,13 +115,13 @@ describe("kubernetes server operations", () => { * Tests both namespace and node listing operations in sequence */ test("list namespaces and nodes", async () => { - // List namespaces using kubectl_list + // List namespaces using kubectl_get console.log("Listing namespaces..."); const namespacesResult = await client.request( { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { resourceType: "namespaces", output: "json" @@ -135,13 +135,13 @@ describe("kubernetes server operations", () => { expect(namespaces.items).toBeDefined(); expect(Array.isArray(namespaces.items)).toBe(true); - // List nodes using kubectl_list + // List nodes using kubectl_get console.log("Listing nodes..."); const listNodesResult = await client.request( { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { resourceType: "nodes", output: "json" @@ -251,15 +251,15 @@ describe("kubernetes server operations", () => { { method: "tools/call", params: { - name: "kubectl_list", + name: "kubectl_get", arguments: { resourceType: "pods", - namespace: "default", - output: "json" + labelSelector: `test=${podBaseName}`, + output: "json", }, }, }, - asResponseSchema(ListPodsResponseSchema) + asResponseSchema(KubectlResponseSchema) ); const podsResponse = JSON.parse(existingPods.content[0].text);