From 699d26d90d2b5d537e40a12c1e701752bc891aaf Mon Sep 17 00:00:00 2001 From: Justin Harms Date: Tue, 1 Sep 2026 11:05:28 -0500 Subject: [PATCH] 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. --- .../plans/2026-09-01-robinhood-agentic-mcp.md | 1204 +++++++++++++++++ 1 file changed, 1204 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-01-robinhood-agentic-mcp.md diff --git a/docs/superpowers/plans/2026-09-01-robinhood-agentic-mcp.md b/docs/superpowers/plans/2026-09-01-robinhood-agentic-mcp.md new file mode 100644 index 0000000..8c96f98 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-robinhood-agentic-mcp.md @@ -0,0 +1,1204 @@ +# 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 tradey’s 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 : "` 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 `), 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": }`. 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 tradey’s `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 tradey’s 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 method’s 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 (tradey’s 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 tool’s 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 7–14 `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 | 7–14 | +| Historicals no hidden interval | 8 | +| Crypto `rhs_account_number` | 11 | +| No watchlist title helper | 12 | +| rhntest + per-method stub + round-trip | 3, 7–14 | +| tools.json coverage | 15 | +| No live MCP / no browser tests | 5–6, 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)`.