mcp: set content to marshaled output (#398)

The ToolHandler constructed by ToolFor sets the result's Content to the
marshaled output, following the spec's suggestion.

See
https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content.

Also, set StructuredContent to the marshaled RawMessage to avoid a
double marshal.

Fixes #391.
This commit is contained in:
Jonathan Amsterdam 2025-09-03 10:37:27 -04:00 • committed by GitHub
commit 7769b2a959
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 28 additions and 12 deletions

View file

@ -9,6 +9,7 @@ import (
"context"
"encoding/base64"
"encoding/gob"
"encoding/json"
"fmt"
"iter"
"maps"
@ -261,26 +262,35 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan
// TODO(v0.3.0): Validate out.
_ = outputResolved
// TODO: return the serialized JSON in a TextContent block, as per spec?
// https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content
// But people may use res.Content for other things.
if res == nil {
res = &CallToolResult{}
}
if res.Content == nil {
res.Content = []Content{} // avoid returning 'null'
}
res.StructuredContent = out
// Marshal the output and put the RawMessage in the StructuredContent field.
var outval any = out
if elemZero != nil {
// Avoid typed nil, which will serialize as JSON null.
// Instead, use the zero value of the non-zero
// Instead, use the zero value of the unpointered type.
var z Out
if any(out) == any(z) { // zero is only non-nil if Out is a pointer type
res.StructuredContent = elemZero
outval = elemZero
}
}
outbytes, err := json.Marshal(outval)
if err != nil {
return nil, fmt.Errorf("marshaling output: %w", err)
}
res.StructuredContent = json.RawMessage(outbytes) // avoid a second marshal over the wire
// If the Content field isn't being used, return the serialized JSON in a
// TextContent block, as the spec suggests:
// https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content.
if res.Content == nil {
res.Content = []Content{&TextContent{
Text: string(outbytes),
}}
}
return res, nil
}
} // end of handler
return &tt, th, nil
}

View file

@ -1175,7 +1175,10 @@ func TestStreamableStateless(t *testing.T) {
req(2, "tools/call", &CallToolParams{Name: "greet", Arguments: hiParams{Name: "World"}}),
},
wantMessages: []jsonrpc.Message{
resp(2, &CallToolResult{Content: []Content{&TextContent{Text: "hi World"}}}, nil),
resp(2, &CallToolResult{
Content: []Content{&TextContent{Text: "hi World"}},
StructuredContent: json.RawMessage("null"),
}, nil),
},
wantSessionID: false,
},
@ -1186,7 +1189,10 @@ func TestStreamableStateless(t *testing.T) {
req(2, "tools/call", &CallToolParams{Name: "greet", Arguments: hiParams{Name: "foo"}}),
},
wantMessages: []jsonrpc.Message{
resp(2, &CallToolResult{Content: []Content{&TextContent{Text: "hi foo"}}}, nil),
resp(2, &CallToolResult{
Content: []Content{&TextContent{Text: "hi foo"}},
StructuredContent: json.RawMessage("null"),
}, nil),
},
wantSessionID: false,
},