Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions pkg/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
199 changes: 199 additions & 0 deletions pkg/output/prune_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
7 changes: 5 additions & 2 deletions pkg/toolsets/core/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
25 changes: 23 additions & 2 deletions pkg/toolsets/core/namespaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -53,13 +54,33 @@ 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) {
ret, err := kubernetes.NewCore(params).ProjectsList(params, api.ListOptions{AsTable: params.ListOutput.AsTable()})
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
}
24 changes: 21 additions & 3 deletions pkg/toolsets/core/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package core

import (
"bytes"
"encoding/json"
"errors"
"fmt"

Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
Loading