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.
17 KiB
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:
- Connects to
https://agent.robinhood.com/mcp/tradingthe way tradey does today (OAuth, token file, streamable HTTP session, JSON-RPC fallback). - Exposes a typed Go method for every tool on that MCP (equity, options, crypto, watchlists, market data, scanner, accounts).
- Uses Alpaca-shaped money and order enums on the public API, translating to Robinhood’s string wire format internally.
- Can be imported later by tradey; this effort does not rewire tradey.
Non-goals (v1)
- A CLI binary (
loginstays a library function; tradey keepstradey login). - Rewiring tradey to import this module.
- Tradey’s
Reader/Executor/Snapshot/Fakedesk 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.comnon-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 (rh → auth → 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 tradey’s 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 library’s default, Grok-chat MCP, and so on).
- MCP
Implementation.Name/Implementation.Version:cfg.Name,cfg.Version(defaultsrobinhood-agentic-mcp/0.1.0). - OAuth dynamic client registration
ClientName: the samecfg.Name. - OAuth callback page text uses
cfg.Name(not a hardcoded “Tradey is signed in”). StreamableClientTransportwithDisableStandaloneSSE: true.- Prefer the SDK session
CallTool. If session connect fails, fall back to HTTP POST JSON-RPCtools/callwithAuthorization: BearerandAccept: application/json, text/event-stream. - Token file JSON matches tradey’s
TokenSet(access, refresh, type, expiry, client_id/secret, auth/token/redirect URLs, account_id). Mode0600. Logintimeout: 5 minutes for the OAuth dance.- Session connect does not prompt; expired OAuth returns an error telling the caller to run
Loginagain. - Connect does not write refreshed tokens back to disk (v1). The token file is whatever
Loginlast 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
omitemptyon the wire map. Optional MCP fields are pointers or zero-means-omit. client.Callstays 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(AlpacaPlaceOrderRequest.Qty/LimitPrice). - Never
float64for money, size, prices, buying power, volume, or bar OHLC.
On the way out, encode with Decimal.String() onto Robinhood’s string fields (quantity, limit_price, stop_price, dollar_amount, option price). On the way in, accept JSON string or number (tradey’s 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 Alpaca’s "day" / "stop" onto Robinhood; the const values are Robinhood’s.
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 (Robinhood’s “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_orders → equity).
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 tradey’s 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 caller’s 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. Tradey’s 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 Robinhood’s MCP surface the way tradey talks to it:
- Required protocol: JSON-RPC
tools/call(tradey’s RPC fallback). Optional extra: streamable-HTTPCallToolif the SDK client can talk to the same httptest without extra machinery. - Requires
Authorization: Bearerwhen the test sets a token. - Dispatches on tool name and returns checked-in envelope fixtures (
testdata/*.json) shaped like live Robinhood (datawrappers, string-or-number amounts, quote/close objects). - Can return HTTP 4xx, JSON-RPC errors, and malformed bodies so
ToolErrorand parse-error paths are exercised.
Required tests:
- Transport:
Connectagainstrhntest(session if the mock can serve it, otherwise RPC fallback), bearer header, HTTP error →ToolError, identityName/Versionsent on the MCP initialize/OAuth client metadata. - Every typed method: (1) stub
Callerasserting MCP tool name + JSON args includingdecimal.String(); (2) round-trip throughrhntestwith a realistic success fixture. - Wire/decimal: omitted/null/
""/0vs unparseable ("n/a", object) — the latter errors, the former does not. - Parser fixtures from tradey’s known envelopes:
accountswithdatawrapper, quotes as{quotes:[{quote:{…}, close:{…}}]}, historicals{historicals:[{symbol, data_points}]}, watchliststitlevsname. WriteTokensmode0600.LoginfromROBINHOOD_ACCESS_TOKEN(no OAuth, no browser).ToolErroriserrors.As-able.- Coverage: frozen
testdata/tools.jsonfrom livetools/listmust 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 |
accounts … scanner |
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
Callerand round-trip throughrhntest. Connect/LoginacceptConfig.Name/Config.Version; empty uses library defaults.LoginwithROBINHOOD_ACCESS_TOKENwrites0600tokens.equity.PlaceOrdersendsquantity/limit_priceas decimal strings,time_in_forcegfd,typelimit— matching tradey’splaceArgsfor the same inputs.- Every name in the frozen
tools/listfixture 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
Executorconstructor split. replacedirective /go.modchange in tradey.