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