From 58b9b48e15c8e8dc369c8e26f3f19f3e88df1680 Mon Sep 17 00:00:00 2001 From: Justin Harms Date: Tue, 1 Sep 2026 10:36:19 -0500 Subject: [PATCH] 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-robinhood-agentic-mcp-design.md | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-01-robinhood-agentic-mcp-design.md diff --git a/docs/superpowers/specs/2026-09-01-robinhood-agentic-mcp-design.md b/docs/superpowers/specs/2026-09-01-robinhood-agentic-mcp-design.md new file mode 100644 index 0000000..b1fdb94 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-robinhood-agentic-mcp-design.md @@ -0,0 +1,352 @@ +# 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 Robinhood’s 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. +- Tradey’s `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/ +``` + +```go +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 tradey’s 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 tradey’s `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`: + +```go +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 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`. 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: + +```go +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 + +```go +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 : " +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 → 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 tradey’s `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 tradey’s 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 | +| `accounts` … `scanner` | 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 tradey’s `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.