fix: parse MCP result payloads and drop place idempotency_key

Typed result structs replace empty envelopes. Equity place sends
ref_id only so live additionalProperties:false schemas accept the call.
This commit is contained in:
2026-09-01 14:18:39 -05:00
parent f52a19181d
commit a6bf8632ce
22 changed files with 2225 additions and 74 deletions
+95 -8
View File
@@ -29,8 +29,18 @@ type PositionsRequest struct {
Cursor string
}
// Position is one holding from get_equity_positions.
type Position struct {
Symbol string
Qty decimal.Decimal
AvgCost decimal.Decimal
}
// PositionsResult is the parsed get_equity_positions payload.
type PositionsResult struct{}
type PositionsResult struct {
Positions []Position
NextCursor string
}
// TaxLotsRequest is the argument set for get_equity_tax_lots.
type TaxLotsRequest struct {
@@ -39,8 +49,21 @@ type TaxLotsRequest struct {
Cursor string
}
// TaxLotRow is one open tax lot from get_equity_tax_lots.
type TaxLotRow struct {
OpenLotID string
Quantity decimal.Decimal
CostBasis decimal.Decimal
AcquiredAt string
Term string
QuantityAvail decimal.Decimal
}
// TaxLotsResult is the parsed get_equity_tax_lots payload.
type TaxLotsResult struct{}
type TaxLotsResult struct {
Lots []TaxLotRow
NextCursor string
}
// QuotesRequest is the argument set for get_equity_quotes.
type QuotesRequest struct {
@@ -74,7 +97,10 @@ type OrdersRequest struct {
}
// OrdersResult is the parsed get_equity_orders payload.
type OrdersResult struct{}
type OrdersResult struct {
Orders []Order
NextCursor string
}
// TradabilityRequest is the argument set for get_equity_tradability.
type TradabilityRequest struct {
@@ -82,8 +108,17 @@ type TradabilityRequest struct {
Symbols []string
}
// SymbolTradability is per-symbol eligibility from get_equity_tradability.
type SymbolTradability struct {
Symbol string
Tradable bool
Fractional bool
}
// TradabilityResult is the parsed get_equity_tradability payload.
type TradabilityResult struct{}
type TradabilityResult struct {
Symbols []SymbolTradability
}
// HistoricalsRequest is the argument set for get_equity_historicals.
type HistoricalsRequest struct {
@@ -115,16 +150,44 @@ type FundamentalsRequest struct {
Bounds string
}
// Fundamentals is one symbol's facts from get_equity_fundamentals.
type Fundamentals struct {
Symbol string
AvgVolume decimal.Decimal
InstrumentKind string
MarketCap decimal.Decimal
PE decimal.Decimal
High52Week decimal.Decimal
Low52Week decimal.Decimal
}
// FundamentalsResult is the parsed get_equity_fundamentals payload.
type FundamentalsResult struct{}
type FundamentalsResult struct {
Fundamentals []Fundamentals
}
// PriceBookRequest is the argument set for get_equity_price_book.
type PriceBookRequest struct {
Symbols []string
}
// BookLevel is one bid or ask rung.
type BookLevel struct {
Price decimal.Decimal
Quantity decimal.Decimal
}
// PriceBook is a Level-2 snapshot for one symbol.
type PriceBook struct {
Symbol string
Bids []BookLevel
Asks []BookLevel
}
// PriceBookResult is the parsed get_equity_price_book payload.
type PriceBookResult struct{}
type PriceBookResult struct {
Books []PriceBook
}
// TechnicalIndicatorsRequest is the argument set for get_equity_technical_indicators.
type TechnicalIndicatorsRequest struct {
@@ -145,8 +208,20 @@ type TechnicalIndicatorsRequest struct {
Method string
}
// IndicatorPoint is one computed indicator bar.
type IndicatorPoint struct {
Time time.Time
Value decimal.Decimal
Upper decimal.Decimal
Lower decimal.Decimal
MACD decimal.Decimal
Signal decimal.Decimal
}
// TechnicalIndicatorsResult is the parsed get_equity_technical_indicators payload.
type TechnicalIndicatorsResult struct{}
type TechnicalIndicatorsResult struct {
Points []IndicatorPoint
}
// NewsRequest is the argument set for get_equity_news.
type NewsRequest struct {
@@ -155,8 +230,20 @@ type NewsRequest struct {
Cursor string
}
// NewsArticle is one article from get_equity_news.
type NewsArticle struct {
Title string
URL string
PublishedAt string
Source string
Summary string
}
// NewsResult is the parsed get_equity_news payload.
type NewsResult struct{}
type NewsResult struct {
Articles []NewsArticle
NextCursor string
}
// Positions calls get_equity_positions.
func (c *Client) Positions(ctx context.Context, req PositionsRequest) (PositionsResult, error) {
+328
View File
@@ -0,0 +1,328 @@
package equity
import (
"encoding/json"
"time"
"s1d3sw1ped/robinhood-agentic-mcp/internal/wire"
)
func (r *PositionsResult) UnmarshalJSON(b []byte) error {
type row struct {
Symbol string `json:"symbol"`
Quantity any `json:"quantity"`
Qty any `json:"qty"`
AverageBuyPrice any `json:"average_buy_price"`
AvgCost any `json:"avg_cost"`
}
rows, next, err := wire.UnmarshalRows[row](b, "positions", "results")
if err != nil {
return err
}
r.NextCursor = next
r.Positions = make([]Position, 0, len(rows))
for _, row := range rows {
qty, err := firstDec(row.Quantity, row.Qty)
if err != nil {
return err
}
avg, err := firstDec(row.AverageBuyPrice, row.AvgCost)
if err != nil {
return err
}
r.Positions = append(r.Positions, Position{Symbol: row.Symbol, Qty: qty, AvgCost: avg})
}
return nil
}
func (r *TaxLotsResult) UnmarshalJSON(b []byte) error {
type row struct {
OpenLotID string `json:"open_lot_id"`
ID string `json:"id"`
Quantity any `json:"quantity"`
QuantityAvailable any `json:"quantity_available"`
CostBasis any `json:"cost_basis"`
AcquiredAt string `json:"acquired_at"`
AcquisitionDate string `json:"acquisition_date"`
Term string `json:"term"`
HoldingPeriod string `json:"holding_period"`
}
rows, next, err := wire.UnmarshalRows[row](b, "tax_lots", "lots", "results")
if err != nil {
return err
}
r.NextCursor = next
r.Lots = make([]TaxLotRow, 0, len(rows))
for _, row := range rows {
id := row.OpenLotID
if id == "" {
id = row.ID
}
qty, err := firstDec(row.Quantity)
if err != nil {
return err
}
avail, err := firstDec(row.QuantityAvailable)
if err != nil {
return err
}
cost, err := firstDec(row.CostBasis)
if err != nil {
return err
}
when := row.AcquiredAt
if when == "" {
when = row.AcquisitionDate
}
term := row.Term
if term == "" {
term = row.HoldingPeriod
}
r.Lots = append(r.Lots, TaxLotRow{
OpenLotID: id,
Quantity: qty,
QuantityAvail: avail,
CostBasis: cost,
AcquiredAt: when,
Term: term,
})
}
return nil
}
func (r *OrdersResult) UnmarshalJSON(b []byte) error {
rows, next, err := wire.UnmarshalRows[Order](b, "orders", "results")
if err != nil {
return err
}
r.NextCursor = next
if len(rows) == 0 {
var one Order
if err := json.Unmarshal(wire.Unwrap(b), &one); err == nil && one.ID != "" {
rows = []Order{one}
}
}
r.Orders = rows
return nil
}
func (r *TradabilityResult) UnmarshalJSON(b []byte) error {
type row struct {
Symbol string `json:"symbol"`
Tradable bool `json:"tradable"`
IsTradable bool `json:"is_tradable"`
Fractional bool `json:"fractional"`
FractionalOk bool `json:"fractional_tradable"`
FractionalTrad bool `json:"fractionally_tradable"`
}
rows, _, err := wire.UnmarshalRows[row](b, "results", "tradability", "instruments")
if err != nil {
return err
}
if len(rows) == 0 {
var one row
if json.Unmarshal(wire.Unwrap(b), &one) == nil && (one.Symbol != "" || one.Tradable || one.IsTradable || one.Fractional) {
rows = []row{one}
}
}
r.Symbols = make([]SymbolTradability, 0, len(rows))
for _, row := range rows {
r.Symbols = append(r.Symbols, SymbolTradability{
Symbol: row.Symbol,
Tradable: row.Tradable || row.IsTradable,
Fractional: row.Fractional || row.FractionalOk || row.FractionalTrad,
})
}
return nil
}
func (r *FundamentalsResult) UnmarshalJSON(b []byte) error {
type row struct {
Symbol string `json:"symbol"`
AverageVolume any `json:"average_volume"`
AvgVolume any `json:"avg_volume"`
Type string `json:"type"`
Instrument string `json:"instrument_kind"`
MarketCap any `json:"market_cap"`
PE any `json:"pe_ratio"`
High52 any `json:"high_52_weeks"`
Low52 any `json:"low_52_weeks"`
}
rows, _, err := wire.UnmarshalRows[row](b, "fundamentals", "results")
if err != nil {
return err
}
if len(rows) == 0 {
var one row
if json.Unmarshal(wire.Unwrap(b), &one) == nil && one.Symbol != "" {
rows = []row{one}
}
}
r.Fundamentals = make([]Fundamentals, 0, len(rows))
for _, row := range rows {
kind := row.Instrument
if kind == "" {
kind = row.Type
}
avg, err := firstDec(row.AverageVolume, row.AvgVolume)
if err != nil {
return err
}
cap, err := firstDec(row.MarketCap)
if err != nil {
return err
}
pe, err := firstDec(row.PE)
if err != nil {
return err
}
hi, err := firstDec(row.High52)
if err != nil {
return err
}
lo, err := firstDec(row.Low52)
if err != nil {
return err
}
r.Fundamentals = append(r.Fundamentals, Fundamentals{
Symbol: row.Symbol,
AvgVolume: avg,
InstrumentKind: kind,
MarketCap: cap,
PE: pe,
High52Week: hi,
Low52Week: lo,
})
}
return nil
}
func (r *PriceBookResult) UnmarshalJSON(b []byte) error {
type level struct {
Price any `json:"price"`
Quantity any `json:"quantity"`
Size any `json:"size"`
}
type book struct {
Symbol string `json:"symbol"`
Bids []level `json:"bids"`
Asks []level `json:"asks"`
}
rows, _, err := wire.UnmarshalRows[book](b, "books", "price_books", "results")
if err != nil {
return err
}
if len(rows) == 0 {
var one book
if json.Unmarshal(wire.Unwrap(b), &one) == nil && one.Symbol != "" {
rows = []book{one}
}
}
levels := func(in []level) ([]BookLevel, error) {
out := make([]BookLevel, 0, len(in))
for _, lv := range in {
px, err := firstDec(lv.Price)
if err != nil {
return nil, err
}
qty, err := firstDec(lv.Quantity, lv.Size)
if err != nil {
return nil, err
}
out = append(out, BookLevel{Price: px, Quantity: qty})
}
return out, nil
}
r.Books = make([]PriceBook, 0, len(rows))
for _, row := range rows {
bids, err := levels(row.Bids)
if err != nil {
return err
}
asks, err := levels(row.Asks)
if err != nil {
return err
}
r.Books = append(r.Books, PriceBook{Symbol: row.Symbol, Bids: bids, Asks: asks})
}
return nil
}
func (r *TechnicalIndicatorsResult) UnmarshalJSON(b []byte) error {
type row struct {
BeginsAt string `json:"begins_at"`
Time string `json:"time"`
Timestamp string `json:"timestamp"`
Value any `json:"value"`
Upper any `json:"upper"`
Lower any `json:"lower"`
MACD any `json:"macd"`
Signal any `json:"signal"`
}
rows, _, err := wire.UnmarshalRows[row](b, "data_points", "points", "results", "values")
if err != nil {
return err
}
r.Points = make([]IndicatorPoint, 0, len(rows))
for _, row := range rows {
s := row.BeginsAt
if s == "" {
s = row.Time
}
if s == "" {
s = row.Timestamp
}
ts, err := time.Parse(time.RFC3339, s)
if err != nil {
continue
}
val, err := firstDec(row.Value)
if err != nil {
return err
}
up, err := firstDec(row.Upper)
if err != nil {
return err
}
lo, err := firstDec(row.Lower)
if err != nil {
return err
}
macd, err := firstDec(row.MACD)
if err != nil {
return err
}
sig, err := firstDec(row.Signal)
if err != nil {
return err
}
r.Points = append(r.Points, IndicatorPoint{Time: ts, Value: val, Upper: up, Lower: lo, MACD: macd, Signal: sig})
}
return nil
}
func (r *NewsResult) UnmarshalJSON(b []byte) error {
type row struct {
Title string `json:"title"`
URL string `json:"url"`
PublishedAt string `json:"published_at"`
Source string `json:"source"`
Summary string `json:"summary"`
}
rows, next, err := wire.UnmarshalRows[row](b, "news", "articles", "results")
if err != nil {
return err
}
r.NextCursor = next
r.Articles = make([]NewsArticle, 0, len(rows))
for _, row := range rows {
r.Articles = append(r.Articles, NewsArticle{
Title: row.Title,
URL: row.URL,
PublishedAt: row.PublishedAt,
Source: row.Source,
Summary: row.Summary,
})
}
return nil
}
+70
View File
@@ -0,0 +1,70 @@
package equity_test
import (
"context"
"encoding/json"
"testing"
decimal "github.com/alpacahq/alpacadecimal"
"s1d3sw1ped/robinhood-agentic-mcp/client"
"s1d3sw1ped/robinhood-agentic-mcp/equity"
)
func TestPositions_parsesEnvelope(t *testing.T) {
t.Parallel()
c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
return json.RawMessage(`{"positions":[{"symbol":"MU","quantity":"3","average_buy_price":"99.6"}],"next_cursor":"n1"}`), nil
}))
got, err := c.Positions(context.Background(), equity.PositionsRequest{AccountNumber: "acct"})
if err != nil {
t.Fatal(err)
}
if got.NextCursor != "n1" || len(got.Positions) != 1 || got.Positions[0].Symbol != "MU" {
t.Fatalf("%+v", got)
}
if !got.Positions[0].Qty.Equal(decimal.NewFromInt(3)) || !got.Positions[0].AvgCost.Equal(decimal.RequireFromString("99.6")) {
t.Fatalf("money %+v", got.Positions[0])
}
}
func TestOrders_parsesList(t *testing.T) {
t.Parallel()
c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
return json.RawMessage(`{"orders":[{"id":"o1","symbol":"MU","state":"filled","quantity":"2"}]}`), nil
}))
got, err := c.Orders(context.Background(), equity.OrdersRequest{AccountNumber: "acct"})
if err != nil {
t.Fatal(err)
}
if len(got.Orders) != 1 || got.Orders[0].ID != "o1" || !got.Orders[0].Qty.Equal(decimal.NewFromInt(2)) {
t.Fatalf("%+v", got)
}
}
func TestTradability_parsesFlags(t *testing.T) {
t.Parallel()
c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
return json.RawMessage(`{"symbol":"MU","tradable":true,"fractional_tradable":true}`), nil
}))
got, err := c.Tradability(context.Background(), equity.TradabilityRequest{AccountNumber: "acct", Symbols: []string{"MU"}})
if err != nil {
t.Fatal(err)
}
if len(got.Symbols) != 1 || !got.Symbols[0].Tradable || !got.Symbols[0].Fractional {
t.Fatalf("%+v", got)
}
}
func TestNews_parsesArticles(t *testing.T) {
t.Parallel()
c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
return json.RawMessage(`{"results":[{"title":"Hi","url":"https://x","published_at":"2026-01-01T00:00:00Z"}],"next_cursor":"c9"}`), nil
}))
got, err := c.News(context.Background(), equity.NewsRequest{Symbol: "MU"})
if err != nil {
t.Fatal(err)
}
if got.NextCursor != "c9" || len(got.Articles) != 1 || got.Articles[0].Title != "Hi" {
t.Fatalf("%+v", got)
}
}
+81 -7
View File
@@ -2,6 +2,7 @@ package equity
import (
"context"
"encoding/json"
decimal "github.com/alpacahq/alpacadecimal"
"s1d3sw1ped/robinhood-agentic-mcp/client"
@@ -42,9 +43,73 @@ type ReviewResult struct {
Warnings []string `json:"warnings"`
}
// Order is a placed equity order.
// Order is a placed or listed equity order.
type Order struct {
ID string `json:"id"`
ID string `json:"id"`
IdempotencyKey string `json:"idempotency_key"`
Symbol string `json:"symbol"`
Side string `json:"side"`
Qty decimal.Decimal `json:"-"`
FilledQty decimal.Decimal `json:"-"`
Price decimal.Decimal `json:"-"`
State string `json:"state"`
}
func (o *Order) UnmarshalJSON(b []byte) error {
var row struct {
ID string `json:"id"`
IdempotencyKey string `json:"idempotency_key"`
Symbol string `json:"symbol"`
Side string `json:"side"`
Quantity any `json:"quantity"`
Qty any `json:"qty"`
FilledQty any `json:"filled_quantity"`
CumulativeQty any `json:"cumulative_quantity"`
Price any `json:"price"`
AveragePrice any `json:"average_price"`
State string `json:"state"`
Status string `json:"status"`
}
if err := json.Unmarshal(b, &row); err != nil {
return err
}
o.ID = row.ID
o.IdempotencyKey = row.IdempotencyKey
o.Symbol = row.Symbol
o.Side = row.Side
o.State = row.State
if o.State == "" {
o.State = row.Status
}
var err error
if o.Qty, err = firstDec(row.Quantity, row.Qty); err != nil {
return err
}
if o.FilledQty, err = firstDec(row.FilledQty, row.CumulativeQty); err != nil {
return err
}
if o.Price, err = firstDec(row.Price, row.AveragePrice); err != nil {
return err
}
return nil
}
func parseOrders(raw json.RawMessage) ([]Order, error) {
rows, _, err := wire.UnmarshalRows[Order](raw, "orders", "results")
if err != nil {
return nil, err
}
if len(rows) > 0 {
return rows, nil
}
var one Order
if err := json.Unmarshal(wire.Unwrap(raw), &one); err != nil {
return nil, err
}
if one.ID != "" {
return []Order{one}, nil
}
return nil, nil
}
// CancelOrderRequest is the argument set for cancel_equity_order.
@@ -62,13 +127,23 @@ func (c *Client) ReviewOrder(ctx context.Context, req PlaceOrderRequest) (Review
return out, nil
}
// PlaceOrder calls place_equity_order. RefID is sent as both ref_id and idempotency_key.
// PlaceOrder calls place_equity_order. RefID is sent as ref_id only.
func (c *Client) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (Order, error) {
var out Order
if err := c.parse(ctx, toolPlace, placeArgs(req, true), &out); err != nil {
raw, err := c.c.Call(ctx, toolPlace, placeArgs(req, true))
if err != nil {
return Order{}, err
}
return out, nil
ords, err := parseOrders(raw)
if err != nil {
return Order{}, client.ToolErrorf(toolPlace, "parse: %w", err)
}
if len(ords) == 1 {
return ords[0], nil
}
if len(ords) > 1 {
return ords[0], nil
}
return Order{}, nil
}
// CancelOrder calls cancel_equity_order.
@@ -128,7 +203,6 @@ func placeArgs(req PlaceOrderRequest, withRef bool) map[string]any {
}
if withRef && req.RefID != "" {
args["ref_id"] = req.RefID
args["idempotency_key"] = req.RefID
}
return args
}
+33 -10
View File
@@ -40,6 +40,26 @@ func TestPlaceOrder_decimalStrings(t *testing.T) {
if got["ref_id"] != "buy:2026-08-18:MU" {
t.Fatalf("ref %v", got["ref_id"])
}
if _, ok := got["idempotency_key"]; ok {
t.Fatalf("idempotency_key %v", got["idempotency_key"])
}
}
func TestPlaceOrder_ordersEnvelope(t *testing.T) {
t.Parallel()
c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
return json.RawMessage(`{"orders":[{"id":"o2","symbol":"MU","side":"buy","quantity":"3","state":"confirmed"}]}`), nil
}))
got, err := c.PlaceOrder(context.Background(), equity.PlaceOrderRequest{Symbol: "MU"})
if err != nil {
t.Fatal(err)
}
if got.ID != "o2" || got.Symbol != "MU" || got.Side != "buy" || got.State != "confirmed" {
t.Fatalf("%+v", got)
}
if !got.Qty.Equal(decimal.NewFromInt(3)) {
t.Fatalf("qty %s", got.Qty)
}
}
func TestEquity_writeArgs(t *testing.T) {
@@ -98,14 +118,13 @@ func TestEquity_writeArgs(t *testing.T) {
},
wantName: "place_equity_order",
wantArgs: map[string]any{
"account_number": "acct",
"symbol": "MU",
"side": "sell",
"type": "stop_market",
"quantity": "3",
"stop_price": "99.6",
"ref_id": "sell:2026-08-18:MU",
"idempotency_key": "sell:2026-08-18:MU",
"account_number": "acct",
"symbol": "MU",
"side": "sell",
"type": "stop_market",
"quantity": "3",
"stop_price": "99.6",
"ref_id": "sell:2026-08-18:MU",
},
},
{
@@ -135,8 +154,7 @@ func TestEquity_writeArgs(t *testing.T) {
"tax_lots": []map[string]any{
{"open_lot_id": "lot-1", "quantity": "1.5"},
},
"ref_id": "sell:lots",
"idempotency_key": "sell:lots",
"ref_id": "sell:lots",
},
},
{
@@ -182,6 +200,11 @@ func TestEquity_writeArgs(t *testing.T) {
t.Fatalf("time_in_force injected: %+v", gotArgs)
}
}
if tc.wantName == "place_equity_order" {
if _, ok := gotArgs["idempotency_key"]; ok {
t.Fatalf("idempotency_key on place: %+v", gotArgs)
}
}
})
}
}