Files
robinhood-agentic-mcp/docs/superpowers/plans/2026-09-01-robinhood-agentic-mcp.md
T
s1d3sw1ped 699d26d90d docs: add robinhood-agentic-mcp implementation plan
TDD tasks for wire, client, rhntest, auth, each asset-class
package, then the rh Connect facade and tool coverage.
2026-09-01 11:05:28 -05:00

1205 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# robinhood-agentic-mcp Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship a Go library at `/fast/projects/golang/robinhood-agentic-mcp` that presents every Robinhood Agentic MCP tool with tradeys transport/auth and Alpaca-shaped decimals/enums.
**Architecture:** One shared `client.Client` (streamable HTTP session, JSON-RPC fallback). Asset-class packages (`accounts`, `equity`, `options`, `crypto`, `watchlists`, `market`, `scanner`) wrap `client.Caller`. Root `rh` is a facade (`Connect`, `Login`, re-exported `Config` and enums). Tests hit `internal/rhntest`, never live Robinhood.
**Tech Stack:** Go 1.25, `github.com/modelcontextprotocol/go-sdk` v1.7.x, `golang.org/x/oauth2`, `github.com/alpacahq/alpacadecimal` imported as `decimal`, `github.com/google/go-cmp` in tests.
## Global Constraints
- Module path: `s1d3sw1ped/robinhood-agentic-mcp`. Go 1.25. Work only in `/fast/projects/golang/robinhood-agentic-mcp`. Do not modify tradey.
- Default MCP URL: `https://agent.robinhood.com/mcp/trading`.
- Identity: `Config.Name` / `Config.Version` set before `Login`/`Connect`. Empty → `robinhood-agentic-mcp` / `0.1.0`.
- Money/size/price: `decimal.Decimal` or `*decimal.Decimal`. Never `float64`. Encode with `Decimal.String()`. Unparseable JSON errors; omitted/null/`""`/`0` do not.
- Enums: Alpaca-shaped names, Robinhood wire values (`Stop` = `"stop_market"`, `GFD` = `"gfd"`).
- Errors: `*client.ToolError` with `Error() string` = `"mcp <name>: <message>"` and `Unwrap()`.
- No CLI. No live MCP tests. No browser-OAuth tests. No `//go:build live`.
- Import cycle: enums and `ToolError` live in `client`. `auth` owns `Config` and `DefaultURL`. `rh` re-exports them. Subpackages import `client`, not `rh`. `rh` imports subpackages only in the facade task.
- TDD: failing test first, then minimal code, then commit per task. `t.Parallel()` where safe. `cmp.Diff` for structs.
- Errors wrap with `%w`, lowercase messages, no panic in library code.
## File map
```
go.mod
go.sum
.gitignore
Makefile
README.md
rh.go // package rh: DefaultURL, Config alias, enums aliases, Connect, Login, API
tools.go // package rh: RegisteredTools()
testdata/tools.json
client/enums.go
client/error.go
client/caller.go
client/rpc.go
client/session.go
auth/config.go
auth/tokens.go
auth/login.go
auth/oauth.go
internal/wire/wire.go
internal/rhntest/server.go
accounts/client.go
accounts/accounts.go
equity/client.go
equity/read.go
equity/write.go
options/client.go
options/read.go
options/write.go
crypto/client.go
crypto/crypto.go
watchlists/client.go
watchlists/watchlists.go
market/client.go
market/market.go
scanner/client.go
scanner/scanner.go
```
Plus `*_test.go` next to each implementation file and `testdata/*.json` fixtures beside the tests that need them.
---
### Task 1: Module and decimal wire
**Files:**
- Create: `go.mod`, `.gitignore`, `Makefile`, `internal/wire/wire.go`, `internal/wire/wire_test.go`
**Interfaces:**
- Produces: `wire.Unwrap(raw json.RawMessage) json.RawMessage`, `wire.Dec(v any) (decimal.Decimal, error)`, `wire.DecOpt(v any) (*decimal.Decimal, error)`, `wire.Encode(d decimal.Decimal) string`
- [ ] **Step 1: Write the failing test**
```go
package wire_test
import (
"encoding/json"
"testing"
decimal "github.com/alpacahq/alpacadecimal"
"s1d3sw1ped/robinhood-agentic-mcp/internal/wire"
)
func TestUnwrap_dataEnvelope(t *testing.T) {
t.Parallel()
in := json.RawMessage(`{"data":{"cash":"1000"}}`)
got := wire.Unwrap(in)
if string(got) != `{"cash":"1000"}` {
t.Fatalf("got %s", got)
}
}
func TestDec_table(t *testing.T) {
t.Parallel()
zero := decimal.Zero
tests := []struct {
name string
in any
want decimal.Decimal
wantErr bool
}{
{"number", float64(99.6), decimal.RequireFromString("99.6"), false},
{"string", "99.60", decimal.RequireFromString("99.60"), false},
{"zeroNum", float64(0), zero, false},
{"zeroStr", "0", zero, false},
{"emptyStr", "", zero, false},
{"nil", nil, zero, false},
{"bad", "n/a", zero, true},
{"obj", map[string]any{"x": 1}, zero, true},
{"bool", true, zero, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := wire.Dec(tc.in)
if tc.wantErr {
if err == nil {
t.Fatalf("want error")
}
return
}
if err != nil {
t.Fatal(err)
}
if !got.Equal(tc.want) {
t.Fatalf("got %s want %s", got, tc.want)
}
})
}
}
func TestDecOpt_nullIsNil(t *testing.T) {
t.Parallel()
got, err := wire.DecOpt(nil)
if err != nil || got != nil {
t.Fatalf("got %v err %v", got, err)
}
got, err = wire.DecOpt("")
if err != nil || got != nil {
t.Fatalf("empty string: %v %v", got, err)
}
got, err = wire.DecOpt("0")
if err != nil || got == nil || !got.IsZero() {
t.Fatalf("zero: %v %v", got, err)
}
_, err = wire.DecOpt("n/a")
if err == nil {
t.Fatal("unparseable must error")
}
}
func TestEncode(t *testing.T) {
t.Parallel()
d := decimal.RequireFromString("99.6")
if wire.Encode(d) != d.String() {
t.Fatalf("%q", wire.Encode(d))
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /fast/projects/golang/robinhood-agentic-mcp && go test ./internal/wire/ -count=1`
Expected: FAIL module/package not found (create `go.mod` first if `go test` refuses, then FAIL undefined `wire`).
- [ ] **Step 3: Write minimal implementation**
`go.mod`:
```
module s1d3sw1ped/robinhood-agentic-mcp
go 1.25.0
```
Then run `go get github.com/alpacahq/alpacadecimal@latest github.com/google/go-cmp@v0.7.0 github.com/modelcontextprotocol/go-sdk@v1.7.0 golang.org/x/oauth2@v0.35.0` so versions are real tags, not invented. If `decimal.RequireFromString` is missing on alpacadecimal, use `decimal.NewFromString` and `t.Fatal` on error in tests.
`.gitignore`:
```
bin/
*.exe
coverage.out
```
`Makefile`:
```
.PHONY: test vet
test:
go test ./...
vet:
go vet ./...
```
`internal/wire/wire.go`: `Unwrap` reads optional `{"data": ...}` (same as tradey `unwrapData`). `Dec` accepts `nil` (zero, nil error), `float64`, `json.Number`, numeric `string`, empty string (zero, nil error); anything else errors with `fmt.Errorf("parse decimal: %v", v)`. `DecOpt`: `nil` or `""``(nil, nil)`; else `Dec` and return a pointer. `Encode` returns `d.String()`.
- [ ] **Step 4: Run tests and make sure they pass**
Run: `go test ./internal/wire/ -count=1`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add go.mod go.sum .gitignore Makefile internal/wire/
git commit -m "feat: add module and decimal wire helpers"
```
---
### Task 2: Enums, ToolError, Caller
**Files:**
- Create: `client/enums.go`, `client/error.go`, `client/caller.go`, `client/error_test.go`, `client/enums_test.go`
**Interfaces:**
- Produces:
- `client.Side` consts `Buy="buy"`, `Sell="sell"`
- `client.OrderType` consts `Market="market"`, `Limit="limit"`, `Stop="stop_market"`, `StopLimit="stop_limit"`, `StopLoss="stop_loss"`
- `client.TimeInForce` consts `GFD="gfd"`, `GTC="gtc"`, `GFW="gfw"`, `GFM="gfm"`
- `client.MarketHours` consts `RegularHours="regular_hours"`, `ExtendedHours="extended_hours"`, `AllDayHours="all_day_hours"`, `RegularCurbHours="regular_curb_hours"`, `RegularCurbOvernightHours="regular_curb_overnight_hours"`
- `type ToolError struct { Name, Message string; Err error }` with `Error() string` and `Unwrap() error`
- `func ToolErrorf(name, format string, args ...any) *ToolError`
- `type Caller interface { Call(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) }`
- `type Func func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error)` with `func (f Func) Call(...)` so tests can inject a function
- [ ] **Step 1: Write the failing test**
```go
package client_test
import (
"errors"
"testing"
"s1d3sw1ped/robinhood-agentic-mcp/client"
)
func TestToolError_formatAndAs(t *testing.T) {
t.Parallel()
inner := errors.New("boom")
err := client.ToolErrorf("get_accounts", "parse quotes: %w", inner)
if err.Error() != "mcp get_accounts: parse quotes: boom" {
t.Fatalf("%q", err.Error())
}
var te *client.ToolError
if !errors.As(err, &te) || te.Name != "get_accounts" {
t.Fatalf("%v", err)
}
if !errors.Is(err, inner) {
t.Fatal("unwrap")
}
}
func TestEnums_wireValues(t *testing.T) {
t.Parallel()
if client.Buy != "buy" || client.Stop != "stop_market" || client.StopLoss != "stop_loss" {
t.Fatal("side/type")
}
if client.GFD != "gfd" || client.RegularHours != "regular_hours" {
t.Fatal("tif/hours")
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./client/ -count=1 -run 'TestToolError|TestEnums'`
Expected: FAIL undefined
- [ ] **Step 3: Write minimal implementation**
`ToolErrorf` sets `Name`, `Message` = `fmt.Sprintf(format, args...)`, `Err` = `fmt.Errorf(format, args...)` so Unwrap works. `Error()` returns `"mcp "+Name+": "+Message`.
- [ ] **Step 4: Run tests and make sure they pass**
Run: `go test ./client/ -count=1`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add client/
git commit -m "feat: add MCP enums, ToolError, and Caller"
```
---
### Task 3: rhntest HTTP mock
**Files:**
- Create: `internal/rhntest/server.go`, `internal/rhntest/server_test.go`
**Interfaces:**
- Consumes: JSON-RPC `tools/call` body `{jsonrpc,id,method,params:{name,arguments}}`
- Produces: `rhntest.New(t *testing.T) *Server` with fields `URL string`, `Token string` (if non-empty, require `Authorization: Bearer <Token>`), methods `Set(name string, result json.RawMessage)`, `SetHTTPError(status int, body string)`, `SetRPCError(name, message string)`, `LastName() string`, `LastArgs() map[string]any`, `Close()`
- [ ] **Step 1: Write the failing test**
```go
package rhntest_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
"s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest"
)
func TestServer_toolsCall(t *testing.T) {
t.Parallel()
s := rhntest.New(t)
s.Token = "tok"
s.Set("get_accounts", json.RawMessage(`{"accounts":[]}`))
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": "get_accounts", "arguments": map[string]any{}},
})
req, _ := http.NewRequest(http.MethodPost, s.URL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer tok")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
t.Fatalf("%d %s", resp.StatusCode, raw)
}
var out struct {
Result json.RawMessage `json:"result"`
}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
if string(out.Result) != `{"accounts":[]}` {
t.Fatalf("%s", out.Result)
}
if s.LastName() != "get_accounts" {
t.Fatalf("%q", s.LastName())
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/rhntest/ -count=1`
Expected: FAIL undefined `New`
- [ ] **Step 3: Write minimal implementation**
`httptest.NewServer`. On POST, if `Token != ""` and header != `"Bearer "+Token`, return 401. If `httpStatus` set globally via `SetHTTPError`, return that. Else decode JSON-RPC, look up `params.name`. If `SetRPCError` for that name, return `{"jsonrpc":"2.0","id":id,"error":{"message":...}}`. Else return `{"jsonrpc":"2.0","id":id,"result": <bytes>}`. Unknown tool → RPC error `"unknown tool"`. `t.Cleanup(s.Close)`.
- [ ] **Step 4: Run tests and make sure they pass**
Run: `go test ./internal/rhntest/ -count=1`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add internal/rhntest/
git commit -m "feat: add httptest Robinhood MCP mock"
```
---
### Task 4: client RPC Call
**Files:**
- Create: `client/rpc.go`, `client/client.go`, `client/rpc_test.go`
- Modify: none
**Interfaces:**
- Consumes: `rhntest.Server`, `wire` unused here
- Produces: `type Client struct { URL, Token, Name, Version string; HTTP *http.Client; Hook Caller; session /* unexported */ }`, `func (c *Client) Call(ctx context.Context, name string, args map[string]any) (json.RawMessage, error)`
- If `Hook != nil`, return `Hook.Call`
- Else if `session != nil`, session path (implemented in Task 5)
- Else JSON-RPC POST as tradey `rpcCall`: `Content-Type: application/json`, `Accept: application/json, text/event-stream`, `Authorization: Bearer `+Token when Token != `""`
- HTTP ≥300, decode errors, and JSON-RPC `error``*ToolError` with `Name` set
- [ ] **Step 1: Write the failing test**
```go
package client_test
import (
"context"
"encoding/json"
"errors"
"testing"
"s1d3sw1ped/robinhood-agentic-mcp/client"
"s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest"
)
func TestClientCall_rpcRoundTrip(t *testing.T) {
t.Parallel()
s := rhntest.New(t)
s.Token = "tok"
s.Set("get_equity_quotes", json.RawMessage(`{"quotes":[{"symbol":"MU"}]}`))
c := &client.Client{URL: s.URL, Token: "tok"}
raw, err := c.Call(context.Background(), "get_equity_quotes", map[string]any{"symbols": []string{"MU"}})
if err != nil {
t.Fatal(err)
}
if string(raw) != `{"quotes":[{"symbol":"MU"}]}` {
t.Fatalf("%s", raw)
}
if s.LastName() != "get_equity_quotes" {
t.Fatal(s.LastName())
}
}
func TestClientCall_httpErrorIsToolError(t *testing.T) {
t.Parallel()
s := rhntest.New(t)
s.SetHTTPError(500, "nope")
c := &client.Client{URL: s.URL}
_, err := c.Call(context.Background(), "get_accounts", map[string]any{})
var te *client.ToolError
if !errors.As(err, &te) || te.Name != "get_accounts" {
t.Fatalf("%v", err)
}
}
func TestClientCall_hook(t *testing.T) {
t.Parallel()
c := &client.Client{Hook: client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
return json.RawMessage(`{"ok":true}`), nil
})}
raw, err := c.Call(context.Background(), "x", nil)
if err != nil || string(raw) != `{"ok":true}` {
t.Fatalf("%s %v", raw, err)
}
}
```
Copy tradeys `rpcCall` from `/fast/projects/golang/tradey/internal/broker/mcp.go` (`rpcReq`/`rpcResp`, POST `tools/call`) and wrap failures with `ToolErrorf(name, "%w", err)` or `ToolErrorf(name, "http %d: %s", code, body)`.
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./client/ -count=1 -run TestClientCall`
Expected: FAIL missing `Call`
- [ ] **Step 3: Write minimal implementation**
- [ ] **Step 4: Run tests and make sure they pass**
Run: `go test ./client/ -count=1`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add client/
git commit -m "feat: add JSON-RPC MCP Call with ToolError"
```
---
### Task 5: Auth tokens and env Login
**Files:**
- Create: `auth/config.go`, `auth/tokens.go`, `auth/login.go`, `auth/tokens_test.go`, `auth/login_test.go`
**Interfaces:**
- Produces:
```go
const DefaultURL = "https://agent.robinhood.com/mcp/trading"
const DefaultName = "robinhood-agentic-mcp"
const DefaultVersion = "0.1.0"
type Config struct {
URL, TokenFile, Name, Version string
}
func (c Config) WithDefaults() Config // empty URL/Name/Version filled
type TokenSet struct {
AccessToken, RefreshToken, TokenType string
Expiry time.Time
ClientID, ClientSecret, AuthURL, TokenURL, RedirectURL, AccountID string
}
func WriteTokens(path, access, refresh string) error
func WriteTokenSet(path string, t TokenSet) error // mode 0600, trailing newline
func ReadTokens(path string) (TokenSet, error)
func Login(ctx context.Context, cfg Config) (accountID string, error)
```
- `Login`: `cfg = cfg.WithDefaults()`. If `ROBINHOOD_ACCESS_TOKEN` set, `WriteTokens(cfg.TokenFile, tok, ROBINHOOD_REFRESH_TOKEN)` and return `""`. Else `loginOAuth` (Task 6). Missing `TokenFile` is an error.
- [ ] **Step 1: Write the failing test**
```go
package auth_test
import (
"os"
"path/filepath"
"testing"
"s1d3sw1ped/robinhood-agentic-mcp/auth"
)
func TestWriteTokensMode(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "tokens.json")
if err := auth.WriteTokens(path, "abc", ""); err != nil {
t.Fatal(err)
}
st, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if st.Mode().Perm() != 0o600 {
t.Fatalf("perm %o", st.Mode().Perm())
}
}
func TestLoginFromEnv(t *testing.T) {
t.Setenv("ROBINHOOD_ACCESS_TOKEN", "tok-live")
t.Setenv("ROBINHOOD_REFRESH_TOKEN", "ref")
path := filepath.Join(t.TempDir(), "tokens.json")
id, err := auth.Login(t.Context(), auth.Config{TokenFile: path, Name: "tradey", Version: "9"})
if err != nil {
t.Fatal(err)
}
if id != "" {
t.Fatalf("id %q", id)
}
tok, err := auth.ReadTokens(path)
if err != nil {
t.Fatal(err)
}
if tok.AccessToken != "tok-live" || tok.RefreshToken != "ref" {
t.Fatalf("%+v", tok)
}
}
func TestWithDefaults(t *testing.T) {
t.Parallel()
c := auth.Config{}.WithDefaults()
if c.URL != auth.DefaultURL || c.Name != auth.DefaultName || c.Version != auth.DefaultVersion {
t.Fatalf("%+v", c)
}
c = auth.Config{Name: "tradey", Version: "1.2.3", URL: "http://x"}.WithDefaults()
if c.Name != "tradey" || c.Version != "1.2.3" || c.URL != "http://x" {
t.Fatalf("%+v", c)
}
}
```
Copy token JSON tags from `/fast/projects/golang/tradey/internal/broker/login.go` `TokenSet`.
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./auth/ -count=1`
Expected: FAIL undefined
- [ ] **Step 3: Write minimal implementation**
`Login` env branch only. If token env is empty, return `fmt.Errorf("login: no ROBINHOOD_ACCESS_TOKEN and oauth not wired")` until Task 6 replaces that with `loginOAuth`.
- [ ] **Step 4: Run tests and make sure they pass**
Run: `go test ./auth/ -count=1`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add auth/
git commit -m "feat: add token file and env Login"
```
---
### Task 6: OAuth Login and session connect
**Files:**
- Create: `auth/oauth.go`, `client/session.go`
- Modify: `auth/login.go` (call `loginOAuth` instead of the placeholder error)
**Interfaces:**
- Consumes: tradey `/fast/projects/golang/tradey/internal/broker/oauth.go` and `connectSession` in `mcp.go`/`oauth.go`
- Produces: `loginOAuth(ctx, cfg Config) (accountID string, error)` using `cfg.Name` as MCP `Implementation.Name` **and** OAuth `ClientName`; callback body `cfg.Name+" is signed in. You can close this tab."`; `client.ConnectSession(ctx, url string, tok auth.TokenSet, name, version string) (*Client, error)` port of tradey `connectSession` (OAuth handler if ClientID+TokenURL present, else bearer transport)
No browser-OAuth test. Identity wiring is tested by exporting `oauthIdentity(cfg Config) (name, version string)` (unexported is fine if tested in package `auth`, not `auth_test`):
```go
func TestOAuthIdentity(t *testing.T) {
gotN, gotV := oauthIdentity(Config{Name: "tradey", Version: "0.9"})
if gotN != "tradey" || gotV != "0.9" {
t.Fatalf("%s %s", gotN, gotV)
}
gotN, gotV = oauthIdentity(Config{})
if gotN != DefaultName || gotV != DefaultVersion {
t.Fatalf("%s %s", gotN, gotV)
}
}
```
`loginOAuth` must call `oauthIdentity(cfg.WithDefaults())` for Implementation.Name, ClientName, and the callback HTML.
Copy `loginOAuth` and `connectSession` / `bearerRT` / `tokenSetFrom` / `openBrowser` from tradey. Substitutions:
- `"tradey"` Implementation.Name → `cfg.Name` (defaulted)
- `"0.1.0"` Version → `cfg.Version`
- `ClientName: "tradey"``cfg.Name`
- `"Tradey is signed in..."``cfg.Name+" is signed in. You can close this tab."`
- `"Open this URL to authorize Tradey with Robinhood"``"Open this URL to authorize "+cfg.Name+" with Robinhood"`
- `"robinhood session expired; run tradey login"``"robinhood session expired; run Login again"`
- After connect, `get_accounts` via a temporary `client.Client{session, Call: sessionCall}` is optional for account_id; port tradeys block that writes `saved.AccountID`. Do **not** enforce tradey `supported()` IRA/margin bans.
`Login`: env token first, else `loginOAuth`.
- [ ] **Step 1: Write the failing `TestOAuthIdentity` in `auth/oauth_test.go` (`package auth`)**
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./auth/ -count=1 -run TestOAuthIdentity`
Expected: FAIL undefined `oauthIdentity`
- [ ] **Step 3: Write the OAuth implementation** (`oauthIdentity` + copy tradey OAuth with the substitutions below)
- [ ] **Step 4: Run tests**
Run: `go test ./auth/ ./client/ -count=1`
Expected: PASS (no browser invoked)
- [ ] **Step 5: Commit**
```bash
git add auth/ client/
git commit -m "feat: add OAuth login and MCP session connect"
```
---
### Task 7: accounts package
**Files:**
- Create: `accounts/client.go`, `accounts/accounts.go`, `accounts/accounts_test.go`, `accounts/testdata/accounts.json` (copy `/fast/projects/golang/tradey/internal/broker/testdata/accounts.json`)
**Interfaces:**
- Consumes: `client.Caller`, `wire.Unwrap`, `wire.Dec` / `DecOpt`
- Produces:
```go
func New(c client.Caller) *Client
func Tools() []string // MCP names this package implements
func (c *Client) Accounts(ctx context.Context, req AccountsRequest) (AccountsResult, error) // get_accounts
func (c *Client) Portfolio(ctx context.Context, req PortfolioRequest) (PortfolioResult, error) // get_portfolio
func (c *Client) RealizedPnL(ctx context.Context, req RealizedPnLRequest) (RealizedPnLResult, error)
func (c *Client) PnLTradeHistory(ctx context.Context, req PnLTradeHistoryRequest) (PnLTradeHistoryResult, error)
func (c *Client) LimitedMarginUpgradeInfo(ctx context.Context, req AccountNumberRequest) (UpgradeInfoResult, error)
func (c *Client) OptionLevelUpgradeInfo(ctx context.Context, req AccountNumberRequest) (UpgradeInfoResult, error)
func (c *Client) CryptoOnboardingInfo(ctx context.Context, req struct{}) (OnboardingInfoResult, error)
func (c *Client) Search(ctx context.Context, req SearchRequest) (SearchResult, error)
```
Request fields (omit empty on the wire map):
| Method | MCP | Args |
|---|---|---|
| Accounts | `get_accounts` | none |
| Portfolio | `get_portfolio` | `account_number` |
| RealizedPnL | `get_realized_pnl` | `account_number`, `span`, `start_date`, `end_date`, `asset_classes []string`, `display_currency`, `timezone` |
| PnLTradeHistory | `get_pnl_trade_history` | `account_number`, `span`, `symbol`, `cursor` |
| LimitedMarginUpgradeInfo | `get_limited_margin_upgrade_info` | `account_number` |
| OptionLevelUpgradeInfo | `get_option_level_upgrade_info` | `account_number` |
| CryptoOnboardingInfo | `get_crypto_account_onboarding_info` | none |
| Search | `search` | `query`, `asset_type`, `limit` |
`Account` in `AccountsResult`: `AccountNumber`, `ID`, `RHSAccountNumber` (json `rhs_account_number` if present), `Type`, `AgenticAllowed`, `Cash`, `BuyingPower *decimal.Decimal`. Do not filter IRA/margin.
- [ ] **Step 1: Write the failing test**
Table-driven stub Caller: each method asserts MCP name + args. Plus rhntest round-trip for `Accounts` using the copied fixture, and `Portfolio` using `{"data":{"buying_power":{"buying_power":"1000.0"}}}` so `wire.Unwrap` + `Dec` yield buying power `1000.0`.
```go
func TestAccounts_toolNames(t *testing.T) {
t.Parallel()
var gotName string
var gotArgs map[string]any
c := accounts.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
gotName, gotArgs = name, args
return json.RawMessage(`{}`), nil
}))
_, _ = c.Portfolio(context.Background(), accounts.PortfolioRequest{AccountNumber: "acct-1"})
if gotName != "get_portfolio" || gotArgs["account_number"] != "acct-1" {
t.Fatalf("%s %+v", gotName, gotArgs)
}
}
```
Repeat a table for every method in this package (name + required args). Include `Tools()` containing exactly the eight MCP names.
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./accounts/ -count=1`
Expected: FAIL undefined
- [ ] **Step 3: Write minimal implementation**
Each method: `c.c.Call(ctx, tool, args)` then unmarshal into a loose struct with `any` money fields, `wire.Unwrap`, `wire.Dec`/`DecOpt`. Parse errors → `client.ToolErrorf(tool, "parse: %w", err)`.
- [ ] **Step 4: Run tests**
Run: `go test ./accounts/ -count=1`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add accounts/
git commit -m "feat: add accounts MCP methods"
```
---
### Task 8: equity reads
**Files:**
- Create: `equity/client.go`, `equity/read.go`, `equity/read_test.go`
**Interfaces:**
- Produces `equity.New(c client.Caller) *Client`, `equity.Tools() []string`, and:
| Method | MCP | Request |
|---|---|---|
| Positions | `get_equity_positions` | `AccountNumber`, `Cursor` |
| TaxLots | `get_equity_tax_lots` | `AccountNumber`, `Symbol`, `Cursor` |
| Quotes | `get_equity_quotes` | `Symbols []string` |
| Orders | `get_equity_orders` | `AccountNumber`, `OrderID`, `State`, `Symbol`, `CreatedAtGTE`, `PlacedAgent`, `Cursor` |
| Tradability | `get_equity_tradability` | `AccountNumber`, `Symbols` |
| Historicals | `get_equity_historicals` | `Symbols`, `StartTime time.Time`, `EndTime time.Time` (zero omits), `Interval`, `Bounds`, `AdjustmentType` — RFC3339 UTC; **no** hidden `minute`/`regular` |
| Fundamentals | `get_equity_fundamentals` | `Symbols`, `Bounds` |
| PriceBook | `get_equity_price_book` | `Symbols` |
| TechnicalIndicators | `get_equity_technical_indicators` | `Symbol`, `Type`, `Interval`, `StartTime time.Time` (required), `EndTime time.Time` (zero omits), `Bounds`, `AdjustmentType`, `Output`, `Period *int`, `NumStd *decimal.Decimal`, `FastPeriod *int`, `SlowPeriod *int`, `SignalPeriod *int`, `Multiplier *decimal.Decimal`, `Method` |
| News | `get_equity_news` | `Symbol`, `Limit int`, `Cursor` |
`Quote`: `Symbol string`, `Bid, Ask, Last, PrevClose, Volume decimal.Decimal`. Parse tradey envelope `{quotes:[{quote:{symbol,last_trade_price,bid_price,ask_price}, close:{symbol,price}}]}`.
`Bar`: `Symbol`, `Time time.Time`, `Open, High, Low, Close, Volume decimal.Decimal`, `Interpolated bool`. Parse `{historicals:[{symbol, data_points:[{begins_at, open, high, low, close, volume, interpolated}]}]}`.
- [ ] **Step 1: Write failing tests**
1. Stub table: every methods MCP name + args (`Historicals` must send `start_time` RFC3339 and must **not** inject `interval` when the request Interval is empty).
2. rhntest Quotes fixture:
```json
{"quotes":[{"symbol":"MU","quote":{"symbol":"MU","last_trade_price":"100","bid_price":"99.9","ask_price":"100.1"},"close":{"symbol":"MU","price":"98"}}]}
```
Assert `Last == 100`, `Bid == 99.9`, `PrevClose == 98`.
3. Historicals fixture from tradey parser: `begins_at` RFC3339, string OHLC.
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./equity/ -count=1`
Expected: FAIL undefined
- [ ] **Step 3: Write minimal implementation**
- [ ] **Step 4: Run tests**
Run: `go test ./equity/ -count=1`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add equity/
git commit -m "feat: add equity read MCP methods"
```
---
### Task 9: equity writes (review / place / cancel)
**Files:**
- Create: `equity/write.go`, `equity/write_test.go`
- Modify: `equity/Tools()` to include the three write tools
**Interfaces:**
```go
type PlaceOrderRequest struct {
AccountNumber string
Symbol string
Side client.Side
Type client.OrderType
Qty *decimal.Decimal
DollarAmount *decimal.Decimal
LimitPrice *decimal.Decimal
StopPrice *decimal.Decimal
TimeInForce client.TimeInForce // empty → omit (Robinhood defaults gfd)
MarketHours client.MarketHours
TaxLots []TaxLot
RefID string
}
type TaxLot struct {
OpenLotID string
Quantity decimal.Decimal
}
func (c *Client) ReviewOrder(ctx context.Context, req PlaceOrderRequest) (ReviewResult, error) // get_equity: review_equity_order; omit ref_id
func (c *Client) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (Order, error) // place_equity_order
func (c *Client) CancelOrder(ctx context.Context, req CancelOrderRequest) error // account_number, order_id
```
Wire map (tradey `placeArgs`): `quantity`/`limit_price`/`stop_price`/`dollar_amount` as `wire.Encode`; `time_in_force` only if non-empty; `ref_id` and `idempotency_key` both set from `RefID` on Place (not Review); `type` = `string(req.Type)` so `client.Stop``"stop_market"`.
- [ ] **Step 1: Write the failing test**
```go
func TestPlaceOrder_decimalStrings(t *testing.T) {
t.Parallel()
var got map[string]any
c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
if name != "place_equity_order" {
t.Fatalf("%s", name)
}
got = args
return json.RawMessage(`{"id":"o1"}`), nil
}))
qty := decimal.NewFromInt(3)
px := decimal.RequireFromString("99.6")
_, err := c.PlaceOrder(context.Background(), equity.PlaceOrderRequest{
AccountNumber: "acct", Symbol: "MU", Side: client.Buy, Type: client.Limit,
Qty: &qty, LimitPrice: &px, TimeInForce: client.GFD, RefID: "buy:2026-08-18:MU",
})
if err != nil {
t.Fatal(err)
}
if got["type"] != "limit" || got["time_in_force"] != "gfd" || got["quantity"] != "3" {
t.Fatalf("%+v", got)
}
if got["limit_price"] != "99.6" && got["limit_price"] != "99.60" {
t.Fatalf("limit %v", got["limit_price"])
}
if got["ref_id"] != "buy:2026-08-18:MU" {
t.Fatalf("ref %v", got["ref_id"])
}
}
```
Also rhntest PlaceOrder success and ReviewOrder parsing `errors`/`warnings` arrays. CancelOrder asserts `account_number` + `order_id`.
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./equity/ -count=1 -run TestPlaceOrder`
Expected: FAIL
- [ ] **Step 3: Write minimal implementation**
- [ ] **Step 4: Run tests**
Run: `go test ./equity/ -count=1`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add equity/
git commit -m "feat: add equity place, review, and cancel"
```
---
### Task 10: options package
**Files:**
- Create: `options/client.go`, `options/read.go`, `options/write.go`, `options/options_test.go`
**Interfaces:**
- `options.New(client.Caller) *Client`, `options.Tools() []string`
| Method | MCP |
|---|---|
| Chains | `get_option_chains` |
| Instruments | `get_option_instruments` |
| Quotes | `get_option_quotes` |
| Positions | `get_option_positions` |
| Orders | `get_option_orders` |
| Historicals | `get_option_historicals` |
| ReviewOrder | `review_option_order` |
| PlaceOrder | `place_option_order` |
| CancelOrder | `cancel_option_order` |
| ReplaceOrder | `replace_option_order` |
| Exercise | `exercise_option` |
| CancelExercise | `cancel_option_exercise` |
Request structs: one field per MCP property from the live schema (instrument_ids, chain_id, chain_symbol, expiration_dates, strike_price, type, legs[], direction, quantity as `*decimal.Decimal` or string-encoded decimal, price/stop_price decimals, time_in_force, market_hours, ref_id, cursor, nonzero, etc.). Legs:
```go
type Leg struct {
OptionID string
Side client.Side
PositionEffect string // "open" | "close"
RatioQuantity int
}
```
- [ ] **Step 1: Write failing tests** — stub table for all 12 names + args; rhntest round-trip for `Quotes` (`instrument_ids`) and `PlaceOrder` (legs encoded as array of maps). `Tools()` lists all 12.
- [ ] **Step 2: Run** `go test ./options/ -count=1` — FAIL
- [ ] **Step 3: Implement**
- [ ] **Step 4: Run** `go test ./options/ -count=1` — PASS
- [ ] **Step 5: Commit** `feat: add options MCP methods`
---
### Task 11: crypto package
**Files:**
- Create: `crypto/client.go`, `crypto/crypto.go`, `crypto/crypto_test.go`
**Interfaces:**
- `crypto.New(client.Caller) *Client`, `crypto.Tools() []string`
- Account field name `RHSAccountNumber` → wire `rhs_account_number`
| Method | MCP |
|---|---|
| Pairs | `get_currency_pairs` |
| Quotes | `get_crypto_quotes` |
| Positions | `get_crypto_positions` |
| Orders | `get_crypto_orders` |
| PreviewOrder | `preview_crypto_order` |
| PlaceOrder | `place_crypto_order` |
| CancelOrder | `cancel_crypto_order` |
`PlaceOrderRequest`: `RHSAccountNumber`, `Symbol`, `Side`, `Type` (`client.StopLoss` = `"stop_loss"`), `Qty *decimal.Decimal`, `DollarAmount *decimal.Decimal`, `LimitPrice`, `StopPrice`, `TimeInForce`, `RefID`. Same decimal string encoding as equity.
- [ ] **Step 1: Failing tests** — stub table all 7; PlaceOrder asserts `rhs_account_number` and `type`=`stop_loss` when `Type: client.StopLoss`; rhntest Quotes round-trip.
- [ ] **Step 2:** `go test ./crypto/ -count=1` FAIL
- [ ] **Step 3: Implement**
- [ ] **Step 4:** PASS
- [ ] **Step 5: Commit** `feat: add crypto MCP methods`
---
### Task 12: watchlists package
**Files:**
- Create: `watchlists/client.go`, `watchlists/watchlists.go`, `watchlists/watchlists_test.go`
**Interfaces:**
- `watchlists.New(client.Caller) *Client`, `watchlists.Tools() []string`
- No title-lookup helper.
| Method | MCP |
|---|---|
| Lists | `get_watchlists` |
| Items | `get_watchlist_items` |
| OptionList | `get_option_watchlist` |
| Popular | `get_popular_watchlists` |
| Create | `create_watchlist` |
| Update | `update_watchlist` |
| Follow | `follow_watchlist` |
| Unfollow | `unfollow_watchlist` |
| Add | `add_to_watchlist` |
| Remove | `remove_from_watchlist` |
| AddOption | `add_option_to_watchlist` |
| RemoveOption | `remove_option_from_watchlist` |
Parse lists with `title` falling back to `name` (tradey). Items keep `symbol` / `object_type` / nested `instrument.symbol`. Do not filter object types in v1 (tradeys equity-only filter stays in tradey).
- [ ] **Step 1: Failing tests** — stub table 12 tools; rhntest Lists `{"watchlists":[{"id":"wl-1","title":"TRADEY"}]}` and Items `{"items":[{"symbol":"MU","object_type":"equity"}]}`.
- [ ] **Step 2:** `go test ./watchlists/ -count=1` FAIL
- [ ] **Step 3: Implement**
- [ ] **Step 4:** PASS
- [ ] **Step 5: Commit** `feat: add watchlist MCP methods`
---
### Task 13: market package
**Files:**
- Create: `market/client.go`, `market/market.go`, `market/market_test.go`
**Interfaces:**
- `market.New(client.Caller) *Client`, `market.Tools() []string`
| Method | MCP |
|---|---|
| Indexes | `get_indexes` |
| IndexQuotes | `get_index_quotes` |
| IndexHistoricals | `get_index_historicals` |
| Financials | `get_financials` |
| EarningsResults | `get_earnings_results` |
| EarningsCalendar | `get_earnings_calendar` |
| SECFilingIndex | `get_sec_filing_index` |
| SECFiling | `get_sec_filing` |
| SECFilingFacts | `get_sec_filing_facts` |
| SECFilingFactsCatalog | `get_sec_filing_facts_catalog` |
Request fields from each tools schema (`symbols`, `instrument_ids`, `start_time` RFC3339, `interval` required for index historicals, `filing_id`, `section`, `filing_ids`, `concepts`, `form_type`, `since`, `until`, `cursor`, `period`, `limit`). Money/OHLC as `decimal.Decimal`.
- [ ] **Step 1: Failing tests** — stub table 10 names; rhntest `EarningsResults` with `next_report_date`/`report_date`.
- [ ] **Step 2:** `go test ./market/ -count=1` FAIL
- [ ] **Step 3: Implement**
- [ ] **Step 4:** PASS
- [ ] **Step 5: Commit** `feat: add market data MCP methods`
---
### Task 14: scanner package
**Files:**
- Create: `scanner/client.go`, `scanner/scanner.go`, `scanner/scanner_test.go`
**Interfaces:**
- `scanner.New(client.Caller) *Client`, `scanner.Tools() []string`
| Method | MCP |
|---|---|
| FilterSpecs | `get_scanner_filter_specs` |
| Datapoints | `get_scanner_datapoints` |
| Scans | `get_scans` |
| Create | `create_scan` |
| Preview | `preview_scan` |
| Run | `run_scan` |
| UpdateFilters | `update_scan_filters` |
| UpdateConfig | `update_scan_config` |
Filter/column request structs match the MCP properties (`filter_type`, `predicate`, `values []string`, `interval`, `length`, `plot`, `expression`, `display_title`, `display_name`, `visible`, `order`). `Run` requires `scan_id`.
- [ ] **Step 1: Failing tests** — stub table 8 names; `Run` args `scan_id`; rhntest `Scans` `{"scans":[]}`.
- [ ] **Step 2:** `go test ./scanner/ -count=1` FAIL
- [ ] **Step 3: Implement**
- [ ] **Step 4:** PASS
- [ ] **Step 5: Commit** `feat: add scanner MCP methods`
---
### Task 15: rh facade, coverage, README
**Files:**
- Create: `rh.go`, `connect.go`, `tools.go`, `rh_test.go`, `testdata/tools.json`, `README.md`
- Modify: none of tradey
**Interfaces:**
```go
package rh
const DefaultURL = auth.DefaultURL
type Config = auth.Config
type Side = client.Side
// re-export every enum const: Buy, Sell, Market, Limit, Stop, StopLimit, StopLoss, GFD, GTC, GFW, GFM, RegularHours, ...
type ToolError = client.ToolError
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 Login(ctx context.Context, cfg Config) (string, error) // auth.Login
func Connect(ctx context.Context, cfg Config) (*API, error)
func RegisteredTools() []string // concat of every package Tools()
```
`Connect`: `cfg = cfg.WithDefaults()`; `ReadTokens(cfg.TokenFile)` (error if missing access token); try `client.ConnectSession`; on failure construct `&client.Client{URL, Token: tok.AccessToken, Name: cfg.Name, Version: cfg.Version}` so `Call` uses RPC. Wire all subclients with `New(c)`. Fail closed on missing tokens.
`testdata/tools.json`: JSON array of every MCP name from Tasks 714 `Tools()` (spec tables). Coverage test: every entry in the file is in `RegisteredTools()`, and every `RegisteredTools()` name is in the file (exact set).
`Connect` test: rhntest with token file mode 0600 + `get_accounts` fixture; `Connect` succeeds via RPC fallback; `api.Equity.Quotes` hits the mock.
README: module path, DefaultURL, Config.Name, example `Login`/`Connect`/`Equity.PlaceOrder` with `decimal` + `rh.Limit`, warning that this moves real money, not investment advice, no CLI, tradey not wired yet.
- [ ] **Step 1: Write failing tests**
```go
func TestRegisteredTools_matchesFixture(t *testing.T) {
t.Parallel()
raw, err := os.ReadFile("testdata/tools.json")
if err != nil {
t.Fatal(err)
}
var want []string
if err := json.Unmarshal(raw, &want); err != nil {
t.Fatal(err)
}
got := rh.RegisteredTools()
sort.Strings(want)
sort.Strings(got)
if diff := cmp.Diff(want, got); diff != "" {
t.Fatal(diff)
}
}
func TestConnect_rpcFallback(t *testing.T) {
s := rhntest.New(t)
s.Token = "tok"
s.Set("get_equity_quotes", json.RawMessage(`{"quotes":[]}`))
path := filepath.Join(t.TempDir(), "tokens.json")
if err := auth.WriteTokens(path, "tok", ""); err != nil {
t.Fatal(err)
}
api, err := rh.Connect(t.Context(), rh.Config{URL: s.URL, TokenFile: path, Name: "tradey"})
if err != nil {
t.Fatal(err)
}
_, err = api.Equity.Quotes(t.Context(), equity.QuotesRequest{Symbols: []string{"MU"}})
if err != nil {
t.Fatal(err)
}
}
```
- [ ] **Step 2:** `go test . -count=1` FAIL
- [ ] **Step 3: Implement facade + README**
- [ ] **Step 4:** `go test ./... -count=1` PASS. `go vet ./...` PASS.
- [ ] **Step 5: Commit**
```bash
git add rh.go connect.go tools.go rh_test.go testdata/tools.json README.md
git commit -m "feat: add rh Connect facade and tool coverage"
```
---
## Self-review (spec coverage)
| Spec requirement | Task |
|---|---|
| Module path, Go 1.25, DefaultURL | 1, 5, 15 |
| Config.Name/Version before Login/Connect | 5, 6, 15 |
| Token file 0600, env Login | 5 |
| OAuth identity + callback text | 6 |
| Session + JSON-RPC fallback | 4, 6, 15 |
| `client.Call` escape hatch | 4 |
| `ToolError` | 2, 4 |
| alpacadecimal + parse rules | 1, 8, 9 |
| Enums Robinhood wire values | 2, 9, 11 |
| No Reader/Executor | never added |
| All tool tables | 714 |
| Historicals no hidden interval | 8 |
| Crypto `rhs_account_number` | 11 |
| No watchlist title helper | 12 |
| rhntest + per-method stub + round-trip | 3, 714 |
| tools.json coverage | 15 |
| No live MCP / no browser tests | 56, 15 |
| tradey untouched | all |
| README | 15 |
No `TBD`/`TODO`. `Connect` is defined in Task 15 after subpackages exist; earlier tasks test via `pkg.New(stub|rpcClient)`.