Skip to content

Commit a8bb7c0

Browse files
committed
feat(kubernetes): resources_delete can get any resource in the cluster
1 parent 3ea23f3 commit a8bb7c0

File tree

4 files changed

+173
-0
lines changed

4 files changed

+173
-0
lines changed

pkg/kubernetes/resources.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK
4747
if err != nil {
4848
return "", err
4949
}
50+
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
51+
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
52+
namespace = namespaceOrDefault(namespace)
53+
}
5054
rg, err := client.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
5155
if err != nil {
5256
return "", err
@@ -68,6 +72,22 @@ func (k *Kubernetes) ResourcesCreateOrUpdate(ctx context.Context, resource strin
6872
return k.resourcesCreateOrUpdate(ctx, parsedResources)
6973
}
7074

75+
func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) error {
76+
client, err := dynamic.NewForConfig(k.cfg)
77+
if err != nil {
78+
return err
79+
}
80+
gvr, err := k.resourceFor(gvk)
81+
if err != nil {
82+
return err
83+
}
84+
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
85+
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
86+
namespace = namespaceOrDefault(namespace)
87+
}
88+
return client.Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
89+
}
90+
7191
func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*unstructured.Unstructured) (string, error) {
7292
client, err := dynamic.NewForConfig(k.cfg)
7393
if err != nil {

pkg/mcp/common_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ func createTestData(ctx context.Context, kc *kubernetes.Clientset) {
163163
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-1"}}, metav1.CreateOptions{})
164164
_, _ = kc.CoreV1().Namespaces().
165165
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-2"}}, metav1.CreateOptions{})
166+
_, _ = kc.CoreV1().Namespaces().
167+
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-to-delete"}}, metav1.CreateOptions{})
166168
_, _ = kc.CoreV1().Pods("default").
167169
Create(ctx, &corev1.Pod{
168170
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-default"},
@@ -190,4 +192,6 @@ func createTestData(ctx context.Context, kc *kubernetes.Clientset) {
190192
},
191193
},
192194
}, metav1.CreateOptions{})
195+
_, _ = kc.CoreV1().ConfigMaps("default").
196+
Create(ctx, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "a-configmap-to-delete"}}, metav1.CreateOptions{})
193197
}

pkg/mcp/resources.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,25 @@ func (s *Sever) initResources() {
5252
mcp.Required(),
5353
),
5454
), resourcesCreateOrUpdate)
55+
s.server.AddTool(mcp.NewTool(
56+
"resources_delete",
57+
mcp.WithDescription("Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name"),
58+
mcp.WithString("apiVersion",
59+
mcp.Description("apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)"),
60+
mcp.Required(),
61+
),
62+
mcp.WithString("kind",
63+
mcp.Description("kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)"),
64+
mcp.Required(),
65+
),
66+
mcp.WithString("namespace",
67+
mcp.Description("Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace"),
68+
),
69+
mcp.WithString("name",
70+
mcp.Description("Name of the resource"),
71+
mcp.Required(),
72+
),
73+
), resourcesDelete)
5574
}
5675

5776
func resourcesList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -114,6 +133,30 @@ func resourcesCreateOrUpdate(ctx context.Context, ctr mcp.CallToolRequest) (*mcp
114133
return NewTextResult(ret, err), nil
115134
}
116135

