@tickerbot/mcp-server 0.3.1 → 0.4.1

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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Model Context Protocol server for [Tickerbot](https://tickerbot.io) — the stock market, in SQL. Install in Claude, Cursor, VS Code Copilot, and other MCP-compatible clients to scan ~14,500 US-listed equities plus a curated set of rates, FX and crypto series, replay any day, or subscribe a query — across 421+ signal columns.
4
4
 
5
+ This package is a thin stdio bridge to the hosted Tickerbot MCP server at `https://api.tickerbot.io/mcp`. On start it reads the tool list from that server with your key, and it forwards every tool call there and returns the result unchanged. A tool added or changed on Tickerbot reaches you the next time your client starts the server, with no package update. Each request to the hosted server carries an `X-Tickerbot-MCP-Client: @tickerbot/mcp-server/<version>` header, so Tickerbot's call log can tell this package's traffic from other MCP clients; it is a label only.
6
+
5
7
  ## Install
6
8
 
7
9
  You need a Tickerbot API key. Get one from your [dashboard](https://tickerbot.io/dashboard).
@@ -66,7 +68,7 @@ Add to `.vscode/mcp.json` in your workspace:
66
68
 
67
69
  ## Tools
68
70
 
69
- 33 tools covering scans, signals, tickers (state, history, bars, coverage, ETF holdings), events and their kinds, news, universes and webhook subscriptions (scan, signal, ticker, events and news). Bars and History answer `data`: one symbol gives its rows, a comma list gives them keyed by symbol.
71
+ The tools are whatever the hosted server lists when the bridge starts. At the time of this release that is 35 tools covering scans, signals, tickers (state, history, bars, coverage, ETF holdings), events and their kinds, news, universes and webhook subscriptions (scan, signal, ticker, events and news). History answers `series` keyed by symbol, one flat row per grain step, for one ticker or a comma list. `tickerbot_list_events` and `tickerbot_search_news` were renamed `tickerbot_query_events` and `tickerbot_query_news`; the old names still work, as deprecated tools, until 2027-01-12. An argument a tool does not declare is sent to the API, which answers `unknown_parameter` for it.
70
72
 
71
73
  | Tool | Endpoint |
72
74
  | --- | --- |
@@ -85,27 +87,33 @@ Add to `.vscode/mcp.json` in your workspace:
85
87
  | `tickerbot_get_bars` | `GET /v2/tickers/{ticker}/bars/{interval}` |
86
88
  | `tickerbot_get_ticker_coverage` | `GET /v2/tickers/{ticker}/coverage` |
87
89
  | `tickerbot_get_etf_holdings` | `GET /v2/tickers/{ticker}/holdings` |
88
- | `tickerbot_list_events` | `GET /v2/events` |
90
+ | `tickerbot_query_events` | `GET /v2/events` |
91
+ | `tickerbot_list_events` | `GET /v2/events` (deprecated: use `tickerbot_query_events`; works until 2027-01-12) |
89
92
  | `tickerbot_list_event_kinds` | `GET /v2/events/kinds` |
90
93
  | `tickerbot_subscribe_events` | `POST /v2/events/subscribe` |
91
- | `tickerbot_search_news` | `GET /v2/news` |
94
+ | `tickerbot_query_news` | `GET /v2/news` |
95
+ | `tickerbot_search_news` | `GET /v2/news` (deprecated: use `tickerbot_query_news`; works until 2027-01-12) |
92
96
  | `tickerbot_subscribe_news` | `POST /v2/news/subscribe` |
93
97
  | `tickerbot_list_universes` | `GET /v2/universes` |
94
98
  | `tickerbot_create_universe` | `POST /v2/universes` |
95
- | `tickerbot_get_universe` | `GET /v2/universes/{id}` |
96
- | `tickerbot_update_universe` | `PATCH /v2/universes/{id}` |
97
- | `tickerbot_delete_universe` | `DELETE /v2/universes/{id}` |
99
+ | `tickerbot_get_universe` | `GET /v2/universes/{universe}` |
100
+ | `tickerbot_update_universe` | `PATCH /v2/universes/{universe}` |
101
+ | `tickerbot_delete_universe` | `DELETE /v2/universes/{universe}` |
98
102
  | `tickerbot_list_webhooks` | `GET /v2/webhooks` |
99
- | `tickerbot_get_webhook` | `GET /v2/webhooks/{id}` |
100
- | `tickerbot_update_webhook` | `PATCH /v2/webhooks/{id}` |
101
- | `tickerbot_delete_webhook` | `DELETE /v2/webhooks/{id}` |
102
- | `tickerbot_list_webhook_deliveries` | `GET /v2/webhooks/{id}/deliveries` |
103
- | `tickerbot_redeliver_webhook_delivery` | `POST /v2/webhooks/{id}/deliveries/{delivery_id}/redeliver` |
104
- | `tickerbot_enable_webhook` | `POST /v2/webhooks/{id}/enable` |
105
- | `tickerbot_test_webhook` | `POST /v2/webhooks/{id}/test` |
103
+ | `tickerbot_get_webhook` | `GET /v2/webhooks/{webhook}` |
104
+ | `tickerbot_update_webhook` | `PATCH /v2/webhooks/{webhook}` |
105
+ | `tickerbot_enable_webhook` | `PATCH /v2/webhooks/{webhook}` (deprecated: use `tickerbot_update_webhook` with `enabled: true`; works until 2027-01-12) |
106
+ | `tickerbot_delete_webhook` | `DELETE /v2/webhooks/{webhook}` |
107
+ | `tickerbot_list_webhook_deliveries` | `GET /v2/webhooks/{webhook}/deliveries` |
108
+ | `tickerbot_redeliver_webhook_delivery` | `POST /v2/webhooks/{webhook}/deliveries/{delivery}/redeliver` |
109
+ | `tickerbot_test_webhook` | `POST /v2/webhooks/{webhook}/test` |
106
110
 
107
111
  Strategies are not exposed via MCP — see the [REST API docs](https://tickerbot.io/api) for those.
108
112
 
113
+ ### When the hosted server can't be reached
114
+
115
+ If `api.tickerbot.io/mcp` does not answer at start, the bridge serves the tool list bundled with this version, runs each call itself against the REST API, and writes a warning to stderr. The bundled list can be behind the server's. The bridge retries the server at most once a minute (on `tools/list`) and switches back when it answers. With no `TICKERBOT_API_KEY` set it does not contact the server: it lists the bundled tools and every call answers with how to set a key.
116
+
109
117
  ## Examples
110
118
 
111
119
  Open Claude and try:
@@ -122,12 +130,20 @@ Open Claude and try:
122
130
 
123
131
  | Env var | Default | Purpose |
124
132
  | --- | --- | --- |
125
- | `TICKERBOT_API_KEY` | *(required)* | Your API key. Sent as `Authorization: Bearer <key>` on every call. |
126
- | `TICKERBOT_API_URL` | `https://api.tickerbot.io` | Override the API base URL (for staging or self-hosted). |
133
+ | `TICKERBOT_API_KEY` | *(required)* | Your API key. Sent as `Authorization: Bearer <key>` to the hosted MCP server (and to the REST API in fallback). |
134
+ | `TICKERBOT_API_URL` | `https://api.tickerbot.io` | Override the base URL. The bridge connects to `<base>/mcp`; the fallback calls `<base>/v2/...`. |
127
135
 
128
136
  ## Remote install
129
137
 
130
- 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.
138
+ Clients that connect to an MCP server by URL (Claude.ai web/mobile, ChatGPT, and most desktop and IDE clients) can use the hosted server at `https://api.tickerbot.io/mcp` directly and sign in with OAuth, no package needed. See [tickerbot.io/docs/mcp](https://tickerbot.io/docs/mcp) for setup.
139
+
140
+ ## Development
141
+
142
+ ```bash
143
+ npm install
144
+ npm test # builds, then runs the bridge against a stubbed hosted server
145
+ node scripts/regen-tools.mjs # refresh the bundled fallback list from ../tickerbot (optional)
146
+ ```
131
147
 
132
148
  ## License
133
149
 
package/dist/handler.d.ts CHANGED
@@ -4,9 +4,7 @@ export interface RunToolOptions {
4
4
  baseUrl?: string;
5
5
  /** Optional user-agent appended to identify the MCP server. */
6
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. */
7
+ /** Override the tool lookup (defaults to the bundled snapshot). */
10
8
  findTool?: (name: string) => ToolDef | undefined;
11
9
  }
12
10
  export declare class ToolError extends Error {
package/dist/handler.js CHANGED
@@ -1,10 +1,10 @@
1
- // Dispatches an MCP tool call to the Tickerbot REST API.
1
+ // Dispatches an MCP tool call to the Tickerbot REST API, against the
2
+ // bundled snapshot (src/tools.ts).
2
3
  //
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.
4
+ // Fallback only (TB-740): the stdio bridge (src/index.ts) forwards calls to
5
+ // the hosted MCP server and uses this runner only while that server cannot
6
+ // be reached, or when no key is set. Mirrors main_service/mcp/handler.js,
7
+ // which runs every call the hosted server answers.
8
8
  import { findTool as bakedFindTool } from './tools.js';
9
9
  const DEFAULT_BASE_URL = 'https://api.tickerbot.io';
10
10
  export class ToolError extends Error {
@@ -113,14 +113,28 @@ function buildRequest(tool, args, baseUrl) {
113
113
  const body = {};
114
114
  let bodyHasContent = false;
115
115
  for (const [key, value] of Object.entries(args)) {
116
- if (value === undefined || value === null || value === '')
117
- continue;
118
116
  // Declared params first; then the older names the route still accepts
119
- // (endpoint.accepts, TB-570), sent where the route reads them. Anything
120
- // else is ignored. Mirrors main_service/mcp/handler.js.
117
+ // (endpoint.accepts, TB-570), sent where the route reads them. The name
118
+ // is judged BEFORE the blank skip (TB-749): any other argument, blank
119
+ // included, is forwarded so the API answers 400 `unknown_parameter`
120
+ // instead of the call silently succeeding without it. A query param on
121
+ // GET, a body field otherwise.
121
122
  const where = tool.endpoint.paramLocation[key] ?? tool.endpoint.accepts?.[key];
122
- if (!where)
123
- continue; // unknown param, ignore
123
+ if (!where) {
124
+ if (value === undefined)
125
+ continue;
126
+ if (tool.endpoint.method === 'GET') {
127
+ query.append(key, value === null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value));
128
+ }
129
+ else {
130
+ body[key] = value;
131
+ bodyHasContent = true;
132
+ }
133
+ continue;
134
+ }
135
+ // A blank DECLARED param means "not sent".
136
+ if (value === undefined || value === null || value === '')
137
+ continue;
124
138
  if (where === 'path') {
125
139
  // An older path-param name fills the slot the docs name it by
126
140
  // (endpoint.pathAliases, TB-570 step 3: `id` → {universe}).
@@ -138,11 +152,6 @@ function buildRequest(tool, args, baseUrl) {
138
152
  bodyHasContent = true;
139
153
  }
140
154
  }
141
- // Fixed query values the tool always sends (endpoint.fixedQuery, TB-570
142
- // step 3: `api_version=2027-01-12` on Bars and History, so they answer
143
- // `data` — one symbol: its rows; a comma list: keyed by symbol).
144
- for (const [k, v] of Object.entries(tool.endpoint.fixedQuery ?? {}))
145
- query.set(k, String(v));
146
155
  // Fixed body fields (endpoint.fixedBody: a deprecated tool built from its
147
156
  // successor, tickerbot_enable_webhook = PATCH `enabled: true`).
148
157
  for (const [k, v] of Object.entries(tool.endpoint.fixedBody ?? {})) {
@@ -0,0 +1,52 @@
1
+ export interface HostedTool {
2
+ name: string;
3
+ description?: string;
4
+ inputSchema: {
5
+ type: 'object';
6
+ [k: string]: unknown;
7
+ };
8
+ outputSchema?: {
9
+ type: 'object';
10
+ [k: string]: unknown;
11
+ };
12
+ [k: string]: unknown;
13
+ }
14
+ export interface HostedCallResult {
15
+ content: unknown[];
16
+ structuredContent?: Record<string, unknown>;
17
+ isError?: boolean;
18
+ [k: string]: unknown;
19
+ }
20
+ /** The server could not be reached or did not answer with JSON-RPC. */
21
+ export declare class HostedUnavailable extends Error {
22
+ readonly status?: number | undefined;
23
+ constructor(message: string, status?: number | undefined);
24
+ }
25
+ /** The server answered with a JSON-RPC error object. */
26
+ export declare class HostedRpcError extends Error {
27
+ readonly code: number;
28
+ readonly data?: unknown | undefined;
29
+ constructor(code: number, message: string, data?: unknown | undefined);
30
+ }
31
+ export interface HostedClientOptions {
32
+ url: string;
33
+ apiKey: string;
34
+ userAgent: string;
35
+ clientVersion: string;
36
+ /** Sent as X-Tickerbot-MCP-Client: `@tickerbot/mcp-server/<version>`. */
37
+ clientLabel: string;
38
+ }
39
+ export declare class HostedClient {
40
+ private readonly opts;
41
+ private nextId;
42
+ private sessionId;
43
+ private protocolVersion;
44
+ constructor(opts: HostedClientOptions);
45
+ /** initialize + notifications/initialized + tools/list. */
46
+ connect(timeoutMs: number): Promise<HostedTool[]>;
47
+ listTools(timeoutMs: number): Promise<HostedTool[]>;
48
+ callTool(name: string, args: Record<string, unknown>, timeoutMs: number): Promise<HostedCallResult>;
49
+ private notify;
50
+ private request;
51
+ private post;
52
+ }
package/dist/hosted.js ADDED
@@ -0,0 +1,158 @@
1
+ // Client for the hosted Tickerbot MCP server ({baseUrl}/mcp, served by
2
+ // main_service/endpoints/mcp.js).
3
+ //
4
+ // The hosted server speaks MCP over Streamable HTTP in its single-JSON form:
5
+ // every JSON-RPC message is one POST to /mcp with `Authorization: Bearer
6
+ // <key>`, and the answer is one JSON body (a notification gets 202 and no
7
+ // body). It keeps no session and never streams, so this client is a plain
8
+ // fetch per message. It still behaves as a spec client (Accept lists
9
+ // text/event-stream, an mcp-session-id is echoed back if one is issued, an
10
+ // SSE answer is read) so a later server change does not break old installs.
11
+ //
12
+ // No key, or a key the server refuses outright, answers HTTP 401 on every
13
+ // request. A key the server accepts at the door but the REST API refuses
14
+ // (malformed, revoked) passes initialize and tools/list; its tools/call
15
+ // comes back as an isError result carrying the REST 401 text, which the
16
+ // bridge returns unchanged.
17
+ /** The server could not be reached or did not answer with JSON-RPC. */
18
+ export class HostedUnavailable extends Error {
19
+ status;
20
+ constructor(message, status) {
21
+ super(message);
22
+ this.status = status;
23
+ this.name = 'HostedUnavailable';
24
+ }
25
+ }
26
+ /** The server answered with a JSON-RPC error object. */
27
+ export class HostedRpcError extends Error {
28
+ code;
29
+ data;
30
+ constructor(code, message, data) {
31
+ super(message);
32
+ this.code = code;
33
+ this.data = data;
34
+ this.name = 'HostedRpcError';
35
+ }
36
+ }
37
+ const CLIENT_PROTOCOL_VERSION = '2025-06-18';
38
+ export class HostedClient {
39
+ opts;
40
+ nextId = 1;
41
+ sessionId;
42
+ protocolVersion;
43
+ constructor(opts) {
44
+ this.opts = opts;
45
+ }
46
+ /** initialize + notifications/initialized + tools/list. */
47
+ async connect(timeoutMs) {
48
+ const init = (await this.request('initialize', {
49
+ protocolVersion: CLIENT_PROTOCOL_VERSION,
50
+ capabilities: {},
51
+ clientInfo: { name: this.opts.userAgent, version: this.opts.clientVersion },
52
+ }, timeoutMs));
53
+ if (init && typeof init.protocolVersion === 'string')
54
+ this.protocolVersion = init.protocolVersion;
55
+ await this.notify('notifications/initialized', timeoutMs);
56
+ return this.listTools(timeoutMs);
57
+ }
58
+ async listTools(timeoutMs) {
59
+ const result = (await this.request('tools/list', {}, timeoutMs));
60
+ if (!result || !Array.isArray(result.tools) || result.tools.length === 0) {
61
+ throw new HostedUnavailable('tools/list returned no tools');
62
+ }
63
+ return result.tools;
64
+ }
65
+ async callTool(name, args, timeoutMs) {
66
+ const result = (await this.request('tools/call', { name, arguments: args }, timeoutMs));
67
+ if (!result || !Array.isArray(result.content)) {
68
+ throw new HostedUnavailable('tools/call returned no content');
69
+ }
70
+ return result;
71
+ }
72
+ async notify(method, timeoutMs) {
73
+ await this.post({ jsonrpc: '2.0', method }, timeoutMs);
74
+ }
75
+ async request(method, params, timeoutMs) {
76
+ const id = this.nextId++;
77
+ const message = await this.post({ jsonrpc: '2.0', id, method, params }, timeoutMs, id);
78
+ if (!message)
79
+ throw new HostedUnavailable(`${method}: no response`);
80
+ if (message.error) {
81
+ const e = message.error;
82
+ throw new HostedRpcError(typeof e.code === 'number' ? e.code : -32603, e.message ?? 'Unknown error', e.data);
83
+ }
84
+ return message.result;
85
+ }
86
+ async post(body, timeoutMs, id) {
87
+ const headers = {
88
+ Authorization: `Bearer ${this.opts.apiKey}`,
89
+ 'Content-Type': 'application/json',
90
+ Accept: 'application/json, text/event-stream',
91
+ 'User-Agent': this.opts.userAgent,
92
+ // Names this client on every request (TB-740). The hosted server
93
+ // forwards it on its REST calls so the call log can tell npm-bridge
94
+ // usage from other clients; it must match `@tickerbot/mcp-server/<semver>`.
95
+ 'X-Tickerbot-MCP-Client': this.opts.clientLabel,
96
+ };
97
+ if (this.sessionId)
98
+ headers['mcp-session-id'] = this.sessionId;
99
+ if (this.protocolVersion)
100
+ headers['mcp-protocol-version'] = this.protocolVersion;
101
+ let res;
102
+ try {
103
+ res = await fetch(this.opts.url, {
104
+ method: 'POST',
105
+ headers,
106
+ body: JSON.stringify(body),
107
+ signal: AbortSignal.timeout(timeoutMs),
108
+ });
109
+ }
110
+ catch (err) {
111
+ const reason = err instanceof Error ? (err.name === 'TimeoutError' ? `timed out after ${timeoutMs} ms` : err.message) : String(err);
112
+ throw new HostedUnavailable(`${this.opts.url}: ${reason}`);
113
+ }
114
+ const session = res.headers.get('mcp-session-id');
115
+ if (session)
116
+ this.sessionId = session;
117
+ if (res.status === 202)
118
+ return null;
119
+ if (!res.ok) {
120
+ const text = await res.text().catch(() => '');
121
+ throw new HostedUnavailable(`${this.opts.url} answered ${res.status}${text ? `: ${text.slice(0, 300)}` : ''}`, res.status);
122
+ }
123
+ if (id === undefined) {
124
+ await res.body?.cancel().catch(() => { });
125
+ return null;
126
+ }
127
+ const type = res.headers.get('content-type') ?? '';
128
+ const text = await res.text();
129
+ const candidates = type.includes('text/event-stream') ? parseSse(text) : [parseJson(text)];
130
+ for (const c of candidates.flatMap((m) => (Array.isArray(m) ? m : [m]))) {
131
+ if (c && typeof c === 'object' && c.id === id)
132
+ return c;
133
+ }
134
+ throw new HostedUnavailable(`${this.opts.url}: no JSON-RPC response for id ${id}`);
135
+ }
136
+ }
137
+ function parseJson(text) {
138
+ try {
139
+ return JSON.parse(text);
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
145
+ // Each SSE event's data lines joined, parsed as JSON.
146
+ function parseSse(text) {
147
+ const out = [];
148
+ for (const event of text.split(/\r?\n\r?\n/)) {
149
+ const data = event
150
+ .split(/\r?\n/)
151
+ .filter((l) => l.startsWith('data:'))
152
+ .map((l) => l.slice(5).replace(/^ /, ''))
153
+ .join('\n');
154
+ if (data)
155
+ out.push(parseJson(data));
156
+ }
157
+ return out;
158
+ }
package/dist/index.js CHANGED
@@ -1,82 +1,117 @@
1
- // Stdio MCP server entrypoint.
1
+ // Stdio MCP server entrypoint: a thin bridge to the hosted Tickerbot MCP
2
+ // server (TB-740).
2
3
  //
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.
4
+ // Launched by the user's MCP client (Claude Desktop, Claude Code, Cursor,
5
+ // VS Code Copilot, etc.) as a subprocess that talks JSON-RPC over
6
+ // stdin/stdout. The key comes from TICKERBOT_API_KEY.
6
7
  //
7
- // Reads the API key from TICKERBOT_API_KEY and forwards every tool call
8
- // through src/handler.ts → api.tickerbot.io.
8
+ // On start it connects to {baseUrl}/mcp (https://api.tickerbot.io/mcp) with
9
+ // the key, and the tool list it serves is the hosted server's tools/list,
10
+ // verbatim. Every tools/call is forwarded there and its result returned
11
+ // unchanged. So a tool added or changed on main_service reaches npm users on
12
+ // their client's next launch, with no npm publish.
9
13
  //
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.
14
+ // Fallback: if the hosted server cannot be reached at start, the bridge
15
+ // serves the bundled snapshot (src/tools.ts) and runs calls itself against
16
+ // the REST API (src/handler.ts), and says so on stderr. While in fallback it
17
+ // retries the hosted server on tools/list, at most once a minute, and
18
+ // switches over when it answers.
19
+ //
20
+ // With no key the bridge does not contact the server (it would answer 401):
21
+ // it serves the snapshot, and every call answers the missing-key message.
16
22
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
17
23
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
18
- import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
24
+ import { CallToolRequestSchema, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
19
25
  import { tools as bakedTools } from './tools.js';
20
26
  import { runTool, ToolError } from './handler.js';
27
+ import { HostedClient, HostedRpcError } from './hosted.js';
21
28
  import { createRequire } from 'node:module';
22
29
  // package.json is the one place the version lives; the hardcoded '0.1.0'
23
- // this replaced was reported to every client through 0.2.1.
24
- const { name: SERVER_NAME, version: SERVER_VERSION } = createRequire(import.meta.url)('../package.json');
30
+ // this replaced was reported to every client through 0.2.1. The mcpb bundle
31
+ // is CJS with no import.meta.url and no package.json beside it, so
32
+ // scripts/build-mcpb.sh inlines the two fields as TICKERBOT_MCP_PACKAGE.
33
+ const { name: SERVER_NAME, version: SERVER_VERSION } = (process.env.TICKERBOT_MCP_PACKAGE
34
+ ? JSON.parse(process.env.TICKERBOT_MCP_PACKAGE)
35
+ : createRequire(import.meta.url)('../package.json'));
25
36
  const DEFAULT_BASE_URL = 'https://api.tickerbot.io';
26
- const DISCOVERY_TIMEOUT_MS = 3000;
27
- async function fetchLiveCatalog(baseUrl) {
28
- const url = `${baseUrl}/mcp/tools`;
29
- try {
30
- const controller = new AbortController();
31
- const timer = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS);
32
- const res = await fetch(url, {
33
- headers: { Accept: 'application/json', 'User-Agent': '@tickerbot/mcp-server' },
34
- signal: controller.signal,
35
- });
36
- clearTimeout(timer);
37
- if (!res.ok) {
38
- process.stderr.write(`[tickerbot-mcp] discovery: ${url} returned ${res.status}; using baked snapshot\n`);
39
- return null;
40
- }
41
- const body = (await res.json());
42
- if (!Array.isArray(body.tools) || body.tools.length === 0) {
43
- process.stderr.write(`[tickerbot-mcp] discovery: malformed response from ${url}; using baked snapshot\n`);
44
- return null;
45
- }
46
- return body.tools;
47
- }
48
- catch (err) {
49
- const message = err instanceof Error ? err.message : String(err);
50
- process.stderr.write(`[tickerbot-mcp] discovery: ${url} failed (${message}); using baked snapshot\n`);
51
- return null;
52
- }
37
+ const CONNECT_TIMEOUT_MS = Number(process.env.TICKERBOT_MCP_CONNECT_TIMEOUT_MS) || 5000;
38
+ const CALL_TIMEOUT_MS = 120_000;
39
+ const RETRY_INTERVAL_MS = 60_000;
40
+ const USER_AGENT = `${SERVER_NAME}/${SERVER_VERSION}`;
41
+ function log(line) {
42
+ process.stderr.write(`[tickerbot-mcp] ${line}\n`);
43
+ }
44
+ function snapshotTools() {
45
+ return bakedTools.map((t) => ({
46
+ name: t.name,
47
+ description: t.description,
48
+ inputSchema: t.inputSchema,
49
+ }));
53
50
  }
54
51
  async function main() {
55
52
  const apiKey = process.env.TICKERBOT_API_KEY;
56
53
  if (!apiKey) {
57
- 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`);
54
+ log(`TICKERBOT_API_KEY is not set. Get a key from https://tickerbot.io/dashboard and add it to your MCP client config (env block).`);
55
+ }
56
+ const baseUrl = (process.env.TICKERBOT_API_URL || DEFAULT_BASE_URL).replace(/\/+$/, '');
57
+ const hostedUrl = `${baseUrl}/mcp`;
58
+ const hosted = apiKey
59
+ ? new HostedClient({ url: hostedUrl, apiKey, userAgent: USER_AGENT, clientVersion: SERVER_VERSION, clientLabel: `${SERVER_NAME}/${SERVER_VERSION}` })
60
+ : null;
61
+ // 'hosted': tools and calls go to {baseUrl}/mcp. 'snapshot': the bundled
62
+ // fallback, calls run here against the REST API.
63
+ let mode = 'snapshot';
64
+ let activeTools = snapshotTools();
65
+ let lastAttempt = 0;
66
+ async function tryHosted() {
67
+ if (!hosted)
68
+ return false;
69
+ lastAttempt = Date.now();
70
+ try {
71
+ activeTools = await hosted.connect(CONNECT_TIMEOUT_MS);
72
+ mode = 'hosted';
73
+ return true;
74
+ }
75
+ catch (err) {
76
+ const message = err instanceof Error ? err.message : String(err);
77
+ log(`WARNING: hosted MCP server unreachable (${message}). Serving the bundled snapshot of ${bakedTools.length} tools, which may be out of date; retrying at most once a minute.`);
78
+ return false;
79
+ }
58
80
  }
59
- const baseUrl = process.env.TICKERBOT_API_URL || DEFAULT_BASE_URL;
60
- // Fetch the live catalog before announcing tools. The handshake (`initialize`
61
- // + `tools/list`) happens after server.connect(), so we have a window to
62
- // populate `activeTools` with the server's current view.
63
- const live = await fetchLiveCatalog(baseUrl);
64
- const activeTools = live ?? bakedTools;
65
- const catalogSource = live ? 'live' : 'snapshot';
66
- const toolMap = new Map(activeTools.map((t) => [t.name, t]));
67
- const findTool = (name) => toolMap.get(name);
81
+ // Connect before announcing tools. The client's initialize + tools/list
82
+ // arrive after server.connect(), so they always see the result.
83
+ await tryHosted();
68
84
  const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
69
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
70
- tools: activeTools.map((t) => ({
71
- name: t.name,
72
- description: t.description,
73
- inputSchema: t.inputSchema,
74
- })),
75
- }));
85
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
86
+ if (mode === 'snapshot' && hosted && Date.now() - lastAttempt >= RETRY_INTERVAL_MS) {
87
+ if (await tryHosted())
88
+ log(`hosted MCP server reachable again; serving its ${activeTools.length} tools`);
89
+ }
90
+ return { tools: activeTools };
91
+ });
76
92
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
77
93
  const { name, arguments: args } = req.params;
94
+ if (mode === 'hosted' && hosted) {
95
+ try {
96
+ return await hosted.callTool(name, (args ?? {}), CALL_TIMEOUT_MS);
97
+ }
98
+ catch (err) {
99
+ if (err instanceof HostedRpcError)
100
+ throw new McpError(err.code, err.message, err.data);
101
+ // Not retried locally: the call may have reached the server (a POST
102
+ // that creates a webhook must not run twice).
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ return {
105
+ content: [{ type: 'text', text: `Tickerbot MCP server unreachable: ${message}` }],
106
+ isError: true,
107
+ };
108
+ }
109
+ }
78
110
  try {
79
- const result = await runTool(name, (args ?? {}), apiKey ?? '', { baseUrl, findTool });
111
+ const result = await runTool(name, (args ?? {}), apiKey ?? '', {
112
+ baseUrl,
113
+ userAgent: USER_AGENT,
114
+ });
80
115
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
81
116
  }
82
117
  catch (err) {
@@ -93,7 +128,7 @@ async function main() {
93
128
  });
94
129
  const transport = new StdioServerTransport();
95
130
  await server.connect(transport);
96
- process.stderr.write(`[tickerbot-mcp] ${SERVER_NAME} v${SERVER_VERSION} ready (${activeTools.length} tools, catalog: ${catalogSource})\n`);
131
+ log(`${SERVER_NAME} v${SERVER_VERSION} ready (${activeTools.length} tools, catalog: ${mode === 'hosted' ? hostedUrl : 'bundled snapshot'})`);
97
132
  }
98
133
  function formatToolError(err) {
99
134
  const parts = [err.message];
@@ -108,6 +143,6 @@ function formatToolError(err) {
108
143
  return parts.join('\n');
109
144
  }
110
145
  main().catch((err) => {
111
- process.stderr.write(`[tickerbot-mcp] fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
146
+ log(`fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
112
147
  process.exit(1);
113
148
  });
package/dist/tools.d.ts CHANGED
@@ -19,9 +19,6 @@ export interface ToolDef {
19
19
  /** An older path-param name -> the slot the docs name it by (TB-570
20
20
  * step 3: `id` fills {universe} / {webhook}, `delivery_id` {delivery}). */
21
21
  pathAliases?: Record<string, string>;
22
- /** Query values every call sends (TB-570 step 3: `api_version`, so
23
- * Bars and History answer the `data` shape the descriptions name). */
24
- fixedQuery?: Record<string, string>;
25
22
  /** Body fields every call sends (a deprecated tool built from its
26
23
  * successor: tickerbot_enable_webhook is PATCH `enabled: true`). */
27
24
  fixedBody?: Record<string, unknown>;
@@ -32,6 +29,8 @@ interface JsonSchemaProp {
32
29
  description: string;
33
30
  enum?: readonly string[];
34
31
  default?: unknown;
32
+ /** A regex the value must match (an id form, a date). */
33
+ pattern?: string;
35
34
  items?: {
36
35
  type: string;
37
36
  };