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
+70 -6
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"time"
decimal "github.com/alpacahq/alpacadecimal"
"s1d3sw1ped/robinhood-agentic-mcp/client"
"s1d3sw1ped/robinhood-agentic-mcp/internal/wire"
)
@@ -24,8 +25,17 @@ type ChainsRequest struct {
UnderlyingSymbol string
}
// OptionChain is one chain from get_option_chains.
type OptionChain struct {
ID string
UnderlyingSymbol string
ExpirationDates []string
}
// ChainsResult is the parsed get_option_chains payload.
type ChainsResult struct{}
type ChainsResult struct {
Chains []OptionChain
}
// InstrumentsRequest is the argument set for get_option_instruments.
type InstrumentsRequest struct {
@@ -40,16 +50,42 @@ type InstrumentsRequest struct {
Cursor string
}
// OptionInstrument is one contract from get_option_instruments.
type OptionInstrument struct {
ID string
ChainID string
ChainSymbol string
ExpirationDate string
StrikePrice decimal.Decimal
Type string
State string
}
// InstrumentsResult is the parsed get_option_instruments payload.
type InstrumentsResult struct{}
type InstrumentsResult struct {
Instruments []OptionInstrument
NextCursor string
}
// QuotesRequest is the argument set for get_option_quotes.
type QuotesRequest struct {
InstrumentIDs []string
}
// OptionQuote is one contract quote from get_option_quotes.
type OptionQuote struct {
InstrumentID string
Bid decimal.Decimal
Ask decimal.Decimal
Last decimal.Decimal
PrevClose decimal.Decimal
Mark decimal.Decimal
}
// QuotesResult is the parsed get_option_quotes payload.
type QuotesResult struct{}
type QuotesResult struct {
Quotes []OptionQuote
}
// PositionsRequest is the argument set for get_option_positions.
type PositionsRequest struct {
@@ -65,8 +101,22 @@ type PositionsRequest struct {
Cursor string
}
// OptionPosition is one holding from get_option_positions.
type OptionPosition struct {
OptionID string
ChainID string
Type string
OptionType string
Quantity decimal.Decimal
AveragePrice decimal.Decimal
ExpirationDate string
}
// PositionsResult is the parsed get_option_positions payload.
type PositionsResult struct{}
type PositionsResult struct {
Positions []OptionPosition
NextCursor string
}
// OrdersRequest is the argument set for get_option_orders.
type OrdersRequest struct {
@@ -81,7 +131,10 @@ type OrdersRequest struct {
}
// OrdersResult is the parsed get_option_orders payload.
type OrdersResult struct{}
type OrdersResult struct {
Orders []Order
NextCursor string
}
// HistoricalsRequest is the argument set for get_option_historicals.
type HistoricalsRequest struct {
@@ -92,8 +145,19 @@ type HistoricalsRequest struct {
Bounds string
}
// OptionBar is one OHLC bar from get_option_historicals.
type OptionBar struct {
InstrumentID string
Time time.Time
Open, High, Low, Close decimal.Decimal
Volume decimal.Decimal
Interpolated bool
}
// HistoricalsResult is the parsed get_option_historicals payload.
type HistoricalsResult struct{}
type HistoricalsResult struct {
Bars []OptionBar
}
// Chains calls get_option_chains.
func (c *Client) Chains(ctx context.Context, req ChainsRequest) (ChainsResult, error) {
+275
View File
@@ -0,0 +1,275 @@
package options
import (
"encoding/json"
"time"
decimal "github.com/alpacahq/alpacadecimal"
"s1d3sw1ped/robinhood-agentic-mcp/internal/wire"
)
func (o *Order) UnmarshalJSON(b []byte) error {
var row struct {
ID string `json:"id"`
State string `json:"state"`
Status string `json:"status"`
Quantity any `json:"quantity"`
Price any `json:"price"`
}
if err := json.Unmarshal(b, &row); err != nil {
return err
}
o.ID = row.ID
o.State = row.State
if o.State == "" {
o.State = row.Status
}
var err error
if o.Qty, err = firstDec(row.Quantity); err != nil {
return err
}
if o.Price, err = firstDec(row.Price); 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
}
func (r *ChainsResult) UnmarshalJSON(b []byte) error {
type row struct {
ID string `json:"id"`
UnderlyingSymbol string `json:"underlying_symbol"`
Symbol string `json:"symbol"`
ExpirationDates []string `json:"expiration_dates"`
}
rows, _, err := wire.UnmarshalRows[row](b, "results", "chains")
if err != nil {
return err
}
r.Chains = make([]OptionChain, 0, len(rows))
for _, row := range rows {
sym := row.UnderlyingSymbol
if sym == "" {
sym = row.Symbol
}
r.Chains = append(r.Chains, OptionChain{ID: row.ID, UnderlyingSymbol: sym, ExpirationDates: row.ExpirationDates})
}
return nil
}
func (r *InstrumentsResult) UnmarshalJSON(b []byte) error {
type row struct {
ID string `json:"id"`
ChainID string `json:"chain_id"`
ChainSymbol string `json:"chain_symbol"`
ExpirationDate string `json:"expiration_date"`
StrikePrice any `json:"strike_price"`
Type string `json:"type"`
State string `json:"state"`
}
rows, next, err := wire.UnmarshalRows[row](b, "results", "instruments")
if err != nil {
return err
}
r.NextCursor = next
r.Instruments = make([]OptionInstrument, 0, len(rows))
for _, row := range rows {
px, err := firstDec(row.StrikePrice)
if err != nil {
return err
}
r.Instruments = append(r.Instruments, OptionInstrument{
ID: row.ID, ChainID: row.ChainID, ChainSymbol: row.ChainSymbol,
ExpirationDate: row.ExpirationDate, StrikePrice: px, Type: row.Type, State: row.State,
})
}
return nil
}
func (r *QuotesResult) UnmarshalJSON(b []byte) error {
type row struct {
InstrumentID string `json:"instrument_id"`
ID string `json:"id"`
Bid any `json:"bid"`
BidPrice any `json:"bid_price"`
Ask any `json:"ask"`
AskPrice any `json:"ask_price"`
Last any `json:"last"`
LastPrice any `json:"last_trade_price"`
PrevClose any `json:"previous_close"`
Mark any `json:"mark"`
MarkPrice any `json:"mark_price"`
}
rows, _, err := wire.UnmarshalRows[row](b, "quotes", "results")
if err != nil {
return err
}
r.Quotes = make([]OptionQuote, 0, len(rows))
for _, row := range rows {
id := row.InstrumentID
if id == "" {
id = row.ID
}
bid, err := firstDec(row.BidPrice, row.Bid)
if err != nil {
return err
}
ask, err := firstDec(row.AskPrice, row.Ask)
if err != nil {
return err
}
last, err := firstDec(row.LastPrice, row.Last)
if err != nil {
return err
}
prev, err := firstDec(row.PrevClose)
if err != nil {
return err
}
mark, err := firstDec(row.MarkPrice, row.Mark)
if err != nil {
return err
}
r.Quotes = append(r.Quotes, OptionQuote{InstrumentID: id, Bid: bid, Ask: ask, Last: last, PrevClose: prev, Mark: mark})
}
return nil
}
func (r *PositionsResult) UnmarshalJSON(b []byte) error {
type row struct {
OptionID string `json:"option_id"`
ID string `json:"id"`
ChainID string `json:"chain_id"`
Type string `json:"type"`
OptionType string `json:"option_type"`
Quantity any `json:"quantity"`
AveragePrice any `json:"average_price"`
ExpirationDate string `json:"expiration_date"`
}
rows, next, err := wire.UnmarshalRows[row](b, "results", "positions")
if err != nil {
return err
}
r.NextCursor = next
r.Positions = make([]OptionPosition, 0, len(rows))
for _, row := range rows {
id := row.OptionID
if id == "" {
id = row.ID
}
qty, err := firstDec(row.Quantity)
if err != nil {
return err
}
avg, err := firstDec(row.AveragePrice)
if err != nil {
return err
}
r.Positions = append(r.Positions, OptionPosition{
OptionID: id, ChainID: row.ChainID, Type: row.Type, OptionType: row.OptionType,
Quantity: qty, AveragePrice: avg, ExpirationDate: row.ExpirationDate,
})
}
return nil
}
func (r *OrdersResult) UnmarshalJSON(b []byte) error {
rows, next, err := wire.UnmarshalRows[Order](b, "orders", "results")
if err != nil {
return err
}
if len(rows) == 0 {
var one Order
if json.Unmarshal(wire.Unwrap(b), &one) == nil && one.ID != "" {
rows = []Order{one}
}
}
r.Orders = rows
r.NextCursor = next
return nil
}
func (r *HistoricalsResult) UnmarshalJSON(b []byte) error {
type pt struct {
BeginsAt string `json:"begins_at"`
Open any `json:"open"`
High any `json:"high"`
Low any `json:"low"`
Close any `json:"close"`
Volume any `json:"volume"`
Interpolated bool `json:"interpolated"`
}
type series struct {
InstrumentID string `json:"instrument_id"`
ID string `json:"id"`
DataPoints []pt `json:"data_points"`
}
rows, _, err := wire.UnmarshalRows[series](b, "historicals", "results")
if err != nil {
return err
}
for _, s := range rows {
id := s.InstrumentID
if id == "" {
id = s.ID
}
for _, p := range s.DataPoints {
ts, err := time.Parse(time.RFC3339, p.BeginsAt)
if err != nil {
continue
}
o, err := firstDec(p.Open)
if err != nil {
return err
}
h, err := firstDec(p.High)
if err != nil {
return err
}
l, err := firstDec(p.Low)
if err != nil {
return err
}
cl, err := firstDec(p.Close)
if err != nil {
return err
}
vol, err := firstDec(p.Volume)
if err != nil {
return err
}
r.Bars = append(r.Bars, OptionBar{InstrumentID: id, Time: ts, Open: o, High: h, Low: l, Close: cl, Volume: vol, Interpolated: p.Interpolated})
}
}
return nil
}
func firstDec(vs ...any) (decimal.Decimal, error) {
for _, v := range vs {
if v == nil {
continue
}
if s, ok := v.(string); ok && s == "" {
continue
}
return wire.Dec(v)
}
return decimal.Zero, nil
}
+34 -10
View File
@@ -57,11 +57,18 @@ type ReplaceOrderRequest struct {
}
// ReviewResult is the pre-trade check from review_option_order.
type ReviewResult struct{}
type ReviewResult struct {
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
Alerts []string `json:"alerts"`
}
// Order is a placed or replaced option order.
// Order is a placed, replaced, or listed option order.
type Order struct {
ID string `json:"id"`
ID string `json:"id"`
State string `json:"state"`
Qty decimal.Decimal `json:"-"`
Price decimal.Decimal `json:"-"`
}
// CancelOrderRequest is the argument set for cancel_option_order.
@@ -81,7 +88,10 @@ type ExerciseRequest struct {
}
// ExerciseResult is the parsed exercise_option payload.
type ExerciseResult struct{}
type ExerciseResult struct {
ID string `json:"id"`
State string `json:"state"`
}
// CancelExerciseRequest is the argument set for cancel_option_exercise.
type CancelExerciseRequest struct {
@@ -100,11 +110,18 @@ func (c *Client) ReviewOrder(ctx context.Context, req PlaceOrderRequest) (Review
// PlaceOrder calls place_option_order. RefID is sent as ref_id.
func (c *Client) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (Order, error) {
var out Order
if err := c.parse(ctx, toolPlace, placeArgs(req, true, false), &out); err != nil {
raw, err := c.c.Call(ctx, toolPlace, placeArgs(req, true, false))
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) > 0 {
return ords[0], nil
}
return Order{}, nil
}
// CancelOrder calls cancel_option_order.
@@ -137,11 +154,18 @@ func (c *Client) ReplaceOrder(ctx context.Context, req ReplaceOrderRequest) (Ord
if req.OrderID != "" {
args["order_id"] = req.OrderID
}
var out Order
if err := c.parse(ctx, toolReplace, args, &out); err != nil {
raw, err := c.c.Call(ctx, toolReplace, args)
if err != nil {
return Order{}, err
}
return out, nil
ords, err := parseOrders(raw)
if err != nil {
return Order{}, client.ToolErrorf(toolReplace, "parse: %w", err)
}
if len(ords) > 0 {
return ords[0], nil
}
return Order{}, nil
}
// Exercise calls exercise_option.