diff --git a/mcp/server.go b/mcp/server.go index c496b33..2e7fbcc 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 c99ca78..0cb7f95 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, },