mcp-server-kubernetes 4.0.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  export declare const serverConfig: {
2
2
  readonly name: "kubernetes";
3
- readonly version: "4.0.0";
3
+ readonly version: "4.0.2";
4
4
  readonly capabilities: {
5
5
  readonly resources: {};
6
6
  readonly tools: {};
@@ -1,6 +1,6 @@
1
1
  export const serverConfig = {
2
2
  name: "kubernetes",
3
- version: "4.0.0",
3
+ version: "4.0.2",
4
4
  capabilities: {
5
5
  resources: {},
6
6
  tools: {},
package/dist/index.d.ts CHANGED
@@ -616,7 +616,7 @@ declare const allTools: ({
616
616
  };
617
617
  readonly filename: {
618
618
  readonly type: "string";
619
- readonly description: "Path to a YAML file to create resources from";
619
+ readonly description: "Path to a YAML file to create resources from. The path is read on the machine running the MCP server, so it is rejected when the server runs over a remote (SSE/Streamable HTTP) transport; use 'manifest' to pass the file's contents instead.";
620
620
  };
621
621
  readonly resourceType: {
622
622
  readonly type: "string";
@@ -643,7 +643,26 @@ declare const allTools: ({
643
643
  readonly items: {
644
644
  readonly type: "string";
645
645
  };
646
- readonly description: "Path to file for creating configmap (e.g. [\"key1=/path/to/file1\", \"key2=/path/to/file2\"])";
646
+ readonly description: "Path to file for creating configmap/secret (e.g. [\"key1=/path/to/file1\", \"key2=/path/to/file2\"]). The path is read on the machine running the MCP server, so it is rejected when the server runs over a remote (SSE/Streamable HTTP) transport; use \"fromFileContent\" to pass file contents directly instead.";
647
+ };
648
+ readonly fromFileContent: {
649
+ readonly type: "array";
650
+ readonly items: {
651
+ readonly type: "object";
652
+ readonly properties: {
653
+ readonly key: {
654
+ readonly type: "string";
655
+ readonly description: "Key to store the content under in the configmap/secret";
656
+ };
657
+ readonly content: {
658
+ readonly type: "string";
659
+ readonly description: "The file content to store";
660
+ };
661
+ };
662
+ readonly required: readonly ["key", "content"];
663
+ readonly additionalProperties: false;
664
+ };
665
+ readonly description: "Inline file contents for creating a configmap/secret, provided by the client instead of a server-side path (e.g. [{\"key\": \"app.conf\", \"content\": \"...\"}]). Safe on all transports; use this instead of \"fromFile\" on remote (SSE/Streamable HTTP) servers.";
647
666
  };
648
667
  readonly secretType: {
649
668
  readonly type: "string";
@@ -0,0 +1 @@
1
+ export declare function isRemoteTransport(): boolean;
@@ -0,0 +1,17 @@
1
+ // Transport-mode detection for security decisions.
2
+ //
3
+ // The server selects its transport at startup based on these env vars (see
4
+ // src/index.ts): if either is set the server listens over HTTP (SSE or
5
+ // Streamable HTTP) and serves remote clients; otherwise it uses stdio and is
6
+ // driven by a local process that already runs as the operator.
7
+ //
8
+ // Some tool inputs are only safe under the stdio trust model. A server-side
9
+ // filesystem path (e.g. `--from-file` / `-f`) is read on the machine running
10
+ // the server: over stdio that machine is the operator's own, but over HTTP it
11
+ // is the server host, so any client that can reach the endpoint could read
12
+ // arbitrary files (kubeconfig, service-account token, /proc/self/environ).
13
+ // Guards use this helper to reject path-based reads on remote transports.
14
+ export function isRemoteTransport() {
15
+ return Boolean(process.env.ENABLE_UNSAFE_SSE_TRANSPORT ||
16
+ process.env.ENABLE_UNSAFE_STREAMABLE_HTTP_TRANSPORT);
17
+ }
@@ -27,7 +27,7 @@ export declare const kubectlCreateSchema: {
27
27
  };
28
28
  readonly filename: {
29
29
  readonly type: "string";
30
- readonly description: "Path to a YAML file to create resources from";
30
+ readonly description: "Path to a YAML file to create resources from. The path is read on the machine running the MCP server, so it is rejected when the server runs over a remote (SSE/Streamable HTTP) transport; use 'manifest' to pass the file's contents instead.";
31
31
  };
32
32
  readonly resourceType: {
33
33
  readonly type: "string";
@@ -54,7 +54,26 @@ export declare const kubectlCreateSchema: {
54
54
  readonly items: {
55
55
  readonly type: "string";
56
56
  };
57
- readonly description: "Path to file for creating configmap (e.g. [\"key1=/path/to/file1\", \"key2=/path/to/file2\"])";
57
+ readonly description: "Path to file for creating configmap/secret (e.g. [\"key1=/path/to/file1\", \"key2=/path/to/file2\"]). The path is read on the machine running the MCP server, so it is rejected when the server runs over a remote (SSE/Streamable HTTP) transport; use \"fromFileContent\" to pass file contents directly instead.";
58
+ };
59
+ readonly fromFileContent: {
60
+ readonly type: "array";
61
+ readonly items: {
62
+ readonly type: "object";
63
+ readonly properties: {
64
+ readonly key: {
65
+ readonly type: "string";
66
+ readonly description: "Key to store the content under in the configmap/secret";
67
+ };
68
+ readonly content: {
69
+ readonly type: "string";
70
+ readonly description: "The file content to store";
71
+ };
72
+ };
73
+ readonly required: readonly ["key", "content"];
74
+ readonly additionalProperties: false;
75
+ };
76
+ readonly description: "Inline file contents for creating a configmap/secret, provided by the client instead of a server-side path (e.g. [{\"key\": \"app.conf\", \"content\": \"...\"}]). Safe on all transports; use this instead of \"fromFile\" on remote (SSE/Streamable HTTP) servers.";
58
77
  };
59
78
  readonly secretType: {
60
79
  readonly type: "string";
@@ -136,6 +155,10 @@ export declare function kubectlCreate(k8sManager: KubernetesManager, input: {
136
155
  namespace?: string;
137
156
  fromLiteral?: string[];
138
157
  fromFile?: string[];
158
+ fromFileContent?: {
159
+ key: string;
160
+ content: string;
161
+ }[];
139
162
  secretType?: "generic" | "docker-registry" | "tls";
140
163
  serviceType?: "clusterip" | "nodeport" | "loadbalancer" | "externalname";
141
164
  tcpPort?: string[];
@@ -5,6 +5,7 @@ import * as path from "path";
5
5
  import * as os from "os";
6
6
  import { getSpawnMaxBuffer } from "../config/max-buffer.js";
7
7
  import { contextParameter, namespaceParameter, dryRunParameter } from "../models/common-parameters.js";
8
+ import { isRemoteTransport } from "../security/transport.js";
8
9
  export const kubectlCreateSchema = {
9
10
  name: "kubectl_create",
10
11
  description: "Create Kubernetes resources using various methods (from file or using subcommands)",
@@ -42,7 +43,7 @@ export const kubectlCreateSchema = {
42
43
  },
43
44
  filename: {
44
45
  type: "string",
45
- description: "Path to a YAML file to create resources from",
46
+ description: "Path to a YAML file to create resources from. The path is read on the machine running the MCP server, so it is rejected when the server runs over a remote (SSE/Streamable HTTP) transport; use 'manifest' to pass the file's contents instead.",
46
47
  },
47
48
  // Resource type to create (determines which subcommand to use)
48
49
  resourceType: {
@@ -64,7 +65,26 @@ export const kubectlCreateSchema = {
64
65
  fromFile: {
65
66
  type: "array",
66
67
  items: { type: "string" },
67
- description: 'Path to file for creating configmap (e.g. ["key1=/path/to/file1", "key2=/path/to/file2"])',
68
+ description: 'Path to file for creating configmap/secret (e.g. ["key1=/path/to/file1", "key2=/path/to/file2"]). The path is read on the machine running the MCP server, so it is rejected when the server runs over a remote (SSE/Streamable HTTP) transport; use "fromFileContent" to pass file contents directly instead.',
69
+ },
70
+ fromFileContent: {
71
+ type: "array",
72
+ items: {
73
+ type: "object",
74
+ properties: {
75
+ key: {
76
+ type: "string",
77
+ description: "Key to store the content under in the configmap/secret",
78
+ },
79
+ content: {
80
+ type: "string",
81
+ description: "The file content to store",
82
+ },
83
+ },
84
+ required: ["key", "content"],
85
+ additionalProperties: false,
86
+ },
87
+ description: 'Inline file contents for creating a configmap/secret, provided by the client instead of a server-side path (e.g. [{"key": "app.conf", "content": "..."}]). Safe on all transports; use this instead of "fromFile" on remote (SSE/Streamable HTTP) servers.',
68
88
  },
69
89
  // Namespace specific parameters
70
90
  // No special parameters for namespace, just name is needed
@@ -143,6 +163,21 @@ export async function kubectlCreate(k8sManager, input) {
143
163
  input.resourceType !== "namespace") {
144
164
  throw new McpError(ErrorCode.InvalidRequest, `Name is required when creating a ${input.resourceType}`);
145
165
  }
166
+ // Reject server-side filesystem reads on remote transports. Over SSE /
167
+ // Streamable HTTP the path resolves on the MCP server host, not the
168
+ // client, so `filename` (-f) and `fromFile` (--from-file) would let any
169
+ // client that can reach the endpoint read arbitrary server files
170
+ // (kubeconfig, service-account token, /proc/self/environ, etc.). See
171
+ // GHSA-m67f-jxm9-cvx8. Clients on these transports must pass content
172
+ // inline via `manifest` or `fromFileContent` instead.
173
+ if (isRemoteTransport()) {
174
+ if (input.filename) {
175
+ throw new McpError(ErrorCode.InvalidRequest, "The 'filename' parameter reads a file from the MCP server's filesystem and is disabled on remote (SSE/Streamable HTTP) transports. Pass the file contents via 'manifest' instead.");
176
+ }
177
+ if (input.fromFile && input.fromFile.length > 0) {
178
+ throw new McpError(ErrorCode.InvalidRequest, "The 'fromFile' parameter reads files from the MCP server's filesystem and is disabled on remote (SSE/Streamable HTTP) transports. Pass the file contents via 'fromFileContent' instead.");
179
+ }
180
+ }
146
181
  // Set up common parameters
147
182
  const namespace = input.namespace || "default";
148
183
  const dryRun = input.dryRun || false;
@@ -151,15 +186,33 @@ export async function kubectlCreate(k8sManager, input) {
151
186
  const context = input.context || "";
152
187
  const command = "kubectl";
153
188
  const args = ["create"];
154
- let tempFile = null;
189
+ const tempFiles = [];
190
+ // Write client-provided content to a private temp file and return its
191
+ // path. The file is tracked for cleanup after kubectl runs.
192
+ const writeTempFile = (contents, label, ext = "") => {
193
+ const tmpDir = os.tmpdir();
194
+ const tempFile = path.join(tmpDir, `create-${label}-${Date.now()}-${tempFiles.length}${ext}`);
195
+ fs.writeFileSync(tempFile, contents, { mode: 0o600 });
196
+ tempFiles.push(tempFile);
197
+ return tempFile;
198
+ };
199
+ // Emit `--from-file=<key>=<tempfile>` args for inline content supplied by
200
+ // the client. Each entry's content is written to a server-side temp file
201
+ // (never a client-controlled path), so this is safe on all transports.
202
+ const pushFromFileContent = (entries) => {
203
+ entries.forEach((entry) => {
204
+ if (!entry.key) {
205
+ throw new McpError(ErrorCode.InvalidRequest, "Each fromFileContent entry requires a non-empty 'key'");
206
+ }
207
+ const tempFile = writeTempFile(entry.content ?? "", "fromfile");
208
+ args.push(`--from-file=${entry.key}=${tempFile}`);
209
+ });
210
+ };
155
211
  // Process manifest content if provided (file-based creation)
156
212
  if (input.manifest || input.filename) {
157
213
  if (input.manifest) {
158
214
  // Create temporary file for the manifest
159
- const tmpDir = os.tmpdir();
160
- tempFile = path.join(tmpDir, `create-manifest-${Date.now()}.yaml`);
161
- fs.writeFileSync(tempFile, input.manifest);
162
- args.push("-f", tempFile);
215
+ args.push("-f", writeTempFile(input.manifest, "manifest", ".yaml"));
163
216
  }
164
217
  else if (input.filename) {
165
218
  args.push("-f", input.filename);
@@ -179,12 +232,17 @@ export async function kubectlCreate(k8sManager, input) {
179
232
  args.push(`--from-literal=${literal}`);
180
233
  });
181
234
  }
182
- // Add --from-file arguments
235
+ // Add --from-file arguments (server-side paths; blocked on remote
236
+ // transports by the guard above)
183
237
  if (input.fromFile && input.fromFile.length > 0) {
184
238
  input.fromFile.forEach((file) => {
185
239
  args.push(`--from-file=${file}`);
186
240
  });
187
241
  }
242
+ // Add inline file contents (safe on all transports)
243
+ if (input.fromFileContent && input.fromFileContent.length > 0) {
244
+ pushFromFileContent(input.fromFileContent);
245
+ }
188
246
  break;
189
247
  case "secret":
190
248
  if (!input.secretType) {
@@ -197,12 +255,17 @@ export async function kubectlCreate(k8sManager, input) {
197
255
  args.push(`--from-literal=${literal}`);
198
256
  });
199
257
  }
200
- // Add --from-file arguments
258
+ // Add --from-file arguments (server-side paths; blocked on remote
259
+ // transports by the guard above)
201
260
  if (input.fromFile && input.fromFile.length > 0) {
202
261
  input.fromFile.forEach((file) => {
203
262
  args.push(`--from-file=${file}`);
204
263
  });
205
264
  }
265
+ // Add inline file contents (safe on all transports)
266
+ if (input.fromFileContent && input.fromFileContent.length > 0) {
267
+ pushFromFileContent(input.fromFileContent);
268
+ }
206
269
  break;
207
270
  case "service":
208
271
  if (!input.serviceType) {
@@ -292,6 +355,17 @@ export async function kubectlCreate(k8sManager, input) {
292
355
  if (context) {
293
356
  args.push("--context", context);
294
357
  }
358
+ // Remove any temp files created for inline content.
359
+ const cleanupTempFiles = () => {
360
+ tempFiles.forEach((tempFile) => {
361
+ try {
362
+ fs.unlinkSync(tempFile);
363
+ }
364
+ catch (err) {
365
+ console.warn(`Failed to delete temporary file ${tempFile}: ${err}`);
366
+ }
367
+ });
368
+ };
295
369
  // Execute the command
296
370
  try {
297
371
  const result = execFileSyncSafe(command, args, {
@@ -299,15 +373,8 @@ export async function kubectlCreate(k8sManager, input) {
299
373
  maxBuffer: getSpawnMaxBuffer(),
300
374
  env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG },
301
375
  });
302
- // Clean up temp file if created
303
- if (tempFile) {
304
- try {
305
- fs.unlinkSync(tempFile);
306
- }
307
- catch (err) {
308
- console.warn(`Failed to delete temporary file ${tempFile}: ${err}`);
309
- }
310
- }
376
+ // Clean up temp files if created
377
+ cleanupTempFiles();
311
378
  return {
312
379
  content: [
313
380
  {
@@ -318,15 +385,8 @@ export async function kubectlCreate(k8sManager, input) {
318
385
  };
319
386
  }
320
387
  catch (error) {
321
- // Clean up temp file if created, even if command failed
322
- if (tempFile) {
323
- try {
324
- fs.unlinkSync(tempFile);
325
- }
326
- catch (err) {
327
- console.warn(`Failed to delete temporary file ${tempFile}: ${err}`);
328
- }
329
- }
388
+ // Clean up temp files if created, even if command failed
389
+ cleanupTempFiles();
330
390
  throw new McpError(ErrorCode.InternalError, `Failed to create resource: ${error.message}`);
331
391
  }
332
392
  }
@@ -76,3 +76,26 @@ export declare function kubectlGet(k8sManager: KubernetesManager, input: {
76
76
  }[];
77
77
  isError: boolean;
78
78
  }>;
79
+ /**
80
+ * Determine whether a (already lower-cased) resourceType string references
81
+ * Kubernetes Secrets in any of the syntaxes kubectl accepts. This backs the
82
+ * masking decision, so it must recognize every way a caller could name a
83
+ * Secret without tripping the naive "=== 'secret'" check:
84
+ * - plain: "secret", "secrets"
85
+ * - resource/name: "secret/my-secret"
86
+ * - group-qualified: "secrets.v1.", "secret.example.com/my-secret"
87
+ * - comma-separated: "secret,configmap"
88
+ *
89
+ * @param {string} resourceType - The lower-cased resourceType argument.
90
+ * @returns {boolean} True if any referenced resource is a Secret.
91
+ */
92
+ export declare function resourceReferencesSecret(resourceType: string): boolean;
93
+ /**
94
+ * Masks sensitive data in Kubernetes secrets by parsing the raw output and replacing
95
+ * all leaf values in the "data" section with a placeholder value ("***").
96
+ *
97
+ * @param {string} output - The raw output from a `kubectl` command, containing secrets data.
98
+ * @param {string} format - The format of the output, either "json" or "yaml".
99
+ * @returns {string} - The masked output in the same format as the input.
100
+ */
101
+ export declare function maskSecretsData(output: string, format: string): string;
@@ -126,9 +126,13 @@ export async function kubectlGet(k8sManager, input) {
126
126
  maxBuffer: getSpawnMaxBuffer(),
127
127
  env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG },
128
128
  });
129
- // Apply secrets masking if enabled and dealing with secrets
129
+ // Apply secrets masking if enabled and dealing with secrets.
130
+ // resourceReferencesSecret normalizes combined forms like
131
+ // "secret/my-secret", group-qualified "secrets.v1./my-secret", and
132
+ // comma-separated lists ("secret,configmap") so the masking decision
133
+ // cannot be bypassed by addressing a Secret through an alternate syntax.
130
134
  const shouldMaskSecrets = process.env.MASK_SECRETS !== "false" &&
131
- (resourceType === "secrets" || resourceType === "secret");
135
+ resourceReferencesSecret(resourceType);
132
136
  let processedResult = result;
133
137
  if (shouldMaskSecrets) {
134
138
  processedResult = maskSecretsData(result, output);
@@ -356,10 +360,54 @@ function isNonNamespacedResource(resourceType) {
356
360
  return nonNamespacedResources.includes(resourceType.toLowerCase());
357
361
  }
358
362
  /**
359
- * Recursively traverses an object and masks values in 'data' sections of Kubernetes secrets.
363
+ * Determine whether a (already lower-cased) resourceType string references
364
+ * Kubernetes Secrets in any of the syntaxes kubectl accepts. This backs the
365
+ * masking decision, so it must recognize every way a caller could name a
366
+ * Secret without tripping the naive "=== 'secret'" check:
367
+ * - plain: "secret", "secrets"
368
+ * - resource/name: "secret/my-secret"
369
+ * - group-qualified: "secrets.v1.", "secret.example.com/my-secret"
370
+ * - comma-separated: "secret,configmap"
371
+ *
372
+ * @param {string} resourceType - The lower-cased resourceType argument.
373
+ * @returns {boolean} True if any referenced resource is a Secret.
374
+ */
375
+ export function resourceReferencesSecret(resourceType) {
376
+ return resourceType
377
+ .split(",")
378
+ .map((part) => {
379
+ // Drop the "/name" portion of a resource/name reference, then the
380
+ // ".group"/".version" suffix of a group-qualified reference.
381
+ const resource = part.trim().split("/")[0].split(".")[0];
382
+ return resource.toLowerCase();
383
+ })
384
+ .some((resource) => resource === "secret" || resource === "secrets");
385
+ }
386
+ // Mask the leaf values of a single Secret object's `data` (and write-only
387
+ // `stringData`) fields, leaving metadata and every other field intact.
388
+ function maskSecretObject(secret) {
389
+ const result = {};
390
+ for (const key in secret) {
391
+ if ((key === "data" || key === "stringData") &&
392
+ typeof secret[key] === "object" &&
393
+ secret[key] !== null) {
394
+ result[key] = maskAllLeafValues(secret[key]);
395
+ }
396
+ else {
397
+ result[key] = maskDataValues(secret[key]);
398
+ }
399
+ }
400
+ return result;
401
+ }
402
+ /**
403
+ * Recursively traverses a parsed kubectl response and masks the `data` values
404
+ * of Kubernetes Secrets only. Masking is scoped by object kind rather than by
405
+ * the presence of a "data" key, so that Secret values are always masked (even
406
+ * inside a mixed `List` returned by e.g. `kubectl get secret,configmap`) while
407
+ * non-Secret resources such as ConfigMaps keep their data.
360
408
  *
361
409
  * @param {any} obj - The object to traverse. Can be an array, object, or primitive value.
362
- * @returns {any} A new object with masked values in 'data' sections.
410
+ * @returns {any} A new object with masked values in Secret 'data' sections.
363
411
  */
364
412
  function maskDataValues(obj) {
365
413
  if (obj == null) {
@@ -369,15 +417,24 @@ function maskDataValues(obj) {
369
417
  return obj.map((item) => maskDataValues(item));
370
418
  }
371
419
  if (typeof obj === "object") {
420
+ const kind = typeof obj.kind === "string" ? obj.kind.toLowerCase() : undefined;
421
+ // A single Secret object.
422
+ if (kind === "secret") {
423
+ return maskSecretObject(obj);
424
+ }
425
+ // A SecretList: every item is a Secret, even when kubectl omits the
426
+ // per-item `kind` field in list output, so mask them unconditionally.
427
+ if (kind === "secretlist" && Array.isArray(obj.items)) {
428
+ return {
429
+ ...obj,
430
+ items: obj.items.map((item) => maskSecretObject(item)),
431
+ };
432
+ }
433
+ // Any other object (including a heterogeneous "List"): recurse so nested
434
+ // Secret objects are still masked without touching non-Secret data.
372
435
  const result = {};
373
436
  for (const key in obj) {
374
- if (key === "data" && typeof obj[key] === "object" && obj[key] !== null) {
375
- // This is a data section - mask all leaf values within it
376
- result[key] = maskAllLeafValues(obj[key]);
377
- }
378
- else {
379
- result[key] = maskDataValues(obj[key]);
380
- }
437
+ result[key] = maskDataValues(obj[key]);
381
438
  }
382
439
  return result;
383
440
  }
@@ -415,7 +472,7 @@ function maskAllLeafValues(obj) {
415
472
  * @param {string} format - The format of the output, either "json" or "yaml".
416
473
  * @returns {string} - The masked output in the same format as the input.
417
474
  */
418
- function maskSecretsData(output, format) {
475
+ export function maskSecretsData(output, format) {
419
476
  try {
420
477
  if (format === "json") {
421
478
  const parsed = JSON.parse(output);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-server-kubernetes",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
4
4
  "description": "MCP server for interacting with Kubernetes clusters via kubectl",
5
5
  "license": "MIT",
6
6
  "type": "module",