From 97fc489d5f3a0ef9c10fb6d5aa0b85623ba01ba8 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 3 Sep 2025 08:02:22 -0400 Subject: [PATCH] mcp: set content to marshaled output 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. --- mcp/server.go | 30 ++++++++++++++++++++---------- mcp/streamable_test.go | 10 ++++++++-- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index c496b33a..2e7fbcc6 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -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 } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index c99ca782..0cb7f955 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -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, },