# 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 that presents every Robinhood Agentic MCP tool with OAuth/token transport 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 this repository. - 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: `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": ...}`. `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 `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) } } ``` Implement `rpcCall` (`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: "example-app", 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: "example-app", Version: "1.2.3", URL: "http://x"}.WithDefaults() if c.Name != "example-app" || c.Version != "1.2.3" || c.URL != "http://x" { t.Fatalf("%+v", c) } } ``` Define token JSON tags on `TokenSet` (access, refresh, type, expiry, client_id/secret, auth/token/redirect URLs, account_id). - [ ] **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 implemented")` 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: Robinhood OAuth + MCP session connect requirements - 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)` (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: "example-app", Version: "0.9"}) if gotN != "example-app" || 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. Implement `loginOAuth` and `connectSession` / `bearerRT` / `tokenSetFrom` / `openBrowser`. Identity rules: - Implementation.Name → `cfg.Name` (defaulted) - `"0.1.0"` Version → `cfg.Version` - `ClientName` → `cfg.Name` - Callback copy → `cfg.Name+" is signed in. You can close this tab."` - Authorize prompt → `"Open this URL to authorize "+cfg.Name+" with Robinhood"` - Expired session → `"robinhood session expired; run Login again"` - After connect, `get_accounts` via a temporary `client.Client{session, Call: sessionCall}` is optional for account_id; persist `saved.AccountID` when present. Do **not** enforce app-level 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` + OAuth with the identity rules 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` **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 Robinhood 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 the 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 (`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`. Items keep `symbol` / `object_type` / nested `instrument.symbol`. Do not filter object types in v1 (equity-only filters stay in the app). - [ ] **Step 1: Failing tests** — stub table 12 tools; rhntest Lists `{"watchlists":[{"id":"wl-1","title":"Tech"}]}` 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: this repository only **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. - [ ] **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: "example-app"}) 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 | | this module stands alone | all | | README | 15 | No `TBD`/`TODO`. `Connect` is defined in Task 15 after subpackages exist; earlier tasks test via `pkg.New(stub|rpcClient)`.