Files
robinhood-agentic-mcp/docs/superpowers/specs/2026-09-01-robinhood-agentic-mcp-design.md
T
s1d3sw1ped 6600aa0eff docs: apply spec corrections for identity, decimal parse, mocks
Callers set MCP/OAuth identity on Config before Login/Connect.
Unparseable money errors instead of silent zero. Tests hit an
httptest Robinhood MCP mock, not only injected Callers.
2026-09-01 10:49:37 -05:00

17 KiB
Raw Blame History

robinhood-agentic-mcp

Date: 2026-09-01
Status: approved design, pending implementation plan
Product: robinhood-agentic-mcp — a Go library that presents the full Robinhood Agentic MCP with the same transport and auth tradey already uses.

Not investment advice. The caller is responsible for every fill in the Robinhood Agentic account.

Goal

A standalone Go module at /fast/projects/golang/robinhood-agentic-mcp that:

  1. Connects to https://agent.robinhood.com/mcp/trading the way tradey does today (OAuth, token file, streamable HTTP session, JSON-RPC fallback).
  2. Exposes a typed Go method for every tool on that MCP (equity, options, crypto, watchlists, market data, scanner, accounts).
  3. Uses Alpaca-shaped money and order enums on the public API, translating to Robinhoods string wire format internally.
  4. Can be imported later by tradey; this effort does not rewire tradey.

Non-goals (v1)

  • A CLI binary (login stays a library function; tradey keeps tradey login).
  • Rewiring tradey to import this module.
  • Tradeys Reader / Executor / Snapshot / Fake desk types (paper/live policy stays in the app).
  • Codegen from tools/list.
  • Proxying or re-hosting the MCP as a server.
  • Banking / credit-card MCP (agent.robinhood.com non-trading endpoints).

Operator contract

type Config struct {
    URL       string // empty → DefaultURL
    TokenFile string
    Name      string // MCP Implementation.Name and OAuth ClientName; empty → "robinhood-agentic-mcp"
    Version   string // MCP Implementation.Version; empty → "0.1.0"
}

func Login(ctx context.Context, cfg Config) (accountID string, err error)
func Connect(ctx context.Context, cfg Config) (*API, error)

Config is defined in auth (Login owns identity and the token file). rh re-exports it as type Config = auth.Config so callers can import once. This avoids an import cycle (rhauth → not rh).

Call Behavior
auth.Login / rh.Login If ROBINHOOD_ACCESS_TOKEN is set, write it (and optional ROBINHOOD_REFRESH_TOKEN) to cfg.TokenFile mode 0600. Otherwise run the browser OAuth dance using cfg.Name / cfg.Version and persist the full token set.
rh.Connect Read cfg.TokenFile, open a session identified as cfg.Name/cfg.Version, return an rh.API with all subclients wired. Fail closed if tokens are missing or the session cannot be used.
client.Call(ctx, name, args) Escape hatch: invoke any tool by MCP name and return JSON.

Identity must be set on Config before Login or Connect. After a session exists it is not changeable. Apps that need a distinct Robinhood OAuth client (tradey, another bot) pass their own Name. Empty Name/Version keep the library defaults.

Daemon/headless callers must not call Login (no browser). They call Connect with an existing token file.

Architecture

Module path: s1d3sw1ped/robinhood-agentic-mcp
Go version: 1.25
Default MCP URL: https://agent.robinhood.com/mcp/trading
MCP SDK: github.com/modelcontextprotocol/go-sdk (same major as tradey, currently v1.7.0)

One shared session. Asset-class packages wrap it. The root package is a facade so a caller can import once.

s1d3sw1ped/robinhood-agentic-mcp          // rh: DefaultURL, Config, Connect, API, shared enums
  client/                                 // session, Call, ToolError, transport
  auth/                                   // Login, TokenSet, Read/Write tokens
  internal/wire                           // unwrap data envelopes, decimal JSON
  internal/rhntest                        // httptest Robinhood MCP mock
  accounts/
  equity/
  options/
  crypto/
  watchlists/
  market/
  scanner/
package rh

const DefaultURL = "https://agent.robinhood.com/mcp/trading"

