@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 +32 -16
- package/dist/handler.d.ts +1 -3
- package/dist/handler.js +26 -17
- package/dist/hosted.d.ts +52 -0
- package/dist/hosted.js +158 -0
- package/dist/index.js +97 -62
- package/dist/tools.d.ts +2 -3
- package/dist/tools.js +411 -263
- package/package.json +3 -2
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
|
-
|
|
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
|
-
| `
|
|
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
|
-
| `
|
|
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/{
|
|
96
|
-
| `tickerbot_update_universe` | `PATCH /v2/universes/{
|
|
97
|
-
| `tickerbot_delete_universe` | `DELETE /v2/universes/{
|
|
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/{
|
|
100
|
-
| `tickerbot_update_webhook` | `PATCH /v2/webhooks/{
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `tickerbot_test_webhook` | `POST /v2/webhooks/{
|
|
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>`
|
|
126
|
-
| `TICKERBOT_API_URL` | `https://api.tickerbot.io` | Override the
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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.
|
|
120
|
-
//
|
|
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
|
-
|
|
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 ?? {})) {
|
package/dist/hosted.d.ts
ADDED
|
@@ -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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 ?? '', {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|