Skip to content

Commit 7769b2a

Browse files
authored
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.
1 parent 07b65d7 commit 7769b2a

File tree

2 files changed

+28
-12
lines changed

2 files changed

+28
-12
lines changed

mcp/server.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"context"
1010
"encoding/base64"
1111
"encoding/gob"
12+
"encoding/json"
1213
"fmt"
1314
"iter"
1415
"maps"
@@ -261,26 +262,35 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan
261262
// TODO(v0.3.0): Validate out.
262263
_ = outputResolved
263264

264-
// TODO: return the serialized JSON in a TextContent block, as per spec?
265-
// https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content
266-
// But people may use res.Content for other things.
267265
if res == nil {
268266
res = &CallToolResult{}
269267
}
270-
if res.Content == nil {
271-
res.Content = []Content{} // avoid returning 'null'
272-
}
273-
res.StructuredContent = out
268+
// Marshal the output and put the RawMessage in the StructuredContent field.
269+
var outval any = out
274270
if elemZero != nil {
275271
// Avoid typed nil, which will serialize as JSON null.
276-
// Instead, use the zero value of the non-zero
272+
// Instead, use the zero value of the unpointered type.
277273
var z Out
278274
if any(out) == any(z) { // zero is only non-nil if Out is a pointer type
279-
res.StructuredContent = elemZero
275+
outval = elemZero
280276
}
281277
}
278+
outbytes, err := json.Marshal(outval)
279+
if err != nil {
280+
return nil, fmt.Errorf("marshaling output: %w", err)
281+
}
282+
res.StructuredContent = json.RawMessage(outbytes) // avoid a second marshal over the wire
283+
284+
// If the Content field isn't being used, return the serialized JSON in a
285+
// TextContent block, as the spec suggests:
286+
// https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content.
287+
if res.Content == nil {
288+
res.Content = []Content{&TextContent{
289+
Text: string(outbytes),
290+
}}
291+
}
282292
return res, nil
283-
}
293+
} // end of handler
284294

285295
return &tt, th, nil
286296
}

mcp/streamable_test.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,7 +1175,10 @@ func TestStreamableStateless(t *testing.T) {
11751175
req(2, "tools/call", &CallToolParams{Name: "greet", Arguments: hiParams{Name: "World"}}),
11761176
},
11771177
wantMessages: []jsonrpc.Message{
1178-
resp(2, &CallToolResult{Content: []Content{&TextContent{Text: "hi World"}}}, nil),
1178+
resp(2, &CallToolResult{
1179+
Content: []Content{&TextContent{Text: "hi World"}},
1180+
StructuredContent: json.RawMessage("null"),
1181+
}, nil),
11791182
},
11801183
wantSessionID: false,
11811184
},
@@ -1186,7 +1189,10 @@ func TestStreamableStateless(t *testing.T) {
11861189
req(2, "tools/call", &CallToolParams{Name: "greet", Arguments: hiParams{Name: "foo"}}),
11871190
},
11881191
wantMessages: []jsonrpc.Message{
1189-
resp(2, &CallToolResult{Content: []Content{&TextContent{Text: "hi foo"}}}, nil),
1192+
resp(2, &CallToolResult{
1193+
Content: []Content{&TextContent{Text: "hi foo"}},
1194+
StructuredContent: json.RawMessage("null"),
1195+
}, nil),
11901196
},
11911197
wantSessionID: false,
11921198
},

0 commit comments

Comments
 (0)