type API struct {
    Client     *client.Client
    Accounts   *accounts.Client
    Equity     *equity.Client
    Options    *options.Client
    Crypto     *crypto.Client
    Watchlists *watchlists.Client
    Market     *market.Client
    Scanner    *scanner.Client
}

func Connect(ctx context.Context, cfg Config) (*API, error)
func Login(ctx context.Context, cfg Config) (accountID string, err error) // re-exports auth.Login

Empty cfg.URL means DefaultURL. Subpackages also export New(c client.Caller) *Client so callers can wire a single package without the facade.

Each subclient depends only on client.Caller. Unit tests inject a stub; transport tests hit rhntest. A future tradey adapter can inject the same caller.

Auth and transport

Copied from tradeys working path (internal/broker/oauth.go, login.go, mcp.go). Identity is caller-owned via Config.Name / Config.Version so each app is a distinct Robinhood OAuth client (tradey, this librarys default, Grok-chat MCP, and so on).

  • MCP Implementation.Name / Implementation.Version: cfg.Name, cfg.Version (defaults robinhood-agentic-mcp / 0.1.0).
  • OAuth dynamic client registration ClientName: the same cfg.Name.
  • OAuth callback page text uses cfg.Name (not a hardcoded “Tradey is signed in”).
  • StreamableClientTransport with DisableStandaloneSSE: true.
  • Prefer the SDK session CallTool. If session connect fails, fall back to HTTP POST JSON-RPC tools/call with Authorization: Bearer and Accept: application/json, text/event-stream.
  • Token file JSON matches tradeys TokenSet (access, refresh, type, expiry, client_id/secret, auth/token/redirect URLs, account_id). Mode 0600.
  • Login timeout: 5 minutes for the OAuth dance.
  • Session connect does not prompt; expired OAuth returns an error telling the caller to run Login again.
  • Connect does not write refreshed tokens back to disk (v1). The token file is whatever Login last wrote.

client.Caller:

type Caller interface {
    Call(ctx context.Context, name string, args map[string]any) (json.RawMessage, error)
}

Typed API conventions

Every MCP tool is func (c *Client) Method(ctx context.Context, req Request) (Result, error).

  • Method names drop the get_ prefix: Quotes, Positions, PlaceOrder, CancelOrder.
  • The MCP tool name is a private constant next to the method (toolQuotes = "get_equity_quotes").
  • Request structs use omitempty on the wire map. Optional MCP fields are pointers or zero-means-omit.
  • client.Call stays public.

Money (Alpaca-shaped)

Public money, size, and price fields use github.com/alpacahq/alpacadecimal imported as decimal, so the type name is decimal.Decimal like alpaca-trade-api-go.

  • Required amounts: decimal.Decimal.
  • Optional amounts: *decimal.Decimal (Alpaca PlaceOrderRequest.Qty / LimitPrice).
  • Never float64 for money, size, prices, buying power, volume, or bar OHLC.

On the way out, encode with Decimal.String() onto Robinhoods string fields (quantity, limit_price, stop_price, dollar_amount, option price). On the way in, accept JSON string or number (tradeys envelope) and parse into decimal.Decimal.

Parse rules for money/size/price:

JSON Optional *decimal.Decimal Required decimal.Decimal
field omitted or JSON null nil, not an error zero value, not an error
0 / "0" / "0.0" pointer to zero zero
"" nil, not an error zero, not an error
valid number or numeric string parsed value parsed value
present but unparseable ("n/a", object, bool, array) error (ToolError parse), never silent zero/nil error, never silent zero

Failed parsing is never coerced into zero or nil. Absent/intentional empty is never treated as a parse failure.

Enums (Alpaca-shaped names, Robinhood wire values)

Exported from the root rh package:

type Side string
const (
    Buy  Side = "buy"
    Sell Side = "sell"
)

type OrderType string
const (
    Market    OrderType = "market"
    Limit     OrderType = "limit"
    Stop      OrderType = "stop_market" // equity + options; crypto uses StopLoss
    StopLimit OrderType = "stop_limit"
    StopLoss  OrderType = "stop_loss"   // crypto only
)

type TimeInForce string
const (
    GFD TimeInForce = "gfd"
    GTC TimeInForce = "gtc"
    GFW TimeInForce = "gfw" // crypto
    GFM TimeInForce = "gfm" // crypto
)

