From bc50f3fd6befc8d4aca4b41a633bc2e217d71fdb Mon Sep 17 00:00:00 2001 From: ash Date: Sat, 5 Sep 2026 04:40:48 +0000 Subject: [PATCH] client: Prefer structuredContent in toolJSON get_equity_historicals can return MCP StructuredContent with empty or non-JSON TextContent. Prefer StructuredContent (marshal to RawMessage) so paper tradey once/run can parse historicals. https://git.s1d3sw1ped.com/s1d3sw1ped/robinhood-agentic-mcp/issues/13 --- client/session.go | 7 ++++ client/session_test.go | 87 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 client/session_test.go diff --git a/client/session.go b/client/session.go index d95a577..2c9fd7e 100644 --- a/client/session.go +++ b/client/session.go @@ -101,6 +101,13 @@ func toolJSON(res *mcp.CallToolResult) (json.RawMessage, error) { if err := res.GetError(); err != nil { return nil, err } + if res.StructuredContent != nil { + b, err := json.Marshal(res.StructuredContent) + if err != nil { + return nil, err + } + return json.RawMessage(b), nil + } var b []byte for _, c := range res.Content { t, ok := c.(*mcp.TextContent) diff --git a/client/session_test.go b/client/session_test.go new file mode 100644 index 0000000..764b251 --- /dev/null +++ b/client/session_test.go @@ -0,0 +1,87 @@ +package client + +import ( + "encoding/json" + "errors" + "testing" + + mcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestToolJSON(t *testing.T) { + t.Parallel() + tests := []struct { + name string + res *mcp.CallToolResult + want string + }{ + { + name: "structured-only", + res: &mcp.CallToolResult{ + StructuredContent: map[string]any{"symbol": "MU"}, + }, + want: `{"symbol":"MU"}`, + }, + { + name: "text JSON", + res: &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: `{"symbol":"MU"}`}}, + }, + want: `{"symbol":"MU"}`, + }, + { + name: "text non-JSON + structured", + res: &mcp.CallToolResult{ + StructuredContent: json.RawMessage(`{"symbol":"MU"}`), + Content: []mcp.Content{&mcp.TextContent{Text: "not json"}}, + }, + want: `{"symbol":"MU"}`, + }, + { + name: "empty", + res: &mcp.CallToolResult{}, + want: `{}`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := toolJSON(tc.res) + if err != nil { + t.Fatal(err) + } + if string(got) != tc.want { + t.Fatalf("got %s want %s", got, tc.want) + } + }) + } +} + +func TestToolJSON_nilRes(t *testing.T) { + t.Parallel() + _, err := toolJSON(nil) + if err == nil || err.Error() != "empty tool result" { + t.Fatalf("%v", err) + } +} + +func TestToolJSON_getError(t *testing.T) { + t.Parallel() + inner := errors.New("tool failed") + res := &mcp.CallToolResult{} + res.SetError(inner) + _, err := toolJSON(res) + if !errors.Is(err, inner) { + t.Fatalf("%v", err) + } +} + +func TestToolJSON_nonJSONText(t *testing.T) { + t.Parallel() + _, err := toolJSON(&mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "not json"}}, + }) + if err == nil || err.Error() != "non-json tool result" { + t.Fatalf("%v", err) + } +}