diff --git a/pkg/output/output.go b/pkg/output/output.go index c558ae9df..e09ada749 100644 --- a/pkg/output/output.go +++ b/pkg/output/output.go @@ -119,6 +119,51 @@ func MarshalYaml(v any) (string, error) { return string(ret), nil } +// PruneForStructuredOutput removes verbose fields from Kubernetes objects that add noise +// without value for structured output consumers (e.g. LLMs parsing the response). +// +// Pruned fields: +// - metadata.managedFields: field ownership tracking, not useful for consumers +// - metadata.annotations["kubectl.kubernetes.io/last-applied-configuration"]: contains a full +// copy of the previous object spec +// +// Handles single objects (map[string]interface{}), Kubernetes lists (map with "items" key), +// and slices of objects ([]map[string]interface{}). +func PruneForStructuredOutput(v any) any { + switch t := v.(type) { + case map[string]interface{}: + pruneObject(t) + if items, ok := t["items"].([]interface{}); ok { + for _, item := range items { + if obj, ok := item.(map[string]interface{}); ok { + pruneObject(obj) + } + } + } + case []map[string]interface{}: + for _, obj := range t { + pruneObject(obj) + } + } + return v +} + +func pruneObject(obj map[string]interface{}) { + metadata, ok := obj["metadata"].(map[string]interface{}) + if !ok { + return + } + delete(metadata, "managedFields") + annotations, ok := metadata["annotations"].(map[string]interface{}) + if !ok { + return + } + delete(annotations, "kubectl.kubernetes.io/last-applied-configuration") + if len(annotations) == 0 { + delete(metadata, "annotations") + } +} + func init() { Names = make([]string, 0) for _, output := range Outputs { diff --git a/pkg/output/prune_test.go b/pkg/output/prune_test.go new file mode 100644 index 000000000..6ac5caac2 --- /dev/null +++ b/pkg/output/prune_test.go @@ -0,0 +1,199 @@ +package output + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type PruneSuite struct { + suite.Suite +} + +func (s *PruneSuite) TestSingleObject() { + s.Run("removes managedFields", func() { + obj := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod", + "namespace": "default", + "managedFields": []interface{}{"field-manager-data"}, + }, + } + result := PruneForStructuredOutput(obj).(map[string]interface{}) + metadata := result["metadata"].(map[string]interface{}) + s.Equal("test-pod", metadata["name"]) + s.Nil(metadata["managedFields"]) + }) + + s.Run("removes last-applied-configuration annotation", func() { + obj := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod", + "annotations": map[string]interface{}{ + "kubectl.kubernetes.io/last-applied-configuration": `{"big":"json"}`, + "app.kubernetes.io/name": "myapp", + }, + }, + } + result := PruneForStructuredOutput(obj).(map[string]interface{}) + metadata := result["metadata"].(map[string]interface{}) + annotations := metadata["annotations"].(map[string]interface{}) + s.Nil(annotations["kubectl.kubernetes.io/last-applied-configuration"]) + s.Equal("myapp", annotations["app.kubernetes.io/name"]) + }) + + s.Run("removes annotations map when last-applied-configuration was the only annotation", func() { + obj := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod", + "annotations": map[string]interface{}{ + "kubectl.kubernetes.io/last-applied-configuration": `{"big":"json"}`, + }, + }, + } + result := PruneForStructuredOutput(obj).(map[string]interface{}) + metadata := result["metadata"].(map[string]interface{}) + s.Nil(metadata["annotations"]) + }) + + s.Run("preserves all other fields", func() { + obj := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod", + "namespace": "default", + "labels": map[string]interface{}{"app": "nginx"}, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{"name": "nginx", "image": "nginx:latest"}, + }, + }, + "status": map[string]interface{}{ + "phase": "Running", + }, + } + result := PruneForStructuredOutput(obj).(map[string]interface{}) + s.Equal("v1", result["apiVersion"]) + s.Equal("Pod", result["kind"]) + s.Equal("Running", result["status"].(map[string]interface{})["phase"]) + metadata := result["metadata"].(map[string]interface{}) + s.Equal("nginx", metadata["labels"].(map[string]interface{})["app"]) + }) +} + +func (s *PruneSuite) TestList() { + s.Run("prunes items in a Kubernetes list", func() { + list := map[string]interface{}{ + "apiVersion": "v1", + "kind": "PodList", + "metadata": map[string]interface{}{}, + "items": []interface{}{ + map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "pod-1", + "managedFields": []interface{}{"data"}, + "annotations": map[string]interface{}{ + "kubectl.kubernetes.io/last-applied-configuration": `{}`, + }, + }, + }, + map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "pod-2", + "managedFields": []interface{}{"data"}, + "annotations": map[string]interface{}{ + "kubectl.kubernetes.io/last-applied-configuration": `{}`, + "custom-annotation": "keep-me", + }, + }, + }, + }, + } + result := PruneForStructuredOutput(list).(map[string]interface{}) + items := result["items"].([]interface{}) + s.Len(items, 2) + + meta1 := items[0].(map[string]interface{})["metadata"].(map[string]interface{}) + s.Equal("pod-1", meta1["name"]) + s.Nil(meta1["managedFields"]) + s.Nil(meta1["annotations"]) + + meta2 := items[1].(map[string]interface{})["metadata"].(map[string]interface{}) + s.Equal("pod-2", meta2["name"]) + s.Nil(meta2["managedFields"]) + s.Equal("keep-me", meta2["annotations"].(map[string]interface{})["custom-annotation"]) + }) +} + +func (s *PruneSuite) TestSliceOfObjects() { + s.Run("prunes each object in a slice", func() { + objects := []map[string]interface{}{ + { + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "pod-1", + "managedFields": []interface{}{"data"}, + }, + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "svc-1", + "managedFields": []interface{}{"data"}, + }, + }, + } + result := PruneForStructuredOutput(objects).([]map[string]interface{}) + s.Len(result, 2) + s.Nil(result[0]["metadata"].(map[string]interface{})["managedFields"]) + s.Nil(result[1]["metadata"].(map[string]interface{})["managedFields"]) + }) +} + +func (s *PruneSuite) TestNoOpCases() { + s.Run("handles object without metadata", func() { + obj := map[string]interface{}{ + "data": "no-metadata-here", + } + result := PruneForStructuredOutput(obj) + s.Equal(obj, result) + }) + + s.Run("handles nil input", func() { + result := PruneForStructuredOutput(nil) + s.Nil(result) + }) + + s.Run("handles non-map input", func() { + result := PruneForStructuredOutput("a string") + s.Equal("a string", result) + }) + + s.Run("handles object with no fields to prune", func() { + obj := map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "clean-pod", + }, + } + result := PruneForStructuredOutput(obj).(map[string]interface{}) + s.Equal("clean-pod", result["metadata"].(map[string]interface{})["name"]) + }) +} + +func TestPruneSuite(t *testing.T) { + suite.Run(t, new(PruneSuite)) +} diff --git a/pkg/toolsets/core/events.go b/pkg/toolsets/core/events.go index 72af6c950..0ae7a96d9 100644 --- a/pkg/toolsets/core/events.go +++ b/pkg/toolsets/core/events.go @@ -49,7 +49,10 @@ func eventsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } yamlEvents, err := output.MarshalYaml(eventMap) if err != nil { - err = fmt.Errorf("failed to list events in all namespaces: %w", err) + return api.NewToolCallResult("", fmt.Errorf("failed to list events in all namespaces: %w", err)), nil } - return api.NewToolCallResult(fmt.Sprintf("# The following events (YAML format) were found:\n%s", yamlEvents), err), nil + return &api.ToolCallResult{ + Content: fmt.Sprintf("# The following events (YAML format) were found:\n%s", yamlEvents), + StructuredContent: eventMap, + }, nil } diff --git a/pkg/toolsets/core/namespaces.go b/pkg/toolsets/core/namespaces.go index 1538cbe0e..debc66a54 100644 --- a/pkg/toolsets/core/namespaces.go +++ b/pkg/toolsets/core/namespaces.go @@ -9,6 +9,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/output" ) func initNamespaces(o api.Openshift) []api.ServerTool { @@ -53,7 +54,17 @@ func namespacesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %w", err)), nil } - return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil + content, err := params.ListOutput.PrintObj(ret) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %w", err)), nil + } + structured := ret.UnstructuredContent() + if params.ListOutput.AsTable() { + if structuredRet, sErr := kubernetes.NewCore(params).NamespacesList(params, api.ListOptions{}); sErr == nil { + structured = structuredRet.UnstructuredContent() + } + } + return &api.ToolCallResult{Content: content, StructuredContent: output.PruneForStructuredOutput(structured)}, nil } func projectsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -61,5 +72,15 @@ func projectsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to list projects: %w", err)), nil } - return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil + content, err := params.ListOutput.PrintObj(ret) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list projects: %w", err)), nil + } + structured := ret.UnstructuredContent() + if params.ListOutput.AsTable() { + if structuredRet, sErr := kubernetes.NewCore(params).ProjectsList(params, api.ListOptions{}); sErr == nil { + structured = structuredRet.UnstructuredContent() + } + } + return &api.ToolCallResult{Content: content, StructuredContent: output.PruneForStructuredOutput(structured)}, nil } diff --git a/pkg/toolsets/core/nodes.go b/pkg/toolsets/core/nodes.go index 4c5a3d99e..e73df2864 100644 --- a/pkg/toolsets/core/nodes.go +++ b/pkg/toolsets/core/nodes.go @@ -2,6 +2,7 @@ package core import ( "bytes" + "encoding/json" "errors" "fmt" @@ -120,7 +121,14 @@ func nodesLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } else if ret == "" { ret = fmt.Sprintf("The node %s has not logged any message yet or the log file is empty", name) } - return api.NewToolCallResult(ret, nil), nil + return &api.ToolCallResult{ + Content: ret, + StructuredContent: map[string]any{ + "log": ret, + "node": name, + "query": query, + }, + }, nil } func nodesStatsSummary(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -132,7 +140,11 @@ func nodesStatsSummary(params api.ToolHandlerParams) (*api.ToolCallResult, error if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to get node stats summary for %s: %w", name, err)), nil } - return api.NewToolCallResult(ret, nil), nil + var structured any + if jsonErr := json.Unmarshal([]byte(ret), &structured); jsonErr != nil { + structured = map[string]any{"summary": ret, "node": name} + } + return &api.ToolCallResult{Content: ret, StructuredContent: structured}, nil } func nodesTop(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -177,5 +189,11 @@ func nodesTop(params api.ToolHandlerParams) (*api.ToolCallResult, error) { return api.NewToolCallResult("", fmt.Errorf("failed to print node metrics: %w", err)), nil } - return api.NewToolCallResult(buf.String(), nil), nil + return &api.ToolCallResult{ + Content: buf.String(), + StructuredContent: map[string]any{ + "metrics": nodeMetrics, + "allocatable": availableResources, + }, + }, nil } diff --git a/pkg/toolsets/core/pods.go b/pkg/toolsets/core/pods.go index 09e4e959a..df541b25e 100644 --- a/pkg/toolsets/core/pods.go +++ b/pkg/toolsets/core/pods.go @@ -275,7 +275,19 @@ func podsListInAllNamespaces(params api.ToolHandlerParams) (*api.ToolCallResult, if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to list pods in all namespaces: %w", err)), nil } - return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil + content, err := params.ListOutput.PrintObj(ret) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list pods in all namespaces: %w", err)), nil + } + structured := ret.UnstructuredContent() + if params.ListOutput.AsTable() { + structuredOptions := resourceListOptions + structuredOptions.AsTable = false + if structuredRet, sErr := kubernetes.NewCore(params).PodsListInAllNamespaces(params, structuredOptions); sErr == nil { + structured = structuredRet.UnstructuredContent() + } + } + return &api.ToolCallResult{Content: content, StructuredContent: output.PruneForStructuredOutput(structured)}, nil } func podsListInNamespace(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -298,7 +310,19 @@ func podsListInNamespace(params api.ToolHandlerParams) (*api.ToolCallResult, err if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to list pods in namespace %s: %w", ns, err)), nil } - return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil + content, err := params.ListOutput.PrintObj(ret) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list pods in namespace %s: %w", ns, err)), nil + } + structured := ret.UnstructuredContent() + if params.ListOutput.AsTable() { + structuredOptions := resourceListOptions + structuredOptions.AsTable = false + if structuredRet, sErr := kubernetes.NewCore(params).PodsListInNamespace(params, ns.(string), structuredOptions); sErr == nil { + structured = structuredRet.UnstructuredContent() + } + } + return &api.ToolCallResult{Content: content, StructuredContent: output.PruneForStructuredOutput(structured)}, nil } func podsGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -314,7 +338,11 @@ func podsGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) { if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to get pod %s in namespace %s: %w", name, ns, err)), nil } - return api.NewToolCallResult(output.MarshalYaml(ret)), nil + marshalledYaml, err := output.MarshalYaml(ret) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get pod %s in namespace %s: %w", name, ns, err)), nil + } + return &api.ToolCallResult{Content: marshalledYaml, StructuredContent: output.PruneForStructuredOutput(ret.Object)}, nil } func podsDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -330,7 +358,15 @@ func podsDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) { if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to delete pod %s in namespace %s: %w", name, ns, err)), nil } - return api.NewToolCallResult(ret, err), nil + return &api.ToolCallResult{ + Content: ret, + StructuredContent: map[string]any{ + "status": "deleted", + "kind": "Pod", + "namespace": ns, + "name": name, + }, + }, nil } func podsTop(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -357,7 +393,7 @@ func podsTop(params api.ToolHandlerParams) (*api.ToolCallResult, error) { if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to get pods top: %w", err)), nil } - return api.NewToolCallResult(buf.String(), nil), nil + return &api.ToolCallResult{Content: buf.String(), StructuredContent: ret}, nil } func podsExec(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -390,7 +426,16 @@ func podsExec(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } else if ret == "" { ret = fmt.Sprintf("The executed command in pod %s in namespace %s has not produced any output", name, ns) } - return api.NewToolCallResult(ret, err), nil + return &api.ToolCallResult{ + Content: ret, + StructuredContent: map[string]any{ + "output": ret, + "pod": name, + "namespace": ns, + "container": container, + "command": command, + }, + }, nil } func podsLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -428,7 +473,15 @@ func podsLog(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } else if ret == "" { ret = fmt.Sprintf("The pod %s in namespace %s has not logged any message yet", name, ns) } - return api.NewToolCallResult(ret, err), nil + return &api.ToolCallResult{ + Content: ret, + StructuredContent: map[string]any{ + "log": ret, + "pod": name, + "namespace": ns, + "container": container, + }, + }, nil } func podsRun(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -454,7 +507,14 @@ func podsRun(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } marshalledYaml, err := output.MarshalYaml(resources) if err != nil { - err = fmt.Errorf("failed to run pod: %w", err) + return api.NewToolCallResult("", fmt.Errorf("failed to run pod: %w", err)), nil + } + structured := make([]map[string]interface{}, len(resources)) + for i, r := range resources { + structured[i] = r.Object } - return api.NewToolCallResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil + return &api.ToolCallResult{ + Content: "# The following resources (YAML) have been created or updated successfully\n" + marshalledYaml, + StructuredContent: output.PruneForStructuredOutput(structured), + }, nil } diff --git a/pkg/toolsets/core/resources.go b/pkg/toolsets/core/resources.go index cfefdd396..ff793bdea 100644 --- a/pkg/toolsets/core/resources.go +++ b/pkg/toolsets/core/resources.go @@ -225,7 +225,19 @@ func resourcesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to list resources: %w", err)), nil } - return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil + content, err := params.ListOutput.PrintObj(ret) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list resources: %w", err)), nil + } + structured := ret.UnstructuredContent() + if params.ListOutput.AsTable() { + structuredOptions := resourceListOptions + structuredOptions.AsTable = false + if structuredRet, sErr := kubernetes.NewCore(params).ResourcesList(params, gvk, ns, structuredOptions); sErr == nil { + structured = structuredRet.UnstructuredContent() + } + } + return &api.ToolCallResult{Content: content, StructuredContent: output.PruneForStructuredOutput(structured)}, nil } func resourcesGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -256,7 +268,11 @@ func resourcesGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) { if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to get resource: %w", err)), nil } - return api.NewToolCallResult(output.MarshalYaml(ret)), nil + marshalledYaml, err := output.MarshalYaml(ret) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get resource: %w", err)), nil + } + return &api.ToolCallResult{Content: marshalledYaml, StructuredContent: output.PruneForStructuredOutput(ret.Object)}, nil } func resourcesCreateOrUpdate(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -276,9 +292,16 @@ func resourcesCreateOrUpdate(params api.ToolHandlerParams) (*api.ToolCallResult, } marshalledYaml, err := output.MarshalYaml(resources) if err != nil { - err = fmt.Errorf("failed to create or update resources: %w", err) + return api.NewToolCallResult("", fmt.Errorf("failed to create or update resources: %w", err)), nil + } + structured := make([]map[string]interface{}, len(resources)) + for i, r := range resources { + structured[i] = r.Object } - return api.NewToolCallResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil + return &api.ToolCallResult{ + Content: "# The following resources (YAML) have been created or updated successfully\n" + marshalledYaml, + StructuredContent: output.PruneForStructuredOutput(structured), + }, nil } func resourcesDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -318,7 +341,16 @@ func resourcesDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to delete resource: %w", err)), nil } - return api.NewToolCallResult("Resource deleted successfully", err), nil + return &api.ToolCallResult{ + Content: "Resource deleted successfully", + StructuredContent: map[string]any{ + "status": "deleted", + "apiVersion": gvk.GroupVersion().String(), + "kind": gvk.Kind, + "namespace": ns, + "name": n, + }, + }, nil } func resourcesScale(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -366,7 +398,10 @@ func resourcesScale(params api.ToolHandlerParams) (*api.ToolCallResult, error) { return api.NewToolCallResult("", fmt.Errorf("failed to marshall scale to yaml format: %v", scale)), nil } - return api.NewToolCallResult("# Current resource scale (YAML) is below\n"+marshalled, err), nil + return &api.ToolCallResult{ + Content: "# Current resource scale (YAML) is below\n" + marshalled, + StructuredContent: output.PruneForStructuredOutput(scale.Object), + }, nil } func parseScaleValue(desiredScale interface{}) (int64, error) {