feat: add crypto MCP methods
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user