mcp-server-kubernetes 4.1.8 → 4.1.10
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.
- package/README.md +48 -8
- package/dist/config/server-config.d.ts +1 -1
- package/dist/config/server-config.js +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +1 -0
- package/dist/security/kubectl-flags.d.ts +21 -0
- package/dist/security/kubectl-flags.js +134 -7
- package/dist/tools/exec_in_pod.js +4 -1
- package/dist/tools/helm-operations.js +9 -13
- package/dist/tools/kubectl-apply.js +6 -22
- package/dist/tools/kubectl-context.d.ts +3 -1
- package/dist/tools/kubectl-context.js +13 -1
- package/dist/tools/kubectl-create.d.ts +2 -2
- package/dist/tools/kubectl-create.js +48 -22
- package/dist/tools/kubectl-delete.js +6 -22
- package/dist/tools/kubectl-generic.js +5 -1
- package/dist/tools/kubectl-logs.js +4 -1
- package/dist/tools/kubectl-operations.js +4 -1
- package/dist/tools/kubectl-patch.js +11 -23
- package/dist/tools/kubectl-rollout.js +6 -1
- package/dist/tools/node-management.js +4 -1
- package/dist/tools/port_forward.js +4 -1
- package/dist/utils/temp-file.d.ts +10 -0
- package/dist/utils/temp-file.js +46 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -77,18 +77,15 @@ To manually install, you can also get the .mcpb by going to the latest [Release]
|
|
|
77
77
|
|
|
78
78
|
[](vscode:mcp/install?%7B%22name%22%3A%20%22kubernetes%22%2C%20%22type%22%3A%20%22stdio%22%2C%20%22command%22%3A%20%22npx%22%2C%20%22args%22%3A%20%5B%22mcp-server-kubernetes%22%5D%7D)
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
1. Install a compatible MCP extension (such as Claude Dev or similar MCP clients)
|
|
83
|
-
2. Configure the extension to use this server:
|
|
80
|
+
VS Code has built-in MCP support. Use the one-click install button above, or add the server to `.vscode/mcp.json` in your workspace (or to your user `mcp.json` via **MCP: Open User Configuration**):
|
|
84
81
|
|
|
85
82
|
```json
|
|
86
83
|
{
|
|
87
|
-
"
|
|
84
|
+
"servers": {
|
|
88
85
|
"kubernetes": {
|
|
86
|
+
"type": "stdio",
|
|
89
87
|
"command": "npx",
|
|
90
|
-
"args": ["mcp-server-kubernetes"]
|
|
91
|
-
"description": "Kubernetes cluster management and operations"
|
|
88
|
+
"args": ["mcp-server-kubernetes"]
|
|
92
89
|
}
|
|
93
90
|
}
|
|
94
91
|
}
|
|
@@ -96,7 +93,7 @@ For VS Code integration, you can use the MCP server with extensions that support
|
|
|
96
93
|
|
|
97
94
|
### Cursor
|
|
98
95
|
|
|
99
|
-
Cursor supports MCP servers through its AI integration. Add the server to
|
|
96
|
+
Cursor supports MCP servers through its AI integration. Add the server to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
|
|
100
97
|
|
|
101
98
|
```json
|
|
102
99
|
{
|
|
@@ -111,6 +108,48 @@ Cursor supports MCP servers through its AI integration. Add the server to your C
|
|
|
111
108
|
|
|
112
109
|
The server will automatically connect to your current kubectl context. You can verify the connection by asking the AI assistant to list your pods or create a test deployment.
|
|
113
110
|
|
|
111
|
+
### Muse Code
|
|
112
|
+
|
|
113
|
+
[Muse Code](https://dev.meta.ai/docs/muse-code/extending#mcp) reads MCP servers from the `mcp_servers` block of `~/.config/muse/settings.json`. The file must include `"schema_version": 1`:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"schema_version": 1,
|
|
118
|
+
"mcp_servers": {
|
|
119
|
+
"kubernetes": {
|
|
120
|
+
"transport": "stdio",
|
|
121
|
+
"command": "npx",
|
|
122
|
+
"args": ["mcp-server-kubernetes"]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
To connect over Streamable HTTP instead, first start the server in HTTP mode (it serves `/mcp` on port `3000` by default; see [Streamable HTTP Transport](ADVANCED_README.md#streamable-http-transport)):
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
ENABLE_UNSAFE_STREAMABLE_HTTP_TRANSPORT=1 npx mcp-server-kubernetes
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Then point Muse Code at that endpoint with the `streamable_http` transport (`headers` is optional):
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"schema_version": 1,
|
|
139
|
+
"mcp_servers": {
|
|
140
|
+
"kubernetes": {
|
|
141
|
+
"transport": "streamable_http",
|
|
142
|
+
"url": "http://localhost:3000/mcp",
|
|
143
|
+
"mode": "optional"
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`"mode": "optional"` lets Muse Code start with a warning if the HTTP server isn't running, instead of aborting.
|
|
150
|
+
|
|
151
|
+
Start a new Muse Code session after editing the file, then run `/mcp` to confirm the `kubernetes` server and its tools are connected.
|
|
152
|
+
|
|
114
153
|
## Usage with mcp-chat
|
|
115
154
|
|
|
116
155
|
[mcp-chat](https://github.com/Flux159/mcp-chat) is a CLI chat client for MCP servers. You can use it to interact with the Kubernetes server.
|
|
@@ -351,6 +390,7 @@ The following destructive operations are disabled:
|
|
|
351
390
|
- `cleanup_pods`: Cleaning up problematic pods
|
|
352
391
|
- `node_management`: Node management operations (can drain nodes)
|
|
353
392
|
- `kubectl_generic`: General kubectl command access (may include destructive operations)
|
|
393
|
+
- `exec_in_pod`: Arbitrary command execution inside a pod (can read service-account tokens and change state)
|
|
354
394
|
|
|
355
395
|
For additional advanced features, see the [ADVANCED_README.md](ADVANCED_README.md) and also the [docs](https://github.com/Flux159/mcp-server-kubernetes/tree/main/docs) folder for specific information on `helm_install`, `helm_template_apply`, node management & pod cleanup.
|
|
356
396
|
|
package/dist/index.d.ts
CHANGED
|
@@ -359,7 +359,9 @@ declare const allTools: ({
|
|
|
359
359
|
readonly name: "kubectl_context";
|
|
360
360
|
readonly description: "Manage Kubernetes contexts - list, get, or set the current context";
|
|
361
361
|
readonly annotations: {
|
|
362
|
-
readonly readOnlyHint:
|
|
362
|
+
readonly readOnlyHint: false;
|
|
363
|
+
readonly destructiveHint: false;
|
|
364
|
+
readonly idempotentHint: true;
|
|
363
365
|
};
|
|
364
366
|
readonly inputSchema: {
|
|
365
367
|
readonly type: "object";
|
|
@@ -601,8 +603,8 @@ declare const allTools: ({
|
|
|
601
603
|
};
|
|
602
604
|
readonly output: {
|
|
603
605
|
readonly type: "string";
|
|
604
|
-
readonly enum: readonly ["json", "yaml", "name", "go-template", "
|
|
605
|
-
readonly description: "Output format. One of: json|yaml|name|go-template|
|
|
606
|
+
readonly enum: readonly ["json", "yaml", "name", "go-template", "template", "jsonpath", "jsonpath-as-json"];
|
|
607
|
+
readonly description: "Output format. One of: json|yaml|name|go-template|template|jsonpath|jsonpath-as-json. The template formats take their template inline, e.g. 'jsonpath={.metadata.name}'.";
|
|
606
608
|
readonly default: "yaml";
|
|
607
609
|
};
|
|
608
610
|
readonly validate: {
|
package/dist/index.js
CHANGED
|
@@ -69,6 +69,7 @@ const destructiveTools = [
|
|
|
69
69
|
cleanupSchema, // Cleanup is also destructive as it deletes resources
|
|
70
70
|
kubectlGenericSchema, // Generic kubectl command can perform destructive operations
|
|
71
71
|
nodeManagementSchema, // Node management can drain nodes (destructive)
|
|
72
|
+
execInPodSchema, // Arbitrary command execution inside a pod; can read SA tokens and mutate state
|
|
72
73
|
];
|
|
73
74
|
// Get all available tools
|
|
74
75
|
const allTools = [
|
|
@@ -61,6 +61,27 @@ export declare function assertNoRemoteFileReads(args: readonly string[]): void;
|
|
|
61
61
|
* Honours the same ALLOW_KUBECTL_UNSAFE_FLAGS escape hatch as the flag guard.
|
|
62
62
|
*/
|
|
63
63
|
export declare function assertNotFlagLike(value: string | undefined, field: string): void;
|
|
64
|
+
/**
|
|
65
|
+
* Apply kubectl_generic's verb policy to a fully-constructed argv, whose first
|
|
66
|
+
* token is the verb.
|
|
67
|
+
*
|
|
68
|
+
* The flag guards above can only see dash-prefixed tokens, but some kubectl
|
|
69
|
+
* verbs reach the same assets through plain operands: "config view --raw"
|
|
70
|
+
* prints the operator's credentials, "config set clusters.x.server <url>"
|
|
71
|
+
* persistently redirects the kubeconfig, "cp" reads and writes local paths
|
|
72
|
+
* given as positionals. Those are matched on the verb instead.
|
|
73
|
+
*
|
|
74
|
+
* kubectl cp resolves any operand without a ":" on the local machine. Under
|
|
75
|
+
* stdio a local *source* is the operator reading their own file, the same as
|
|
76
|
+
* "-f", and stays allowed; a local *destination* is a write to a path chosen
|
|
77
|
+
* by whoever steers the tool arguments, like --log-file, and is refused. Over
|
|
78
|
+
* a remote transport both sides would be the server host, so cp is refused
|
|
79
|
+
* outright.
|
|
80
|
+
*
|
|
81
|
+
* The remote-only checks have no escape hatch, matching
|
|
82
|
+
* assertNoRemoteFileReads; the rest honour ALLOW_KUBECTL_UNSAFE_FLAGS.
|
|
83
|
+
*/
|
|
84
|
+
export declare function assertAllowedGenericCommand(args: readonly string[]): void;
|
|
64
85
|
/**
|
|
65
86
|
* Drop-in replacement for child_process.execFileSync that scans the argv for
|
|
66
87
|
* credential/target-redirecting flags before executing. Tool files import this
|
|
@@ -132,11 +132,24 @@ const FILE_READ_SHORT_FLAGS = new Set([
|
|
|
132
132
|
// assertSafeArgv refuses them in any argv, on any transport, rather than only
|
|
133
133
|
// where a server-side path would be attacker-chosen. Every tool has positional
|
|
134
134
|
// slots, read-only ones included, so a name-shaped denial is not enough.
|
|
135
|
+
//
|
|
136
|
+
// kubectl also accepts "templatefile" as an alias of "go-template-file", so
|
|
137
|
+
// the set is a list of known spellings rather than the whole rule: every
|
|
138
|
+
// file-reading format kubectl has ever shipped is named "...file", and
|
|
139
|
+
// isFileReadOutputFormat refuses that suffix too, so an alias added upstream
|
|
140
|
+
// is refused before anyone has to notice it here.
|
|
135
141
|
const FILE_READ_OUTPUT_FORMATS = new Set([
|
|
136
142
|
"go-template-file",
|
|
143
|
+
"templatefile",
|
|
137
144
|
"jsonpath-file",
|
|
138
145
|
"custom-columns-file",
|
|
139
146
|
]);
|
|
147
|
+
// `format` is the full -o value, e.g. "templatefile=/etc/passwd" or just
|
|
148
|
+
// "go-template-file" when the path arrives through --template instead.
|
|
149
|
+
function isFileReadOutputFormat(format) {
|
|
150
|
+
const name = format.split("=")[0].toLowerCase();
|
|
151
|
+
return FILE_READ_OUTPUT_FORMATS.has(name) || name.endsWith("file");
|
|
152
|
+
}
|
|
140
153
|
function isUnsafeFlagsAllowed() {
|
|
141
154
|
return process.env.ALLOW_KUBECTL_UNSAFE_FLAGS === "true";
|
|
142
155
|
}
|
|
@@ -205,6 +218,19 @@ function isDangerousFlagName(rawName, fromArgs) {
|
|
|
205
218
|
}
|
|
206
219
|
return false;
|
|
207
220
|
}
|
|
221
|
+
// Tool inputs are typed string[] at compile time only: nothing validates the
|
|
222
|
+
// JSON a client sends against the tool's inputSchema, so an `args` entry can be
|
|
223
|
+
// an array, number or object. child_process stringifies every argv element, so
|
|
224
|
+
// ["--server=https://attacker"] reaches kubectl as "--server=https://attacker"
|
|
225
|
+
// — a token the scans below would never have looked at had they skipped it.
|
|
226
|
+
// Refuse anything that is not a string rather than guess how it will print.
|
|
227
|
+
function assertStringToken(tok) {
|
|
228
|
+
if (typeof tok === "string")
|
|
229
|
+
return;
|
|
230
|
+
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl/helm with a non-string argument ` +
|
|
231
|
+
`(${JSON.stringify(tok) ?? typeof tok}): every command-line argument ` +
|
|
232
|
+
`must be a string.`);
|
|
233
|
+
}
|
|
208
234
|
function reject(flag) {
|
|
209
235
|
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl with flag "${flag}": this flag can redirect ` +
|
|
210
236
|
`kubectl/helm to a different API server, substitute credentials, or ` +
|
|
@@ -232,8 +258,7 @@ export function assertNoDangerousFlags(flags, args) {
|
|
|
232
258
|
}
|
|
233
259
|
if (args) {
|
|
234
260
|
for (const tok of args) {
|
|
235
|
-
|
|
236
|
-
continue;
|
|
261
|
+
assertStringToken(tok);
|
|
237
262
|
if (!tok.startsWith("-"))
|
|
238
263
|
continue;
|
|
239
264
|
if (isDangerousFlagName(tok, true))
|
|
@@ -253,12 +278,11 @@ export function assertNoDangerousFlags(flags, args) {
|
|
|
253
278
|
* flag unless ALLOW_KUBECTL_UNSAFE_FLAGS=true.
|
|
254
279
|
*/
|
|
255
280
|
export function assertSafeArgv(args) {
|
|
281
|
+
args.forEach(assertStringToken);
|
|
256
282
|
if (isUnsafeFlagsAllowed())
|
|
257
283
|
return;
|
|
258
284
|
for (let i = 0; i < args.length; i++) {
|
|
259
285
|
const tok = args[i];
|
|
260
|
-
if (typeof tok !== "string")
|
|
261
|
-
continue;
|
|
262
286
|
if (!tok.startsWith("-"))
|
|
263
287
|
continue;
|
|
264
288
|
const name = normalizeFlagName(tok);
|
|
@@ -273,8 +297,7 @@ export function assertSafeArgv(args) {
|
|
|
273
297
|
// ("-o go-template-file=<path>"), so it has to be matched on the value
|
|
274
298
|
// rather than on the flag name.
|
|
275
299
|
const format = outputFormatValue(tok, args[i + 1]);
|
|
276
|
-
if (format !== undefined &&
|
|
277
|
-
FILE_READ_OUTPUT_FORMATS.has(format.split("=")[0])) {
|
|
300
|
+
if (format !== undefined && isFileReadOutputFormat(format)) {
|
|
278
301
|
rejectOutputFileRead(tok, format);
|
|
279
302
|
}
|
|
280
303
|
}
|
|
@@ -348,7 +371,8 @@ export function assertNoRemoteFileReads(args) {
|
|
|
348
371
|
return;
|
|
349
372
|
for (let i = 0; i < args.length; i++) {
|
|
350
373
|
const tok = args[i];
|
|
351
|
-
|
|
374
|
+
assertStringToken(tok);
|
|
375
|
+
if (!tok.startsWith("-"))
|
|
352
376
|
continue;
|
|
353
377
|
if (FILE_READ_FLAGS.has(normalizeFlagName(tok)))
|
|
354
378
|
rejectFileRead(tok);
|
|
@@ -387,6 +411,109 @@ export function assertNotFlagLike(value, field) {
|
|
|
387
411
|
`the ${field}, which would let the caller inject arbitrary flags. ` +
|
|
388
412
|
`Provide a ${field} that does not begin with "-".`);
|
|
389
413
|
}
|
|
414
|
+
// kubectl verbs kubectl_generic refuses on every transport. Each one either
|
|
415
|
+
// hands back or rewrites the operator's own credentials, or never returns:
|
|
416
|
+
// execFileSync blocks the event loop, so a call that does not exit stalls the
|
|
417
|
+
// server for every client until the child is killed. The structured tools
|
|
418
|
+
// cover the legitimate uses (kubectl_context, port_forward).
|
|
419
|
+
const GENERIC_DENIED_VERBS = new Map([
|
|
420
|
+
[
|
|
421
|
+
"config",
|
|
422
|
+
"it prints and rewrites the operator's kubeconfig (\"config view --raw\" " +
|
|
423
|
+
"returns the credentials, \"config set\" repoints a cluster at another " +
|
|
424
|
+
"server). Use the kubectl_context tool for context operations",
|
|
425
|
+
],
|
|
426
|
+
[
|
|
427
|
+
"proxy",
|
|
428
|
+
"it serves the Kubernetes API with the operator's credentials on a " +
|
|
429
|
+
"local port and never exits",
|
|
430
|
+
],
|
|
431
|
+
[
|
|
432
|
+
"port-forward",
|
|
433
|
+
"it never exits. Use the port_forward tool, which runs it in the " +
|
|
434
|
+
"background",
|
|
435
|
+
],
|
|
436
|
+
["attach", "it is interactive and never exits"],
|
|
437
|
+
["edit", "it opens an interactive editor on the MCP server host"],
|
|
438
|
+
]);
|
|
439
|
+
// kubectl verbs whose positional operands are paths on the machine kubectl
|
|
440
|
+
// runs on. Like FILE_READ_FLAGS they are only a problem on a remote transport,
|
|
441
|
+
// where that machine is the server host rather than the caller's — but the
|
|
442
|
+
// path is a positional operand, which assertNoRemoteFileReads cannot see.
|
|
443
|
+
const GENERIC_REMOTE_DENIED_VERBS = new Set(["cp", "kustomize"]);
|
|
444
|
+
// kubectl subcommand names, including plugin names ("kubectl foo" runs
|
|
445
|
+
// kubectl-foo). Anything else is not a verb: kubectl skips an empty first
|
|
446
|
+
// argument and takes the next token as the verb instead, so an empty
|
|
447
|
+
// `command` would let the real verb ride in through a later slot unchecked.
|
|
448
|
+
const VERB_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
449
|
+
function rejectVerb(verb, reason) {
|
|
450
|
+
throw new McpError(ErrorCode.InvalidParams, `Refusing to run "kubectl ${verb}" through kubectl_generic: ${reason}.`);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Apply kubectl_generic's verb policy to a fully-constructed argv, whose first
|
|
454
|
+
* token is the verb.
|
|
455
|
+
*
|
|
456
|
+
* The flag guards above can only see dash-prefixed tokens, but some kubectl
|
|
457
|
+
* verbs reach the same assets through plain operands: "config view --raw"
|
|
458
|
+
* prints the operator's credentials, "config set clusters.x.server <url>"
|
|
459
|
+
* persistently redirects the kubeconfig, "cp" reads and writes local paths
|
|
460
|
+
* given as positionals. Those are matched on the verb instead.
|
|
461
|
+
*
|
|
462
|
+
* kubectl cp resolves any operand without a ":" on the local machine. Under
|
|
463
|
+
* stdio a local *source* is the operator reading their own file, the same as
|
|
464
|
+
* "-f", and stays allowed; a local *destination* is a write to a path chosen
|
|
465
|
+
* by whoever steers the tool arguments, like --log-file, and is refused. Over
|
|
466
|
+
* a remote transport both sides would be the server host, so cp is refused
|
|
467
|
+
* outright.
|
|
468
|
+
*
|
|
469
|
+
* The remote-only checks have no escape hatch, matching
|
|
470
|
+
* assertNoRemoteFileReads; the rest honour ALLOW_KUBECTL_UNSAFE_FLAGS.
|
|
471
|
+
*/
|
|
472
|
+
export function assertAllowedGenericCommand(args) {
|
|
473
|
+
const verb = args[0];
|
|
474
|
+
if (typeof verb !== "string" || !VERB_PATTERN.test(verb)) {
|
|
475
|
+
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl_generic with command="${verb}": the command ` +
|
|
476
|
+
`must be a kubectl subcommand name such as "get" or "rollout".`);
|
|
477
|
+
}
|
|
478
|
+
if (isRemoteTransport() && GENERIC_REMOTE_DENIED_VERBS.has(verb)) {
|
|
479
|
+
rejectVerb(verb, "its operands are paths on the MCP server's own filesystem, which is " +
|
|
480
|
+
"disabled on remote (SSE/Streamable HTTP) transports because the " +
|
|
481
|
+
"path is chosen by the client but resolved on the server host");
|
|
482
|
+
}
|
|
483
|
+
if (isUnsafeFlagsAllowed())
|
|
484
|
+
return;
|
|
485
|
+
const reason = GENERIC_DENIED_VERBS.get(verb);
|
|
486
|
+
if (reason !== undefined)
|
|
487
|
+
rejectVerb(verb, reason);
|
|
488
|
+
if (verb === "cp") {
|
|
489
|
+
const destinations = cpOperands(args).slice(1);
|
|
490
|
+
const local = destinations.find((operand) => !operand.includes(":"));
|
|
491
|
+
if (local !== undefined) {
|
|
492
|
+
rejectVerb(verb, `"${local}" would be written to the MCP server's own filesystem. ` +
|
|
493
|
+
`kubectl_generic only copies into a pod ("<pod>:<path>"); ` +
|
|
494
|
+
`copying out of one would let the caller overwrite any file the ` +
|
|
495
|
+
`server user can write`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// The operands of a "kubectl cp" argv, in order. Every token that does not
|
|
500
|
+
// start with "-" counts, including the values of split flags ("-c main"):
|
|
501
|
+
// telling those apart would need kubectl's flag table, and treating a value as
|
|
502
|
+
// an operand can only refuse a call, never let one through. After "--" every
|
|
503
|
+
// token is an operand.
|
|
504
|
+
function cpOperands(args) {
|
|
505
|
+
const operands = [];
|
|
506
|
+
let afterSeparator = false;
|
|
507
|
+
for (const tok of args.slice(1)) {
|
|
508
|
+
if (!afterSeparator && tok === "--") {
|
|
509
|
+
afterSeparator = true;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (afterSeparator || !tok.startsWith("-"))
|
|
513
|
+
operands.push(tok);
|
|
514
|
+
}
|
|
515
|
+
return operands;
|
|
516
|
+
}
|
|
390
517
|
/**
|
|
391
518
|
* Drop-in replacement for child_process.execFileSync that scans the argv for
|
|
392
519
|
* credential/target-redirecting flags before executing. Tool files import this
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* interpretation. Shell operators (pipes, redirects, etc.) are intentionally
|
|
9
9
|
* not supported.
|
|
10
10
|
*/
|
|
11
|
-
import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
11
|
+
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
12
12
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
13
13
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
14
14
|
import { contextParameter, namespaceParameter } from "../models/common-parameters.js";
|
|
@@ -74,6 +74,9 @@ export async function execInPod(k8sManager, input) {
|
|
|
74
74
|
throw new McpError(ErrorCode.InvalidParams, `Command array element at index ${i} must be a string`);
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
+
// The pod name is a positional operand; a leading "-" would make kubectl
|
|
78
|
+
// parse it as a flag (e.g. --filename=<server path>).
|
|
79
|
+
assertNotFlagLike(input.name, "name");
|
|
77
80
|
try {
|
|
78
81
|
const args = ["exec", input.name, "-n", namespace];
|
|
79
82
|
if (input.container) {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
8
8
|
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
9
|
-
import {
|
|
9
|
+
import { removeTempFile, writeTempFile } from "../utils/temp-file.js";
|
|
10
10
|
import { dump } from "js-yaml";
|
|
11
11
|
import { isRemoteTransport } from "../security/transport.js";
|
|
12
12
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
@@ -226,8 +226,7 @@ async function installHelmChartTemplate(params) {
|
|
|
226
226
|
}
|
|
227
227
|
else if (params.values) {
|
|
228
228
|
steps.push("Using provided values object");
|
|
229
|
-
tempValuesFile =
|
|
230
|
-
writeFileSync(tempValuesFile, dump(params.values));
|
|
229
|
+
tempValuesFile = writeTempFile("values.yaml", dump(params.values));
|
|
231
230
|
templateArgs.push("-f", tempValuesFile);
|
|
232
231
|
}
|
|
233
232
|
let yamlOutput;
|
|
@@ -237,20 +236,19 @@ async function installHelmChartTemplate(params) {
|
|
|
237
236
|
finally {
|
|
238
237
|
// Clean up temp file
|
|
239
238
|
if (tempValuesFile) {
|
|
240
|
-
|
|
239
|
+
removeTempFile(tempValuesFile);
|
|
241
240
|
}
|
|
242
241
|
}
|
|
243
242
|
// Step 4: Apply YAML using kubectl
|
|
244
243
|
steps.push("Applying YAML using kubectl");
|
|
245
|
-
const tempYamlFile =
|
|
246
|
-
writeFileSync(tempYamlFile, yamlOutput);
|
|
244
|
+
const tempYamlFile = writeTempFile("template.yaml", yamlOutput);
|
|
247
245
|
try {
|
|
248
246
|
executeCommand("kubectl", ["apply", "-f", tempYamlFile]);
|
|
249
247
|
steps.push("Helm chart installed successfully using template mode");
|
|
250
248
|
}
|
|
251
249
|
finally {
|
|
252
250
|
// Clean up temp file
|
|
253
|
-
|
|
251
|
+
removeTempFile(tempYamlFile);
|
|
254
252
|
}
|
|
255
253
|
return {
|
|
256
254
|
content: [
|
|
@@ -317,14 +315,13 @@ export async function installHelmChart(params) {
|
|
|
317
315
|
// Add values object if provided
|
|
318
316
|
if (params.values) {
|
|
319
317
|
const valuesContent = dump(params.values);
|
|
320
|
-
const tempFile =
|
|
321
|
-
writeFileSync(tempFile, valuesContent);
|
|
318
|
+
const tempFile = writeTempFile("values.yaml", valuesContent);
|
|
322
319
|
try {
|
|
323
320
|
args.push("-f", tempFile);
|
|
324
321
|
executeCommand("helm", args);
|
|
325
322
|
}
|
|
326
323
|
finally {
|
|
327
|
-
|
|
324
|
+
removeTempFile(tempFile);
|
|
328
325
|
}
|
|
329
326
|
}
|
|
330
327
|
else {
|
|
@@ -385,14 +382,13 @@ export async function upgradeHelmChart(params) {
|
|
|
385
382
|
// Add values object if provided
|
|
386
383
|
if (params.values) {
|
|
387
384
|
const valuesContent = dump(params.values);
|
|
388
|
-
const tempFile =
|
|
389
|
-
writeFileSync(tempFile, valuesContent);
|
|
385
|
+
const tempFile = writeTempFile("values.yaml", valuesContent);
|
|
390
386
|
try {
|
|
391
387
|
args.push("-f", tempFile);
|
|
392
388
|
executeCommand("helm", args);
|
|
393
389
|
}
|
|
394
390
|
finally {
|
|
395
|
-
|
|
391
|
+
removeTempFile(tempFile);
|
|
396
392
|
}
|
|
397
393
|
}
|
|
398
394
|
else {
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
2
2
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
-
import * as fs from "fs";
|
|
4
|
-
import * as path from "path";
|
|
5
|
-
import * as os from "os";
|
|
6
3
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
4
|
+
import { removeTempFile, writeTempFile } from "../utils/temp-file.js";
|
|
7
5
|
import { contextParameter, namespaceParameter, dryRunParameter } from "../models/common-parameters.js";
|
|
8
6
|
import { isRemoteTransport } from "../security/transport.js";
|
|
9
7
|
export const kubectlApplySchema = {
|
|
@@ -59,9 +57,7 @@ export async function kubectlApply(k8sManager, input) {
|
|
|
59
57
|
// Process manifest content if provided
|
|
60
58
|
if (input.manifest) {
|
|
61
59
|
// Create temporary file for the manifest
|
|
62
|
-
|
|
63
|
-
tempFile = path.join(tmpDir, `manifest-${Date.now()}.yaml`);
|
|
64
|
-
fs.writeFileSync(tempFile, input.manifest);
|
|
60
|
+
tempFile = writeTempFile("manifest.yaml", input.manifest);
|
|
65
61
|
args.push("-f", tempFile);
|
|
66
62
|
}
|
|
67
63
|
else if (input.filename) {
|
|
@@ -89,14 +85,8 @@ export async function kubectlApply(k8sManager, input) {
|
|
|
89
85
|
env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG },
|
|
90
86
|
});
|
|
91
87
|
// Clean up temp file if created
|
|
92
|
-
if (tempFile)
|
|
93
|
-
|
|
94
|
-
fs.unlinkSync(tempFile);
|
|
95
|
-
}
|
|
96
|
-
catch (err) {
|
|
97
|
-
console.warn(`Failed to delete temporary file ${tempFile}: ${err}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
88
|
+
if (tempFile)
|
|
89
|
+
removeTempFile(tempFile);
|
|
100
90
|
return {
|
|
101
91
|
content: [
|
|
102
92
|
{
|
|
@@ -108,14 +98,8 @@ export async function kubectlApply(k8sManager, input) {
|
|
|
108
98
|
}
|
|
109
99
|
catch (error) {
|
|
110
100
|
// Clean up temp file if created, even if command failed
|
|
111
|
-
if (tempFile)
|
|
112
|
-
|
|
113
|
-
fs.unlinkSync(tempFile);
|
|
114
|
-
}
|
|
115
|
-
catch (err) {
|
|
116
|
-
console.warn(`Failed to delete temporary file ${tempFile}: ${err}`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
101
|
+
if (tempFile)
|
|
102
|
+
removeTempFile(tempFile);
|
|
119
103
|
throw new McpError(ErrorCode.InternalError, `Failed to apply manifest: ${error.message}`);
|
|
120
104
|
}
|
|
121
105
|
}
|
|
@@ -3,7 +3,9 @@ export declare const kubectlContextSchema: {
|
|
|
3
3
|
readonly name: "kubectl_context";
|
|
4
4
|
readonly description: "Manage Kubernetes contexts - list, get, or set the current context";
|
|
5
5
|
readonly annotations: {
|
|
6
|
-
readonly readOnlyHint:
|
|
6
|
+
readonly readOnlyHint: false;
|
|
7
|
+
readonly destructiveHint: false;
|
|
8
|
+
readonly idempotentHint: true;
|
|
7
9
|
};
|
|
8
10
|
readonly inputSchema: {
|
|
9
11
|
readonly type: "object";
|
|
@@ -4,8 +4,14 @@ import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
|
4
4
|
export const kubectlContextSchema = {
|
|
5
5
|
name: "kubectl_context",
|
|
6
6
|
description: "Manage Kubernetes contexts - list, get, or set the current context",
|
|
7
|
+
// Not read-only: "set" runs "kubectl config use-context", which rewrites
|
|
8
|
+
// the current-context in the kubeconfig and so retargets every later
|
|
9
|
+
// command. Under ALLOW_ONLY_READONLY_TOOLS the tool stays available for
|
|
10
|
+
// list/get, and "set" is refused at call time.
|
|
7
11
|
annotations: {
|
|
8
|
-
readOnlyHint:
|
|
12
|
+
readOnlyHint: false,
|
|
13
|
+
destructiveHint: false,
|
|
14
|
+
idempotentHint: true,
|
|
9
15
|
},
|
|
10
16
|
inputSchema: {
|
|
11
17
|
type: "object",
|
|
@@ -205,6 +211,12 @@ export async function kubectlContext(k8sManager, input) {
|
|
|
205
211
|
throw error;
|
|
206
212
|
}
|
|
207
213
|
case "set":
|
|
214
|
+
if (process.env.ALLOW_ONLY_READONLY_TOOLS === "true") {
|
|
215
|
+
throw new McpError(ErrorCode.InvalidRequest, "Switching the current context is disabled in read-only mode " +
|
|
216
|
+
"(ALLOW_ONLY_READONLY_TOOLS=true): it rewrites the kubeconfig " +
|
|
217
|
+
"and retargets every later command. Pass a 'context' to " +
|
|
218
|
+
"individual tools instead.");
|
|
219
|
+
}
|
|
208
220
|
// Validate input
|
|
209
221
|
if (!name) {
|
|
210
222
|
throw new McpError(ErrorCode.InvalidParams, "Name parameter is required for set operation");
|
|
@@ -12,8 +12,8 @@ export declare const kubectlCreateSchema: {
|
|
|
12
12
|
};
|
|
13
13
|
readonly output: {
|
|
14
14
|
readonly type: "string";
|
|
15
|
-
readonly enum: readonly ["json", "yaml", "name", "go-template", "
|
|
16
|
-
readonly description: "Output format. One of: json|yaml|name|go-template|
|
|
15
|
+
readonly enum: readonly ["json", "yaml", "name", "go-template", "template", "jsonpath", "jsonpath-as-json"];
|
|
16
|
+
readonly description: "Output format. One of: json|yaml|name|go-template|template|jsonpath|jsonpath-as-json. The template formats take their template inline, e.g. 'jsonpath={.metadata.name}'.";
|
|
17
17
|
readonly default: "yaml";
|
|
18
18
|
};
|
|
19
19
|
readonly validate: {
|
|
@@ -1,11 +1,26 @@
|
|
|
1
|
-
import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
1
|
+
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
2
2
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
-
import * as fs from "fs";
|
|
4
|
-
import * as path from "path";
|
|
5
|
-
import * as os from "os";
|
|
6
3
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
4
|
+
import { removeTempFile, writeTempFile } from "../utils/temp-file.js";
|
|
7
5
|
import { contextParameter, namespaceParameter, dryRunParameter } from "../models/common-parameters.js";
|
|
8
6
|
import { isRemoteTransport } from "../security/transport.js";
|
|
7
|
+
// Valid ConfigMap / Secret data keys, as Kubernetes validates them.
|
|
8
|
+
const DATA_KEY_PATTERN = /^[-._a-zA-Z0-9]{1,253}$/;
|
|
9
|
+
// Output formats kubectl renders without reading anything from disk. The
|
|
10
|
+
// template formats carry their template inline ("jsonpath={.metadata.name}").
|
|
11
|
+
const INLINE_OUTPUT_FORMATS = new Set([
|
|
12
|
+
"json",
|
|
13
|
+
"yaml",
|
|
14
|
+
"name",
|
|
15
|
+
"go-template",
|
|
16
|
+
"template",
|
|
17
|
+
"jsonpath",
|
|
18
|
+
"jsonpath-as-json",
|
|
19
|
+
]);
|
|
20
|
+
function isInlineOutputFormat(output) {
|
|
21
|
+
return (typeof output === "string" &&
|
|
22
|
+
INLINE_OUTPUT_FORMATS.has(output.split("=")[0]));
|
|
23
|
+
}
|
|
9
24
|
export const kubectlCreateSchema = {
|
|
10
25
|
name: "kubectl_create",
|
|
11
26
|
description: "Create Kubernetes resources using various methods (from file or using subcommands)",
|
|
@@ -21,14 +36,11 @@ export const kubectlCreateSchema = {
|
|
|
21
36
|
"yaml",
|
|
22
37
|
"name",
|
|
23
38
|
"go-template",
|
|
24
|
-
"go-template-file",
|
|
25
39
|
"template",
|
|
26
|
-
"templatefile",
|
|
27
40
|
"jsonpath",
|
|
28
41
|
"jsonpath-as-json",
|
|
29
|
-
"jsonpath-file",
|
|
30
42
|
],
|
|
31
|
-
description: "Output format. One of: json|yaml|name|go-template|
|
|
43
|
+
description: "Output format. One of: json|yaml|name|go-template|template|jsonpath|jsonpath-as-json. The template formats take their template inline, e.g. 'jsonpath={.metadata.name}'.",
|
|
32
44
|
default: "yaml",
|
|
33
45
|
},
|
|
34
46
|
validate: {
|
|
@@ -163,6 +175,11 @@ export async function kubectlCreate(k8sManager, input) {
|
|
|
163
175
|
input.resourceType !== "namespace") {
|
|
164
176
|
throw new McpError(ErrorCode.InvalidRequest, `Name is required when creating a ${input.resourceType}`);
|
|
165
177
|
}
|
|
178
|
+
// Positional operands of "kubectl create <type> [<subtype>] <name>": a
|
|
179
|
+
// leading "-" would make kubectl parse them as flags.
|
|
180
|
+
assertNotFlagLike(input.name, "name");
|
|
181
|
+
assertNotFlagLike(input.secretType, "secretType");
|
|
182
|
+
assertNotFlagLike(input.serviceType, "serviceType");
|
|
166
183
|
// Reject server-side filesystem reads on remote transports. Over SSE /
|
|
167
184
|
// Streamable HTTP the path resolves on the MCP server host, not the
|
|
168
185
|
// client, so `filename` (-f) and `fromFile` (--from-file) would let any
|
|
@@ -178,21 +195,37 @@ export async function kubectlCreate(k8sManager, input) {
|
|
|
178
195
|
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
196
|
}
|
|
180
197
|
}
|
|
198
|
+
// kubectl parses --from-file as a comma-separated list, so a
|
|
199
|
+
// fromFileContent key of "x=/etc/passwd,y" would add a second source that
|
|
200
|
+
// names a file on the server. Kubernetes only accepts these characters in
|
|
201
|
+
// ConfigMap and Secret data keys anyway, so no legitimate key is refused.
|
|
202
|
+
// Checked up front so a bad entry fails before any temp file is written.
|
|
203
|
+
for (const entry of input.fromFileContent ?? []) {
|
|
204
|
+
if (entry?.key && !DATA_KEY_PATTERN.test(String(entry.key))) {
|
|
205
|
+
throw new McpError(ErrorCode.InvalidRequest, `Invalid fromFileContent key ${JSON.stringify(entry.key)}: keys ` +
|
|
206
|
+
`may only contain letters, digits, '-', '_' and '.'`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
181
209
|
// Set up common parameters
|
|
182
210
|
const namespace = input.namespace || "default";
|
|
183
211
|
const dryRun = input.dryRun || false;
|
|
184
212
|
const validate = input.validate ?? true;
|
|
185
213
|
const output = input.output || "yaml";
|
|
214
|
+
// The schema's enum is not enforced at runtime, and kubectl reads a file
|
|
215
|
+
// for some formats ("templatefile=<path>"), so only the formats that
|
|
216
|
+
// render inline are passed through.
|
|
217
|
+
if (!isInlineOutputFormat(output)) {
|
|
218
|
+
throw new McpError(ErrorCode.InvalidParams, `Unsupported output format "${output}". Use one of: ` +
|
|
219
|
+
`${[...INLINE_OUTPUT_FORMATS].join(", ")}.`);
|
|
220
|
+
}
|
|
186
221
|
const context = input.context || "";
|
|
187
222
|
const command = "kubectl";
|
|
188
223
|
const args = ["create"];
|
|
189
224
|
const tempFiles = [];
|
|
190
225
|
// Write client-provided content to a private temp file and return its
|
|
191
226
|
// path. The file is tracked for cleanup after kubectl runs.
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
const tempFile = path.join(tmpDir, `create-${label}-${Date.now()}-${tempFiles.length}${ext}`);
|
|
195
|
-
fs.writeFileSync(tempFile, contents, { mode: 0o600 });
|
|
227
|
+
const trackTempFile = (name, contents) => {
|
|
228
|
+
const tempFile = writeTempFile(name, contents);
|
|
196
229
|
tempFiles.push(tempFile);
|
|
197
230
|
return tempFile;
|
|
198
231
|
};
|
|
@@ -204,7 +237,7 @@ export async function kubectlCreate(k8sManager, input) {
|
|
|
204
237
|
if (!entry.key) {
|
|
205
238
|
throw new McpError(ErrorCode.InvalidRequest, "Each fromFileContent entry requires a non-empty 'key'");
|
|
206
239
|
}
|
|
207
|
-
const tempFile =
|
|
240
|
+
const tempFile = trackTempFile("fromfile", entry.content ?? "");
|
|
208
241
|
args.push(`--from-file=${entry.key}=${tempFile}`);
|
|
209
242
|
});
|
|
210
243
|
};
|
|
@@ -212,7 +245,7 @@ export async function kubectlCreate(k8sManager, input) {
|
|
|
212
245
|
if (input.manifest || input.filename) {
|
|
213
246
|
if (input.manifest) {
|
|
214
247
|
// Create temporary file for the manifest
|
|
215
|
-
args.push("-f",
|
|
248
|
+
args.push("-f", trackTempFile("manifest.yaml", input.manifest));
|
|
216
249
|
}
|
|
217
250
|
else if (input.filename) {
|
|
218
251
|
args.push("-f", input.filename);
|
|
@@ -357,14 +390,7 @@ export async function kubectlCreate(k8sManager, input) {
|
|
|
357
390
|
}
|
|
358
391
|
// Remove any temp files created for inline content.
|
|
359
392
|
const cleanupTempFiles = () => {
|
|
360
|
-
tempFiles.forEach(
|
|
361
|
-
try {
|
|
362
|
-
fs.unlinkSync(tempFile);
|
|
363
|
-
}
|
|
364
|
-
catch (err) {
|
|
365
|
-
console.warn(`Failed to delete temporary file ${tempFile}: ${err}`);
|
|
366
|
-
}
|
|
367
|
-
});
|
|
393
|
+
tempFiles.forEach(removeTempFile);
|
|
368
394
|
};
|
|
369
395
|
// Execute the command
|
|
370
396
|
try {
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
2
2
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
-
import * as fs from "fs";
|
|
4
|
-
import * as path from "path";
|
|
5
|
-
import * as os from "os";
|
|
6
3
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
4
|
+
import { removeTempFile, writeTempFile } from "../utils/temp-file.js";
|
|
7
5
|
import { contextParameter, namespaceParameter, } from "../models/common-parameters.js";
|
|
8
6
|
import { isRemoteTransport } from "../security/transport.js";
|
|
9
7
|
export const kubectlDeleteSchema = {
|
|
@@ -84,9 +82,7 @@ export async function kubectlDelete(k8sManager, input) {
|
|
|
84
82
|
// Handle deleting from manifest or file
|
|
85
83
|
if (input.manifest) {
|
|
86
84
|
// Create temporary file for the manifest
|
|
87
|
-
|
|
88
|
-
tempFile = path.join(tmpDir, `delete-manifest-${Date.now()}.yaml`);
|
|
89
|
-
fs.writeFileSync(tempFile, input.manifest);
|
|
85
|
+
tempFile = writeTempFile("manifest.yaml", input.manifest);
|
|
90
86
|
args.push("-f", tempFile);
|
|
91
87
|
}
|
|
92
88
|
else if (input.filename) {
|
|
@@ -136,14 +132,8 @@ export async function kubectlDelete(k8sManager, input) {
|
|
|
136
132
|
env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG },
|
|
137
133
|
});
|
|
138
134
|
// Clean up temp file if created
|
|
139
|
-
if (tempFile)
|
|
140
|
-
|
|
141
|
-
fs.unlinkSync(tempFile);
|
|
142
|
-
}
|
|
143
|
-
catch (err) {
|
|
144
|
-
console.warn(`Failed to delete temporary file ${tempFile}: ${err}`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
135
|
+
if (tempFile)
|
|
136
|
+
removeTempFile(tempFile);
|
|
147
137
|
return {
|
|
148
138
|
content: [
|
|
149
139
|
{
|
|
@@ -155,14 +145,8 @@ export async function kubectlDelete(k8sManager, input) {
|
|
|
155
145
|
}
|
|
156
146
|
catch (error) {
|
|
157
147
|
// Clean up temp file if created, even if command failed
|
|
158
|
-
if (tempFile)
|
|
159
|
-
|
|
160
|
-
fs.unlinkSync(tempFile);
|
|
161
|
-
}
|
|
162
|
-
catch (err) {
|
|
163
|
-
console.warn(`Failed to delete temporary file ${tempFile}: ${err}`);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
148
|
+
if (tempFile)
|
|
149
|
+
removeTempFile(tempFile);
|
|
166
150
|
if (error.status === 404 || error.message.includes("not found")) {
|
|
167
151
|
return {
|
|
168
152
|
content: [
|
|
@@ -2,7 +2,7 @@ import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
|
2
2
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
4
4
|
import { contextParameter, namespaceParameter, } from "../models/common-parameters.js";
|
|
5
|
-
import { assertNoDangerousFlags, assertNoRemoteFileReads, assertNotFlagLike, } from "../security/kubectl-flags.js";
|
|
5
|
+
import { assertAllowedGenericCommand, assertNoDangerousFlags, assertNoRemoteFileReads, assertNotFlagLike, } from "../security/kubectl-flags.js";
|
|
6
6
|
export const kubectlGenericSchema = {
|
|
7
7
|
name: "kubectl_generic",
|
|
8
8
|
description: "Execute any kubectl command with the provided arguments and flags",
|
|
@@ -121,6 +121,10 @@ export async function kubectlGeneric(k8sManager, input) {
|
|
|
121
121
|
// matched in the argv. See src/security/transport.ts for the trust model.
|
|
122
122
|
// No-op under stdio, where the files are the operator's own.
|
|
123
123
|
assertNoRemoteFileReads(cmdArgs);
|
|
124
|
+
// Some verbs reach the operator's credentials or the server's filesystem
|
|
125
|
+
// through plain operands ("config view --raw", "config set ...server",
|
|
126
|
+
// "cp <path> ..."), which none of the flag scans above can see.
|
|
127
|
+
assertAllowedGenericCommand(cmdArgs);
|
|
124
128
|
// Execute the command
|
|
125
129
|
try {
|
|
126
130
|
console.error(`Executing: kubectl ${cmdArgs.join(" ")}`);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
1
|
+
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
2
2
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
4
4
|
import { contextParameter, namespaceParameter, } from "../models/common-parameters.js";
|
|
@@ -63,6 +63,9 @@ export const kubectlLogsSchema = {
|
|
|
63
63
|
};
|
|
64
64
|
export async function kubectlLogs(k8sManager, input) {
|
|
65
65
|
try {
|
|
66
|
+
// The resource name is a positional operand; a leading "-" would make
|
|
67
|
+
// kubectl parse it as a flag.
|
|
68
|
+
assertNotFlagLike(input.name, "name");
|
|
66
69
|
const resourceType = (input.resourceType ?? "pod").toLowerCase();
|
|
67
70
|
const name = input.name;
|
|
68
71
|
const namespace = input.namespace || "default";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
1
|
+
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
2
2
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
3
3
|
import { contextParameter } from "../models/common-parameters.js";
|
|
4
4
|
export const explainResourceSchema = {
|
|
@@ -86,6 +86,9 @@ const executeKubectlCommand = (command, args) => {
|
|
|
86
86
|
};
|
|
87
87
|
export async function explainResource(params) {
|
|
88
88
|
try {
|
|
89
|
+
// The resource is a positional operand; a leading "-" would make kubectl
|
|
90
|
+
// parse it as a flag.
|
|
91
|
+
assertNotFlagLike(params.resource, "resource");
|
|
89
92
|
const command = "kubectl";
|
|
90
93
|
const args = ["explain"];
|
|
91
94
|
if (params.apiVersion) {
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
1
|
+
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
2
2
|
import { isRemoteTransport } from "../security/transport.js";
|
|
3
3
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
-
import * as fs from "fs";
|
|
5
|
-
import * as path from "path";
|
|
6
|
-
import * as os from "os";
|
|
7
4
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
5
|
+
import { removeTempFile, writeTempFile } from "../utils/temp-file.js";
|
|
8
6
|
import { contextParameter, dryRunParameter, namespaceParameter, } from "../models/common-parameters.js";
|
|
9
7
|
export const kubectlPatchSchema = {
|
|
10
8
|
name: "kubectl_patch",
|
|
@@ -65,6 +63,10 @@ export async function kubectlPatch(k8sManager, input) {
|
|
|
65
63
|
const dryRun = input.dryRun || false;
|
|
66
64
|
const context = input.context || "";
|
|
67
65
|
let tempFile = null;
|
|
66
|
+
// Positional operands: a leading "-" would make kubectl parse them as
|
|
67
|
+
// flags (e.g. --filename=<server path>).
|
|
68
|
+
assertNotFlagLike(input.resourceType, "resourceType");
|
|
69
|
+
assertNotFlagLike(input.name, "name");
|
|
68
70
|
const command = "kubectl";
|
|
69
71
|
const args = ["patch", input.resourceType, input.name, "-n", namespace];
|
|
70
72
|
// Add patch type flag
|
|
@@ -87,9 +89,7 @@ export async function kubectlPatch(k8sManager, input) {
|
|
|
87
89
|
throw new McpError(ErrorCode.InvalidRequest, "patchData must be a valid JSON object, not a string.");
|
|
88
90
|
}
|
|
89
91
|
// Create a temporary file for the patch data
|
|
90
|
-
|
|
91
|
-
tempFile = path.join(tmpDir, `patch-${Date.now()}.json`);
|
|
92
|
-
fs.writeFileSync(tempFile, JSON.stringify(input.patchData));
|
|
92
|
+
tempFile = writeTempFile("patch.json", JSON.stringify(input.patchData));
|
|
93
93
|
args.push("--patch-file", tempFile);
|
|
94
94
|
}
|
|
95
95
|
else if (input.patchFile) {
|
|
@@ -111,14 +111,8 @@ export async function kubectlPatch(k8sManager, input) {
|
|
|
111
111
|
env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG },
|
|
112
112
|
});
|
|
113
113
|
// Clean up temp file if created
|
|
114
|
-
if (tempFile)
|
|
115
|
-
|
|
116
|
-
fs.unlinkSync(tempFile);
|
|
117
|
-
}
|
|
118
|
-
catch (err) {
|
|
119
|
-
console.warn(`Failed to delete temporary file ${tempFile}: ${err}`);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
114
|
+
if (tempFile)
|
|
115
|
+
removeTempFile(tempFile);
|
|
122
116
|
return {
|
|
123
117
|
content: [
|
|
124
118
|
{
|
|
@@ -130,14 +124,8 @@ export async function kubectlPatch(k8sManager, input) {
|
|
|
130
124
|
}
|
|
131
125
|
catch (error) {
|
|
132
126
|
// Clean up temp file if created, even if command failed
|
|
133
|
-
if (tempFile)
|
|
134
|
-
|
|
135
|
-
fs.unlinkSync(tempFile);
|
|
136
|
-
}
|
|
137
|
-
catch (err) {
|
|
138
|
-
console.warn(`Failed to delete temporary file ${tempFile}: ${err}`);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
127
|
+
if (tempFile)
|
|
128
|
+
removeTempFile(tempFile);
|
|
141
129
|
throw new McpError(ErrorCode.InternalError, `Failed to patch resource: ${error.message}`);
|
|
142
130
|
}
|
|
143
131
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
1
|
+
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
2
2
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
4
4
|
import { contextParameter, namespaceParameter } from "../models/common-parameters.js";
|
|
@@ -55,6 +55,11 @@ export async function kubectlRollout(k8sManager, input) {
|
|
|
55
55
|
const namespace = input.namespace || "default";
|
|
56
56
|
const watch = input.watch || false;
|
|
57
57
|
const context = input.context || "";
|
|
58
|
+
// The schema enums are not enforced at runtime, so the positional
|
|
59
|
+
// operands get the same flag-shape check as every other tool.
|
|
60
|
+
assertNotFlagLike(input.subCommand, "subCommand");
|
|
61
|
+
assertNotFlagLike(input.resourceType, "resourceType");
|
|
62
|
+
assertNotFlagLike(input.name, "name");
|
|
58
63
|
const command = "kubectl";
|
|
59
64
|
const args = [
|
|
60
65
|
"rollout",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* and confirmation requirements for destructive operations.
|
|
6
6
|
* Note: Use kubectl_get with resourceType="nodes" to list nodes.
|
|
7
7
|
*/
|
|
8
|
-
import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
8
|
+
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
9
9
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
10
10
|
/**
|
|
11
11
|
* Schema for node_management tool.
|
|
@@ -126,6 +126,9 @@ const getNodeStatus = (nodeName) => {
|
|
|
126
126
|
export async function nodeManagement(params) {
|
|
127
127
|
const { operation, nodeName, force = false, gracePeriod = -1, deleteLocalData = false, ignoreDaemonsets = true, timeout = "0", dryRun = false, confirmDrain = false, } = params;
|
|
128
128
|
try {
|
|
129
|
+
// The node name is a positional operand of cordon/drain/uncordon; a
|
|
130
|
+
// leading "-" would make kubectl parse it as a flag.
|
|
131
|
+
assertNotFlagLike(nodeName, "nodeName");
|
|
129
132
|
switch (operation) {
|
|
130
133
|
case "cordon":
|
|
131
134
|
return handleCordonNode(nodeName);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
-
import { assertSafeArgv } from "../security/kubectl-flags.js";
|
|
3
|
+
import { assertNotFlagLike, assertSafeArgv, } from "../security/kubectl-flags.js";
|
|
4
4
|
// Use spawn instead of exec because port-forward is a long-running process
|
|
5
5
|
async function executePortForward(args) {
|
|
6
6
|
// port_forward uses spawn (long-running process) rather than the shared
|
|
@@ -60,6 +60,9 @@ export const PortForwardSchema = {
|
|
|
60
60
|
},
|
|
61
61
|
};
|
|
62
62
|
export async function startPortForward(k8sManager, input) {
|
|
63
|
+
// resourceType leads the "<type>/<name>" operand, so a leading "-" would
|
|
64
|
+
// make kubectl parse the whole operand as a flag.
|
|
65
|
+
assertNotFlagLike(input.resourceType, "resourceType");
|
|
63
66
|
const args = ["port-forward"];
|
|
64
67
|
if (input.namespace) {
|
|
65
68
|
args.push("-n", input.namespace);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write `contents` to a new file named `name` inside a fresh private temp
|
|
3
|
+
* directory and return the file's path. Release it with removeTempFile.
|
|
4
|
+
*/
|
|
5
|
+
export declare function writeTempFile(name: string, contents: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Remove a file created by writeTempFile, along with its private directory.
|
|
8
|
+
* Never throws: a failed cleanup is logged, not surfaced to the caller.
|
|
9
|
+
*/
|
|
10
|
+
export declare function removeTempFile(file: string): void;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
// Tools hand kubectl/helm client-supplied content (manifests, patches, values)
|
|
5
|
+
// through a file. The shared temp directory is writable by every local user,
|
|
6
|
+
// so the file goes in a fresh directory of its own: mkdtemp picks an
|
|
7
|
+
// unpredictable name and creates it 0700, so no one else can read the file or
|
|
8
|
+
// stage anything at its path. The exclusive create ("wx") makes a path that
|
|
9
|
+
// somehow already exists an error rather than a file — or a symlink — to
|
|
10
|
+
// write through.
|
|
11
|
+
/**
|
|
12
|
+
* Write `contents` to a new file named `name` inside a fresh private temp
|
|
13
|
+
* directory and return the file's path. Release it with removeTempFile.
|
|
14
|
+
*/
|
|
15
|
+
export function writeTempFile(name, contents) {
|
|
16
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-k8s-"));
|
|
17
|
+
const file = path.join(dir, name);
|
|
18
|
+
try {
|
|
19
|
+
fs.writeFileSync(file, contents, { flag: "wx", mode: 0o600 });
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
return file;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Remove a file created by writeTempFile, along with its private directory.
|
|
29
|
+
* Never throws: a failed cleanup is logged, not surfaced to the caller.
|
|
30
|
+
*/
|
|
31
|
+
export function removeTempFile(file) {
|
|
32
|
+
const dir = path.dirname(file);
|
|
33
|
+
// Only ever remove a directory writeTempFile made: a caller-supplied path
|
|
34
|
+
// reaching here by mistake must not take its parent directory with it.
|
|
35
|
+
if (path.dirname(dir) !== os.tmpdir() ||
|
|
36
|
+
!path.basename(dir).startsWith("mcp-k8s-")) {
|
|
37
|
+
console.warn(`Refusing to remove ${file}: not a writeTempFile path`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
console.warn(`Failed to delete temporary file ${file}: ${err}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-server-kubernetes",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.10",
|
|
4
4
|
"description": "MCP server for interacting with Kubernetes clusters via kubectl",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@kubernetes/client-node": "1.3.0",
|
|
41
|
-
"@modelcontextprotocol/sdk": "1.
|
|
41
|
+
"@modelcontextprotocol/sdk": "1.32.0",
|
|
42
42
|
"@opentelemetry/api": "1.9.1",
|
|
43
43
|
"@opentelemetry/auto-instrumentations-node": "0.77.0",
|
|
44
44
|
"@opentelemetry/exporter-trace-otlp-grpc": "0.219.0",
|