feat: add rh Connect facade and tool coverage
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+72
@@ -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"
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user