diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ce0844 --- /dev/null +++ b/README.md @@ -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. diff --git a/connect.go b/connect.go new file mode 100644 index 0000000..ed0f8f6 --- /dev/null +++ b/connect.go @@ -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 +} diff --git a/rh.go b/rh.go new file mode 100644 index 0000000..e81a3d6 --- /dev/null +++ b/rh.go @@ -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) +} diff --git a/rh_test.go b/rh_test.go new file mode 100644 index 0000000..1264307 --- /dev/null +++ b/rh_test.go @@ -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) + } +} diff --git a/testdata/tools.json b/testdata/tools.json new file mode 100644 index 0000000..9e69d4c --- /dev/null +++ b/testdata/tools.json @@ -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" +] diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..1acb2f3 --- /dev/null +++ b/tools.go @@ -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 +}