diff --git a/mcp/tools.go b/mcp/tools.go index 500503e2a..c762ab087 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -486,6 +486,11 @@ func (r CallToolResult) MarshalJSON() ([]byte, error) { } m["content"] = content + // Marshal StructuredContent if present + if r.StructuredContent != nil { + m["structuredContent"] = r.StructuredContent + } + // Marshal IsError if true if r.IsError { m["isError"] = r.IsError @@ -526,6 +531,11 @@ func (r *CallToolResult) UnmarshalJSON(data []byte) error { } } + // Unmarshal StructuredContent if present + if structured, ok := raw["structuredContent"]; ok { + r.StructuredContent = structured + } + // Unmarshal IsError if isError, ok := raw["isError"]; ok { if isErrorBool, ok := isError.(bool); ok { diff --git a/mcp/tools_test.go b/mcp/tools_test.go index 7beec31dd..25876a62d 100644 --- a/mcp/tools_test.go +++ b/mcp/tools_test.go @@ -306,7 +306,6 @@ func TestParseToolCallToolRequest(t *testing.T) { param15 := ParseInt64(request, "string_value", 1) assert.Equal(t, fmt.Sprintf("%T", param15), "int64") t.Logf("param15 type: %T,value:%v", param15, param15) - } func TestCallToolRequestBindArguments(t *testing.T) { @@ -580,6 +579,567 @@ func TestNewToolResultStructured(t *testing.T) { assert.NotNil(t, result.StructuredContent) } +// TestCallToolResultMarshalJSON tests the custom JSON marshaling of CallToolResult +func TestCallToolResultMarshalJSON(t *testing.T) { + tests := []struct { + name string + result CallToolResult + expected map[string]any + }{ + { + name: "basic result with text content", + result: CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{"key": "value"}), + }, + Content: []Content{ + TextContent{Type: "text", Text: "Hello, world!"}, + }, + IsError: false, + }, + expected: map[string]any{ + "_meta": map[string]any{"key": "value"}, + "content": []any{ + map[string]any{ + "type": "text", + "text": "Hello, world!", + }, + }, + }, + }, + { + name: "result with structured content", + result: CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{"key": "value"}), + }, + Content: []Content{ + TextContent{Type: "text", Text: "Operation completed"}, + }, + StructuredContent: map[string]any{ + "status": "success", + "count": 42, + "message": "Data processed successfully", + }, + IsError: false, + }, + expected: map[string]any{ + "_meta": map[string]any{"key": "value"}, + "content": []any{ + map[string]any{ + "type": "text", + "text": "Operation completed", + }, + }, + "structuredContent": map[string]any{ + "status": "success", + "count": float64(42), // JSON numbers are unmarshaled as float64 + "message": "Data processed successfully", + }, + }, + }, + { + name: "error result", + result: CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{"error_code": "E001"}), + }, + Content: []Content{ + TextContent{Type: "text", Text: "An error occurred"}, + }, + IsError: true, + }, + expected: map[string]any{ + "_meta": map[string]any{"error_code": "E001"}, + "content": []any{ + map[string]any{ + "type": "text", + "text": "An error occurred", + }, + }, + "isError": true, + }, + }, + { + name: "result with multiple content types", + result: CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{"session_id": "12345"}), + }, + Content: []Content{ + TextContent{Type: "text", Text: "Processing complete"}, + ImageContent{Type: "image", Data: "base64-encoded-image-data", MIMEType: "image/jpeg"}, + }, + StructuredContent: map[string]any{ + "processed_items": 100, + "errors": 0, + }, + IsError: false, + }, + expected: map[string]any{ + "_meta": map[string]any{"session_id": "12345"}, + "content": []any{ + map[string]any{ + "type": "text", + "text": "Processing complete", + }, + map[string]any{ + "type": "image", + "data": "base64-encoded-image-data", + "mimeType": "image/jpeg", + }, + }, + "structuredContent": map[string]any{ + "processed_items": float64(100), + "errors": float64(0), + }, + }, + }, + { + name: "result with nil structured content", + result: CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{"key": "value"}), + }, + Content: []Content{ + TextContent{Type: "text", Text: "Simple result"}, + }, + StructuredContent: nil, + IsError: false, + }, + expected: map[string]any{ + "_meta": map[string]any{"key": "value"}, + "content": []any{ + map[string]any{ + "type": "text", + "text": "Simple result", + }, + }, + }, + }, + { + name: "result with empty content array", + result: CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{"key": "value"}), + }, + Content: []Content{}, + StructuredContent: map[string]any{ + "data": "structured only", + }, + IsError: false, + }, + expected: map[string]any{ + "_meta": map[string]any{"key": "value"}, + "content": []any{}, + "structuredContent": map[string]any{ + "data": "structured only", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Marshal the result + data, err := json.Marshal(tt.result) + assert.NoError(t, err) + + // Unmarshal to map for comparison + var result map[string]any + err = json.Unmarshal(data, &result) + assert.NoError(t, err) + + // Compare expected fields + for key, expectedValue := range tt.expected { + assert.Contains(t, result, key, "Result should contain key: %s", key) + assert.Equal(t, expectedValue, result[key], "Value for key %s should match", key) + } + + // Verify that unexpected fields are not present + for key := range result { + if key != "_meta" && key != "content" && key != "structuredContent" && key != "isError" { + t.Errorf("Unexpected field in result: %s", key) + } + } + }) + } +} + +// TestCallToolResultUnmarshalJSON tests the custom JSON unmarshaling of CallToolResult +func TestCallToolResultUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + jsonData string + expected CallToolResult + wantErr bool + }{ + { + name: "basic result with text content", + jsonData: `{ + "_meta": {"key": "value"}, + "content": [ + {"type": "text", "text": "Hello, world!"} + ] + }`, + expected: CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{"key": "value"}), + }, + Content: []Content{ + TextContent{Type: "text", Text: "Hello, world!"}, + }, + IsError: false, + }, + wantErr: false, + }, + { + name: "result with structured content", + jsonData: `{ + "_meta": {"key": "value"}, + "content": [ + {"type": "text", "text": "Operation completed"} + ], + "structuredContent": { + "status": "success", + "count": 42, + "message": "Data processed successfully" + } + }`, + expected: CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{"key": "value"}), + }, + Content: []Content{ + TextContent{Type: "text", Text: "Operation completed"}, + }, + StructuredContent: map[string]any{ + "status": "success", + "count": float64(42), + "message": "Data processed successfully", + }, + IsError: false, + }, + wantErr: false, + }, + { + name: "error result", + jsonData: `{ + "_meta": {"error_code": "E001"}, + "content": [ + {"type": "text", "text": "An error occurred"} + ], + "isError": true + }`, + expected: CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{"error_code": "E001"}), + }, + Content: []Content{ + TextContent{Type: "text", Text: "An error occurred"}, + }, + IsError: true, + }, + wantErr: false, + }, + { + name: "result with multiple content types", + jsonData: `{ + "_meta": {"session_id": "12345"}, + "content": [ + {"type": "text", "text": "Processing complete"}, + {"type": "image", "data": "base64-encoded-image-data", "mimeType": "image/jpeg"} + ], + "structuredContent": { + "processed_items": 100, + "errors": 0 + } + }`, + expected: CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{"session_id": "12345"}), + }, + Content: []Content{ + TextContent{Type: "text", Text: "Processing complete"}, + ImageContent{Type: "image", Data: "base64-encoded-image-data", MIMEType: "image/jpeg"}, + }, + StructuredContent: map[string]any{ + "processed_items": float64(100), + "errors": float64(0), + }, + IsError: false, + }, + wantErr: false, + }, + { + name: "result with nil structured content", + jsonData: `{ + "_meta": {"key": "value"}, + "content": [ + {"type": "text", "text": "Simple result"} + ] + }`, + expected: CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{"key": "value"}), + }, + Content: []Content{ + TextContent{Type: "text", Text: "Simple result"}, + }, + StructuredContent: nil, + IsError: false, + }, + wantErr: false, + }, + { + name: "result with empty content array", + jsonData: `{ + "_meta": {"key": "value"}, + "content": [], + "structuredContent": { + "data": "structured only" + } + }`, + expected: CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{"key": "value"}), + }, + Content: []Content{}, + StructuredContent: map[string]any{ + "data": "structured only", + }, + IsError: false, + }, + wantErr: false, + }, + { + name: "invalid JSON", + jsonData: `{invalid json}`, + wantErr: true, + }, + { + name: "result with missing content field", + jsonData: `{ + "_meta": {"key": "value"}, + "structuredContent": {"data": "no content"} + }`, + expected: CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{"key": "value"}), + }, + Content: nil, + StructuredContent: map[string]any{ + "data": "no content", + }, + IsError: false, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var result CallToolResult + err := json.Unmarshal([]byte(tt.jsonData), &result) + + if tt.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + + // Compare Meta + if tt.expected.Meta != nil { + assert.Equal(t, tt.expected.Meta, result.Meta) + } + + // Compare Content + assert.Len(t, result.Content, len(tt.expected.Content)) + for i, expectedContent := range tt.expected.Content { + if i < len(result.Content) { + // Compare content types and values + switch expected := expectedContent.(type) { + case TextContent: + if actual, ok := result.Content[i].(TextContent); ok { + assert.Equal(t, expected.Text, actual.Text) + } else { + t.Errorf("Expected TextContent at index %d, got %T", i, result.Content[i]) + } + case ImageContent: + if actual, ok := result.Content[i].(ImageContent); ok { + assert.Equal(t, expected.Data, actual.Data) + assert.Equal(t, expected.MIMEType, actual.MIMEType) + } else { + t.Errorf("Expected ImageContent at index %d, got %T", i, result.Content[i]) + } + } + } + } + + // Compare StructuredContent + assert.Equal(t, tt.expected.StructuredContent, result.StructuredContent) + + // Compare IsError + assert.Equal(t, tt.expected.IsError, result.IsError) + }) + } +} + +// TestCallToolResultRoundTrip tests that marshaling and unmarshaling preserves all data +func TestCallToolResultRoundTrip(t *testing.T) { + original := CallToolResult{ + Result: Result{ + Meta: NewMetaFromMap(map[string]any{ + "session_id": "12345", + "user_id": "user123", + "timestamp": "2024-01-01T00:00:00Z", + }), + }, + Content: []Content{ + TextContent{Type: "text", Text: "Operation started"}, + ImageContent{Type: "image", Data: "base64-encoded-chart-data", MIMEType: "image/png"}, + TextContent{Type: "text", Text: "Operation completed successfully"}, + }, + StructuredContent: map[string]any{ + "status": "success", + "processed_count": float64(150.0), + "error_count": float64(0.0), + "warnings": []any{"Minor issue detected"}, + "metadata": map[string]any{ + "version": "1.0.0", + "build": "2024-01-01", + }, + }, + IsError: false, + } + + // Marshal to JSON + data, err := json.Marshal(original) + assert.NoError(t, err) + + // Unmarshal back + var unmarshaled CallToolResult + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err) + + // Verify all fields are preserved + assert.Equal(t, original.Meta, unmarshaled.Meta) + assert.Equal(t, original.IsError, unmarshaled.IsError) + assert.Equal(t, original.StructuredContent, unmarshaled.StructuredContent) + + // Verify content array + assert.Len(t, unmarshaled.Content, len(original.Content)) + for i, expectedContent := range original.Content { + if i < len(unmarshaled.Content) { + switch expected := expectedContent.(type) { + case TextContent: + if actual, ok := unmarshaled.Content[i].(TextContent); ok { + assert.Equal(t, expected.Text, actual.Text) + } else { + t.Errorf("Expected TextContent at index %d, got %T", i, unmarshaled.Content[i]) + } + case ImageContent: + if actual, ok := unmarshaled.Content[i].(ImageContent); ok { + assert.Equal(t, expected.Data, actual.Data) + assert.Equal(t, expected.MIMEType, actual.MIMEType) + } else { + t.Errorf("Expected ImageContent at index %d, got %T", i, unmarshaled.Content[i]) + } + } + } + } +} + +// TestCallToolResultEdgeCases tests edge cases for CallToolResult marshaling/unmarshaling +func TestCallToolResultEdgeCases(t *testing.T) { + tests := []struct { + name string + result CallToolResult + jsonData string + }{ + { + name: "result with complex structured content", + result: CallToolResult{ + Content: []Content{ + TextContent{Type: "text", Text: "Complex data returned"}, + }, + StructuredContent: map[string]any{ + "nested": map[string]any{ + "array": []any{1, 2, 3, "string", true, nil}, + "object": map[string]any{ + "deep": map[string]any{ + "value": "very deep", + }, + }, + }, + "mixed_types": []any{ + map[string]any{"type": "object"}, + "string", + 42.5, + true, + nil, + }, + }, + }, + }, + { + name: "result with empty structured content object", + result: CallToolResult{ + Content: []Content{ + TextContent{Type: "text", Text: "Empty structured content"}, + }, + StructuredContent: map[string]any{}, + }, + }, + { + name: "result with null structured content in JSON", + jsonData: `{ + "content": [{"type": "text", "text": "Null structured content"}], + "structuredContent": null + }`, + }, + { + name: "result with missing isError field", + jsonData: `{ + "content": [{"type": "text", "text": "No error field"}] + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var data []byte + var err error + + if tt.jsonData != "" { + // Test unmarshaling from JSON + var result CallToolResult + err = json.Unmarshal([]byte(tt.jsonData), &result) + assert.NoError(t, err) + + // Verify the result can be marshaled back + data, err = json.Marshal(result) + assert.NoError(t, err) + } else { + // Test marshaling the result + data, err = json.Marshal(tt.result) + assert.NoError(t, err) + + // Verify it can be unmarshaled back + var result CallToolResult + err = json.Unmarshal(data, &result) + assert.NoError(t, err) + } + + // Verify the JSON is valid + var jsonMap map[string]any + err = json.Unmarshal(data, &jsonMap) + assert.NoError(t, err) + }) + } +} + // TestNewItemsAPICompatibility tests that the new Items API functions // generate the same schema as the original Items() function with manual schema objects func TestNewItemsAPICompatibility(t *testing.T) { diff --git a/mcp/utils.go b/mcp/utils.go index 4d2b170b4..fd2ba0b0d 100644 --- a/mcp/utils.go +++ b/mcp/utils.go @@ -670,6 +670,12 @@ func ParseCallToolResult(rawMessage *json.RawMessage) (*CallToolResult, error) { result.Content = append(result.Content, content) } + // Handle structured content + structuredContent, ok := jsonContent["structuredContent"] + if ok { + result.StructuredContent = structuredContent + } + return &result, nil } diff --git a/mcptest/mcptest_test.go b/mcptest/mcptest_test.go index 18922cb84..3e4be38e3 100644 --- a/mcptest/mcptest_test.go +++ b/mcptest/mcptest_test.go @@ -78,6 +78,95 @@ func resultToString(result *mcp.CallToolResult) (string, error) { return b.String(), nil } +func TestServerWithToolStructuredContent(t *testing.T) { + ctx := context.Background() + + srv, err := mcptest.NewServer(t, server.ServerTool{ + Tool: mcp.NewTool("get_user", + mcp.WithDescription("Gets user information with structured data."), + mcp.WithString("user_id", mcp.Description("The user ID to look up.")), + ), + Handler: structuredContentHandler, + }) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + client := srv.Client() + + var req mcp.CallToolRequest + req.Params.Name = "get_user" + req.Params.Arguments = map[string]any{ + "user_id": "123", + } + + result, err := client.CallTool(ctx, req) + if err != nil { + t.Fatal("CallTool:", err) + } + + if result.IsError { + t.Fatalf("unexpected error result: %+v", result) + } + + if len(result.Content) != 1 { + t.Fatalf("Expected 1 content item, got %d", len(result.Content)) + } + + // Check text content (fallback) + textContent, ok := result.Content[0].(mcp.TextContent) + if !ok { + t.Fatalf("Expected content to be TextContent, got %T", result.Content[0]) + } + expectedText := "User found" + if textContent.Text != expectedText { + t.Errorf("Expected text %q, got %q", expectedText, textContent.Text) + } + + // Check structured content + if result.StructuredContent == nil { + t.Fatal("Expected StructuredContent to be present") + } + + structuredData, ok := result.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("Expected StructuredContent to be map[string]any, got %T", result.StructuredContent) + } + + // Verify structured data + if structuredData["id"] != "123" { + t.Errorf("Expected id '123', got %v", structuredData["id"]) + } + if structuredData["name"] != "John Doe" { + t.Errorf("Expected name 'John Doe', got %v", structuredData["name"]) + } + if structuredData["email"] != "john@example.com" { + t.Errorf("Expected email 'john@example.com', got %v", structuredData["email"]) + } + if structuredData["active"] != true { + t.Errorf("Expected active true, got %v", structuredData["active"]) + } +} + +func structuredContentHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + userID, ok := request.GetArguments()["user_id"].(string) + if !ok { + return mcp.NewToolResultError("user_id parameter is required"), nil + } + + // Create structured data + userData := map[string]any{ + "id": userID, + "name": "John Doe", + "email": "john@example.com", + "active": true, + } + + // Use NewToolResultStructured to create result with both text fallback and structured content + return mcp.NewToolResultStructured(userData, "User found"), nil +} + func TestServerWithPrompt(t *testing.T) { ctx := context.Background()