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
+93 -9
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"
)
@@ -27,16 +28,34 @@ type IndexesRequest struct {
Symbols string // comma-separated; live schema is a string, not an array
}
// Index is one market index from get_indexes.
type Index struct {
ID string
Symbol string
Name string
}
// IndexesResult is the parsed get_indexes payload.
type IndexesResult struct{}
type IndexesResult struct {
Indexes []Index
}
// IndexQuotesRequest is the argument set for get_index_quotes.
type IndexQuotesRequest struct {
InstrumentIDs []string
}
// IndexQuote is a live index level from get_index_quotes.
type IndexQuote struct {
InstrumentID string
Value decimal.Decimal
State string
}
// IndexQuotesResult is the parsed get_index_quotes payload.
type IndexQuotesResult struct{}
type IndexQuotesResult struct {
Quotes []IndexQuote
}
// IndexHistoricalsRequest is the argument set for get_index_historicals.
type IndexHistoricalsRequest struct {
@@ -46,8 +65,18 @@ type IndexHistoricalsRequest struct {
Interval string // required — no hidden default
}
// IndexBar is one OHLC bar from get_index_historicals.
type IndexBar struct {
InstrumentID string
Time time.Time
Open, High, Low, Close decimal.Decimal
Interpolated bool
}
// IndexHistoricalsResult is the parsed get_index_historicals payload.
type IndexHistoricalsResult struct{}
type IndexHistoricalsResult struct {
Bars []IndexBar
}
// FinancialsRequest is the argument set for get_financials.
type FinancialsRequest struct {
@@ -56,8 +85,20 @@ type FinancialsRequest struct {
Limit int
}
// FinancialPeriod is one fiscal period from get_financials.
type FinancialPeriod struct {
Symbol string
Period string
Revenue decimal.Decimal
GrossProfit decimal.Decimal
NetIncome decimal.Decimal
NetMargin decimal.Decimal
}
// FinancialsResult is the parsed get_financials payload.
type FinancialsResult struct{}
type FinancialsResult struct {
Periods []FinancialPeriod
}
// EarningsResultsRequest is the argument set for get_earnings_results.
type EarningsResultsRequest struct {
@@ -77,8 +118,16 @@ type EarningsCalendarRequest struct {
Filter string
}
// EarningsEvent is one calendar row from get_earnings_calendar.
type EarningsEvent struct {
Symbol string
ReportDate string
}
// EarningsCalendarResult is the parsed get_earnings_calendar payload.
type EarningsCalendarResult struct{}
type EarningsCalendarResult struct {
Events []EarningsEvent
}
// SECFilingIndexRequest is the argument set for get_sec_filing_index.
type SECFilingIndexRequest struct {
@@ -89,8 +138,18 @@ type SECFilingIndexRequest struct {
Cursor string
}
// SECFilingRef is one filing from get_sec_filing_index.
type SECFilingRef struct {
FilingID string
FormType string
FiledAt string
}
// SECFilingIndexResult is the parsed get_sec_filing_index payload.
type SECFilingIndexResult struct{}
type SECFilingIndexResult struct {
Filings []SECFilingRef
NextCursor string
}
// SECFilingRequest is the argument set for get_sec_filing.
type SECFilingRequest struct {
@@ -98,8 +157,19 @@ type SECFilingRequest struct {
Section string
}
// SECSection is one table-of-contents row or section body.
type SECSection struct {
ID string
Title string
Text string
}
// SECFilingResult is the parsed get_sec_filing payload.
type SECFilingResult struct{}
type SECFilingResult struct {
FilingID string
Sections []SECSection
Text string
}
// SECFilingFactsRequest is the argument set for get_sec_filing_facts.
type SECFilingFactsRequest struct {
@@ -108,7 +178,15 @@ type SECFilingFactsRequest struct {
}
// SECFilingFactsResult is the parsed get_sec_filing_facts payload.
type SECFilingFactsResult struct{}
type SECFact struct {
Concept string
Value string
Unit string
}
type SECFilingFactsResult struct {
Facts []SECFact
}
// SECFilingFactsCatalogRequest is the argument set for get_sec_filing_facts_catalog.
type SECFilingFactsCatalogRequest struct {
@@ -119,7 +197,13 @@ type SECFilingFactsCatalogRequest struct {
}
// SECFilingFactsCatalogResult is the parsed get_sec_filing_facts_catalog payload.
type SECFilingFactsCatalogResult struct{}
type SECConcept struct {
Name string
}
type SECFilingFactsCatalogResult struct {
Concepts []SECConcept
}
// Indexes calls get_indexes.
func (c *Client) Indexes(ctx context.Context, req IndexesRequest) (IndexesResult, error) {
+14
View File
@@ -241,3 +241,17 @@ func TestEarningsResults_rhntest(t *testing.T) {
t.Fatal(diff)
}
}
func TestEarningsResults_liveEnvelope(t *testing.T) {
t.Parallel()
c := market.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
return json.RawMessage(`{"data":{"results":[{"report":{"date":"2026-07-15"}}]}}`), nil
}))
got, err := c.EarningsResults(context.Background(), market.EarningsResultsRequest{Symbol: "MU"})
if err != nil {
t.Fatal(err)
}
if got.ReportDate != "2026-07-15" || got.NextReportDate != "2026-07-15" {
t.Fatalf("%+v", got)
}
}
+298
View File
@@ -0,0 +1,298 @@
package market
import (
"encoding/json"
"time"
decimal "github.com/alpacahq/alpacadecimal"
"s1d3sw1ped/robinhood-agentic-mcp/internal/wire"
)
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
}
func (r *IndexesResult) UnmarshalJSON(b []byte) error {
type row struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
Name string `json:"name"`
}
rows, _, err := wire.UnmarshalRows[row](b, "indexes", "results")
if err != nil {
return err
}
r.Indexes = make([]Index, 0, len(rows))
for _, row := range rows {
r.Indexes = append(r.Indexes, Index{ID: row.ID, Symbol: row.Symbol, Name: row.Name})
}
return nil
}
func (r *IndexQuotesResult) UnmarshalJSON(b []byte) error {
type row struct {
InstrumentID string `json:"instrument_id"`
ID string `json:"id"`
Value any `json:"value"`
Last any `json:"last"`
State string `json:"state"`
}
rows, _, err := wire.UnmarshalRows[row](b, "quotes", "results")
if err != nil {
return err
}
r.Quotes = make([]IndexQuote, 0, len(rows))
for _, row := range rows {
id := row.InstrumentID
if id == "" {
id = row.ID
}
val, err := firstDec(row.Value, row.Last)
if err != nil {
return err
}
r.Quotes = append(r.Quotes, IndexQuote{InstrumentID: id, Value: val, State: row.State})
}
return nil
}
func (r *IndexHistoricalsResult) 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"`
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
}
r.Bars = append(r.Bars, IndexBar{InstrumentID: id, Time: ts, Open: o, High: h, Low: l, Close: cl, Interpolated: p.Interpolated})
}
}
return nil
}
func (r *FinancialsResult) UnmarshalJSON(b []byte) error {
type row struct {
Symbol string `json:"symbol"`
Period string `json:"period"`
Revenue any `json:"revenue"`
GrossProfit any `json:"gross_profit"`
NetIncome any `json:"net_income"`
NetMargin any `json:"net_margin"`
}
rows, _, err := wire.UnmarshalRows[row](b, "financials", "results")
if err != nil {
return err
}
r.Periods = make([]FinancialPeriod, 0, len(rows))
for _, row := range rows {
rev, err := firstDec(row.Revenue)
if err != nil {
return err
}
gp, err := firstDec(row.GrossProfit)
if err != nil {
return err
}
ni, err := firstDec(row.NetIncome)
if err != nil {
return err
}
nm, err := firstDec(row.NetMargin)
if err != nil {
return err
}
r.Periods = append(r.Periods, FinancialPeriod{
Symbol: row.Symbol, Period: row.Period, Revenue: rev, GrossProfit: gp, NetIncome: ni, NetMargin: nm,
})
}
return nil
}
func (r *EarningsResultsResult) UnmarshalJSON(b []byte) error {
var wrap struct {
NextReportDate string `json:"next_report_date"`
ReportDate string `json:"report_date"`
Results []struct {
Report struct {
Date string `json:"date"`
} `json:"report"`
} `json:"results"`
}
if err := json.Unmarshal(wire.Unwrap(b), &wrap); err != nil {
return err
}
r.NextReportDate = wrap.NextReportDate
r.ReportDate = wrap.ReportDate
if r.ReportDate == "" && len(wrap.Results) > 0 {
r.ReportDate = wrap.Results[0].Report.Date
}
if r.NextReportDate == "" {
r.NextReportDate = r.ReportDate
}
return nil
}
func (r *EarningsCalendarResult) UnmarshalJSON(b []byte) error {
type row struct {
Symbol string `json:"symbol"`
ReportDate string `json:"report_date"`
Date string `json:"date"`
}
rows, _, err := wire.UnmarshalRows[row](b, "results", "earnings")
if err != nil {
return err
}
r.Events = make([]EarningsEvent, 0, len(rows))
for _, row := range rows {
d := row.ReportDate
if d == "" {
d = row.Date
}
r.Events = append(r.Events, EarningsEvent{Symbol: row.Symbol, ReportDate: d})
}
return nil
}
func (r *SECFilingIndexResult) UnmarshalJSON(b []byte) error {
type row struct {
FilingID string `json:"filing_id"`
ID string `json:"id"`
FormType string `json:"form_type"`
FiledAt string `json:"filed_at"`
Date string `json:"date"`
}
rows, next, err := wire.UnmarshalRows[row](b, "filings", "results")
if err != nil {
return err
}
r.NextCursor = next
r.Filings = make([]SECFilingRef, 0, len(rows))
for _, row := range rows {
id := row.FilingID
if id == "" {
id = row.ID
}
when := row.FiledAt
if when == "" {
when = row.Date
}
r.Filings = append(r.Filings, SECFilingRef{FilingID: id, FormType: row.FormType, FiledAt: when})
}
return nil
}
func (r *SECFilingResult) UnmarshalJSON(b []byte) error {
var wrap struct {
FilingID string `json:"filing_id"`
ID string `json:"id"`
Text string `json:"text"`
Sections []struct {
ID string `json:"id"`
Title string `json:"title"`
Text string `json:"text"`
} `json:"sections"`
TOC []struct {
ID string `json:"id"`
Title string `json:"title"`
} `json:"table_of_contents"`
}
if err := json.Unmarshal(wire.Unwrap(b), &wrap); err != nil {
return err
}
r.FilingID = wrap.FilingID
if r.FilingID == "" {
r.FilingID = wrap.ID
}
r.Text = wrap.Text
for _, s := range wrap.Sections {
r.Sections = append(r.Sections, SECSection{ID: s.ID, Title: s.Title, Text: s.Text})
}
if len(r.Sections) == 0 {
for _, s := range wrap.TOC {
r.Sections = append(r.Sections, SECSection{ID: s.ID, Title: s.Title})
}
}
return nil
}
func (r *SECFilingFactsResult) UnmarshalJSON(b []byte) error {
type row struct {
Concept string `json:"concept"`
Value string `json:"value"`
Unit string `json:"unit"`
}
rows, _, err := wire.UnmarshalRows[row](b, "facts", "results")
if err != nil {
return err
}
r.Facts = make([]SECFact, 0, len(rows))
for _, row := range rows {
r.Facts = append(r.Facts, SECFact{Concept: row.Concept, Value: row.Value, Unit: row.Unit})
}
return nil
}
func (r *SECFilingFactsCatalogResult) UnmarshalJSON(b []byte) error {
type row struct {
Name string `json:"name"`
Concept string `json:"concept"`
}
rows, _, err := wire.UnmarshalRows[row](b, "concepts", "results")
if err != nil {
return err
}
r.Concepts = make([]SECConcept, 0, len(rows))
for _, row := range rows {
n := row.Name
if n == "" {
n = row.Concept
}
r.Concepts = append(r.Concepts, SECConcept{Name: n})
}
return nil
}