type MarketHours string
const (
    RegularHours              MarketHours = "regular_hours"
    ExtendedHours             MarketHours = "extended_hours"
    AllDayHours               MarketHours = "all_day_hours"
    RegularCurbHours          MarketHours = "regular_curb_hours"
    RegularCurbOvernightHours MarketHours = "regular_curb_overnight_hours"
)

Callers write rh.Buy, rh.Limit, rh.GFD. The library does not map Alpacas "day" / "stop" onto Robinhood; the const values are Robinhoods.

Errors

type ToolError struct {
    Name    string // MCP tool name
    Message string
    Err     error  // transport/SDK/parse cause; may be nil
}

func (e *ToolError) Error() string  // "mcp <name>: <message>"
func (e *ToolError) Unwrap() error

Call and every typed method return *ToolError (or wrap one) on tool/transport failure so callers can errors.As. Parse failures use the same type with Name set and Message like parse quotes. Login/Connect fail closed: no empty client, no nil-session success.

HTTP ≥300, JSON-RPC error, and SDK CallTool errors all become ToolError.

Tool map

Source of truth for coverage is a frozen testdata/tools.json captured from live tools/list during implementation (checked in, not refreshed by CI). Every name in that fixture must have a typed method. The tables below are the known inventory (Robinhoods “Trading with your agent” page plus extra tools already on the connected MCP). Names that appear in the fixture but not in a table still get a method in the matching package in the same PR (get_advanced_ordersequity).

accounts

MCP tool Method
get_accounts Accounts
get_portfolio Portfolio
get_realized_pnl RealizedPnL
get_pnl_trade_history PnLTradeHistory
get_limited_margin_upgrade_info LimitedMarginUpgradeInfo
get_option_level_upgrade_info OptionLevelUpgradeInfo
get_crypto_account_onboarding_info CryptoOnboardingInfo
search Search

This library does not enforce tradeys supported() account policy (IRA/UTMA/full margin bans). It returns whatever get_accounts returns, including agentic_allowed. Apps decide.

equity

MCP tool Method
get_equity_positions Positions
get_equity_tax_lots TaxLots
get_equity_quotes Quotes
get_equity_orders Orders
get_equity_tradability Tradability
get_equity_historicals Historicals
get_equity_fundamentals Fundamentals
get_equity_price_book PriceBook
get_equity_technical_indicators TechnicalIndicators
get_equity_news News
review_equity_order ReviewOrder
place_equity_order PlaceOrder
cancel_equity_order CancelOrder

Historicals accepts caller interval/bounds/adjustment (no hidden tradey minute/regular defaults). Batching more than 10 symbols is the callers job; the method sends one MCP call.

options

MCP tool Method
get_option_chains Chains
get_option_instruments Instruments
get_option_quotes Quotes
get_option_positions Positions
get_option_orders Orders
get_option_historicals Historicals
review_option_order ReviewOrder
place_option_order PlaceOrder
cancel_option_order CancelOrder
replace_option_order ReplaceOrder
exercise_option Exercise
cancel_option_exercise CancelExercise

crypto

MCP tool Method
get_currency_pairs Pairs
get_crypto_quotes Quotes
get_crypto_positions Positions
get_crypto_orders Orders
preview_crypto_order PreviewOrder
place_crypto_order PlaceOrder
cancel_crypto_order CancelOrder

Crypto account numbers use rhs_account_number on the wire, named RHSAccountNumber in request structs.

watchlists

MCP tool Method
get_watchlists Lists
get_watchlist_items Items
get_option_watchlist OptionList
get_popular_watchlists Popular
create_watchlist Create
update_watchlist Update
follow_watchlist Follow
unfollow_watchlist Unfollow
add_to_watchlist Add
remove_from_watchlist Remove
add_option_to_watchlist AddOption
remove_option_from_watchlist RemoveOption

No title-lookup helper in v1. Tradeys Watchlist(title) (match title, then get_watchlist_items, keep equity/ETF) stays in tradey until rewire.

market

MCP tool Method
get_indexes Indexes
get_index_quotes IndexQuotes
get_index_historicals IndexHistoricals
get_financials Financials
get_earnings_results EarningsResults
get_earnings_calendar EarningsCalendar
get_sec_filing_index SECFilingIndex
get_sec_filing SECFiling
get_sec_filing_facts SECFilingFacts
get_sec_filing_facts_catalog SECFilingFactsCatalog

