diff --git a/mcp/content.go b/mcp/content.go index adb2048e..fb32abfb 100644 --- a/mcp/content.go +++ b/mcp/content.go @@ -254,30 +254,25 @@ type ToolResultContent struct { } func (c *ToolResultContent) MarshalJSON() ([]byte, error) { - // Marshal nested content - var contentWire []*wireContent + var contentWire []json.RawMessage for _, content := range c.Content { data, err := content.MarshalJSON() if err != nil { return nil, err } - var w wireContent - if err := internaljson.Unmarshal(data, &w); err != nil { - return nil, err - } - contentWire = append(contentWire, &w) + contentWire = append(contentWire, data) } if contentWire == nil { - contentWire = []*wireContent{} // avoid JSON null + contentWire = []json.RawMessage{} // avoid JSON null } wire := struct { - Type string `json:"type"` - ToolUseID string `json:"toolUseId"` - Content []*wireContent `json:"content"` - StructuredContent any `json:"structuredContent,omitempty"` - IsError bool `json:"isError,omitempty"` - Meta Meta `json:"_meta,omitempty"` + Type string `json:"type"` + ToolUseID string `json:"toolUseId"` + Content []json.RawMessage `json:"content"` + StructuredContent any `json:"structuredContent,omitempty"` + IsError bool `json:"isError,omitempty"` + Meta Meta `json:"_meta,omitempty"` }{ Type: "tool_result", ToolUseID: c.ToolUseID, diff --git a/mcp/sampling_test.go b/mcp/sampling_test.go index 7a3ecab6..4f7fbefb 100644 --- a/mcp/sampling_test.go +++ b/mcp/sampling_test.go @@ -10,6 +10,8 @@ package mcp import ( "context" + "encoding/json" + "errors" "strings" "testing" @@ -155,6 +157,91 @@ func TestSamplingWithTools_ToolResult(t *testing.T) { } } +func TestSamplingWithTools_ToolResultContent(t *testing.T) { + for _, tt := range []struct { + name string + content []Content + want string + }{ + {"empty text", []Content{&TextContent{}}, `[{"type":"text","text":""}]`}, + {"empty image", []Content{&ImageContent{}}, `[{"type":"image","mimeType":"","data":""}]`}, + {"empty audio", []Content{&AudioContent{}}, `[{"type":"audio","mimeType":"","data":""}]`}, + {"nil content", nil, `[]`}, + {"empty content", []Content{}, `[]`}, + { + "text metadata", + []Content{&TextContent{Text: "done", Meta: Meta{"sequence": int64(9007199254740993), "source": "test"}, Annotations: &Annotations{Priority: 1}}}, + `[{"type":"text","text":"done","_meta":{"sequence":9007199254740993,"source":"test"},"annotations":{"priority":1}}]`, + }, + { + "mixed content", + []Content{&TextContent{}, &ImageContent{MIMEType: "image/png", Data: []byte("image")}, &AudioContent{MIMEType: "audio/wav", Data: []byte("audio")}}, + `[{"type":"text","text":""},{"type":"image","mimeType":"image/png","data":"aW1hZ2U="},{"type":"audio","mimeType":"audio/wav","data":"YXVkaW8="}]`, + }, + { + "empty resource", + []Content{&EmbeddedResource{Resource: &ResourceContents{URI: "file:///empty.txt"}}}, + `[{"type":"resource","resource":{"uri":"file:///empty.txt","text":""}}]`, + }, + } { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + ct, st := NewInMemoryTransports() + var logbuf safeBuffer + client := NewClient(testImpl, &ClientOptions{ + CreateMessageWithToolsHandler: func(context.Context, *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) { + return &CreateMessageWithToolsResult{ + Model: "test-model", Role: "assistant", Content: []Content{&TextContent{Text: "done"}}, + }, nil + }, + Capabilities: &ClientCapabilities{ + Sampling: &SamplingCapabilities{Tools: &SamplingToolsCapabilities{}}, + }, + }) + ss, err := NewServer(testImpl, nil).Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + cs, err := client.Connect(ctx, &LoggingTransport{Transport: ct, Writer: &logbuf}, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + _, err = ss.CreateMessageWithTools(ctx, &CreateMessageWithToolsParams{ + MaxTokens: 100, + Messages: []*SamplingMessageV2{ + {Role: "assistant", Content: []Content{&ToolUseContent{ID: "tool_1", Name: "test", Input: map[string]any{}}}}, + {Role: "user", Content: []Content{&ToolResultContent{ToolUseID: "tool_1", Content: tt.content}}}, + }, + }) + if err != nil { + t.Fatal(err) + } + // Inspect the received JSON: decoding into Go values hides missing empty fields. + logbuf.mu.Lock() + logs := logbuf.buf.String() + logbuf.mu.Unlock() + want := `{"type":"tool_result","toolUseId":"tool_1","content":` + tt.want + `}` + if !strings.Contains(logs, want) { + t.Errorf("sampling request missing %s:\n%s", want, logs) + } + }) + } +} + +func TestToolResultContentMarshalError(t *testing.T) { + content := &ToolResultContent{ + ToolUseID: "tool_1", + Content: []Content{&TextContent{Meta: Meta{"invalid": make(chan int)}}}, + } + _, err := content.MarshalJSON() + var unsupported *json.UnsupportedTypeError + if !errors.As(err, &unsupported) { + t.Fatalf("MarshalJSON() error = %v, want json.UnsupportedTypeError", err) + } +} + func TestSamplingToolsCapabilities(t *testing.T) { ctx := context.Background()