mcp-server-kubernetes 0.1.2 → 0.1.4
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 +2 -5
- package/dist/index.js +201 -21
- package/dist/types.d.ts +44 -0
- package/dist/types.js +12 -0
- package/dist/unit.test.js +240 -110
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -35,19 +35,16 @@ If you have errors, open up a standard terminal and run `kubectl get pods` to se
|
|
|
35
35
|
- [x] List all pods
|
|
36
36
|
- [x] List all services
|
|
37
37
|
- [x] List all deployments
|
|
38
|
+
- [x] List all nodes
|
|
38
39
|
- [x] Create a pod
|
|
39
40
|
- [x] Delete a pod
|
|
40
41
|
- [x] Describe a pod
|
|
41
42
|
- [x] List all namespaces
|
|
43
|
+
- [x] Get logs from a pod for debugging (supports pods, deployments, jobs, and label selectors)
|
|
42
44
|
- [ ] Port forward to a pod
|
|
43
|
-
- [ ] Get logs from a pod for debugging
|
|
44
45
|
- [ ] Choose namespace for next commands (memory)
|
|
45
46
|
- [ ] Support Helm for installing charts
|
|
46
47
|
|
|
47
|
-
## In Progress
|
|
48
|
-
|
|
49
|
-
- [ ] [Docker support](https://github.com/Flux159/mcp-server-kubernetes/pull/9)
|
|
50
|
-
|
|
51
48
|
## Local Development
|
|
52
49
|
|
|
53
50
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -19,15 +19,6 @@ class KubernetesManager {
|
|
|
19
19
|
// process.on("SIGTERM", () => this.cleanup());
|
|
20
20
|
}
|
|
21
21
|
async cleanup() {
|
|
22
|
-
console.log("Cleaning up resources...");
|
|
23
|
-
// Stop port forwards
|
|
24
|
-
// for (const pf of this.portForwards) {
|
|
25
|
-
// try {
|
|
26
|
-
// await pf.server.stop();
|
|
27
|
-
// } catch (error) {
|
|
28
|
-
// console.error(`Failed to close port-forward ${pf.id}:`, error);
|
|
29
|
-
// }
|
|
30
|
-
// }
|
|
31
22
|
// Stop watches
|
|
32
23
|
for (const watch of this.watches) {
|
|
33
24
|
watch.abort.abort();
|
|
@@ -41,6 +32,7 @@ class KubernetesManager {
|
|
|
41
32
|
console.error(`Failed to delete ${resource.kind} ${resource.name}:`, error);
|
|
42
33
|
}
|
|
43
34
|
}
|
|
35
|
+
// TODO: Cleanup port forwards when implemented
|
|
44
36
|
}
|
|
45
37
|
trackResource(kind, name, namespace) {
|
|
46
38
|
this.resources.push({ kind, name, namespace, createdAt: new Date() });
|
|
@@ -83,6 +75,7 @@ class KubernetesManager {
|
|
|
83
75
|
}
|
|
84
76
|
const k8sManager = new KubernetesManager();
|
|
85
77
|
// Template configurations with health checks and resource limits
|
|
78
|
+
// TODO: Update create_pod to accept custom images and custom template files
|
|
86
79
|
const containerTemplates = {
|
|
87
80
|
ubuntu: {
|
|
88
81
|
name: "main",
|
|
@@ -238,6 +231,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
238
231
|
},
|
|
239
232
|
},
|
|
240
233
|
{
|
|
234
|
+
// TODO: Add support for custom images and templates (see above in containerTemplates definition)
|
|
241
235
|
name: "create_pod",
|
|
242
236
|
description: "Create a new Kubernetes pod",
|
|
243
237
|
inputSchema: {
|
|
@@ -259,6 +253,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
259
253
|
},
|
|
260
254
|
},
|
|
261
255
|
{
|
|
256
|
+
// TODO: Support for custom deployments (see above)
|
|
262
257
|
name: "create_deployment",
|
|
263
258
|
description: "Create a new Kubernetes deployment",
|
|
264
259
|
inputSchema: {
|
|
@@ -313,6 +308,63 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
313
308
|
properties: {},
|
|
314
309
|
},
|
|
315
310
|
},
|
|
311
|
+
{
|
|
312
|
+
name: "list_nodes",
|
|
313
|
+
description: "List all nodes in the cluster",
|
|
314
|
+
inputSchema: {
|
|
315
|
+
type: "object",
|
|
316
|
+
properties: {},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: "get_logs",
|
|
321
|
+
description: "Get logs from pods, deployments, jobs, or resources matching a label selector",
|
|
322
|
+
inputSchema: {
|
|
323
|
+
type: "object",
|
|
324
|
+
properties: {
|
|
325
|
+
resourceType: {
|
|
326
|
+
type: "string",
|
|
327
|
+
enum: ["pod", "deployment", "job"],
|
|
328
|
+
description: "Type of resource to get logs from",
|
|
329
|
+
},
|
|
330
|
+
name: {
|
|
331
|
+
type: "string",
|
|
332
|
+
description: "Name of the resource",
|
|
333
|
+
},
|
|
334
|
+
namespace: {
|
|
335
|
+
type: "string",
|
|
336
|
+
description: "Namespace of the resource",
|
|
337
|
+
default: "default",
|
|
338
|
+
},
|
|
339
|
+
labelSelector: {
|
|
340
|
+
type: "string",
|
|
341
|
+
description: "Label selector to filter resources",
|
|
342
|
+
optional: true,
|
|
343
|
+
},
|
|
344
|
+
container: {
|
|
345
|
+
type: "string",
|
|
346
|
+
description: "Container name (required when pod has multiple containers)",
|
|
347
|
+
optional: true,
|
|
348
|
+
},
|
|
349
|
+
tail: {
|
|
350
|
+
type: "number",
|
|
351
|
+
description: "Number of lines to show from end of logs",
|
|
352
|
+
optional: true,
|
|
353
|
+
},
|
|
354
|
+
since: {
|
|
355
|
+
type: "number",
|
|
356
|
+
description: "Get logs since relative time in seconds",
|
|
357
|
+
optional: true,
|
|
358
|
+
},
|
|
359
|
+
timestamps: {
|
|
360
|
+
type: "boolean",
|
|
361
|
+
description: "Include timestamps in logs",
|
|
362
|
+
default: false,
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
required: ["resourceType"],
|
|
366
|
+
},
|
|
367
|
+
},
|
|
316
368
|
],
|
|
317
369
|
};
|
|
318
370
|
});
|
|
@@ -404,9 +456,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
404
456
|
};
|
|
405
457
|
}
|
|
406
458
|
case "create_pod": {
|
|
407
|
-
console.error("calling create_pod");
|
|
408
|
-
console.error(input);
|
|
409
|
-
console.error(request);
|
|
410
459
|
const createPodInput = input;
|
|
411
460
|
const templateConfig = containerTemplates[createPodInput.template];
|
|
412
461
|
const pod = {
|
|
@@ -426,21 +475,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
426
475
|
...templateConfig,
|
|
427
476
|
...(createPodInput.command && {
|
|
428
477
|
command: createPodInput.command,
|
|
478
|
+
args: undefined, // Clear default args when command is overridden
|
|
429
479
|
}),
|
|
430
480
|
},
|
|
431
481
|
],
|
|
432
482
|
},
|
|
433
483
|
};
|
|
434
|
-
const
|
|
484
|
+
const response = await k8sManager
|
|
435
485
|
.getCoreApi()
|
|
436
|
-
.createNamespacedPod(createPodInput.namespace, pod)
|
|
486
|
+
.createNamespacedPod(createPodInput.namespace, pod)
|
|
487
|
+
.catch((error) => {
|
|
488
|
+
console.error("Pod creation error:", {
|
|
489
|
+
status: error.response?.statusCode,
|
|
490
|
+
message: error.response?.body?.message || error.message,
|
|
491
|
+
details: error.response?.body,
|
|
492
|
+
});
|
|
493
|
+
throw error;
|
|
494
|
+
});
|
|
437
495
|
k8sManager.trackResource("Pod", createPodInput.name, createPodInput.namespace);
|
|
438
496
|
return {
|
|
439
497
|
content: [
|
|
440
498
|
{
|
|
441
499
|
type: "text",
|
|
442
500
|
text: JSON.stringify({
|
|
443
|
-
podName: body.metadata.name,
|
|
501
|
+
podName: response.body.metadata.name,
|
|
444
502
|
status: "created",
|
|
445
503
|
}, null, 2),
|
|
446
504
|
},
|
|
@@ -496,7 +554,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
496
554
|
type: "text",
|
|
497
555
|
text: JSON.stringify({
|
|
498
556
|
error: "Pod not found",
|
|
499
|
-
status: "not_found"
|
|
557
|
+
status: "not_found",
|
|
500
558
|
}, null, 2),
|
|
501
559
|
},
|
|
502
560
|
],
|
|
@@ -513,7 +571,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
513
571
|
labels: body.metadata?.labels,
|
|
514
572
|
},
|
|
515
573
|
spec: {
|
|
516
|
-
containers: body.spec?.containers.map(container => ({
|
|
574
|
+
containers: body.spec?.containers.map((container) => ({
|
|
517
575
|
name: container.name,
|
|
518
576
|
image: container.image,
|
|
519
577
|
ports: container.ports,
|
|
@@ -525,7 +583,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
525
583
|
phase: body.status?.phase,
|
|
526
584
|
conditions: body.status?.conditions,
|
|
527
585
|
containerStatuses: body.status?.containerStatuses,
|
|
528
|
-
}
|
|
586
|
+
},
|
|
529
587
|
};
|
|
530
588
|
return {
|
|
531
589
|
content: [
|
|
@@ -544,7 +602,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
544
602
|
type: "text",
|
|
545
603
|
text: JSON.stringify({
|
|
546
604
|
error: "Pod not found",
|
|
547
|
-
status: "not_found"
|
|
605
|
+
status: "not_found",
|
|
548
606
|
}, null, 2),
|
|
549
607
|
},
|
|
550
608
|
],
|
|
@@ -567,6 +625,119 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
567
625
|
],
|
|
568
626
|
};
|
|
569
627
|
}
|
|
628
|
+
case "list_nodes": {
|
|
629
|
+
const { body } = await k8sManager.getCoreApi().listNode();
|
|
630
|
+
return {
|
|
631
|
+
content: [
|
|
632
|
+
{
|
|
633
|
+
type: "text",
|
|
634
|
+
text: JSON.stringify({
|
|
635
|
+
nodes: body.items,
|
|
636
|
+
}, null, 2),
|
|
637
|
+
},
|
|
638
|
+
],
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
case "get_logs": {
|
|
642
|
+
const { resourceType, name, namespace = "default", labelSelector, container, tail = 100, sinceSeconds, timestamps, pretty = true, follow = false, } = input;
|
|
643
|
+
async function getPodLogs(podName, podNamespace) {
|
|
644
|
+
try {
|
|
645
|
+
const { body } = await k8sManager.getCoreApi().readNamespacedPodLog(podName, podNamespace, container, follow, undefined, // insecureSkipTLSVerifyBackend
|
|
646
|
+
undefined, // limitBytes
|
|
647
|
+
pretty ? "true" : "false", undefined, // previous
|
|
648
|
+
sinceSeconds, tail, timestamps);
|
|
649
|
+
return body;
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
if (error.response?.statusCode === 404) {
|
|
653
|
+
throw new McpError(ErrorCode.InvalidRequest, `Pod ${podName} not found in namespace ${podNamespace}`);
|
|
654
|
+
}
|
|
655
|
+
// Log full error details
|
|
656
|
+
console.error("Full error:", {
|
|
657
|
+
statusCode: error.response?.statusCode,
|
|
658
|
+
message: error.response?.body?.message || error.message,
|
|
659
|
+
details: error.response?.body,
|
|
660
|
+
});
|
|
661
|
+
throw new McpError(ErrorCode.InternalError, `Failed to get logs for pod ${podName}: ${error.response?.body?.message || error.message}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const logs = {};
|
|
665
|
+
try {
|
|
666
|
+
// Get logs based on resource type
|
|
667
|
+
switch (resourceType.toLowerCase()) {
|
|
668
|
+
case "pod": {
|
|
669
|
+
if (!name) {
|
|
670
|
+
throw new McpError(ErrorCode.InvalidRequest, "Pod name is required when resourceType is 'pod'");
|
|
671
|
+
}
|
|
672
|
+
logs[name] = await getPodLogs(name, namespace);
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
case "deployment": {
|
|
676
|
+
if (!name) {
|
|
677
|
+
throw new McpError(ErrorCode.InvalidRequest, "Deployment name is required when resourceType is 'deployment'");
|
|
678
|
+
}
|
|
679
|
+
const { body: deployment } = await k8sManager
|
|
680
|
+
.getAppsApi()
|
|
681
|
+
.readNamespacedDeployment(name, namespace);
|
|
682
|
+
if (!deployment.spec?.selector?.matchLabels) {
|
|
683
|
+
throw new McpError(ErrorCode.InvalidRequest, `Deployment ${name} has no selector`);
|
|
684
|
+
}
|
|
685
|
+
const selector = Object.entries(deployment.spec.selector.matchLabels)
|
|
686
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
687
|
+
.join(",");
|
|
688
|
+
const { body: podList } = await k8sManager
|
|
689
|
+
.getCoreApi()
|
|
690
|
+
.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, selector);
|
|
691
|
+
for (const pod of podList.items) {
|
|
692
|
+
if (pod.metadata?.name) {
|
|
693
|
+
logs[pod.metadata.name] = await getPodLogs(pod.metadata.name, namespace);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
case "job": {
|
|
699
|
+
if (!name) {
|
|
700
|
+
throw new McpError(ErrorCode.InvalidRequest, "Job name is required when resourceType is 'job'");
|
|
701
|
+
}
|
|
702
|
+
const { body: podList } = await k8sManager
|
|
703
|
+
.getCoreApi()
|
|
704
|
+
.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `job-name=${name}`);
|
|
705
|
+
for (const pod of podList.items) {
|
|
706
|
+
if (pod.metadata?.name) {
|
|
707
|
+
logs[pod.metadata.name] = await getPodLogs(pod.metadata.name, namespace);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
default:
|
|
713
|
+
throw new McpError(ErrorCode.InvalidRequest, `Unsupported resource type: ${resourceType}`);
|
|
714
|
+
}
|
|
715
|
+
// If labelSelector is provided, filter or add logs by label
|
|
716
|
+
if (labelSelector) {
|
|
717
|
+
const { body: labeledPods } = await k8sManager
|
|
718
|
+
.getCoreApi()
|
|
719
|
+
.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, labelSelector);
|
|
720
|
+
for (const pod of labeledPods.items) {
|
|
721
|
+
if (pod.metadata?.name) {
|
|
722
|
+
logs[pod.metadata.name] = await getPodLogs(pod.metadata.name, namespace);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return {
|
|
727
|
+
content: [
|
|
728
|
+
{
|
|
729
|
+
type: "text",
|
|
730
|
+
text: JSON.stringify({ logs }, null, 2),
|
|
731
|
+
},
|
|
732
|
+
],
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
catch (error) {
|
|
736
|
+
if (error instanceof McpError)
|
|
737
|
+
throw error;
|
|
738
|
+
throw new McpError(ErrorCode.InternalError, `Failed to get logs: ${error}`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
570
741
|
default:
|
|
571
742
|
throw new McpError(ErrorCode.InvalidRequest, `Unknown tool: ${name}`);
|
|
572
743
|
}
|
|
@@ -605,6 +776,12 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
|
605
776
|
mimeType: "application/json",
|
|
606
777
|
description: "List of all namespaces",
|
|
607
778
|
},
|
|
779
|
+
{
|
|
780
|
+
uri: "k8s://nodes",
|
|
781
|
+
name: "Kubernetes Nodes",
|
|
782
|
+
mimeType: "application/json",
|
|
783
|
+
description: "List of all nodes in the cluster",
|
|
784
|
+
},
|
|
608
785
|
],
|
|
609
786
|
};
|
|
610
787
|
});
|
|
@@ -612,8 +789,11 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
|
612
789
|
try {
|
|
613
790
|
const uri = request.params.uri;
|
|
614
791
|
const parts = uri.replace("k8s://", "").split("/");
|
|
615
|
-
|
|
616
|
-
|
|
792
|
+
const isNamespaces = parts[0] === "namespaces";
|
|
793
|
+
const isNodes = parts[0] === "nodes";
|
|
794
|
+
if ((isNamespaces || isNodes) && parts.length === 1) {
|
|
795
|
+
const fn = isNodes ? "listNode" : "listNamespace";
|
|
796
|
+
const { body } = await k8sManager.getCoreApi()[fn]();
|
|
617
797
|
return {
|
|
618
798
|
contents: [
|
|
619
799
|
{
|
package/dist/types.d.ts
CHANGED
|
@@ -229,6 +229,50 @@ export declare const ListNamespacesResponseSchema: z.ZodObject<{
|
|
|
229
229
|
text: string;
|
|
230
230
|
}[];
|
|
231
231
|
}>;
|
|
232
|
+
export declare const ListNodesResponseSchema: z.ZodObject<{
|
|
233
|
+
content: z.ZodArray<z.ZodObject<{
|
|
234
|
+
type: z.ZodLiteral<"text">;
|
|
235
|
+
text: z.ZodString;
|
|
236
|
+
}, "strip", z.ZodTypeAny, {
|
|
237
|
+
type: "text";
|
|
238
|
+
text: string;
|
|
239
|
+
}, {
|
|
240
|
+
type: "text";
|
|
241
|
+
text: string;
|
|
242
|
+
}>, "many">;
|
|
243
|
+
}, "strip", z.ZodTypeAny, {
|
|
244
|
+
content: {
|
|
245
|
+
type: "text";
|
|
246
|
+
text: string;
|
|
247
|
+
}[];
|
|
248
|
+
}, {
|
|
249
|
+
content: {
|
|
250
|
+
type: "text";
|
|
251
|
+
text: string;
|
|
252
|
+
}[];
|
|
253
|
+
}>;
|
|
254
|
+
export declare const GetLogsResponseSchema: z.ZodObject<{
|
|
255
|
+
content: z.ZodArray<z.ZodObject<{
|
|
256
|
+
type: z.ZodLiteral<"text">;
|
|
257
|
+
text: z.ZodString;
|
|
258
|
+
}, "strip", z.ZodTypeAny, {
|
|
259
|
+
type: "text";
|
|
260
|
+
text: string;
|
|
261
|
+
}, {
|
|
262
|
+
type: "text";
|
|
263
|
+
text: string;
|
|
264
|
+
}>, "many">;
|
|
265
|
+
}, "strip", z.ZodTypeAny, {
|
|
266
|
+
content: {
|
|
267
|
+
type: "text";
|
|
268
|
+
text: string;
|
|
269
|
+
}[];
|
|
270
|
+
}, {
|
|
271
|
+
content: {
|
|
272
|
+
type: "text";
|
|
273
|
+
text: string;
|
|
274
|
+
}[];
|
|
275
|
+
}>;
|
|
232
276
|
export declare const ListResourcesResponseSchema: z.ZodObject<{
|
|
233
277
|
resources: z.ZodArray<z.ZodObject<{
|
|
234
278
|
uri: z.ZodString;
|
package/dist/types.js
CHANGED
|
@@ -70,6 +70,18 @@ export const ListNamespacesResponseSchema = z.object({
|
|
|
70
70
|
text: z.string(),
|
|
71
71
|
})),
|
|
72
72
|
});
|
|
73
|
+
export const ListNodesResponseSchema = z.object({
|
|
74
|
+
content: z.array(z.object({
|
|
75
|
+
type: z.literal("text"),
|
|
76
|
+
text: z.string(),
|
|
77
|
+
})),
|
|
78
|
+
});
|
|
79
|
+
export const GetLogsResponseSchema = z.object({
|
|
80
|
+
content: z.array(z.object({
|
|
81
|
+
type: z.literal("text"),
|
|
82
|
+
text: z.string(),
|
|
83
|
+
})),
|
|
84
|
+
});
|
|
73
85
|
export const ListResourcesResponseSchema = z.object({
|
|
74
86
|
resources: z.array(ResourceSchema),
|
|
75
87
|
});
|
package/dist/unit.test.js
CHANGED
|
@@ -1,24 +1,79 @@
|
|
|
1
|
-
|
|
1
|
+
// Import required test frameworks and SDK components
|
|
2
|
+
import { expect, test, describe, beforeEach, afterEach, } from "vitest";
|
|
2
3
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
4
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
|
-
import { ListToolsResponseSchema, ListPodsResponseSchema,
|
|
5
|
+
import { ListToolsResponseSchema, ListPodsResponseSchema, ListNamespacesResponseSchema, ListNodesResponseSchema, CreatePodResponseSchema, DeletePodResponseSchema, } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Utility function to create a promise that resolves after specified milliseconds
|
|
8
|
+
* Useful for waiting between operations or ensuring async operations complete
|
|
9
|
+
*/
|
|
5
10
|
async function sleep(ms) {
|
|
6
11
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
12
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Generates a random SHA-like string for unique resource naming
|
|
15
|
+
* Used to avoid naming conflicts when creating test resources
|
|
16
|
+
*/
|
|
17
|
+
function generateRandomSHA() {
|
|
18
|
+
return Math.random().toString(36).substring(2, 15);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Test suite for kubernetes server operations
|
|
22
|
+
* Tests the core functionality of kubernetes operations including:
|
|
23
|
+
* - Listing available tools
|
|
24
|
+
* - Namespace and node operations
|
|
25
|
+
* - Pod lifecycle management (create, monitor, delete)
|
|
26
|
+
*/
|
|
27
|
+
describe("kubernetes server operations", () => {
|
|
28
|
+
let transport;
|
|
29
|
+
let client;
|
|
30
|
+
/**
|
|
31
|
+
* Set up before each test:
|
|
32
|
+
* - Creates a new StdioClientTransport instance
|
|
33
|
+
* - Initializes and connects the MCP client
|
|
34
|
+
* - Waits for connection to be established
|
|
35
|
+
*/
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
try {
|
|
38
|
+
transport = new StdioClientTransport({
|
|
39
|
+
command: "bun",
|
|
40
|
+
args: ["src/index.ts"],
|
|
41
|
+
stderr: "pipe",
|
|
42
|
+
});
|
|
43
|
+
client = new Client({
|
|
44
|
+
name: "test-client",
|
|
45
|
+
version: "1.0.0",
|
|
46
|
+
}, {
|
|
47
|
+
capabilities: {},
|
|
48
|
+
});
|
|
49
|
+
await client.connect(transport);
|
|
50
|
+
// Wait for connection to be fully established
|
|
51
|
+
await sleep(1000);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
console.error("Error in beforeEach:", e);
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
13
57
|
});
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Clean up after each test:
|
|
60
|
+
* - Closes the transport connection
|
|
61
|
+
* - Waits to ensure clean shutdown
|
|
62
|
+
*/
|
|
63
|
+
afterEach(async () => {
|
|
64
|
+
try {
|
|
65
|
+
await transport.close();
|
|
66
|
+
await sleep(1000);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
console.error("Error during cleanup:", e);
|
|
70
|
+
}
|
|
19
71
|
});
|
|
20
|
-
|
|
21
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Test case: Verify the availability of kubernetes tools
|
|
74
|
+
* Ensures that the server exposes the expected kubernetes operations
|
|
75
|
+
*/
|
|
76
|
+
test("list available tools", async () => {
|
|
22
77
|
// List available tools stays the same
|
|
23
78
|
console.log("Listing available tools...");
|
|
24
79
|
const toolsList = await client.request({
|
|
@@ -26,134 +81,209 @@ test("kubernetes server operations", async () => {
|
|
|
26
81
|
}, ListToolsResponseSchema);
|
|
27
82
|
expect(toolsList.tools).toBeDefined();
|
|
28
83
|
expect(toolsList.tools.length).toBeGreaterThan(0);
|
|
84
|
+
});
|
|
85
|
+
/**
|
|
86
|
+
* Test case: Verify namespace and node listing functionality
|
|
87
|
+
* Tests both namespace and node listing operations in sequence
|
|
88
|
+
*/
|
|
89
|
+
test("list namespaces and nodes", async () => {
|
|
29
90
|
// List namespaces
|
|
30
91
|
console.log("Listing namespaces...");
|
|
31
92
|
const namespacesResult = await client.request({
|
|
32
93
|
method: "tools/call",
|
|
33
94
|
params: {
|
|
34
95
|
name: "list_namespaces",
|
|
35
|
-
arguments: {},
|
|
96
|
+
arguments: {},
|
|
36
97
|
},
|
|
37
98
|
}, ListNamespacesResponseSchema);
|
|
38
99
|
expect(namespacesResult.content[0].type).toBe("text");
|
|
39
100
|
const namespaces = JSON.parse(namespacesResult.content[0].text);
|
|
40
101
|
expect(namespaces.namespaces).toBeDefined();
|
|
41
|
-
//
|
|
42
|
-
console.log("
|
|
43
|
-
const
|
|
44
|
-
method: "tools/call",
|
|
45
|
-
params: {
|
|
46
|
-
name: "delete_pod",
|
|
47
|
-
arguments: {
|
|
48
|
-
// Changed from input to arguments
|
|
49
|
-
name: "test-pod",
|
|
50
|
-
namespace: "default",
|
|
51
|
-
ignoreNotFound: true,
|
|
52
|
-
},
|
|
53
|
-
},
|
|
54
|
-
}, DeletePodResponseSchema);
|
|
55
|
-
expect(deletePodResult.content[0].type).toBe("text");
|
|
56
|
-
const deleteResult = JSON.parse(deletePodResult.content[0].text);
|
|
57
|
-
expect(deleteResult.success).toBe(true);
|
|
58
|
-
await sleep(2000);
|
|
59
|
-
// Create a pod
|
|
60
|
-
console.log("Creating test pod...");
|
|
61
|
-
const createPodResult = await client.request({
|
|
62
|
-
method: "tools/call",
|
|
63
|
-
params: {
|
|
64
|
-
name: "create_pod",
|
|
65
|
-
arguments: {
|
|
66
|
-
// Changed from input to arguments
|
|
67
|
-
name: "test-pod",
|
|
68
|
-
namespace: "default",
|
|
69
|
-
template: "nginx",
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
}, CreatePodResponseSchema);
|
|
73
|
-
expect(createPodResult.content[0].type).toBe("text");
|
|
74
|
-
const createResult = JSON.parse(createPodResult.content[0].text);
|
|
75
|
-
expect(createResult.podName).toBe("test-pod");
|
|
76
|
-
expect(createResult.status).toBe("created");
|
|
77
|
-
// Describe the pod
|
|
78
|
-
console.log("Describing test pod...");
|
|
79
|
-
const describePodResult = await client.request({
|
|
102
|
+
// List nodes
|
|
103
|
+
console.log("Listing nodes...");
|
|
104
|
+
const listNodesResult = await client.request({
|
|
80
105
|
method: "tools/call",
|
|
81
106
|
params: {
|
|
82
|
-
name: "
|
|
83
|
-
arguments: {
|
|
84
|
-
name: "test-pod",
|
|
85
|
-
namespace: "default",
|
|
86
|
-
},
|
|
107
|
+
name: "list_nodes",
|
|
108
|
+
arguments: {},
|
|
87
109
|
},
|
|
88
|
-
},
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
expect(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
110
|
+
}, ListNodesResponseSchema);
|
|
111
|
+
expect(listNodesResult.content[0].type).toBe("text");
|
|
112
|
+
const nodes = JSON.parse(listNodesResult.content[0].text);
|
|
113
|
+
expect(nodes.nodes).toBeDefined();
|
|
114
|
+
expect(Array.isArray(nodes.nodes)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
/**
|
|
117
|
+
* Test case: Complete pod lifecycle management
|
|
118
|
+
* Tests the full lifecycle of a pod including:
|
|
119
|
+
* 1. Cleanup of existing test pods
|
|
120
|
+
* 2. Creation of new test pod
|
|
121
|
+
* 3. Monitoring pod until running state
|
|
122
|
+
* 4. Verification of pod logs
|
|
123
|
+
* 5. Pod deletion and termination verification
|
|
124
|
+
*
|
|
125
|
+
* Note: Test timeout is set to 120 seconds to accommodate all operations via vitest.config.ts
|
|
126
|
+
*/
|
|
127
|
+
test("pod lifecycle management", async () => {
|
|
128
|
+
const podBaseName = "unit-test";
|
|
129
|
+
const podName = `${podBaseName}-${generateRandomSHA()}`;
|
|
130
|
+
// Step 1: Check if pods with unit-test prefix exist and terminate them if found
|
|
131
|
+
const existingPods = await client.request({
|
|
98
132
|
method: "tools/call",
|
|
99
133
|
params: {
|
|
100
134
|
name: "list_pods",
|
|
101
135
|
arguments: {
|
|
102
|
-
// Changed from input to arguments
|
|
103
136
|
namespace: "default",
|
|
104
137
|
},
|
|
105
138
|
},
|
|
106
139
|
}, ListPodsResponseSchema);
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
140
|
+
const podsResponse = JSON.parse(existingPods.content[0].text);
|
|
141
|
+
const existingTestPods = podsResponse.items?.filter((pod) => pod.metadata?.name?.startsWith(podBaseName)) || [];
|
|
142
|
+
// Terminate existing test pods if found
|
|
143
|
+
for (const pod of existingTestPods) {
|
|
144
|
+
await client.request({
|
|
145
|
+
method: "tools/call",
|
|
146
|
+
params: {
|
|
147
|
+
name: "delete_pod",
|
|
148
|
+
arguments: {
|
|
149
|
+
name: pod.metadata.name,
|
|
150
|
+
namespace: "default",
|
|
151
|
+
ignoreNotFound: true,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
}, DeletePodResponseSchema);
|
|
155
|
+
// Wait for pod to be fully terminated
|
|
156
|
+
let podDeleted = false;
|
|
157
|
+
const terminationStartTime = Date.now();
|
|
158
|
+
while (!podDeleted && Date.now() - terminationStartTime < 10000) {
|
|
159
|
+
try {
|
|
160
|
+
await client.request({
|
|
161
|
+
method: "tools/call",
|
|
162
|
+
params: {
|
|
163
|
+
name: "describe_pod",
|
|
164
|
+
arguments: {
|
|
165
|
+
name: pod.metadata.name,
|
|
166
|
+
namespace: "default",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
}, ListPodsResponseSchema);
|
|
170
|
+
await sleep(500);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
// If we get an error, it might be because the pod is gone (404)
|
|
174
|
+
podDeleted = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Create new pod with random SHA name
|
|
179
|
+
const createPodResult = await client.request({
|
|
114
180
|
method: "tools/call",
|
|
115
181
|
params: {
|
|
116
|
-
name: "
|
|
182
|
+
name: "create_pod",
|
|
117
183
|
arguments: {
|
|
118
|
-
|
|
184
|
+
name: podName,
|
|
119
185
|
namespace: "default",
|
|
186
|
+
template: "busybox",
|
|
187
|
+
command: ["/bin/sh", "-c", "echo Pod is running && sleep infinity"],
|
|
120
188
|
},
|
|
121
189
|
},
|
|
122
|
-
},
|
|
123
|
-
expect(
|
|
124
|
-
const
|
|
125
|
-
expect(
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
190
|
+
}, CreatePodResponseSchema);
|
|
191
|
+
expect(createPodResult.content[0].type).toBe("text");
|
|
192
|
+
const podResult = JSON.parse(createPodResult.content[0].text);
|
|
193
|
+
expect(podResult.podName).toBe(podName);
|
|
194
|
+
// Step 2: Wait for Running state (up to 60 seconds)
|
|
195
|
+
let podRunning = false;
|
|
196
|
+
const startTime = Date.now();
|
|
197
|
+
while (!podRunning && Date.now() - startTime < 60000) {
|
|
198
|
+
const podStatus = await client.request({
|
|
199
|
+
method: "tools/call",
|
|
200
|
+
params: {
|
|
201
|
+
name: "describe_pod",
|
|
202
|
+
arguments: {
|
|
203
|
+
name: podName,
|
|
204
|
+
namespace: "default",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
}, ListPodsResponseSchema);
|
|
208
|
+
const status = JSON.parse(podStatus.content[0].text);
|
|
209
|
+
if (status.status?.phase === "Running") {
|
|
210
|
+
podRunning = true;
|
|
211
|
+
console.log(`Pod ${podName} is running. Checking logs...`);
|
|
212
|
+
// Check pod logs once running
|
|
213
|
+
const logsResult = await client.request({
|
|
214
|
+
method: "tools/call",
|
|
215
|
+
params: {
|
|
216
|
+
name: "get_logs",
|
|
217
|
+
arguments: {
|
|
218
|
+
resourceType: "pod",
|
|
219
|
+
name: podName,
|
|
220
|
+
namespace: "default",
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
}, ListPodsResponseSchema);
|
|
224
|
+
expect(logsResult.content[0].type).toBe("text");
|
|
225
|
+
const logs = JSON.parse(logsResult.content[0].text);
|
|
226
|
+
expect(logs.logs[podName]).toContain("Pod is running");
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
await sleep(1000);
|
|
230
|
+
}
|
|
231
|
+
expect(podRunning).toBe(true);
|
|
232
|
+
// Step 3: Terminate pod and verify termination (wait up to 10 seconds)
|
|
233
|
+
const deletePodResult = await client.request({
|
|
141
234
|
method: "tools/call",
|
|
142
235
|
params: {
|
|
143
|
-
name: "
|
|
236
|
+
name: "delete_pod",
|
|
144
237
|
arguments: {
|
|
145
|
-
|
|
238
|
+
name: podName,
|
|
146
239
|
namespace: "default",
|
|
147
240
|
},
|
|
148
241
|
},
|
|
149
|
-
},
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
242
|
+
}, DeletePodResponseSchema);
|
|
243
|
+
expect(deletePodResult.content[0].type).toBe("text");
|
|
244
|
+
const deleteResult = JSON.parse(deletePodResult.content[0].text);
|
|
245
|
+
expect(deleteResult.status).toBe("deleted");
|
|
246
|
+
// Try to verify pod termination, but don't fail the test if we can't confirm it
|
|
247
|
+
try {
|
|
248
|
+
let podTerminated = false;
|
|
249
|
+
const terminationStartTime = Date.now();
|
|
250
|
+
while (!podTerminated && Date.now() - terminationStartTime < 10000) {
|
|
251
|
+
try {
|
|
252
|
+
const podStatus = await client.request({
|
|
253
|
+
method: "tools/call",
|
|
254
|
+
params: {
|
|
255
|
+
name: "describe_pod",
|
|
256
|
+
arguments: {
|
|
257
|
+
name: podName,
|
|
258
|
+
namespace: "default",
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
}, ListPodsResponseSchema);
|
|
262
|
+
// Pod still exists, check if it's in Terminating state
|
|
263
|
+
const status = JSON.parse(podStatus.content[0].text);
|
|
264
|
+
if (status.status?.phase === "Terminating") {
|
|
265
|
+
podTerminated = true;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
await sleep(500);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
// If we get an error (404), the pod is gone which also means it's terminated
|
|
272
|
+
podTerminated = true;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Log termination status but don't fail the test
|
|
277
|
+
if (podTerminated) {
|
|
278
|
+
console.log(`Pod ${podName} termination confirmed`);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
console.log(`Pod ${podName} termination could not be confirmed within timeout, but deletion was initiated`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
// Ignore any errors during termination check
|
|
286
|
+
console.log(`Error checking pod termination status: ${error}`);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
159
289
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-server-kubernetes",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "MCP server for interacting with Kubernetes clusters via kubectl",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
"build": "tsc && shx chmod +x dist/*.js",
|
|
20
20
|
"dev": "tsc --watch",
|
|
21
21
|
"start": "node dist/index.js",
|
|
22
|
-
"test": "vitest
|
|
23
|
-
"prepublishOnly": "npm run build"
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"prepublishOnly": "npm run build",
|
|
24
|
+
"dockerbuild": "docker buildx build -t flux159/mcp-server-kubernetes --platform linux/amd64,linux/arm64 --push ."
|
|
24
25
|
},
|
|
25
26
|
"keywords": [
|
|
26
27
|
"mcp",
|