Files
robinhood-agentic-mcp/docs/superpowers/specs/2026-09-01-robinhood-agentic-mcp-design.md
T
s1d3sw1ped 58b9b48e15 docs: add robinhood-agentic-mcp library design spec
Typed Go client for the full Robinhood Agentic MCP, using tradey's
transport/auth and Alpaca-shaped decimals and order enums.
2026-09-01 10:36:19 -05:00

14 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

Call Behavior
auth.Login(ctx, mcpURL, tokenFile) If ROBINHOOD_ACCESS_TOKEN is set, write it (and optional ROBINHOOD_REFRESH_TOKEN) to tokenFile mode 0600. Otherwise run the browser OAuth dance and persist the full token set.
rh.Connect(ctx, mcpURL, tokenFile) Read tokenFile, open a session, 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.

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, Connect, API, shared enums
  client/                                 // session, Call, ToolError, transport
  auth/                                   // Login, TokenSet, Read/Write tokens
  internal/wire                           // unwrap data envelopes, decimal JSON
  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, mcpURL, tokenFile string) (*API, error)
func Login(ctx context.Context, mcpURL, tokenFile string) (accountID string, err error) // re-exports auth.Login

Empty mcpURL 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. That keeps tests stub-only and lets a future tradey adapter inject the same caller.

Auth and transport

Copied from tradeys working path (internal/broker/oauth.go, login.go, mcp.go), with identity strings changed so this library is a distinct OAuth client from tradey and from the Grok-chat MCP.

  • MCP implementation name: robinhood-agentic-mcp, version 0.1.0.
  • OAuth dynamic client registration ClientName: robinhood-agentic-mcp (not tradey).
  • 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. Missing/empty becomes the zero decimal or a nil pointer, matching whether the field is required.

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  → browser OAuth or env token → tokens.json (0600)
Connect → ReadTokens → session (or RPC fallback) → 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.

  • Stub client.Caller per package (same idea as tradeys MCP.Call func field).
  • Every typed method has a test that asserts the MCP tool name and the JSON args (including decimal→string).
  • Parser tests cover tradeys known envelopes: accounts with data wrapper, quotes as {quotes:[{quote:{…}, close:{…}}]}, historicals {historicals:[{symbol, data_points}]}, watchlists title vs name, string-or-number amounts.
  • WriteTokens mode 0600.
  • Login from ROBINHOOD_ACCESS_TOKEN (no OAuth).
  • ToolError is errors.As-able from a typed method failure.
  • Coverage test: a frozen tools/list fixture (captured once, checked in) must have a method for every name. When the live MCP grows, update the fixture and add the method.

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

Package map

Package Does Depends on
rh Connect, 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
accountsscanner typed methods client.Caller, rh enums, internal/wire

Success criteria

  • go test ./... passes with no network.
  • Connect against a stub caller is unnecessary; typed methods work with an injected Caller.
  • 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.