The implementation plan still said "oauth not wired" in a Task 5 error-string sketch. Prefer "not implemented" so unfinished wiring language does not read like a sibling-product leak.
39 KiB
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.Versionset beforeLogin/Connect. Empty →robinhood-agentic-mcp/0.1.0. - Money/size/price:
decimal.Decimalor*decimal.Decimal. Neverfloat64. Encode withDecimal.String(). Unparseable JSON errors; omitted/null/""/0do not. - Enums: Alpaca-shaped names, Robinhood wire values (
Stop="stop_market",GFD="gfd"). - Errors:
*client.ToolErrorwithError() string="mcp <name>: <message>"andUnwrap(). - No CLI. No live MCP tests. No browser-OAuth tests. No
//go:build live. - Import cycle: enums and
ToolErrorlive inclient.authownsConfigandDefaultURL.rhre-exports them. Subpackages importclient, notrh.rhimports subpackages only in the facade task. - TDD: failing test first, then minimal code, then commit per task.
t.Parallel()where safe.cmp.Difffor 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
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
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.SideconstsBuy="buy",Sell="sell"client.OrderTypeconstsMarket="market",Limit="limit",Stop="stop_market",StopLimit="stop_limit",StopLoss="stop_loss"client.TimeInForceconstsGFD="gfd",GTC="gtc",GFW="gfw",GFM="gfm"client.MarketHoursconstsRegularHours="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 }withError() stringandUnwrap() errorfunc ToolErrorf(name, format string, args ...any) *ToolErrortype 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)withfunc (f Func) Call(...)so tests can inject a function
-
Step 1: Write the failing test
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
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/callbody{jsonrpc,id,method,params:{name,arguments}} -
Produces:
rhntest.New(t *testing.T) *Serverwith fieldsURL string,Token string(if non-empty, requireAuthorization: Bearer <Token>), methodsSet(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
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
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,wireunused 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, returnHook.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→*ToolErrorwithNameset
- If
-
Step 1: Write the failing test
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
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:
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(). IfROBINHOOD_ACCESS_TOKENset,WriteTokens(cfg.TokenFile, tok, ROBINHOOD_REFRESH_TOKEN)and return"". ElseloginOAuth(Task 6). MissingTokenFileis an error. -
Step 1: Write the failing test
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
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(callloginOAuthinstead of the placeholder error)
Interfaces:
- Consumes: Robinhood OAuth + MCP session connect requirements
- Produces:
loginOAuth(ctx, cfg Config) (accountID string, error)usingcfg.Nameas MCPImplementation.Nameand OAuthClientName; callback bodycfg.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):
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.VersionClientName→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_accountsvia a temporaryclient.Client{session, Call: sessionCall}is optional for account_id; persistsaved.AccountIDwhen present. Do not enforce app-level IRA/margin bans.
Login: env token first, else loginOAuth.
-
Step 1: Write the failing
TestOAuthIdentityinauth/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
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:
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.
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
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
- Stub table: every method’s MCP name + args (
Historicalsmust sendstart_timeRFC3339 and must not injectintervalwhen the request Interval is empty). - rhntest Quotes fixture:
{"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.
- Historicals fixture from the parser:
begins_atRFC3339, 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
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:
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
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
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:
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) andPlaceOrder(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→ wirerhs_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_numberandtype=stop_losswhenType: client.StopLoss; rhntest Quotes round-trip. -
Step 2:
go test ./crypto/ -count=1FAIL -
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=1FAIL -
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
EarningsResultswithnext_report_date/report_date. -
Step 2:
go test ./market/ -count=1FAIL -
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;
Runargsscan_id; rhntestScans{"scans":[]}. -
Step 2:
go test ./scanner/ -count=1FAIL -
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:
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
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=1FAIL -
Step 3: Implement facade + README
-
Step 4:
go test ./... -count=1PASS.go vet ./...PASS. -
Step 5: Commit
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).