From 0ee22da3a22d4f35532d2f098e54ecd3f7b8a7bc Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Thu, 28 Aug 2025 15:12:17 +0300 Subject: [PATCH] feat: Add configurable annotation filtering to list_resources - Add include_annotations parameter to control annotation inclusion (default: true) - Add exclude_annotation_keys parameter with wildcard support (e.g., nvidia.com/*) - Add include_annotation_keys parameter for exclusive annotation inclusion - Implement filterAnnotations function with pattern matching - Default excludes kubectl.kubernetes.io/last-applied-configuration - Add comprehensive unit tests for all filtering scenarios - Update README.md with detailed documentation and examples Resolves issue with metadata output truncation in large clusters, particularly those with GPU nodes containing extensive NVIDIA annotations. Fixes #89 Signed-off-by: Juan Antonio Osorio --- README.md | 68 +++++++- pkg/mcp/helpers.go | 54 +++++++ pkg/mcp/list_resources.go | 17 +- pkg/mcp/list_resources_test.go | 278 +++++++++++++++++++++++++++++++++ pkg/mcp/tools.go | 8 + 5 files changed, 420 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6cb2b70..064d5ff 100644 --- a/README.md +++ b/README.md @@ -199,8 +199,16 @@ Parameters: - `version` (required): API version (e.g., v1, v1beta1) - `resource` (required): Resource name (e.g., deployments, services) - `namespace`: Namespace (required for namespaced resources) +- `label_selector`: Kubernetes label selector for filtering resources (optional) +- `include_annotations`: Whether to include annotations in the output (default: true) +- `exclude_annotation_keys`: List of annotation keys to exclude from output (supports wildcards with *) +- `include_annotation_keys`: List of annotation keys to include in output (if specified, only these are included) -Example: +##### Annotation Filtering + +The `list_resources` tool provides powerful annotation filtering capabilities to control metadata output size and prevent truncation issues with large annotations (such as GPU node annotations). + +**Basic Usage:** ```json { @@ -215,6 +223,64 @@ Example: } ``` +**Exclude specific annotations (useful for GPU nodes):** + +```json +{ + "name": "list_resources", + "arguments": { + "resource_type": "clustered", + "group": "", + "version": "v1", + "resource": "nodes", + "exclude_annotation_keys": [ + "nvidia.com/*", + "kubectl.kubernetes.io/last-applied-configuration" + ] + } +} +``` + +**Include only specific annotations:** + +```json +{ + "name": "list_resources", + "arguments": { + "resource_type": "namespaced", + "group": "", + "version": "v1", + "resource": "pods", + "namespace": "default", + "include_annotation_keys": ["app", "version", "prometheus.io/scrape"] + } +} +``` + +**Disable annotations completely for maximum performance:** + +```json +{ + "name": "list_resources", + "arguments": { + "resource_type": "namespaced", + "group": "", + "version": "v1", + "resource": "pods", + "namespace": "default", + "include_annotations": false + } +} +``` + +**Annotation Filtering Rules:** + +- By default, `kubectl.kubernetes.io/last-applied-configuration` is excluded to prevent large configuration data +- `exclude_annotation_keys` supports wildcard patterns using `*` (e.g., `nvidia.com/*` excludes all NVIDIA annotations) +- When `include_annotation_keys` is specified, it takes precedence and only those annotations are included +- Setting `include_annotations: false` completely removes all annotations from the output +- Wildcard patterns only support `*` at the end of the key (e.g., `nvidia.com/*`) + #### apply_resource Applies (creates or updates) a Kubernetes resource. diff --git a/pkg/mcp/helpers.go b/pkg/mcp/helpers.go index 28251b2..0763758 100644 --- a/pkg/mcp/helpers.go +++ b/pkg/mcp/helpers.go @@ -1,6 +1,60 @@ package mcp +import ( + "strings" +) + // BoolPtr returns a pointer to the given bool value func BoolPtr(b bool) *bool { return &b } + +// filterAnnotations filters annotations based on include/exclude lists +func filterAnnotations(annotations map[string]string, includeKeys, excludeKeys []string) map[string]string { + if annotations == nil { + return nil + } + + result := make(map[string]string) + + // If includeKeys is specified, only include those keys + if len(includeKeys) > 0 { + for _, key := range includeKeys { + if value, exists := annotations[key]; exists { + result[key] = value + } + } + return result + } + + // Otherwise, include all except excluded keys + for key, value := range annotations { + excluded := false + for _, excludeKey := range excludeKeys { + if matchesPattern(key, excludeKey) { + excluded = true + break + } + } + if !excluded { + result[key] = value + } + } + + return result +} + +// matchesPattern checks if a key matches a pattern (supports wildcards with *) +func matchesPattern(key, pattern string) bool { + if pattern == key { + return true + } + + // Simple wildcard matching - only supports * at the end + if strings.HasSuffix(pattern, "*") { + prefix := strings.TrimSuffix(pattern, "*") + return strings.HasPrefix(key, prefix) + } + + return false +} diff --git a/pkg/mcp/list_resources.go b/pkg/mcp/list_resources.go index 1c2b70f..65f7cdf 100644 --- a/pkg/mcp/list_resources.go +++ b/pkg/mcp/list_resources.go @@ -33,6 +33,12 @@ func (m *Implementation) HandleListResources(ctx context.Context, request mcp.Ca namespace := mcp.ParseString(request, "namespace", "") labelSelector := mcp.ParseString(request, "label_selector", "") + // Parse new annotation filtering parameters + includeAnnotations := request.GetBool("include_annotations", true) + excludeAnnotationKeys := request.GetStringSlice( + "exclude_annotation_keys", []string{"kubectl.kubernetes.io/last-applied-configuration"}) + includeAnnotationKeys := request.GetStringSlice("include_annotation_keys", []string{}) + // Validate parameters if resourceType == "" { return mcp.NewToolResultError("resource_type is required"), nil @@ -90,10 +96,13 @@ func (m *Implementation) HandleListResources(ctx context.Context, request mcp.Ca // Extract metadata from each resource for _, item := range list.Items { - // Get annotations and filter out the last-applied-configuration annotation - annotations := item.GetAnnotations() - if annotations != nil { - delete(annotations, "kubectl.kubernetes.io/last-applied-configuration") + // Process annotations based on parameters + var annotations map[string]string + if includeAnnotations { + rawAnnotations := item.GetAnnotations() + if rawAnnotations != nil { + annotations = filterAnnotations(rawAnnotations, includeAnnotationKeys, excludeAnnotationKeys) + } } metadata := metav1.PartialObjectMetadata{ diff --git a/pkg/mcp/list_resources_test.go b/pkg/mcp/list_resources_test.go index baa9f99..a6d90b0 100644 --- a/pkg/mcp/list_resources_test.go +++ b/pkg/mcp/list_resources_test.go @@ -180,6 +180,284 @@ func TestHandleListResourcesNamespacedSuccess(t *testing.T) { assert.NotContains(t, textContent.Text, "spec", "Result should not contain the spec field") } +func TestFilterAnnotations(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + includeKeys []string + excludeKeys []string + expected map[string]string + }{ + { + name: "exclude specific keys", + annotations: map[string]string{ + "kubectl.kubernetes.io/last-applied-configuration": "large-config", + "nvidia.com/gpu.present": "true", + "app": "test", + }, + includeKeys: []string{}, + excludeKeys: []string{"kubectl.kubernetes.io/last-applied-configuration", "nvidia.com/gpu.present"}, + expected: map[string]string{ + "app": "test", + }, + }, + { + name: "exclude with wildcard", + annotations: map[string]string{ + "nvidia.com/gpu.present": "true", + "nvidia.com/gpu.count": "2", + "app": "test", + "version": "1.0", + }, + includeKeys: []string{}, + excludeKeys: []string{"nvidia.com/*"}, + expected: map[string]string{ + "app": "test", + "version": "1.0", + }, + }, + { + name: "include specific keys only", + annotations: map[string]string{ + "kubectl.kubernetes.io/last-applied-configuration": "large-config", + "nvidia.com/gpu.present": "true", + "app": "test", + "version": "1.0", + }, + includeKeys: []string{"app", "version"}, + excludeKeys: []string{}, + expected: map[string]string{ + "app": "test", + "version": "1.0", + }, + }, + { + name: "nil annotations", + annotations: nil, + includeKeys: []string{}, + excludeKeys: []string{"test"}, + expected: nil, + }, + { + name: "empty annotations", + annotations: map[string]string{}, + includeKeys: []string{}, + excludeKeys: []string{"test"}, + expected: map[string]string{}, + }, + { + name: "include keys takes precedence over exclude", + annotations: map[string]string{ + "app": "test", + "version": "1.0", + "debug": "true", + }, + includeKeys: []string{"app"}, + excludeKeys: []string{"app"}, // This should be ignored since includeKeys is specified + expected: map[string]string{ + "app": "test", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := filterAnnotations(tt.annotations, tt.includeKeys, tt.excludeKeys) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMatchesPattern(t *testing.T) { + tests := []struct { + name string + key string + pattern string + expected bool + }{ + { + name: "exact match", + key: "app", + pattern: "app", + expected: true, + }, + { + name: "no match", + key: "app", + pattern: "version", + expected: false, + }, + { + name: "wildcard match", + key: "nvidia.com/gpu.present", + pattern: "nvidia.com/*", + expected: true, + }, + { + name: "wildcard no match", + key: "app", + pattern: "nvidia.com/*", + expected: false, + }, + { + name: "wildcard partial match", + key: "nvidia.com.gpu.present", + pattern: "nvidia.com/*", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matchesPattern(tt.key, tt.pattern) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHandleListResourcesWithAnnotationFiltering(t *testing.T) { + // Create a mock k8s client + mockClient := &k8s.Client{} + + // Create a fake dynamic client + scheme := runtime.NewScheme() + + // Register list kinds for the resources we'll be testing + listKinds := map[schema.GroupVersionResource]string{ + {Group: "", Version: "v1", Resource: "pods"}: "PodList", + } + + fakeDynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds) + + // Add a fake list response with annotations + fakeDynamicClient.PrependReactor("list", "pods", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { + list := &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod", + "namespace": "default", + "annotations": map[string]interface{}{ + "kubectl.kubernetes.io/last-applied-configuration": "large-config-data", + "nvidia.com/gpu.present": "true", + "nvidia.com/gpu.count": "2", + "app": "test-app", + "version": "1.0", + }, + }, + }, + }, + }, + } + return true, list, nil + }) + + // Set the dynamic client + mockClient.SetDynamicClient(fakeDynamicClient) + + // Create an implementation + impl := NewImplementation(mockClient) + + tests := []struct { + name string + includeAnnotations interface{} + excludeAnnotationKeys interface{} + includeAnnotationKeys interface{} + shouldContain []string + shouldNotContain []string + }{ + { + name: "default behavior - exclude last-applied-configuration", + includeAnnotations: nil, // defaults to true + excludeAnnotationKeys: nil, // defaults to ["kubectl.kubernetes.io/last-applied-configuration"] + includeAnnotationKeys: nil, + shouldContain: []string{"nvidia.com/gpu.present", "app", "version"}, + shouldNotContain: []string{"kubectl.kubernetes.io/last-applied-configuration"}, + }, + { + name: "exclude nvidia annotations", + includeAnnotations: true, + excludeAnnotationKeys: []interface{}{"nvidia.com/*", "kubectl.kubernetes.io/last-applied-configuration"}, + includeAnnotationKeys: nil, + shouldContain: []string{"app", "version"}, + shouldNotContain: []string{"nvidia.com/gpu.present", "nvidia.com/gpu.count", "kubectl.kubernetes.io/last-applied-configuration"}, + }, + { + name: "include only specific annotations", + includeAnnotations: true, + excludeAnnotationKeys: nil, + includeAnnotationKeys: []interface{}{"app", "version"}, + shouldContain: []string{"app", "version"}, + shouldNotContain: []string{"nvidia.com/gpu.present", "kubectl.kubernetes.io/last-applied-configuration"}, + }, + { + name: "disable annotations completely", + includeAnnotations: false, + excludeAnnotationKeys: nil, + includeAnnotationKeys: nil, + shouldContain: []string{}, + shouldNotContain: []string{"app", "version", "nvidia.com/gpu.present"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a test request + arguments := map[string]interface{}{ + "resource_type": types.ResourceTypeNamespaced, + "group": "", + "version": "v1", + "resource": "pods", + "namespace": "default", + } + + // Add annotation filtering parameters + if tt.includeAnnotations != nil { + arguments["include_annotations"] = tt.includeAnnotations + } + if tt.excludeAnnotationKeys != nil { + arguments["exclude_annotation_keys"] = tt.excludeAnnotationKeys + } + if tt.includeAnnotationKeys != nil { + arguments["include_annotation_keys"] = tt.includeAnnotationKeys + } + + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: types.ListResourcesToolName, + Arguments: arguments, + }, + } + + // Test HandleListResources + ctx := context.Background() + result, err := impl.HandleListResources(ctx, request) + + // Verify there was no error + assert.NoError(t, err, "HandleListResources should not return an error") + assert.NotNil(t, result, "Result should not be nil") + assert.False(t, result.IsError, "Result should not be an error") + + // Get the result content + textContent, ok := mcp.AsTextContent(result.Content[0]) + assert.True(t, ok, "Content should be TextContent") + + // Check what should be contained + for _, item := range tt.shouldContain { + assert.Contains(t, textContent.Text, item, fmt.Sprintf("Result should contain %s", item)) + } + + // Check what should not be contained + for _, item := range tt.shouldNotContain { + assert.NotContains(t, textContent.Text, item, fmt.Sprintf("Result should not contain %s", item)) + } + }) + } +} + func TestHandleListResourcesMissingParameters(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index 7aa0402..e100fff 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -23,6 +23,14 @@ func NewListResourcesTool() mcp.Tool { mcp.Required()), mcp.WithString("namespace", mcp.Description("Namespace (required for namespaced resources)")), + mcp.WithString("label_selector", + mcp.Description("Kubernetes label selector for filtering resources (optional)")), + mcp.WithBoolean("include_annotations", + mcp.Description("Whether to include annotations in the output (default: true)")), + mcp.WithArray("exclude_annotation_keys", + mcp.Description("List of annotation keys to exclude from output (supports wildcards with *)")), + mcp.WithArray("include_annotation_keys", + mcp.Description("List of annotation keys to include in output (if specified, only these are included)")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: "List Kubernetes resources", ReadOnlyHint: BoolPtr(true),