UNPKG

mcp-server-kubernetes

Version:

MCP server for interacting with Kubernetes clusters via kubectl

498 lines (497 loc) 19.6 kB
import { execFileSyncSafe } from "../security/kubectl-flags.js"; 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", description: "Get or list Kubernetes resources by resource type, name, and optionally namespace", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: { resourceType: { type: "string", description: "Type of resource to get (e.g., pods, deployments, services, configmaps, events, etc.)", }, name: { type: "string", description: "Name of the resource (optional - if not provided, lists all resources of the specified type)", }, namespace: namespaceParameter, output: { type: "string", enum: ["json", "yaml", "wide", "name", "custom"], description: "Output format", default: "json", }, allNamespaces: { type: "boolean", description: "If true, list resources across all namespaces", default: false, }, labelSelector: { type: "string", description: "Filter resources by label selector (e.g. 'app=nginx')", }, fieldSelector: { type: "string", description: "Filter resources by field selector (e.g. 'metadata.name=my-pod')", }, sortBy: { type: "string", description: "Sort events by a field (default: lastTimestamp). Only applicable for events.", }, context: contextParameter, }, required: ["resourceType"], }, }; export async function kubectlGet(k8sManager, input) { try { const resourceType = input.resourceType.toLowerCase(); const name = input.name || ""; const namespace = input.namespace || "default"; const output = input.output || "json"; const allNamespaces = input.allNamespaces || false; const labelSelector = input.labelSelector || ""; const fieldSelector = input.fieldSelector || ""; const sortBy = input.sortBy; const context = input.context || ""; // Build the kubectl command const command = "kubectl"; const args = ["get", resourceType]; // Add name if provided if (name) { args.push(name); } // For events, default to all namespaces unless explicitly specified const shouldShowAllNamespaces = resourceType === "events" ? input.namespace ? false : true : allNamespaces; // Add namespace flag unless all namespaces is specified if (shouldShowAllNamespaces) { args.push("--all-namespaces"); } else if (namespace && !isNonNamespacedResource(resourceType)) { args.push("-n", namespace); } if (context) { args.push("--context", context); } // Add label selector if provided if (labelSelector) { args.push("-l", labelSelector); } // Add field selector if provided if (fieldSelector) { args.push(`--field-selector=${fieldSelector}`); } // Add sort-by for events if (resourceType === "events" && sortBy) { args.push(`--sort-by=.${sortBy}`); } else if (resourceType === "events") { args.push(`--sort-by=.lastTimestamp`); } // Add output format if (output === "json") { args.push("-o", "json"); } else if (output === "yaml") { args.push("-o", "yaml"); } else if (output === "wide") { args.push("-o", "wide"); } else if (output === "name") { args.push("-o", "name"); } else if (output === "custom") { if (resourceType === "events") { args.push("-o", "custom-columns=LASTSEEN:.lastTimestamp,TYPE:.type,REASON:.reason,OBJECT:.involvedObject.name,MESSAGE:.message"); } else { args.push("-o", "custom-columns=NAME:.metadata.name,NAMESPACE:.metadata.namespace,STATUS:.status.phase,AGE:.metadata.creationTimestamp"); } } // Execute the command try { const result = execFileSyncSafe(command, args, { encoding: "utf8", maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); // Apply secrets masking if enabled and dealing with secrets. // resourceReferencesSecret normalizes combined forms like // "secret/my-secret", group-qualified "secrets.v1./my-secret", and // comma-separated lists ("secret,configmap") so the masking decision // cannot be bypassed by addressing a Secret through an alternate syntax. const shouldMaskSecrets = process.env.MASK_SECRETS !== "false" && resourceReferencesSecret(resourceType); 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(processedResult); if (parsed.kind && parsed.kind.endsWith("List") && parsed.items) { if (resourceType === "events") { const formattedEvents = parsed.items.map((event) => ({ type: event.type || "", reason: event.reason || "", message: event.message || "", involvedObject: { kind: event.involvedObject?.kind || "", name: event.involvedObject?.name || "", namespace: event.involvedObject?.namespace || "", }, firstTimestamp: event.firstTimestamp || "", lastTimestamp: event.lastTimestamp || "", count: event.count || 0, })); return { content: [ { type: "text", text: JSON.stringify({ events: formattedEvents }, null, 2), }, ], }; } else { const items = parsed.items.map((item) => ({ name: item.metadata?.name || "", namespace: item.metadata?.namespace || "", kind: item.kind || resourceType, status: getResourceStatus(item, resourceType), createdAt: item.metadata?.creationTimestamp, })); return { content: [ { type: "text", text: JSON.stringify({ items }, null, 2), }, ], }; } } } catch (parseError) { // If JSON parsing fails, return the raw output console.error("Error parsing JSON:", parseError); } } return { content: [ { type: "text", text: processedResult, }, ], }; } catch (error) { if (error.status === 404 || error.message.includes("not found")) { return { content: [ { type: "text", text: JSON.stringify({ error: `Resource ${resourceType}${name ? `/${name}` : ""} not found`, status: "not_found", }, null, 2), }, ], isError: true, }; } throw new McpError(ErrorCode.InternalError, `Failed to get resource: ${error.message}`); } } catch (error) { throw new McpError(ErrorCode.InternalError, `Failed to execute kubectl get command: ${error.message}`); } } // Compute pod status the same way kubectl does, by inspecting container statuses // Based on kubernetes/pkg/printers/internalversion/printers.go printPod() function getPodStatus(pod) { let reason = pod.status?.phase || "Unknown"; if (pod.status?.reason) { reason = pod.status.reason; } // Check init container statuses const initContainerStatuses = pod.status?.initContainerStatuses || []; for (let i = 0; i < initContainerStatuses.length; i++) { const container = initContainerStatuses[i]; const terminated = container.state?.terminated; const waiting = container.state?.waiting; if (terminated && terminated.exitCode === 0) { // Init container completed successfully, continue continue; } if (terminated) { if (terminated.reason) { reason = `Init:${terminated.reason}`; } else if (terminated.signal) { reason = `Init:Signal:${terminated.signal}`; } else { reason = `Init:ExitCode:${terminated.exitCode}`; } } else if (waiting && waiting.reason && waiting.reason !== "PodInitializing") { reason = `Init:${waiting.reason}`; } else { const totalInit = initContainerStatuses.length; reason = `Init:${i}/${totalInit}`; } break; } // If all init containers are done, check regular container statuses if (initContainerStatuses.length === 0 || !reason.startsWith("Init:")) { const containerStatuses = pod.status?.containerStatuses || []; let hasRunning = false; for (let i = containerStatuses.length - 1; i >= 0; i--) { const container = containerStatuses[i]; const waiting = container.state?.waiting; const terminated = container.state?.terminated; if (waiting && waiting.reason) { reason = waiting.reason; } else if (terminated) { if (terminated.reason) { reason = terminated.reason; } else if (terminated.signal) { reason = `Signal:${terminated.signal}`; } else { reason = `ExitCode:${terminated.exitCode}`; } } else if (container.ready && container.state?.running) { hasRunning = true; } } // If all containers are ready and running, use the phase if (hasRunning && reason === (pod.status?.phase || "Unknown")) { reason = pod.status?.phase || "Running"; } } // Handle pod deletion if (pod.metadata?.deletionTimestamp) { reason = "Terminating"; } return reason; } // Extract status from various resource types function getResourceStatus(resource, resourceType) { if (!resource) return "Unknown"; const isPod = resource.kind === "Pod" || resourceType === "pods" || resourceType === "pod" || resourceType === "po" || (resource.status?.phase !== undefined && resource.status?.containerStatuses !== undefined); // Pod status - use kubectl-equivalent logic if (isPod) { return getPodStatus(resource); } // Deployment, ReplicaSet, StatefulSet status if (resource.status?.readyReplicas !== undefined) { const ready = resource.status.readyReplicas || 0; const total = resource.status.replicas || 0; return `${ready}/${total} ready`; } // Service status if (resource.spec?.type) { return resource.spec.type; } // Node status if (resource.status?.conditions) { const readyCondition = resource.status.conditions.find((c) => c.type === "Ready"); if (readyCondition) { return readyCondition.status === "True" ? "Ready" : "NotReady"; } } // Job/CronJob status if (resource.status?.succeeded !== undefined) { return resource.status.succeeded ? "Completed" : "Running"; } // PV/PVC status if (resource.status?.phase) { return resource.status.phase; } return "Active"; } // Helper function to determine if a resource is non-namespaced function isNonNamespacedResource(resourceType) { const nonNamespacedResources = [ "nodes", "node", "no", "namespaces", "namespace", "ns", "persistentvolumes", "pv", "storageclasses", "sc", "clusterroles", "clusterrolebindings", "customresourcedefinitions", "crd", "crds", ]; return nonNamespacedResources.includes(resourceType.toLowerCase()); } /** * Determine whether a (already lower-cased) resourceType string references * Kubernetes Secrets in any of the syntaxes kubectl accepts. This backs the * masking decision, so it must recognize every way a caller could name a * Secret without tripping the naive "=== 'secret'" check: * - plain: "secret", "secrets" * - resource/name: "secret/my-secret" * - group-qualified: "secrets.v1.", "secret.example.com/my-secret" * - comma-separated: "secret,configmap" * * @param {string} resourceType - The lower-cased resourceType argument. * @returns {boolean} True if any referenced resource is a Secret. */ export function resourceReferencesSecret(resourceType) { return resourceType .split(",") .map((part) => { // Drop the "/name" portion of a resource/name reference, then the // ".group"/".version" suffix of a group-qualified reference. const resource = part.trim().split("/")[0].split(".")[0]; return resource.toLowerCase(); }) .some((resource) => resource === "secret" || resource === "secrets"); } // Mask the leaf values of a single Secret object's `data` (and write-only // `stringData`) fields, leaving metadata and every other field intact. function maskSecretObject(secret) { const result = {}; for (const key in secret) { if ((key === "data" || key === "stringData") && typeof secret[key] === "object" && secret[key] !== null) { result[key] = maskAllLeafValues(secret[key]); } else { result[key] = maskDataValues(secret[key]); } } return result; } /** * Recursively traverses a parsed kubectl response and masks the `data` values * of Kubernetes Secrets only. Masking is scoped by object kind rather than by * the presence of a "data" key, so that Secret values are always masked (even * inside a mixed `List` returned by e.g. `kubectl get secret,configmap`) while * non-Secret resources such as ConfigMaps keep their data. * * @param {any} obj - The object to traverse. Can be an array, object, or primitive value. * @returns {any} A new object with masked values in Secret 'data' sections. */ function maskDataValues(obj) { if (obj == null) { return obj; } if (Array.isArray(obj)) { return obj.map((item) => maskDataValues(item)); } if (typeof obj === "object") { const kind = typeof obj.kind === "string" ? obj.kind.toLowerCase() : undefined; // A single Secret object. if (kind === "secret") { return maskSecretObject(obj); } // A SecretList: every item is a Secret, even when kubectl omits the // per-item `kind` field in list output, so mask them unconditionally. if (kind === "secretlist" && Array.isArray(obj.items)) { return { ...obj, items: obj.items.map((item) => maskSecretObject(item)), }; } // Any other object (including a heterogeneous "List"): recurse so nested // Secret objects are still masked without touching non-Secret data. const result = {}; for (const key in obj) { result[key] = maskDataValues(obj[key]); } return result; } return obj; } /** * 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) { const maskValue = "***"; if (obj == null) { return obj; } if (Array.isArray(obj)) { return obj.map((item) => maskAllLeafValues(item)); } if (typeof obj === "object") { const result = {}; for (const key in obj) { result[key] = maskAllLeafValues(obj[key]); } return result; } // This is a leaf value (string, number, boolean) - mask it return maskValue; } /** * 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. */ export function maskSecretsData(output, format) { 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; }