scanner

MCP tool Method
get_scanner_filter_specs FilterSpecs
get_scanner_datapoints Datapoints
get_scans Scans
create_scan Create
preview_scan Preview
run_scan Run
update_scan_filters UpdateFilters
update_scan_config UpdateConfig

If the captured fixture omits a table row (tool removed upstream), drop that method rather than stub a dead tool. Do not ship a library that silently drops tools that are still listed.

Data flow

Login(Config)  → browser OAuth or env token → tokens.json (0600), OAuth client = cfg.Name
Connect(Config) → ReadTokens → session (or RPC fallback) as cfg.Name/cfg.Version → rh.API
API.Equity.PlaceOrder(ctx, req)
  → map req to MCP args (decimal.String, rh.Limit → "limit")
  → client.Call("place_equity_order", args)
  → toolJSON / unwrap data
  → parse into Result (decimal from string|number)

Pagination: request structs take Cursor string; results expose NextCursor / Next when the MCP returns one. No auto-paging in v1.

Testing

CI must not call the live broker or open a browser. Tests otherwise go as far as they can: they hit an in-process Robinhood MCP mock over HTTP, not only an injected Caller.

internal/rhntest is an httptest.Server that speaks Robinhoods MCP surface the way tradey talks to it:

  • Required protocol: JSON-RPC tools/call (tradeys RPC fallback). Optional extra: streamable-HTTP CallTool if the SDK client can talk to the same httptest without extra machinery.
  • Requires Authorization: Bearer when the test sets a token.
  • Dispatches on tool name and returns checked-in envelope fixtures (testdata/*.json) shaped like live Robinhood (data wrappers, string-or-number amounts, quote/close objects).
  • Can return HTTP 4xx, JSON-RPC errors, and malformed bodies so ToolError and parse-error paths are exercised.

Required tests:

  • Transport: Connect against rhntest (session if the mock can serve it, otherwise RPC fallback), bearer header, HTTP error → ToolError, identity Name/Version sent on the MCP initialize/OAuth client metadata.
  • Every typed method: (1) stub Caller asserting MCP tool name + JSON args including decimal.String(); (2) round-trip through rhntest with a realistic success fixture.
  • Wire/decimal: omitted/null/""/0 vs unparseable ("n/a", object) — the latter errors, the former does not.
  • Parser fixtures from tradeys known envelopes: accounts with data wrapper, quotes as {quotes:[{quote:{…}, close:{…}}]}, historicals {historicals:[{symbol, data_points}]}, watchlists title vs name.
  • WriteTokens mode 0600.
  • Login from ROBINHOOD_ACCESS_TOKEN (no OAuth, no browser).
  • ToolError is errors.As-able.
  • Coverage: frozen testdata/tools.json from live tools/list must have a method for every name.

No live-MCP or browser-OAuth in default go test. An optional //go:build live smoke is out of v1.

Package map

Package Does Depends on
rh Config, Connect, Login, API, enums all subpackages
client session, Call, ToolError, RPC fallback MCP SDK, oauth2
auth Login, token file client (session during OAuth), MCP auth/oauthex
internal/wire unwrap data, decimal JSON alpacadecimal
internal/rhntest httptest Robinhood MCP mock client protocol
accountsscanner typed methods client.Caller, rh enums, internal/wire

Success criteria

  • go test ./... passes with no live network (httptest mock only).
  • Typed methods work with an injected Caller and round-trip through rhntest.
  • Connect/Login accept Config.Name / Config.Version; empty uses library defaults.
  • Login with ROBINHOOD_ACCESS_TOKEN writes 0600 tokens.
  • equity.PlaceOrder sends quantity/limit_price as decimal strings, time_in_force gfd, type limit — matching tradeys placeArgs for the same inputs.
  • Every name in the frozen tools/list fixture has a method.
  • tradey still builds; this repo does not import tradey and tradey is not changed.

Out of scope until tradey rewire

  • Watchlist(title string) ([]string, error) helper.
  • Composite Snapshot.
  • Paper Executor constructor split.
  • replace directive / go.mod change in tradey.