Skip to content
Merged
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
19 changes: 12 additions & 7 deletions pkg/api/toolsets.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,18 @@ type ToolCallResult struct {
// NewToolCallResult creates a ToolCallResult with text content only.
// Use this for tools that return human-readable text output.
func NewToolCallResult(content string, err error) *ToolCallResult {
return NewToolCallResultFull(content, nil, err)
}

// NewToolCallResultFull creates a ToolCallResult with both human-readable text
// and structured content.
// Use this when the text representation differs from a JSON serialization of the
// structured content (e.g., YAML or Table text alongside extracted structured data).
func NewToolCallResultFull(text string, structured any, err error) *ToolCallResult {
return &ToolCallResult{
Content: content,
Error: err,
Content: text,
StructuredContent: structured,
Error: err,
}
}

Expand All @@ -90,11 +99,7 @@ func NewToolCallResultStructured(structured any, err error) *ToolCallResult {
content = string(b)
}
}
return &ToolCallResult{
Content: content,
StructuredContent: structured,
Error: err,
}
return NewToolCallResultFull(content, structured, err)
}

type ToolHandlerParams struct {
Expand Down
31 changes: 31 additions & 0 deletions pkg/api/toolsets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,37 @@ func (s *ToolsetsSuite) TestNewToolCallResultStructured() {
})
}

func (s *ToolsetsSuite) TestNewToolCallResultFull() {
s.Run("sets text, structured, and nil error", func() {
structured := []map[string]any{{"name": "pod-1"}}
result := NewToolCallResultFull("formatted text", structured, nil)
s.Equal("formatted text", result.Content)
s.Equal(structured, result.StructuredContent)
s.Nil(result.Error)
})
s.Run("sets text, structured, and error", func() {
err := errors.New("partial failure")
structured := map[string]any{"key": "value"}
result := NewToolCallResultFull("some text", structured, err)
s.Equal("some text", result.Content)
s.Equal(structured, result.StructuredContent)
s.Equal(err, result.Error)
})
s.Run("preserves human-readable text separate from structured data", func() {
structured := []map[string]any{{"Name": "ns-1"}, {"Name": "ns-2"}}
result := NewToolCallResultFull("NAMESPACE AGE\nns-1 10d\nns-2 5d", structured, nil)
s.Contains(result.Content, "NAMESPACE")
items, ok := result.StructuredContent.([]map[string]any)
s.Require().True(ok)
s.Len(items, 2)
})
s.Run("allows nil structured content", func() {
result := NewToolCallResultFull("text only", nil, nil)
s.Equal("text only", result.Content)
s.Nil(result.StructuredContent)
})
}

