@tickerbot/mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # @tickerbot/mcp-server
2
+
3
+ Model Context Protocol server for the [Tickerbot Signals API](https://tickerbot.io). Lets Claude, Cursor, VS Code Copilot, and other MCP-compatible clients run scans, fetch tickers, and query signals in natural language.
4
+
5
+ ## Install
6
+
7
+ You need a Tickerbot API key. Get one from your [dashboard](https://tickerbot.io/dashboard).
8
+
9
+ ### Claude Desktop
10
+
11
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%/Claude/claude_desktop_config.json` (Windows):
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "tickerbot": {
17
+ "command": "npx",
18
+ "args": ["-y", "@tickerbot/mcp-server"],
19
+ "env": {
20
+ "TICKERBOT_API_KEY": "tb_live_..."
21
+ }
22
+ }
23
+ }
24
+ }
25
+ ```
26
+
27
+ Restart Claude Desktop.
28
+
29
+ ### Claude Code
30
+
31
+ ```bash
32
+ claude mcp add tickerbot --env TICKERBOT_API_KEY=tb_live_... -- npx -y @tickerbot/mcp-server
33
+ ```
34
+
35
+ ### Cursor
36
+
37
+ Add to `~/.cursor/mcp.json`:
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "tickerbot": {
43
+ "command": "npx",
44
+ "args": ["-y", "@tickerbot/mcp-server"],
45
+ "env": { "TICKERBOT_API_KEY": "tb_live_..." }
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ ### VS Code (Copilot)
52
+
53
+ Add to `.vscode/mcp.json` in your workspace:
54
+
55
+ ```json
56
+ {
57
+ "servers": {
58
+ "tickerbot": {
59
+ "command": "npx",
60
+ "args": ["-y", "@tickerbot/mcp-server"],
61
+ "env": { "TICKERBOT_API_KEY": "tb_live_..." }
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ ## Tools
68
+
69
+ 19 tools covering tickers, signals, scans, universes, news, and webhook subscriptions.
70
+
71
+ | Tool | Endpoint |
72
+ | --- | --- |
73
+ | `tickerbot_list_tickers` | `GET /v2/tickers` |
74
+ | `tickerbot_get_ticker` | `GET /v2/tickers/{ticker}` |
75
+ | `tickerbot_get_ticker_history` | `GET /v2/tickers/{ticker}/history` |
76
+ | `tickerbot_get_ticker_events` | `GET /v2/tickers/{ticker}/events` |
77
+ | `tickerbot_list_signals_catalog` | `GET /v2/signals` |
78
+ | `tickerbot_get_signals_match` | `GET /v2/signals/{signal}` |
79
+ | `tickerbot_get_signal_history` | `GET /v2/signals/{signal}/{ticker}/history/{interval}` |
80
+ | `tickerbot_list_signal_events` | `GET /v2/signals/{signal}/{ticker}/events` |
81
+ | `tickerbot_scan` | `GET /v2/scan` (live or `asof`) |
82
+ | `tickerbot_list_universes` | `GET /v2/universes` |
83
+ | `tickerbot_get_universe` | `GET /v2/universes/{id}` |
84
+ | `tickerbot_create_universe` | `POST /v2/universes` |
85
+ | `tickerbot_create_custom_signal` | `POST /v2/signals` |
86
+ | `tickerbot_search_news` | `GET /v2/news/scan` |
87
+ | `tickerbot_subscribe_scan` | `POST /v2/scan/subscribe` |
88
+ | `tickerbot_subscribe_signal` | `POST /v2/signals/{signal}/subscribe` |
89
+ | `tickerbot_subscribe_ticker` | `POST /v2/tickers/{ticker}/subscribe` |
90
+ | `tickerbot_list_webhooks` | `GET /v2/webhooks` |
91
+ | `tickerbot_delete_webhook` | `DELETE /v2/webhooks/{id}` |
92
+
93
+ Strategies are not exposed via MCP — see the [REST API docs](https://tickerbot.io/api) for those.
94
+
95
+ ## Examples
96
+
97
+ Open Claude and try:
98
+
99
+ > *"Find oversold semiconductor stocks bouncing on volume."*
100
+
101
+ > *"What's NVDA's RSI and short interest right now?"*
102
+
103
+ > *"How often has the gap-up + small-cap + high-RVOL setup hit over the last 30 days?"*
104
+
105
+ > *"Save a signal called `oversold_with_volume` defined as `rsi_14 < 30 AND volume_ratio_20d > 2`."*
106
+
107
+ ## Configuration
108
+
109
+ | Env var | Default | Purpose |
110
+ | --- | --- | --- |
111
+ | `TICKERBOT_API_KEY` | *(required)* | Your API key. Sent as `Authorization: Bearer <key>` on every call. |
112
+ | `TICKERBOT_API_URL` | `https://api.tickerbot.io` | Override the API base URL (for staging or self-hosted). |
113
+
114
+ ## Remote install
115
+
116
+ For consumer chat apps (Claude.ai web/mobile, ChatGPT) that don't run subprocesses, use the hosted remote MCP endpoint at `https://mcp.tickerbot.io` instead. See [tickerbot.io/mcp-server](https://tickerbot.io/mcp-server) for setup.
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // Thin shim so `npx -y @tickerbot/mcp-server` works without users needing
3
+ // to know the dist path. Just hands off to the compiled entrypoint.
4
+ import('../dist/index.js')
@@ -0,0 +1,17 @@
1
+ import { type ToolDef } from './tools.js';
2
+ export interface RunToolOptions {
3
+ /** Base URL for the API. Defaults to https://api.tickerbot.io. */
4
+ baseUrl?: string;
5
+ /** Optional user-agent appended to identify the MCP server. */
6
+ userAgent?: string;
7
+ /** Override the tool lookup. Used when index.ts has fetched the live
8
+ * catalog from /mcp/tools — we dispatch against the server's
9
+ * current tool definitions, not the baked snapshot. */
10
+ findTool?: (name: string) => ToolDef | undefined;
11
+ }
12
+ export declare class ToolError extends Error {
13
+ readonly status?: number | undefined;
14
+ readonly body?: unknown | undefined;
15
+ constructor(message: string, status?: number | undefined, body?: unknown | undefined);
16
+ }
17
+ export declare function runTool(name: string, args: Record<string, unknown>, apiKey: string, options?: RunToolOptions): Promise<unknown>;
@@ -0,0 +1,90 @@
1
+ // Dispatches an MCP tool call to the Tickerbot REST API.
2
+ //
3
+ // Same handler is used by both transports:
4
+ // - the stdio binary (src/index.ts) for local desktop/IDE clients
5
+ // - the HTTP endpoint on main_service for remote chat clients
6
+ //
7
+ // One source of truth for path substitution, query/body assembly, auth.
8
+ import { findTool as bakedFindTool } from './tools.js';
9
+ const DEFAULT_BASE_URL = 'https://api.tickerbot.io';
10
+ export class ToolError extends Error {
11
+ status;
12
+ body;
13
+ constructor(message, status, body) {
14
+ super(message);
15
+ this.status = status;
16
+ this.body = body;
17
+ this.name = 'ToolError';
18
+ }
19
+ }
20
+ export async function runTool(name, args, apiKey, options = {}) {
21
+ if (!apiKey) {
22
+ throw new ToolError('Missing TICKERBOT_API_KEY. Get a key from https://tickerbot.io/dashboard and set it in your MCP client config.');
23
+ }
24
+ const lookup = options.findTool ?? bakedFindTool;
25
+ const tool = lookup(name);
26
+ if (!tool) {
27
+ throw new ToolError(`Unknown tool: ${name}`);
28
+ }
29
+ const { url, body } = buildRequest(tool, args, options.baseUrl ?? DEFAULT_BASE_URL);
30
+ const headers = {
31
+ Authorization: `Bearer ${apiKey}`,
32
+ Accept: 'application/json',
33
+ 'User-Agent': options.userAgent ?? '@tickerbot/mcp-server',
34
+ };
35
+ if (body !== undefined) {
36
+ headers['Content-Type'] = 'application/json';
37
+ }
38
+ const res = await fetch(url, {
39
+ method: tool.endpoint.method,
40
+ headers,
41
+ body: body !== undefined ? JSON.stringify(body) : undefined,
42
+ });
43
+ const text = await res.text();
44
+ let parsed = text;
45
+ try {
46
+ parsed = text ? JSON.parse(text) : null;
47
+ }
48
+ catch {
49
+ // non-JSON response — fall through with text body
50
+ }
51
+ if (!res.ok) {
52
+ throw new ToolError(`Tickerbot ${tool.endpoint.method} ${tool.endpoint.path} failed: ${res.status} ${res.statusText}`, res.status, parsed);
53
+ }
54
+ return parsed;
55
+ }
56
+ function buildRequest(tool, args, baseUrl) {
57
+ let path = tool.endpoint.path;
58
+ const query = new URLSearchParams();
59
+ const body = {};
60
+ let bodyHasContent = false;
61
+ for (const [key, value] of Object.entries(args)) {
62
+ if (value === undefined || value === null || value === '')
63
+ continue;
64
+ const where = tool.endpoint.paramLocation[key];
65
+ if (!where)
66
+ continue; // unknown param, ignore
67
+ if (where === 'path') {
68
+ const token = `{${key}}`;
69
+ if (!path.includes(token)) {
70
+ throw new ToolError(`Tool ${tool.name} declares path param "${key}" but path template "${tool.endpoint.path}" has no {${key}} placeholder.`);
71
+ }
72
+ path = path.replace(token, encodeURIComponent(String(value)));
73
+ }
74
+ else if (where === 'query') {
75
+ query.append(key, String(value));
76
+ }
77
+ else if (where === 'body') {
78
+ body[key] = value;
79
+ bodyHasContent = true;
80
+ }
81
+ }
82
+ // Verify no path placeholders left unfilled
83
+ const unfilled = path.match(/\{([a-zA-Z_]+)\}/);
84
+ if (unfilled) {
85
+ throw new ToolError(`Tool ${tool.name} missing required path param "${unfilled[1]}".`);
86
+ }
87
+ const qs = query.toString();
88
+ const url = `${baseUrl}${path}${qs ? `?${qs}` : ''}`;
89
+ return { url, body: bodyHasContent ? body : undefined };
90
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,111 @@
1
+ // Stdio MCP server entrypoint.
2
+ //
3
+ // Local install target. Launched by the user's MCP client (Claude Desktop,
4
+ // Claude Code, Cursor, VS Code Copilot, etc.) as a subprocess that talks
5
+ // JSON-RPC over stdin/stdout.
6
+ //
7
+ // Reads the API key from TICKERBOT_API_KEY and forwards every tool call
8
+ // through src/handler.ts → api.tickerbot.io.
9
+ //
10
+ // Tool catalog: at startup, this subprocess fetches the live catalog from
11
+ // {baseUrl}/mcp/tools. main_service is the single source of truth — any
12
+ // add/rename/description-tweak on the server side shows up here on the
13
+ // next subprocess spawn (which means the next Claude Desktop relaunch).
14
+ // If the fetch fails (offline, server down, network error), we fall back
15
+ // to the baked snapshot in tools.ts so the package still works.
16
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
17
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
18
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
19
+ import { tools as bakedTools } from './tools.js';
20
+ import { runTool, ToolError } from './handler.js';
21
+ const SERVER_NAME = '@tickerbot/mcp-server';
22
+ const SERVER_VERSION = '0.1.0';
23
+ const DEFAULT_BASE_URL = 'https://api.tickerbot.io';
24
+ const DISCOVERY_TIMEOUT_MS = 3000;
25
+ async function fetchLiveCatalog(baseUrl) {
26
+ const url = `${baseUrl}/mcp/tools`;
27
+ try {
28
+ const controller = new AbortController();
29
+ const timer = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS);
30
+ const res = await fetch(url, {
31
+ headers: { Accept: 'application/json', 'User-Agent': '@tickerbot/mcp-server' },
32
+ signal: controller.signal,
33
+ });
34
+ clearTimeout(timer);
35
+ if (!res.ok) {
36
+ process.stderr.write(`[tickerbot-mcp] discovery: ${url} returned ${res.status}; using baked snapshot\n`);
37
+ return null;
38
+ }
39
+ const body = (await res.json());
40
+ if (!Array.isArray(body.tools) || body.tools.length === 0) {
41
+ process.stderr.write(`[tickerbot-mcp] discovery: malformed response from ${url}; using baked snapshot\n`);
42
+ return null;
43
+ }
44
+ return body.tools;
45
+ }
46
+ catch (err) {
47
+ const message = err instanceof Error ? err.message : String(err);
48
+ process.stderr.write(`[tickerbot-mcp] discovery: ${url} failed (${message}); using baked snapshot\n`);
49
+ return null;
50
+ }
51
+ }
52
+ async function main() {
53
+ const apiKey = process.env.TICKERBOT_API_KEY;
54
+ if (!apiKey) {
55
+ process.stderr.write(`[tickerbot-mcp] TICKERBOT_API_KEY is not set. Get a key from https://tickerbot.io/dashboard and add it to your MCP client config (env block).\n`);
56
+ }
57
+ const baseUrl = process.env.TICKERBOT_API_URL || DEFAULT_BASE_URL;
58
+ // Fetch the live catalog before announcing tools. The handshake (`initialize`
59
+ // + `tools/list`) happens after server.connect(), so we have a window to
60
+ // populate `activeTools` with the server's current view.
61
+ const live = await fetchLiveCatalog(baseUrl);
62
+ const activeTools = live ?? bakedTools;
63
+ const catalogSource = live ? 'live' : 'snapshot';
64
+ const toolMap = new Map(activeTools.map((t) => [t.name, t]));
65
+ const findTool = (name) => toolMap.get(name);
66
+ const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
67
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
68
+ tools: activeTools.map((t) => ({
69
+ name: t.name,
70
+ description: t.description,
71
+ inputSchema: t.inputSchema,
72
+ })),
73
+ }));
74
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
75
+ const { name, arguments: args } = req.params;
76
+ try {
77
+ const result = await runTool(name, (args ?? {}), apiKey ?? '', { baseUrl, findTool });
78
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
79
+ }
80
+ catch (err) {
81
+ const message = err instanceof ToolError
82
+ ? formatToolError(err)
83
+ : err instanceof Error
84
+ ? err.message
85
+ : String(err);
86
+ return {
87
+ content: [{ type: 'text', text: message }],
88
+ isError: true,
89
+ };
90
+ }
91
+ });
92
+ const transport = new StdioServerTransport();
93
+ await server.connect(transport);
94
+ process.stderr.write(`[tickerbot-mcp] ${SERVER_NAME} v${SERVER_VERSION} ready (${activeTools.length} tools, catalog: ${catalogSource})\n`);
95
+ }
96
+ function formatToolError(err) {
97
+ const parts = [err.message];
98
+ if (err.body !== undefined) {
99
+ try {
100
+ parts.push(typeof err.body === 'string' ? err.body : JSON.stringify(err.body, null, 2));
101
+ }
102
+ catch {
103
+ // ignore
104
+ }
105
+ }
106
+ return parts.join('\n');
107
+ }
108
+ main().catch((err) => {
109
+ process.stderr.write(`[tickerbot-mcp] fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
110
+ process.exit(1);
111
+ });
@@ -0,0 +1,29 @@
1
+ export type ParamLocation = 'path' | 'query' | 'body';
2
+ export interface ToolDef {
3
+ name: string;
4
+ description: string;
5
+ inputSchema: {
6
+ type: 'object';
7
+ properties: Record<string, JsonSchemaProp>;
8
+ required?: string[];
9
+ };
10
+ endpoint: {
11
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
12
+ /** Path template. Path params appear as `{name}` placeholders. */
13
+ path: string;
14
+ /** Where each declared input param goes. */
15
+ paramLocation: Record<string, ParamLocation>;
16
+ };
17
+ }
18
+ interface JsonSchemaProp {
19
+ type: 'string' | 'integer' | 'number' | 'boolean' | 'array' | 'object';
20
+ description: string;
21
+ enum?: readonly string[];
22
+ default?: unknown;
23
+ items?: {
24
+ type: string;
25
+ };
26
+ }
27
+ export declare const tools: readonly ToolDef[];
28
+ export declare function findTool(name: string): ToolDef | undefined;
29
+ export {};
package/dist/tools.js ADDED
@@ -0,0 +1,397 @@
1
+ // OFFLINE FALLBACK tool registry for the Tickerbot MCP server.
2
+ //
3
+ // The canonical tool list lives in main_service/mcp/tools.js on the
4
+ // server. At startup, this package fetches `${baseUrl}/mcp/tools` and
5
+ // uses the live catalog — see src/index.ts. This file is the snapshot
6
+ // we fall back to if the fetch fails (offline, server down, network
7
+ // blip), and it's also what gets bundled at npm-publish time for
8
+ // freshness in cold-start scenarios.
9
+ //
10
+ // Staleness here is fine. Updating this file does NOT change behavior
11
+ // for online users — they always see whatever main_service is serving.
12
+ // Refresh this snapshot by running the regen script when the live
13
+ // catalog has drifted enough that you want offline users to see the
14
+ // newer version. No need to republish for every catalog tweak.
15
+ export const tools = [
16
+ // ── Tickers ──────────────────────────────────────────────────────────
17
+ {
18
+ name: 'tickerbot_list_tickers',
19
+ description: 'List active tickers from the Tickerbot universe (~12,000 US equities + top 100 crypto). Use `tickers` for bulk lookup of named symbols (returns full rows); otherwise walks the universe alphabetically with `cursor` pagination. Supports filters: search, asset_type, exchange, sector, min_market_cap.',
20
+ inputSchema: {
21
+ type: 'object',
22
+ properties: {
23
+ tickers: { type: 'string', description: 'Comma-separated symbols (max 50). When set, returns full rows for these symbols and pagination params are ignored.' },
24
+ limit: { type: 'integer', description: 'Page size. Max 1000. Default 50.' },
25
+ cursor: { type: 'string', description: 'Opaque cursor from a prior response.' },
26
+ search: { type: 'string', description: 'Case-insensitive substring filter on ticker/name. Orders results by market_cap desc.' },
27
+ asset_type: { type: 'string', description: 'Filter by asset type (e.g. "equity", "crypto").' },
28
+ exchange: { type: 'string', description: 'Filter by exchange (e.g. "XNYS", "XNAS", "BATS").' },
29
+ sector: { type: 'string', description: 'Exact-match sector filter (e.g. "Technology").' },
30
+ min_market_cap: { type: 'number', description: 'Minimum market cap in USD. Orders results by market_cap desc.' },
31
+ },
32
+ },
33
+ endpoint: {
34
+ method: 'GET',
35
+ path: '/v2/tickers',
36
+ paramLocation: {
37
+ tickers: 'query', limit: 'query', cursor: 'query', search: 'query',
38
+ asset_type: 'query', exchange: 'query', sector: 'query', min_market_cap: 'query',
39
+ },
40
+ },
41
+ },
42
+ {
43
+ name: 'tickerbot_get_ticker',
44
+ description: 'Get the full current row for one ticker — every column on the schema (price, change, indicators like rsi_14, every boolean flag like above_sma_50, fundamentals like pe_ratio). Pass `asof` (YYYY-MM-DD) for the row as it stood at the close of a past trading day.',
45
+ inputSchema: {
46
+ type: 'object',
47
+ properties: {
48
+ ticker: { type: 'string', description: 'Symbol. Case-insensitive. Equities: bare symbol (AAPL). Crypto: bare symbol (BTC).' },
49
+ asof: { type: 'string', description: 'Optional YYYY-MM-DD. Returns the row at close of that day.' },
50
+ },
51
+ required: ['ticker'],
52
+ },
53
+ endpoint: {
54
+ method: 'GET',
55
+ path: '/v2/tickers/{ticker}',
56
+ paramLocation: { ticker: 'path', asof: 'query' },
57
+ },
58
+ },
59
+ {
60
+ name: 'tickerbot_get_ticker_history',
61
+ description: 'Time-travel: get the full wide row for one ticker as it stood at a past date. Returns indicators, boolean flags, and the most-recent fundamentals known on that date. Use for backtests and reconstructions.',
62
+ inputSchema: {
63
+ type: 'object',
64
+ properties: {
65
+ ticker: { type: 'string', description: 'Symbol.' },
66
+ asof: { type: 'string', description: 'Target date as YYYY-MM-DD or full ISO timestamp. Returns the most-recent daily snapshot on or before this date.' },
67
+ },
68
+ required: ['ticker', 'asof'],
69
+ },
70
+ endpoint: {
71
+ method: 'GET',
72
+ path: '/v2/tickers/{ticker}/history',
73
+ paramLocation: { ticker: 'path', asof: 'query' },
74
+ },
75
+ },
76
+ {
77
+ name: 'tickerbot_get_ticker_events',
78
+ description: 'Get the discrete event log for one ticker (splits, dividends, analyst rating changes), newest-first. Optional date window and event-kind filter.',
79
+ inputSchema: {
80
+ type: 'object',
81
+ properties: {
82
+ ticker: { type: 'string', description: 'Symbol.' },
83
+ from: { type: 'string', description: 'Earliest event timestamp (inclusive). YYYY-MM-DD or ISO.' },
84
+ to: { type: 'string', description: 'Latest event timestamp (inclusive). YYYY-MM-DD or ISO.' },
85
+ kind: { type: 'string', description: 'Filter by event kind.', enum: ['split', 'dividend', 'rating_change'] },
86
+ limit: { type: 'integer', description: 'Page size. Max 1000. Default 100.' },
87
+ cursor: { type: 'string', description: 'Opaque cursor.' },
88
+ },
89
+ required: ['ticker'],
90
+ },
91
+ endpoint: {
92
+ method: 'GET',
93
+ path: '/v2/tickers/{ticker}/events',
94
+ paramLocation: { ticker: 'path', from: 'query', to: 'query', kind: 'query', limit: 'query', cursor: 'query' },
95
+ },
96
+ },
97
+ // ── Signals catalog ──────────────────────────────────────────────────
98
+ {
99
+ name: 'tickerbot_list_signals_catalog',
100
+ description: 'List the unified signal catalog: every built-in column on the schema (`kind: builtin`) plus the caller\'s custom signals (`kind: expression`). Use to discover what `q=` clauses and signal names are available before composing a scan.',
101
+ inputSchema: {
102
+ type: 'object',
103
+ properties: {
104
+ kind: { type: 'string', description: 'Filter by kind. Omit for both.', enum: ['builtin', 'custom'] },
105
+ limit: { type: 'integer', description: 'Page size for custom slice. Max 200. Default 50.' },
106
+ cursor: { type: 'string', description: 'Opaque cursor.' },
107
+ },
108
+ },
109
+ endpoint: {
110
+ method: 'GET',
111
+ path: '/v2/signals',
112
+ paramLocation: { kind: 'query', limit: 'query', cursor: 'query' },
113
+ },
114
+ },
115
+ {
116
+ name: 'tickerbot_get_signals_match',
117
+ description: 'Find tickers that match a single signal right now (or at a past date with `asof`). Booleans need no condition (returns tickers where flag is true). Numerics need a `condition` like ">70" or "<=200". Sorted by signal value desc for numerics.',
118
+ inputSchema: {
119
+ type: 'object',
120
+ properties: {
121
+ signal: { type: 'string', description: 'Column name on ticker. E.g. golden_cross_today, above_sma_50, rsi_14, market_cap, pe_ratio.' },
122
+ condition: { type: 'string', description: 'Required for numerics. Single bound: <op><value>, ops in (>, >=, =, !=, <, <=). E.g. ">70".' },
123
+ asof: { type: 'string', description: 'Optional YYYY-MM-DD. Match against historical daily state.' },
124
+ universe: { type: 'string', description: 'Optional universe slug.' },
125
+ limit: { type: 'integer', description: 'Page size. Max 200. Default 50.' },
126
+ cursor: { type: 'string', description: 'Opaque cursor.' },
127
+ },
128
+ required: ['signal'],
129
+ },
130
+ endpoint: {
131
+ method: 'GET',
132
+ path: '/v2/signals/{signal}',
133
+ paramLocation: { signal: 'path', condition: 'query', asof: 'query', universe: 'query', limit: 'query', cursor: 'query' },
134
+ },
135
+ },
136
+ {
137
+ name: 'tickerbot_get_signal_history',
138
+ description: 'Get the time series of one signal for one ticker at a chosen interval (1m, 1h, 1d, 1w). Use for charting a numeric signal\'s evolution or seeing when a boolean flag was on/off across time.',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ signal: { type: 'string', description: 'Column name.' },
143
+ ticker: { type: 'string', description: 'Symbol.' },
144
+ interval: { type: 'string', description: 'Bar interval.', enum: ['1m', '1h', '1d', '1w'] },
145
+ from: { type: 'string', description: 'Earliest bar timestamp. YYYY-MM-DD or ISO.' },
146
+ to: { type: 'string', description: 'Latest bar timestamp. YYYY-MM-DD or ISO.' },
147
+ limit: { type: 'integer', description: 'Page size.' },
148
+ cursor: { type: 'string', description: 'Opaque cursor.' },
149
+ },
150
+ required: ['signal', 'ticker', 'interval'],
151
+ },
152
+ endpoint: {
153
+ method: 'GET',
154
+ path: '/v2/signals/{signal}/{ticker}/history/{interval}',
155
+ paramLocation: { signal: 'path', ticker: 'path', interval: 'path', from: 'query', to: 'query', limit: 'query', cursor: 'query' },
156
+ },
157
+ },
158
+ {
159
+ name: 'tickerbot_list_signal_events',
160
+ description: 'Get the list of discrete firings of a boolean signal for one ticker (each time the flag went true). Newest-first, paginated.',
161
+ inputSchema: {
162
+ type: 'object',
163
+ properties: {
164
+ signal: { type: 'string', description: 'Boolean signal column.' },
165
+ ticker: { type: 'string', description: 'Symbol.' },
166
+ from: { type: 'string', description: 'Earliest event timestamp.' },
167
+ to: { type: 'string', description: 'Latest event timestamp.' },
168
+ limit: { type: 'integer', description: 'Page size.' },
169
+ cursor: { type: 'string', description: 'Opaque cursor.' },
170
+ },
171
+ required: ['signal', 'ticker'],
172
+ },
173
+ endpoint: {
174
+ method: 'GET',
175
+ path: '/v2/signals/{signal}/{ticker}/events',
176
+ paramLocation: { signal: 'path', ticker: 'path', from: 'query', to: 'query', limit: 'query', cursor: 'query' },
177
+ },
178
+ },
179
+ // ── Scan ─────────────────────────────────────────────────────────────
180
+ {
181
+ name: 'tickerbot_scan',
182
+ description: 'Run a SQL WHERE clause against the live ticker universe (or against a past trading day with `asof`). Returns matching tickers sorted by chosen column. The `q` grammar is a flat WHERE: column names from the schema, AND/OR/NOT, comparison operators, numeric/string literals. No JOIN, GROUP BY, or subqueries. Example: `gap_up AND market_cap < 2000000000 AND NOT earnings_this_week`.',
183
+ inputSchema: {
184
+ type: 'object',
185
+ properties: {
186
+ q: { type: 'string', description: 'SQL WHERE expression. Max 4000 chars. Identifiers are bare column names from the schema (e.g. above_sma_50, rsi_14, market_cap). Numeric literals fully specified (no 1.5B shorthand).' },
187
+ universe: { type: 'string', description: 'Optional universe slug (top_10, top_100, or your own).' },
188
+ asof: { type: 'string', description: 'Optional YYYY-MM-DD. Run the WHERE against historical close-of-day snapshot for this date.' },
189
+ order: { type: 'string', description: 'Sort column. Default day_change_pct.' },
190
+ dir: { type: 'string', description: 'Sort direction.', enum: ['asc', 'desc'] },
191
+ fields: { type: 'string', description: 'Comma-separated extra columns to include in each result row beyond the default set.' },
192
+ limit: { type: 'integer', description: 'Page size. Max 100. Default 50.' },
193
+ cursor: { type: 'string', description: 'Opaque cursor.' },
194
+ },
195
+ required: ['q'],
196
+ },
197
+ endpoint: {
198
+ method: 'GET',
199
+ path: '/v2/scan',
200
+ paramLocation: {
201
+ q: 'query', universe: 'query', asof: 'query',
202
+ order: 'query', dir: 'query', fields: 'query',
203
+ limit: 'query', cursor: 'query',
204
+ },
205
+ },
206
+ },
207
+ // ── Universes ────────────────────────────────────────────────────────
208
+ {
209
+ name: 'tickerbot_list_universes',
210
+ description: 'List the caller\'s saved universes (named ticker sets to scope scans). Each universe has a slug, name, and ticker count.',
211
+ inputSchema: {
212
+ type: 'object',
213
+ properties: {
214
+ limit: { type: 'integer', description: 'Page size.' },
215
+ cursor: { type: 'string', description: 'Opaque cursor.' },
216
+ },
217
+ },
218
+ endpoint: {
219
+ method: 'GET',
220
+ path: '/v2/universes',
221
+ paramLocation: { limit: 'query', cursor: 'query' },
222
+ },
223
+ },
224
+ {
225
+ name: 'tickerbot_get_universe',
226
+ description: 'Get one universe by slug, including its ticker list.',
227
+ inputSchema: {
228
+ type: 'object',
229
+ properties: {
230
+ id: { type: 'string', description: 'Universe slug.' },
231
+ },
232
+ required: ['id'],
233
+ },
234
+ endpoint: {
235
+ method: 'GET',
236
+ path: '/v2/universes/{id}',
237
+ paramLocation: { id: 'path' },
238
+ },
239
+ },
240
+ {
241
+ name: 'tickerbot_create_universe',
242
+ description: 'Create a new universe (named set of tickers) for scoping future scans. Returns the new universe with its assigned slug.',
243
+ inputSchema: {
244
+ type: 'object',
245
+ properties: {
246
+ name: { type: 'string', description: 'Human-readable name.' },
247
+ tickers: { type: 'array', description: 'List of ticker symbols.', items: { type: 'string' } },
248
+ },
249
+ required: ['name', 'tickers'],
250
+ },
251
+ endpoint: {
252
+ method: 'POST',
253
+ path: '/v2/universes',
254
+ paramLocation: { name: 'body', tickers: 'body' },
255
+ },
256
+ },
257
+ // ── Custom signals (write) ───────────────────────────────────────────
258
+ {
259
+ name: 'tickerbot_create_custom_signal',
260
+ description: 'Save a SQL WHERE expression as a named custom signal the caller can reference by name in future scans. E.g. name="oversold_with_volume", expr="rsi_14 < 30 AND volume_ratio_20d > 2".',
261
+ inputSchema: {
262
+ type: 'object',
263
+ properties: {
264
+ name: { type: 'string', description: 'Snake_case identifier for the signal (lowercase letters, digits, underscore).' },
265
+ expr: { type: 'string', description: 'SQL WHERE expression. Same grammar as scan `q`.' },
266
+ description: { type: 'string', description: 'Optional human description.' },
267
+ },
268
+ required: ['name', 'expr'],
269
+ },
270
+ endpoint: {
271
+ method: 'POST',
272
+ path: '/v2/signals',
273
+ paramLocation: { name: 'body', expr: 'body', description: 'body' },
274
+ },
275
+ },
276
+ // ── News ─────────────────────────────────────────────────────────────
277
+ {
278
+ name: 'tickerbot_search_news',
279
+ description: 'Search the news archive (back to 2015) with a SQL WHERE clause. Plan-gated: Scale+ required for the archive. Columns on news_article include `time_published`, `title`, `summary`, `source`, `source_domain`, `category`, `authors`, `topics`, `tickers` (array), `overall_sentiment_score`, `overall_sentiment_label`, `url`. To filter to one ticker use `\'NVDA\' = ANY(tickers)` or the auto-unnest alias `tk = \'NVDA\'`. Example: `q=tk=\'NVDA\' AND time_published >= NOW() - INTERVAL \'1 day\'`. Supports group_by + having for aggregation (e.g. count of articles per day).',
280
+ inputSchema: {
281
+ type: 'object',
282
+ properties: {
283
+ q: { type: 'string', description: 'SQL WHERE on news_article. Required.' },
284
+ select: { type: 'string', description: 'Comma-separated columns to include. Defaults to a slim set.' },
285
+ group_by: { type: 'string', description: 'Comma-separated columns for aggregation (e.g. `date_trunc(\'day\', time_published)`).' },
286
+ having: { type: 'string', description: 'WHERE-style filter on aggregates. Requires group_by.' },
287
+ order: { type: 'string', description: 'Sort column or SELECT alias. Default time_published (non-aggregate) or volume (aggregate).' },
288
+ dir: { type: 'string', description: 'Sort direction.', enum: ['asc', 'desc'] },
289
+ limit: { type: 'integer', description: 'Page size.' },
290
+ cursor: { type: 'string', description: 'Opaque cursor.' },
291
+ },
292
+ required: ['q'],
293
+ },
294
+ endpoint: {
295
+ method: 'GET',
296
+ path: '/v2/news/scan',
297
+ paramLocation: {
298
+ q: 'query', select: 'query', group_by: 'query', having: 'query',
299
+ order: 'query', dir: 'query', limit: 'query', cursor: 'query',
300
+ },
301
+ },
302
+ },
303
+ // ── Webhooks / subscribe (alert provisioning) ───────────────────────
304
+ // Subscribe tools let the caller wire a webhook end-to-end in chat:
305
+ // pass `target_url` to deliver to Discord/Slack/their own server;
306
+ // omit it for in-app delivery (visible in the Tickerbot dashboard).
307
+ // `cadence` is plan-gated — Hobby caps at 5m, Pro+ at 1m.
308
+ {
309
+ name: 'tickerbot_subscribe_scan',
310
+ description: 'Register a webhook that fires when matches for a scan query change. Pass `target_url` to deliver to your own URL (Discord, Slack, server endpoint); omit for in-app delivery in the dashboard. `cadence` is plan-gated (Hobby max 5m, Pro+ 1m). Use to satisfy "alert me when this happens" prompts.',
311
+ inputSchema: {
312
+ type: 'object',
313
+ properties: {
314
+ q: { type: 'string', description: 'SQL WHERE expression — same grammar as scan.' },
315
+ name: { type: 'string', description: 'Human-readable label. Defaults to a truncated version of the query.' },
316
+ universe: { type: 'string', description: 'Optional universe slug to scope the watch.' },
317
+ target_url: { type: 'string', description: 'Optional https URL to POST matches to. Omit for in-app delivery.' },
318
+ cadence: { type: 'string', description: 'Evaluation cadence.', enum: ['1m', '5m', '15m', 'hourly', 'nyse_open'] },
319
+ },
320
+ required: ['q'],
321
+ },
322
+ endpoint: {
323
+ method: 'POST',
324
+ path: '/v2/scan/subscribe',
325
+ paramLocation: { q: 'body', name: 'body', universe: 'body', target_url: 'body', cadence: 'body' },
326
+ },
327
+ },
328
+ {
329
+ name: 'tickerbot_subscribe_signal',
330
+ description: 'Register a webhook that fires when a signal turns true (booleans) or its value crosses a condition (numerics). Optional `ticker` restricts to one symbol; omit to watch the whole universe. Pass `target_url` for outbound delivery or omit for in-app.',
331
+ inputSchema: {
332
+ type: 'object',
333
+ properties: {
334
+ signal: { type: 'string', description: 'Column name (e.g. golden_cross_today, rsi_14).' },
335
+ ticker: { type: 'string', description: 'Optional ticker to restrict the watch to one symbol.' },
336
+ universe: { type: 'string', description: 'Optional universe slug.' },
337
+ condition: { type: 'string', description: 'Required for numerics: single bound like ">70" or "<=200". Ignored for booleans.' },
338
+ name: { type: 'string', description: 'Human-readable label.' },
339
+ target_url: { type: 'string', description: 'Optional https URL for delivery; omit for in-app.' },
340
+ cadence: { type: 'string', description: 'Evaluation cadence.', enum: ['1m', '5m', '15m', 'hourly', 'nyse_open'] },
341
+ },
342
+ required: ['signal'],
343
+ },
344
+ endpoint: {
345
+ method: 'POST',
346
+ path: '/v2/signals/{signal}/subscribe',
347
+ paramLocation: { signal: 'path', ticker: 'body', universe: 'body', condition: 'body', name: 'body', target_url: 'body', cadence: 'body' },
348
+ },
349
+ },
350
+ {
351
+ name: 'tickerbot_subscribe_ticker',
352
+ description: 'Register a webhook that fires when one ticker matches a condition. `condition` is a SQL WHERE-clause fragment scoped to that ticker (e.g. "rsi_14 > 70 AND relative_volume > 2"). Pass `target_url` for outbound delivery or omit for in-app.',
353
+ inputSchema: {
354
+ type: 'object',
355
+ properties: {
356
+ ticker: { type: 'string', description: 'Symbol.' },
357
+ condition: { type: 'string', description: 'SQL WHERE fragment evaluated for this ticker (e.g. "rsi_14 > 70 AND gap_up").' },
358
+ name: { type: 'string', description: 'Human-readable label.' },
359
+ target_url: { type: 'string', description: 'Optional https URL for delivery; omit for in-app.' },
360
+ cadence: { type: 'string', description: 'Evaluation cadence.', enum: ['1m', '5m', '15m', 'hourly', 'nyse_open'] },
361
+ },
362
+ required: ['ticker', 'condition'],
363
+ },
364
+ endpoint: {
365
+ method: 'POST',
366
+ path: '/v2/tickers/{ticker}/subscribe',
367
+ paramLocation: { ticker: 'path', condition: 'body', name: 'body', target_url: 'body', cadence: 'body' },
368
+ },
369
+ },
370
+ {
371
+ name: 'tickerbot_list_webhooks',
372
+ description: 'List the caller\'s active webhook subscriptions (rules created via the subscribe tools).',
373
+ inputSchema: {
374
+ type: 'object',
375
+ properties: {
376
+ limit: { type: 'integer', description: 'Page size.' },
377
+ cursor: { type: 'string', description: 'Opaque cursor.' },
378
+ },
379
+ },
380
+ endpoint: { method: 'GET', path: '/v2/webhooks', paramLocation: { limit: 'query', cursor: 'query' } },
381
+ },
382
+ {
383
+ name: 'tickerbot_delete_webhook',
384
+ description: 'Delete a webhook subscription by id. Use after listing webhooks when the user wants to remove an alert.',
385
+ inputSchema: {
386
+ type: 'object',
387
+ properties: {
388
+ id: { type: 'string', description: 'Webhook id (looks like `wh_…`).' },
389
+ },
390
+ required: ['id'],
391
+ },
392
+ endpoint: { method: 'DELETE', path: '/v2/webhooks/{id}', paramLocation: { id: 'path' } },
393
+ },
394
+ ];
395
+ export function findTool(name) {
396
+ return tools.find((t) => t.name === name);
397
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@tickerbot/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for the Tickerbot Signals API. Lets Claude, ChatGPT, Cursor, and other MCP-compatible clients run scans, fetch tickers, and query signals in natural language.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "tickerbot-mcp": "bin/tickerbot-mcp.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "bin",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "prepublishOnly": "npm run build",
18
+ "dev": "tsc --watch"
19
+ },
20
+ "engines": {
21
+ "node": ">=18.0.0"
22
+ },
23
+ "keywords": [
24
+ "mcp",
25
+ "model-context-protocol",
26
+ "tickerbot",
27
+ "stocks",
28
+ "signals",
29
+ "claude",
30
+ "ai"
31
+ ],
32
+ "author": "Tickerbot",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/tickerbot/tickerbot-mcp.git"
37
+ },
38
+ "homepage": "https://tickerbot.io/mcp-server",
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^20.0.0",
44
+ "typescript": "^5.4.0"
45
+ }
46
+ }