From 9e711957c0b2a7da23df10c3d28aa7fa5b8462d0 Mon Sep 17 00:00:00 2001 From: s1d3sw1ped_bot Date: Tue, 1 Sep 2026 19:43:35 +0000 Subject: [PATCH] docs: Remove sibling product and lab path leaks Outsiders reading this module should not see private sibling names (tradey), unfinished rewire notes, or /fast/projects lab paths. Keep the library self-contained in README, design, plan, and test fixtures/identity strings. --- README.md | 4 +- auth/login_test.go | 6 +- auth/oauth_test.go | 4 +- .../plans/2026-09-01-robinhood-agentic-mcp.md | 64 +++++++++---------- ...2026-09-01-robinhood-agentic-mcp-design.md | 48 +++++++------- rh_test.go | 6 +- watchlists/watchlists_test.go | 16 ++--- 7 files changed, 74 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 8ce0844..d306c7e 100644 --- a/README.md +++ b/README.md @@ -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{ diff --git a/auth/login_test.go b/auth/login_test.go index 728e511..7a33cb0 100644 --- a/auth/login_test.go +++ b/auth/login_test.go @@ -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) } } diff --git a/auth/oauth_test.go b/auth/oauth_test.go index c381afe..2fed039 100644 --- a/auth/oauth_test.go +++ b/auth/oauth_test.go @@ -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{}) diff --git a/docs/superpowers/plans/2026-09-01-robinhood-agentic-mcp.md b/docs/superpowers/plans/2026-09-01-robinhood-agentic-mcp.md index 8c96f98..9773f74 100644 --- a/docs/superpowers/plans/2026-09-01-robinhood-agentic-mcp.md +++ b/docs/superpowers/plans/2026-09-01-robinhood-agentic-mcp.md @@ -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 tradey’s 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 tradey’s `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 tradey’s 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 (tradey’s 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, 7–14 | | tools.json coverage | 15 | | No live MCP / no browser tests | 5–6, 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)`. diff --git a/docs/superpowers/specs/2026-09-01-robinhood-agentic-mcp-design.md b/docs/superpowers/specs/2026-09-01-robinhood-agentic-mcp-design.md index 4dbca67..4ace8a0 100644 --- a/docs/superpowers/specs/2026-09-01-robinhood-agentic-mcp-design.md +++ b/docs/superpowers/specs/2026-09-01-robinhood-agentic-mcp-design.md @@ -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 Robinhood’s 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. -- Tradey’s `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 tradey’s 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 library’s 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 library’s 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 tradey’s `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 Robinhood’s string fields (`quantity`, `limit_price`, `stop_price`, `dollar_amount`, option `price`). On the way in, accept JSON string **or** number (tradey’s envelope) and parse into `decimal.Decimal`. +On the way out, encode with `Decimal.String()` onto Robinhood’s 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 tradey’s `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 tradey’s `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 caller’s 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 caller’s 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. Tradey’s `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 Robinhood’s MCP surface the way tradey talks to it: +`internal/rhntest` is an `httptest.Server` that speaks Robinhood’s MCP surface: -- Required protocol: JSON-RPC `tools/call` (tradey’s 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 tradey’s 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 tradey’s `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. diff --git a/rh_test.go b/rh_test.go index 7dc1aaf..cdcabba 100644 --- a/rh_test.go +++ b/rh_test.go @@ -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") } diff --git a/watchlists/watchlists_test.go b/watchlists/watchlists_test.go index a608190..ea4bb96 100644 --- a/watchlists/watchlists_test.go +++ b/watchlists/watchlists_test.go @@ -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"}, }, }