feat: add rh Connect facade and tool coverage

This commit is contained in:
2026-09-01 13:40:40 -05:00
parent 4318b6aefe
commit eb3077ae2a
6 changed files with 358 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
# robinhood-agentic-mcp
Go library for the [Robinhood Agentic MCP](https://agent.robinhood.com/mcp/trading).
**Module:** `s1d3sw1ped/robinhood-agentic-mcp`
**Go:** 1.25
**Default MCP URL:** `https://agent.robinhood.com/mcp/trading` (`rh.DefaultURL`)
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.
## 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`.
```go
cfg := rh.Config{
TokenFile: "tokens.json",
Name: "my-bot", // MCP identity and OAuth ClientName
Version: "0.1.0",
}
```
Daemon/headless callers must not call `Login` (no browser). They call `Connect` with an existing token file (mode `0600`).
## Example
```go
package main
import (
"context"
"log"
decimal "github.com/alpacahq/alpacadecimal"
rh "s1d3sw1ped/robinhood-agentic-mcp"
"s1d3sw1ped/robinhood-agentic-mcp/equity"
)
func main() {
ctx := context.Background()
cfg := rh.Config{
TokenFile: "tokens.json",
Name: "my-bot",
}
if _, err := rh.Login(ctx, cfg); err != nil {
log.Fatal(err)
}
api, err := rh.Connect(ctx, cfg)
if err != nil {
log.Fatal(err)
}
qty := decimal.RequireFromString("1")
limit := decimal.RequireFromString("100")
_, err = api.Equity.PlaceOrder(ctx, equity.PlaceOrderRequest{
Symbol: "MU",
Side: rh.Buy,
Type: rh.Limit,
Qty: &qty,
LimitPrice: &limit,
TimeInForce: rh.GFD,
})
if err != nil {
log.Fatal(err)
}
}
```
`Login` writes tokens when `ROBINHOOD_ACCESS_TOKEN` is set, otherwise it runs a browser OAuth dance. `Connect` opens a streamable MCP session as `cfg.Name`/`cfg.Version`, or falls back to JSON-RPC `tools/call` with a bearer token.
Subpackages (`accounts`, `equity`, `options`, `crypto`, `watchlists`, `market`, `scanner`) also export `New(c client.Caller) *Client` for use without the facade. `api.Client.Call` is the escape hatch for any MCP tool by name.
+54
View File
@@ -0,0 +1,54 @@
package rh
import (
"context"
"fmt"
"s1d3sw1ped/robinhood-agentic-mcp/accounts"
"s1d3sw1ped/robinhood-agentic-mcp/auth"
"s1d3sw1ped/robinhood-agentic-mcp/client"
"s1d3sw1ped/robinhood-agentic-mcp/crypto"
"s1d3sw1ped/robinhood-agentic-mcp/equity"
"s1d3sw1ped/robinhood-agentic-mcp/market"
"s1d3sw1ped/robinhood-agentic-mcp/options"
"s1d3sw1ped/robinhood-agentic-mcp/scanner"
"s1d3sw1ped/robinhood-agentic-mcp/watchlists"
)
// Connect reads tokens and returns a wired API. Session connect is preferred;
// JSON-RPC tools/call is used when the session cannot be opened. Missing tokens fail closed.
func Connect(ctx context.Context, cfg Config) (*API, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
cfg = cfg.WithDefaults()
if cfg.TokenFile == "" {
return nil, fmt.Errorf("connect: missing TokenFile")
}
tok, err := auth.ReadTokens(cfg.TokenFile)
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
if tok.AccessToken == "" {
return nil, fmt.Errorf("connect: missing access token")
}
c, err := client.ConnectSession(ctx, cfg.URL, auth.ClientToken(tok), cfg.Name, cfg.Version)
if err != nil {
c = &client.Client{
URL: cfg.URL,
Token: tok.AccessToken,
Name: cfg.Name,
Version: cfg.Version,
}
}
return &API{
Client: c,
Accounts: accounts.New(c),
Equity: equity.New(c),
Options: options.New(c),
Crypto: crypto.New(c),
Watchlists: watchlists.New(c),
Market: market.New(c),
Scanner: scanner.New(c),
}, nil
}
+80
View File
@@ -0,0 +1,80 @@
package rh
import (
"context"
"s1d3sw1ped/robinhood-agentic-mcp/accounts"
"s1d3sw1ped/robinhood-agentic-mcp/auth"
"s1d3sw1ped/robinhood-agentic-mcp/client"
"s1d3sw1ped/robinhood-agentic-mcp/crypto"
"s1d3sw1ped/robinhood-agentic-mcp/equity"
"s1d3sw1ped/robinhood-agentic-mcp/market"
"s1d3sw1ped/robinhood-agentic-mcp/options"
"s1d3sw1ped/robinhood-agentic-mcp/scanner"
"s1d3sw1ped/robinhood-agentic-mcp/watchlists"
)
const DefaultURL = auth.DefaultURL
// Config is Login/Connect identity and the token file.
type Config = auth.Config
// Side is an order side. Values are Robinhood wire strings.
type Side = client.Side
const (
Buy Side = client.Buy
Sell Side = client.Sell
)
// OrderType is an order type. Values are Robinhood wire strings.
type OrderType = client.OrderType
const (
Market OrderType = client.Market
Limit OrderType = client.Limit
Stop OrderType = client.Stop // equity + options; crypto uses StopLoss
StopLimit OrderType = client.StopLimit
StopLoss OrderType = client.StopLoss // crypto only
)
// TimeInForce is an order duration. Values are Robinhood wire strings.
type TimeInForce = client.TimeInForce
const (
GFD TimeInForce = client.GFD
GTC TimeInForce = client.GTC
GFW TimeInForce = client.GFW // crypto
GFM TimeInForce = client.GFM // crypto
)
// MarketHours is a trading-session window. Values are Robinhood wire strings.
type MarketHours = client.MarketHours
const (
RegularHours MarketHours = client.RegularHours
ExtendedHours MarketHours = client.ExtendedHours
AllDayHours MarketHours = client.AllDayHours
RegularCurbHours MarketHours = client.RegularCurbHours
RegularCurbOvernightHours MarketHours = client.RegularCurbOvernightHours
)
// ToolError is a failed MCP tool call or a parse of its result.
type ToolError = client.ToolError
// API is a connected Robinhood Agentic MCP client with every asset package wired.
type API struct {
Client *client.Client
Accounts *accounts.Client
Equity *equity.Client
Options *options.Client
Crypto *crypto.Client
Watchlists *watchlists.Client
Market *market.Client
Scanner *scanner.Client
}
// Login authenticates to Robinhood Agentic MCP and writes tokens.
func Login(ctx context.Context, cfg Config) (string, error) {
return auth.Login(ctx, cfg)
}
+51
View File
@@ -0,0 +1,51 @@
package rh_test
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"testing"
"github.com/google/go-cmp/cmp"
"s1d3sw1ped/robinhood-agentic-mcp"
"s1d3sw1ped/robinhood-agentic-mcp/auth"
"s1d3sw1ped/robinhood-agentic-mcp/equity"
"s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest"
)
func TestRegisteredTools_matchesFixture(t *testing.T) {
t.Parallel()
raw, err := os.ReadFile("testdata/tools.json")
if err != nil {
t.Fatal(err)
}
var want []string
if err := json.Unmarshal(raw, &want); err != nil {
t.Fatal(err)
}
got := rh.RegisteredTools()
sort.Strings(want)
sort.Strings(got)
if diff := cmp.Diff(want, got); diff != "" {
t.Fatal(diff)
}
}
func TestConnect_rpcFallback(t *testing.T) {
s := rhntest.New(t)
s.Token = "tok"
s.Set("get_equity_quotes", json.RawMessage(`{"quotes":[]}`))
path := filepath.Join(t.TempDir(), "tokens.json")
if err := auth.WriteTokens(path, "tok", ""); err != nil {
t.Fatal(err)
}
api, err := rh.Connect(t.Context(), rh.Config{URL: s.URL, TokenFile: path, Name: "tradey"})
if err != nil {
t.Fatal(err)
}
_, err = api.Equity.Quotes(t.Context(), equity.QuotesRequest{Symbols: []string{"MU"}})
if err != nil {
t.Fatal(err)
}
}
+72
View File
@@ -0,0 +1,72 @@
[
"get_accounts",
"get_portfolio",
"get_realized_pnl",
"get_pnl_trade_history",
"get_limited_margin_upgrade_info",
"get_option_level_upgrade_info",
"get_crypto_account_onboarding_info",
"search",
"get_equity_positions",
"get_equity_tax_lots",
"get_equity_quotes",
"get_equity_orders",
"get_equity_tradability",
"get_equity_historicals",
"get_equity_fundamentals",
"get_equity_price_book",
"get_equity_technical_indicators",
"get_equity_news",
"review_equity_order",
"place_equity_order",
"cancel_equity_order",
"get_option_chains",
"get_option_instruments",
"get_option_quotes",
"get_option_positions",
"get_option_orders",
"get_option_historicals",
"review_option_order",
"place_option_order",
"cancel_option_order",
"replace_option_order",
"exercise_option",
"cancel_option_exercise",
"get_currency_pairs",
"get_crypto_quotes",
"get_crypto_positions",
"get_crypto_orders",
"preview_crypto_order",
"place_crypto_order",
"cancel_crypto_order",
"get_watchlists",
"get_watchlist_items",
"get_option_watchlist",
"get_popular_watchlists",
"create_watchlist",
"update_watchlist",
"follow_watchlist",
"unfollow_watchlist",
"add_to_watchlist",
"remove_from_watchlist",
"add_option_to_watchlist",
"remove_option_from_watchlist",
"get_indexes",
"get_index_quotes",
"get_index_historicals",
"get_financials",
"get_earnings_results",
"get_earnings_calendar",
"get_sec_filing_index",
"get_sec_filing",
"get_sec_filing_facts",
"get_sec_filing_facts_catalog",
"get_scanner_filter_specs",
"get_scanner_datapoints",
"get_scans",
"create_scan",
"preview_scan",
"run_scan",
"update_scan_filters",
"update_scan_config"
]
+24
View File
@@ -0,0 +1,24 @@
package rh
import (
"s1d3sw1ped/robinhood-agentic-mcp/accounts"
"s1d3sw1ped/robinhood-agentic-mcp/crypto"
"s1d3sw1ped/robinhood-agentic-mcp/equity"
"s1d3sw1ped/robinhood-agentic-mcp/market"
"s1d3sw1ped/robinhood-agentic-mcp/options"
"s1d3sw1ped/robinhood-agentic-mcp/scanner"
"s1d3sw1ped/robinhood-agentic-mcp/watchlists"
)
// RegisteredTools concatenates every asset package Tools() list.
func RegisteredTools() []string {
var names []string
names = append(names, accounts.Tools()...)
names = append(names, equity.Tools()...)
names = append(names, options.Tools()...)
names = append(names, crypto.Tools()...)
names = append(names, watchlists.Tools()...)
names = append(names, market.Tools()...)
names = append(names, scanner.Tools()...)
return names
}