Skip to content

Commit 5be9852

Browse files
committed
feat(kubernetes): pods_run can get any resource in the cluster
1 parent a8bb7c0 commit 5be9852

File tree

5 files changed

+268
-2
lines changed

5 files changed

+268
-2
lines changed

pkg/kubernetes/pods.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@ package kubernetes
22

33
import (
44
"context"
5+
"github.com/manusa/kubernetes-mcp-server/pkg/version"
56
v1 "k8s.io/api/core/v1"
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
9+
"k8s.io/apimachinery/pkg/runtime"
610
"k8s.io/apimachinery/pkg/runtime/schema"
11+
"k8s.io/apimachinery/pkg/util/intstr"
12+
"k8s.io/apimachinery/pkg/util/rand"
713
"k8s.io/client-go/kubernetes"
814
)
915

@@ -44,3 +50,56 @@ func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name string) (strin
4450
}
4551
return string(rawData), nil
4652
}
53+
54+
func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string, port int32) (string, error) {
55+
if name == "" {
56+
name = version.BinaryName + "-run-" + rand.String(5)
57+
}
58+
labels := map[string]string{
59+
AppKubernetesName: name,
60+
AppKubernetesComponent: name,
61+
AppKubernetesManagedBy: version.BinaryName,
62+
AppKubernetesPartOf: version.BinaryName + "-run-sandbox",
63+
}
64+
// NewPod
65+
var resources []any
66+
pod := &v1.Pod{
67+
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"},
68+
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespaceOrDefault(namespace), Labels: labels},
69+
Spec: v1.PodSpec{Containers: []v1.Container{{
70+
Name: name,
71+
Image: image,
72+
ImagePullPolicy: v1.PullAlways,
73+
}}},
74+
}
75+
resources = append(resources, pod)
76+
if port > 0 {
77+
pod.Spec.Containers[0].Ports = []v1.ContainerPort{{ContainerPort: port}}
78+
svc := &v1.Service{
79+
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"},
80+
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespaceOrDefault(namespace), Labels: labels},
81+
Spec: v1.ServiceSpec{
82+
Selector: labels,
83+
Type: v1.ServiceTypeClusterIP,
84+
Ports: []v1.ServicePort{{Port: port, TargetPort: intstr.FromInt32(port)}},
85+
},
86+
}
87+
resources = append(resources, svc)
88+
}
89+
90+
// Convert the objects to Unstructured and reuse resourcesCreateOrUpdate functionality
91+
converter := runtime.DefaultUnstructuredConverter
92+
var toCreate []*unstructured.Unstructured
93+
for _, obj := range resources {
94+
m, err := converter.ToUnstructured(obj)
95+
if err != nil {
96+
return "", err
97+
}
98+
u := &unstructured.Unstructured{}
99+
if err = converter.FromUnstructured(m, u); err != nil {
100+
return "", err
101+
}
102+
toCreate = append(toCreate, u)
103+
}
104+
return k.resourcesCreateOrUpdate(ctx, toCreate)
105+
}

pkg/mcp/mcp_test.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,18 @@ import (
66
)
77

