From 4318b6aefedae0eabfc170688ba97884583a1caa Mon Sep 17 00:00:00 2001 From: Justin Harms Date: Tue, 1 Sep 2026 13:31:44 -0500 Subject: [PATCH] feat: add scanner MCP methods --- scanner/client.go | 27 ++++ scanner/scanner.go | 293 ++++++++++++++++++++++++++++++++++++++++ scanner/scanner_test.go | 242 +++++++++++++++++++++++++++++++++ 3 files changed, 562 insertions(+) create mode 100644 scanner/client.go create mode 100644 scanner/scanner.go create mode 100644 scanner/scanner_test.go diff --git a/scanner/client.go b/scanner/client.go new file mode 100644 index 0000000..b044eec --- /dev/null +++ b/scanner/client.go @@ -0,0 +1,27 @@ +package scanner + +import "s1d3sw1ped/robinhood-agentic-mcp/client" + +// Client wraps Robinhood scanner MCP tools. +type Client struct { + c client.Caller +} + +// New returns a scanner 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{ + toolFilterSpecs, + toolDatapoints, + toolScans, + toolCreate, + toolPreview, + toolRun, + toolUpdateFilters, + toolUpdateConfig, + } +} diff --git a/scanner/scanner.go b/scanner/scanner.go new file mode 100644 index 0000000..2d142a0 --- /dev/null +++ b/scanner/scanner.go @@ -0,0 +1,293 @@ +package scanner + +import ( + "context" + "encoding/json" + + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +const ( + toolFilterSpecs = "get_scanner_filter_specs" + toolDatapoints = "get_scanner_datapoints" + toolScans = "get_scans" + toolCreate = "create_scan" + toolPreview = "preview_scan" + toolRun = "run_scan" + toolUpdateFilters = "update_scan_filters" + toolUpdateConfig = "update_scan_config" +) + +// Filter is one scanner filter (enum-based or expression-based). +type Filter struct { + FilterType string + Predicate string + Values []string + Interval string + Length int + Plot string + Expression string + DisplayTitle string +} + +// Column is one extra result column on a scan. +type Column struct { + DisplayName string + Expression string + Visible *bool + Order *int +} + +// FilterSpecsRequest is the argument set for get_scanner_filter_specs (none). +type FilterSpecsRequest struct{} + +// FilterSpecsResult is the parsed get_scanner_filter_specs payload. +type FilterSpecsResult struct{} + +// DatapointsRequest is the argument set for get_scanner_datapoints (none). +type DatapointsRequest struct{} + +// DatapointsResult is the parsed get_scanner_datapoints payload. +type DatapointsResult struct{} + +// ScansRequest is the argument set for get_scans (none). +type ScansRequest struct{} + +// Scan is one saved scanner from get_scans. +type Scan struct { + ID string `json:"id"` + Title string `json:"title"` +} + +// ScansResult is the parsed get_scans payload. +type ScansResult struct { + Scans []Scan `json:"scans"` +} + +// CreateRequest is the argument set for create_scan. +type CreateRequest struct { + ScanID string + Preset string + Filters []Filter + Columns []Column + Title string +} + +// CreateResult is the parsed create_scan payload. +type CreateResult struct{} + +// PreviewRequest is the argument set for preview_scan. +type PreviewRequest struct { + Filters []Filter + Columns []Column +} + +// PreviewResult is the parsed preview_scan payload. +type PreviewResult struct{} + +// RunRequest is the argument set for run_scan. +type RunRequest struct { + ScanID string +} + +// RunResult is the parsed run_scan payload. +type RunResult struct{} + +// UpdateFiltersRequest is the argument set for update_scan_filters. +type UpdateFiltersRequest struct { + ScanID string + Filters []Filter +} + +// UpdateFiltersResult is the parsed update_scan_filters payload. +type UpdateFiltersResult struct{} + +// UpdateConfigRequest is the argument set for update_scan_config. +type UpdateConfigRequest struct { + ScanID string + SortingColumn string + SortingDirection string + Columns []Column +} + +// UpdateConfigResult is the parsed update_scan_config payload. +type UpdateConfigResult struct{} + +// FilterSpecs calls get_scanner_filter_specs. +func (c *Client) FilterSpecs(ctx context.Context, req FilterSpecsRequest) (FilterSpecsResult, error) { + var out FilterSpecsResult + if err := c.parse(ctx, toolFilterSpecs, map[string]any{}, &out); err != nil { + return FilterSpecsResult{}, err + } + return out, nil +} + +// Datapoints calls get_scanner_datapoints. +func (c *Client) Datapoints(ctx context.Context, req DatapointsRequest) (DatapointsResult, error) { + var out DatapointsResult + if err := c.parse(ctx, toolDatapoints, map[string]any{}, &out); err != nil { + return DatapointsResult{}, err + } + return out, nil +} + +// Scans calls get_scans. +func (c *Client) Scans(ctx context.Context, req ScansRequest) (ScansResult, error) { + var out ScansResult + if err := c.parse(ctx, toolScans, map[string]any{}, &out); err != nil { + return ScansResult{}, err + } + return out, nil +} + +// Create calls create_scan. +func (c *Client) Create(ctx context.Context, req CreateRequest) (CreateResult, error) { + args := map[string]any{} + if req.ScanID != "" { + args["scan_id"] = req.ScanID + } + if req.Preset != "" { + args["preset"] = req.Preset + } + if len(req.Filters) > 0 { + args["filters"] = encodeFilters(req.Filters) + } + if len(req.Columns) > 0 { + args["columns"] = encodeColumns(req.Columns) + } + if req.Title != "" { + args["title"] = req.Title + } + var out CreateResult + if err := c.parse(ctx, toolCreate, args, &out); err != nil { + return CreateResult{}, err + } + return out, nil +} + +// Preview calls preview_scan. +func (c *Client) Preview(ctx context.Context, req PreviewRequest) (PreviewResult, error) { + args := map[string]any{} + if len(req.Filters) > 0 { + args["filters"] = encodeFilters(req.Filters) + } + if len(req.Columns) > 0 { + args["columns"] = encodeColumns(req.Columns) + } + var out PreviewResult + if err := c.parse(ctx, toolPreview, args, &out); err != nil { + return PreviewResult{}, err + } + return out, nil +} + +// Run calls run_scan. scan_id is always sent (required). +func (c *Client) Run(ctx context.Context, req RunRequest) (RunResult, error) { + var out RunResult + if err := c.parse(ctx, toolRun, map[string]any{"scan_id": req.ScanID}, &out); err != nil { + return RunResult{}, err + } + return out, nil +} + +// UpdateFilters calls update_scan_filters. scan_id and filters are always sent (required; empty filters clears). +func (c *Client) UpdateFilters(ctx context.Context, req UpdateFiltersRequest) (UpdateFiltersResult, error) { + filters := req.Filters + if filters == nil { + filters = []Filter{} + } + args := map[string]any{ + "scan_id": req.ScanID, + "filters": encodeFilters(filters), + } + var out UpdateFiltersResult + if err := c.parse(ctx, toolUpdateFilters, args, &out); err != nil { + return UpdateFiltersResult{}, err + } + return out, nil +} + +// UpdateConfig calls update_scan_config. scan_id is always sent (required). +func (c *Client) UpdateConfig(ctx context.Context, req UpdateConfigRequest) (UpdateConfigResult, error) { + args := map[string]any{"scan_id": req.ScanID} + if req.SortingColumn != "" { + args["sorting_column"] = req.SortingColumn + } + if req.SortingDirection != "" { + args["sorting_direction"] = req.SortingDirection + } + if len(req.Columns) > 0 { + args["columns"] = encodeColumns(req.Columns) + } + var out UpdateConfigResult + if err := c.parse(ctx, toolUpdateConfig, args, &out); err != nil { + return UpdateConfigResult{}, err + } + return out, nil +} + +func encodeFilters(filters []Filter) []map[string]any { + out := make([]map[string]any, len(filters)) + for i, f := range filters { + m := map[string]any{} + if f.FilterType != "" { + m["filter_type"] = f.FilterType + } + if f.Predicate != "" { + m["predicate"] = f.Predicate + } + if len(f.Values) > 0 { + m["values"] = f.Values + } + if f.Interval != "" { + m["interval"] = f.Interval + } + if f.Length != 0 { + m["length"] = f.Length + } + if f.Plot != "" { + m["plot"] = f.Plot + } + if f.Expression != "" { + m["expression"] = f.Expression + } + if f.DisplayTitle != "" { + m["display_title"] = f.DisplayTitle + } + out[i] = m + } + return out +} + +func encodeColumns(cols []Column) []map[string]any { + out := make([]map[string]any, len(cols)) + for i, col := range cols { + m := map[string]any{} + if col.DisplayName != "" { + m["display_name"] = col.DisplayName + } + if col.Expression != "" { + m["expression"] = col.Expression + } + if col.Visible != nil { + m["visible"] = *col.Visible + } + if col.Order != nil { + m["order"] = *col.Order + } + out[i] = m + } + return out +} + +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/scanner/scanner_test.go b/scanner/scanner_test.go new file mode 100644 index 0000000..6b5b68c --- /dev/null +++ b/scanner/scanner_test.go @@ -0,0 +1,242 @@ +package scanner_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/scanner" +) + +func TestScanner_toolNames(t *testing.T) { + t.Parallel() + visible := true + order := 1 + enumFilter := scanner.Filter{ + FilterType: "FILTER_TYPE_RSI", + Predicate: "PREDICATE_GREATER_THAN", + Values: []string{"70"}, + Interval: "1d", + Length: 14, + Plot: "close", + } + enumFilterWire := []map[string]any{ + { + "filter_type": "FILTER_TYPE_RSI", + "predicate": "PREDICATE_GREATER_THAN", + "values": []string{"70"}, + "interval": "1d", + "length": 14, + "plot": "close", + }, + } + exprFilter := scanner.Filter{ + Predicate: "=", + Values: []string{"True"}, + Expression: `dayVolume / volumeAvg(candleCount=30, candlePeriod="1d", session="all")`, + DisplayTitle: "Relative volume (30D)", + } + exprFilterWire := []map[string]any{ + { + "predicate": "=", + "values": []string{"True"}, + "expression": `dayVolume / volumeAvg(candleCount=30, candlePeriod="1d", session="all")`, + "display_title": "Relative volume (30D)", + }, + } + col := scanner.Column{ + DisplayName: "Put/Call volume", + Expression: "optionsPutDayVolume / optionsCallDayVolume", + Visible: &visible, + Order: &order, + } + colWire := []map[string]any{ + { + "display_name": "Put/Call volume", + "expression": "optionsPutDayVolume / optionsCallDayVolume", + "visible": true, + "order": 1, + }, + } + tests := []struct { + name string + call func(*scanner.Client) error + wantName string + wantArgs map[string]any + }{ + { + name: "FilterSpecs", + call: func(c *scanner.Client) error { + _, err := c.FilterSpecs(context.Background(), scanner.FilterSpecsRequest{}) + return err + }, + wantName: "get_scanner_filter_specs", + wantArgs: map[string]any{}, + }, + { + name: "Datapoints", + call: func(c *scanner.Client) error { + _, err := c.Datapoints(context.Background(), scanner.DatapointsRequest{}) + return err + }, + wantName: "get_scanner_datapoints", + wantArgs: map[string]any{}, + }, + { + name: "Scans", + call: func(c *scanner.Client) error { + _, err := c.Scans(context.Background(), scanner.ScansRequest{}) + return err + }, + wantName: "get_scans", + wantArgs: map[string]any{}, + }, + { + name: "Create", + call: func(c *scanner.Client) error { + _, err := c.Create(context.Background(), scanner.CreateRequest{ + ScanID: "scan-1", + Preset: "INITIAL", + Filters: []scanner.Filter{enumFilter}, + Columns: []scanner.Column{col}, + Title: "RSI overbought", + }) + return err + }, + wantName: "create_scan", + wantArgs: map[string]any{ + "scan_id": "scan-1", + "preset": "INITIAL", + "filters": enumFilterWire, + "columns": colWire, + "title": "RSI overbought", + }, + }, + { + name: "Preview", + call: func(c *scanner.Client) error { + _, err := c.Preview(context.Background(), scanner.PreviewRequest{ + Filters: []scanner.Filter{exprFilter}, + Columns: []scanner.Column{col}, + }) + return err + }, + wantName: "preview_scan", + wantArgs: map[string]any{ + "filters": exprFilterWire, + "columns": colWire, + }, + }, + { + name: "Run", + call: func(c *scanner.Client) error { + _, err := c.Run(context.Background(), scanner.RunRequest{ScanID: "scan-1"}) + return err + }, + wantName: "run_scan", + wantArgs: map[string]any{"scan_id": "scan-1"}, + }, + { + name: "UpdateFilters", + call: func(c *scanner.Client) error { + _, err := c.UpdateFilters(context.Background(), scanner.UpdateFiltersRequest{ + ScanID: "scan-1", + Filters: []scanner.Filter{enumFilter}, + }) + return err + }, + wantName: "update_scan_filters", + wantArgs: map[string]any{ + "scan_id": "scan-1", + "filters": enumFilterWire, + }, + }, + { + name: "UpdateConfig", + call: func(c *scanner.Client) error { + _, err := c.UpdateConfig(context.Background(), scanner.UpdateConfigRequest{ + ScanID: "scan-1", + SortingColumn: "Volume", + SortingDirection: "desc", + Columns: []scanner.Column{col}, + }) + return err + }, + wantName: "update_scan_config", + wantArgs: map[string]any{ + "scan_id": "scan-1", + "sorting_column": "Volume", + "sorting_direction": "desc", + "columns": colWire, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var gotName string + var gotArgs map[string]any + c := scanner.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + gotName, gotArgs = name, args + return json.RawMessage(`{"scans":[]}`), 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 == "Run" { + if gotArgs["scan_id"] != "scan-1" { + t.Fatalf("scan_id %v", gotArgs["scan_id"]) + } + } + }) + } +} + +func TestTools(t *testing.T) { + t.Parallel() + want := []string{ + "create_scan", + "get_scanner_datapoints", + "get_scanner_filter_specs", + "get_scans", + "preview_scan", + "run_scan", + "update_scan_config", + "update_scan_filters", + } + got := append([]string(nil), scanner.Tools()...) + sort.Strings(got) + if diff := cmp.Diff(want, got); diff != "" { + t.Fatal(diff) + } +} + +func TestScans_rhntest(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.Set("get_scans", json.RawMessage(`{"scans":[]}`)) + c := scanner.New(&client.Client{URL: s.URL}) + got, err := c.Scans(context.Background(), scanner.ScansRequest{}) + if err != nil { + t.Fatal(err) + } + if len(got.Scans) != 0 { + t.Fatalf("%+v", got) + } + if s.LastName() != "get_scans" { + t.Fatalf("%s", s.LastName()) + } + if diff := cmp.Diff(map[string]any{}, s.LastArgs()); diff != "" { + t.Fatal(diff) + } +}