docs: Remove sibling product and lab path leaks #5

Merged
Ghost merged 2 commits from scrub/internal-refs into develop 2026-09-01 14:44:07 -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.
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
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
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_REFRESH_TOKEN", "ref")
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 {
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 {
t.Fatalf("%+v", c)
}
c = auth.Config{Name: "tradey", Version: "1.2.3", URL: "http://x"}.WithDefaults()
if c.Name != "tradey" || c.Version != "1.2.3" || c.URL != "http://x" {
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)
}
}
+2 -2
View File
@@ -3,8 +3,8 @@ package auth
import "testing"
func TestOAuthIdentity(t *testing.T) {
gotN, gotV := oauthIdentity(Config{Name: "tradey", Version: "0.9"})
if gotN != "tradey" || gotV != "0.9" {
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{})
@@ -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.
**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.
@@ -10,7 +10,7 @@
## 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`.
- 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.
@@ -165,7 +165,7 @@ func TestEncode(t *testing.T) {
- [ ] **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`).
@@ -202,7 +202,7 @@ 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**
@@ -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)`
- If `Hook != nil`, return `Hook.Call`
- 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
- [ ] **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**
@@ -541,7 +541,7 @@ 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: "tradey", Version: "9"})
id, err := auth.Login(t.Context(), auth.Config{TokenFile: path, Name: "example-app", Version: "9"})
if err != nil {
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 {
t.Fatalf("%+v", c)
}
c = auth.Config{Name: "tradey", Version: "1.2.3", URL: "http://x"}.WithDefaults()
if c.Name != "tradey" || c.Version != "1.2.3" || c.URL != "http://x" {
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)
}
}
```
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**
@@ -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)
**Interfaces:**
- Consumes: tradey `/fast/projects/golang/tradey/internal/broker/oauth.go` and `connectSession` in `mcp.go`/`oauth.go`
- 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)
- 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: "tradey", Version: "0.9"})
if gotN != "tradey" || gotV != "0.9" {
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{})
@@ -624,15 +624,15 @@ func TestOAuthIdentity(t *testing.T) {
`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`
- `ClientName: "tradey"``cfg.Name`
- `"Tradey is signed in..."``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"`
- `"robinhood session expired; run tradey login"``"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.
- `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`.
@@ -644,7 +644,7 @@ Run: `go test ./auth/ -count=1 -run TestOAuthIdentity`
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**
@@ -664,7 +664,7 @@ 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` (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:**
- 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` |
| 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}]}]}`.
@@ -780,7 +780,7 @@ git commit -m "feat: add accounts MCP methods"
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**
@@ -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
```
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**
@@ -1000,9 +1000,9 @@ type Leg struct {
| AddOption | `add_option_to_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
@@ -1086,7 +1086,7 @@ Filter/column request structs match the MCP properties (`filter_type`, `predicat
**Files:**
- Create: `rh.go`, `connect.go`, `tools.go`, `rh_test.go`, `testdata/tools.json`, `README.md`
- Modify: none of tradey
- Modify: this repository only
**Interfaces:**
```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.
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**
@@ -1151,7 +1151,7 @@ func TestConnect_rpcFallback(t *testing.T) {
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: "tradey"})
api, err := rh.Connect(t.Context(), rh.Config{URL: s.URL, TokenFile: path, Name: "example-app"})
if err != nil {
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 |
| tools.json coverage | 15 |
| No live MCP / no browser tests | 56, 15 |
| tradey untouched | all |
| 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)`.
@@ -2,24 +2,24 @@
**Date:** 2026-09-01
**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.
## 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).
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)
- A CLI binary (`login` stays a library function; tradey keeps `tradey login`).
- Rewiring tradey to import this module.
- Tradeys `Reader` / `Executor` / `Snapshot` / `Fake` desk types (paper/live policy stays in the app).
- A CLI binary (`login` stays a library function).
- Rewiring downstream apps to import this module.
- App-level `Reader` / `Executor` / `Snapshot` / `Fake` desk types (paper/live policy stays in the app).
- Codegen from `tools/list`.
- Proxying or re-hosting the MCP as a server.
- 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. |
| `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.
@@ -56,7 +56,7 @@ Daemon/headless callers must not call `Login` (no browser). They call `Connect`
Module path: `s1d3sw1ped/robinhood-agentic-mcp`
Go version: `1.25`
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.
@@ -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.
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
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`).
- 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`.
- 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.
- 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.
@@ -138,7 +138,7 @@ Public money, size, and price fields use `github.com/alpacahq/alpacadecimal` imp
- Optional amounts: `*decimal.Decimal` (Alpaca `PlaceOrderRequest.Qty` / `LimitPrice`).
- 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:
@@ -226,7 +226,7 @@ Source of truth for coverage is a frozen `testdata/tools.json` captured from liv
| `get_crypto_account_onboarding_info` | `CryptoOnboardingInfo` |
| `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`
@@ -246,7 +246,7 @@ This library does **not** enforce tradeys `supported()` account policy (IRA/U
| `place_equity_order` | `PlaceOrder` |
| `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`
@@ -296,7 +296,7 @@ Crypto account numbers use `rhs_account_number` on the wire, named `RHSAccountNu
| `add_option_to_watchlist` | `AddOption` |
| `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`
@@ -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`.
`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.
- 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.
@@ -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.
- **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.
- 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`.
- `Login` from `ROBINHOOD_ACCESS_TOKEN` (no OAuth, no browser).
- `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`.
- `Connect`/`Login` accept `Config.Name` / `Config.Version`; empty uses library defaults.
- `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.
- 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.
- Composite `Snapshot`.
- 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 {
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 {
t.Fatal(err)
}
@@ -67,7 +67,7 @@ func TestConnect_canceledContext(t *testing.T) {
}
ctx, cancel := context.WithCancel(t.Context())
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 {
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))
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 {
t.Fatal("expected nil API")
}
+8 -8
View File
@@ -60,14 +60,14 @@ func TestWatchlists_toolNames(t *testing.T) {
name: "Create",
call: func(c *watchlists.Client) error {
return c.Create(context.Background(), watchlists.CreateRequest{
DisplayName: "TRADEY",
DisplayName: "Tech",
IconEmoji: "📈",
DisplayDescription: "vwap book",
})
},
wantName: "create_watchlist",
wantArgs: map[string]any{
"display_name": "TRADEY",
"display_name": "Tech",
"icon_emoji": "📈",
"display_description": "vwap book",
},
@@ -77,7 +77,7 @@ func TestWatchlists_toolNames(t *testing.T) {
call: func(c *watchlists.Client) error {
return c.Update(context.Background(), watchlists.UpdateRequest{
ListID: "wl-1",
DisplayName: "TRADEY",
DisplayName: "Tech",
IconEmoji: "📈",
DisplayDescription: "vwap book",
})
@@ -85,7 +85,7 @@ func TestWatchlists_toolNames(t *testing.T) {
wantName: "update_watchlist",
wantArgs: map[string]any{
"list_id": "wl-1",
"display_name": "TRADEY",
"display_name": "Tech",
"icon_emoji": "📈",
"display_description": "vwap book",
},
@@ -219,13 +219,13 @@ func TestTools(t *testing.T) {
func TestLists_rhntest(t *testing.T) {
t.Parallel()
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})
got, err := c.Lists(context.Background(), watchlists.ListsRequest{})
if err != nil {
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)
}
if s.LastName() != "get_watchlists" {
@@ -260,7 +260,7 @@ func TestItems_rhntest(t *testing.T) {
func TestLists_titleFallsBackToName(t *testing.T) {
t.Parallel()
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{})
if err != nil {
@@ -268,7 +268,7 @@ func TestLists_titleFallsBackToName(t *testing.T) {
}
want := watchlists.ListsResult{
Watchlists: []watchlists.Watchlist{
{ID: "wl-1", Title: "tradey"},
{ID: "wl-1", Title: "tech"},
{ID: "wl-2", Title: "Other"},
},
}