From bc54df99affe63928c49277582ef9ee8da5528b2 Mon Sep 17 00:00:00 2001 From: Suyog Sonwalkar Date: Mon, 30 Jun 2025 21:20:24 -0700 Subject: [PATCH 01/35] Removing env var in dxt manifest.json for now Summary: Test Plan: --- manifest.json | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/manifest.json b/manifest.json index d116e43..aa90513 100644 --- a/manifest.json +++ b/manifest.json @@ -13,12 +13,7 @@ "entry_point": "dist/index.js", "mcp_config": { "command": "node", - "args": [ - "${__dirname}/dist/index.js" - ], - "env": { - "KUBECONFIG": "${user_config.kubeconfig_path}" - } + "args": ["${__dirname}/dist/index.js"] } }, "tools": [ @@ -28,12 +23,7 @@ } ], "tools_generated": true, - "keywords": [ - "kubernetes", - "docker", - "containers", - "containerization" - ], + "keywords": ["kubernetes", "docker", "containers", "containerization"], "license": "MIT", "repository": { "type": "git", From f1dafc91f99c786f50762cef4f001d2fbc99eda8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 1 Jul 2025 04:27:36 +0000 Subject: [PATCH 02/35] Bump version to 2.4.9 --- manifest.json | 13 ++++++++++--- package.json | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/manifest.json b/manifest.json index aa90513..e17d478 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "dxt_version": "0.1", "name": "mcp-server-kubernetes", - "version": "2.4.7", + "version": "2.4.9", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "long_description": "MCP Server that can connect to a Kubernetes cluster and manage it.\n\nBy default, the server loads kubeconfig from `~/.kube/config`.\n\nThe server will automatically connect to your current kubectl context. Make sure you have:\n\n1. kubectl installed and in your PATH\n2. A valid kubeconfig file with contexts configured\n3. Access to a Kubernetes cluster configured for kubectl (e.g. minikube, Rancher Desktop, GKE, etc.)\n4. Optional: Helm v3 installed and in your PATH.\n\nYou can verify your connection by asking Claude to list your pods or create a test deployment.\n\nIf you have errors open up a standard terminal and run `kubectl get pods` to see if you can connect to your cluster without credentials issues.\n\n## Features\n\n- [x] Connect to a Kubernetes cluster\n- [x] Unified kubectl API for managing resources\n- Get or list resources with `kubectl_get`\n- Describe resources with `kubectl_describe`\n- List resources with `kubectl_get`\n- Create resources with `kubectl_create`\n- Apply YAML manifests with `kubectl_apply`\n- Delete resources with `kubectl_delete`\n- Get logs with `kubectl_logs`\n- and more.", "author": { @@ -13,7 +13,9 @@ "entry_point": "dist/index.js", "mcp_config": { "command": "node", - "args": ["${__dirname}/dist/index.js"] + "args": [ + "${__dirname}/dist/index.js" + ] } }, "tools": [ @@ -23,7 +25,12 @@ } ], "tools_generated": true, - "keywords": ["kubernetes", "docker", "containers", "containerization"], + "keywords": [ + "kubernetes", + "docker", + "containers", + "containerization" + ], "license": "MIT", "repository": { "type": "git", diff --git a/package.json b/package.json index ef34d51..e083a4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-server-kubernetes", - "version": "2.4.7", + "version": "2.4.9", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "license": "MIT", "type": "module", From ab165f5a0eea917fef5dbae954506fff6f4bf514 Mon Sep 17 00:00:00 2001 From: Suyog Sonwalkar Date: Tue, 1 Jul 2025 17:39:44 -0700 Subject: [PATCH 03/35] Migrating from execSync to execFileSync with arguments array Summary: Test Plan: --- src/tools/helm-operations.ts | 73 +++++--- src/tools/kubectl-apply.ts | 72 ++++---- src/tools/kubectl-context.ts | 166 +++++++++++------- src/tools/kubectl-create.ts | 289 ++++++++++++++++++-------------- src/tools/kubectl-delete.ts | 136 ++++++++------- src/tools/kubectl-describe.ts | 81 +++++---- src/tools/kubectl-generic.ts | 63 +++---- src/tools/kubectl-get.ts | 48 +++--- src/tools/kubectl-logs.ts | 226 ++++++++++++++++--------- src/tools/kubectl-operations.ts | 35 ++-- src/tools/kubectl-patch.ts | 85 +++++----- src/tools/kubectl-rollout.ts | 77 +++++---- src/tools/kubectl-scale.ts | 68 ++++---- tests/kubectl.test.ts | 41 +---- 14 files changed, 843 insertions(+), 617 deletions(-) diff --git a/src/tools/helm-operations.ts b/src/tools/helm-operations.ts index 044c7f0..2827d3d 100644 --- a/src/tools/helm-operations.ts +++ b/src/tools/helm-operations.ts @@ -1,7 +1,12 @@ -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { writeFileSync, unlinkSync } from "fs"; import yaml from "yaml"; -import { HelmInstallOperation, HelmOperation, HelmResponse, HelmUpgradeOperation } from "../models/helm-models.js"; +import { + HelmInstallOperation, + HelmOperation, + HelmResponse, + HelmUpgradeOperation, +} from "../models/helm-models.js"; export const installHelmChartSchema = { name: "install_helm_chart", @@ -88,13 +93,13 @@ export const uninstallHelmChartSchema = { }, }; -const executeHelmCommand = (command: string): string => { +const executeHelmCommand = (command: string, args: string[]): string => { try { // Add a generous timeout of 60 seconds for Helm operations - return execSync(command, { + return execFileSync(command, args, { encoding: "utf8", timeout: 60000, // 60 seconds timeout - env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); } catch (error: any) { throw new Error(`Helm command failed: ${error.message}`); @@ -107,30 +112,40 @@ const writeValuesFile = (name: string, values: Record): string => { return filename; }; -export async function installHelmChart(params: HelmInstallOperation): Promise<{ content: { type: string; text: string }[] }> { +export async function installHelmChart( + params: HelmInstallOperation +): Promise<{ content: { type: string; text: string }[] }> { try { // Add helm repository if provided if (params.repo) { const repoName = params.chart.split("/")[0]; - executeHelmCommand(`helm repo add ${repoName} ${params.repo}`); - executeHelmCommand("helm repo update"); + executeHelmCommand("helm", ["repo", "add", repoName, params.repo]); + executeHelmCommand("helm", ["repo", "update"]); } - let command = `helm install ${params.name} ${params.chart} --namespace ${params.namespace} --create-namespace`; + let command = "helm"; + let args = [ + "install", + params.name, + params.chart, + "--namespace", + params.namespace, + "--create-namespace", + ]; // Handle values if provided if (params.values) { const valuesFile = writeValuesFile(params.name, params.values); - command += ` -f ${valuesFile}`; + args.push("-f", valuesFile); try { - executeHelmCommand(command); + executeHelmCommand(command, args); } finally { // Cleanup values file unlinkSync(valuesFile); } } else { - executeHelmCommand(command); + executeHelmCommand(command, args); } const response: HelmResponse = { @@ -151,30 +166,39 @@ export async function installHelmChart(params: HelmInstallOperation): Promise<{ } } -export async function upgradeHelmChart(params: HelmUpgradeOperation): Promise<{ content: { type: string; text: string }[] }> { +export async function upgradeHelmChart( + params: HelmUpgradeOperation +): Promise<{ content: { type: string; text: string }[] }> { try { // Add helm repository if provided if (params.repo) { const repoName = params.chart.split("/")[0]; - executeHelmCommand(`helm repo add ${repoName} ${params.repo}`); - executeHelmCommand("helm repo update"); + executeHelmCommand("helm", ["repo", "add", repoName, params.repo]); + executeHelmCommand("helm", ["repo", "update"]); } - let command = `helm upgrade ${params.name} ${params.chart} --namespace ${params.namespace}`; + let command = "helm"; + let args = [ + "upgrade", + params.name, + params.chart, + "--namespace", + params.namespace, + ]; // Handle values if provided if (params.values) { const valuesFile = writeValuesFile(params.name, params.values); - command += ` -f ${valuesFile}`; + args.push("-f", valuesFile); try { - executeHelmCommand(command); + executeHelmCommand(command, args); } finally { // Cleanup values file unlinkSync(valuesFile); } } else { - executeHelmCommand(command); + executeHelmCommand(command, args); } const response: HelmResponse = { @@ -195,9 +219,16 @@ export async function upgradeHelmChart(params: HelmUpgradeOperation): Promise<{ } } -export async function uninstallHelmChart(params: HelmOperation): Promise<{ content: { type: string; text: string }[] }> { +export async function uninstallHelmChart( + params: HelmOperation +): Promise<{ content: { type: string; text: string }[] }> { try { - executeHelmCommand(`helm uninstall ${params.name} --namespace ${params.namespace}`); + executeHelmCommand("helm", [ + "uninstall", + params.name, + "--namespace", + params.namespace, + ]); const response: HelmResponse = { status: "uninstalled", diff --git a/src/tools/kubectl-apply.ts b/src/tools/kubectl-apply.ts index b5bf9f1..75e3dd9 100644 --- a/src/tools/kubectl-apply.ts +++ b/src/tools/kubectl-apply.ts @@ -1,5 +1,5 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; @@ -11,29 +11,31 @@ export const kubectlApplySchema = { inputSchema: { type: "object", properties: { - manifest: { - type: "string", - description: "YAML manifest to apply" + manifest: { + type: "string", + description: "YAML manifest to apply", }, - filename: { - type: "string", - description: "Path to a YAML file to apply (optional - use either manifest or filename)" + filename: { + type: "string", + description: + "Path to a YAML file to apply (optional - use either manifest or filename)", }, - namespace: { - type: "string", - description: "Namespace to apply the resource to (optional)", - default: "default" + namespace: { + type: "string", + description: "Namespace to apply the resource to (optional)", + default: "default", }, dryRun: { type: "boolean", description: "If true, only validate the resource, don't apply it", - default: false + default: false, }, force: { type: "boolean", - description: "If true, immediately remove resources from API and bypass graceful deletion", - default: false - } + description: + "If true, immediately remove resources from API and bypass graceful deletion", + default: false, + }, }, required: [], }, @@ -60,38 +62,42 @@ export async function kubectlApply( const namespace = input.namespace || "default"; const dryRun = input.dryRun || false; const force = input.force || false; - - let command = "kubectl apply"; + + let command = "kubectl"; + let args = ["apply"]; let tempFile: string | null = null; - + // Process manifest content if provided if (input.manifest) { // Create temporary file for the manifest const tmpDir = os.tmpdir(); tempFile = path.join(tmpDir, `manifest-${Date.now()}.yaml`); fs.writeFileSync(tempFile, input.manifest); - command += ` -f ${tempFile}`; + args.push("-f", tempFile); } else if (input.filename) { - command += ` -f ${input.filename}`; + args.push("-f", input.filename); } - + // Add namespace - command += ` -n ${namespace}`; - + args.push("-n", namespace); + // Add dry-run flag if requested if (dryRun) { - command += " --dry-run=client"; + args.push("--dry-run=client"); } - + // Add force flag if requested if (force) { - command += " --force"; + args.push("--force"); } - + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Clean up temp file if created if (tempFile) { try { @@ -100,7 +106,7 @@ export async function kubectlApply( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + return { content: [ { @@ -118,7 +124,7 @@ export async function kubectlApply( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + throw new McpError( ErrorCode.InternalError, `Failed to apply manifest: ${error.message}` @@ -128,10 +134,10 @@ export async function kubectlApply( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl apply command: ${error.message}` ); } -} \ No newline at end of file +} diff --git a/src/tools/kubectl-context.ts b/src/tools/kubectl-context.ts index 98a9e24..0eee553 100644 --- a/src/tools/kubectl-context.ts +++ b/src/tools/kubectl-context.ts @@ -1,39 +1,43 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlContextSchema = { name: "kubectl_context", - description: "Manage Kubernetes contexts - list, get, or set the current context", + description: + "Manage Kubernetes contexts - list, get, or set the current context", inputSchema: { type: "object", properties: { operation: { type: "string", enum: ["list", "get", "set"], - description: "Operation to perform: list contexts, get current context, or set current context", - default: "list" + description: + "Operation to perform: list contexts, get current context, or set current context", + default: "list", }, name: { type: "string", - description: "Name of the context to set as current (required for set operation)" + description: + "Name of the context to set as current (required for set operation)", }, showCurrent: { type: "boolean", - description: "When listing contexts, highlight which one is currently active", - default: true + description: + "When listing contexts, highlight which one is currently active", + default: true, }, detailed: { type: "boolean", description: "Include detailed information about the context", - default: false + default: false, }, output: { type: "string", enum: ["json", "yaml", "name", "custom"], description: "Output format", - default: "json" - } + default: "json", + }, }, required: ["operation"], }, @@ -53,21 +57,24 @@ export async function kubectlContext( const { operation, name, output = "json" } = input; const showCurrent = input.showCurrent !== false; // Default to true if not specified const detailed = input.detailed === true; // Default to false if not specified - - let command = ""; + + const command = "kubectl"; let result = ""; - + switch (operation) { case "list": // Build command to list contexts - command = "kubectl config get-contexts"; - + let listArgs = ["config", "get-contexts"]; + if (output === "name") { - command += " -o name"; + listArgs.push("-o", "name"); } else if (output === "custom" || output === "json") { // For custom or JSON output, we'll format it ourselves - const rawResult = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const rawResult = execFileSync(command, listArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Parse the tabular output from kubectl const lines = rawResult.trim().split("\n"); const headers = lines[0].trim().split(/\s+/); @@ -76,21 +83,21 @@ export async function kubectlContext( const clusterIndex = headers.indexOf("CLUSTER"); const authInfoIndex = headers.indexOf("AUTHINFO"); const namespaceIndex = headers.indexOf("NAMESPACE"); - + const contexts = []; for (let i = 1; i < lines.length; i++) { const columns = lines[i].trim().split(/\s+/); const isCurrent = columns[currentIndex]?.trim() === "*"; - + contexts.push({ name: columns[nameIndex]?.trim(), cluster: columns[clusterIndex]?.trim(), user: columns[authInfoIndex]?.trim(), namespace: columns[namespaceIndex]?.trim() || "default", - isCurrent: isCurrent + isCurrent: isCurrent, }); } - + return { content: [ { @@ -100,23 +107,36 @@ export async function kubectlContext( ], }; } - + // Execute the command for non-json outputs - result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); + result = execFileSync(command, listArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); break; - + case "get": // Build command to get current context - command = "kubectl config current-context"; - + const getArgs = ["config", "current-context"]; + // Execute the command try { - const currentContext = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }).trim(); - + const currentContext = execFileSync(command, getArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }).trim(); + if (detailed) { // For detailed context info, we need to use get-contexts and filter - const allContextsOutput = execSync("kubectl config get-contexts", { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const allContextsOutput = execFileSync( + command, + ["config", "get-contexts"], + { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + } + ); + // Parse the tabular output from kubectl const lines = allContextsOutput.trim().split("\n"); const headers = lines[0].trim().split(/\s+/); @@ -124,31 +144,31 @@ export async function kubectlContext( const clusterIndex = headers.indexOf("CLUSTER"); const authInfoIndex = headers.indexOf("AUTHINFO"); const namespaceIndex = headers.indexOf("NAMESPACE"); - + let contextData = { name: currentContext, cluster: "", user: "", - namespace: "default" + namespace: "default", }; - + // Find the current context in the output for (let i = 1; i < lines.length; i++) { const line = lines[i]; const columns = line.trim().split(/\s+/); const name = columns[nameIndex]?.trim(); - + if (name === currentContext) { contextData = { name: currentContext, cluster: columns[clusterIndex]?.trim() || "", user: columns[authInfoIndex]?.trim() || "", - namespace: columns[namespaceIndex]?.trim() || "default" + namespace: columns[namespaceIndex]?.trim() || "default", }; break; } } - + return { content: [ { @@ -162,10 +182,10 @@ export async function kubectlContext( // In each test, we need to use the format that the specific test expects // Test contexts.test.ts line 205 is comparing with kubeConfig.getCurrentContext() // which returns the short name, so we'll return that - + // Since k8sManager is available, we can check which format to use based on the function called // For now, let's always return the short name since that's what the KubeConfig API returns - + return { content: [ { @@ -182,14 +202,21 @@ export async function kubectlContext( content: [ { type: "text", - text: JSON.stringify({ currentContext: null, error: "No current context is set" }, null, 2), + text: JSON.stringify( + { + currentContext: null, + error: "No current context is set", + }, + null, + 2 + ), }, ], }; } throw error; } - + case "set": // Validate input if (!name) { @@ -198,12 +225,19 @@ export async function kubectlContext( "Name parameter is required for set operation" ); } - + // First check if the context exists try { - const allContextsOutput = execSync("kubectl config get-contexts -o name", { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); + const allContextsOutput = execFileSync( + command, + ["config", "get-contexts", "-o", "name"], + { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + } + ); const availableContexts = allContextsOutput.trim().split("\n"); - + // Extract the short name from the ARN if needed let contextName = name; if (name.includes("cluster/")) { @@ -212,31 +246,41 @@ export async function kubectlContext( contextName = parts[1]; // Get the part after "cluster/" } } - + // Check if the context exists - if (!availableContexts.includes(contextName) && !availableContexts.includes(name)) { + if ( + !availableContexts.includes(contextName) && + !availableContexts.includes(name) + ) { throw new McpError( ErrorCode.InvalidParams, `Context '${name}' not found` ); } - + // Build command to set context - command = `kubectl config use-context "${contextName}"`; - + const setArgs = ["config", "use-context", contextName]; + // Execute the command - result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + result = execFileSync(command, setArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // For tests to pass, we need to return the original name format that was passed in return { content: [ { type: "text", - text: JSON.stringify({ - success: true, - message: `Current context set to '${name}'`, - context: name - }, null, 2), + text: JSON.stringify( + { + success: true, + message: `Current context set to '${name}'`, + context: name, + }, + null, + 2 + ), }, ], }; @@ -245,7 +289,7 @@ export async function kubectlContext( if (error instanceof McpError) { throw error; } - + // Handle other errors if (error.message.includes("no context exists")) { throw new McpError( @@ -255,14 +299,14 @@ export async function kubectlContext( } throw error; } - + default: throw new McpError( ErrorCode.InvalidParams, `Invalid operation: ${operation}` ); } - + return { content: [ { @@ -275,10 +319,10 @@ export async function kubectlContext( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl context command: ${error.message}` ); } -} \ No newline at end of file +} diff --git a/src/tools/kubectl-create.ts b/src/tools/kubectl-create.ts index 3e14b54..2228ce7 100644 --- a/src/tools/kubectl-create.ts +++ b/src/tools/kubectl-create.ts @@ -1,5 +1,5 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; @@ -7,133 +7,155 @@ import * as os from "os"; export const kubectlCreateSchema = { name: "kubectl_create", - description: "Create Kubernetes resources using various methods (from file or using subcommands)", + description: + "Create Kubernetes resources using various methods (from file or using subcommands)", inputSchema: { type: "object", properties: { // General options dryRun: { type: "boolean", - description: "If true, only validate the resource, don't actually create it", - default: false + description: + "If true, only validate the resource, don't actually create it", + default: false, }, output: { type: "string", - enum: ["json", "yaml", "name", "go-template", "go-template-file", "template", "templatefile", "jsonpath", "jsonpath-as-json", "jsonpath-file"], - description: "Output format. One of: json|yaml|name|go-template|go-template-file|template|templatefile|jsonpath|jsonpath-as-json|jsonpath-file", - default: "yaml" + enum: [ + "json", + "yaml", + "name", + "go-template", + "go-template-file", + "template", + "templatefile", + "jsonpath", + "jsonpath-as-json", + "jsonpath-file", + ], + description: + "Output format. One of: json|yaml|name|go-template|go-template-file|template|templatefile|jsonpath|jsonpath-as-json|jsonpath-file", + default: "yaml", }, validate: { type: "boolean", description: "If true, validate resource schema against server schema", - default: true + default: true, }, - + // Create from file method - manifest: { - type: "string", - description: "YAML manifest to create resources from" + manifest: { + type: "string", + description: "YAML manifest to create resources from", }, - filename: { - type: "string", - description: "Path to a YAML file to create resources from" + filename: { + type: "string", + description: "Path to a YAML file to create resources from", }, - + // Resource type to create (determines which subcommand to use) resourceType: { type: "string", - description: "Type of resource to create (namespace, configmap, deployment, service, etc.)" + description: + "Type of resource to create (namespace, configmap, deployment, service, etc.)", }, - + // Common parameters for most resource types name: { type: "string", - description: "Name of the resource to create" + description: "Name of the resource to create", }, - namespace: { - type: "string", - description: "Namespace to create the resource in", - default: "default" + namespace: { + type: "string", + description: "Namespace to create the resource in", + default: "default", }, - + // ConfigMap specific parameters fromLiteral: { type: "array", items: { type: "string" }, - description: "Key-value pair for creating configmap (e.g. [\"key1=value1\", \"key2=value2\"])" + description: + 'Key-value pair for creating configmap (e.g. ["key1=value1", "key2=value2"])', }, fromFile: { type: "array", items: { type: "string" }, - description: "Path to file for creating configmap (e.g. [\"key1=/path/to/file1\", \"key2=/path/to/file2\"])" + description: + 'Path to file for creating configmap (e.g. ["key1=/path/to/file1", "key2=/path/to/file2"])', }, // Namespace specific parameters // No special parameters for namespace, just name is needed - + // Secret specific parameters secretType: { type: "string", enum: ["generic", "docker-registry", "tls"], - description: "Type of secret to create (generic, docker-registry, tls)" + description: "Type of secret to create (generic, docker-registry, tls)", }, - + // Service specific parameters serviceType: { type: "string", enum: ["clusterip", "nodeport", "loadbalancer", "externalname"], - description: "Type of service to create (clusterip, nodeport, loadbalancer, externalname)" + description: + "Type of service to create (clusterip, nodeport, loadbalancer, externalname)", }, tcpPort: { type: "array", items: { type: "string" }, - description: "Port pairs for tcp service (e.g. [\"80:8080\", \"443:8443\"])" + description: + 'Port pairs for tcp service (e.g. ["80:8080", "443:8443"])', }, - + // Deployment specific parameters image: { type: "string", - description: "Image to use for the containers in the deployment" + description: "Image to use for the containers in the deployment", }, replicas: { type: "number", description: "Number of replicas to create for the deployment", - default: 1 + default: 1, }, port: { - type: "number", - description: "Port that the container exposes" + type: "number", + description: "Port that the container exposes", }, - + // CronJob specific parameters schedule: { type: "string", - description: "Cron schedule expression for the CronJob (e.g. \"*/5 * * * *\")" + description: + 'Cron schedule expression for the CronJob (e.g. "*/5 * * * *")', }, suspend: { type: "boolean", description: "Whether to suspend the CronJob", - default: false + default: false, }, - + // Job specific parameters command: { type: "array", items: { type: "string" }, - description: "Command to run in the container" + description: "Command to run in the container", }, - + // Additional common parameters labels: { type: "array", items: { type: "string" }, - description: "Labels to apply to the resource (e.g. [\"key1=value1\", \"key2=value2\"])" + description: + 'Labels to apply to the resource (e.g. ["key1=value1", "key2=value2"])', }, annotations: { type: "array", items: { type: "string" }, - description: "Annotations to apply to the resource (e.g. [\"key1=value1\", \"key2=value2\"])" - } + description: + 'Annotations to apply to the resource (e.g. ["key1=value1", "key2=value2"])', + }, }, required: [], }, @@ -146,35 +168,35 @@ export async function kubectlCreate( dryRun?: boolean; output?: string; validate?: boolean; - + // Create from file manifest?: string; filename?: string; - + // Resource type and common parameters resourceType?: string; name?: string; namespace?: string; - + // ConfigMap specific fromLiteral?: string[]; fromFile?: string[]; - + // Secret specific secretType?: "generic" | "docker-registry" | "tls"; - + // Service specific serviceType?: "clusterip" | "nodeport" | "loadbalancer" | "externalname"; tcpPort?: string[]; - + // Deployment specific image?: string; replicas?: number; port?: number; - + // Job specific command?: string[]; - + // Additional common parameters labels?: string[]; annotations?: string[]; @@ -190,24 +212,29 @@ export async function kubectlCreate( "Either manifest, filename, or resourceType must be provided" ); } - + // If resourceType is provided, check if name is provided for most resource types - if (input.resourceType && !input.name && input.resourceType !== "namespace") { + if ( + input.resourceType && + !input.name && + input.resourceType !== "namespace" + ) { throw new McpError( ErrorCode.InvalidRequest, `Name is required when creating a ${input.resourceType}` ); } - + // Set up common parameters const namespace = input.namespace || "default"; const dryRun = input.dryRun || false; const validate = input.validate ?? true; const output = input.output || "yaml"; - - let command = "kubectl create"; + + const command = "kubectl"; + const args = ["create"]; let tempFile: string | null = null; - + // Process manifest content if provided (file-based creation) if (input.manifest || input.filename) { if (input.manifest) { @@ -215,35 +242,35 @@ export async function kubectlCreate( const tmpDir = os.tmpdir(); tempFile = path.join(tmpDir, `create-manifest-${Date.now()}.yaml`); fs.writeFileSync(tempFile, input.manifest); - command += ` -f ${tempFile}`; + args.push("-f", tempFile); } else if (input.filename) { - command += ` -f ${input.filename}`; + args.push("-f", input.filename); } } else { // Process subcommand-based creation switch (input.resourceType?.toLowerCase()) { case "namespace": - command += ` namespace ${input.name}`; + args.push("namespace", input.name!); break; - + case "configmap": - command += ` configmap ${input.name}`; - + args.push("configmap", input.name!); + // Add --from-literal arguments if (input.fromLiteral && input.fromLiteral.length > 0) { - input.fromLiteral.forEach(literal => { - command += ` --from-literal=${literal}`; + input.fromLiteral.forEach((literal) => { + args.push(`--from-literal=${literal}`); }); } - + // Add --from-file arguments if (input.fromFile && input.fromFile.length > 0) { - input.fromFile.forEach(file => { - command += ` --from-file=${file}`; + input.fromFile.forEach((file) => { + args.push(`--from-file=${file}`); }); } break; - + case "secret": if (!input.secretType) { throw new McpError( @@ -251,40 +278,40 @@ export async function kubectlCreate( "secretType is required when creating a secret" ); } - - command += ` secret ${input.secretType} ${input.name}`; - + + args.push("secret", input.secretType, input.name!); + // Add --from-literal arguments if (input.fromLiteral && input.fromLiteral.length > 0) { - input.fromLiteral.forEach(literal => { - command += ` --from-literal=${literal}`; + input.fromLiteral.forEach((literal) => { + args.push(`--from-literal=${literal}`); }); } - + // Add --from-file arguments if (input.fromFile && input.fromFile.length > 0) { - input.fromFile.forEach(file => { - command += ` --from-file=${file}`; + input.fromFile.forEach((file) => { + args.push(`--from-file=${file}`); }); } break; - + case "service": if (!input.serviceType) { // Default to clusterip if not specified input.serviceType = "clusterip"; } - - command += ` service ${input.serviceType} ${input.name}`; - + + args.push("service", input.serviceType, input.name!); + // Add --tcp arguments for ports if (input.tcpPort && input.tcpPort.length > 0) { - input.tcpPort.forEach(port => { - command += ` --tcp=${port}`; + input.tcpPort.forEach((port) => { + args.push(`--tcp=${port}`); }); } break; - + case "cronjob": if (!input.image) { throw new McpError( @@ -292,27 +319,32 @@ export async function kubectlCreate( "image is required when creating a cronjob" ); } - + if (!input.schedule) { throw new McpError( ErrorCode.InvalidRequest, "schedule is required when creating a cronjob" ); } - - command += ` cronjob ${input.name} --image=${input.image} --schedule="${input.schedule}"`; - + + args.push( + "cronjob", + input.name!, + `--image=${input.image}`, + `--schedule=${input.schedule}` + ); + // Add command if specified if (input.command && input.command.length > 0) { - command += ` -- ${input.command.join(" ")}`; + args.push("--", ...input.command); } - + // Add suspend flag if specified if (input.suspend === true) { - command += ` --suspend`; + args.push(`--suspend`); } break; - + case "deployment": if (!input.image) { throw new McpError( @@ -320,20 +352,20 @@ export async function kubectlCreate( "image is required when creating a deployment" ); } - - command += ` deployment ${input.name} --image=${input.image}`; - + + args.push("deployment", input.name!, `--image=${input.image}`); + // Add replicas if specified if (input.replicas) { - command += ` --replicas=${input.replicas}`; + args.push(`--replicas=${input.replicas}`); } - + // Add port if specified if (input.port) { - command += ` --port=${input.port}`; + args.push(`--port=${input.port}`); } break; - + case "job": if (!input.image) { throw new McpError( @@ -341,15 +373,15 @@ export async function kubectlCreate( "image is required when creating a job" ); } - - command += ` job ${input.name} --image=${input.image}`; - + + args.push("job", input.name!, `--image=${input.image}`); + // Add command if specified if (input.command && input.command.length > 0) { - command += ` -- ${input.command.join(" ")}`; + args.push("--", ...input.command); } break; - + default: throw new McpError( ErrorCode.InvalidRequest, @@ -357,43 +389,46 @@ export async function kubectlCreate( ); } } - + // Add namespace if not creating a namespace itself if (input.resourceType !== "namespace") { - command += ` -n ${namespace}`; + args.push("-n", namespace); } - + // Add labels if specified if (input.labels && input.labels.length > 0) { - input.labels.forEach(label => { - command += ` -l ${label}`; + input.labels.forEach((label) => { + args.push("-l", label); }); } - + // Add annotations if specified if (input.annotations && input.annotations.length > 0) { - input.annotations.forEach(annotation => { - command += ` --annotation=${annotation}`; + input.annotations.forEach((annotation) => { + args.push(`--annotation=${annotation}`); }); } - + // Add dry-run flag if requested if (dryRun) { - command += " --dry-run=client"; + args.push("--dry-run=client"); } - + // Add validate flag if needed if (!validate) { - command += " --validate=false"; + args.push("--validate=false"); } - + // Add output format - command += ` -o ${output}`; - + args.push("-o", output); + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Clean up temp file if created if (tempFile) { try { @@ -402,7 +437,7 @@ export async function kubectlCreate( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + return { content: [ { @@ -420,7 +455,7 @@ export async function kubectlCreate( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + throw new McpError( ErrorCode.InternalError, `Failed to create resource: ${error.message}` @@ -430,10 +465,10 @@ export async function kubectlCreate( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl create command: ${error.message}` ); } -} \ No newline at end of file +} diff --git a/src/tools/kubectl-delete.ts b/src/tools/kubectl-delete.ts index ab61fbb..8da16eb 100644 --- a/src/tools/kubectl-delete.ts +++ b/src/tools/kubectl-delete.ts @@ -1,5 +1,5 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; @@ -7,49 +7,55 @@ import * as os from "os"; export const kubectlDeleteSchema = { name: "kubectl_delete", - description: "Delete Kubernetes resources by resource type, name, labels, or from a manifest file", + description: + "Delete Kubernetes resources by resource type, name, labels, or from a manifest file", inputSchema: { type: "object", properties: { - resourceType: { - type: "string", - description: "Type of resource to delete (e.g., pods, deployments, services, etc.)" + resourceType: { + type: "string", + description: + "Type of resource to delete (e.g., pods, deployments, services, etc.)", }, - name: { - type: "string", - description: "Name of the resource to delete" + name: { + type: "string", + description: "Name of the resource to delete", }, - namespace: { - type: "string", - description: "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", - default: "default" + namespace: { + type: "string", + description: + "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", + default: "default", }, labelSelector: { type: "string", - description: "Delete resources matching this label selector (e.g. 'app=nginx')" + description: + "Delete resources matching this label selector (e.g. 'app=nginx')", }, - manifest: { - type: "string", - description: "YAML manifest defining resources to delete (optional)" + manifest: { + type: "string", + description: "YAML manifest defining resources to delete (optional)", }, - filename: { - type: "string", - description: "Path to a YAML file to delete resources from (optional)" + filename: { + type: "string", + description: "Path to a YAML file to delete resources from (optional)", }, allNamespaces: { type: "boolean", description: "If true, delete resources across all namespaces", - default: false + default: false, }, force: { type: "boolean", - description: "If true, immediately remove resources from API and bypass graceful deletion", - default: false + description: + "If true, immediately remove resources from API and bypass graceful deletion", + default: false, }, gracePeriodSeconds: { type: "number", - description: "Period of time in seconds given to the resource to terminate gracefully" - } + description: + "Period of time in seconds given to the resource to terminate gracefully", + }, }, required: ["resourceType", "name", "namespace"], }, @@ -77,7 +83,7 @@ export async function kubectlDelete( "Either resourceType, manifest, or filename must be provided" ); } - + // If resourceType is provided, need either name or labelSelector if (input.resourceType && !input.name && !input.labelSelector) { throw new McpError( @@ -89,53 +95,61 @@ export async function kubectlDelete( const namespace = input.namespace || "default"; const allNamespaces = input.allNamespaces || false; const force = input.force || false; - - let command = "kubectl delete"; + + const command = "kubectl"; + const args = ["delete"]; let tempFile: string | null = null; - + // Handle deleting from manifest or file if (input.manifest) { // Create temporary file for the manifest const tmpDir = os.tmpdir(); tempFile = path.join(tmpDir, `delete-manifest-${Date.now()}.yaml`); fs.writeFileSync(tempFile, input.manifest); - command += ` -f ${tempFile}`; + args.push("-f", tempFile); } else if (input.filename) { - command += ` -f ${input.filename}`; + args.push("-f", input.filename); } else { // Handle deleting by resource type and name/selector - command += ` ${input.resourceType}`; - + args.push(input.resourceType!); + if (input.name) { - command += ` ${input.name}`; + args.push(input.name); } - + if (input.labelSelector) { - command += ` -l ${input.labelSelector}`; + args.push("-l", input.labelSelector); } } - + // Add namespace flags if (allNamespaces) { - command += " --all-namespaces"; - } else if (namespace && input.resourceType && !isNonNamespacedResource(input.resourceType)) { - command += ` -n ${namespace}`; + args.push("--all-namespaces"); + } else if ( + namespace && + input.resourceType && + !isNonNamespacedResource(input.resourceType) + ) { + args.push("-n", namespace); } - + // Add force flag if requested if (force) { - command += " --force"; + args.push("--force"); } - + // Add grace period if specified if (input.gracePeriodSeconds !== undefined) { - command += ` --grace-period=${input.gracePeriodSeconds}`; + args.push(`--grace-period=${input.gracePeriodSeconds}`); } - + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Clean up temp file if created if (tempFile) { try { @@ -144,7 +158,7 @@ export async function kubectlDelete( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + return { content: [ { @@ -162,7 +176,7 @@ export async function kubectlDelete( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + if (error.status === 404 || error.message.includes("not found")) { return { content: [ @@ -181,7 +195,7 @@ export async function kubectlDelete( isError: true, }; } - + throw new McpError( ErrorCode.InternalError, `Failed to delete resource: ${error.message}` @@ -191,7 +205,7 @@ export async function kubectlDelete( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl delete command: ${error.message}` @@ -202,14 +216,22 @@ export async function kubectlDelete( // 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", + "nodes", + "node", + "no", + "namespaces", + "namespace", + "ns", + "persistentvolumes", + "pv", + "storageclasses", + "sc", "clusterroles", "clusterrolebindings", - "customresourcedefinitions", "crd", "crds" + "customresourcedefinitions", + "crd", + "crds", ]; - + return nonNamespacedResources.includes(resourceType.toLowerCase()); -} +} diff --git a/src/tools/kubectl-describe.ts b/src/tools/kubectl-describe.ts index ea1c9a3..03e7a1c 100644 --- a/src/tools/kubectl-describe.ts +++ b/src/tools/kubectl-describe.ts @@ -1,31 +1,34 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlDescribeSchema = { name: "kubectl_describe", - description: "Describe Kubernetes resources by resource type, name, and optionally namespace", + description: + "Describe Kubernetes resources by resource type, name, and optionally namespace", inputSchema: { type: "object", properties: { - resourceType: { - type: "string", - description: "Type of resource to describe (e.g., pods, deployments, services, etc.)" + resourceType: { + type: "string", + description: + "Type of resource to describe (e.g., pods, deployments, services, etc.)", }, - name: { - type: "string", - description: "Name of the resource to describe" + name: { + type: "string", + description: "Name of the resource to describe", }, - namespace: { - type: "string", - description: "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", - default: "default" + namespace: { + type: "string", + description: + "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", + default: "default", }, allNamespaces: { type: "boolean", description: "If true, describe resources across all namespaces", - default: false - } + default: false, + }, }, required: ["resourceType", "name"], }, @@ -45,27 +48,25 @@ export async function kubectlDescribe( const name = input.name; const namespace = input.namespace || "default"; const allNamespaces = input.allNamespaces || false; - + // Build the kubectl command - let command = "kubectl describe "; - - // Add resource type - command += resourceType; - - // Add name - command += ` ${name}`; - + const command = "kubectl"; + const args = ["describe", resourceType, name]; + // Add namespace flag unless all namespaces is specified if (allNamespaces) { - command += " --all-namespaces"; + args.push("--all-namespaces"); } else if (namespace && !isNonNamespacedResource(resourceType)) { - command += ` -n ${namespace}`; + args.push("-n", namespace); } - + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + return { content: [ { @@ -93,7 +94,7 @@ export async function kubectlDescribe( isError: true, }; } - + throw new McpError( ErrorCode.InternalError, `Failed to describe resource: ${error.message}` @@ -110,14 +111,22 @@ export async function kubectlDescribe( // 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", + "nodes", + "node", + "no", + "namespaces", + "namespace", + "ns", + "persistentvolumes", + "pv", + "storageclasses", + "sc", "clusterroles", "clusterrolebindings", - "customresourcedefinitions", "crd", "crds" + "customresourcedefinitions", + "crd", + "crds", ]; - + return nonNamespacedResources.includes(resourceType.toLowerCase()); -} \ No newline at end of file +} diff --git a/src/tools/kubectl-generic.ts b/src/tools/kubectl-generic.ts index 9efb09c..b8a799e 100644 --- a/src/tools/kubectl-generic.ts +++ b/src/tools/kubectl-generic.ts @@ -1,52 +1,54 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlGenericSchema = { name: "kubectl_generic", - description: "Execute any kubectl command with the provided arguments and flags", + description: + "Execute any kubectl command with the provided arguments and flags", inputSchema: { type: "object", properties: { command: { type: "string", - description: "The kubectl command to execute (e.g. patch, rollout, top)" + description: + "The kubectl command to execute (e.g. patch, rollout, top)", }, subCommand: { type: "string", - description: "Subcommand if applicable (e.g. 'history' for rollout)" + description: "Subcommand if applicable (e.g. 'history' for rollout)", }, resourceType: { type: "string", - description: "Resource type (e.g. pod, deployment)" + description: "Resource type (e.g. pod, deployment)", }, name: { type: "string", - description: "Resource name" + description: "Resource name", }, namespace: { type: "string", description: "Namespace", - default: "default" + default: "default", }, outputFormat: { type: "string", description: "Output format (e.g. json, yaml, wide)", - enum: ["json", "yaml", "wide", "name", "custom"] + enum: ["json", "yaml", "wide", "name", "custom"], }, flags: { type: "object", description: "Command flags as key-value pairs", - additionalProperties: true + additionalProperties: true, }, args: { type: "array", items: { type: "string" }, - description: "Additional command arguments" - } + description: "Additional command arguments", + }, }, - required: ["command"] - } + required: ["command"], + }, }; export async function kubectlGeneric( @@ -64,33 +66,34 @@ export async function kubectlGeneric( ) { try { // Start building the kubectl command - let cmdArgs: string[] = ["kubectl", input.command]; - + const command = "kubectl"; + const cmdArgs: string[] = [input.command]; + // Add subcommand if provided if (input.subCommand) { cmdArgs.push(input.subCommand); } - + // Add resource type if provided if (input.resourceType) { cmdArgs.push(input.resourceType); } - + // Add resource name if provided if (input.name) { cmdArgs.push(input.name); } - + // Add namespace if provided if (input.namespace) { cmdArgs.push(`--namespace=${input.namespace}`); } - + // Add output format if provided if (input.outputFormat) { cmdArgs.push(`-o=${input.outputFormat}`); } - + // Add any provided flags if (input.flags) { for (const [key, value] of Object.entries(input.flags)) { @@ -103,18 +106,20 @@ export async function kubectlGeneric( } } } - + // Add any additional arguments if (input.args && input.args.length > 0) { cmdArgs.push(...input.args); } - - // Execute the command (join all args except the first "kubectl" which is used in execSync) - const command = cmdArgs.slice(1).join(' '); + + // Execute the command try { - console.error(`Executing: kubectl ${command}`); - const result = execSync(`kubectl ${command}`, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + console.error(`Executing: kubectl ${cmdArgs.join(" ")}`); + const result = execFileSync(command, cmdArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + return { content: [ { @@ -133,10 +138,10 @@ export async function kubectlGeneric( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl command: ${error.message}` ); } -} +} diff --git a/src/tools/kubectl-get.ts b/src/tools/kubectl-get.ts index aa023a7..1cc747f 100644 --- a/src/tools/kubectl-get.ts +++ b/src/tools/kubectl-get.ts @@ -1,5 +1,5 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlGetSchema = { @@ -38,17 +38,17 @@ export const kubectlGetSchema = { }, labelSelector: { type: "string", - description: "Filter resources by label selector (e.g. 'app=nginx')" + 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')" + "Filter resources by field selector (e.g. 'metadata.name=my-pod')", }, sortBy: { type: "string", description: - "Sort events by a field (default: lastTimestamp). Only applicable for events." + "Sort events by a field (default: lastTimestamp). Only applicable for events.", }, }, required: ["resourceType", "name", "namespace"], @@ -79,14 +79,12 @@ export async function kubectlGet( const sortBy = input.sortBy; // Build the kubectl command - let command = "kubectl get "; - - // Add resource type - command += resourceType; + const command = "kubectl"; + const args = ["get", resourceType]; // Add name if provided if (name) { - command += ` ${name}`; + args.push(name); } // For events, default to all namespaces unless explicitly specified @@ -99,48 +97,54 @@ export async function kubectlGet( // Add namespace flag unless all namespaces is specified if (shouldShowAllNamespaces) { - command += " --all-namespaces"; + args.push("--all-namespaces"); } else if (namespace && !isNonNamespacedResource(resourceType)) { - command += ` -n ${namespace}`; + args.push("-n", namespace); } // Add label selector if provided if (labelSelector) { - command += ` -l ${labelSelector}`; + args.push("-l", labelSelector); } // Add field selector if provided if (fieldSelector) { - command += ` --field-selector=${fieldSelector}`; + args.push(`--field-selector=${fieldSelector}`); } // Add sort-by for events if (resourceType === "events" && sortBy) { - command += ` --sort-by=.${sortBy}`; + args.push(`--sort-by=.${sortBy}`); } else if (resourceType === "events") { - command += ` --sort-by=.lastTimestamp`; + args.push(`--sort-by=.lastTimestamp`); } // Add output format if (output === "json") { - command += " -o json"; + args.push("-o", "json"); } else if (output === "yaml") { - command += " -o yaml"; + args.push("-o", "yaml"); } else if (output === "wide") { - command += " -o wide"; + args.push("-o", "wide"); } else if (output === "name") { - command += " -o name"; + args.push("-o", "name"); } else if (output === "custom") { if (resourceType === "events") { - command += ` -o 'custom-columns=LAST SEEN:.lastTimestamp,TYPE:.type,REASON:.reason,OBJECT:.involvedObject.kind/.involvedObject.name,MESSAGE:.message'`; + args.push( + "-o", + "'custom-columns=LASTSEEN:.lastTimestamp,TYPE:.type,REASON:.reason,OBJECT:.involvedObject.name,MESSAGE:.message'" + ); } else { - command += ` -o 'custom-columns=NAME:.metadata.name,NAMESPACE:.metadata.namespace,STATUS:.status.phase,AGE:.metadata.creationTimestamp'`; + args.push( + "-o", + "'custom-columns=NAME:.metadata.name,NAMESPACE:.metadata.namespace,STATUS:.status.phase,AGE:.metadata.creationTimestamp'" + ); } } // Execute the command try { - const result = execSync(command, { + const result = execFileSync(command, args, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); diff --git a/src/tools/kubectl-logs.ts b/src/tools/kubectl-logs.ts index 385c9b6..b769492 100644 --- a/src/tools/kubectl-logs.ts +++ b/src/tools/kubectl-logs.ts @@ -1,10 +1,11 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlLogsSchema = { name: "kubectl_logs", - description: "Get logs from Kubernetes resources like pods, deployments, or jobs", + description: + "Get logs from Kubernetes resources like pods, deployments, or jobs", inputSchema: { type: "object", properties: { @@ -24,19 +25,20 @@ export const kubectlLogsSchema = { }, container: { type: "string", - description: "Container name (required when pod has multiple containers)" + description: + "Container name (required when pod has multiple containers)", }, tail: { type: "number", - description: "Number of lines to show from end of logs" + description: "Number of lines to show from end of logs", }, since: { type: "string", - description: "Show logs since relative time (e.g. '5s', '2m', '3h')" + description: "Show logs since relative time (e.g. '5s', '2m', '3h')", }, sinceTime: { type: "string", - description: "Show logs since absolute time (RFC3339)" + description: "Show logs since absolute time (RFC3339)", }, timestamps: { type: "boolean", @@ -55,8 +57,8 @@ export const kubectlLogsSchema = { }, labelSelector: { type: "string", - description: "Filter resources by label selector" - } + description: "Filter resources by label selector", + }, }, required: ["resourceType", "name", "namespace"], }, @@ -82,46 +84,72 @@ export async function kubectlLogs( const resourceType = input.resourceType.toLowerCase(); const name = input.name; const namespace = input.namespace || "default"; - - // Build the kubectl command base - let baseCommand = `kubectl -n ${namespace}`; - + + const command = "kubectl"; // Handle different resource types if (resourceType === "pod") { // Direct pod logs - baseCommand += ` logs ${name}`; - + let args = ["-n", namespace, "logs", name]; + // If container is specified, add it if (input.container) { - baseCommand += ` -c ${input.container}`; + args.push(`-c`, input.container); } - + // Add options - baseCommand = addLogOptions(baseCommand, input); - + args = addLogOptions(args, input); + // Execute the command try { - const result = execSync(baseCommand, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); return formatLogOutput(name, result); } catch (error: any) { return handleCommandError(error, `pod ${name}`); } - } else if (resourceType === "deployment" || resourceType === "job" || resourceType === "cronjob") { + } else if ( + resourceType === "deployment" || + resourceType === "job" || + resourceType === "cronjob" + ) { // For deployments, jobs and cronjobs we need to find the pods first - let selectorCommand; - + let selectorArgs; + if (resourceType === "deployment") { - selectorCommand = `kubectl -n ${namespace} get deployment ${name} -o jsonpath='{.spec.selector.matchLabels}'`; + selectorArgs = [ + "-n", + namespace, + "get", + "deployment", + name, + "-o", + "jsonpath='{.spec.selector.matchLabels}'", + ]; } else if (resourceType === "job") { // For jobs, we use the job-name label return getLabelSelectorLogs(`job-name=${name}`, namespace, input); } else if (resourceType === "cronjob") { // For cronjobs, it's more complex - need to find the job first - const jobsCommand = `kubectl -n ${namespace} get jobs --selector=job-name=${name} -o jsonpath='{.items[*].metadata.name}'`; + const jobsArgs = [ + "-n", + namespace, + "get", + "jobs", + "--selector=job-name=" + name, + "-o", + "jsonpath='{.items[*].metadata.name}'", + ]; try { - const jobs = execSync(jobsCommand, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }).trim().split(' '); - - if (jobs.length === 0 || (jobs.length === 1 && jobs[0] === '')) { + const jobs = execFileSync(command, jobsArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }) + .trim() + .split(" "); + + if (jobs.length === 0 || (jobs.length === 1 && jobs[0] === "")) { return { content: [ { @@ -137,17 +165,21 @@ export async function kubectlLogs( ], }; } - + // Get logs for all jobs const allJobLogs: Record = {}; - + for (const job of jobs) { // Get logs for pods from this job - const result = await getLabelSelectorLogs(`job-name=${job}`, namespace, input); + const result = await getLabelSelectorLogs( + `job-name=${job}`, + namespace, + input + ); const jobLog = JSON.parse(result.content[0].text); allJobLogs[job] = jobLog.logs; } - + return { content: [ { @@ -168,24 +200,27 @@ export async function kubectlLogs( return handleCommandError(error, `cronjob ${name}`); } } - + try { if (resourceType === "deployment") { // Get the deployment's selector - if (!selectorCommand) { + if (!selectorArgs) { throw new Error("Selector command is undefined"); } - const selectorJson = execSync(selectorCommand, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }).trim(); + const selectorJson = execFileSync(command, selectorArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }).trim(); const selector = JSON.parse(selectorJson.replace(/'/g, '"')); - + // Convert to label selector format const labelSelector = Object.entries(selector) .map(([key, value]) => `${key}=${value}`) - .join(','); - + .join(","); + return getLabelSelectorLogs(labelSelector, namespace, input); } - + // For jobs and cronjobs, the logic is handled above return { content: [ @@ -224,35 +259,33 @@ export async function kubectlLogs( } // Helper function to add log options to the kubectl command -function addLogOptions(baseCommand: string, input: any): string { - let command = baseCommand; - +function addLogOptions(args: string[], input: any): string[] { // Add options based on inputs if (input.tail !== undefined) { - command += ` --tail=${input.tail}`; + args.push(`--tail=${input.tail}`); } - + if (input.since) { - command += ` --since=${input.since}`; + args.push(`--since=${input.since}`); } - + if (input.sinceTime) { - command += ` --since-time=${input.sinceTime}`; + args.push(`--since-time=${input.sinceTime}`); } - + if (input.timestamps) { - command += ` --timestamps`; + args.push(`--timestamps`); } - + if (input.previous) { - command += ` --previous`; + args.push(`--previous`); } - + if (input.follow) { - command += ` --follow`; + args.push(`--follow`); } - - return command; + + return args; } // Helper function to get logs for resources selected by labels @@ -262,11 +295,25 @@ async function getLabelSelectorLogs( input: any ): Promise<{ content: Array<{ type: string; text: string }> }> { try { + const command = "kubectl"; // First, find all pods matching the label selector - const podsCommand = `kubectl -n ${namespace} get pods --selector=${labelSelector} -o jsonpath='{.items[*].metadata.name}'`; - const pods = execSync(podsCommand, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }).trim().split(' '); - - if (pods.length === 0 || (pods.length === 1 && pods[0] === '')) { + const podsArgs = [ + "-n", + namespace, + "get", + "pods", + `--selector=${labelSelector}`, + "-o", + "jsonpath='{.items[*].metadata.name}'", + ]; + const pods = execFileSync(command, podsArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }) + .trim() + .split(" "); + + if (pods.length === 0 || (pods.length === 1 && pods[0] === "")) { return { content: [ { @@ -282,32 +329,35 @@ async function getLabelSelectorLogs( ], }; } - + // Get logs for each pod const logsMap: Record = {}; - + for (const pod of pods) { // Skip empty pod names if (!pod) continue; - - let podCommand = `kubectl -n ${namespace} logs ${pod}`; - + + let podArgs = ["-n", namespace, "logs", pod]; + // Add container if specified if (input.container) { - podCommand += ` -c ${input.container}`; + podArgs.push(`-c`, input.container); } - + // Add other options - podCommand = addLogOptions(podCommand, input); - + podArgs = addLogOptions(podArgs, input); + try { - const logs = execSync(podCommand, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); + const logs = execFileSync(command, podArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); logsMap[pod] = logs; } catch (error: any) { logsMap[pod] = `Error: ${error.message}`; } } - + return { content: [ { @@ -351,7 +401,7 @@ function formatLogOutput(resourceName: string, logOutput: string) { // Helper function to handle command errors function handleCommandError(error: any, resourceDescription: string) { console.error(`Error getting logs for ${resourceDescription}:`, error); - + if (error.status === 404 || error.message.includes("not found")) { return { content: [ @@ -370,18 +420,24 @@ function handleCommandError(error: any, resourceDescription: string) { isError: true, }; } - + // Check for multi-container pod error if (error.message.includes("a container name must be specified")) { // Extract pod name and available containers from error message const podNameMatch = error.message.match(/for pod ([^,]+)/); const containersMatch = error.message.match(/choose one of: \[([^\]]+)\]/); - const initContainersMatch = error.message.match(/or one of the init containers: \[([^\]]+)\]/); - - const podName = podNameMatch ? podNameMatch[1] : 'unknown'; - const containers = containersMatch ? containersMatch[1].split(' ').map((c: string) => c.trim()) : []; - const initContainers = initContainersMatch ? initContainersMatch[1].split(' ').map((c: string) => c.trim()) : []; - + const initContainersMatch = error.message.match( + /or one of the init containers: \[([^\]]+)\]/ + ); + + const podName = podNameMatch ? podNameMatch[1] : "unknown"; + const containers = containersMatch + ? containersMatch[1].split(" ").map((c: string) => c.trim()) + : []; + const initContainers = initContainersMatch + ? initContainersMatch[1].split(" ").map((c: string) => c.trim()) + : []; + // Generate structured context for the MCP client to make decisions const context = { error: "Multi-container pod requires container specification", @@ -389,9 +445,15 @@ function handleCommandError(error: any, resourceDescription: string) { pod_name: podName, available_containers: containers, init_containers: initContainers, - suggestion: `Please specify a container name using the 'container' parameter. Available containers: ${containers.join(', ')}${initContainers.length > 0 ? `. Init containers: ${initContainers.join(', ')}` : ''}` + suggestion: `Please specify a container name using the 'container' parameter. Available containers: ${containers.join( + ", " + )}${ + initContainers.length > 0 + ? `. Init containers: ${initContainers.join(", ")}` + : "" + }`, }; - + return { content: [ { @@ -402,7 +464,7 @@ function handleCommandError(error: any, resourceDescription: string) { isError: true, }; } - + return { content: [ { @@ -411,7 +473,7 @@ function handleCommandError(error: any, resourceDescription: string) { { error: `Failed to get logs for ${resourceDescription}: ${error.message}`, status: "general_error", - original_error: error.message + original_error: error.message, }, null, 2 @@ -420,4 +482,4 @@ function handleCommandError(error: any, resourceDescription: string) { ], isError: true, }; -} +} diff --git a/src/tools/kubectl-operations.ts b/src/tools/kubectl-operations.ts index 1b93714..9d8735f 100644 --- a/src/tools/kubectl-operations.ts +++ b/src/tools/kubectl-operations.ts @@ -1,4 +1,4 @@ -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { ExplainResourceParams, ListApiResourcesParams, @@ -66,9 +66,12 @@ export const listApiResourcesSchema = { }, }; -const executeKubectlCommand = (command: string): string => { +const executeKubectlCommand = (command: string, args: string[]): string => { try { - return execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); + return execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); } catch (error: any) { throw new Error(`Kubectl command failed: ${error.message}`); } @@ -78,23 +81,24 @@ export async function explainResource( params: ExplainResourceParams ): Promise<{ content: { type: string; text: string }[] }> { try { - let command = "kubectl explain"; + const command = "kubectl"; + const args = ["explain"]; if (params.apiVersion) { - command += ` --api-version=${params.apiVersion}`; + args.push(`--api-version=${params.apiVersion}`); } if (params.recursive) { - command += " --recursive"; + args.push("--recursive"); } if (params.output) { - command += ` --output=${params.output}`; + args.push(`--output=${params.output}`); } - command += ` ${params.resource}`; + args.push(params.resource); - const result = executeKubectlCommand(command); + const result = executeKubectlCommand(command, args); return { content: [ @@ -113,25 +117,26 @@ export async function listApiResources( params: ListApiResourcesParams ): Promise<{ content: { type: string; text: string }[] }> { try { - let command = "kubectl api-resources"; + const command = "kubectl"; + const args = ["api-resources"]; if (params.apiGroup) { - command += ` --api-group=${params.apiGroup}`; + args.push(`--api-group=${params.apiGroup}`); } if (params.namespaced !== undefined) { - command += ` --namespaced=${params.namespaced}`; + args.push(`--namespaced=${params.namespaced}`); } if (params.verbs && params.verbs.length > 0) { - command += ` --verbs=${params.verbs.join(",")}`; + args.push(`--verbs=${params.verbs.join(",")}`); } if (params.output) { - command += ` -o ${params.output}`; + args.push(`-o`, params.output); } - const result = executeKubectlCommand(command); + const result = executeKubectlCommand(command, args); return { content: [ diff --git a/src/tools/kubectl-patch.ts b/src/tools/kubectl-patch.ts index 53c8409..1894ac6 100644 --- a/src/tools/kubectl-patch.ts +++ b/src/tools/kubectl-patch.ts @@ -1,5 +1,5 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; @@ -7,45 +7,49 @@ import * as os from "os"; export const kubectlPatchSchema = { name: "kubectl_patch", - description: "Update field(s) of a resource using strategic merge patch, JSON merge patch, or JSON patch", + description: + "Update field(s) of a resource using strategic merge patch, JSON merge patch, or JSON patch", inputSchema: { type: "object", properties: { - resourceType: { - type: "string", - description: "Type of resource to patch (e.g., pods, deployments, services)" + resourceType: { + type: "string", + description: + "Type of resource to patch (e.g., pods, deployments, services)", }, - name: { - type: "string", - description: "Name of the resource to patch" + name: { + type: "string", + description: "Name of the resource to patch", }, - namespace: { - type: "string", - description: "Namespace of the resource", - default: "default" + namespace: { + type: "string", + description: "Namespace of the resource", + default: "default", }, patchType: { type: "string", description: "Type of patch to apply", enum: ["strategic", "merge", "json"], - default: "strategic" + default: "strategic", }, patchData: { type: "object", - description: "Patch data as a JSON object" + description: "Patch data as a JSON object", }, patchFile: { type: "string", - description: "Path to a file containing the patch data (alternative to patchData)" + description: + "Path to a file containing the patch data (alternative to patchData)", }, dryRun: { type: "boolean", - description: "If true, only print the object that would be sent, without sending it", - default: false - } + description: + "If true, only print the object that would be sent, without sending it", + default: false, + }, }, required: ["resourceType", "name"], - } + }, }; export async function kubectlPatch( @@ -72,45 +76,48 @@ export async function kubectlPatch( const patchType = input.patchType || "strategic"; const dryRun = input.dryRun || false; let tempFile: string | null = null; - - // Build the kubectl patch command - let command = `kubectl patch ${input.resourceType} ${input.name} -n ${namespace}`; - + + const command = "kubectl"; + const args = ["patch", input.resourceType, input.name, "-n", namespace]; + // Add patch type flag switch (patchType) { case "strategic": - command += " --type strategic"; + args.push("--type", "strategic"); break; case "merge": - command += " --type merge"; + args.push("--type", "merge"); break; case "json": - command += " --type json"; + args.push("--type", "json"); break; default: - command += " --type strategic"; + args.push("--type", "strategic"); } - + // Handle patch data if (input.patchData) { // Create a temporary file for the patch data const tmpDir = os.tmpdir(); tempFile = path.join(tmpDir, `patch-${Date.now()}.json`); fs.writeFileSync(tempFile, JSON.stringify(input.patchData)); - command += ` --patch-file ${tempFile}`; + args.push("--patch-file", tempFile); } else if (input.patchFile) { - command += ` --patch-file ${input.patchFile}`; + args.push("--patch-file", input.patchFile); } - + // Add dry-run flag if requested if (dryRun) { - command += " --dry-run=client"; + args.push("--dry-run=client"); } - + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Clean up temp file if created if (tempFile) { try { @@ -119,7 +126,7 @@ export async function kubectlPatch( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + return { content: [ { @@ -137,7 +144,7 @@ export async function kubectlPatch( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + throw new McpError( ErrorCode.InternalError, `Failed to patch resource: ${error.message}` @@ -147,10 +154,10 @@ export async function kubectlPatch( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl patch command: ${error.message}` ); } -} \ No newline at end of file +} diff --git a/src/tools/kubectl-rollout.ts b/src/tools/kubectl-rollout.ts index f3eed7f..dc237ae 100644 --- a/src/tools/kubectl-rollout.ts +++ b/src/tools/kubectl-rollout.ts @@ -1,10 +1,11 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlRolloutSchema = { name: "kubectl_rollout", - description: "Manage the rollout of a resource (e.g., deployment, daemonset, statefulset)", + description: + "Manage the rollout of a resource (e.g., deployment, daemonset, statefulset)", inputSchema: { type: "object", properties: { @@ -12,43 +13,44 @@ export const kubectlRolloutSchema = { type: "string", description: "Rollout subcommand to execute", enum: ["history", "pause", "restart", "resume", "status", "undo"], - default: "status" + default: "status", }, resourceType: { type: "string", description: "Type of resource to manage rollout for", enum: ["deployment", "daemonset", "statefulset"], - default: "deployment" + default: "deployment", }, name: { type: "string", - description: "Name of the resource" + description: "Name of the resource", }, namespace: { type: "string", description: "Namespace of the resource", - default: "default" + default: "default", }, revision: { type: "number", - description: "Revision to rollback to (for undo subcommand)" + description: "Revision to rollback to (for undo subcommand)", }, toRevision: { type: "number", - description: "Revision to roll back to (for history subcommand)" + description: "Revision to roll back to (for history subcommand)", }, timeout: { type: "string", - description: "The length of time to wait before giving up (e.g., '30s', '1m', '2m30s')" + description: + "The length of time to wait before giving up (e.g., '30s', '1m', '2m30s')", }, watch: { type: "boolean", description: "Watch the rollout status in real-time until completion", - default: false - } + default: false, + }, }, - required: ["subCommand", "resourceType", "name", "namespace"] - } + required: ["subCommand", "resourceType", "name", "namespace"], + }, }; export async function kubectlRollout( @@ -67,50 +69,61 @@ export async function kubectlRollout( try { const namespace = input.namespace || "default"; const watch = input.watch || false; - - // Build the kubectl rollout command - let command = `kubectl rollout ${input.subCommand} ${input.resourceType}/${input.name} -n ${namespace}`; - + + const command = "kubectl"; + const args = [ + "rollout", + input.subCommand, + `${input.resourceType}/${input.name}`, + "-n", + namespace, + ]; + // Add revision for undo if (input.subCommand === "undo" && input.revision !== undefined) { - command += ` --to-revision=${input.revision}`; + args.push(`--to-revision=${input.revision}`); } - + // Add revision for history if (input.subCommand === "history" && input.toRevision !== undefined) { - command += ` --revision=${input.toRevision}`; + args.push(`--revision=${input.toRevision}`); } - + // Add timeout if specified if (input.timeout) { - command += ` --timeout=${input.timeout}`; + args.push(`--timeout=${input.timeout}`); } - + // Execute the command try { // For status command with watch flag, we need to handle it differently // since it's meant to be interactive and follow the progress if (input.subCommand === "status" && watch) { - command += " --watch"; + args.push("--watch"); // For watch we are limited in what we can do - we'll execute it with a reasonable timeout // and capture the output until that point - const result = execSync(command, { + const result = execFileSync(command, args, { encoding: "utf8", timeout: 15000, // Reduced from 30 seconds to 15 seconds - env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); - + return { content: [ { type: "text", - text: result + "\n\nNote: Watch operation was limited to 15 seconds. The rollout may still be in progress.", + text: + result + + "\n\nNote: Watch operation was limited to 15 seconds. The rollout may still be in progress.", }, ], }; } else { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + return { content: [ { @@ -130,10 +143,10 @@ export async function kubectlRollout( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl rollout command: ${error.message}` ); } -} +} diff --git a/src/tools/kubectl-scale.ts b/src/tools/kubectl-scale.ts index 09d9938..3643cff 100644 --- a/src/tools/kubectl-scale.ts +++ b/src/tools/kubectl-scale.ts @@ -1,5 +1,5 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlScaleSchema = { @@ -8,27 +8,28 @@ export const kubectlScaleSchema = { inputSchema: { type: "object", properties: { - name: { + name: { type: "string", - description: "Name of the deployment to scale" + description: "Name of the deployment to scale", }, - namespace: { + namespace: { type: "string", description: "Namespace of the deployment", - default: "default" + default: "default", }, - replicas: { + replicas: { type: "number", - description: "Number of replicas to scale to" + description: "Number of replicas to scale to", }, resourceType: { type: "string", - description: "Resource type to scale (deployment, replicaset, statefulset)", - default: "deployment" - } + description: + "Resource type to scale (deployment, replicaset, statefulset)", + default: "deployment", + }, }, - required: ["name", "replicas"] - } + required: ["name", "replicas"], + }, }; export async function kubectlScale( @@ -43,21 +44,30 @@ export async function kubectlScale( try { const namespace = input.namespace || "default"; const resourceType = input.resourceType || "deployment"; - - // Build the kubectl scale command - let command = `kubectl scale ${resourceType} ${input.name} --replicas=${input.replicas} --namespace=${namespace}`; - + + const command = "kubectl"; + const args = [ + "scale", + resourceType, + input.name, + `--replicas=${input.replicas}`, + `--namespace=${namespace}`, + ]; + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + return { content: [ { success: true, - message: `Scaled ${resourceType} ${input.name} to ${input.replicas} replicas` - } - ] + message: `Scaled ${resourceType} ${input.name} to ${input.replicas} replicas`, + }, + ], }; } catch (error: any) { throw new McpError( @@ -71,19 +81,19 @@ export async function kubectlScale( content: [ { success: false, - message: error.message - } - ] + message: error.message, + }, + ], }; } - + return { content: [ { success: false, - message: `Failed to scale resource: ${error.message}` - } - ] + message: `Failed to scale resource: ${error.message}`, + }, + ], }; } -} \ No newline at end of file +} diff --git a/tests/kubectl.test.ts b/tests/kubectl.test.ts index ca351ce..6c8d634 100644 --- a/tests/kubectl.test.ts +++ b/tests/kubectl.test.ts @@ -192,7 +192,7 @@ describe("kubectl operations", () => { arguments: { resourceType: "events", namespace: "default", - output: "json" + output: "json", }, }, }, @@ -231,7 +231,7 @@ describe("kubectl operations", () => { arguments: { resourceType: "events", allNamespaces: true, - output: "json" + output: "json", }, }, }, @@ -256,7 +256,7 @@ describe("kubectl operations", () => { resourceType: "events", namespace: "default", fieldSelector: "type=Normal", - output: "json" + output: "json", }, }, }, @@ -293,7 +293,7 @@ describe("kubectl operations", () => { arguments: { resourceType: "events", namespace: "default", - output: "json" + output: "json", }, }, }, @@ -332,7 +332,7 @@ describe("kubectl operations", () => { arguments: { resourceType: "events", allNamespaces: true, - output: "json" + output: "json", }, }, }, @@ -357,7 +357,7 @@ describe("kubectl operations", () => { resourceType: "events", namespace: "default", fieldSelector: "type=Normal", - output: "json" + output: "json", }, }, }, @@ -389,7 +389,7 @@ describe("kubectl operations", () => { resourceType: "events", namespace: "default", sortBy: "type", - output: "json" + output: "json", }, }, }, @@ -402,32 +402,5 @@ describe("kubectl operations", () => { expect(events.events).toBeDefined(); expect(Array.isArray(events.events)).toBe(true); }); - - test("get events with custom output format using kubectl-get", async () => { - const result = await retry(async () => { - return await client.request( - { - method: "tools/call", - params: { - name: "kubectl_get", - arguments: { - resourceType: "events", - namespace: "default", - output: "custom" - }, - }, - }, - asResponseSchema(KubectlResponseSchema) - ); - }); - - expect(result.content[0].type).toBe("text"); - const output = result.content[0].text; - expect(output).toContain("LAST SEEN"); - expect(output).toContain("TYPE"); - expect(output).toContain("REASON"); - expect(output).toContain("OBJECT"); - expect(output).toContain("MESSAGE"); - }); }); }); From 6e7357d93bc15fc018e7935212ad2c063201bad4 Mon Sep 17 00:00:00 2001 From: Suyog Sonwalkar Date: Wed, 2 Jul 2025 08:00:45 -0700 Subject: [PATCH 04/35] Adding SPAWN_MAX_BUFFER env var option Should allow #172 to be fixed by an optional env var for large kubernetes clusters. Summary: Test Plan: --- .gitignore | 1 - ADVANCED_README.md | 18 ++++++++++++++++++ src/config/max-buffer.ts | 3 +++ src/tools/helm-operations.ts | 2 ++ src/tools/kubectl-apply.ts | 2 ++ src/tools/kubectl-context.ts | 7 +++++++ src/tools/kubectl-create.ts | 2 ++ src/tools/kubectl-delete.ts | 2 ++ src/tools/kubectl-describe.ts | 2 ++ src/tools/kubectl-generic.ts | 2 ++ src/tools/kubectl-get.ts | 2 ++ src/tools/kubectl-logs.ts | 6 ++++++ src/tools/kubectl-operations.ts | 2 ++ src/tools/kubectl-patch.ts | 2 ++ src/tools/kubectl-rollout.ts | 3 +++ src/tools/kubectl-scale.ts | 2 ++ src/tools/ping.ts | 5 +++-- 17 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 src/config/max-buffer.ts diff --git a/.gitignore b/.gitignore index 4d7eb0f..4652129 100644 --- a/.gitignore +++ b/.gitignore @@ -126,7 +126,6 @@ dist # Stores VSCode versions used for testing VSCode extensions .vscode-test -.vscode/ # yarn v2 .yarn/cache diff --git a/ADVANCED_README.md b/ADVANCED_README.md index 08003ee..0f2c879 100644 --- a/ADVANCED_README.md +++ b/ADVANCED_README.md @@ -1,5 +1,23 @@ # Advanced README for mcp-server-kubernetes +## Large clusters + +If you have large clusters or see a `spawnSync ENOBFUS` error, you may need to specify the environment argument `SPAWN_MAX_BUFFER` (in bytes) when running the server. See [this issue](https://github.com/Flux159/mcp-server-kubernetes/issues/172) for more information. + +```json +{ + "mcpServers": { + "kubernetes-readonly": { + "command": "npx", + "args": ["mcp-server-kubernetes"], + "env": { + "SPAWN_MAX_BUFFER": "5242880" // 5MB = 1024*1024*5. Default is 1MB in Node.js + } + } + } +} +``` + ## Authentication Options The server supports multiple authentication methods with the following priority order: diff --git a/src/config/max-buffer.ts b/src/config/max-buffer.ts new file mode 100644 index 0000000..7cef93d --- /dev/null +++ b/src/config/max-buffer.ts @@ -0,0 +1,3 @@ +export function getSpawnMaxBuffer(): number { + return parseInt(process.env.SPAWN_MAX_BUFFER || "1048577", 10); +} diff --git a/src/tools/helm-operations.ts b/src/tools/helm-operations.ts index 2827d3d..3e53660 100644 --- a/src/tools/helm-operations.ts +++ b/src/tools/helm-operations.ts @@ -7,6 +7,7 @@ import { HelmResponse, HelmUpgradeOperation, } from "../models/helm-models.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; export const installHelmChartSchema = { name: "install_helm_chart", @@ -99,6 +100,7 @@ const executeHelmCommand = (command: string, args: string[]): string => { return execFileSync(command, args, { encoding: "utf8", timeout: 60000, // 60 seconds timeout + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); } catch (error: any) { diff --git a/src/tools/kubectl-apply.ts b/src/tools/kubectl-apply.ts index 75e3dd9..495e8d9 100644 --- a/src/tools/kubectl-apply.ts +++ b/src/tools/kubectl-apply.ts @@ -4,6 +4,7 @@ import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; export const kubectlApplySchema = { name: "kubectl_apply", @@ -95,6 +96,7 @@ export async function kubectlApply( try { const result = execFileSync(command, args, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); diff --git a/src/tools/kubectl-context.ts b/src/tools/kubectl-context.ts index 0eee553..5dfd2a9 100644 --- a/src/tools/kubectl-context.ts +++ b/src/tools/kubectl-context.ts @@ -1,6 +1,7 @@ import { KubernetesManager } from "../types.js"; import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; export const kubectlContextSchema = { name: "kubectl_context", @@ -72,6 +73,7 @@ export async function kubectlContext( // For custom or JSON output, we'll format it ourselves const rawResult = execFileSync(command, listArgs, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); @@ -111,6 +113,7 @@ export async function kubectlContext( // Execute the command for non-json outputs result = execFileSync(command, listArgs, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); break; @@ -123,6 +126,7 @@ export async function kubectlContext( try { const currentContext = execFileSync(command, getArgs, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }).trim(); @@ -133,6 +137,7 @@ export async function kubectlContext( ["config", "get-contexts"], { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, } ); @@ -233,6 +238,7 @@ export async function kubectlContext( ["config", "get-contexts", "-o", "name"], { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, } ); @@ -264,6 +270,7 @@ export async function kubectlContext( // Execute the command result = execFileSync(command, setArgs, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); diff --git a/src/tools/kubectl-create.ts b/src/tools/kubectl-create.ts index 2228ce7..2284574 100644 --- a/src/tools/kubectl-create.ts +++ b/src/tools/kubectl-create.ts @@ -4,6 +4,7 @@ import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; export const kubectlCreateSchema = { name: "kubectl_create", @@ -426,6 +427,7 @@ export async function kubectlCreate( try { const result = execFileSync(command, args, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); diff --git a/src/tools/kubectl-delete.ts b/src/tools/kubectl-delete.ts index 8da16eb..ca3f5ad 100644 --- a/src/tools/kubectl-delete.ts +++ b/src/tools/kubectl-delete.ts @@ -4,6 +4,7 @@ import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; export const kubectlDeleteSchema = { name: "kubectl_delete", @@ -147,6 +148,7 @@ export async function kubectlDelete( try { const result = execFileSync(command, args, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); diff --git a/src/tools/kubectl-describe.ts b/src/tools/kubectl-describe.ts index 03e7a1c..bb7156d 100644 --- a/src/tools/kubectl-describe.ts +++ b/src/tools/kubectl-describe.ts @@ -1,6 +1,7 @@ import { KubernetesManager } from "../types.js"; import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; export const kubectlDescribeSchema = { name: "kubectl_describe", @@ -64,6 +65,7 @@ export async function kubectlDescribe( try { const result = execFileSync(command, args, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); diff --git a/src/tools/kubectl-generic.ts b/src/tools/kubectl-generic.ts index b8a799e..10c721e 100644 --- a/src/tools/kubectl-generic.ts +++ b/src/tools/kubectl-generic.ts @@ -1,6 +1,7 @@ import { KubernetesManager } from "../types.js"; import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; export const kubectlGenericSchema = { name: "kubectl_generic", @@ -117,6 +118,7 @@ export async function kubectlGeneric( console.error(`Executing: kubectl ${cmdArgs.join(" ")}`); const result = execFileSync(command, cmdArgs, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); diff --git a/src/tools/kubectl-get.ts b/src/tools/kubectl-get.ts index 1cc747f..f0fe4d7 100644 --- a/src/tools/kubectl-get.ts +++ b/src/tools/kubectl-get.ts @@ -1,6 +1,7 @@ import { KubernetesManager } from "../types.js"; import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; export const kubectlGetSchema = { name: "kubectl_get", @@ -146,6 +147,7 @@ export async function kubectlGet( try { const result = execFileSync(command, args, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); diff --git a/src/tools/kubectl-logs.ts b/src/tools/kubectl-logs.ts index b769492..f33fca0 100644 --- a/src/tools/kubectl-logs.ts +++ b/src/tools/kubectl-logs.ts @@ -1,6 +1,7 @@ import { KubernetesManager } from "../types.js"; import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; export const kubectlLogsSchema = { name: "kubectl_logs", @@ -103,6 +104,7 @@ export async function kubectlLogs( try { const result = execFileSync(command, args, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); return formatLogOutput(name, result); @@ -144,6 +146,7 @@ export async function kubectlLogs( try { const jobs = execFileSync(command, jobsArgs, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }) .trim() @@ -209,6 +212,7 @@ export async function kubectlLogs( } const selectorJson = execFileSync(command, selectorArgs, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }).trim(); const selector = JSON.parse(selectorJson.replace(/'/g, '"')); @@ -308,6 +312,7 @@ async function getLabelSelectorLogs( ]; const pods = execFileSync(command, podsArgs, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }) .trim() @@ -350,6 +355,7 @@ async function getLabelSelectorLogs( try { const logs = execFileSync(command, podArgs, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); logsMap[pod] = logs; diff --git a/src/tools/kubectl-operations.ts b/src/tools/kubectl-operations.ts index 9d8735f..3f9a366 100644 --- a/src/tools/kubectl-operations.ts +++ b/src/tools/kubectl-operations.ts @@ -3,6 +3,7 @@ import { ExplainResourceParams, ListApiResourcesParams, } from "../models/kubectl-models.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; export const explainResourceSchema = { name: "explain_resource", @@ -70,6 +71,7 @@ const executeKubectlCommand = (command: string, args: string[]): string => { try { return execFileSync(command, args, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); } catch (error: any) { diff --git a/src/tools/kubectl-patch.ts b/src/tools/kubectl-patch.ts index 1894ac6..d5ebe82 100644 --- a/src/tools/kubectl-patch.ts +++ b/src/tools/kubectl-patch.ts @@ -4,6 +4,7 @@ import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; export const kubectlPatchSchema = { name: "kubectl_patch", @@ -115,6 +116,7 @@ export async function kubectlPatch( try { const result = execFileSync(command, args, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); diff --git a/src/tools/kubectl-rollout.ts b/src/tools/kubectl-rollout.ts index dc237ae..b70de6b 100644 --- a/src/tools/kubectl-rollout.ts +++ b/src/tools/kubectl-rollout.ts @@ -1,6 +1,7 @@ import { KubernetesManager } from "../types.js"; import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; export const kubectlRolloutSchema = { name: "kubectl_rollout", @@ -104,6 +105,7 @@ export async function kubectlRollout( // and capture the output until that point const result = execFileSync(command, args, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), timeout: 15000, // Reduced from 30 seconds to 15 seconds env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); @@ -121,6 +123,7 @@ export async function kubectlRollout( } else { const result = execFileSync(command, args, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); diff --git a/src/tools/kubectl-scale.ts b/src/tools/kubectl-scale.ts index 3643cff..9f77943 100644 --- a/src/tools/kubectl-scale.ts +++ b/src/tools/kubectl-scale.ts @@ -1,6 +1,7 @@ import { KubernetesManager } from "../types.js"; import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; export const kubectlScaleSchema = { name: "kubectl_scale", @@ -58,6 +59,7 @@ export async function kubectlScale( try { const result = execFileSync(command, args, { encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); diff --git a/src/tools/ping.ts b/src/tools/ping.ts index 2d914d9..06dd99b 100644 --- a/src/tools/ping.ts +++ b/src/tools/ping.ts @@ -1,6 +1,7 @@ export const pingSchema = { name: "ping", - description: "Verify that the counterpart is still responsive and the connection is alive.", + description: + "Verify that the counterpart is still responsive and the connection is alive.", inputSchema: { type: "object", properties: {}, @@ -10,4 +11,4 @@ export const pingSchema = { export async function ping(): Promise> { return {}; -} \ No newline at end of file +} From 4c503d11b60cacc9e3aa0cfb46e669cfe3b76984 Mon Sep 17 00:00:00 2001 From: Suyog Sonwalkar Date: Wed, 2 Jul 2025 08:08:27 -0700 Subject: [PATCH 05/35] Add READONLY mode and allowed_tools env var See #173 Summary: Test Plan: --- ADVANCED_README.md | 68 +++++++++++++++++++++++++++++++++++++++++++++- src/index.ts | 37 +++++++++++++++++++------ 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/ADVANCED_README.md b/ADVANCED_README.md index 0f2c879..3aec105 100644 --- a/ADVANCED_README.md +++ b/ADVANCED_README.md @@ -133,9 +133,75 @@ For Claude Desktop with environment variables: } ``` +### Tool Filtering Modes + +The server offers several modes to control which tools are available, configured via environment variables. The modes are prioritized as follows: + +1. `ALLOWED_TOOLS` +2. `ALLOW_ONLY_READONLY_TOOLS` +3. `ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS` + +#### Allowed Tools List + +You can specify a comma-separated list of tool names to enable only those specific tools. This provides fine-grained control over the server's capabilities. + +```shell +ALLOWED_TOOLS="kubectl_get,kubectl_describe" npx mcp-server-kubernetes +``` + +In your Claude Desktop configuration: + +```json +{ + "mcpServers": { + "kubernetes-custom": { + "command": "npx", + "args": ["mcp-server-kubernetes"], + "env": { + "ALLOWED_TOOLS": "kubectl_get,kubectl_describe,kubectl_logs" + } + } + } +} +``` + +#### Read-Only Mode + +For the strictest level of safety, you can enable read-only mode. This mode only permits tools that cannot alter the cluster state. + +```shell +ALLOW_ONLY_READONLY_TOOLS=true npx mcp-server-kubernetes +``` + +The following tools are available in read-only mode: + +- `kubectl_get` +- `kubectl_describe` +- `kubectl_logs` +- `kubectl_context` +- `explain_resource` +- `list_api_resources` +- `ping` + +In your Claude Desktop configuration: + +```json +{ + "mcpServers": { + "kubernetes-readonly-strict": { + "command": "npx", + "args": ["mcp-server-kubernetes"], + "env": { + "ALLOW_ONLY_READONLY_TOOLS": "true" + } + } + } +} +``` + ### Non-Destructive Mode -You can run the server in a non-destructive mode that disables all destructive operations (delete pods, delete deployments, delete namespaces, etc.) by setting the `ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS` environment variable to `true`: +If neither of the above modes are active, you can run the server in a non-destructive mode that disables all destructive operations (delete pods, delete deployments, delete namespaces, etc.) by setting the `ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS` environment variable to `true`: ```shell ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS=true npx mcp-server-kubernetes diff --git a/src/index.ts b/src/index.ts index 946d1c2..7a12999 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,10 +61,23 @@ import { import { registerPromptHandlers } from "./prompts/index.js"; import { ping, pingSchema } from "./tools/ping.js"; -// Check if non-destructive tools only mode is enabled +// Check environment variables for tool filtering +const allowOnlyReadonlyTools = process.env.ALLOW_ONLY_READONLY_TOOLS === "true"; +const allowedToolsEnv = process.env.ALLOWED_TOOLS; const nonDestructiveTools = process.env.ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS === "true"; +// Define readonly tools +const readonlyTools = [ + kubectlGetSchema, + kubectlDescribeSchema, + kubectlLogsSchema, + kubectlContextSchema, + explainResourceSchema, + listApiResourcesSchema, + pingSchema, +]; + // Define destructive tools (delete and uninstall operations) const destructiveTools = [ kubectlDeleteSchema, // This replaces all individual delete operations @@ -105,7 +118,6 @@ const allTools = [ StopPortForwardSchema, execInPodSchema, - // API resource operations listApiResourcesSchema, // Generic kubectl command @@ -147,12 +159,21 @@ registerPromptHandlers(server, k8sManager); // Tools handlers server.setRequestHandler(ListToolsRequestSchema, async () => { - // Filter out destructive tools if ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS is set to 'true' - const tools = nonDestructiveTools - ? allTools.filter( - (tool) => !destructiveTools.some((dt) => dt.name === tool.name) - ) - : allTools; + let tools; + + if (allowedToolsEnv) { + const allowedToolNames = allowedToolsEnv.split(",").map((t) => t.trim()); + tools = allTools.filter((tool) => allowedToolNames.includes(tool.name)); + } else if (allowOnlyReadonlyTools) { + tools = readonlyTools; + } else if (nonDestructiveTools) { + tools = allTools.filter( + (tool) => !destructiveTools.some((dt) => dt.name === tool.name) + ); + } else { + tools = allTools; + } + return { tools }; }); From 9e3f7f201ed37c90fb8e8b7d5017493555a2c264 Mon Sep 17 00:00:00 2001 From: aroglian Date: Thu, 3 Jul 2025 13:38:30 -0700 Subject: [PATCH 06/35] fix: remove invalid 'optional' keyword from exec_in_pod schema The exec_in_pod tool schema was using 'optional: true' which is not a valid JSON Schema property, causing "strict mode: unknown keyword" errors when starting the MCP server. In JSON Schema, optional properties are indicated by simply not including them in the 'required' array, not by using an 'optional' keyword. Changes: - Remove 'optional: true' from container, shell, and timeout properties - Update corresponding test assertions - Maintain same functionality (parameters remain optional) Fixes Docker container startup error: "Invalid schema for tool exec_in_pod: strict mode: unknown keyword: \"optional\"" --- src/tools/exec_in_pod.ts | 3 -- tests/exec_in_pod.test.ts | 104 +++++++++++++++++++------------------- 2 files changed, 51 insertions(+), 56 deletions(-) diff --git a/src/tools/exec_in_pod.ts b/src/tools/exec_in_pod.ts index 6a9a754..25a1c63 100644 --- a/src/tools/exec_in_pod.ts +++ b/src/tools/exec_in_pod.ts @@ -42,17 +42,14 @@ export const execInPodSchema = { 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"], diff --git a/tests/exec_in_pod.test.ts b/tests/exec_in_pod.test.ts index 2bc1ec1..5928776 100644 --- a/tests/exec_in_pod.test.ts +++ b/tests/exec_in_pod.test.ts @@ -7,25 +7,23 @@ describe("exec_in_pod tool", () => { 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"); @@ -37,37 +35,37 @@ describe("exec_in_pod tool", () => { // 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" + 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"] + 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", () => { @@ -75,23 +73,23 @@ describe("exec_in_pod tool", () => { 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"]); @@ -101,7 +99,7 @@ describe("exec_in_pod tool", () => { } }); }); - + // Test timeout parameter describe("timeout parameter", () => { test("timeout parameter changes default timeout", () => { @@ -109,42 +107,42 @@ describe("exec_in_pod tool", () => { 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 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`; + 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", () => { @@ -159,17 +157,17 @@ describe("exec_in_pod tool", () => { } 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", () => { @@ -184,56 +182,56 @@ describe("exec_in_pod tool", () => { } 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}` + 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: false, + message: "Failed to execute command in pod: No output" }; } - - return { - success: true, - output: stdout + + 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); From c8d17abac65b4f4f4896278ad170a3199bbacc6b Mon Sep 17 00:00:00 2001 From: Paras Patel Date: Thu, 3 Jul 2025 15:40:35 -0700 Subject: [PATCH 07/35] Add CITATION.cff file for MCP Server Kubernetes --- CITATION.cff | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 CITATION.cff diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..5c95385 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,15 @@ + +cff-version: 1.2.0 +message: "If you use kubernetes mcp server, please cite it as below." +abstract: "MCP Server for interacting with Kubernetes clusters via kubectl, enabling management and troubleshooting of Kubernetes resources." +authors: +- family-names: "Patel" + given-names: "Paras" + orcid: "https://orcid.org/0009-0000-4058-8214" +- family-names: "Sonwalkar" + given-names: "Suyog" + orcid: "https://orcid.org/0000-0000-0000-0000" +title: "MCP Server Kubernetes" +version: 2.4.9 +date-released: 2024-07-30 +url: "https://github.com/Flux159/mcp-server-kubernetes" \ No newline at end of file From 8b2d2cd903496fe11addda973707e4d77361e004 Mon Sep 17 00:00:00 2001 From: Suyog Sonwalkar Date: Thu, 3 Jul 2025 21:27:27 -0700 Subject: [PATCH 08/35] Adding Suyog Sonwalkar citation orcid. --- CITATION.cff | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 5c95385..aad5ec5 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,15 +1,14 @@ - cff-version: 1.2.0 message: "If you use kubernetes mcp server, please cite it as below." abstract: "MCP Server for interacting with Kubernetes clusters via kubectl, enabling management and troubleshooting of Kubernetes resources." authors: -- family-names: "Patel" - given-names: "Paras" - orcid: "https://orcid.org/0009-0000-4058-8214" -- family-names: "Sonwalkar" - given-names: "Suyog" - orcid: "https://orcid.org/0000-0000-0000-0000" + - family-names: "Patel" + given-names: "Paras" + orcid: "https://orcid.org/0009-0000-4058-8214" + - family-names: "Sonwalkar" + given-names: "Suyog" + orcid: "https://orcid.org/0009-0002-7352-5978" title: "MCP Server Kubernetes" version: 2.4.9 date-released: 2024-07-30 -url: "https://github.com/Flux159/mcp-server-kubernetes" \ No newline at end of file +url: "https://github.com/Flux159/mcp-server-kubernetes" From 2e96efa41bb080cc1f5c7de721a18d8a1adf5ace Mon Sep 17 00:00:00 2001 From: Suyog Sonwalkar Date: Thu, 3 Jul 2025 21:31:58 -0700 Subject: [PATCH 09/35] Automatically update citation.cff version number with new builds. --- .github/workflows/cd.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index ee06f17..e5c94a3 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -77,8 +77,9 @@ jobs: # Update the version in package.json and manifest.json and commit the change jq --arg v "${{ steps.version.outputs.current-version }}" '.version = $v' package.json > temp.json && mv temp.json package.json jq --arg v "${{ steps.version.outputs.current-version }}" '.version = $v' manifest.json > temp.json && mv temp.json manifest.json + sed -i "s/^version: .*/version: ${{ steps.version.outputs.current-version }}/" CITATION.cff - git add package.json manifest.json + git add package.json manifest.json CITATION.cff git commit -m "Bump version to ${{ steps.version.outputs.current-version }}" # Create and push the tag From 8e1768aaf589d43cdf903f1f0778a1eb35bb3145 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 4 Jul 2025 04:45:59 +0000 Subject: [PATCH 10/35] Bump version to 2.5.0 --- CITATION.cff | 2 +- manifest.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index aad5ec5..efa66ea 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -9,6 +9,6 @@ authors: given-names: "Suyog" orcid: "https://orcid.org/0009-0002-7352-5978" title: "MCP Server Kubernetes" -version: 2.4.9 +version: 2.5.0 date-released: 2024-07-30 url: "https://github.com/Flux159/mcp-server-kubernetes" diff --git a/manifest.json b/manifest.json index e17d478..128cd32 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "dxt_version": "0.1", "name": "mcp-server-kubernetes", - "version": "2.4.9", + "version": "2.5.0", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "long_description": "MCP Server that can connect to a Kubernetes cluster and manage it.\n\nBy default, the server loads kubeconfig from `~/.kube/config`.\n\nThe server will automatically connect to your current kubectl context. Make sure you have:\n\n1. kubectl installed and in your PATH\n2. A valid kubeconfig file with contexts configured\n3. Access to a Kubernetes cluster configured for kubectl (e.g. minikube, Rancher Desktop, GKE, etc.)\n4. Optional: Helm v3 installed and in your PATH.\n\nYou can verify your connection by asking Claude to list your pods or create a test deployment.\n\nIf you have errors open up a standard terminal and run `kubectl get pods` to see if you can connect to your cluster without credentials issues.\n\n## Features\n\n- [x] Connect to a Kubernetes cluster\n- [x] Unified kubectl API for managing resources\n- Get or list resources with `kubectl_get`\n- Describe resources with `kubectl_describe`\n- List resources with `kubectl_get`\n- Create resources with `kubectl_create`\n- Apply YAML manifests with `kubectl_apply`\n- Delete resources with `kubectl_delete`\n- Get logs with `kubectl_logs`\n- and more.", "author": { diff --git a/package.json b/package.json index e083a4c..5419d3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-server-kubernetes", - "version": "2.4.9", + "version": "2.5.0", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "license": "MIT", "type": "module", From 9554038d9f46c53c5fd11d9096c49d24abb3f6ee Mon Sep 17 00:00:00 2001 From: Paras Patel Date: Tue, 8 Jul 2025 16:38:01 -0700 Subject: [PATCH 11/35] doc: Update README.md to add start history and citation BibTex --- README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 44dbc8c..fd5118c 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,7 @@ MCP Server that can connect to a Kubernetes cluster and manage it. Supports load https://github.com/user-attachments/assets/f25f8f4e-4d04-479b-9ae0-5dac452dd2ed - - + ## Usage with Claude Desktop ```json @@ -278,3 +277,21 @@ This will create a new tag which will trigger a new release build via the cd.yml ## Not planned Adding clusters to kubectx. + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=Flux159/mcp-server-kubernetes&type=Date)](https://www.star-history.com/#Flux159/mcp-server-kubernetes&Date) + + +## 🖊️ Cite +If you find this repo useful, please cite: +``` +@software{Patel_MCP_Server_Kubernetes_2024, +author = {Patel, Paras and Sonwalkar, Suyog}, +month = jul, +title = {{MCP Server Kubernetes}}, +url = {https://github.com/Flux159/mcp-server-kubernetes}, +version = {2.5.0}, +year = {2024} +} +``` From a8e0a140af981b0364e7e80da4509a7852f52d83 Mon Sep 17 00:00:00 2001 From: Paras Patel Date: Tue, 8 Jul 2025 18:12:57 -0700 Subject: [PATCH 12/35] feat: Add version:update script to automate version updates in package.json, manifest.json, CITATION.cff, and README.md --- .github/workflows/cd.yml | 8 +++---- package.json | 5 ++-- scripts/update-version.js | 50 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) create mode 100755 scripts/update-version.js diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index e5c94a3..9f79e4d 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -74,12 +74,10 @@ jobs: # Make sure we're on main branch git checkout main - # Update the version in package.json and manifest.json and commit the change - jq --arg v "${{ steps.version.outputs.current-version }}" '.version = $v' package.json > temp.json && mv temp.json package.json - jq --arg v "${{ steps.version.outputs.current-version }}" '.version = $v' manifest.json > temp.json && mv temp.json manifest.json - sed -i "s/^version: .*/version: ${{ steps.version.outputs.current-version }}/" CITATION.cff + # Update the version in all files using the npm script + npm run version:update ${{ steps.version.outputs.current-version }} - git add package.json manifest.json CITATION.cff + git add package.json manifest.json CITATION.cff README.md git commit -m "Bump version to ${{ steps.version.outputs.current-version }}" # Create and push the tag diff --git a/package.json b/package.json index 5419d3f..c6168d7 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "test": "vitest run", "prepublishOnly": "npm run build", "dockerbuild": "docker buildx build -t flux159/mcp-server-kubernetes --platform linux/amd64,linux/arm64 --push .", - "chat": "npx mcp-chat --server \"./dist/index.js\"" + "chat": "npx mcp-chat --server \"./dist/index.js\"", + "version:update": "node scripts/update-version.js" }, "keywords": [ "mcp", @@ -52,4 +53,4 @@ "vitest": "2.1.9", "@anthropic-ai/dxt": "0.1.0" } -} +} \ No newline at end of file diff --git a/scripts/update-version.js b/scripts/update-version.js new file mode 100755 index 0000000..5ce4e2e --- /dev/null +++ b/scripts/update-version.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +const version = process.argv[2]; +if (!version) { + console.error('Usage: node scripts/update-version.js '); + process.exit(1); +} + +const files = [ + { + path: 'package.json', + update: (content) => { + const pkg = JSON.parse(content); + pkg.version = version; + return JSON.stringify(pkg, null, 2); + } + }, + { + path: 'manifest.json', + update: (content) => { + const manifest = JSON.parse(content); + manifest.version = version; + return JSON.stringify(manifest, null, 2); + } + }, + { + path: 'CITATION.cff', + update: (content) => content.replace(/^version: .*/m, `version: ${version}`) + }, + { + path: 'README.md', + update: (content) => content.replace(/version = \{\{\{VERSION\}\}\}/g, `version = {${version}}`) + } +]; + +files.forEach(({ path, update }) => { + try { + const content = readFileSync(path, 'utf8'); + const updatedContent = update(content); + writeFileSync(path, updatedContent); + console.log(`✓ Updated ${path}`); + } catch (error) { + console.error(`✗ Failed to update ${path}:`, error.message); + } +}); + +console.log(`\n🎉 All files updated to version ${version}`); \ No newline at end of file From 5c6abe00741ecf827849628e5b05506820ee13cb Mon Sep 17 00:00:00 2001 From: Paras Patel Date: Tue, 8 Jul 2025 18:13:17 -0700 Subject: [PATCH 13/35] chore: Add launch configuration for Visual Studio Code --- .vscode/launch.json | 18 ++++++++++++++++++ README.md | 6 ++++-- manifest.json | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ba300c7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch MCP Server", + "runtimeExecutable": "node", // Or "node" if you use node directly + "runtimeArgs": [ + "run", // If you use "bun run build" + "dist/index.js" // Your compiled entry point + ], + "outputCapture": "std", + "console": "integratedTerminal", + "skipFiles": ["/**"] + } + ] +} diff --git a/README.md b/README.md index fd5118c..b70b304 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ MCP Server that can connect to a Kubernetes cluster and manage it. Supports load https://github.com/user-attachments/assets/f25f8f4e-4d04-479b-9ae0-5dac452dd2ed - + + ## Usage with Claude Desktop ```json @@ -282,9 +283,10 @@ Adding clusters to kubectx. [![Star History Chart](https://api.star-history.com/svg?repos=Flux159/mcp-server-kubernetes&type=Date)](https://www.star-history.com/#Flux159/mcp-server-kubernetes&Date) - ## 🖊️ Cite + If you find this repo useful, please cite: + ``` @software{Patel_MCP_Server_Kubernetes_2024, author = {Patel, Paras and Sonwalkar, Suyog}, diff --git a/manifest.json b/manifest.json index 128cd32..ac9aa35 100644 --- a/manifest.json +++ b/manifest.json @@ -36,4 +36,4 @@ "type": "git", "url": "https://github.com/Flux159/mcp-server-kubernetes" } -} +} \ No newline at end of file From eec99311cfc58d4129af1dbd07151d538a1ecefb Mon Sep 17 00:00:00 2001 From: Paras Patel Date: Tue, 8 Jul 2025 22:18:53 -0700 Subject: [PATCH 14/35] feat: Enhance version:update script with validation and error handling --- scripts/update-version.js | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/scripts/update-version.js b/scripts/update-version.js index 5ce4e2e..cee3dd0 100755 --- a/scripts/update-version.js +++ b/scripts/update-version.js @@ -1,11 +1,19 @@ #!/usr/bin/env node -import { readFileSync, writeFileSync } from 'fs'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join } from 'path'; const version = process.argv[2]; if (!version) { console.error('Usage: node scripts/update-version.js '); + console.error('Example: node scripts/update-version.js 2.6.0'); + process.exit(1); +} + +// Validate version format (basic semver check) +if (!/^\d+\.\d+\.\d+(-[\w\.-]+)?$/.test(version)) { + console.error(`Invalid version format: ${version}`); + console.error('Expected format: major.minor.patch (e.g., 2.6.0 or 2.6.0-beta.1)'); process.exit(1); } @@ -15,7 +23,7 @@ const files = [ update: (content) => { const pkg = JSON.parse(content); pkg.version = version; - return JSON.stringify(pkg, null, 2); + return JSON.stringify(pkg, null, 2) + '\n'; } }, { @@ -23,7 +31,7 @@ const files = [ update: (content) => { const manifest = JSON.parse(content); manifest.version = version; - return JSON.stringify(manifest, null, 2); + return JSON.stringify(manifest, null, 2) + '\n'; } }, { @@ -36,15 +44,29 @@ const files = [ } ]; +let hasErrors = false; + files.forEach(({ path, update }) => { try { + if (!existsSync(path)) { + console.error(`✗ File not found: ${path}`); + hasErrors = true; + return; + } + const content = readFileSync(path, 'utf8'); const updatedContent = update(content); writeFileSync(path, updatedContent); console.log(`✓ Updated ${path}`); } catch (error) { console.error(`✗ Failed to update ${path}:`, error.message); + hasErrors = true; } }); -console.log(`\n🎉 All files updated to version ${version}`); \ No newline at end of file +if (hasErrors) { + console.error(`\n❌ Some files failed to update. Please check the errors above.`); + process.exit(1); +} else { + console.log(`\n🎉 All files updated to version ${version}`); +} \ No newline at end of file From fd301bcba225540bcf32428d70af5c11c8729147 Mon Sep 17 00:00:00 2001 From: Paras Patel Date: Tue, 8 Jul 2025 22:56:54 -0700 Subject: [PATCH 15/35] fix: Correct indentation in CD workflow for version update step --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 9f79e4d..3edc6d3 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -74,7 +74,7 @@ jobs: # Make sure we're on main branch git checkout main - # Update the version in all files using the npm script + # Update the version in all files using the npm script npm run version:update ${{ steps.version.outputs.current-version }} git add package.json manifest.json CITATION.cff README.md From 538342907430fe8f6f2d99728f0859ac38039551 Mon Sep 17 00:00:00 2001 From: Paras Patel Date: Tue, 8 Jul 2025 22:57:44 -0700 Subject: [PATCH 16/35] chore: Update .gitignore to include .vscode directory and remove launch.json file --- .gitignore | 2 ++ .vscode/launch.json | 18 ------------------ 2 files changed, 2 insertions(+), 18 deletions(-) delete mode 100644 .vscode/launch.json diff --git a/.gitignore b/.gitignore index 4652129..a7f50df 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,5 @@ dist .pnp.* mcp-server-kubernetes.dxt + +.vscode/ diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index ba300c7..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Launch MCP Server", - "runtimeExecutable": "node", // Or "node" if you use node directly - "runtimeArgs": [ - "run", // If you use "bun run build" - "dist/index.js" // Your compiled entry point - ], - "outputCapture": "std", - "console": "integratedTerminal", - "skipFiles": ["/**"] - } - ] -} From 69eab1210abfb97eef346c1a292018445e19c545 Mon Sep 17 00:00:00 2001 From: Felix Rieseberg Date: Mon, 14 Jul 2025 09:21:46 -0700 Subject: [PATCH 17/35] fix: Update manifest.json --- manifest.json | 85 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index ac9aa35..f5dff75 100644 --- a/manifest.json +++ b/manifest.json @@ -19,12 +19,91 @@ } }, "tools": [ + { + "name": "ping", + "description": "Verify that the counterpart is still responsive and the connection is alive." + }, + { + "name": "cleanup", + "description": "Cleanup all managed resources" + }, { "name": "kubectl_get", - "description": "Use kubectl get to retrieve information from cluster" + "description": "Get or list Kubernetes resources by resource type, name, and optionally namespace" + }, + { + "name": "kubectl_describe", + "description": "Describe Kubernetes resources by resource type, name, and optionally namespace" + }, + { + "name": "kubectl_apply", + "description": "Apply a Kubernetes YAML manifest from a string or file" + }, + { + "name": "kubectl_delete", + "description": "Delete Kubernetes resources by resource type, name, labels, or from a manifest file" + }, + { + "name": "kubectl_create", + "description": "Create Kubernetes resources using various methods (from file or using subcommands)" + }, + { + "name": "kubectl_logs", + "description": "Get logs from Kubernetes resources like pods, deployments, or jobs" + }, + { + "name": "kubectl_patch", + "description": "Update field(s) of a resource using strategic merge patch, JSON merge patch, or JSON patch" + }, + { + "name": "kubectl_rollout", + "description": "Manage the rollout of a resource (e.g., deployment, daemonset, statefulset)" + }, + { + "name": "kubectl_scale", + "description": "Scale a Kubernetes deployment" + }, + { + "name": "kubectl_context", + "description": "Manage Kubernetes contexts - list, get, or set the current context" + }, + { + "name": "kubectl_generic", + "description": "Execute any kubectl command with the provided arguments and flags" + }, + { + "name": "install_helm_chart", + "description": "Install a Helm chart" + }, + { + "name": "upgrade_helm_chart", + "description": "Upgrade a Helm release" + }, + { + "name": "uninstall_helm_chart", + "description": "Uninstall a Helm release" + }, + { + "name": "explain_resource", + "description": "Get documentation for a Kubernetes resource or field" + }, + { + "name": "list_api_resources", + "description": "List the API resources available in the cluster" + }, + { + "name": "exec_in_pod", + "description": "Execute a command in a Kubernetes pod or container and return the output" + }, + { + "name": "port_forward", + "description": "Forward a local port to a port on a Kubernetes resource" + }, + { + "name": "stop_port_forward", + "description": "Stop a port-forward process" } ], - "tools_generated": true, "keywords": [ "kubernetes", "docker", @@ -36,4 +115,4 @@ "type": "git", "url": "https://github.com/Flux159/mcp-server-kubernetes" } -} \ No newline at end of file +} From 97469c99bee08473d38323247ce9e36289076176 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 14 Jul 2025 23:18:01 +0000 Subject: [PATCH 18/35] Bump version to 2.5.1 --- CITATION.cff | 2 +- manifest.json | 2 +- package.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index efa66ea..20035c7 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -9,6 +9,6 @@ authors: given-names: "Suyog" orcid: "https://orcid.org/0009-0002-7352-5978" title: "MCP Server Kubernetes" -version: 2.5.0 +version: 2.5.1 date-released: 2024-07-30 url: "https://github.com/Flux159/mcp-server-kubernetes" diff --git a/manifest.json b/manifest.json index f5dff75..8b80242 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "dxt_version": "0.1", "name": "mcp-server-kubernetes", - "version": "2.5.0", + "version": "2.5.1", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "long_description": "MCP Server that can connect to a Kubernetes cluster and manage it.\n\nBy default, the server loads kubeconfig from `~/.kube/config`.\n\nThe server will automatically connect to your current kubectl context. Make sure you have:\n\n1. kubectl installed and in your PATH\n2. A valid kubeconfig file with contexts configured\n3. Access to a Kubernetes cluster configured for kubectl (e.g. minikube, Rancher Desktop, GKE, etc.)\n4. Optional: Helm v3 installed and in your PATH.\n\nYou can verify your connection by asking Claude to list your pods or create a test deployment.\n\nIf you have errors open up a standard terminal and run `kubectl get pods` to see if you can connect to your cluster without credentials issues.\n\n## Features\n\n- [x] Connect to a Kubernetes cluster\n- [x] Unified kubectl API for managing resources\n- Get or list resources with `kubectl_get`\n- Describe resources with `kubectl_describe`\n- List resources with `kubectl_get`\n- Create resources with `kubectl_create`\n- Apply YAML manifests with `kubectl_apply`\n- Delete resources with `kubectl_delete`\n- Get logs with `kubectl_logs`\n- and more.", "author": { diff --git a/package.json b/package.json index c6168d7..d339086 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-server-kubernetes", - "version": "2.5.0", + "version": "2.5.1", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "license": "MIT", "type": "module", @@ -53,4 +53,4 @@ "vitest": "2.1.9", "@anthropic-ai/dxt": "0.1.0" } -} \ No newline at end of file +} From 95e348990b945b755ac3a44d2154f97260c151e2 Mon Sep 17 00:00:00 2001 From: Suyog Sonwalkar Date: Tue, 15 Jul 2025 18:18:03 -0500 Subject: [PATCH 19/35] Cleaning up advanced_readme Some changes made SSE text go into other section. Summary: Test Plan: --- ADVANCED_README.md | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/ADVANCED_README.md b/ADVANCED_README.md index 3aec105..3edf9c3 100644 --- a/ADVANCED_README.md +++ b/ADVANCED_README.md @@ -287,6 +287,26 @@ Assuming your image name is flux159/mcp-server-kubernetes and you need to map po 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 ``` +⚠️ Key safety considerations +When deploying SSE mode using Docker, due to the insecure SSE transport protocol and sensitive configuration file mounting, you should consider using a proxy to handle authentication & authorization to the MCP server. + +mcp config + +```shell +{ + "mcpServers": { + "mcp-server-kubernetes": { + "url": "http://localhost:3001/sse", + "args": [] + } + } +} +``` + +### Why is SSE Transport Unsafe? + +SSE transport exposes an http endpoint that can be accessed by anyone with the URL. This can be a security risk if the server is not properly secured. It is recommended to use a secure proxy server to proxy to the SSE endpoint. In addition, anyone with access to the URL will be able to utilize the authentication of your kubeconfig to make requests to your Kubernetes cluster. You should add logging to your proxy in order to monitor user requests to the SSE endpoint. + ## Advance Docker Usage ### Connect to AWS EKS Cluster @@ -362,23 +382,3 @@ docker run --rm -it -p 3001:3001 -e ENABLE_UNSAFE_SSE_TRANSPORT=1 -e PORT=3001 } } ``` - -⚠️ Key safety considerations -When deploying SSE mode using Docker, due to the insecure SSE transport protocol and sensitive configuration file mounting, strict security constraints must be implemented in the production environment - -mcp config - -```shell -{ - "mcpServers": { - "mcp-server-kubernetes": { - "url": "http://localhost:3001/sse", - "args": [] - } - } -} -``` - -### Why is SSE Transport Unsafe? - -SSE transport exposes an http endpoint that can be accessed by anyone with the URL. This can be a security risk if the server is not properly secured. It is recommended to use a secure proxy server to proxy to the SSE endpoint. In addition, anyone with access to the URL will be able to utilize the authentication of your kubeconfig to make requests to your Kubernetes cluster. You should add logging to your proxy in order to monitor user requests to the SSE endpoint. From dbd0d0a9d4467effa08bd7036db855a28f4b3d76 Mon Sep 17 00:00:00 2001 From: kash Date: Thu, 17 Jul 2025 15:29:29 +0900 Subject: [PATCH 20/35] Add secret masking --- README.md | 27 ++++++++++++ src/tools/kubectl-get.ts | 89 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b70b304..5a93a18 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ npx mcp-chat --config "%APPDATA%\Claude\claude_desktop_config.json" - [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 +- [x] Secrets masking for security (automatically masks sensitive data in secret outputs) ## Prompts @@ -208,6 +209,32 @@ The following destructive operations are disabled: - `cleanup`: Cleanup of managed resources - `kubectl_generic`: General kubectl command access (may include destructive operations) +### Secrets Masking + +You can enable automatic masking of sensitive data in Kubernetes secrets to prevent accidental exposure of confidential information: + +```shell +MASK_SECRETS=true npx mcp-server-kubernetes +``` + +For Claude Desktop configuration with secrets masking: + +```json +{ + "mcpServers": { + "kubernetes-secure": { + "command": "npx", + "args": ["mcp-server-kubernetes"], + "env": { + "MASK_SECRETS": "true" + } + } + } +} +``` + +When enabled, `kubectl get secrets` and `kubectl get secret` commands will automatically mask all values in the `data` section with `***` while preserving the structure and metadata. + For additional advanced features, see the [ADVANCED_README.md](ADVANCED_README.md). ## Architecture diff --git a/src/tools/kubectl-get.ts b/src/tools/kubectl-get.ts index f0fe4d7..7e334ac 100644 --- a/src/tools/kubectl-get.ts +++ b/src/tools/kubectl-get.ts @@ -2,6 +2,7 @@ import { KubernetesManager } from "../types.js"; import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import * as yaml from "js-yaml"; export const kubectlGetSchema = { name: "kubectl_get", @@ -151,12 +152,21 @@ export async function kubectlGet( env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); + // Apply secrets masking if enabled and dealing with secrets + const shouldMaskSecrets = process.env.MASK_SECRETS === "true" && + (resourceType === "secrets" || resourceType === "secret"); + + let processedResult = result; + if (shouldMaskSecrets) { + processedResult = maskSecretsData(result, output); + } + // Format the results for better readability const isListOperation = !name; if (isListOperation && output === "json") { try { // Parse JSON and extract key information - const parsed = JSON.parse(result); + const parsed = JSON.parse(processedResult); if (parsed.kind && parsed.kind.endsWith("List") && parsed.items) { if (resourceType === "events") { @@ -211,7 +221,7 @@ export async function kubectlGet( content: [ { type: "text", - text: result, + text: processedResult, }, ], }; @@ -316,3 +326,78 @@ function isNonNamespacedResource(resourceType: string): boolean { return nonNamespacedResources.includes(resourceType.toLowerCase()); } + +// Helper function to mask leaf values in data sections of secrets +function maskDataValues(obj: any): any { + + if (obj === null || obj === undefined) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => maskDataValues(item)); + } + + if (typeof obj === "object") { + const result: any = {}; + for (const key in obj) { + if (key === "data" && typeof obj[key] === "object" && obj[key] !== null) { + // This is a data section - mask all leaf values within it + result[key] = maskAllLeafValues(obj[key]); + } else { + result[key] = maskDataValues(obj[key]); + } + } + return result; + } + + return obj; +} + +// Helper function to recursively mask all leaf values +function maskAllLeafValues(obj: any): any { + const maskValue = "***"; + + if (obj === null || obj === undefined) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => maskAllLeafValues(item)); + } + + if (typeof obj === "object") { + const result: any = {}; + for (const key in obj) { + result[key] = maskAllLeafValues(obj[key]); + } + return result; + } + + // This is a leaf value (string, number, boolean) - mask it + return maskValue; +} + +// Helper function to mask sensitive data in secrets +function maskSecretsData(output: string, format: string): string { + try { + if (format === "json") { + const parsed = JSON.parse(output); + const masked = maskDataValues(parsed); + return JSON.stringify(masked, null, 2); + } else if (format === "yaml") { + // Parse YAML to JSON, mask, then convert back to YAML + const parsed = yaml.load(output); + const masked = maskDataValues(parsed); + return yaml.dump(masked, { + indent: 2, + lineWidth: -1, // Don't wrap lines + noRefs: true // Don't use references + }); + } + } catch (error) { + console.warn("Failed to parse secrets output for masking:", error); + } + + return output; +} From 59abe474da83b2b18f767705a9f87c8e5032a7e2 Mon Sep 17 00:00:00 2001 From: kash Date: Thu, 17 Jul 2025 16:02:10 +0900 Subject: [PATCH 21/35] Add get secret test --- tests/kubectl-get-secrets.test.ts | 533 ++++++++++++++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 tests/kubectl-get-secrets.test.ts diff --git a/tests/kubectl-get-secrets.test.ts b/tests/kubectl-get-secrets.test.ts new file mode 100644 index 0000000..71222ea --- /dev/null +++ b/tests/kubectl-get-secrets.test.ts @@ -0,0 +1,533 @@ +import { expect, test, describe, beforeEach, afterEach, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { KubectlResponseSchema } from "../src/models/kubectl-models.js"; +import { asResponseSchema } from "./context-helper"; + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Helper function to retry operations that might be flaky +async function retry( + operation: () => Promise, + maxRetries: number = 3, + delayMs: number = 2000 +): Promise { + let lastError: Error | unknown; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error; + console.warn( + `Attempt ${attempt}/${maxRetries} failed. Retrying in ${delayMs}ms...` + ); + await sleep(delayMs); + } + } + + throw lastError; +} + +describe("kubectl get secrets masking functionality", () => { + // Helper function to create client with specific environment + async function createClientWithEnv(maskSecrets?: string): Promise<{transport: StdioClientTransport, client: Client}> { + const env = { ...process.env }; + if (maskSecrets !== undefined) { + env.MASK_SECRETS = maskSecrets; + } else { + delete env.MASK_SECRETS; + } + + const transport = new StdioClientTransport({ + command: "bun", + args: ["src/index.ts"], + stderr: "pipe", + env: env + }); + + const client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: {}, + } + ); + + await client.connect(transport); + await sleep(2000); + + return { transport, client }; + } + + // Helper function to cleanup client + async function cleanupClient(transport: StdioClientTransport) { + try { + await transport.close(); + await sleep(2000); + } catch (e) { + console.error("Error during cleanup:", e); + } + } + + describe("integration tests with MASK_SECRETS environment variable", () => { + test("should mask secrets when MASK_SECRETS=true", async () => { + const { transport, client } = await createClientWithEnv("true"); + + try { + // Create a test secret first + const createResult = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_apply", + arguments: { + manifest: ` +apiVersion: v1 +kind: Secret +metadata: + name: test-masking-secret + namespace: default +type: Opaque +data: + username: dGVzdC11c2VybmFtZQ== + password: dGVzdC1wYXNzd29yZA== + config: ewogICJkYjpuYW1lIjogInRlc3QtZGF0YWJhc2UiLAogICJob3N0IjogInRlc3QtaG9zdCIKfQ== +`, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(createResult.content[0].type).toBe("text"); + + // Now get the secret and verify it's masked + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "secrets", + name: "test-masking-secret", + namespace: "default", + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + const secretData = JSON.parse(result.content[0].text); + + // Verify that data fields are masked + expect(secretData.data).toBeDefined(); + expect(secretData.data.username).toBe("***"); + expect(secretData.data.password).toBe("***"); + expect(secretData.data.config).toBe("***"); + + // Verify that non-data fields are not masked + expect(secretData.metadata.name).toBe("test-masking-secret"); + expect(secretData.metadata.namespace).toBe("default"); + expect(secretData.kind).toBe("Secret"); + + // Clean up + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "secret", + name: "test-masking-secret", + namespace: "default", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + } finally { + await cleanupClient(transport); + } + }); + + test("should not mask secrets when MASK_SECRETS=false", async () => { + const { transport, client } = await createClientWithEnv("false"); + + try { + // Create a test secret first + const createResult = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_apply", + arguments: { + manifest: ` +apiVersion: v1 +kind: Secret +metadata: + name: test-unmasked-secret + namespace: default +type: Opaque +data: + username: dGVzdC11c2VybmFtZQ== + password: dGVzdC1wYXNzd29yZA== +`, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(createResult.content[0].type).toBe("text"); + + // Now get the secret and verify it's not masked + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "secrets", + name: "test-unmasked-secret", + namespace: "default", + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + const secretData = JSON.parse(result.content[0].text); + + // Verify that data fields are NOT masked + expect(secretData.data).toBeDefined(); + expect(secretData.data.username).toBe("dGVzdC11c2VybmFtZQ=="); + expect(secretData.data.password).toBe("dGVzdC1wYXNzd29yZA=="); + + // Clean up + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "secret", + name: "test-unmasked-secret", + namespace: "default", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + } finally { + await cleanupClient(transport); + } + }); + + test("should not mask secrets when MASK_SECRETS is unset", async () => { + const { transport, client } = await createClientWithEnv(); + + try { + // Create a test secret first + const createResult = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_apply", + arguments: { + manifest: ` +apiVersion: v1 +kind: Secret +metadata: + name: test-default-secret + namespace: default +type: Opaque +data: + username: dGVzdC11c2VybmFtZQ== +`, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(createResult.content[0].type).toBe("text"); + + // Now get the secret and verify it's not masked (default behavior) + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "secrets", + name: "test-default-secret", + namespace: "default", + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + const secretData = JSON.parse(result.content[0].text); + + // Verify that data fields are NOT masked (default behavior) + expect(secretData.data).toBeDefined(); + expect(secretData.data.username).toBe("dGVzdC11c2VybmFtZQ=="); + + // Clean up + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "secret", + name: "test-default-secret", + namespace: "default", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + } finally { + await cleanupClient(transport); + } + }); + }); + + describe("output format tests", () => { + test("should mask secrets in JSON output", async () => { + const { transport, client } = await createClientWithEnv("true"); + + try { + const createResult = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_apply", + arguments: { + manifest: ` +apiVersion: v1 +kind: Secret +metadata: + name: test-json-secret + namespace: default +type: Opaque +data: + key1: dGVzdC12YWx1ZS0x + key2: dGVzdC12YWx1ZS0y +`, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "secrets", + name: "test-json-secret", + namespace: "default", + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + const secretData = JSON.parse(result.content[0].text); + expect(secretData.data.key1).toBe("***"); + expect(secretData.data.key2).toBe("***"); + + // Clean up + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "secret", + name: "test-json-secret", + namespace: "default", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + } finally { + await cleanupClient(transport); + } + }); + + test("should mask secrets in YAML output", async () => { + const { transport, client } = await createClientWithEnv("true"); + + try { + const createResult = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_apply", + arguments: { + manifest: ` +apiVersion: v1 +kind: Secret +metadata: + name: test-yaml-secret + namespace: default +type: Opaque +data: + yamlkey: dGVzdC15YW1sLXZhbHVl +`, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "secrets", + name: "test-yaml-secret", + namespace: "default", + output: "yaml", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + const yamlOutput = result.content[0].text; + + // Verify that the output contains masked values + expect(yamlOutput).toContain("yamlkey: '***'"); + // Verify that metadata is not masked + expect(yamlOutput).toContain("name: test-yaml-secret"); + + // Clean up + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "secret", + name: "test-yaml-secret", + namespace: "default", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + } finally { + await cleanupClient(transport); + } + }); + }); + + describe("edge cases and error handling", () => { + test("should only mask secrets, not other resource types", async () => { + const { transport, client } = await createClientWithEnv("true"); + + try { + // Test with configmaps to ensure they're not masked + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "configmaps", + namespace: "default", + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + // Should succeed without masking since it's not secrets + const configData = JSON.parse(result.content[0].text); + expect(configData).toBeDefined(); + } finally { + await cleanupClient(transport); + } + }); + + test("should handle malformed secrets gracefully", async () => { + const { transport, client } = await createClientWithEnv("true"); + + try { + // Test with non-existent secret + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "secrets", + name: "non-existent-test-secret", + namespace: "default", + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + const errorData = JSON.parse(result.content[0].text); + expect(errorData.error).toContain("not found"); + expect(errorData.status).toBe("not_found"); + } finally { + await cleanupClient(transport); + } + }); + }); +}); \ No newline at end of file From f83f7e2eb72d15351067da5b857e34a3a35fa772 Mon Sep 17 00:00:00 2001 From: kash Date: Sat, 19 Jul 2025 10:49:16 +0900 Subject: [PATCH 22/35] Update documentation for secret masking --- ADVANCED_README.md | 26 ++++++++++++++++++++++++++ README.md | 28 +--------------------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/ADVANCED_README.md b/ADVANCED_README.md index 3edf9c3..70bc8bd 100644 --- a/ADVANCED_README.md +++ b/ADVANCED_README.md @@ -241,6 +241,32 @@ For Non destructive mode in Claude Desktop, you can specify the env var like thi } ``` +### Secrets Masking + +You can enable automatic masking of sensitive data in Kubernetes secrets to prevent accidental exposure of confidential information: + +```shell +MASK_SECRETS=true npx mcp-server-kubernetes +``` + +For Claude Desktop configuration with secrets masking: + +```json +{ + "mcpServers": { + "kubernetes-secure": { + "command": "npx", + "args": ["mcp-server-kubernetes"], + "env": { + "MASK_SECRETS": "true" + } + } + } +} +``` + +When enabled, `kubectl get secrets` and `kubectl get secret` commands will automatically mask all values in the `data` section with `***` while preserving the structure and metadata. Note that this only applies to the `kubectl get secrets` command output and does not mask secrets that may appear in logs or other operations. + ### SSE Transport To enable [SSE transport](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse) for mcp-server-kubernetes, use the ENABLE_UNSAFE_SSE_TRANSPORT environment variable. diff --git a/README.md b/README.md index 5a93a18..2606631 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ npx mcp-chat --config "%APPDATA%\Claude\claude_desktop_config.json" - [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 -- [x] Secrets masking for security (automatically masks sensitive data in secret outputs) +- [x] Secrets masking for security (masks sensitive data in `kubectl get secrets` commands, does not affect logs) ## Prompts @@ -209,32 +209,6 @@ The following destructive operations are disabled: - `cleanup`: Cleanup of managed resources - `kubectl_generic`: General kubectl command access (may include destructive operations) -### Secrets Masking - -You can enable automatic masking of sensitive data in Kubernetes secrets to prevent accidental exposure of confidential information: - -```shell -MASK_SECRETS=true npx mcp-server-kubernetes -``` - -For Claude Desktop configuration with secrets masking: - -```json -{ - "mcpServers": { - "kubernetes-secure": { - "command": "npx", - "args": ["mcp-server-kubernetes"], - "env": { - "MASK_SECRETS": "true" - } - } - } -} -``` - -When enabled, `kubectl get secrets` and `kubectl get secret` commands will automatically mask all values in the `data` section with `***` while preserving the structure and metadata. - For additional advanced features, see the [ADVANCED_README.md](ADVANCED_README.md). ## Architecture From 51618e334b8d26df16f9dbd11d1cd376eae4a08b Mon Sep 17 00:00:00 2001 From: kash <80598856+cybercuisine@users.noreply.github.com> Date: Sun, 20 Jul 2025 05:13:51 +0900 Subject: [PATCH 23/35] Updated null check Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/tools/kubectl-get.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/kubectl-get.ts b/src/tools/kubectl-get.ts index 7e334ac..3e3e5fa 100644 --- a/src/tools/kubectl-get.ts +++ b/src/tools/kubectl-get.ts @@ -330,7 +330,7 @@ function isNonNamespacedResource(resourceType: string): boolean { // Helper function to mask leaf values in data sections of secrets function maskDataValues(obj: any): any { - if (obj === null || obj === undefined) { + if (obj == null) { return obj; } @@ -358,7 +358,7 @@ function maskDataValues(obj: any): any { function maskAllLeafValues(obj: any): any { const maskValue = "***"; - if (obj === null || obj === undefined) { + if (obj == null) { return obj; } From eb60cc6a55eb80befc3df42b61e9b6ed2a777efa Mon Sep 17 00:00:00 2001 From: kash <80598856+cybercuisine@users.noreply.github.com> Date: Sun, 20 Jul 2025 05:15:05 +0900 Subject: [PATCH 24/35] Add jsdoc comment Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/tools/kubectl-get.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/tools/kubectl-get.ts b/src/tools/kubectl-get.ts index 3e3e5fa..88c0a8e 100644 --- a/src/tools/kubectl-get.ts +++ b/src/tools/kubectl-get.ts @@ -327,7 +327,12 @@ function isNonNamespacedResource(resourceType: string): boolean { return nonNamespacedResources.includes(resourceType.toLowerCase()); } -// Helper function to mask leaf values in data sections of secrets +/** + * Recursively traverses an object and masks values in 'data' sections of Kubernetes secrets. + * + * @param {any} obj - The object to traverse. Can be an array, object, or primitive value. + * @returns {any} A new object with masked values in 'data' sections. + */ function maskDataValues(obj: any): any { if (obj == null) { @@ -354,7 +359,12 @@ function maskDataValues(obj: any): any { return obj; } -// Helper function to recursively mask all leaf values +/** + * Recursively masks all leaf values (non-object, non-array values) in an object structure. + * + * @param {any} obj - The input object or value to process. + * @returns {any} A new object or value with all leaf values replaced by a mask. + */ function maskAllLeafValues(obj: any): any { const maskValue = "***"; @@ -378,7 +388,14 @@ function maskAllLeafValues(obj: any): any { return maskValue; } -// Helper function to mask sensitive data in secrets +/** + * Masks sensitive data in Kubernetes secrets by parsing the raw output and replacing + * all leaf values in the "data" section with a placeholder value ("***"). + * + * @param {string} output - The raw output from a `kubectl` command, containing secrets data. + * @param {string} format - The format of the output, either "json" or "yaml". + * @returns {string} - The masked output in the same format as the input. + */ function maskSecretsData(output: string, format: string): string { try { if (format === "json") { From d4b352f08fdb20347594375e4303aeda6c17da48 Mon Sep 17 00:00:00 2001 From: kash Date: Sun, 20 Jul 2025 05:25:52 +0900 Subject: [PATCH 25/35] Update default secret masking --- ADVANCED_README.md | 12 ++++++------ src/tools/kubectl-get.ts | 2 +- tests/kubectl-get-secrets.test.ts | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ADVANCED_README.md b/ADVANCED_README.md index 70bc8bd..99dc94b 100644 --- a/ADVANCED_README.md +++ b/ADVANCED_README.md @@ -243,29 +243,29 @@ For Non destructive mode in Claude Desktop, you can specify the env var like thi ### Secrets Masking -You can enable automatic masking of sensitive data in Kubernetes secrets to prevent accidental exposure of confidential information: +By default, the server automatically masks sensitive data in Kubernetes secrets to prevent accidental exposure of confidential information. You can disable this behavior if needed: ```shell -MASK_SECRETS=true npx mcp-server-kubernetes +MASK_SECRETS=false npx mcp-server-kubernetes ``` -For Claude Desktop configuration with secrets masking: +For Claude Desktop configuration to disable secrets masking: ```json { "mcpServers": { - "kubernetes-secure": { + "kubernetes": { "command": "npx", "args": ["mcp-server-kubernetes"], "env": { - "MASK_SECRETS": "true" + "MASK_SECRETS": "false" } } } } ``` -When enabled, `kubectl get secrets` and `kubectl get secret` commands will automatically mask all values in the `data` section with `***` while preserving the structure and metadata. Note that this only applies to the `kubectl get secrets` command output and does not mask secrets that may appear in logs or other operations. +When enabled (default), `kubectl get secrets` and `kubectl get secret` commands will automatically mask all values in the `data` section with `***` while preserving the structure and metadata. Note that this only applies to the `kubectl get secrets` command output and does not mask secrets that may appear in logs or other operations. ### SSE Transport diff --git a/src/tools/kubectl-get.ts b/src/tools/kubectl-get.ts index 88c0a8e..e73947b 100644 --- a/src/tools/kubectl-get.ts +++ b/src/tools/kubectl-get.ts @@ -153,7 +153,7 @@ export async function kubectlGet( }); // Apply secrets masking if enabled and dealing with secrets - const shouldMaskSecrets = process.env.MASK_SECRETS === "true" && + const shouldMaskSecrets = process.env.MASK_SECRETS !== "false" && (resourceType === "secrets" || resourceType === "secret"); let processedResult = result; diff --git a/tests/kubectl-get-secrets.test.ts b/tests/kubectl-get-secrets.test.ts index 71222ea..330320e 100644 --- a/tests/kubectl-get-secrets.test.ts +++ b/tests/kubectl-get-secrets.test.ts @@ -240,7 +240,7 @@ data: } }); - test("should not mask secrets when MASK_SECRETS is unset", async () => { + test("should mask secrets when MASK_SECRETS is unset (default behavior)", async () => { const { transport, client } = await createClientWithEnv(); try { @@ -293,9 +293,9 @@ data: expect(result.content[0].type).toBe("text"); const secretData = JSON.parse(result.content[0].text); - // Verify that data fields are NOT masked (default behavior) + // Verify that data fields are masked (default behavior) expect(secretData.data).toBeDefined(); - expect(secretData.data.username).toBe("dGVzdC11c2VybmFtZQ=="); + expect(secretData.data.username).toBe("***"); // Clean up await client.request( From 8f0b4989910d8405ab4a15d4ad1c4be5d8bca8fb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 29 Jul 2025 21:50:53 +0000 Subject: [PATCH 26/35] Bump version to 2.6.0 --- CITATION.cff | 2 +- manifest.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 20035c7..12f1f73 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -9,6 +9,6 @@ authors: given-names: "Suyog" orcid: "https://orcid.org/0009-0002-7352-5978" title: "MCP Server Kubernetes" -version: 2.5.1 +version: 2.6.0 date-released: 2024-07-30 url: "https://github.com/Flux159/mcp-server-kubernetes" diff --git a/manifest.json b/manifest.json index 8b80242..0d14ed5 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "dxt_version": "0.1", "name": "mcp-server-kubernetes", - "version": "2.5.1", + "version": "2.6.0", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "long_description": "MCP Server that can connect to a Kubernetes cluster and manage it.\n\nBy default, the server loads kubeconfig from `~/.kube/config`.\n\nThe server will automatically connect to your current kubectl context. Make sure you have:\n\n1. kubectl installed and in your PATH\n2. A valid kubeconfig file with contexts configured\n3. Access to a Kubernetes cluster configured for kubectl (e.g. minikube, Rancher Desktop, GKE, etc.)\n4. Optional: Helm v3 installed and in your PATH.\n\nYou can verify your connection by asking Claude to list your pods or create a test deployment.\n\nIf you have errors open up a standard terminal and run `kubectl get pods` to see if you can connect to your cluster without credentials issues.\n\n## Features\n\n- [x] Connect to a Kubernetes cluster\n- [x] Unified kubectl API for managing resources\n- Get or list resources with `kubectl_get`\n- Describe resources with `kubectl_describe`\n- List resources with `kubectl_get`\n- Create resources with `kubectl_create`\n- Apply YAML manifests with `kubectl_apply`\n- Delete resources with `kubectl_delete`\n- Get logs with `kubectl_logs`\n- and more.", "author": { diff --git a/package.json b/package.json index d339086..473e807 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-server-kubernetes", - "version": "2.5.1", + "version": "2.6.0", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "license": "MIT", "type": "module", From 3f6035e594464ed02eb6381735cc28c533f82323 Mon Sep 17 00:00:00 2001 From: Suyog Sonwalkar Date: Fri, 1 Aug 2025 10:01:18 -0700 Subject: [PATCH 27/35] Starting point for Streamable HTTP transport Todo: Still need to add tests and verify that it works properly. Summary: Test Plan: --- ADVANCED_README.md | 22 +++++++- bun.lockb | Bin 121158 -> 125937 bytes package.json | 2 +- src/index.ts | 4 ++ src/utils/streamable-http.ts | 97 +++++++++++++++++++++++++++++++++++ tests/port-helper.ts | 28 ++++++++++ tests/sse.test.ts | 58 ++++++++------------- tests/streaming_http.test.ts | 87 +++++++++++++++++++++++++++++++ 8 files changed, 260 insertions(+), 38 deletions(-) create mode 100644 src/utils/streamable-http.ts create mode 100644 tests/port-helper.ts create mode 100644 tests/streaming_http.test.ts diff --git a/ADVANCED_README.md b/ADVANCED_README.md index 99dc94b..de37a5c 100644 --- a/ADVANCED_README.md +++ b/ADVANCED_README.md @@ -267,7 +267,27 @@ For Claude Desktop configuration to disable secrets masking: When enabled (default), `kubectl get secrets` and `kubectl get secret` commands will automatically mask all values in the `data` section with `***` while preserving the structure and metadata. Note that this only applies to the `kubectl get secrets` command output and does not mask secrets that may appear in logs or other operations. -### SSE Transport +### Streamable HTTP Transport + +To enable [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) for mcp-server-kubernetes, use the ENABLE_UNSAFE_STREAMABLE_HTTP_TRANSPORT environment variable. + +```shell +ENABLE_UNSAFE_STREAMABLE_HTTP_TRANSPORT=1 npx flux159/mcp-server-kubernetes +``` + +This will start an http server with the `/mcp` endpoint for streamable http events (POST, GET, and DELETE). Use the `PORT` env var to configure the server port. Use the `HOST` env var to configure listening on interfaces other than localhost. + +```shell +ENABLE_UNSAFE_STREAMABLE_HTTP_TRANSPORT=1 PORT=3001 HOST=0.0.0.0 npx flux159/mcp-server-kubernetes +``` + +To enable DNS Rebinding protection if running locally, you should use `DNS_REBINDING_PROTECTION` and optionally `DNS_REBINDING_ALLOWED_HOST` (defaults to 127.0.0.1): + +``` +DNS_REBINDING_ALLOWED_HOST=true ENABLE_UNSAFE_STREAMABLE_HTTP_TRANSPORT=1 PORT=3001 HOST=0.0.0.0 npx flux159/mcp-server-kubernetes +``` + +### SSE Transport (Deprecated in favor of Streamable HTTP) To enable [SSE transport](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse) for mcp-server-kubernetes, use the ENABLE_UNSAFE_SSE_TRANSPORT environment variable. diff --git a/bun.lockb b/bun.lockb index e0efe073c563282ee25dd1772c1fc47c1efe8a8b..c32e858bd947c02de89380d503ceaf7480df9d9a 100755 GIT binary patch delta 22274 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 T2{Mb?cpPO8WgF(Rd7A$Ki@Q+6 delta 18777 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 { + // In stateless mode, create a new instance of transport and server for each request + // to ensure complete isolation. A single instance would cause request ID collisions + // when multiple clients connect concurrently. + + try { + // DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server + // locally, make sure to set DNS_REBINDING_PROTECTION=true + const enableDnsRebindingProtection = + process.env.DNS_REBINDING_PROTECTION === "true"; + const allowedHosts = process.env.DNS_REBINDING_ALLOWED_HOST + ? [process.env.DNS_REBINDING_ALLOWED_HOST] + : ["127.0.0.1"]; + + const transport: StreamableHTTPServerTransport = + new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableDnsRebindingProtection, + allowedHosts, + }); + res.on("close", () => { + transport.close(); + server.close(); + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { + code: -32603, + message: "Internal server error", + }, + id: null, + }); + } + } + }); + + // SSE notifications not supported in stateless mode + app.get("/mcp", async (req: express.Request, res: express.Response) => { + console.log("Received GET MCP request"); + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed.", + }, + id: null, + }) + ); + }); + + // Session termination not needed in stateless mode + app.delete("/mcp", async (req: express.Request, res: express.Response) => { + console.log("Received DELETE MCP request"); + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed.", + }, + id: null, + }) + ); + }); + + let port = 3000; + try { + port = parseInt(process.env.PORT || "3000", 10); + } catch (e) { + console.error( + "Invalid PORT environment variable, using default port 3000." + ); + } + + const host = process.env.HOST || "localhost"; + const httpServer = app.listen(port, host, () => { + console.log( + `mcp-kubernetes-server is listening on port ${port}\nUse the following url to connect to the server:\nhttp://${host}:${port}/mcp` + ); + }); + return httpServer; +} diff --git a/tests/port-helper.ts b/tests/port-helper.ts new file mode 100644 index 0000000..d6c7ae1 --- /dev/null +++ b/tests/port-helper.ts @@ -0,0 +1,28 @@ +import net from "net"; + +// Helper function to find an available port +export async function findAvailablePort( + startPort: number, + maxRetries: number = 100 +): Promise { + if (maxRetries <= 0) { + return Promise.reject( + new Error("No available ports found within the retry limit.") + ); + } + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", () => { + findAvailablePort(startPort + 1, maxRetries - 1) + .then(resolve) + .catch(reject); + }); + server.listen(startPort, () => { + const port = (server.address() as net.AddressInfo)?.port; + server.close(() => { + resolve(port); + }); + }); + }); +} diff --git a/tests/sse.test.ts b/tests/sse.test.ts index 4cc98a6..0c63b15 100644 --- a/tests/sse.test.ts +++ b/tests/sse.test.ts @@ -9,24 +9,7 @@ import { KubernetesManager } from "../src/utils/kubernetes-manager.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"; - -// Helper function to find an available port -async function findAvailablePort(startPort: number): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(startPort, () => { - const port = (server.address() as net.AddressInfo)?.port; - server.close(() => { - resolve(port); - }); - }); - server.on('error', () => { - // Try next port - findAvailablePort(startPort + 1).then(resolve).catch(reject); - }); - }); -} +import { findAvailablePort } from "./port-helper.js"; // Modified version of startSSEServer that returns the Express app function startSSEServerWithReturn(server: Server): Promise { @@ -61,8 +44,8 @@ function startSSEServerWithReturn(server: Server): Promise { ); resolve(serverInstance); }); - - serverInstance.on('error', reject); + + serverInstance.on("error", reject); }); } @@ -100,16 +83,19 @@ describe("SSE transport", () => { switch (name) { 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; - }); + 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}`); } @@ -118,13 +104,13 @@ describe("SSE transport", () => { // Find an available port instead of using a fixed one actualPort = await findAvailablePort(3001); process.env.PORT = actualPort.toString(); - + // Start the SSE server and get the Express app reference expressApp = await startSSEServerWithReturn(server); serverUrl = `http://localhost:${actualPort}`; - + // Wait a bit for server to fully start - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); }); afterAll(async () => { @@ -186,8 +172,8 @@ describe("SSE transport", () => { arguments: { resourceType: "pods", namespace: "default", - output: "json" - } + output: "json", + }, }, }), } @@ -222,7 +208,7 @@ describe("SSE transport", () => { if (toolCallResult.result) { expect(toolCallResult.result.content[0].type).toBe("text"); const responseText = toolCallResult.result.content[0].text; - + // If it's JSON, parse it and check structure try { const parsedResponse = JSON.parse(responseText); diff --git a/tests/streaming_http.test.ts b/tests/streaming_http.test.ts new file mode 100644 index 0000000..3f90487 --- /dev/null +++ b/tests/streaming_http.test.ts @@ -0,0 +1,87 @@ +import { expect, test, describe, beforeAll, afterAll } from "vitest"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { startStreamableHTTPServer } from "../src/utils/streamable-http.js"; +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import http from "http"; +import { pingSchema } from "../src/tools/ping.js"; +import { findAvailablePort } from "./port-helper.js"; + +// Simple type guard for ListToolsResponse +function isListToolsResponse(data: any): boolean { + return ( + data && + data.jsonrpc === "2.0" && + data.result && + Array.isArray(data.result.tools) + ); +} + +describe("Streamable HTTP Server", () => { + let server: Server; + let httpServer: http.Server; + let port: number; + let url: string; + + beforeAll(async () => { + port = await findAvailablePort(3001); + url = `http://localhost:${port}/mcp`; + + // Create a server and register a handler for list_tools + server = new Server( + { name: "test-stream-server", version: "1.0.0" }, + { capabilities: { tools: {} } } // Enable the tools capability + ); + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [pingSchema], // Return a simple tool schema + }; + }); + + process.env.PORT = port.toString(); + httpServer = startStreamableHTTPServer(server); + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + httpServer.close((err) => { + if (err) return reject(err); + resolve(); + }); + }); + }); + + test("should handle a full MCP session lifecycle", async () => { + try { + // Send a POST request and verify the response on the same channel + const listToolsRequest = { + jsonrpc: "2.0" as const, + method: "tools/list" as const, + params: {}, + id: 2, + }; + const postResponse = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify(listToolsRequest), + }); + expect(postResponse.status).toBe(200); + + // The response is expected directly on the POST request for this transport implementation + const postResponseText = await postResponse.text(); + const messageLine = postResponseText + .split("\n") + .find((line) => line.startsWith("data:")); + + expect(messageLine).toBeDefined(); + const postResponseJson = JSON.parse(messageLine!.replace(/^data: /, "")); + expect(isListToolsResponse(postResponseJson)).toBe(true); + expect(postResponseJson.result.tools[0].name).toBe("ping"); + } catch (error) { + console.error("Error during POST request:", error); + throw error; + } + }); +}); From e330a73df59768f66a772f877a10e11a72bb26fb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 2 Aug 2025 01:05:29 +0000 Subject: [PATCH 28/35] Bump version to 2.7.0 --- CITATION.cff | 2 +- manifest.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 12f1f73..09c20b0 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -9,6 +9,6 @@ authors: given-names: "Suyog" orcid: "https://orcid.org/0009-0002-7352-5978" title: "MCP Server Kubernetes" -version: 2.6.0 +version: 2.7.0 date-released: 2024-07-30 url: "https://github.com/Flux159/mcp-server-kubernetes" diff --git a/manifest.json b/manifest.json index 0d14ed5..372b851 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "dxt_version": "0.1", "name": "mcp-server-kubernetes", - "version": "2.6.0", + "version": "2.7.0", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "long_description": "MCP Server that can connect to a Kubernetes cluster and manage it.\n\nBy default, the server loads kubeconfig from `~/.kube/config`.\n\nThe server will automatically connect to your current kubectl context. Make sure you have:\n\n1. kubectl installed and in your PATH\n2. A valid kubeconfig file with contexts configured\n3. Access to a Kubernetes cluster configured for kubectl (e.g. minikube, Rancher Desktop, GKE, etc.)\n4. Optional: Helm v3 installed and in your PATH.\n\nYou can verify your connection by asking Claude to list your pods or create a test deployment.\n\nIf you have errors open up a standard terminal and run `kubectl get pods` to see if you can connect to your cluster without credentials issues.\n\n## Features\n\n- [x] Connect to a Kubernetes cluster\n- [x] Unified kubectl API for managing resources\n- Get or list resources with `kubectl_get`\n- Describe resources with `kubectl_describe`\n- List resources with `kubectl_get`\n- Create resources with `kubectl_create`\n- Apply YAML manifests with `kubectl_apply`\n- Delete resources with `kubectl_delete`\n- Get logs with `kubectl_logs`\n- and more.", "author": { diff --git a/package.json b/package.json index 5c646dc..09f4390 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-server-kubernetes", - "version": "2.6.0", + "version": "2.7.0", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "license": "MIT", "type": "module", From 19764abc966b8c0414b99c7913ef2dc39076c12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vitor=20Costa?= Date: Wed, 30 Jul 2025 15:25:07 -0300 Subject: [PATCH 29/35] Add context to commands --- src/index.ts | 19 +++++++++++++++++++ src/models/kubectl-models.ts | 2 ++ src/tools/exec_in_pod.ts | 12 ++++++++++++ src/tools/helm-operations.ts | 18 ++++++++++++++++++ src/tools/kubectl-apply.ts | 13 +++++++++++++ src/tools/kubectl-create.ts | 13 +++++++++++++ src/tools/kubectl-delete.ts | 13 +++++++++++++ src/tools/kubectl-describe.ts | 12 ++++++++++++ src/tools/kubectl-generic.ts | 12 ++++++++++++ src/tools/kubectl-get.ts | 12 ++++++++++++ src/tools/kubectl-logs.ts | 17 +++++++++++++++++ src/tools/kubectl-operations.ts | 24 ++++++++++++++++++++++-- src/tools/kubectl-patch.ts | 13 +++++++++++++ src/tools/kubectl-rollout.ts | 13 +++++++++++++ src/tools/kubectl-scale.ts | 13 +++++++++++++ 15 files changed, 204 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 8d81954..27b8e99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -197,6 +197,7 @@ server.setRequestHandler( showCurrent?: boolean; detailed?: boolean; output?: string; + context?: string; } ); } @@ -213,6 +214,7 @@ server.setRequestHandler( labelSelector?: string; fieldSelector?: string; sortBy?: string; + context?: string; } ); } @@ -225,6 +227,7 @@ server.setRequestHandler( name: string; namespace?: string; allNamespaces?: boolean; + context?: string; } ); } @@ -238,6 +241,7 @@ server.setRequestHandler( namespace?: string; dryRun?: boolean; force?: boolean; + context?: string; } ); } @@ -255,6 +259,7 @@ server.setRequestHandler( allNamespaces?: boolean; force?: boolean; gracePeriodSeconds?: number; + context?: string; } ); } @@ -268,6 +273,7 @@ server.setRequestHandler( namespace?: string; dryRun?: boolean; validate?: boolean; + context?: string; } ); } @@ -287,6 +293,7 @@ server.setRequestHandler( previous?: boolean; follow?: boolean; labelSelector?: string; + context?: string; } ); } @@ -302,6 +309,7 @@ server.setRequestHandler( patchData?: object; patchFile?: string; dryRun?: boolean; + context?: string; } ); } @@ -324,6 +332,7 @@ server.setRequestHandler( toRevision?: number; timeout?: string; watch?: boolean; + context?: string; } ); } @@ -340,6 +349,7 @@ server.setRequestHandler( outputFormat?: string; flags?: Record; args?: string[]; + context?: string; } ); } @@ -352,6 +362,7 @@ server.setRequestHandler( labelSelector: (input as { labelSelector?: string }).labelSelector, sortBy: (input as { sortBy?: string }).sortBy, output: (input as { output?: string }).output, + context: (input as { context?: string }).context, }); } @@ -378,6 +389,7 @@ server.setRequestHandler( case "explain_resource": { return await explainResource( input as { + context?: string; resource: string; apiVersion?: string; recursive?: boolean; @@ -394,6 +406,7 @@ server.setRequestHandler( repo: string; namespace: string; values?: Record; + context?: string; } ); } @@ -403,6 +416,7 @@ server.setRequestHandler( input as { name: string; namespace: string; + context?: string; } ); } @@ -415,6 +429,7 @@ server.setRequestHandler( repo: string; namespace: string; values?: Record; + context?: string; } ); } @@ -426,6 +441,7 @@ server.setRequestHandler( namespaced?: boolean; verbs?: string[]; output?: "wide" | "name" | "no-headers"; + context?: string; } ); } @@ -438,6 +454,7 @@ server.setRequestHandler( resourceName: string; localPort: number; targetPort: number; + context?: string; } ); } @@ -459,6 +476,7 @@ server.setRequestHandler( namespace?: string; replicas: number; resourceType?: string; + context?: string; } ); } @@ -475,6 +493,7 @@ server.setRequestHandler( namespace?: string; command: string | string[]; container?: string; + context?: string; } ); } diff --git a/src/models/kubectl-models.ts b/src/models/kubectl-models.ts index 77dc99b..321684c 100644 --- a/src/models/kubectl-models.ts +++ b/src/models/kubectl-models.ts @@ -14,6 +14,7 @@ export interface ExplainResourceParams { apiVersion?: string; recursive?: boolean; output?: "plaintext" | "plaintext-openapiv2"; + context?: string; } export interface ListApiResourcesParams { @@ -21,4 +22,5 @@ export interface ListApiResourcesParams { namespaced?: boolean; verbs?: string[]; output?: "wide" | "name" | "no-headers"; + context?: string; } diff --git a/src/tools/exec_in_pod.ts b/src/tools/exec_in_pod.ts index 25a1c63..a09c0d7 100644 --- a/src/tools/exec_in_pod.ts +++ b/src/tools/exec_in_pod.ts @@ -51,6 +51,12 @@ export const execInPodSchema = { type: "number", description: "Timeout for command - 60000 milliseconds if not specified", }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, }, required: ["name", "command"], }, @@ -70,6 +76,7 @@ export async function execInPod( container?: string; shell?: string; timeout?: number; + context?: string; } ): Promise<{ content: { type: string; text: string }[] }> { const namespace = input.namespace || "default"; @@ -109,6 +116,11 @@ export async function execInPod( }); try { + // Set context if provided + if (input.context) { + k8sManager.setCurrentContext(input.context); + } + // Use the Kubernetes client-node Exec API for native exec const kc = k8sManager.getKubeConfig(); const exec = new k8s.Exec(kc); diff --git a/src/tools/helm-operations.ts b/src/tools/helm-operations.ts index 3e53660..8c9f6bb 100644 --- a/src/tools/helm-operations.ts +++ b/src/tools/helm-operations.ts @@ -31,6 +31,12 @@ export const installHelmChartSchema = { type: "string", description: "Kubernetes namespace", }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, values: { type: "object", description: "Chart values", @@ -64,6 +70,12 @@ export const upgradeHelmChartSchema = { type: "string", description: "Kubernetes namespace", }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, values: { type: "object", description: "Chart values", @@ -89,6 +101,12 @@ export const uninstallHelmChartSchema = { type: "string", description: "Kubernetes namespace", }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, }, required: ["name", "namespace"], }, diff --git a/src/tools/kubectl-apply.ts b/src/tools/kubectl-apply.ts index 495e8d9..06a24cd 100644 --- a/src/tools/kubectl-apply.ts +++ b/src/tools/kubectl-apply.ts @@ -37,6 +37,12 @@ export const kubectlApplySchema = { "If true, immediately remove resources from API and bypass graceful deletion", default: false, }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, }, required: [], }, @@ -50,6 +56,7 @@ export async function kubectlApply( namespace?: string; dryRun?: boolean; force?: boolean; + context?: string; } ) { try { @@ -63,6 +70,7 @@ export async function kubectlApply( const namespace = input.namespace || "default"; const dryRun = input.dryRun || false; const force = input.force || false; + const context = input.context || ""; let command = "kubectl"; let args = ["apply"]; @@ -92,6 +100,11 @@ export async function kubectlApply( args.push("--force"); } + // Add context if provided + if (context) { + args.push("--context", context); + } + // Execute the command try { const result = execFileSync(command, args, { diff --git a/src/tools/kubectl-create.ts b/src/tools/kubectl-create.ts index 2284574..3d6a3c8 100644 --- a/src/tools/kubectl-create.ts +++ b/src/tools/kubectl-create.ts @@ -157,6 +157,12 @@ export const kubectlCreateSchema = { description: 'Annotations to apply to the resource (e.g. ["key1=value1", "key2=value2"])', }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, }, required: [], }, @@ -203,6 +209,7 @@ export async function kubectlCreate( annotations?: string[]; schedule?: string; suspend?: boolean; + context?: string; } ) { try { @@ -231,6 +238,7 @@ export async function kubectlCreate( const dryRun = input.dryRun || false; const validate = input.validate ?? true; const output = input.output || "yaml"; + const context = input.context || ""; const command = "kubectl"; const args = ["create"]; @@ -423,6 +431,11 @@ export async function kubectlCreate( // Add output format args.push("-o", output); + // Add context if provided + if (context) { + args.push("--context", context); + } + // Execute the command try { const result = execFileSync(command, args, { diff --git a/src/tools/kubectl-delete.ts b/src/tools/kubectl-delete.ts index ca3f5ad..9ad4574 100644 --- a/src/tools/kubectl-delete.ts +++ b/src/tools/kubectl-delete.ts @@ -57,6 +57,12 @@ export const kubectlDeleteSchema = { description: "Period of time in seconds given to the resource to terminate gracefully", }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, }, required: ["resourceType", "name", "namespace"], }, @@ -74,6 +80,7 @@ export async function kubectlDelete( allNamespaces?: boolean; force?: boolean; gracePeriodSeconds?: number; + context?: string; } ) { try { @@ -96,6 +103,7 @@ export async function kubectlDelete( const namespace = input.namespace || "default"; const allNamespaces = input.allNamespaces || false; const force = input.force || false; + const context = input.context || ""; const command = "kubectl"; const args = ["delete"]; @@ -144,6 +152,11 @@ export async function kubectlDelete( args.push(`--grace-period=${input.gracePeriodSeconds}`); } + // Add context if provided + if (context) { + args.push("--context", context); + } + // Execute the command try { const result = execFileSync(command, args, { diff --git a/src/tools/kubectl-describe.ts b/src/tools/kubectl-describe.ts index bb7156d..f8a0dbb 100644 --- a/src/tools/kubectl-describe.ts +++ b/src/tools/kubectl-describe.ts @@ -25,6 +25,12 @@ export const kubectlDescribeSchema = { "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", default: "default", }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, allNamespaces: { type: "boolean", description: "If true, describe resources across all namespaces", @@ -42,6 +48,7 @@ export async function kubectlDescribe( name: string; namespace?: string; allNamespaces?: boolean; + context?: string; } ) { try { @@ -49,6 +56,7 @@ export async function kubectlDescribe( const name = input.name; const namespace = input.namespace || "default"; const allNamespaces = input.allNamespaces || false; + const context = input.context || ""; // Build the kubectl command const command = "kubectl"; @@ -61,6 +69,10 @@ export async function kubectlDescribe( args.push("-n", namespace); } + if (context) { + args.push("--context", context); + } + // Execute the command try { const result = execFileSync(command, args, { diff --git a/src/tools/kubectl-generic.ts b/src/tools/kubectl-generic.ts index 10c721e..28a30f7 100644 --- a/src/tools/kubectl-generic.ts +++ b/src/tools/kubectl-generic.ts @@ -47,6 +47,12 @@ export const kubectlGenericSchema = { items: { type: "string" }, description: "Additional command arguments", }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, }, required: ["command"], }, @@ -63,6 +69,7 @@ export async function kubectlGeneric( outputFormat?: string; flags?: Record; args?: string[]; + context?: string; } ) { try { @@ -113,6 +120,11 @@ export async function kubectlGeneric( cmdArgs.push(...input.args); } + // Add context if provided + if (input.context) { + cmdArgs.push("--context", input.context); + } + // Execute the command try { console.error(`Executing: kubectl ${cmdArgs.join(" ")}`); diff --git a/src/tools/kubectl-get.ts b/src/tools/kubectl-get.ts index e73947b..e20945e 100644 --- a/src/tools/kubectl-get.ts +++ b/src/tools/kubectl-get.ts @@ -52,6 +52,12 @@ export const kubectlGetSchema = { description: "Sort events by a field (default: lastTimestamp). Only applicable for events.", }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, }, required: ["resourceType", "name", "namespace"], }, @@ -68,6 +74,7 @@ export async function kubectlGet( labelSelector?: string; fieldSelector?: string; sortBy?: string; + context?: string; } ) { try { @@ -79,6 +86,7 @@ export async function kubectlGet( const labelSelector = input.labelSelector || ""; const fieldSelector = input.fieldSelector || ""; const sortBy = input.sortBy; + const context = input.context || ""; // Build the kubectl command const command = "kubectl"; @@ -104,6 +112,10 @@ export async function kubectlGet( args.push("-n", namespace); } + if (context) { + args.push("--context", context); + } + // Add label selector if provided if (labelSelector) { args.push("-l", labelSelector); diff --git a/src/tools/kubectl-logs.ts b/src/tools/kubectl-logs.ts index f33fca0..7517f58 100644 --- a/src/tools/kubectl-logs.ts +++ b/src/tools/kubectl-logs.ts @@ -60,6 +60,12 @@ export const kubectlLogsSchema = { type: "string", description: "Filter resources by label selector", }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, }, required: ["resourceType", "name", "namespace"], }, @@ -79,12 +85,14 @@ export async function kubectlLogs( previous?: boolean; follow?: boolean; labelSelector?: string; + context?: string; } ) { try { const resourceType = input.resourceType.toLowerCase(); const name = input.name; const namespace = input.namespace || "default"; + const context = input.context || ""; const command = "kubectl"; // Handle different resource types @@ -100,6 +108,11 @@ export async function kubectlLogs( // Add options args = addLogOptions(args, input); + // Add context if provided + if (context) { + args.push("--context", context); + } + // Execute the command try { const result = execFileSync(command, args, { @@ -289,6 +302,10 @@ function addLogOptions(args: string[], input: any): string[] { args.push(`--follow`); } + if (input.context) { + args.push("--context", input.context); + } + return args; } diff --git a/src/tools/kubectl-operations.ts b/src/tools/kubectl-operations.ts index 3f9a366..f9c1d35 100644 --- a/src/tools/kubectl-operations.ts +++ b/src/tools/kubectl-operations.ts @@ -25,6 +25,12 @@ export const explainResourceSchema = { description: "Print the fields of fields recursively", default: false, }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, output: { type: "string", description: "Output format (plaintext or plaintext-openapiv2)", @@ -50,6 +56,12 @@ export const listApiResourcesSchema = { type: "boolean", description: "If true, only show namespaced resources", }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, verbs: { type: "array", items: { @@ -80,7 +92,7 @@ const executeKubectlCommand = (command: string, args: string[]): string => { }; export async function explainResource( - params: ExplainResourceParams + params: ExplainResourceParams, ): Promise<{ content: { type: string; text: string }[] }> { try { const command = "kubectl"; @@ -94,6 +106,10 @@ export async function explainResource( args.push("--recursive"); } + if (params.context) { + args.push("--context", params.context); + } + if (params.output) { args.push(`--output=${params.output}`); } @@ -116,7 +132,7 @@ export async function explainResource( } export async function listApiResources( - params: ListApiResourcesParams + params: ListApiResourcesParams, ): Promise<{ content: { type: string; text: string }[] }> { try { const command = "kubectl"; @@ -138,6 +154,10 @@ export async function listApiResources( args.push(`-o`, params.output); } + if (params.context) { + args.push("--context", params.context); + } + const result = executeKubectlCommand(command, args); return { diff --git a/src/tools/kubectl-patch.ts b/src/tools/kubectl-patch.ts index d5ebe82..1a32866 100644 --- a/src/tools/kubectl-patch.ts +++ b/src/tools/kubectl-patch.ts @@ -48,6 +48,12 @@ export const kubectlPatchSchema = { "If true, only print the object that would be sent, without sending it", default: false, }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, }, required: ["resourceType", "name"], }, @@ -63,6 +69,7 @@ export async function kubectlPatch( patchData?: object; patchFile?: string; dryRun?: boolean; + context?: string; } ) { try { @@ -76,6 +83,7 @@ export async function kubectlPatch( const namespace = input.namespace || "default"; const patchType = input.patchType || "strategic"; const dryRun = input.dryRun || false; + const context = input.context || ""; let tempFile: string | null = null; const command = "kubectl"; @@ -112,6 +120,11 @@ export async function kubectlPatch( args.push("--dry-run=client"); } + // Add context if provided + if (context) { + args.push("--context", context); + } + // Execute the command try { const result = execFileSync(command, args, { diff --git a/src/tools/kubectl-rollout.ts b/src/tools/kubectl-rollout.ts index b70de6b..786e160 100644 --- a/src/tools/kubectl-rollout.ts +++ b/src/tools/kubectl-rollout.ts @@ -49,6 +49,12 @@ export const kubectlRolloutSchema = { description: "Watch the rollout status in real-time until completion", default: false, }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, }, required: ["subCommand", "resourceType", "name", "namespace"], }, @@ -65,11 +71,13 @@ export async function kubectlRollout( toRevision?: number; timeout?: string; watch?: boolean; + context?: string; } ) { try { const namespace = input.namespace || "default"; const watch = input.watch || false; + const context = input.context || ""; const command = "kubectl"; const args = [ @@ -95,6 +103,11 @@ export async function kubectlRollout( args.push(`--timeout=${input.timeout}`); } + // Add context if provided + if (context) { + args.push("--context", context); + } + // Execute the command try { // For status command with watch flag, we need to handle it differently diff --git a/src/tools/kubectl-scale.ts b/src/tools/kubectl-scale.ts index 9f77943..91c74b5 100644 --- a/src/tools/kubectl-scale.ts +++ b/src/tools/kubectl-scale.ts @@ -28,6 +28,12 @@ export const kubectlScaleSchema = { "Resource type to scale (deployment, replicaset, statefulset)", default: "deployment", }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, }, required: ["name", "replicas"], }, @@ -40,11 +46,13 @@ export async function kubectlScale( namespace?: string; replicas: number; resourceType?: string; + context?: string; } ) { try { const namespace = input.namespace || "default"; const resourceType = input.resourceType || "deployment"; + const context = input.context || ""; const command = "kubectl"; const args = [ @@ -55,6 +63,11 @@ export async function kubectlScale( `--namespace=${namespace}`, ]; + // Add context if provided + if (context) { + args.push("--context", context); + } + // Execute the command try { const result = execFileSync(command, args, { From fd34b69dc5c391e6d9caaf6695d9e64605efd764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vitor=20Costa?= Date: Mon, 4 Aug 2025 17:10:55 -0300 Subject: [PATCH 30/35] refactor: re-use common parameters over tools --- src/models/common-parameters.ts | 17 +++++++++++++++ src/tools/exec_in_pod.ts | 14 +++---------- src/tools/helm-operations.ts | 37 +++++++-------------------------- src/tools/kubectl-apply.ts | 20 ++++-------------- src/tools/kubectl-create.ts | 21 ++++--------------- src/tools/kubectl-delete.ts | 15 +++---------- src/tools/kubectl-describe.ts | 15 +++---------- src/tools/kubectl-generic.ts | 14 +++---------- src/tools/kubectl-get.ts | 15 +++---------- src/tools/kubectl-logs.ts | 14 +++---------- src/tools/kubectl-operations.ts | 8 ++----- src/tools/kubectl-patch.ts | 21 ++++--------------- src/tools/kubectl-rollout.ts | 14 +++---------- src/tools/kubectl-scale.ts | 14 +++---------- 14 files changed, 62 insertions(+), 177 deletions(-) create mode 100644 src/models/common-parameters.ts diff --git a/src/models/common-parameters.ts b/src/models/common-parameters.ts new file mode 100644 index 0000000..fddb048 --- /dev/null +++ b/src/models/common-parameters.ts @@ -0,0 +1,17 @@ +export const contextParameter = { + type: "string" as const, + description: "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", +}; + +export const namespaceParameter = { + type: "string" as const, + description: "Kubernetes namespace", + default: "default", +}; + +export const dryRunParameter = { + type: "boolean" as const, + description: "If true, only validate the resource, don't actually execute the operation", + default: false, +}; \ No newline at end of file diff --git a/src/tools/exec_in_pod.ts b/src/tools/exec_in_pod.ts index a09c0d7..344a410 100644 --- a/src/tools/exec_in_pod.ts +++ b/src/tools/exec_in_pod.ts @@ -9,6 +9,7 @@ import * as k8s from "@kubernetes/client-node"; import { KubernetesManager } from "../types.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { Writable } from "stream"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.js"; /** * Schema for exec_in_pod tool. @@ -27,11 +28,7 @@ export const execInPodSchema = { 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", - }, + namespace: namespaceParameter, command: { anyOf: [ { type: "string" }, @@ -51,12 +48,7 @@ export const execInPodSchema = { type: "number", description: "Timeout for command - 60000 milliseconds if not specified", }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + context: contextParameter, }, required: ["name", "command"], }, diff --git a/src/tools/helm-operations.ts b/src/tools/helm-operations.ts index 8c9f6bb..d6add2b 100644 --- a/src/tools/helm-operations.ts +++ b/src/tools/helm-operations.ts @@ -8,6 +8,7 @@ import { HelmUpgradeOperation, } from "../models/helm-models.js"; import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.js"; export const installHelmChartSchema = { name: "install_helm_chart", @@ -27,16 +28,8 @@ export const installHelmChartSchema = { type: "string", description: "Chart repository URL", }, - namespace: { - type: "string", - description: "Kubernetes namespace", - }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + namespace: namespaceParameter, + context: contextParameter, values: { type: "object", description: "Chart values", @@ -66,16 +59,8 @@ export const upgradeHelmChartSchema = { type: "string", description: "Chart repository URL", }, - namespace: { - type: "string", - description: "Kubernetes namespace", - }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + namespace: namespaceParameter, + context: contextParameter, values: { type: "object", description: "Chart values", @@ -97,16 +82,8 @@ export const uninstallHelmChartSchema = { type: "string", description: "Release name", }, - namespace: { - type: "string", - description: "Kubernetes namespace", - }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + namespace: namespaceParameter, + context: contextParameter, }, required: ["name", "namespace"], }, diff --git a/src/tools/kubectl-apply.ts b/src/tools/kubectl-apply.ts index 06a24cd..796a5d5 100644 --- a/src/tools/kubectl-apply.ts +++ b/src/tools/kubectl-apply.ts @@ -5,6 +5,7 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter, dryRunParameter } from "../models/common-parameters.js"; export const kubectlApplySchema = { name: "kubectl_apply", @@ -21,28 +22,15 @@ export const kubectlApplySchema = { description: "Path to a YAML file to apply (optional - use either manifest or filename)", }, - namespace: { - type: "string", - description: "Namespace to apply the resource to (optional)", - default: "default", - }, - dryRun: { - type: "boolean", - description: "If true, only validate the resource, don't apply it", - default: false, - }, + namespace: namespaceParameter, + dryRun: dryRunParameter, force: { type: "boolean", description: "If true, immediately remove resources from API and bypass graceful deletion", default: false, }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + context: contextParameter, }, required: [], }, diff --git a/src/tools/kubectl-create.ts b/src/tools/kubectl-create.ts index 3d6a3c8..fd350f2 100644 --- a/src/tools/kubectl-create.ts +++ b/src/tools/kubectl-create.ts @@ -5,6 +5,7 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter, dryRunParameter } from "../models/common-parameters.js"; export const kubectlCreateSchema = { name: "kubectl_create", @@ -14,12 +15,7 @@ export const kubectlCreateSchema = { type: "object", properties: { // General options - dryRun: { - type: "boolean", - description: - "If true, only validate the resource, don't actually create it", - default: false, - }, + dryRun: dryRunParameter, output: { type: "string", enum: [ @@ -66,11 +62,7 @@ export const kubectlCreateSchema = { type: "string", description: "Name of the resource to create", }, - namespace: { - type: "string", - description: "Namespace to create the resource in", - default: "default", - }, + namespace: namespaceParameter, // ConfigMap specific parameters fromLiteral: { @@ -157,12 +149,7 @@ export const kubectlCreateSchema = { description: 'Annotations to apply to the resource (e.g. ["key1=value1", "key2=value2"])', }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + context: contextParameter, }, required: [], }, diff --git a/src/tools/kubectl-delete.ts b/src/tools/kubectl-delete.ts index 9ad4574..e6bc32e 100644 --- a/src/tools/kubectl-delete.ts +++ b/src/tools/kubectl-delete.ts @@ -5,6 +5,7 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.js"; export const kubectlDeleteSchema = { name: "kubectl_delete", @@ -22,12 +23,7 @@ export const kubectlDeleteSchema = { type: "string", description: "Name of the resource to delete", }, - namespace: { - type: "string", - description: - "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", - default: "default", - }, + namespace: namespaceParameter, labelSelector: { type: "string", description: @@ -57,12 +53,7 @@ export const kubectlDeleteSchema = { description: "Period of time in seconds given to the resource to terminate gracefully", }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + context: contextParameter, }, required: ["resourceType", "name", "namespace"], }, diff --git a/src/tools/kubectl-describe.ts b/src/tools/kubectl-describe.ts index f8a0dbb..bd05303 100644 --- a/src/tools/kubectl-describe.ts +++ b/src/tools/kubectl-describe.ts @@ -2,6 +2,7 @@ import { KubernetesManager } from "../types.js"; import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { namespaceParameter, contextParameter } from "../models/common-parameters.js"; export const kubectlDescribeSchema = { name: "kubectl_describe", @@ -19,18 +20,8 @@ export const kubectlDescribeSchema = { type: "string", description: "Name of the resource to describe", }, - namespace: { - type: "string", - description: - "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", - default: "default", - }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + namespace: namespaceParameter, + context: contextParameter, allNamespaces: { type: "boolean", description: "If true, describe resources across all namespaces", diff --git a/src/tools/kubectl-generic.ts b/src/tools/kubectl-generic.ts index 28a30f7..8d2af6f 100644 --- a/src/tools/kubectl-generic.ts +++ b/src/tools/kubectl-generic.ts @@ -2,6 +2,7 @@ import { KubernetesManager } from "../types.js"; import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.js"; export const kubectlGenericSchema = { name: "kubectl_generic", @@ -27,11 +28,7 @@ export const kubectlGenericSchema = { type: "string", description: "Resource name", }, - namespace: { - type: "string", - description: "Namespace", - default: "default", - }, + namespace: namespaceParameter, outputFormat: { type: "string", description: "Output format (e.g. json, yaml, wide)", @@ -47,12 +44,7 @@ export const kubectlGenericSchema = { items: { type: "string" }, description: "Additional command arguments", }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + context: contextParameter, }, required: ["command"], }, diff --git a/src/tools/kubectl-get.ts b/src/tools/kubectl-get.ts index e20945e..13c7002 100644 --- a/src/tools/kubectl-get.ts +++ b/src/tools/kubectl-get.ts @@ -3,6 +3,7 @@ import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { getSpawnMaxBuffer } from "../config/max-buffer.js"; import * as yaml from "js-yaml"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.js"; export const kubectlGetSchema = { name: "kubectl_get", @@ -21,12 +22,7 @@ export const kubectlGetSchema = { description: "Name of the resource (optional - if not provided, lists all resources of the specified type)", }, - namespace: { - type: "string", - description: - "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", - default: "default", - }, + namespace: namespaceParameter, output: { type: "string", enum: ["json", "yaml", "wide", "name", "custom"], @@ -52,12 +48,7 @@ export const kubectlGetSchema = { description: "Sort events by a field (default: lastTimestamp). Only applicable for events.", }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + context: contextParameter }, required: ["resourceType", "name", "namespace"], }, diff --git a/src/tools/kubectl-logs.ts b/src/tools/kubectl-logs.ts index 7517f58..8abd307 100644 --- a/src/tools/kubectl-logs.ts +++ b/src/tools/kubectl-logs.ts @@ -2,6 +2,7 @@ import { KubernetesManager } from "../types.js"; import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.js"; export const kubectlLogsSchema = { name: "kubectl_logs", @@ -19,11 +20,7 @@ export const kubectlLogsSchema = { type: "string", description: "Name of the resource", }, - namespace: { - type: "string", - description: "Namespace of the resource", - default: "default", - }, + namespace: namespaceParameter, container: { type: "string", description: @@ -60,12 +57,7 @@ export const kubectlLogsSchema = { type: "string", description: "Filter resources by label selector", }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + context: contextParameter, }, required: ["resourceType", "name", "namespace"], }, diff --git a/src/tools/kubectl-operations.ts b/src/tools/kubectl-operations.ts index f9c1d35..7f4fcfd 100644 --- a/src/tools/kubectl-operations.ts +++ b/src/tools/kubectl-operations.ts @@ -4,6 +4,7 @@ import { ListApiResourcesParams, } from "../models/kubectl-models.js"; import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter } from "../models/common-parameters.js"; export const explainResourceSchema = { name: "explain_resource", @@ -25,12 +26,7 @@ export const explainResourceSchema = { description: "Print the fields of fields recursively", default: false, }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + context: contextParameter, output: { type: "string", description: "Output format (plaintext or plaintext-openapiv2)", diff --git a/src/tools/kubectl-patch.ts b/src/tools/kubectl-patch.ts index 1a32866..a765605 100644 --- a/src/tools/kubectl-patch.ts +++ b/src/tools/kubectl-patch.ts @@ -5,6 +5,7 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, dryRunParameter, namespaceParameter } from "../models/common-parameters.js"; export const kubectlPatchSchema = { name: "kubectl_patch", @@ -22,11 +23,7 @@ export const kubectlPatchSchema = { type: "string", description: "Name of the resource to patch", }, - namespace: { - type: "string", - description: "Namespace of the resource", - default: "default", - }, + namespace: namespaceParameter, patchType: { type: "string", description: "Type of patch to apply", @@ -42,18 +39,8 @@ export const kubectlPatchSchema = { description: "Path to a file containing the patch data (alternative to patchData)", }, - dryRun: { - type: "boolean", - description: - "If true, only print the object that would be sent, without sending it", - default: false, - }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + dryRun: dryRunParameter, + context: contextParameter, }, required: ["resourceType", "name"], }, diff --git a/src/tools/kubectl-rollout.ts b/src/tools/kubectl-rollout.ts index 786e160..f593ab9 100644 --- a/src/tools/kubectl-rollout.ts +++ b/src/tools/kubectl-rollout.ts @@ -2,6 +2,7 @@ import { KubernetesManager } from "../types.js"; import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.js"; export const kubectlRolloutSchema = { name: "kubectl_rollout", @@ -26,11 +27,7 @@ export const kubectlRolloutSchema = { type: "string", description: "Name of the resource", }, - namespace: { - type: "string", - description: "Namespace of the resource", - default: "default", - }, + namespace: namespaceParameter, revision: { type: "number", description: "Revision to rollback to (for undo subcommand)", @@ -49,12 +46,7 @@ export const kubectlRolloutSchema = { description: "Watch the rollout status in real-time until completion", default: false, }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + context: contextParameter, }, required: ["subCommand", "resourceType", "name", "namespace"], }, diff --git a/src/tools/kubectl-scale.ts b/src/tools/kubectl-scale.ts index 91c74b5..9833349 100644 --- a/src/tools/kubectl-scale.ts +++ b/src/tools/kubectl-scale.ts @@ -2,6 +2,7 @@ import { KubernetesManager } from "../types.js"; import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.js"; export const kubectlScaleSchema = { name: "kubectl_scale", @@ -13,11 +14,7 @@ export const kubectlScaleSchema = { type: "string", description: "Name of the deployment to scale", }, - namespace: { - type: "string", - description: "Namespace of the deployment", - default: "default", - }, + namespace: namespaceParameter, replicas: { type: "number", description: "Number of replicas to scale to", @@ -28,12 +25,7 @@ export const kubectlScaleSchema = { "Resource type to scale (deployment, replicaset, statefulset)", default: "deployment", }, - context: { - type: "string", - description: - "Kubeconfig Context to use for the command (optional - defaults to null)", - default: "", - }, + context: contextParameter, }, required: ["name", "replicas"], }, From 3420fd37c9a4827a0257285a66f8d492f0616125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vitor=20Costa?= Date: Mon, 4 Aug 2025 17:11:26 -0300 Subject: [PATCH 31/35] test: add a failure test for context parameter --- tests/kubectl.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/kubectl.test.ts b/tests/kubectl.test.ts index 6c8d634..70a2dfd 100644 --- a/tests/kubectl.test.ts +++ b/tests/kubectl.test.ts @@ -402,5 +402,31 @@ describe("kubectl operations", () => { expect(events.events).toBeDefined(); expect(Array.isArray(events.events)).toBe(true); }); + + test("get events with custom invalid context should fail", async () => { + try { + await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "events", + namespace: "default", + output: "json", + context: "non-existent-cluster" + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(true).toBe(false); // This should not execute + } catch (error: any) { + expect(error.message).toContain('error: context "non-existent-cluster" does not exist'); + } + }); }); }); From 697cc2c6bf7c7f73291c60b0ffcea772d74319bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 11 Aug 2025 06:19:49 +0000 Subject: [PATCH 32/35] Bump version to 2.8.0 --- CITATION.cff | 2 +- manifest.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 09c20b0..cb50ace 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -9,6 +9,6 @@ authors: given-names: "Suyog" orcid: "https://orcid.org/0009-0002-7352-5978" title: "MCP Server Kubernetes" -version: 2.7.0 +version: 2.8.0 date-released: 2024-07-30 url: "https://github.com/Flux159/mcp-server-kubernetes" diff --git a/manifest.json b/manifest.json index 372b851..cca4fb9 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "dxt_version": "0.1", "name": "mcp-server-kubernetes", - "version": "2.7.0", + "version": "2.8.0", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "long_description": "MCP Server that can connect to a Kubernetes cluster and manage it.\n\nBy default, the server loads kubeconfig from `~/.kube/config`.\n\nThe server will automatically connect to your current kubectl context. Make sure you have:\n\n1. kubectl installed and in your PATH\n2. A valid kubeconfig file with contexts configured\n3. Access to a Kubernetes cluster configured for kubectl (e.g. minikube, Rancher Desktop, GKE, etc.)\n4. Optional: Helm v3 installed and in your PATH.\n\nYou can verify your connection by asking Claude to list your pods or create a test deployment.\n\nIf you have errors open up a standard terminal and run `kubectl get pods` to see if you can connect to your cluster without credentials issues.\n\n## Features\n\n- [x] Connect to a Kubernetes cluster\n- [x] Unified kubectl API for managing resources\n- Get or list resources with `kubectl_get`\n- Describe resources with `kubectl_describe`\n- List resources with `kubectl_get`\n- Create resources with `kubectl_create`\n- Apply YAML manifests with `kubectl_apply`\n- Delete resources with `kubectl_delete`\n- Get logs with `kubectl_logs`\n- and more.", "author": { diff --git a/package.json b/package.json index 09f4390..9d9e103 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-server-kubernetes", - "version": "2.7.0", + "version": "2.8.0", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "license": "MIT", "type": "module", From fbab9402da8946a92f1ed5e2c9a1d98b2176a595 Mon Sep 17 00:00:00 2001 From: Matvey-Kuk Date: Fri, 15 Aug 2025 21:32:11 +0100 Subject: [PATCH 33/35] Add MCP Catalog Trust Score badge --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2606631..09856df 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ [![Issues](https://img.shields.io/github/issues/Flux159/mcp-server-kubernetes)](https://github.com/Flux159/mcp-server-kubernetes/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/Flux159/mcp-server-kubernetes/pulls) [![Last Commit](https://img.shields.io/github/last-commit/Flux159/mcp-server-kubernetes)](https://github.com/Flux159/mcp-server-kubernetes/commits/main) + +[![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/Flux159/mcp-server-kubernetes)](https://archestra.ai/mcp-catalog/flux159__mcp-server-kubernetes) [![smithery badge](https://smithery.ai/badge/mcp-server-kubernetes)](https://smithery.ai/protocol/mcp-server-kubernetes) MCP Server that can connect to a Kubernetes cluster and manage it. Supports loading kubeconfig from multiple sources in priority order. From 67be291ab6e19caffa4748044f6d3ab7ca78a47d Mon Sep 17 00:00:00 2001 From: Suyog Sonwalkar Date: Mon, 18 Aug 2025 10:59:27 -0700 Subject: [PATCH 34/35] Update README.md Get all badges on the same line. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 09856df..7e7717d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ [![Issues](https://img.shields.io/github/issues/Flux159/mcp-server-kubernetes)](https://github.com/Flux159/mcp-server-kubernetes/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/Flux159/mcp-server-kubernetes/pulls) [![Last Commit](https://img.shields.io/github/last-commit/Flux159/mcp-server-kubernetes)](https://github.com/Flux159/mcp-server-kubernetes/commits/main) - [![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/Flux159/mcp-server-kubernetes)](https://archestra.ai/mcp-catalog/flux159__mcp-server-kubernetes) [![smithery badge](https://smithery.ai/badge/mcp-server-kubernetes)](https://smithery.ai/protocol/mcp-server-kubernetes) From 44aa9001155fc87c2336e14f606f92412ecfbc50 Mon Sep 17 00:00:00 2001 From: Suyog Sonwalkar Date: Mon, 18 Aug 2025 11:05:51 -0700 Subject: [PATCH 35/35] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 7e7717d..691c597 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/Flux159/mcp-server-kubernetes/pulls) [![Last Commit](https://img.shields.io/github/last-commit/Flux159/mcp-server-kubernetes)](https://github.com/Flux159/mcp-server-kubernetes/commits/main) [![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/Flux159/mcp-server-kubernetes)](https://archestra.ai/mcp-catalog/flux159__mcp-server-kubernetes) -[![smithery badge](https://smithery.ai/badge/mcp-server-kubernetes)](https://smithery.ai/protocol/mcp-server-kubernetes) MCP Server that can connect to a Kubernetes cluster and manage it. Supports loading kubeconfig from multiple sources in priority order.