docs: Remove sibling product and lab path leaks

Strip private sibling names, rewire notes, and lab paths so the module stands alone.
This commit was merged in pull request #5.
This commit is contained in:
s1d3sw1ped_bot
2026-09-01 14:44:06 -05:00
7 changed files with 74 additions and 74 deletions
+2 -2
View File
@@ -8,13 +8,13 @@ Go library for the [Robinhood Agentic MCP](https://agent.robinhood.com/mcp/tradi
This moves **real money** in a Robinhood Agentic account. Not investment advice. The caller is responsible for every fill. This moves **real money** in a Robinhood Agentic account. Not investment advice. The caller is responsible for every fill.
There is no CLI. `Login` is a library function. tradey is not wired to this module yet. There is no CLI. `Login` is a library function.
## Identity ## Identity
Set `Config.Name` and `Config.Version` **before** `Login` or `Connect`. They are the MCP `Implementation` name/version and the OAuth client name. After a session exists they cannot be changed. Set `Config.Name` and `Config.Version` **before** `Login` or `Connect`. They are the MCP `Implementation` name/version and the OAuth client name. After a session exists they cannot be changed.
Empty `Name` / `Version` / `URL` become `robinhood-agentic-mcp` / `0.1.0` / `rh.DefaultURL`. Apps that need a distinct Robinhood OAuth client (tradey, another bot) pass their own `Name`. Empty `Name` / `Version` / `URL` become `robinhood-agentic-mcp` / `0.1.0` / `rh.DefaultURL`. Apps that need a distinct Robinhood OAuth client pass their own `Name`.
```go ```go
cfg := rh.Config{ cfg := rh.Config{
+3 -3
View File
@@ -11,7 +11,7 @@ func TestLoginFromEnv(t *testing.T) {
t.Setenv("ROBINHOOD_ACCESS_TOKEN", "tok-live") t.Setenv("ROBINHOOD_ACCESS_TOKEN", "tok-live")
t.Setenv("ROBINHOOD_REFRESH_TOKEN", "ref") t.Setenv("ROBINHOOD_REFRESH_TOKEN", "ref")
path := filepath.Join(t.TempDir(), "tokens.json") path := filepath.Join(t.TempDir(), "tokens.json")
id, err := auth.Login(t.Context(), auth.Config{TokenFile: path, Name: "tradey", Version: "9"}) id, err := auth.Login(t.Context(), auth.Config{TokenFile: path, Name: "example-app", Version: "9"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -33,8 +33,8 @@ func TestWithDefaults(t *testing.T) {
if c.URL != auth.DefaultURL || c.Name != auth.DefaultName || c.Version != auth.DefaultVersion { if c.URL != auth.DefaultURL || c.Name != auth.DefaultName || c.Version != auth.DefaultVersion {
t.Fatalf("%+v", c) t.Fatalf("%+v", c)
} }
c = auth.Config{Name: "tradey", Version: "1.2.3", URL: "http://x"}.WithDefaults() c = auth.Config{Name: "example-app", Version: "1.2.3", URL: "http://x"}.WithDefaults()
if c.Name != "tradey" || c.Version != "1.2.3" || c.URL != "http://x" { if c.Name != "example-app" || c.Version != "1.2.3" || c.URL != "http://x" {
t.Fatalf("%+v", c) t.Fatalf("%+v", c)
} }
} }
+2 -2
View File
@@ -3,8 +3,8 @@ package auth
import "testing" import "testing"
func TestOAuthIdentity(t *testing.T) { func TestOAuthIdentity(t *testing.T) {
gotN, gotV := oauthIdentity(Config{Name: "tradey", Version: "0.9"}) gotN, gotV := oauthIdentity(Config{Name: "example-app", Version: "0.9"})
if gotN != "tradey" || gotV != "0.9" { if gotN != "example-app" || gotV != "0.9" {
t.Fatalf("%s %s", gotN, gotV) t.Fatalf("%s %s", gotN, gotV)
} }
gotN, gotV = oauthIdentity(Config{}) gotN, gotV = oauthIdentity(Config{})
@@ -2,7 +2,7 @@
> **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. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship a Go library at `/fast/projects/golang/robinhood-agentic-mcp` that presents every Robinhood Agentic MCP tool with tradeys transport/auth and Alpaca-shaped decimals/enums. **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. **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.
@@ -10,7 +10,7 @@
## Global Constraints ## Global Constraints
- Module path: `s1d3sw1ped/robinhood-agentic-mcp`. Go 1.25. Work only in `/fast/projects/golang/robinhood-agentic-mcp`. Do not modify tradey. - Module path: `s1d3sw1ped/robinhood-agentic-mcp`. Go 1.25. Work only in this repository.
- Default MCP URL: `https://agent.robinhood.com/mcp/trading`. - 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`. - 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. - Money/size/price: `decimal.Decimal` or `*decimal.Decimal`. Never `float64`. Encode with `Decimal.String()`. Unparseable JSON errors; omitted/null/`""`/`0` do not.
@@ -165,7 +165,7 @@ func TestEncode(t *testing.T) {
- [ ] **Step 2: Run test to verify it fails** - [ ] **Step 2: Run test to verify it fails**
Run: `cd /fast/projects/golang/robinhood-agentic-mcp && go test ./internal/wire/ -count=1` 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`). Expected: FAIL module/package not found (create `go.mod` first if `go test` refuses, then FAIL undefined `wire`).
@@ -202,7 +202,7 @@ vet:
go vet ./... go vet ./...
``` ```
`internal/wire/wire.go`: `Unwrap` reads optional `{"data": ...}` (same as tradey `unwrapData`). `Dec` accepts `nil` (zero, nil error), `float64`, `json.Number`, numeric `string`, empty string (zero, nil error); anything else errors with `fmt.Errorf("parse decimal: %v", v)`. `DecOpt`: `nil` or `""``(nil, nil)`; else `Dec` and return a pointer. `Encode` returns `d.String()`. `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** - [ ] **Step 4: Run tests and make sure they pass**
@@ -395,7 +395,7 @@ git commit -m "feat: add httptest Robinhood MCP mock"
- 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)` - 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` - If `Hook != nil`, return `Hook.Call`
- Else if `session != nil`, session path (implemented in Task 5) - Else if `session != nil`, session path (implemented in Task 5)
- Else JSON-RPC POST as tradey `rpcCall`: `Content-Type: application/json`, `Accept: application/json, text/event-stream`, `Authorization: Bearer `+Token when Token != `""` - 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 - HTTP ≥300, decode errors, and JSON-RPC `error``*ToolError` with `Name` set
- [ ] **Step 1: Write the failing test** - [ ] **Step 1: Write the failing test**
@@ -455,7 +455,7 @@ func TestClientCall_hook(t *testing.T) {
} }
``` ```
Copy tradeys `rpcCall` from `/fast/projects/golang/tradey/internal/broker/mcp.go` (`rpcReq`/`rpcResp`, POST `tools/call`) and wrap failures with `ToolErrorf(name, "%w", err)` or `ToolErrorf(name, "http %d: %s", code, body)`. 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** - [ ] **Step 2: Run test to verify it fails**
@@ -541,7 +541,7 @@ func TestLoginFromEnv(t *testing.T) {
t.Setenv("ROBINHOOD_ACCESS_TOKEN", "tok-live") t.Setenv("ROBINHOOD_ACCESS_TOKEN", "tok-live")
t.Setenv("ROBINHOOD_REFRESH_TOKEN", "ref") t.Setenv("ROBINHOOD_REFRESH_TOKEN", "ref")
path := filepath.Join(t.TempDir(), "tokens.json") path := filepath.Join(t.TempDir(), "tokens.json")
id, err := auth.Login(t.Context(), auth.Config{TokenFile: path, Name: "tradey", Version: "9"}) id, err := auth.Login(t.Context(), auth.Config{TokenFile: path, Name: "example-app", Version: "9"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -563,14 +563,14 @@ func TestWithDefaults(t *testing.T) {
if c.URL != auth.DefaultURL || c.Name != auth.DefaultName || c.Version != auth.DefaultVersion { if c.URL != auth.DefaultURL || c.Name != auth.DefaultName || c.Version != auth.DefaultVersion {
t.Fatalf("%+v", c) t.Fatalf("%+v", c)
} }
c = auth.Config{Name: "tradey", Version: "1.2.3", URL: "http://x"}.WithDefaults() c = auth.Config{Name: "example-app", Version: "1.2.3", URL: "http://x"}.WithDefaults()
if c.Name != "tradey" || c.Version != "1.2.3" || c.URL != "http://x" { if c.Name != "example-app" || c.Version != "1.2.3" || c.URL != "http://x" {
t.Fatalf("%+v", c) t.Fatalf("%+v", c)
} }
} }
``` ```
Copy token JSON tags from `/fast/projects/golang/tradey/internal/broker/login.go` `TokenSet`. 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** - [ ] **Step 2: Run test to verify it fails**
@@ -604,15 +604,15 @@ git commit -m "feat: add token file and env Login"
- Modify: `auth/login.go` (call `loginOAuth` instead of the placeholder error) - Modify: `auth/login.go` (call `loginOAuth` instead of the placeholder error)
**Interfaces:** **Interfaces:**
- Consumes: tradey `/fast/projects/golang/tradey/internal/broker/oauth.go` and `connectSession` in `mcp.go`/`oauth.go` - 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)` port of tradey `connectSession` (OAuth handler if ClientID+TokenURL present, else bearer transport) - 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`): 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 ```go
func TestOAuthIdentity(t *testing.T) { func TestOAuthIdentity(t *testing.T) {
gotN, gotV := oauthIdentity(Config{Name: "tradey", Version: "0.9"}) gotN, gotV := oauthIdentity(Config{Name: "example-app", Version: "0.9"})
if gotN != "tradey" || gotV != "0.9" { if gotN != "example-app" || gotV != "0.9" {
t.Fatalf("%s %s", gotN, gotV) t.Fatalf("%s %s", gotN, gotV)
} }
gotN, gotV = oauthIdentity(Config{}) gotN, gotV = oauthIdentity(Config{})
@@ -624,15 +624,15 @@ func TestOAuthIdentity(t *testing.T) {
`loginOAuth` must call `oauthIdentity(cfg.WithDefaults())` for Implementation.Name, ClientName, and the callback HTML. `loginOAuth` must call `oauthIdentity(cfg.WithDefaults())` for Implementation.Name, ClientName, and the callback HTML.
Copy `loginOAuth` and `connectSession` / `bearerRT` / `tokenSetFrom` / `openBrowser` from tradey. Substitutions: Implement `loginOAuth` and `connectSession` / `bearerRT` / `tokenSetFrom` / `openBrowser`. Identity rules:
- `"tradey"` Implementation.Name → `cfg.Name` (defaulted) - Implementation.Name → `cfg.Name` (defaulted)
- `"0.1.0"` Version → `cfg.Version` - `"0.1.0"` Version → `cfg.Version`
- `ClientName: "tradey"``cfg.Name` - `ClientName``cfg.Name`
- `"Tradey is signed in..."``cfg.Name+" is signed in. You can close this tab."` - Callback copy`cfg.Name+" is signed in. You can close this tab."`
- `"Open this URL to authorize Tradey with Robinhood"``"Open this URL to authorize "+cfg.Name+" with Robinhood"` - Authorize prompt`"Open this URL to authorize "+cfg.Name+" with Robinhood"`
- `"robinhood session expired; run tradey login"``"robinhood session expired; run Login again"` - 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; port tradeys block that writes `saved.AccountID`. Do **not** enforce tradey `supported()` IRA/margin bans. - 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`. `Login`: env token first, else `loginOAuth`.
@@ -644,7 +644,7 @@ Run: `go test ./auth/ -count=1 -run TestOAuthIdentity`
Expected: FAIL undefined `oauthIdentity` Expected: FAIL undefined `oauthIdentity`
- [ ] **Step 3: Write the OAuth implementation** (`oauthIdentity` + copy tradey OAuth with the substitutions below) - [ ] **Step 3: Write the OAuth implementation** (`oauthIdentity` + OAuth with the identity rules below)
- [ ] **Step 4: Run tests** - [ ] **Step 4: Run tests**
@@ -664,7 +664,7 @@ git commit -m "feat: add OAuth login and MCP session connect"
### Task 7: accounts package ### Task 7: accounts package
**Files:** **Files:**
- Create: `accounts/client.go`, `accounts/accounts.go`, `accounts/accounts_test.go`, `accounts/testdata/accounts.json` (copy `/fast/projects/golang/tradey/internal/broker/testdata/accounts.json`) - Create: `accounts/client.go`, `accounts/accounts.go`, `accounts/accounts_test.go`, `accounts/testdata/accounts.json`
**Interfaces:** **Interfaces:**
- Consumes: `client.Caller`, `wire.Unwrap`, `wire.Dec` / `DecOpt` - Consumes: `client.Caller`, `wire.Unwrap`, `wire.Dec` / `DecOpt`
@@ -765,7 +765,7 @@ git commit -m "feat: add accounts MCP methods"
| 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` | | 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` | | News | `get_equity_news` | `Symbol`, `Limit int`, `Cursor` |
`Quote`: `Symbol string`, `Bid, Ask, Last, PrevClose, Volume decimal.Decimal`. Parse tradey envelope `{quotes:[{quote:{symbol,last_trade_price,bid_price,ask_price}, close:{symbol,price}}]}`. `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}]}]}`. `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}]}]}`.
@@ -780,7 +780,7 @@ git commit -m "feat: add accounts MCP methods"
Assert `Last == 100`, `Bid == 99.9`, `PrevClose == 98`. Assert `Last == 100`, `Bid == 99.9`, `PrevClose == 98`.
3. Historicals fixture from tradey parser: `begins_at` RFC3339, string OHLC. 3. Historicals fixture from the parser: `begins_at` RFC3339, string OHLC.
- [ ] **Step 2: Run test to verify it fails** - [ ] **Step 2: Run test to verify it fails**
@@ -836,7 +836,7 @@ func (c *Client) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (Order,
func (c *Client) CancelOrder(ctx context.Context, req CancelOrderRequest) error // account_number, order_id func (c *Client) CancelOrder(ctx context.Context, req CancelOrderRequest) error // account_number, order_id
``` ```
Wire map (tradey `placeArgs`): `quantity`/`limit_price`/`stop_price`/`dollar_amount` as `wire.Encode`; `time_in_force` only if non-empty; `ref_id` and `idempotency_key` both set from `RefID` on Place (not Review); `type` = `string(req.Type)` so `client.Stop``"stop_market"`. 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** - [ ] **Step 1: Write the failing test**
@@ -1000,9 +1000,9 @@ type Leg struct {
| AddOption | `add_option_to_watchlist` | | AddOption | `add_option_to_watchlist` |
| RemoveOption | `remove_option_from_watchlist` | | RemoveOption | `remove_option_from_watchlist` |
Parse lists with `title` falling back to `name` (tradey). Items keep `symbol` / `object_type` / nested `instrument.symbol`. Do not filter object types in v1 (tradeys equity-only filter stays in tradey). 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":"TRADEY"}]}` and Items `{"items":[{"symbol":"MU","object_type":"equity"}]}`. - [ ] **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 2:** `go test ./watchlists/ -count=1` FAIL
@@ -1086,7 +1086,7 @@ Filter/column request structs match the MCP properties (`filter_type`, `predicat
**Files:** **Files:**
- Create: `rh.go`, `connect.go`, `tools.go`, `rh_test.go`, `testdata/tools.json`, `README.md` - Create: `rh.go`, `connect.go`, `tools.go`, `rh_test.go`, `testdata/tools.json`, `README.md`
- Modify: none of tradey - Modify: this repository only
**Interfaces:** **Interfaces:**
```go ```go
@@ -1120,7 +1120,7 @@ func RegisteredTools() []string // concat of every package Tools()
`Connect` test: rhntest with token file mode 0600 + `get_accounts` fixture; `Connect` succeeds via RPC fallback; `api.Equity.Quotes` hits the mock. `Connect` test: rhntest with token file mode 0600 + `get_accounts` fixture; `Connect` succeeds via RPC fallback; `api.Equity.Quotes` hits the mock.
README: module path, DefaultURL, Config.Name, example `Login`/`Connect`/`Equity.PlaceOrder` with `decimal` + `rh.Limit`, warning that this moves real money, not investment advice, no CLI, tradey not wired yet. 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** - [ ] **Step 1: Write failing tests**
@@ -1151,7 +1151,7 @@ func TestConnect_rpcFallback(t *testing.T) {
if err := auth.WriteTokens(path, "tok", ""); err != nil { if err := auth.WriteTokens(path, "tok", ""); err != nil {
t.Fatal(err) t.Fatal(err)
} }
api, err := rh.Connect(t.Context(), rh.Config{URL: s.URL, TokenFile: path, Name: "tradey"}) api, err := rh.Connect(t.Context(), rh.Config{URL: s.URL, TokenFile: path, Name: "example-app"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1198,7 +1198,7 @@ git commit -m "feat: add rh Connect facade and tool coverage"
| rhntest + per-method stub + round-trip | 3, 714 | | rhntest + per-method stub + round-trip | 3, 714 |
| tools.json coverage | 15 | | tools.json coverage | 15 |
| No live MCP / no browser tests | 56, 15 | | No live MCP / no browser tests | 56, 15 |
| tradey untouched | all | | this module stands alone | all |
| README | 15 | | README | 15 |
No `TBD`/`TODO`. `Connect` is defined in Task 15 after subpackages exist; earlier tasks test via `pkg.New(stub|rpcClient)`. No `TBD`/`TODO`. `Connect` is defined in Task 15 after subpackages exist; earlier tasks test via `pkg.New(stub|rpcClient)`.
@@ -2,24 +2,24 @@
**Date:** 2026-09-01 **Date:** 2026-09-01
**Status:** approved design, pending implementation plan **Status:** approved design, pending implementation plan
**Product:** `robinhood-agentic-mcp` — a Go library that presents the full Robinhood Agentic MCP with the same transport and auth tradey already uses. **Product:** `robinhood-agentic-mcp` — a Go library that presents the full Robinhood Agentic MCP over OAuth, a token file, streamable HTTP, and JSON-RPC fallback.
Not investment advice. The caller is responsible for every fill in the Robinhood Agentic account. Not investment advice. The caller is responsible for every fill in the Robinhood Agentic account.
## Goal ## Goal
A standalone Go module at `/fast/projects/golang/robinhood-agentic-mcp` that: A standalone Go module that:
1. Connects to `https://agent.robinhood.com/mcp/trading` the way tradey does today (OAuth, token file, streamable HTTP session, JSON-RPC fallback). 1. Connects to `https://agent.robinhood.com/mcp/trading` (OAuth, token file, streamable HTTP session, JSON-RPC fallback).
2. Exposes a typed Go method for every tool on that MCP (equity, options, crypto, watchlists, market data, scanner, accounts). 2. Exposes a typed Go method for every tool on that MCP (equity, options, crypto, watchlists, market data, scanner, accounts).
3. Uses Alpaca-shaped money and order enums on the public API, translating to Robinhoods string wire format internally. 3. Uses Alpaca-shaped money and order enums on the public API, translating to Robinhoods string wire format internally.
4. Can be imported later by tradey; this effort does **not** rewire tradey. 4. Is importable by any Go app; this effort ships the library alone, not consumer rewires.
## Non-goals (v1) ## Non-goals (v1)
- A CLI binary (`login` stays a library function; tradey keeps `tradey login`). - A CLI binary (`login` stays a library function).
- Rewiring tradey to import this module. - Rewiring downstream apps to import this module.
- Tradeys `Reader` / `Executor` / `Snapshot` / `Fake` desk types (paper/live policy stays in the app). - App-level `Reader` / `Executor` / `Snapshot` / `Fake` desk types (paper/live policy stays in the app).
- Codegen from `tools/list`. - Codegen from `tools/list`.
- Proxying or re-hosting the MCP as a server. - Proxying or re-hosting the MCP as a server.
- Banking / credit-card MCP (`agent.robinhood.com` non-trading endpoints). - Banking / credit-card MCP (`agent.robinhood.com` non-trading endpoints).
@@ -47,7 +47,7 @@ func Connect(ctx context.Context, cfg Config) (*API, error)
| `rh.Connect` | Read `cfg.TokenFile`, open a session identified as `cfg.Name`/`cfg.Version`, return an `rh.API` with all subclients wired. Fail closed if tokens are missing or the session cannot be used. | | `rh.Connect` | Read `cfg.TokenFile`, open a session identified as `cfg.Name`/`cfg.Version`, return an `rh.API` with all subclients wired. Fail closed if tokens are missing or the session cannot be used. |
| `client.Call(ctx, name, args)` | Escape hatch: invoke any tool by MCP name and return JSON. | | `client.Call(ctx, name, args)` | Escape hatch: invoke any tool by MCP name and return JSON. |
Identity must be set on `Config` **before** `Login` or `Connect`. After a session exists it is not changeable. Apps that need a distinct Robinhood OAuth client (tradey, another bot) pass their own `Name`. Empty `Name`/`Version` keep the library defaults. Identity must be set on `Config` **before** `Login` or `Connect`. After a session exists it is not changeable. Apps that need a distinct Robinhood OAuth client pass their own `Name`. Empty `Name`/`Version` keep the library defaults.
Daemon/headless callers must not call `Login` (no browser). They call `Connect` with an existing token file. Daemon/headless callers must not call `Login` (no browser). They call `Connect` with an existing token file.
@@ -56,7 +56,7 @@ Daemon/headless callers must not call `Login` (no browser). They call `Connect`
Module path: `s1d3sw1ped/robinhood-agentic-mcp` Module path: `s1d3sw1ped/robinhood-agentic-mcp`
Go version: `1.25` Go version: `1.25`
Default MCP URL: `https://agent.robinhood.com/mcp/trading` Default MCP URL: `https://agent.robinhood.com/mcp/trading`
MCP SDK: `github.com/modelcontextprotocol/go-sdk` (same major as tradey, currently v1.7.0) MCP SDK: `github.com/modelcontextprotocol/go-sdk` (currently v1.7.0)
One shared session. Asset-class packages wrap it. The root package is a facade so a caller can import once. One shared session. Asset-class packages wrap it. The root package is a facade so a caller can import once.
@@ -97,18 +97,18 @@ func Login(ctx context.Context, cfg Config) (accountID string, err error) // re-
Empty `cfg.URL` means `DefaultURL`. Subpackages also export `New(c client.Caller) *Client` so callers can wire a single package without the facade. Empty `cfg.URL` means `DefaultURL`. Subpackages also export `New(c client.Caller) *Client` so callers can wire a single package without the facade.
Each subclient depends only on `client.Caller`. Unit tests inject a stub; transport tests hit `rhntest`. A future tradey adapter can inject the same caller. Each subclient depends only on `client.Caller`. Unit tests inject a stub; transport tests hit `rhntest`. Downstream adapters can inject the same caller.
## Auth and transport ## Auth and transport
Copied from tradeys working path (`internal/broker/oauth.go`, `login.go`, `mcp.go`). Identity is caller-owned via `Config.Name` / `Config.Version` so each app is a distinct Robinhood OAuth client (tradey, this librarys default, Grok-chat MCP, and so on). Auth and session code lives in `auth/` and `client/`. Identity is caller-owned via `Config.Name` / `Config.Version` so each app is a distinct Robinhood OAuth client (this librarys default, or any caller-chosen name).
- MCP `Implementation.Name` / `Implementation.Version`: `cfg.Name`, `cfg.Version` (defaults `robinhood-agentic-mcp` / `0.1.0`). - MCP `Implementation.Name` / `Implementation.Version`: `cfg.Name`, `cfg.Version` (defaults `robinhood-agentic-mcp` / `0.1.0`).
- OAuth dynamic client registration `ClientName`: the same `cfg.Name`. - OAuth dynamic client registration `ClientName`: the same `cfg.Name`.
- OAuth callback page text uses `cfg.Name` (not a hardcoded “Tradey is signed in”). - OAuth callback page text uses `cfg.Name` (not a hardcoded product string).
- `StreamableClientTransport` with `DisableStandaloneSSE: true`. - `StreamableClientTransport` with `DisableStandaloneSSE: true`.
- Prefer the SDK session `CallTool`. If session connect fails, fall back to HTTP POST JSON-RPC `tools/call` with `Authorization: Bearer` and `Accept: application/json, text/event-stream`. - Prefer the SDK session `CallTool`. If session connect fails, fall back to HTTP POST JSON-RPC `tools/call` with `Authorization: Bearer` and `Accept: application/json, text/event-stream`.
- Token file JSON matches tradeys `TokenSet` (access, refresh, type, expiry, client_id/secret, auth/token/redirect URLs, account_id). Mode `0600`. - Token file JSON is `TokenSet` (access, refresh, type, expiry, client_id/secret, auth/token/redirect URLs, account_id). Mode `0600`.
- `Login` timeout: 5 minutes for the OAuth dance. - `Login` timeout: 5 minutes for the OAuth dance.
- Session connect does not prompt; expired OAuth returns an error telling the caller to run `Login` again. - Session connect does not prompt; expired OAuth returns an error telling the caller to run `Login` again.
- Connect does **not** write refreshed tokens back to disk (v1). The token file is whatever `Login` last wrote. - Connect does **not** write refreshed tokens back to disk (v1). The token file is whatever `Login` last wrote.
@@ -138,7 +138,7 @@ Public money, size, and price fields use `github.com/alpacahq/alpacadecimal` imp
- Optional amounts: `*decimal.Decimal` (Alpaca `PlaceOrderRequest.Qty` / `LimitPrice`). - Optional amounts: `*decimal.Decimal` (Alpaca `PlaceOrderRequest.Qty` / `LimitPrice`).
- Never `float64` for money, size, prices, buying power, volume, or bar OHLC. - Never `float64` for money, size, prices, buying power, volume, or bar OHLC.
On the way out, encode with `Decimal.String()` onto Robinhoods string fields (`quantity`, `limit_price`, `stop_price`, `dollar_amount`, option `price`). On the way in, accept JSON string **or** number (tradeys envelope) and parse into `decimal.Decimal`. On the way out, encode with `Decimal.String()` onto Robinhoods string fields (`quantity`, `limit_price`, `stop_price`, `dollar_amount`, option `price`). On the way in, accept JSON string **or** number (Robinhood envelopes) and parse into `decimal.Decimal`.
Parse rules for money/size/price: Parse rules for money/size/price:
@@ -226,7 +226,7 @@ Source of truth for coverage is a frozen `testdata/tools.json` captured from liv
| `get_crypto_account_onboarding_info` | `CryptoOnboardingInfo` | | `get_crypto_account_onboarding_info` | `CryptoOnboardingInfo` |
| `search` | `Search` | | `search` | `Search` |
This library does **not** enforce tradeys `supported()` account policy (IRA/UTMA/full margin bans). It returns whatever `get_accounts` returns, including `agentic_allowed`. Apps decide. This library does **not** enforce an app-level account policy (IRA/UTMA/full margin bans). It returns whatever `get_accounts` returns, including `agentic_allowed`. Apps decide.
### `equity` ### `equity`
@@ -246,7 +246,7 @@ This library does **not** enforce tradeys `supported()` account policy (IRA/U
| `place_equity_order` | `PlaceOrder` | | `place_equity_order` | `PlaceOrder` |
| `cancel_equity_order` | `CancelOrder` | | `cancel_equity_order` | `CancelOrder` |
`Historicals` accepts caller interval/bounds/adjustment (no hidden tradey `minute`/`regular` defaults). Batching more than 10 symbols is the callers job; the method sends one MCP call. `Historicals` accepts caller interval/bounds/adjustment (no hidden `minute`/`regular` defaults). Batching more than 10 symbols is the callers job; the method sends one MCP call.
### `options` ### `options`
@@ -296,7 +296,7 @@ Crypto account numbers use `rhs_account_number` on the wire, named `RHSAccountNu
| `add_option_to_watchlist` | `AddOption` | | `add_option_to_watchlist` | `AddOption` |
| `remove_option_from_watchlist` | `RemoveOption` | | `remove_option_from_watchlist` | `RemoveOption` |
No title-lookup helper in v1. Tradeys `Watchlist(title)` (match title, then `get_watchlist_items`, keep equity/ETF) stays in tradey until rewire. No title-lookup helper in v1. Callers that want match-by-title then `get_watchlist_items` (optionally filtering equity/ETF) implement that in the app.
### `market` ### `market`
@@ -346,9 +346,9 @@ Pagination: request structs take `Cursor string`; results expose `NextCursor` /
CI must not call the live broker or open a browser. Tests otherwise go as far as they can: they hit an in-process Robinhood MCP mock over HTTP, not only an injected `Caller`. CI must not call the live broker or open a browser. Tests otherwise go as far as they can: they hit an in-process Robinhood MCP mock over HTTP, not only an injected `Caller`.
`internal/rhntest` is an `httptest.Server` that speaks Robinhoods MCP surface the way tradey talks to it: `internal/rhntest` is an `httptest.Server` that speaks Robinhoods MCP surface:
- Required protocol: JSON-RPC `tools/call` (tradeys RPC fallback). Optional extra: streamable-HTTP `CallTool` if the SDK client can talk to the same httptest without extra machinery. - Required protocol: JSON-RPC `tools/call` (RPC fallback). Optional extra: streamable-HTTP `CallTool` if the SDK client can talk to the same httptest without extra machinery.
- Requires `Authorization: Bearer` when the test sets a token. - Requires `Authorization: Bearer` when the test sets a token.
- Dispatches on tool name and returns checked-in envelope fixtures (`testdata/*.json`) shaped like live Robinhood (`data` wrappers, string-or-number amounts, quote/close objects). - Dispatches on tool name and returns checked-in envelope fixtures (`testdata/*.json`) shaped like live Robinhood (`data` wrappers, string-or-number amounts, quote/close objects).
- Can return HTTP 4xx, JSON-RPC errors, and malformed bodies so `ToolError` and parse-error paths are exercised. - Can return HTTP 4xx, JSON-RPC errors, and malformed bodies so `ToolError` and parse-error paths are exercised.
@@ -358,7 +358,7 @@ Required tests:
- **Transport:** `Connect` against `rhntest` (session if the mock can serve it, otherwise RPC fallback), bearer header, HTTP error → `ToolError`, identity `Name`/`Version` sent on the MCP initialize/OAuth client metadata. - **Transport:** `Connect` against `rhntest` (session if the mock can serve it, otherwise RPC fallback), bearer header, HTTP error → `ToolError`, identity `Name`/`Version` sent on the MCP initialize/OAuth client metadata.
- **Every typed method:** (1) stub `Caller` asserting MCP tool name + JSON args including `decimal.String()`; (2) round-trip through `rhntest` with a realistic success fixture. - **Every typed method:** (1) stub `Caller` asserting MCP tool name + JSON args including `decimal.String()`; (2) round-trip through `rhntest` with a realistic success fixture.
- **Wire/decimal:** omitted/null/`""`/`0` vs unparseable (`"n/a"`, object) — the latter errors, the former does not. - **Wire/decimal:** omitted/null/`""`/`0` vs unparseable (`"n/a"`, object) — the latter errors, the former does not.
- Parser fixtures from tradeys known envelopes: `accounts` with `data` wrapper, quotes as `{quotes:[{quote:{…}, close:{…}}]}`, historicals `{historicals:[{symbol, data_points}]}`, watchlists `title` vs `name`. - Parser fixtures from known Robinhood envelopes: `accounts` with `data` wrapper, quotes as `{quotes:[{quote:{…}, close:{…}}]}`, historicals `{historicals:[{symbol, data_points}]}`, watchlists `title` vs `name`.
- `WriteTokens` mode `0600`. - `WriteTokens` mode `0600`.
- `Login` from `ROBINHOOD_ACCESS_TOKEN` (no OAuth, no browser). - `Login` from `ROBINHOOD_ACCESS_TOKEN` (no OAuth, no browser).
- `ToolError` is `errors.As`-able. - `ToolError` is `errors.As`-able.
@@ -383,13 +383,13 @@ No live-MCP or browser-OAuth tests, including `//go:build live` smokes. Not defe
- Typed methods work with an injected `Caller` **and** round-trip through `rhntest`. - Typed methods work with an injected `Caller` **and** round-trip through `rhntest`.
- `Connect`/`Login` accept `Config.Name` / `Config.Version`; empty uses library defaults. - `Connect`/`Login` accept `Config.Name` / `Config.Version`; empty uses library defaults.
- `Login` with `ROBINHOOD_ACCESS_TOKEN` writes `0600` tokens. - `Login` with `ROBINHOOD_ACCESS_TOKEN` writes `0600` tokens.
- `equity.PlaceOrder` sends `quantity`/`limit_price` as decimal strings, `time_in_force` `gfd`, `type` `limit` — matching tradeys `placeArgs` for the same inputs. - `equity.PlaceOrder` sends `quantity`/`limit_price` as decimal strings, `time_in_force` `gfd`, `type` `limit`.
- Every name in the frozen `tools/list` fixture has a method. - Every name in the frozen `tools/list` fixture has a method.
- tradey still builds; this repo does not import tradey and tradey is not changed. - This module stands alone; it does not import or modify downstream apps.
## Out of scope until tradey rewire ## Out of scope (v1)
- `Watchlist(title string) ([]string, error)` helper. - `Watchlist(title string) ([]string, error)` helper.
- Composite `Snapshot`. - Composite `Snapshot`.
- Paper `Executor` constructor split. - Paper `Executor` constructor split.
- `replace` directive / `go.mod` change in tradey. - Downstream `go.mod` / `replace` rewires.
+3 -3
View File
@@ -43,7 +43,7 @@ func TestConnect_rpcFallback(t *testing.T) {
if err := auth.WriteTokens(path, "tok", ""); err != nil { if err := auth.WriteTokens(path, "tok", ""); err != nil {
t.Fatal(err) t.Fatal(err)
} }
api, err := rh.Connect(t.Context(), rh.Config{URL: s.URL, TokenFile: path, Name: "tradey"}) api, err := rh.Connect(t.Context(), rh.Config{URL: s.URL, TokenFile: path, Name: "example-app"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -67,7 +67,7 @@ func TestConnect_canceledContext(t *testing.T) {
} }
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
cancel() cancel()
api, err := rh.Connect(ctx, rh.Config{URL: "http://127.0.0.1:1", TokenFile: path, Name: "tradey"}) api, err := rh.Connect(ctx, rh.Config{URL: "http://127.0.0.1:1", TokenFile: path, Name: "example-app"})
if api != nil { if api != nil {
t.Fatal("expected nil API") t.Fatal("expected nil API")
} }
@@ -84,7 +84,7 @@ func TestConnect_deadlineExceeded(t *testing.T) {
} }
ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second))
defer cancel() defer cancel()
api, err := rh.Connect(ctx, rh.Config{URL: "http://127.0.0.1:1", TokenFile: path, Name: "tradey"}) api, err := rh.Connect(ctx, rh.Config{URL: "http://127.0.0.1:1", TokenFile: path, Name: "example-app"})
if api != nil { if api != nil {
t.Fatal("expected nil API") t.Fatal("expected nil API")
} }
+8 -8
View File
@@ -60,14 +60,14 @@ func TestWatchlists_toolNames(t *testing.T) {
name: "Create", name: "Create",
call: func(c *watchlists.Client) error { call: func(c *watchlists.Client) error {
return c.Create(context.Background(), watchlists.CreateRequest{ return c.Create(context.Background(), watchlists.CreateRequest{
DisplayName: "TRADEY", DisplayName: "Tech",
IconEmoji: "📈", IconEmoji: "📈",
DisplayDescription: "vwap book", DisplayDescription: "vwap book",
}) })
}, },
wantName: "create_watchlist", wantName: "create_watchlist",
wantArgs: map[string]any{ wantArgs: map[string]any{
"display_name": "TRADEY", "display_name": "Tech",
"icon_emoji": "📈", "icon_emoji": "📈",
"display_description": "vwap book", "display_description": "vwap book",
}, },
@@ -77,7 +77,7 @@ func TestWatchlists_toolNames(t *testing.T) {
call: func(c *watchlists.Client) error { call: func(c *watchlists.Client) error {
return c.Update(context.Background(), watchlists.UpdateRequest{ return c.Update(context.Background(), watchlists.UpdateRequest{
ListID: "wl-1", ListID: "wl-1",
DisplayName: "TRADEY", DisplayName: "Tech",
IconEmoji: "📈", IconEmoji: "📈",
DisplayDescription: "vwap book", DisplayDescription: "vwap book",
}) })
@@ -85,7 +85,7 @@ func TestWatchlists_toolNames(t *testing.T) {
wantName: "update_watchlist", wantName: "update_watchlist",
wantArgs: map[string]any{ wantArgs: map[string]any{
"list_id": "wl-1", "list_id": "wl-1",
"display_name": "TRADEY", "display_name": "Tech",
"icon_emoji": "📈", "icon_emoji": "📈",
"display_description": "vwap book", "display_description": "vwap book",
}, },
@@ -219,13 +219,13 @@ func TestTools(t *testing.T) {
func TestLists_rhntest(t *testing.T) { func TestLists_rhntest(t *testing.T) {
t.Parallel() t.Parallel()
s := rhntest.New(t) s := rhntest.New(t)
s.Set("get_watchlists", json.RawMessage(`{"watchlists":[{"id":"wl-1","title":"TRADEY"}]}`)) s.Set("get_watchlists", json.RawMessage(`{"watchlists":[{"id":"wl-1","title":"Tech"}]}`))
c := watchlists.New(&client.Client{URL: s.URL}) c := watchlists.New(&client.Client{URL: s.URL})
got, err := c.Lists(context.Background(), watchlists.ListsRequest{}) got, err := c.Lists(context.Background(), watchlists.ListsRequest{})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(got.Watchlists) != 1 || got.Watchlists[0].ID != "wl-1" || got.Watchlists[0].Title != "TRADEY" { if len(got.Watchlists) != 1 || got.Watchlists[0].ID != "wl-1" || got.Watchlists[0].Title != "Tech" {
t.Fatalf("%+v", got) t.Fatalf("%+v", got)
} }
if s.LastName() != "get_watchlists" { if s.LastName() != "get_watchlists" {
@@ -260,7 +260,7 @@ func TestItems_rhntest(t *testing.T) {
func TestLists_titleFallsBackToName(t *testing.T) { func TestLists_titleFallsBackToName(t *testing.T) {
t.Parallel() t.Parallel()
c := watchlists.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { c := watchlists.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
return json.RawMessage(`{"watchlists":[{"id":"wl-1","title":"tradey"},{"id":"wl-2","name":"Other"}]}`), nil return json.RawMessage(`{"watchlists":[{"id":"wl-1","title":"tech"},{"id":"wl-2","name":"Other"}]}`), nil
})) }))
got, err := c.Lists(context.Background(), watchlists.ListsRequest{}) got, err := c.Lists(context.Background(), watchlists.ListsRequest{})
if err != nil { if err != nil {
@@ -268,7 +268,7 @@ func TestLists_titleFallsBackToName(t *testing.T) {
} }
want := watchlists.ListsResult{ want := watchlists.ListsResult{
Watchlists: []watchlists.Watchlist{ Watchlists: []watchlists.Watchlist{
{ID: "wl-1", Title: "tradey"}, {ID: "wl-1", Title: "tech"},
{ID: "wl-2", Title: "Other"}, {ID: "wl-2", Title: "Other"},
}, },
} }