9e711957c0
Outsiders reading this module should not see private sibling names (tradey), unfinished rewire notes, or /fast/projects lab paths. Keep the library self-contained in README, design, plan, and test fixtures/identity strings.
396 lines
17 KiB
Markdown
396 lines
17 KiB
Markdown
# 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 over OAuth, a token file, streamable HTTP, and JSON-RPC fallback.
|
||
|
||
Not investment advice. The caller is responsible for every fill in the Robinhood Agentic account.
|
||
|
||
## Goal
|
||
|
||
A standalone Go module that:
|
||
|
||
1. Connects to `https://agent.robinhood.com/mcp/trading` (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. Is importable by any Go app; this effort ships the library alone, not consumer rewires.
|
||
|
||
## Non-goals (v1)
|
||
|
||
- A CLI binary (`login` stays a library function).
|
||
- Rewiring downstream apps to import this module.
|
||
- App-level `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).
|
||
- Live MCP or browser-OAuth tests (`//go:build live` smokes included). Tests stop at `rhntest` and env-token `Login`.
|
||
|
||
## Operator contract
|
||
|
||
```go
|
||
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 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` (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/
|
||
```
|
||
|
||
```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, 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`. Downstream adapters can inject the same caller.
|
||
|
||
## Auth and transport
|
||
|
||
Auth and session code lives in `auth/` and `client/`. Identity is caller-owned via `Config.Name` / `Config.Version` so each app is a distinct Robinhood OAuth client (this library’s default, or any caller-chosen name).
|
||
|
||
- 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 product string).
|
||
- `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 is `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 (Robinhood envelopes) 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:
|
||
|
||
```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 <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 an app-level 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 `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. Callers that want match-by-title then `get_watchlist_items` (optionally filtering equity/ETF) implement that in the app.
|
||
|
||
### `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:
|
||
|
||
- Required protocol: JSON-RPC `tools/call` (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 known Robinhood 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 tests, including `//go:build live` smokes. Not deferred — not in this library.
|
||
|
||
## 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). There is no live-MCP test target.
|
||
- 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`.
|
||
- Every name in the frozen `tools/list` fixture has a method.
|
||
- This module stands alone; it does not import or modify downstream apps.
|
||
|
||
## Out of scope (v1)
|
||
|
||
- `Watchlist(title string) ([]string, error)` helper.
|
||
- Composite `Snapshot`.
|
||
- Paper `Executor` constructor split.
|
||
- Downstream `go.mod` / `replace` rewires.
|