mcp-server-kubernetes
Version:
MCP server for interacting with Kubernetes clusters via kubectl
498 lines (497 loc) • 19.6 kB
JavaScript
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;
}