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:
@@ -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
@@ -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
@@ -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 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)`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-3
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user