func (s *ToolsetsSuite) TestToolMeta() {
s.Run("Meta is omitted from JSON when nil", func() {
tool := Tool{Name: "test_tool"}
Expand Down
26 changes: 25 additions & 1 deletion pkg/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"os"
"reflect"
"slices"
"sync"
"time"
Expand Down Expand Up @@ -399,6 +400,10 @@ func NewTextResult(content string, err error) *mcp.CallToolResult {
// The Content field contains the JSON-serialized form of structuredContent
// for backward compatibility with MCP clients that don't support structuredContent.
//
// Per the MCP specification, structuredContent must marshal to a JSON object.
// If structuredContent is a slice/array, it is automatically wrapped in
// {"items": [...]} to satisfy this requirement.
//
// Per the MCP specification:
// "For backwards compatibility, a tool that returns structured content SHOULD
// also return the serialized JSON in a TextContent block."
Expand All @@ -425,7 +430,26 @@ func NewStructuredResult(content string, structuredContent any, err error) *mcp.
},
}
if structuredContent != nil {
result.StructuredContent = structuredContent
result.StructuredContent = ensureStructuredObject(structuredContent)
}
return result
}

// ensureStructuredObject wraps slice/array values in a {"items": ...} object
// because the MCP specification requires structuredContent to be a JSON object.
// A typed nil slice (e.g. []string(nil)) returns nil to avoid {"items": null}.
// Note: this checks the top-level reflect.Kind, so a pointer-to-slice (*[]T)
// would not be wrapped. All current callers pass value types.
func ensureStructuredObject(v any) any {
rv := reflect.ValueOf(v)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we still are missing handling typed nil, e.g. []string(nil). This would still show up as a rv.Kind() == reflect.Slice, but would end up json serializing to {"items": null} which I don't think is correct

if rv.Kind() == reflect.Slice {
if rv.IsNil() {
return nil
}
return map[string]any{"items": v}
}
if rv.Kind() == reflect.Array {
return map[string]any{"items": v}
}
return v
}
18 changes: 18 additions & 0 deletions pkg/mcp/text_result_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@ func (s *TextResultSuite) TestNewStructuredResult() {
s.Equal(`{"pods":["pod-1","pod-2"]}`, tc.Text)
s.Equal(structured, result.StructuredContent)
})
s.Run("wraps slice in object for MCP spec compliance", func() {
items := []map[string]any{{"name": "ns-1"}, {"name": "ns-2"}}
result := NewStructuredResult("text", items, nil)
s.False(result.IsError)
wrapped, ok := result.StructuredContent.(map[string]any)
s.Require().True(ok, "expected map[string]any wrapper")
s.Equal(items, wrapped["items"])
})
s.Run("does not wrap map structured content", func() {
structured := map[string]any{"key": "value"}
result := NewStructuredResult("text", structured, nil)
s.Equal(structured, result.StructuredContent)
})
s.Run("omits structured content for typed nil slice", func() {
var items []map[string]any // typed nil
result := NewStructuredResult("text", items, nil)
s.Nil(result.StructuredContent, "typed nil slice should not produce {\"items\": null}")
})
s.Run("omits structured content when nil", func() {
result := NewStructuredResult("text output", nil, nil)
s.False(result.IsError)
Expand Down
82 changes: 81 additions & 1 deletion pkg/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,27 @@ var Yaml = &yaml{}

var Table = &table{}

// PrintResult holds both the text representation and optional structured data
// extracted from a Kubernetes object.
type PrintResult struct {
// Text is the human-readable formatted output (YAML or Table).
Text string
// Structured is an optional JSON-serializable value extracted from the object.
// For Table output, this is []map[string]any with column headers as keys.
// For YAML output, this is the cleaned-up object items as []map[string]any (lists)
// or a single map[string]any (individual objects).
Structured any
}

type Output interface {
// GetName returns the name of the output format, will be used by the CLI to identify the output format.
GetName() string
// AsTable true if the kubernetes request should be made with the `application/json;as=Table;v=0.1` header.
AsTable() bool
// PrintObj prints the given object as a string.
PrintObj(obj runtime.Unstructured) (string, error)
// PrintObjStructured prints the given object and also extracts structured data.
PrintObjStructured(obj runtime.Unstructured) (*PrintResult, error)
}

var Outputs = []Output{
Expand Down Expand Up @@ -50,6 +64,23 @@ func (p *yaml) AsTable() bool {
func (p *yaml) PrintObj(obj runtime.Unstructured) (string, error) {
return MarshalYaml(obj)
}
func (p *yaml) PrintObjStructured(obj runtime.Unstructured) (*PrintResult, error) {
text, err := p.PrintObj(obj)
if err != nil {
return nil, err
}
switch t := obj.(type) {
case *unstructured.UnstructuredList:
items := make([]map[string]any, 0, len(t.Items))
for _, item := range t.Items {
items = append(items, item.DeepCopy().Object)
}
return &PrintResult{Text: text, Structured: items}, nil
case *unstructured.Unstructured:
return &PrintResult{Text: text, Structured: t.DeepCopy().Object}, nil
}
return &PrintResult{Text: text}, nil
}

type table struct{}

Expand All @@ -60,12 +91,34 @@ func (p *table) AsTable() bool {
return true
}
func (p *table) PrintObj(obj runtime.Unstructured) (string, error) {
text, _, err := p.printTable(obj)
return text, err
}

func (p *table) PrintObjStructured(obj runtime.Unstructured) (*PrintResult, error) {
text, t, err := p.printTable(obj)
if err != nil {
return nil, err
}
// Guard against typed nil leaking into the any interface — a nil []map[string]any
// assigned to Structured (type any) would create a non-nil interface, causing
// downstream nil checks (e.g. in NewStructuredResult) to incorrectly pass.
if structured := tableToStructured(t); structured != nil {
return &PrintResult{Text: text, Structured: structured}, nil
}
return &PrintResult{Text: text}, nil
}

// printTable formats the object as a table and returns the text, the parsed Table (if available), and any error.
func (p *table) printTable(obj runtime.Unstructured) (string, *metav1.Table, error) {
var objectToPrint runtime.Object = obj
var parsedTable *metav1.Table
withNamespace := false
if obj.GetObjectKind().GroupVersionKind() == metav1.SchemeGroupVersion.WithKind("Table") {
t := &metav1.Table{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), t); err == nil {
objectToPrint = t
parsedTable = t
// Process the Raw object to retrieve the complete metadata (see kubectl/pkg/printers/table_printer.go)
for i := range t.Rows {
row := &t.Rows[i]
Expand All @@ -92,7 +145,34 @@ func (p *table) PrintObj(obj runtime.Unstructured) (string, error) {
ShowLabels: true,
})
err := printer.PrintObj(objectToPrint, buf)
return buf.String(), err
return buf.String(), parsedTable, err
}

// tableToStructured converts a Kubernetes Table response to []map[string]any
// using column definitions as keys.
func tableToStructured(t *metav1.Table) []map[string]any {
if t == nil || len(t.Rows) == 0 {
return nil
}
result := make([]map[string]any, 0, len(t.Rows))
for _, row := range t.Rows {
item := make(map[string]any, len(t.ColumnDefinitions))
for ci, col := range t.ColumnDefinitions {
if ci < len(row.Cells) {
item[col.Name] = row.Cells[ci]
}
}
// Add namespace from the embedded object metadata if available
if row.Object.Object != nil {
if u, ok := row.Object.Object.(*unstructured.Unstructured); ok {
if ns := u.GetNamespace(); ns != "" {
item["Namespace"] = ns
}
}
}
result = append(result, item)
}
return result
}

func MarshalYaml(v any) (string, error) {
Expand Down
Loading