From 504ef5b73b6d499b5e2b743b28b549de3a16910b Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Wed, 30 Apr 2025 09:29:33 +0300 Subject: [PATCH] Add resource deletion capabilities as requested in issue #20 --- pkg/k8s/client.go | 16 ++ pkg/mcp/delete_resource.go | 89 ++++++++++ pkg/mcp/delete_resource_test.go | 294 ++++++++++++++++++++++++++++++++ pkg/mcp/server.go | 1 + 4 files changed, 400 insertions(+) create mode 100644 pkg/mcp/delete_resource.go create mode 100644 pkg/mcp/delete_resource_test.go diff --git a/pkg/k8s/client.go b/pkg/k8s/client.go index a78e01c..d427ab2 100644 --- a/pkg/k8s/client.go +++ b/pkg/k8s/client.go @@ -186,6 +186,22 @@ func (c *Client) ApplyNamespacedResource(ctx context.Context, gvr schema.GroupVe return c.dynamicClient.Resource(gvr).Namespace(namespace).Create(ctx, obj, metav1.CreateOptions{}) } +// DeleteClusteredResource deletes a clustered resource +func (c *Client) DeleteClusteredResource(ctx context.Context, gvr schema.GroupVersionResource, name string) error { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.dynamicClient.Resource(gvr).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// DeleteNamespacedResource deletes a namespaced resource +func (c *Client) DeleteNamespacedResource(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string) error { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.dynamicClient.Resource(gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + // SetDynamicClient sets the dynamic client (for testing purposes) func (c *Client) SetDynamicClient(dynamicClient dynamic.Interface) { c.mu.Lock() diff --git a/pkg/mcp/delete_resource.go b/pkg/mcp/delete_resource.go new file mode 100644 index 0000000..90d9f67 --- /dev/null +++ b/pkg/mcp/delete_resource.go @@ -0,0 +1,89 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/mark3labs/mcp-go/mcp" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// HandleDeleteResource handles the delete_resource tool +func (m *Implementation) HandleDeleteResource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Parse parameters + resourceType := mcp.ParseString(request, "resource_type", "") + group := mcp.ParseString(request, "group", "") + version := mcp.ParseString(request, "version", "") + resource := mcp.ParseString(request, "resource", "") + namespace := mcp.ParseString(request, "namespace", "") + name := mcp.ParseString(request, "name", "") + + // Validate parameters + if resourceType == "" { + return mcp.NewToolResultError("resource_type is required"), nil + } + if version == "" { + return mcp.NewToolResultError("version is required"), nil + } + if resource == "" { + return mcp.NewToolResultError("resource is required"), nil + } + if name == "" { + return mcp.NewToolResultError("name is required"), nil + } + if resourceType == "namespaced" && namespace == "" { + return mcp.NewToolResultError("namespace is required for namespaced resources"), nil + } + + // Create GVR + // Validate resource_type + if resourceType != "clustered" && resourceType != "namespaced" { + return mcp.NewToolResultError("Invalid resource_type: " + resourceType), nil + } + + gvr := schema.GroupVersionResource{ + Group: group, + Version: version, + Resource: resource, + } + + // Delete resource + var err error + switch resourceType { + case "clustered": + err = m.k8sClient.DeleteClusteredResource(ctx, gvr, name) + case "namespaced": + err = m.k8sClient.DeleteNamespacedResource(ctx, gvr, namespace, name) + default: + return mcp.NewToolResultError(fmt.Sprintf("Invalid resource_type: %s", resourceType)), nil + } + + if err != nil { + return mcp.NewToolResultErrorFromErr("Failed to delete resource", err), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Successfully deleted %s resource %s", resourceType, name)), nil +} + +// NewDeleteResourceTool creates a new delete_resource tool +func NewDeleteResourceTool() mcp.Tool { + return mcp.NewTool("delete_resource", + mcp.WithDescription("Delete a Kubernetes resource"), + mcp.WithString("resource_type", + mcp.Description("Type of resource to delete (clustered or namespaced)"), + mcp.Required()), + mcp.WithString("group", + mcp.Description("API group (e.g., apps, networking.k8s.io)")), + mcp.WithString("version", + mcp.Description("API version (e.g., v1, v1beta1)"), + mcp.Required()), + mcp.WithString("resource", + mcp.Description("Resource name (e.g., deployments, services)"), + mcp.Required()), + mcp.WithString("namespace", + mcp.Description("Namespace (required for namespaced resources)")), + mcp.WithString("name", + mcp.Description("Name of the resource to delete"), + mcp.Required()), + ) +} \ No newline at end of file diff --git a/pkg/mcp/delete_resource_test.go b/pkg/mcp/delete_resource_test.go new file mode 100644 index 0000000..b95d864 --- /dev/null +++ b/pkg/mcp/delete_resource_test.go @@ -0,0 +1,294 @@ +package mcp + +import ( + "context" + "fmt" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic/fake" + ktesting "k8s.io/client-go/testing" + + "github.com/StacklokLabs/mkp/pkg/k8s" +) + +func TestHandleDeleteResourceClusteredSuccess(t *testing.T) { + // Create a mock k8s client + mockClient := &k8s.Client{} + + // Create a fake dynamic client + scheme := runtime.NewScheme() + fakeDynamicClient := fake.NewSimpleDynamicClient(scheme) + + // Add a fake delete response + fakeDynamicClient.PrependReactor("delete", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, nil + }) + + // Set the dynamic client + mockClient.SetDynamicClient(fakeDynamicClient) + + // Create an implementation + impl := NewImplementation(mockClient) + + // Create a test request + request := mcp.CallToolRequest{} + request.Params.Name = "delete_resource" + request.Params.Arguments = map[string]interface{}{ + "resource_type": "clustered", + "group": "rbac.authorization.k8s.io", + "version": "v1", + "resource": "clusterroles", + "name": "test-cluster-role", + } + + // Test HandleDeleteResource + ctx := context.Background() + result, err := impl.HandleDeleteResource(ctx, request) + + // Verify there was no error + assert.NoError(t, err, "HandleDeleteResource should not return an error") + + // Verify the result is not nil + assert.NotNil(t, result, "Result should not be nil") + + // Verify the result is successful + assert.False(t, result.IsError, "Result should not be an error") + + // Verify the result contains the resource name + textContent, ok := mcp.AsTextContent(result.Content[0]) + assert.True(t, ok, "Content should be TextContent") + assert.Contains(t, textContent.Text, "test-cluster-role", "Result should contain the resource name") +} + +func TestHandleDeleteResourceNamespacedSuccess(t *testing.T) { + // Create a mock k8s client + mockClient := &k8s.Client{} + + // Create a fake dynamic client + scheme := runtime.NewScheme() + fakeDynamicClient := fake.NewSimpleDynamicClient(scheme) + + // Add a fake delete response + fakeDynamicClient.PrependReactor("delete", "services", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, nil + }) + + // Set the dynamic client + mockClient.SetDynamicClient(fakeDynamicClient) + + // Create an implementation + impl := NewImplementation(mockClient) + + // Create a test request + request := mcp.CallToolRequest{} + request.Params.Name = "delete_resource" + request.Params.Arguments = map[string]interface{}{ + "resource_type": "namespaced", + "group": "", + "version": "v1", + "resource": "services", + "namespace": "default", + "name": "test-service", + } + + // Test HandleDeleteResource + ctx := context.Background() + result, err := impl.HandleDeleteResource(ctx, request) + + // Verify there was no error + assert.NoError(t, err, "HandleDeleteResource should not return an error") + + // Verify the result is not nil + assert.NotNil(t, result, "Result should not be nil") + + // Verify the result is successful + assert.False(t, result.IsError, "Result should not be an error") + + // Verify the result contains the resource name + textContent, ok := mcp.AsTextContent(result.Content[0]) + assert.True(t, ok, "Content should be TextContent") + assert.Contains(t, textContent.Text, "test-service", "Result should contain the resource name") +} + +func TestHandleDeleteResourceMissingParameters(t *testing.T) { + // Create a mock k8s client + mockClient := &k8s.Client{} + + // Create an implementation + impl := NewImplementation(mockClient) + + // Test cases for missing parameters + testCases := []struct { + name string + arguments map[string]interface{} + errorMsg string + }{ + { + name: "Missing resource_type", + arguments: map[string]interface{}{ + "group": "apps", + "version": "v1", + "resource": "deployments", + "name": "test-deployment", + }, + errorMsg: "resource_type is required", + }, + { + name: "Missing version", + arguments: map[string]interface{}{ + "resource_type": "clustered", + "group": "apps", + "resource": "deployments", + "name": "test-deployment", + }, + errorMsg: "version is required", + }, + { + name: "Missing resource", + arguments: map[string]interface{}{ + "resource_type": "clustered", + "group": "apps", + "version": "v1", + "name": "test-deployment", + }, + errorMsg: "resource is required", + }, + { + name: "Missing name", + arguments: map[string]interface{}{ + "resource_type": "clustered", + "group": "apps", + "version": "v1", + "resource": "deployments", + }, + errorMsg: "name is required", + }, + { + name: "Missing namespace for namespaced resource", + arguments: map[string]interface{}{ + "resource_type": "namespaced", + "group": "apps", + "version": "v1", + "resource": "deployments", + "name": "test-deployment", + }, + errorMsg: "namespace is required for namespaced resources", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create a test request + request := mcp.CallToolRequest{} + request.Params.Name = "delete_resource" + request.Params.Arguments = tc.arguments + + // Test HandleDeleteResource + ctx := context.Background() + result, err := impl.HandleDeleteResource(ctx, request) + + // Verify there was no error + assert.NoError(t, err, "HandleDeleteResource should not return an error") + + // Verify the result is not nil + assert.NotNil(t, result, "Result should not be nil") + + // Verify the result is an error + assert.True(t, result.IsError, "Result should be an error") + + // Verify the error message + textContent, ok := mcp.AsTextContent(result.Content[0]) + assert.True(t, ok, "Content should be TextContent") + assert.Equal(t, tc.errorMsg, textContent.Text, "Error message should match") + }) + } +} + +func TestHandleDeleteResourceInvalidResourceType(t *testing.T) { + // Create a mock k8s client + mockClient := &k8s.Client{} + + // Create an implementation + impl := NewImplementation(mockClient) + + // Create a test request with invalid resource_type + request := mcp.CallToolRequest{} + request.Params.Name = "delete_resource" + request.Params.Arguments = map[string]interface{}{ + "resource_type": "invalid", + "group": "apps", + "version": "v1", + "resource": "deployments", + "name": "test-deployment", + } + + // Test HandleDeleteResource + ctx := context.Background() + result, err := impl.HandleDeleteResource(ctx, request) + + // Verify there was no error + assert.NoError(t, err, "HandleDeleteResource should not return an error") + + // Verify the result is not nil + assert.NotNil(t, result, "Result should not be nil") + + // Verify the result is an error + assert.True(t, result.IsError, "Result should be an error") + + // Verify the error message + textContent, ok := mcp.AsTextContent(result.Content[0]) + assert.True(t, ok, "Content should be TextContent") + assert.Equal(t, "Invalid resource_type: invalid", textContent.Text, "Error message should match") +} + +func TestHandleDeleteResourceDeleteError(t *testing.T) { + // Create a mock k8s client + mockClient := &k8s.Client{} + + // Create a fake dynamic client + scheme := runtime.NewScheme() + fakeDynamicClient := fake.NewSimpleDynamicClient(scheme) + + // Add a fake delete response with error + fakeDynamicClient.PrependReactor("delete", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, fmt.Errorf("failed to delete resource") + }) + + // Set the dynamic client + mockClient.SetDynamicClient(fakeDynamicClient) + + // Create an implementation + impl := NewImplementation(mockClient) + + // Create a test request + request := mcp.CallToolRequest{} + request.Params.Name = "delete_resource" + request.Params.Arguments = map[string]interface{}{ + "resource_type": "clustered", + "group": "rbac.authorization.k8s.io", + "version": "v1", + "resource": "clusterroles", + "name": "test-cluster-role", + } + + // Test HandleDeleteResource + ctx := context.Background() + result, err := impl.HandleDeleteResource(ctx, request) + + // Verify there was no error + assert.NoError(t, err, "HandleDeleteResource should not return an error") + + // Verify the result is not nil + assert.NotNil(t, result, "Result should not be nil") + + // Verify the result is an error + assert.True(t, result.IsError, "Result should be an error") + + // Verify the error message + textContent, ok := mcp.AsTextContent(result.Content[0]) + assert.True(t, ok, "Content should be TextContent") + assert.Contains(t, textContent.Text, "Failed to delete resource", "Error message should contain 'Failed to delete resource'") +} \ No newline at end of file diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index 71f89e0..5e80739 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -51,6 +51,7 @@ func CreateServer(k8sClient *k8s.Client, config *Config) *server.MCPServer { if config.ReadWrite { mcpServer.AddTool(NewApplyResourceTool(), impl.HandleApplyResource) + mcpServer.AddTool(NewDeleteResourceTool(), impl.HandleDeleteResource) } // Add resource templates