Files
robinhood-agentic-mcp/docs/superpowers/plans/2026-09-01-robinhood-agentic-mcp.md
T
s1d3sw1ped_bot 9e711957c0 docs: Remove sibling product and lab path leaks
Outsiders reading this module should not see private sibling
names (tradey), unfinished rewire notes, or /fast/projects
lab paths. Keep the library self-contained in README, design,
plan, and test fixtures/identity strings.
2026-09-01 19:43:35 +00:00

39 KiB
Raw Blame History

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 <name>: <message>" and Unwrap().
  • No CLI. No live MCP tests. No browser-OAuth tests. No //go:build live.
  • Import cycle: enums and ToolError live in client. auth owns Config and DefaultURL. rh re-exports them. Subpackages import client, not rh. rh imports subpackages only in the facade task.
  • TDD: failing test first, then minimal code, then commit per task. t.Parallel() where safe. cmp.Diff for structs.
  • Errors wrap with %w, lowercase messages, no panic in library code.

File map

go.mod
go.sum
.gitignore
Makefile
README.md
rh.go                         // package rh: DefaultURL, Config alias, enums aliases, Connect, Login, API
tools.go                      // package rh: RegisteredTools()
testdata/tools.json
client/enums.go
client/error.go
client/caller.go
client/rpc.go
client/session.go
auth/config.go
auth/tokens.go
auth/login.go
auth/oauth.go
internal/wire/wire.go
internal/rhntest/server.go
accounts/client.go
accounts/accounts.go
equity/client.go
equity/read.go
equity/write.go
options/client.go
options/read.go
options/write.go
crypto/client.go
crypto/crypto.go
watchlists/client.go
watchlists/watchlists.go
market/client.go
market/market.go
scanner/client.go
scanner/scanner.go

Plus *_test.go next to each implementation file and testdata/*.json fixtures beside the tests that need them.


Task 1: Module and decimal wire

Files:

  • Create: go.mod, .gitignore, Makefile, internal/wire/wire.go, internal/wire/wire_test.go

Interfaces:

  • Produces: wire.Unwrap(raw json.RawMessage) json.RawMessage, wire.Dec(v any) (decimal.Decimal, error), wire.DecOpt(v any) (*decimal.Decimal, error), wire.Encode(d decimal.Decimal) string

  • Step 1: Write the failing test

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.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

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/call body {jsonrpc,id,method,params:{name,arguments}}

  • Produces: rhntest.New(t *testing.T) *Server with fields URL string, Token string (if non-empty, require Authorization: Bearer <Token>), methods Set(name string, result json.RawMessage), SetHTTPError(status int, body string), SetRPCError(name, message string), LastName() string, LastArgs() map[string]any, Close()

  • Step 1: Write the failing test

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, 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

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(). 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

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 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
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):

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
  • ClientNamecfg.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
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
  1. Stub table: every methods MCP name + args (Historicals must send start_time RFC3339 and must not inject interval when the request Interval is empty).
  2. rhntest Quotes fixture:
{"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.

  1. 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
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) 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 tools schema (symbols, instrument_ids, start_time RFC3339, interval required for index historicals, filing_id, section, filing_ids, concepts, form_type, since, until, cursor, period, limit). Money/OHLC as decimal.Decimal.

  • Step 1: Failing tests — stub table 10 names; rhntest EarningsResults with next_report_date/report_date.

  • Step 2: go test ./market/ -count=1 FAIL

  • Step 3: Implement

  • Step 4: PASS

  • Step 5: Commit feat: add market data MCP methods


Task 14: scanner package

Files:

  • Create: scanner/client.go, scanner/scanner.go, scanner/scanner_test.go

Interfaces:

  • scanner.New(client.Caller) *Client, scanner.Tools() []string
Method MCP
FilterSpecs get_scanner_filter_specs
Datapoints get_scanner_datapoints
Scans get_scans
Create create_scan
Preview preview_scan
Run run_scan
UpdateFilters update_scan_filters
UpdateConfig update_scan_config

Filter/column request structs match the MCP properties (filter_type, predicate, values []string, interval, length, plot, expression, display_title, display_name, visible, order). Run requires scan_id.

  • Step 1: Failing tests — stub table 8 names; Run args scan_id; rhntest Scans {"scans":[]}.

  • Step 2: go test ./scanner/ -count=1 FAIL

  • Step 3: Implement

  • Step 4: PASS

  • Step 5: Commit feat: add scanner MCP methods


Task 15: rh facade, coverage, README

Files:

  • Create: rh.go, connect.go, tools.go, rh_test.go, testdata/tools.json, README.md
  • Modify: 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 714 Tools() (spec tables). Coverage test: every entry in the file is in RegisteredTools(), and every RegisteredTools() name is in the file (exact set).

Connect test: rhntest with token file mode 0600 + get_accounts fixture; Connect succeeds via RPC fallback; api.Equity.Quotes hits the mock.

README: module path, DefaultURL, Config.Name, example Login/Connect/Equity.PlaceOrder with decimal + rh.Limit, warning that this moves real money, not investment advice, no CLI.

  • 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=1 FAIL

  • Step 3: Implement facade + README

  • Step 4: go test ./... -count=1 PASS. 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 714
Historicals no hidden interval 8
Crypto rhs_account_number 11
No watchlist title helper 12
rhntest + per-method stub + round-trip 3, 714
tools.json coverage 15
No live MCP / no browser tests 56, 15
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).