9e711957c0
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.
1205 lines
39 KiB
Markdown
1205 lines
39 KiB
Markdown
# 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**
|
||
|
||
```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 <Token>`), methods `Set(name string, result json.RawMessage)`, `SetHTTPError(status int, body string)`, `SetRPCError(name, message string)`, `LastName() string`, `LastArgs() map[string]any`, `Close()`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```go
|
||
package rhntest_test
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"testing"
|
||
|
||
"s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest"
|
||
)
|
||
|
||
func TestServer_toolsCall(t *testing.T) {
|
||
t.Parallel()
|
||
s := rhntest.New(t)
|
||
s.Token = "tok"
|
||
s.Set("get_accounts", json.RawMessage(`{"accounts":[]}`))
|
||
body, _ := json.Marshal(map[string]any{
|
||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||
"params": map[string]any{"name": "get_accounts", "arguments": map[string]any{}},
|
||
})
|
||
req, _ := http.NewRequest(http.MethodPost, s.URL, bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer tok")
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("%d %s", resp.StatusCode, raw)
|
||
}
|
||
var out struct {
|
||
Result json.RawMessage `json:"result"`
|
||
}
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if string(out.Result) != `{"accounts":[]}` {
|
||
t.Fatalf("%s", out.Result)
|
||
}
|
||
if s.LastName() != "get_accounts" {
|
||
t.Fatalf("%q", s.LastName())
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `go test ./internal/rhntest/ -count=1`
|
||
|
||
Expected: FAIL undefined `New`
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
`httptest.NewServer`. On POST, if `Token != ""` and header != `"Bearer "+Token`, return 401. If `httpStatus` set globally via `SetHTTPError`, return that. Else decode JSON-RPC, look up `params.name`. If `SetRPCError` for that name, return `{"jsonrpc":"2.0","id":id,"error":{"message":...}}`. Else return `{"jsonrpc":"2.0","id":id,"result": <bytes>}`. Unknown tool → RPC error `"unknown tool"`. `t.Cleanup(s.Close)`.
|
||
|
||
- [ ] **Step 4: Run tests and make sure they pass**
|
||
|
||
Run: `go test ./internal/rhntest/ -count=1`
|
||
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add internal/rhntest/
|
||
git commit -m "feat: add httptest Robinhood MCP mock"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: client RPC Call
|
||
|
||
**Files:**
|
||
- Create: `client/rpc.go`, `client/client.go`, `client/rpc_test.go`
|
||
- Modify: none
|
||
|
||
**Interfaces:**
|
||
- Consumes: `rhntest.Server`, `wire` unused here
|
||
- Produces: `type Client struct { URL, Token, Name, Version string; HTTP *http.Client; Hook Caller; session /* unexported */ }`, `func (c *Client) Call(ctx context.Context, name string, args map[string]any) (json.RawMessage, error)`
|
||
- If `Hook != nil`, return `Hook.Call`
|
||
- Else if `session != nil`, session path (implemented in Task 5)
|
||
- Else JSON-RPC POST as `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 wired")` until Task 6 replaces that with `loginOAuth`.
|
||
|
||
- [ ] **Step 4: Run tests and make sure they pass**
|
||
|
||
Run: `go test ./auth/ -count=1`
|
||
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add auth/
|
||
git commit -m "feat: add token file and env Login"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: OAuth Login and session connect
|
||
|
||
**Files:**
|
||
- Create: `auth/oauth.go`, `client/session.go`
|
||
- Modify: `auth/login.go` (call `loginOAuth` instead of the placeholder error)
|
||
|
||
**Interfaces:**
|
||
- Consumes: 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)`.
|