Skip to content

Commit dc64bec

Browse files
manusaclaude
andauthored
refactor: add structured output support and MCP spec compliance (#960)
* refactor(output): add PrintObjStructured to Output interface Extend the Output interface with a PrintObjStructured method that returns both a human-readable text representation and optional structured data extracted from Kubernetes objects. This lays the groundwork for consumers that need programmatic access to the data alongside the formatted text. - Add PrintResult struct holding Text and Structured fields - Implement PrintObjStructured for yaml (returns deep-copied list items or object map) - Implement PrintObjStructured for table (extracts column-keyed maps via tableToStructured helper) - Refactor table.PrintObj to delegate to printTable for code reuse - Rewrite output tests to testify/suite pattern with new coverage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Marc Nuri <marc@marcnuri.com> * feat(api): add NewToolCallResultFull constructor Add a NewToolCallResultFull constructor that creates a ToolCallResult with both human-readable text and structured content as separate fields. Unlike NewToolCallResultStructured (which JSON-serializes structured data into Content), this preserves the original text representation (e.g. table or YAML output) alongside the structured data. Refactor NewToolCallResult and NewToolCallResultStructured to delegate to NewToolCallResultFull, making it the single canonical constructor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Marc Nuri <marc@marcnuri.com> * fix(mcp): wrap slice structuredContent in object per MCP spec The MCP specification requires structuredContent to marshal to a JSON object, but NewStructuredResult could receive slice/array values. Add ensureStructuredObject to automatically wrap slices in {"items": [...]} so that the result always complies with the spec. Typed nil slices (e.g. []string(nil)) are detected via reflect and return nil to avoid producing {"items": null}. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Marc Nuri <marc@marcnuri.com> --------- Signed-off-by: Marc Nuri <marc@marcnuri.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a3f75ea commit dc64bec

6 files changed

Lines changed: 346 additions & 26 deletions

File tree

pkg/api/toolsets.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,18 @@ type ToolCallResult struct {
6666
// NewToolCallResult creates a ToolCallResult with text content only.
6767
// Use this for tools that return human-readable text output.
6868
func NewToolCallResult(content string, err error) *ToolCallResult {
69+
return NewToolCallResultFull(content, nil, err)
70+
}
71+
72+
// NewToolCallResultFull creates a ToolCallResult with both human-readable text
73+
// and structured content.
74+
// Use this when the text representation differs from a JSON serialization of the
75+
// structured content (e.g., YAML or Table text alongside extracted structured data).
76+
func NewToolCallResultFull(text string, structured any, err error) *ToolCallResult {
6977
return &ToolCallResult{
70-
Content: content,
71-
Error: err,
78+
Content: text,
79+
StructuredContent: structured,
80+
Error: err,
7281
}
7382
}
7483

@@ -90,11 +99,7 @@ func NewToolCallResultStructured(structured any, err error) *ToolCallResult {
9099
content = string(b)
91100
}
92101
}
93-
return &ToolCallResult{
94-
Content: content,
95-
StructuredContent: structured,
96-
Error: err,
97-
}
102+
return NewToolCallResultFull(content, structured, err)
98103
}
99104

100105
type ToolHandlerParams struct {

pkg/api/toolsets_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,37 @@ func (s *ToolsetsSuite) TestNewToolCallResultStructured() {
103103
})
104104
}
105105