136+
func resourcesDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
137+
k, err := kubernetes.NewKubernetes()
138+
if err != nil {
139+
return NewTextResult("", fmt.Errorf("failed to delete resource: %v", err)), nil
140+
}
141+
namespace := ctr.Params.Arguments["namespace"]
142+
if namespace == nil {
143+
namespace = ""
144+
}
145+
gvk, err := parseGroupVersionKind(ctr.Params.Arguments)
146+
if err != nil {
147+
return NewTextResult("", fmt.Errorf("failed to delete resource, %s", err)), nil
148+
}
149+
name := ctr.Params.Arguments["name"]
150+
if name == nil {
151+
return NewTextResult("", errors.New("failed to delete resource, missing argument name")), nil
152+
}
153+
err = k.ResourcesDelete(ctx, gvk, namespace.(string), name.(string))
154+
if err != nil {
155+
return NewTextResult("", fmt.Errorf("failed to delete resource: %v", err)), nil
156+
}
157+
return NewTextResult("Resource deleted successfully", err), nil
158+
}
159+
117160
func parseGroupVersionKind(arguments map[string]interface{}) (*schema.GroupVersionKind, error) {
118161
apiVersion := arguments["apiVersion"]
119162
if apiVersion == nil {

pkg/mcp/resources_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,3 +322,109 @@ func TestResourcesCreateOrUpdate(t *testing.T) {
322322
})
323323
})
324324
}
325+
326+
func TestResourcesDelete(t *testing.T) {
327+
testCase(t, func(c *mcpContext) {
328+
c.withEnvTest()
329+
t.Run("resources_delete with missing apiVersion returns error", func(t *testing.T) {
330+
toolResult, _ := c.callTool("resources_delete", map[string]interface{}{})
331+
if !toolResult.IsError {
332+
t.Fatalf("call tool should fail")
333+
return
334+
}
335+
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to delete resource, missing argument apiVersion" {
336+
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
337+
return
338+
}
339+
})
340+
t.Run("resources_delete with missing kind returns error", func(t *testing.T) {
341+
toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1"})
342+
if !toolResult.IsError {
343+
t.Fatalf("call tool should fail")
344+
return
345+
}
346+
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to delete resource, missing argument kind" {
347+
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
348+
return
349+
}
350+
})
351+
t.Run("resources_delete with invalid apiVersion returns error", func(t *testing.T) {
352+
toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod", "name": "a-pod"})
353+
if !toolResult.IsError {
354+
t.Fatalf("call tool should fail")
355+
return
356+
}
357+
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to delete resource, invalid argument apiVersion" {
358+
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
359+
return
360+
}
361+
})
362+
t.Run("resources_delete with nonexistent apiVersion returns error", func(t *testing.T) {
363+
toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom", "name": "a-custom"})
364+
if !toolResult.IsError {
365+
t.Fatalf("call tool should fail")
366+
return
367+
}
368+
if toolResult.Content[0].(map[string]interface{})["text"].(string) != `failed to delete resource: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` {
369+
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
370+
return
371+
}
372+
})
373+
t.Run("resources_delete with missing name returns error", func(t *testing.T) {
374+
toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"})
375+
if !toolResult.IsError {
376+
t.Fatalf("call tool should fail")
377+
return
378+
}
379+
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to delete resource, missing argument name" {
380+
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
381+
return
382+
}
383+
})
384+
resourcesDeleteCm, err := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "a-configmap-to-delete"})
385+
t.Run("resources_delete with valid namespaced resource returns success", func(t *testing.T) {
386+
if err != nil {
387+
t.Fatalf("call tool failed %v", err)
388+
return
389+
}
390+
if resourcesDeleteCm.IsError {
391+
t.Fatalf("call tool failed")
392+
return
393+
}
394+
if resourcesDeleteCm.Content[0].(map[string]interface{})["text"].(string) != "Resource deleted successfully" {
395+
t.Fatalf("invalid tool result content got: %v", resourcesDeleteCm.Content[0].(map[string]interface{})["text"].(string))
396+
return
397+
}
398+
})
399+
client := c.newKubernetesClient()
400+
t.Run("resources_delete with valid namespaced resource deletes ConfigMap", func(t *testing.T) {
401+
_, err := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-configmap-to-delete", metav1.GetOptions{})
402+
if err == nil {
403+
t.Fatalf("ConfigMap not deleted")
404+
return
405+
}
406+
})
407+
resourcesDeleteNamespace, err := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "ns-to-delete"})
408+
t.Run("resources_delete with valid namespaced resource returns success", func(t *testing.T) {
409+
if err != nil {
410+
t.Fatalf("call tool failed %v", err)
411+
return
412+
}
413+
if resourcesDeleteNamespace.IsError {
414+
t.Fatalf("call tool failed")
415+
return
416+
}
417+
if resourcesDeleteNamespace.Content[0].(map[string]interface{})["text"].(string) != "Resource deleted successfully" {
418+
t.Fatalf("invalid tool result content got: %v", resourcesDeleteNamespace.Content[0].(map[string]interface{})["text"].(string))
419+
return
420+
}
421+
})
422+
t.Run("resources_delete with valid namespaced resource deletes Namespace", func(t *testing.T) {
423+
ns, err := client.CoreV1().Namespaces().Get(c.ctx, "ns-to-delete", metav1.GetOptions{})
424+
if err == nil && ns != nil && ns.ObjectMeta.DeletionTimestamp == nil {
425+
t.Fatalf("Namespace not deleted")
426+
return
427+
}
428+
})
429+
})
430+
}

0 commit comments

Comments
 (0)