mcp-server-kubernetes 2.5.1 → 2.7.0
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 +1 -0
- package/dist/index.js +5 -0
- package/dist/tools/kubectl-get.js +93 -2
- package/dist/utils/streamable-http.d.ts +3 -0
- package/dist/utils/streamable-http.js +79 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -93,6 +93,7 @@ npx mcp-chat --config "%APPDATA%\Claude\claude_desktop_config.json"
|
|
|
93
93
|
- [x] Troubleshooting Prompt (`k8s-diagnose`)
|
|
94
94
|
- Guides through a systematic Kubernetes troubleshooting flow for pods based on a keyword and optional namespace.
|
|
95
95
|
- [x] Non-destructive mode for read and create/update-only access to clusters
|
|
96
|
+
- [x] Secrets masking for security (masks sensitive data in `kubectl get secrets` commands, does not affect logs)
|
|
96
97
|
|
|
97
98
|
## Prompts
|
|
98
99
|
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { kubectlPatch, kubectlPatchSchema } from "./tools/kubectl-patch.js";
|
|
|
24
24
|
import { kubectlRollout, kubectlRolloutSchema, } from "./tools/kubectl-rollout.js";
|
|
25
25
|
import { registerPromptHandlers } from "./prompts/index.js";
|
|
26
26
|
import { ping, pingSchema } from "./tools/ping.js";
|
|
27
|
+
import { startStreamableHTTPServer } from "./utils/streamable-http.js";
|
|
27
28
|
// Check environment variables for tool filtering
|
|
28
29
|
const allowOnlyReadonlyTools = process.env.ALLOW_ONLY_READONLY_TOOLS === "true";
|
|
29
30
|
const allowedToolsEnv = process.env.ALLOWED_TOOLS;
|
|
@@ -217,6 +218,10 @@ if (process.env.ENABLE_UNSAFE_SSE_TRANSPORT) {
|
|
|
217
218
|
startSSEServer(server);
|
|
218
219
|
console.log(`SSE server started`);
|
|
219
220
|
}
|
|
221
|
+
else if (process.env.ENABLE_UNSAFE_STREAMABLE_HTTP_TRANSPORT) {
|
|
222
|
+
startStreamableHTTPServer(server);
|
|
223
|
+
console.log(`Streamable HTTP server started`);
|
|
224
|
+
}
|
|
220
225
|
else {
|
|
221
226
|
const transport = new StdioServerTransport();
|
|
222
227
|
console.error(`Starting Kubernetes MCP server v${serverConfig.version}, handling commands...`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "child_process";
|
|
2
2
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
4
|
+
import * as yaml from "js-yaml";
|
|
4
5
|
export const kubectlGetSchema = {
|
|
5
6
|
name: "kubectl_get",
|
|
6
7
|
description: "Get or list Kubernetes resources by resource type, name, and optionally namespace",
|
|
@@ -120,12 +121,19 @@ export async function kubectlGet(k8sManager, input) {
|
|
|
120
121
|
maxBuffer: getSpawnMaxBuffer(),
|
|
121
122
|
env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG },
|
|
122
123
|
});
|
|
124
|
+
// Apply secrets masking if enabled and dealing with secrets
|
|
125
|
+
const shouldMaskSecrets = process.env.MASK_SECRETS !== "false" &&
|
|
126
|
+
(resourceType === "secrets" || resourceType === "secret");
|
|
127
|
+
let processedResult = result;
|
|
128
|
+
if (shouldMaskSecrets) {
|
|
129
|
+
processedResult = maskSecretsData(result, output);
|
|
130
|
+
}
|
|
123
131
|
// Format the results for better readability
|
|
124
132
|
const isListOperation = !name;
|
|
125
133
|
if (isListOperation && output === "json") {
|
|
126
134
|
try {
|
|
127
135
|
// Parse JSON and extract key information
|
|
128
|
-
const parsed = JSON.parse(
|
|
136
|
+
const parsed = JSON.parse(processedResult);
|
|
129
137
|
if (parsed.kind && parsed.kind.endsWith("List") && parsed.items) {
|
|
130
138
|
if (resourceType === "events") {
|
|
131
139
|
const formattedEvents = parsed.items.map((event) => ({
|
|
@@ -178,7 +186,7 @@ export async function kubectlGet(k8sManager, input) {
|
|
|
178
186
|
content: [
|
|
179
187
|
{
|
|
180
188
|
type: "text",
|
|
181
|
-
text:
|
|
189
|
+
text: processedResult,
|
|
182
190
|
},
|
|
183
191
|
],
|
|
184
192
|
};
|
|
@@ -261,3 +269,86 @@ function isNonNamespacedResource(resourceType) {
|
|
|
261
269
|
];
|
|
262
270
|
return nonNamespacedResources.includes(resourceType.toLowerCase());
|
|
263
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* Recursively traverses an object and masks values in 'data' sections of Kubernetes secrets.
|
|
274
|
+
*
|
|
275
|
+
* @param {any} obj - The object to traverse. Can be an array, object, or primitive value.
|
|
276
|
+
* @returns {any} A new object with masked values in 'data' sections.
|
|
277
|
+
*/
|
|
278
|
+
function maskDataValues(obj) {
|
|
279
|
+
if (obj == null) {
|
|
280
|
+
return obj;
|
|
281
|
+
}
|
|
282
|
+
if (Array.isArray(obj)) {
|
|
283
|
+
return obj.map(item => maskDataValues(item));
|
|
284
|
+
}
|
|
285
|
+
if (typeof obj === "object") {
|
|
286
|
+
const result = {};
|
|
287
|
+
for (const key in obj) {
|
|
288
|
+
if (key === "data" && typeof obj[key] === "object" && obj[key] !== null) {
|
|
289
|
+
// This is a data section - mask all leaf values within it
|
|
290
|
+
result[key] = maskAllLeafValues(obj[key]);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
result[key] = maskDataValues(obj[key]);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
return obj;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Recursively masks all leaf values (non-object, non-array values) in an object structure.
|
|
302
|
+
*
|
|
303
|
+
* @param {any} obj - The input object or value to process.
|
|
304
|
+
* @returns {any} A new object or value with all leaf values replaced by a mask.
|
|
305
|
+
*/
|
|
306
|
+
function maskAllLeafValues(obj) {
|
|
307
|
+
const maskValue = "***";
|
|
308
|
+
if (obj == null) {
|
|
309
|
+
return obj;
|
|
310
|
+
}
|
|
311
|
+
if (Array.isArray(obj)) {
|
|
312
|
+
return obj.map(item => maskAllLeafValues(item));
|
|
313
|
+
}
|
|
314
|
+
if (typeof obj === "object") {
|
|
315
|
+
const result = {};
|
|
316
|
+
for (const key in obj) {
|
|
317
|
+
result[key] = maskAllLeafValues(obj[key]);
|
|
318
|
+
}
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
// This is a leaf value (string, number, boolean) - mask it
|
|
322
|
+
return maskValue;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Masks sensitive data in Kubernetes secrets by parsing the raw output and replacing
|
|
326
|
+
* all leaf values in the "data" section with a placeholder value ("***").
|
|
327
|
+
*
|
|
328
|
+
* @param {string} output - The raw output from a `kubectl` command, containing secrets data.
|
|
329
|
+
* @param {string} format - The format of the output, either "json" or "yaml".
|
|
330
|
+
* @returns {string} - The masked output in the same format as the input.
|
|
331
|
+
*/
|
|
332
|
+
function maskSecretsData(output, format) {
|
|
333
|
+
try {
|
|
334
|
+
if (format === "json") {
|
|
335
|
+
const parsed = JSON.parse(output);
|
|
336
|
+
const masked = maskDataValues(parsed);
|
|
337
|
+
return JSON.stringify(masked, null, 2);
|
|
338
|
+
}
|
|
339
|
+
else if (format === "yaml") {
|
|
340
|
+
// Parse YAML to JSON, mask, then convert back to YAML
|
|
341
|
+
const parsed = yaml.load(output);
|
|
342
|
+
const masked = maskDataValues(parsed);
|
|
343
|
+
return yaml.dump(masked, {
|
|
344
|
+
indent: 2,
|
|
345
|
+
lineWidth: -1, // Don't wrap lines
|
|
346
|
+
noRefs: true // Don't use references
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
console.warn("Failed to parse secrets output for masking:", error);
|
|
352
|
+
}
|
|
353
|
+
return output;
|
|
354
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3
|
+
export function startStreamableHTTPServer(server) {
|
|
4
|
+
const app = express();
|
|
5
|
+
app.use(express.json());
|
|
6
|
+
app.post("/mcp", async (req, res) => {
|
|
7
|
+
// In stateless mode, create a new instance of transport and server for each request
|
|
8
|
+
// to ensure complete isolation. A single instance would cause request ID collisions
|
|
9
|
+
// when multiple clients connect concurrently.
|
|
10
|
+
try {
|
|
11
|
+
// DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server
|
|
12
|
+
// locally, make sure to set DNS_REBINDING_PROTECTION=true
|
|
13
|
+
const enableDnsRebindingProtection = process.env.DNS_REBINDING_PROTECTION === "true";
|
|
14
|
+
const allowedHosts = process.env.DNS_REBINDING_ALLOWED_HOST
|
|
15
|
+
? [process.env.DNS_REBINDING_ALLOWED_HOST]
|
|
16
|
+
: ["127.0.0.1"];
|
|
17
|
+
const transport = new StreamableHTTPServerTransport({
|
|
18
|
+
sessionIdGenerator: undefined,
|
|
19
|
+
enableDnsRebindingProtection,
|
|
20
|
+
allowedHosts,
|
|
21
|
+
});
|
|
22
|
+
res.on("close", () => {
|
|
23
|
+
transport.close();
|
|
24
|
+
server.close();
|
|
25
|
+
});
|
|
26
|
+
await server.connect(transport);
|
|
27
|
+
await transport.handleRequest(req, res, req.body);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
console.error("Error handling MCP request:", error);
|
|
31
|
+
if (!res.headersSent) {
|
|
32
|
+
res.status(500).json({
|
|
33
|
+
jsonrpc: "2.0",
|
|
34
|
+
error: {
|
|
35
|
+
code: -32603,
|
|
36
|
+
message: "Internal server error",
|
|
37
|
+
},
|
|
38
|
+
id: null,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
// SSE notifications not supported in stateless mode
|
|
44
|
+
app.get("/mcp", async (req, res) => {
|
|
45
|
+
console.log("Received GET MCP request");
|
|
46
|
+
res.writeHead(405).end(JSON.stringify({
|
|
47
|
+
jsonrpc: "2.0",
|
|
48
|
+
error: {
|
|
49
|
+
code: -32000,
|
|
50
|
+
message: "Method not allowed.",
|
|
51
|
+
},
|
|
52
|
+
id: null,
|
|
53
|
+
}));
|
|
54
|
+
});
|
|
55
|
+
// Session termination not needed in stateless mode
|
|
56
|
+
app.delete("/mcp", async (req, res) => {
|
|
57
|
+
console.log("Received DELETE MCP request");
|
|
58
|
+
res.writeHead(405).end(JSON.stringify({
|
|
59
|
+
jsonrpc: "2.0",
|
|
60
|
+
error: {
|
|
61
|
+
code: -32000,
|
|
62
|
+
message: "Method not allowed.",
|
|
63
|
+
},
|
|
64
|
+
id: null,
|
|
65
|
+
}));
|
|
66
|
+
});
|
|
67
|
+
let port = 3000;
|
|
68
|
+
try {
|
|
69
|
+
port = parseInt(process.env.PORT || "3000", 10);
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
console.error("Invalid PORT environment variable, using default port 3000.");
|
|
73
|
+
}
|
|
74
|
+
const host = process.env.HOST || "localhost";
|
|
75
|
+
const httpServer = app.listen(port, host, () => {
|
|
76
|
+
console.log(`mcp-kubernetes-server is listening on port ${port}\nUse the following url to connect to the server:\nhttp://${host}:${port}/mcp`);
|
|
77
|
+
});
|
|
78
|
+
return httpServer;
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-server-kubernetes",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
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.17.0",
|
|
42
42
|
"express": "4.21.2",
|
|
43
43
|
"js-yaml": "4.1.0",
|
|
44
44
|
"yaml": "2.7.0",
|