88
func TestTools(t *testing.T) {
9-
expectedNames := []string{"pods_list", "pods_list_in_namespace", "configuration_view"}
9+
expectedNames := []string{
10+
"configuration_view",
11+
"pods_list",
12+
"pods_list_in_namespace",
13+
"pods_get",
14+
"pods_log",
15+
"pods_run",
16+
"resources_list",
17+
"resources_get",
18+
"resources_create_or_update",
19+
"resources_delete",
20+
}
1021
testCase(t, func(c *mcpContext) {
1122
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
1223
t.Run("ListTools returns tools", func(t *testing.T) {

pkg/mcp/pods.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,23 @@ func (s *Sever) initPods() {
4343
mcp.Required(),
4444
),
4545
), podsLog)
46+
s.server.AddTool(mcp.NewTool(
47+
"pods_run",
48+
mcp.WithDescription("Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name"),
49+
mcp.WithString("namespace",
50+
mcp.Description("Namespace to run the Pod in"),
51+
),
52+
mcp.WithString("name",
53+
mcp.Description("Name of the Pod (Optional, random name if not provided)"),
54+
),
55+
mcp.WithString("image",
56+
mcp.Description("Container Image to run in the Pod"),
57+
mcp.Required(),
58+
),
59+
mcp.WithNumber("port",
60+
mcp.Description("TCP/IP port to expose from the Pod container (Optional, no port exposed if not provided)"),
61+
),
62+
), podsRun)
4663
}
4764

4865
func podsListInAllNamespaces(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -112,3 +129,31 @@ func podsLog(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult,
112129
}
113130
return NewTextResult(ret, err), nil
114131
}
132+
133+
func podsRun(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
134+
k, err := kubernetes.NewKubernetes()
135+
if err != nil {
136+
return NewTextResult("", fmt.Errorf("failed to run pod: %v", err)), nil
137+
}
138+
ns := ctr.Params.Arguments["namespace"]
139+
if ns == nil {
140+
ns = ""
141+
}
142+
name := ctr.Params.Arguments["name"]
143+
if name == nil {
144+
name = ""
145+
}
146+
image := ctr.Params.Arguments["image"]
147+
if image == nil {
148+
return NewTextResult("", errors.New("failed to run pod, missing argument image")), nil
149+
}
150+
port := ctr.Params.Arguments["port"]
151+
if port == nil {
152+
port = float64(0)
153+
}
154+
ret, err := k.PodsRun(ctx, ns.(string), name.(string), image.(string), int32(port.(float64)))
155+
if err != nil {
156+
return NewTextResult("", fmt.Errorf("failed to get pod %s log in namespace %s: %v", name, ns, err)), nil
157+
}
158+
return NewTextResult(ret, err), nil
159+
}

pkg/mcp/pods_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package mcp
33
import (
44
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
55
"sigs.k8s.io/yaml"
6+
"strings"
67
"testing"
78
)
89

@@ -253,3 +254,153 @@ func TestPodsLog(t *testing.T) {
253254
})
254255
})
255256
}
257+
258+
func TestPodsRun(t *testing.T) {
259+
testCase(t, func(c *mcpContext) {
260+
c.withEnvTest()
261+
t.Run("pods_run with nil image returns error", func(t *testing.T) {
262+
toolResult, _ := c.callTool("pods_run", map[string]interface{}{})
263+
if toolResult.IsError != true {
264+
t.Errorf("call tool should fail")
265+
return
266+
}
267+
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to run pod, missing argument image" {
268+
t.Errorf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
269+
return
270+
}
271+
})
272+
podsRunNilNamespace, err := c.callTool("pods_run", map[string]interface{}{"image": "nginx"})
273+
t.Run("pods_run with image and nil namespace runs pod", func(t *testing.T) {
274+
if err != nil {
275+
t.Errorf("call tool failed %v", err)
276+
return
277+
}
278+
if podsRunNilNamespace.IsError {
279+
t.Errorf("call tool failed")
280+
return
281+
}
282+
})
283+
var decodedNilNamespace []unstructured.Unstructured
284+
err = yaml.Unmarshal([]byte(podsRunNilNamespace.Content[0].(map[string]interface{})["text"].(string)), &decodedNilNamespace)
285+
t.Run("pods_run with image and nil namespace has yaml content", func(t *testing.T) {
286+
if err != nil {
287+
t.Errorf("invalid tool result content %v", err)
288+
return
289+
}
290+
})
291+
t.Run("pods_run with image and nil namespace returns 1 item (Pod)", func(t *testing.T) {
292+
if len(decodedNilNamespace) != 1 {
293+
t.Errorf("invalid pods count, expected 1, got %v", len(decodedNilNamespace))
294+
return
295+
}
296+
if decodedNilNamespace[0].GetKind() != "Pod" {
297+
t.Errorf("invalid pod kind, expected Pod, got %v", decodedNilNamespace[0].GetKind())
298+
return
299+
}
300+
})
301+
t.Run("pods_run with image and nil namespace returns pod in default", func(t *testing.T) {
302+
if decodedNilNamespace[0].GetNamespace() != "default" {
303+
t.Errorf("invalid pod namespace, expected default, got %v", decodedNilNamespace[0].GetNamespace())
304+
return
305+
}
306+
})
307+
t.Run("pods_run with image and nil namespace returns pod with random name", func(t *testing.T) {
308+
if !strings.HasPrefix(decodedNilNamespace[0].GetName(), "kubernetes-mcp-server-run-") {
309+
t.Errorf("invalid pod name, expected random, got %v", decodedNilNamespace[0].GetName())
310+
return
311+
}
312+
})
313+
t.Run("pods_run with image and nil namespace returns pod with labels", func(t *testing.T) {
314+
labels := decodedNilNamespace[0].Object["metadata"].(map[string]interface{})["labels"].(map[string]interface{})
315+
if labels["app.kubernetes.io/name"] == "" {
316+
t.Errorf("invalid labels, expected app.kubernetes.io/name, got %v", labels)
317+
return
318+
}
319+
if labels["app.kubernetes.io/component"] == "" {
320+
t.Errorf("invalid labels, expected app.kubernetes.io/component, got %v", labels)
321+
return
322+
}
323+
if labels["app.kubernetes.io/managed-by"] != "kubernetes-mcp-server" {
324+
t.Errorf("invalid labels, expected app.kubernetes.io/managed-by, got %v", labels)
325+
return
326+
}
327+
if labels["app.kubernetes.io/part-of"] != "kubernetes-mcp-server-run-sandbox" {
328+
t.Errorf("invalid labels, expected app.kubernetes.io/part-of, got %v", labels)
329+
return
330+
}
331+
})
332+
t.Run("pods_run with image and nil namespace returns pod with nginx container", func(t *testing.T) {
333+
containers := decodedNilNamespace[0].Object["spec"].(map[string]interface{})["containers"].([]interface{})
334+
if containers[0].(map[string]interface{})["image"] != "nginx" {
335+
t.Errorf("invalid container name, expected nginx, got %v", containers[0].(map[string]interface{})["image"])
336+
return
337+
}
338+
})
339+
340+
podsRunNamespaceAndPort, err := c.callTool("pods_run", map[string]interface{}{"image": "nginx", "port": 80})
341+
t.Run("pods_run with image, namespace, and port runs pod", func(t *testing.T) {
342+
if err != nil {
343+
t.Errorf("call tool failed %v", err)
344+
return
345+
}
346+
if podsRunNamespaceAndPort.IsError {
347+
t.Errorf("call tool failed")
348+
return
349+
}
350+
})
351+
var decodedNamespaceAndPort []unstructured.Unstructured
352+
err = yaml.Unmarshal([]byte(podsRunNamespaceAndPort.Content[0].(map[string]interface{})["text"].(string)), &decodedNamespaceAndPort)
353+
t.Run("pods_run with image, namespace, and port has yaml content", func(t *testing.T) {
354+
if err != nil {
355+
t.Errorf("invalid tool result content %v", err)
356+
return
357+
}
358+
})
359+
t.Run("pods_run with image, namespace, and port returns 2 items (Pod + Service)", func(t *testing.T) {
360+
if len(decodedNamespaceAndPort) != 2 {
361+
t.Errorf("invalid pods count, expected 2, got %v", len(decodedNamespaceAndPort))
362+
return
363+
}
364+
if decodedNamespaceAndPort[0].GetKind() != "Pod" {
365+
t.Errorf("invalid pod kind, expected Pod, got %v", decodedNamespaceAndPort[0].GetKind())
366+
return
367+
}
368+
if decodedNamespaceAndPort[1].GetKind() != "Service" {
369+
t.Errorf("invalid service kind, expected Service, got %v", decodedNamespaceAndPort[1].GetKind())
370+
return
371+
}
372+
})
373+
t.Run("pods_run with image, namespace, and port returns pod with port", func(t *testing.T) {
374+
containers := decodedNamespaceAndPort[0].Object["spec"].(map[string]interface{})["containers"].([]interface{})
375+
ports := containers[0].(map[string]interface{})["ports"].([]interface{})
376+
if ports[0].(map[string]interface{})["containerPort"] != int64(80) {
377+
t.Errorf("invalid container port, expected 80, got %v", ports[0].(map[string]interface{})["containerPort"])
378+
return
379+
}
380+
})
381+
t.Run("pods_run with image, namespace, and port returns service with port and selector", func(t *testing.T) {
382+
ports := decodedNamespaceAndPort[1].Object["spec"].(map[string]interface{})["ports"].([]interface{})
383+
if ports[0].(map[string]interface{})["port"] != int64(80) {
384+
t.Errorf("invalid service port, expected 80, got %v", ports[0].(map[string]interface{})["port"])
385+
return
386+
}
387+
if ports[0].(map[string]interface{})["targetPort"] != int64(80) {
388+
t.Errorf("invalid service target port, expected 80, got %v", ports[0].(map[string]interface{})["targetPort"])
389+
return
390+
}
391+
selector := decodedNamespaceAndPort[1].Object["spec"].(map[string]interface{})["selector"].(map[string]interface{})
392+
if selector["app.kubernetes.io/name"] == "" {
393+
t.Errorf("invalid service selector, expected app.kubernetes.io/name, got %v", selector)
394+
return
395+
}
396+
if selector["app.kubernetes.io/managed-by"] != "kubernetes-mcp-server" {
397+
t.Errorf("invalid service selector, expected app.kubernetes.io/managed-by, got %v", selector)
398+
return
399+
}
400+
if selector["app.kubernetes.io/part-of"] != "kubernetes-mcp-server-run-sandbox" {
401+
t.Errorf("invalid service selector, expected app.kubernetes.io/part-of, got %v", selector)
402+
return
403+
}
404+
})
405+
})
406+
}

pkg/mcp/resources_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ func TestResourcesCreateOrUpdate(t *testing.T) {
277277
return
278278
}
279279
if resourcesCreateOrUpdateCustom.IsError {
280-
t.Fatalf("call tool failed")
280+
t.Fatalf("call tool failed, got: %v", resourcesCreateOrUpdateCustom.Content)
281281
return
282282
}
283283
})

0 commit comments

Comments
 (0)