From 1af5d7145b31ece735ed2dbe700cbbb1f2b48736 Mon Sep 17 00:00:00 2001 From: Justin Harms Date: Tue, 1 Sep 2026 13:05:29 -0500 Subject: [PATCH] feat: add crypto MCP methods --- crypto/client.go | 26 ++++ crypto/crypto.go | 257 ++++++++++++++++++++++++++++++++++++++ crypto/crypto_test.go | 282 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 565 insertions(+) create mode 100644 crypto/client.go create mode 100644 crypto/crypto.go create mode 100644 crypto/crypto_test.go diff --git a/crypto/client.go b/crypto/client.go new file mode 100644 index 0000000..876cbb1 --- /dev/null +++ b/crypto/client.go @@ -0,0 +1,26 @@ +package crypto + +import "s1d3sw1ped/robinhood-agentic-mcp/client" + +// Client wraps Robinhood crypto MCP tools. +type Client struct { + c client.Caller +} + +// New returns a crypto client that invokes tools through c. +func New(c client.Caller) *Client { + return &Client{c: c} +} + +// Tools returns the MCP names this package implements. +func Tools() []string { + return []string{ + toolPairs, + toolQuotes, + toolPositions, + toolOrders, + toolPreview, + toolPlace, + toolCancel, + } +} diff --git a/crypto/crypto.go b/crypto/crypto.go new file mode 100644 index 0000000..69c2eaa --- /dev/null +++ b/crypto/crypto.go @@ -0,0 +1,257 @@ +package crypto + +import ( + "context" + "encoding/json" + + decimal "github.com/alpacahq/alpacadecimal" + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +const ( + toolPairs = "get_currency_pairs" + toolQuotes = "get_crypto_quotes" + toolPositions = "get_crypto_positions" + toolOrders = "get_crypto_orders" + toolPreview = "preview_crypto_order" + toolPlace = "place_crypto_order" + toolCancel = "cancel_crypto_order" +) + +// PairsRequest is the argument set for get_currency_pairs. +type PairsRequest struct { + Cursor string + Limit int +} + +// PairsResult is the parsed get_currency_pairs payload. +type PairsResult struct{} + +// QuotesRequest is the argument set for get_crypto_quotes. +type QuotesRequest struct { + Symbols []string + Timezone string + RHSAccountNumber string +} + +// QuotesResult is the parsed get_crypto_quotes payload. +type QuotesResult struct{} + +// PositionsRequest is the argument set for get_crypto_positions. +type PositionsRequest struct { + RHSAccountNumber string + Cursor string +} + +// PositionsResult is the parsed get_crypto_positions payload. +type PositionsResult struct{} + +// OrdersRequest is the argument set for get_crypto_orders. +type OrdersRequest struct { + RHSAccountNumber string + OrderID string + State string + StateGroup string + Side client.Side + Symbol string + CreatedAtGTE string + UpdatedAtGTE string + Cursor string +} + +// OrdersResult is the parsed get_crypto_orders payload. +type OrdersResult struct{} + +// PlaceOrderRequest is the argument set for preview_crypto_order and place_crypto_order. +type PlaceOrderRequest struct { + RHSAccountNumber string + Symbol string + Side client.Side + Type client.OrderType + Qty *decimal.Decimal + DollarAmount *decimal.Decimal + LimitPrice *decimal.Decimal + StopPrice *decimal.Decimal + TimeInForce client.TimeInForce // empty → omit (Robinhood defaults gtc/gfd by type) + RefID string +} + +// PreviewResult is the pre-trade check from preview_crypto_order. +type PreviewResult struct{} + +// Order is a placed crypto order. +type Order struct { + ID string `json:"id"` +} + +// CancelOrderRequest is the argument set for cancel_crypto_order. +type CancelOrderRequest struct { + RHSAccountNumber string + OrderID string +} + +// Pairs calls get_currency_pairs. +func (c *Client) Pairs(ctx context.Context, req PairsRequest) (PairsResult, error) { + args := map[string]any{} + if req.Cursor != "" { + args["cursor"] = req.Cursor + } + if req.Limit != 0 { + args["limit"] = req.Limit + } + var out PairsResult + if err := c.parse(ctx, toolPairs, args, &out); err != nil { + return PairsResult{}, err + } + return out, nil +} + +// Quotes calls get_crypto_quotes. +func (c *Client) Quotes(ctx context.Context, req QuotesRequest) (QuotesResult, error) { + args := map[string]any{} + if len(req.Symbols) > 0 { + args["symbols"] = req.Symbols + } + if req.Timezone != "" { + args["timezone"] = req.Timezone + } + if req.RHSAccountNumber != "" { + args["rhs_account_number"] = req.RHSAccountNumber + } + var out QuotesResult + if err := c.parse(ctx, toolQuotes, args, &out); err != nil { + return QuotesResult{}, err + } + return out, nil +} + +// Positions calls get_crypto_positions. +func (c *Client) Positions(ctx context.Context, req PositionsRequest) (PositionsResult, error) { + args := map[string]any{} + if req.RHSAccountNumber != "" { + args["rhs_account_number"] = req.RHSAccountNumber + } + if req.Cursor != "" { + args["cursor"] = req.Cursor + } + var out PositionsResult + if err := c.parse(ctx, toolPositions, args, &out); err != nil { + return PositionsResult{}, err + } + return out, nil +} + +// Orders calls get_crypto_orders. +func (c *Client) Orders(ctx context.Context, req OrdersRequest) (OrdersResult, error) { + args := map[string]any{} + if req.RHSAccountNumber != "" { + args["rhs_account_number"] = req.RHSAccountNumber + } + if req.OrderID != "" { + args["order_id"] = req.OrderID + } + if req.State != "" { + args["state"] = req.State + } + if req.StateGroup != "" { + args["state_group"] = req.StateGroup + } + if req.Side != "" { + args["side"] = string(req.Side) + } + if req.Symbol != "" { + args["symbol"] = req.Symbol + } + if req.CreatedAtGTE != "" { + args["created_at_gte"] = req.CreatedAtGTE + } + if req.UpdatedAtGTE != "" { + args["updated_at_gte"] = req.UpdatedAtGTE + } + if req.Cursor != "" { + args["cursor"] = req.Cursor + } + var out OrdersResult + if err := c.parse(ctx, toolOrders, args, &out); err != nil { + return OrdersResult{}, err + } + return out, nil +} + +// PreviewOrder calls preview_crypto_order. RefID is not sent. +func (c *Client) PreviewOrder(ctx context.Context, req PlaceOrderRequest) (PreviewResult, error) { + var out PreviewResult + if err := c.parse(ctx, toolPreview, placeArgs(req, false), &out); err != nil { + return PreviewResult{}, err + } + return out, nil +} + +// PlaceOrder calls place_crypto_order. RefID is sent as ref_id. +func (c *Client) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (Order, error) { + var out Order + if err := c.parse(ctx, toolPlace, placeArgs(req, true), &out); err != nil { + return Order{}, err + } + return out, nil +} + +// CancelOrder calls cancel_crypto_order. +func (c *Client) CancelOrder(ctx context.Context, req CancelOrderRequest) error { + args := map[string]any{} + if req.RHSAccountNumber != "" { + args["rhs_account_number"] = req.RHSAccountNumber + } + if req.OrderID != "" { + args["order_id"] = req.OrderID + } + _, err := c.c.Call(ctx, toolCancel, args) + return err +} + +func placeArgs(req PlaceOrderRequest, withRef bool) map[string]any { + args := map[string]any{} + if req.RHSAccountNumber != "" { + args["rhs_account_number"] = req.RHSAccountNumber + } + if req.Symbol != "" { + args["symbol"] = req.Symbol + } + if req.Side != "" { + args["side"] = string(req.Side) + } + if req.Type != "" { + args["type"] = string(req.Type) + } + if req.Qty != nil { + args["quantity"] = wire.Encode(*req.Qty) + } + if req.DollarAmount != nil { + args["dollar_amount"] = wire.Encode(*req.DollarAmount) + } + if req.LimitPrice != nil { + args["limit_price"] = wire.Encode(*req.LimitPrice) + } + if req.StopPrice != nil { + args["stop_price"] = wire.Encode(*req.StopPrice) + } + if req.TimeInForce != "" { + args["time_in_force"] = string(req.TimeInForce) + } + if withRef && req.RefID != "" { + args["ref_id"] = req.RefID + } + return args +} + +func (c *Client) parse(ctx context.Context, tool string, args map[string]any, dest any) error { + raw, err := c.c.Call(ctx, tool, args) + if err != nil { + return err + } + if err := json.Unmarshal(wire.Unwrap(raw), dest); err != nil { + return client.ToolErrorf(tool, "parse: %w", err) + } + return nil +} diff --git a/crypto/crypto_test.go b/crypto/crypto_test.go new file mode 100644 index 0000000..da9993d --- /dev/null +++ b/crypto/crypto_test.go @@ -0,0 +1,282 @@ +package crypto_test + +import ( + "context" + "encoding/json" + "sort" + "testing" + + decimal "github.com/alpacahq/alpacadecimal" + "github.com/google/go-cmp/cmp" + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/crypto" + "s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest" +) + +func TestCrypto_toolNames(t *testing.T) { + t.Parallel() + qty := decimal.RequireFromString("0.001") + px := decimal.RequireFromString("3000") + stop := decimal.RequireFromString("50000") + dollars := decimal.RequireFromString("100") + tests := []struct { + name string + call func(*crypto.Client) error + wantName string + wantArgs map[string]any + }{ + { + name: "Pairs", + call: func(c *crypto.Client) error { + _, err := c.Pairs(context.Background(), crypto.PairsRequest{ + Cursor: "c1", + Limit: 25, + }) + return err + }, + wantName: "get_currency_pairs", + wantArgs: map[string]any{"cursor": "c1", "limit": 25}, + }, + { + name: "Quotes", + call: func(c *crypto.Client) error { + _, err := c.Quotes(context.Background(), crypto.QuotesRequest{ + Symbols: []string{"BTC-USD", "ETH-USD"}, + Timezone: "America/New_York", + RHSAccountNumber: "123456789", + }) + return err + }, + wantName: "get_crypto_quotes", + wantArgs: map[string]any{ + "symbols": []string{"BTC-USD", "ETH-USD"}, + "timezone": "America/New_York", + "rhs_account_number": "123456789", + }, + }, + { + name: "Positions", + call: func(c *crypto.Client) error { + _, err := c.Positions(context.Background(), crypto.PositionsRequest{ + RHSAccountNumber: "123456789", + Cursor: "c1", + }) + return err + }, + wantName: "get_crypto_positions", + wantArgs: map[string]any{"rhs_account_number": "123456789", "cursor": "c1"}, + }, + { + name: "Orders", + call: func(c *crypto.Client) error { + _, err := c.Orders(context.Background(), crypto.OrdersRequest{ + RHSAccountNumber: "123456789", + OrderID: "o1", + State: "filled", + StateGroup: "closed", + Side: client.Buy, + Symbol: "BTC-USD", + CreatedAtGTE: "2026-08-18T00:00:00Z", + UpdatedAtGTE: "2026-08-18T13:30:00Z", + Cursor: "c1", + }) + return err + }, + wantName: "get_crypto_orders", + wantArgs: map[string]any{ + "rhs_account_number": "123456789", + "order_id": "o1", + "state": "filled", + "state_group": "closed", + "side": "buy", + "symbol": "BTC-USD", + "created_at_gte": "2026-08-18T00:00:00Z", + "updated_at_gte": "2026-08-18T13:30:00Z", + "cursor": "c1", + }, + }, + { + name: "PreviewOrder", + call: func(c *crypto.Client) error { + _, err := c.PreviewOrder(context.Background(), crypto.PlaceOrderRequest{ + RHSAccountNumber: "123456789", + Symbol: "ETH", + Side: client.Buy, + Type: client.Limit, + DollarAmount: &dollars, + LimitPrice: &px, + TimeInForce: client.GTC, + RefID: "buy:eth", + }) + return err + }, + wantName: "preview_crypto_order", + wantArgs: map[string]any{ + "rhs_account_number": "123456789", + "symbol": "ETH", + "side": "buy", + "type": "limit", + "dollar_amount": "100", + "limit_price": "3000", + "time_in_force": "gtc", + }, + }, + { + name: "PlaceOrder", + call: func(c *crypto.Client) error { + _, err := c.PlaceOrder(context.Background(), crypto.PlaceOrderRequest{ + RHSAccountNumber: "123456789", + Symbol: "BTC-USD", + Side: client.Sell, + Type: client.StopLoss, + Qty: &qty, + StopPrice: &stop, + TimeInForce: client.GFW, + RefID: "sell:btc", + }) + return err + }, + wantName: "place_crypto_order", + wantArgs: map[string]any{ + "rhs_account_number": "123456789", + "symbol": "BTC-USD", + "side": "sell", + "type": "stop_loss", + "quantity": "0.001", + "stop_price": "50000", + "time_in_force": "gfw", + "ref_id": "sell:btc", + }, + }, + { + name: "CancelOrder", + call: func(c *crypto.Client) error { + return c.CancelOrder(context.Background(), crypto.CancelOrderRequest{ + RHSAccountNumber: "123456789", + OrderID: "o1", + }) + }, + wantName: "cancel_crypto_order", + wantArgs: map[string]any{"rhs_account_number": "123456789", "order_id": "o1"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var gotName string + var gotArgs map[string]any + c := crypto.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + gotName, gotArgs = name, args + return json.RawMessage(`{"id":"o1"}`), nil + })) + if err := tc.call(c); err != nil { + t.Fatal(err) + } + if gotName != tc.wantName { + t.Fatalf("%s %+v", gotName, gotArgs) + } + if diff := cmp.Diff(tc.wantArgs, gotArgs); diff != "" { + t.Fatal(diff) + } + if tc.name == "PreviewOrder" { + if _, ok := gotArgs["ref_id"]; ok { + t.Fatalf("ref_id on preview: %+v", gotArgs) + } + } + if tc.name == "PlaceOrder" { + if _, ok := gotArgs["idempotency_key"]; ok { + t.Fatalf("idempotency_key on place: %+v", gotArgs) + } + if gotArgs["rhs_account_number"] != "123456789" { + t.Fatalf("rhs_account_number %v", gotArgs["rhs_account_number"]) + } + if gotArgs["type"] != "stop_loss" { + t.Fatalf("type %v", gotArgs["type"]) + } + } + }) + } +} + +func TestTools(t *testing.T) { + t.Parallel() + want := []string{ + "cancel_crypto_order", + "get_crypto_orders", + "get_crypto_positions", + "get_crypto_quotes", + "get_currency_pairs", + "place_crypto_order", + "preview_crypto_order", + } + got := append([]string(nil), crypto.Tools()...) + sort.Strings(got) + if diff := cmp.Diff(want, got); diff != "" { + t.Fatal(diff) + } +} + +func TestQuotes_rhntest(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.Set("get_crypto_quotes", json.RawMessage(`{}`)) + c := crypto.New(&client.Client{URL: s.URL}) + _, err := c.Quotes(context.Background(), crypto.QuotesRequest{ + Symbols: []string{"BTC-USD", "ETH-USD"}, + RHSAccountNumber: "123456789", + }) + if err != nil { + t.Fatal(err) + } + if s.LastName() != "get_crypto_quotes" { + t.Fatalf("%s", s.LastName()) + } + want := map[string]any{ + "symbols": []any{"BTC-USD", "ETH-USD"}, + "rhs_account_number": "123456789", + } + if diff := cmp.Diff(want, s.LastArgs()); diff != "" { + t.Fatal(diff) + } +} + +func TestPlaceOrder_rhntest(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.Set("place_crypto_order", json.RawMessage(`{"id":"o1"}`)) + c := crypto.New(&client.Client{URL: s.URL}) + qty := decimal.RequireFromString("0.001") + stop := decimal.RequireFromString("50000") + got, err := c.PlaceOrder(context.Background(), crypto.PlaceOrderRequest{ + RHSAccountNumber: "123456789", + Symbol: "BTC-USD", + Side: client.Sell, + Type: client.StopLoss, + Qty: &qty, + StopPrice: &stop, + TimeInForce: client.GFW, + RefID: "sell:btc", + }) + if err != nil { + t.Fatal(err) + } + if got.ID != "o1" { + t.Fatalf("%+v", got) + } + if s.LastName() != "place_crypto_order" { + t.Fatalf("%s", s.LastName()) + } + want := map[string]any{ + "rhs_account_number": "123456789", + "symbol": "BTC-USD", + "side": "sell", + "type": "stop_loss", + "quantity": "0.001", + "stop_price": "50000", + "time_in_force": "gfw", + "ref_id": "sell:btc", + } + if diff := cmp.Diff(want, s.LastArgs()); diff != "" { + t.Fatal(diff) + } +}