feat: add watchlist MCP methods

This commit is contained in:
2026-09-01 13:14:00 -05:00
parent 1af5d7145b
commit abb83ea21a
3 changed files with 662 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
package watchlists
import "s1d3sw1ped/robinhood-agentic-mcp/client"
// Client wraps Robinhood watchlist MCP tools.
type Client struct {
c client.Caller
}
// New returns a watchlists 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{
toolLists,
toolItems,
toolOptionList,
toolPopular,
toolCreate,
toolUpdate,
toolFollow,
toolUnfollow,
toolAdd,
toolRemove,
toolAddOption,
toolRemoveOption,
}
}
+324
View File
@@ -0,0 +1,324 @@
package watchlists
import (
"context"
"encoding/json"
"s1d3sw1ped/robinhood-agentic-mcp/client"
"s1d3sw1ped/robinhood-agentic-mcp/internal/wire"
)
const (
toolLists = "get_watchlists"
toolItems = "get_watchlist_items"
toolOptionList = "get_option_watchlist"
toolPopular = "get_popular_watchlists"
toolCreate = "create_watchlist"
toolUpdate = "update_watchlist"
toolFollow = "follow_watchlist"
toolUnfollow = "unfollow_watchlist"
toolAdd = "add_to_watchlist"
toolRemove = "remove_from_watchlist"
toolAddOption = "add_option_to_watchlist"
toolRemoveOption = "remove_option_from_watchlist"
)
// ListsRequest is the argument set for get_watchlists (none).
type ListsRequest struct{}
// Watchlist is one list from get_watchlists. Title falls back to name.
type Watchlist struct {
ID string
Title string
}
// ListsResult is the parsed get_watchlists payload.
type ListsResult struct {
Watchlists []Watchlist
}
// ItemsRequest is the argument set for get_watchlist_items.
type ItemsRequest struct {
ListID string
}
// Instrument is the nested instrument object on a watchlist item.
type Instrument struct {
Symbol string
Type string
}
// Item is one row from get_watchlist_items. Object types are not filtered.
type Item struct {
Symbol string
ObjectType string
Instrument *Instrument
}
// ItemsResult is the parsed get_watchlist_items payload.
type ItemsResult struct {
Items []Item
}
// OptionListRequest is the argument set for get_option_watchlist (none).
type OptionListRequest struct{}
// OptionListResult is the parsed get_option_watchlist payload.
type OptionListResult struct{}
// PopularRequest is the argument set for get_popular_watchlists (none).
type PopularRequest struct{}
// PopularResult is the parsed get_popular_watchlists payload.
type PopularResult struct{}
// CreateRequest is the argument set for create_watchlist.
type CreateRequest struct {
DisplayName string
IconEmoji string
DisplayDescription string
}
// UpdateRequest is the argument set for update_watchlist.
type UpdateRequest struct {
ListID string
DisplayName string
IconEmoji string
DisplayDescription string
}
// ListIDRequest is a single list_id argument.
type ListIDRequest struct {
ListID string
}
// AddRequest is the argument set for add_to_watchlist.
type AddRequest struct {
ListID string
Symbols []string
CurrencyPairIDs []string
IndexIDs []string
}
// RemoveRequest is the argument set for remove_from_watchlist.
type RemoveRequest struct {
ListID string
Symbols []string
CurrencyPairIDs []string
IndexIDs []string
}
// OptionMutateRequest is the argument set for add/remove option watchlist tools.
type OptionMutateRequest struct {
OptionIDs []string
PositionType string
}
// Lists calls get_watchlists.
func (c *Client) Lists(ctx context.Context, req ListsRequest) (ListsResult, error) {
raw, err := c.c.Call(ctx, toolLists, map[string]any{})
if err != nil {
return ListsResult{}, err
}
data := wire.Unwrap(raw)
var wrap struct {
Watchlists []watchlistJSON `json:"watchlists"`
}
if err := json.Unmarshal(data, &wrap); err != nil {
var list []watchlistJSON
if err := json.Unmarshal(data, &list); err != nil {
return ListsResult{}, client.ToolErrorf(toolLists, "parse: %w", err)
}
wrap.Watchlists = list
}
out := make([]Watchlist, 0, len(wrap.Watchlists))
for _, w := range wrap.Watchlists {
title := w.Title
if title == "" {
title = w.Name
}
out = append(out, Watchlist{ID: w.ID, Title: title})
}
return ListsResult{Watchlists: out}, nil
}
// Items calls get_watchlist_items. Every object_type is kept.
func (c *Client) Items(ctx context.Context, req ItemsRequest) (ItemsResult, error) {
raw, err := c.c.Call(ctx, toolItems, listIDArgs(req.ListID))
if err != nil {
return ItemsResult{}, err
}
data := wire.Unwrap(raw)
var wrap struct {
Items []itemJSON `json:"items"`
}
if err := json.Unmarshal(data, &wrap); err != nil {
var list []itemJSON
if err := json.Unmarshal(data, &list); err != nil {
return ItemsResult{}, client.ToolErrorf(toolItems, "parse: %w", err)
}
wrap.Items = list
}
out := make([]Item, 0, len(wrap.Items))
for _, it := range wrap.Items {
sym := it.Symbol
typ := it.ObjectType
var inst *Instrument
if it.Instrument != nil {
inst = &Instrument{Symbol: it.Instrument.Symbol, Type: it.Instrument.Type}
if typ == "" {
typ = it.Instrument.Type
}
if sym == "" {
sym = it.Instrument.Symbol
}
}
out = append(out, Item{Symbol: sym, ObjectType: typ, Instrument: inst})
}
return ItemsResult{Items: out}, nil
}
// OptionList calls get_option_watchlist.
func (c *Client) OptionList(ctx context.Context, req OptionListRequest) (OptionListResult, error) {
var out OptionListResult
if err := c.parse(ctx, toolOptionList, map[string]any{}, &out); err != nil {
return OptionListResult{}, err
}
return out, nil
}
// Popular calls get_popular_watchlists.
func (c *Client) Popular(ctx context.Context, req PopularRequest) (PopularResult, error) {
var out PopularResult
if err := c.parse(ctx, toolPopular, map[string]any{}, &out); err != nil {
return PopularResult{}, err
}
return out, nil
}
// Create calls create_watchlist.
func (c *Client) Create(ctx context.Context, req CreateRequest) error {
args := map[string]any{}
if req.DisplayName != "" {
args["display_name"] = req.DisplayName
}
if req.IconEmoji != "" {
args["icon_emoji"] = req.IconEmoji
}
if req.DisplayDescription != "" {
args["display_description"] = req.DisplayDescription
}
_, err := c.c.Call(ctx, toolCreate, args)
return err
}
// Update calls update_watchlist.
func (c *Client) Update(ctx context.Context, req UpdateRequest) error {
args := listIDArgs(req.ListID)
if req.DisplayName != "" {
args["display_name"] = req.DisplayName
}
if req.IconEmoji != "" {
args["icon_emoji"] = req.IconEmoji
}
if req.DisplayDescription != "" {
args["display_description"] = req.DisplayDescription
}
_, err := c.c.Call(ctx, toolUpdate, args)
return err
}
// Follow calls follow_watchlist.
func (c *Client) Follow(ctx context.Context, req ListIDRequest) error {
_, err := c.c.Call(ctx, toolFollow, listIDArgs(req.ListID))
return err
}
// Unfollow calls unfollow_watchlist.
func (c *Client) Unfollow(ctx context.Context, req ListIDRequest) error {
_, err := c.c.Call(ctx, toolUnfollow, listIDArgs(req.ListID))
return err
}
// Add calls add_to_watchlist.
func (c *Client) Add(ctx context.Context, req AddRequest) error {
_, err := c.c.Call(ctx, toolAdd, mutateArgs(req.ListID, req.Symbols, req.CurrencyPairIDs, req.IndexIDs))
return err
}
// Remove calls remove_from_watchlist.
func (c *Client) Remove(ctx context.Context, req RemoveRequest) error {
_, err := c.c.Call(ctx, toolRemove, mutateArgs(req.ListID, req.Symbols, req.CurrencyPairIDs, req.IndexIDs))
return err
}
// AddOption calls add_option_to_watchlist.
func (c *Client) AddOption(ctx context.Context, req OptionMutateRequest) error {
_, err := c.c.Call(ctx, toolAddOption, optionArgs(req))
return err
}
// RemoveOption calls remove_option_from_watchlist.
func (c *Client) RemoveOption(ctx context.Context, req OptionMutateRequest) error {
_, err := c.c.Call(ctx, toolRemoveOption, optionArgs(req))
return err
}
type watchlistJSON struct {
ID string `json:"id"`
Title string `json:"title"`
Name string `json:"name"`
}
type itemJSON struct {
Symbol string `json:"symbol"`
ObjectType string `json:"object_type"`
Instrument *struct {
Symbol string `json:"symbol"`
Type string `json:"type"`
} `json:"instrument"`
}
func listIDArgs(id string) map[string]any {
args := map[string]any{}
if id != "" {
args["list_id"] = id
}
return args
}
func mutateArgs(listID string, symbols, pairIDs, indexIDs []string) map[string]any {
args := listIDArgs(listID)
if len(symbols) > 0 {
args["symbols"] = symbols
}
if len(pairIDs) > 0 {
args["currency_pair_ids"] = pairIDs
}
if len(indexIDs) > 0 {
args["index_ids"] = indexIDs
}
return args
}
func optionArgs(req OptionMutateRequest) map[string]any {
args := map[string]any{}
if len(req.OptionIDs) > 0 {
args["option_ids"] = req.OptionIDs
}
if req.PositionType != "" {
args["position_type"] = req.PositionType
}
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
}
+307
View File
@@ -0,0 +1,307 @@
package watchlists_test
import (
"context"
"encoding/json"
"sort"
"testing"
"github.com/google/go-cmp/cmp"
"s1d3sw1ped/robinhood-agentic-mcp/client"
"s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest"
"s1d3sw1ped/robinhood-agentic-mcp/watchlists"
)
func TestWatchlists_toolNames(t *testing.T) {
t.Parallel()
tests := []struct {
name string
call func(*watchlists.Client) error
wantName string
wantArgs map[string]any
}{
{
name: "Lists",
call: func(c *watchlists.Client) error {
_, err := c.Lists(context.Background(), watchlists.ListsRequest{})
return err
},
wantName: "get_watchlists",
wantArgs: map[string]any{},
},
{
name: "Items",
call: func(c *watchlists.Client) error {
_, err := c.Items(context.Background(), watchlists.ItemsRequest{ListID: "wl-1"})
return err
},
wantName: "get_watchlist_items",
wantArgs: map[string]any{"list_id": "wl-1"},
},
{
name: "OptionList",
call: func(c *watchlists.Client) error {
_, err := c.OptionList(context.Background(), watchlists.OptionListRequest{})
return err
},
wantName: "get_option_watchlist",
wantArgs: map[string]any{},
},
{
name: "Popular",
call: func(c *watchlists.Client) error {
_, err := c.Popular(context.Background(), watchlists.PopularRequest{})
return err
},
wantName: "get_popular_watchlists",
wantArgs: map[string]any{},
},
{
name: "Create",
call: func(c *watchlists.Client) error {
return c.Create(context.Background(), watchlists.CreateRequest{
DisplayName: "TRADEY",
IconEmoji: "📈",
DisplayDescription: "vwap book",
})
},
wantName: "create_watchlist",
wantArgs: map[string]any{
"display_name": "TRADEY",
"icon_emoji": "📈",
"display_description": "vwap book",
},
},
{
name: "Update",
call: func(c *watchlists.Client) error {
return c.Update(context.Background(), watchlists.UpdateRequest{
ListID: "wl-1",
DisplayName: "TRADEY",
IconEmoji: "📈",
DisplayDescription: "vwap book",
})
},
wantName: "update_watchlist",
wantArgs: map[string]any{
"list_id": "wl-1",
"display_name": "TRADEY",
"icon_emoji": "📈",
"display_description": "vwap book",
},
},
{
name: "Follow",
call: func(c *watchlists.Client) error {
return c.Follow(context.Background(), watchlists.ListIDRequest{ListID: "wl-pop"})
},
wantName: "follow_watchlist",
wantArgs: map[string]any{"list_id": "wl-pop"},
},
{
name: "Unfollow",
call: func(c *watchlists.Client) error {
return c.Unfollow(context.Background(), watchlists.ListIDRequest{ListID: "wl-pop"})
},
wantName: "unfollow_watchlist",
wantArgs: map[string]any{"list_id": "wl-pop"},
},
{
name: "Add",
call: func(c *watchlists.Client) error {
return c.Add(context.Background(), watchlists.AddRequest{
ListID: "wl-1",
Symbols: []string{"MU", "SPY"},
CurrencyPairIDs: []string{"btc-1"},
IndexIDs: []string{"idx-spx"},
})
},
wantName: "add_to_watchlist",
wantArgs: map[string]any{
"list_id": "wl-1",
"symbols": []string{"MU", "SPY"},
"currency_pair_ids": []string{"btc-1"},
"index_ids": []string{"idx-spx"},
},
},
{
name: "Remove",
call: func(c *watchlists.Client) error {
return c.Remove(context.Background(), watchlists.RemoveRequest{
ListID: "wl-1",
Symbols: []string{"MU"},
CurrencyPairIDs: []string{"btc-1"},
IndexIDs: []string{"idx-spx"},
})
},
wantName: "remove_from_watchlist",
wantArgs: map[string]any{
"list_id": "wl-1",
"symbols": []string{"MU"},
"currency_pair_ids": []string{"btc-1"},
"index_ids": []string{"idx-spx"},
},
},
{
name: "AddOption",
call: func(c *watchlists.Client) error {
return c.AddOption(context.Background(), watchlists.OptionMutateRequest{
OptionIDs: []string{"opt-1", "opt-2"},
PositionType: "long",
})
},
wantName: "add_option_to_watchlist",
wantArgs: map[string]any{
"option_ids": []string{"opt-1", "opt-2"},
"position_type": "long",
},
},
{
name: "RemoveOption",
call: func(c *watchlists.Client) error {
return c.RemoveOption(context.Background(), watchlists.OptionMutateRequest{
OptionIDs: []string{"opt-1"},
PositionType: "short",
})
},
wantName: "remove_option_from_watchlist",
wantArgs: map[string]any{
"option_ids": []string{"opt-1"},
"position_type": "short",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var gotName string
var gotArgs map[string]any
c := watchlists.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
gotName, gotArgs = name, args
return json.RawMessage(`{}`), 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)
}
})
}
}
func TestTools(t *testing.T) {
t.Parallel()
want := []string{
"add_option_to_watchlist",
"add_to_watchlist",
"create_watchlist",
"follow_watchlist",
"get_option_watchlist",
"get_popular_watchlists",
"get_watchlist_items",
"get_watchlists",
"remove_from_watchlist",
"remove_option_from_watchlist",
"unfollow_watchlist",
"update_watchlist",
}
got := append([]string(nil), watchlists.Tools()...)
sort.Strings(got)
if diff := cmp.Diff(want, got); diff != "" {
t.Fatal(diff)
}
}
func TestLists_rhntest(t *testing.T) {
t.Parallel()
s := rhntest.New(t)
s.Set("get_watchlists", json.RawMessage(`{"watchlists":[{"id":"wl-1","title":"TRADEY"}]}`))
c := watchlists.New(&client.Client{URL: s.URL})
got, err := c.Lists(context.Background(), watchlists.ListsRequest{})
if err != nil {
t.Fatal(err)
}
if len(got.Watchlists) != 1 || got.Watchlists[0].ID != "wl-1" || got.Watchlists[0].Title != "TRADEY" {
t.Fatalf("%+v", got)
}
if s.LastName() != "get_watchlists" {
t.Fatalf("%s", s.LastName())
}
if diff := cmp.Diff(map[string]any{}, s.LastArgs()); diff != "" {
t.Fatal(diff)
}
}
func TestItems_rhntest(t *testing.T) {
t.Parallel()
s := rhntest.New(t)
s.Set("get_watchlist_items", json.RawMessage(`{"items":[{"symbol":"MU","object_type":"equity"}]}`))
c := watchlists.New(&client.Client{URL: s.URL})
got, err := c.Items(context.Background(), watchlists.ItemsRequest{ListID: "wl-1"})
if err != nil {
t.Fatal(err)
}
if len(got.Items) != 1 || got.Items[0].Symbol != "MU" || got.Items[0].ObjectType != "equity" {
t.Fatalf("%+v", got)
}
if s.LastName() != "get_watchlist_items" {
t.Fatalf("%s", s.LastName())
}
want := map[string]any{"list_id": "wl-1"}
if diff := cmp.Diff(want, s.LastArgs()); diff != "" {
t.Fatal(diff)
}
}
func TestLists_titleFallsBackToName(t *testing.T) {
t.Parallel()
c := watchlists.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
return json.RawMessage(`{"watchlists":[{"id":"wl-1","title":"tradey"},{"id":"wl-2","name":"Other"}]}`), nil
}))
got, err := c.Lists(context.Background(), watchlists.ListsRequest{})
if err != nil {
t.Fatal(err)
}
want := watchlists.ListsResult{
Watchlists: []watchlists.Watchlist{
{ID: "wl-1", Title: "tradey"},
{ID: "wl-2", Title: "Other"},
},
}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatal(diff)
}
}
func TestItems_keepsObjectTypesAndNestedSymbol(t *testing.T) {
t.Parallel()
c := watchlists.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
return json.RawMessage(`{"items":[
{"symbol":"MU","object_type":"equity"},
{"symbol":"BTC","object_type":"crypto"},
{"instrument":{"symbol":"SPY","type":"etf"}}
]}`), nil
}))
got, err := c.Items(context.Background(), watchlists.ItemsRequest{ListID: "wl-1"})
if err != nil {
t.Fatal(err)
}
want := watchlists.ItemsResult{
Items: []watchlists.Item{
{Symbol: "MU", ObjectType: "equity"},
{Symbol: "BTC", ObjectType: "crypto"},
{
Symbol: "SPY",
ObjectType: "etf",
Instrument: &watchlists.Instrument{Symbol: "SPY", Type: "etf"},
},
},
}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatal(diff)
}
}