106+
func (s *ToolsetsSuite) TestNewToolCallResultFull() {
107+
s.Run("sets text, structured, and nil error", func() {
108+
structured := []map[string]any{{"name": "pod-1"}}
109+
result := NewToolCallResultFull("formatted text", structured, nil)
110+
s.Equal("formatted text", result.Content)
111+
s.Equal(structured, result.StructuredContent)
112+
s.Nil(result.Error)
113+
})
114+
s.Run("sets text, structured, and error", func() {
115+
err := errors.New("partial failure")
116+
structured := map[string]any{"key": "value"}
117+
result := NewToolCallResultFull("some text", structured, err)
118+
s.Equal("some text", result.Content)
119+
s.Equal(structured, result.StructuredContent)
120+
s.Equal(err, result.Error)
121+
})
122+
s.Run("preserves human-readable text separate from structured data", func() {
123+
structured := []map[string]any{{"Name": "ns-1"}, {"Name": "ns-2"}}
124+
result := NewToolCallResultFull("NAMESPACE AGE\nns-1 10d\nns-2 5d", structured, nil)
125+
s.Contains(result.Content, "NAMESPACE")
126+
items, ok := result.StructuredContent.([]map[string]any)
127+
s.Require().True(ok)
128+
s.Len(items, 2)
129+
})
130+
s.Run("allows nil structured content", func() {
131+
result := NewToolCallResultFull("text only", nil, nil)
132+
s.Equal("text only", result.Content)
133+
s.Nil(result.StructuredContent)
134+
})
135+
}
136+
106137
func (s *ToolsetsSuite) TestToolMeta() {
107138
s.Run("Meta is omitted from JSON when nil", func() {
108139
tool := Tool{Name: "test_tool"}

pkg/mcp/mcp.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"net/http"
77
"os"
8+
"reflect"
89
"slices"
910
"sync"
1011
"time"
@@ -399,6 +400,10 @@ func NewTextResult(content string, err error) *mcp.CallToolResult {
399400
// The Content field contains the JSON-serialized form of structuredContent
400401
// for backward compatibility with MCP clients that don't support structuredContent.
401402
//
403+
// Per the MCP specification, structuredContent must marshal to a JSON object.
404+
// If structuredContent is a slice/array, it is automatically wrapped in
405+
// {"items": [...]} to satisfy this requirement.
406+
//
402407
// Per the MCP specification:
403408
// "For backwards compatibility, a tool that returns structured content SHOULD
404409
// also return the serialized JSON in a TextContent block."
@@ -425,7 +430,26 @@ func NewStructuredResult(content string, structuredContent any, err error) *mcp.
425430
},
426431
}
427432
if structuredContent != nil {
428-
result.StructuredContent = structuredContent
433+
result.StructuredContent = ensureStructuredObject(structuredContent)
429434
}
430435
return result
431436
}
437+
438+
// ensureStructuredObject wraps slice/array values in a {"items": ...} object
439+
// because the MCP specification requires structuredContent to be a JSON object.
440+
// A typed nil slice (e.g. []string(nil)) returns nil to avoid {"items": null}.
441+
// Note: this checks the top-level reflect.Kind, so a pointer-to-slice (*[]T)
442+
// would not be wrapped. All current callers pass value types.
443+
func ensureStructuredObject(v any) any {
444+
rv := reflect.ValueOf(v)
445+
if rv.Kind() == reflect.Slice {
446+
if rv.IsNil() {
447+
return nil
448+
}
449+
return map[string]any{"items": v}
450+
}
451+
if rv.Kind() == reflect.Array {
452+
return map[string]any{"items": v}
453+
}
454+
return v
455+
}

pkg/mcp/text_result_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,24 @@ func (s *TextResultSuite) TestNewStructuredResult() {
4848
s.Equal(`{"pods":["pod-1","pod-2"]}`, tc.Text)
4949
s.Equal(structured, result.StructuredContent)
5050
})
51+
s.Run("wraps slice in object for MCP spec compliance", func() {
52+
items := []map[string]any{{"name": "ns-1"}, {"name": "ns-2"}}
53+
result := NewStructuredResult("text", items, nil)
54+
s.False(result.IsError)
55+
wrapped, ok := result.StructuredContent.(map[string]any)
56+
s.Require().True(ok, "expected map[string]any wrapper")
57+
s.Equal(items, wrapped["items"])
58+
})
59+
s.Run("does not wrap map structured content", func() {
60+
structured := map[string]any{"key": "value"}
61+
result := NewStructuredResult("text", structured, nil)
62+
s.Equal(structured, result.StructuredContent)
63+
})
64+
s.Run("omits structured content for typed nil slice", func() {
65+
var items []map[string]any // typed nil
66+
result := NewStructuredResult("text", items, nil)
67+
s.Nil(result.StructuredContent, "typed nil slice should not produce {\"items\": null}")
68+
})
5169
s.Run("omits structured content when nil", func() {
5270
result := NewStructuredResult("text output", nil, nil)
5371
s.False(result.IsError)

pkg/output/output.go

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,27 @@ var Yaml = &yaml{}
1414

1515
var Table = &table{}
1616

17+
// PrintResult holds both the text representation and optional structured data
18+
// extracted from a Kubernetes object.
19+
type PrintResult struct {
20+
// Text is the human-readable formatted output (YAML or Table).
21+
Text string
22+
// Structured is an optional JSON-serializable value extracted from the object.
23+
// For Table output, this is []map[string]any with column headers as keys.
24+
// For YAML output, this is the cleaned-up object items as []map[string]any (lists)
25+
// or a single map[string]any (individual objects).
26+
Structured any
27+
}
28+
1729
type Output interface {
1830
// GetName returns the name of the output format, will be used by the CLI to identify the output format.
1931
GetName() string
2032
// AsTable true if the kubernetes request should be made with the `application/json;as=Table;v=0.1` header.
2133
AsTable() bool
2234
// PrintObj prints the given object as a string.
2335
PrintObj(obj runtime.Unstructured) (string, error)
36+
// PrintObjStructured prints the given object and also extracts structured data.
37+
PrintObjStructured(obj runtime.Unstructured) (*PrintResult, error)
2438
}
2539

2640
var Outputs = []Output{
@@ -50,6 +64,23 @@ func (p *yaml) AsTable() bool {
5064
func (p *yaml) PrintObj(obj runtime.Unstructured) (string, error) {
5165
return MarshalYaml(obj)
5266
}
67+
func (p *yaml) PrintObjStructured(obj runtime.Unstructured) (*PrintResult, error) {
68+
text, err := p.PrintObj(obj)
69+
if err != nil {
70+
return nil, err
71+
}
72+
switch t := obj.(type) {
73+
case *unstructured.UnstructuredList:
74+
items := make([]map[string]any, 0, len(t.Items))
75+
for _, item := range t.Items {
76+
items = append(items, item.DeepCopy().Object)
77+
}
78+
return &PrintResult{Text: text, Structured: items}, nil
79+
case *unstructured.Unstructured:
80+
return &PrintResult{Text: text, Structured: t.DeepCopy().Object}, nil
81+
}
82+
return &PrintResult{Text: text}, nil
83+
}
5384

5485
type table struct{}
5586

@@ -60,12 +91,34 @@ func (p *table) AsTable() bool {
6091
return true
6192
}
6293
func (p *table) PrintObj(obj runtime.Unstructured) (string, error) {
94+
text, _, err := p.printTable(obj)
95+
return text, err
96+
}
97+
98+
func (p *table) PrintObjStructured(obj runtime.Unstructured) (*PrintResult, error) {
99+
text, t, err := p.printTable(obj)
100+
if err != nil {
101+
return nil, err
102+
}
103+
// Guard against typed nil leaking into the any interface — a nil []map[string]any
104+
// assigned to Structured (type any) would create a non-nil interface, causing
105+
// downstream nil checks (e.g. in NewStructuredResult) to incorrectly pass.
106+
if structured := tableToStructured(t); structured != nil {
107+
return &PrintResult{Text: text, Structured: structured}, nil
108+
}
109+
return &PrintResult{Text: text}, nil
110+
}
111+
112+
// printTable formats the object as a table and returns the text, the parsed Table (if available), and any error.
113+
func (p *table) printTable(obj runtime.Unstructured) (string, *metav1.Table, error) {
63114
var objectToPrint runtime.Object = obj
115+
var parsedTable *metav1.Table
64116
withNamespace := false
65117
if obj.GetObjectKind().GroupVersionKind() == metav1.SchemeGroupVersion.WithKind("Table") {
66118
t := &metav1.Table{}
67119
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), t); err == nil {
68120
objectToPrint = t
121+
parsedTable = t
69122
// Process the Raw object to retrieve the complete metadata (see kubectl/pkg/printers/table_printer.go)
70123
for i := range t.Rows {
71124
row := &t.Rows[i]
@@ -92,7 +145,34 @@ func (p *table) PrintObj(obj runtime.Unstructured) (string, error) {
92145
ShowLabels: true,
93146
})
94147
err := printer.PrintObj(objectToPrint, buf)
95-
return buf.String(), err
148+
return buf.String(), parsedTable, err
149+
}
150+
151+
// tableToStructured converts a Kubernetes Table response to []map[string]any
152+
// using column definitions as keys.
153+
func tableToStructured(t *metav1.Table) []map[string]any {
154+
if t == nil || len(t.Rows) == 0 {
155+
return nil
156+
}
157+
result := make([]map[string]any, 0, len(t.Rows))
158+
for _, row := range t.Rows {
159+
item := make(map[string]any, len(t.ColumnDefinitions))
160+
for ci, col := range t.ColumnDefinitions {
161+
if ci < len(row.Cells) {
162+
item[col.Name] = row.Cells[ci]
163+
}
164+
}
165+
// Add namespace from the embedded object metadata if available
166+
if row.Object.Object != nil {
167+
if u, ok := row.Object.Object.(*unstructured.Unstructured); ok {
168+
if ns := u.GetNamespace(); ns != "" {
169+
item["Namespace"] = ns
170+
}
171+
}
172+
}
173+
result = append(result, item)
174+
}
175+
return result
96176
}
97177

98178
func MarshalYaml(v any) (string, error) {

0 commit comments

Comments
 (0)