@coinrithm/mcp-trading 0.7.18 → 0.7.20

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.
@@ -50,37 +50,37 @@ const PRESETS = {
50
50
  modelFail: 5,
51
51
  },
52
52
  };
53
- const THESIS_BODY = `# Momentum Futures — strategy
54
-
55
- You operate a CoinRithm **paper-trading** futures account (50,000 virtual mUSD).
56
- Everything here is simulated; it is not financial advice and never touches real
57
- money. Edit this prose freely (any language) — it is your agent's borders.
58
-
59
- ## Each cycle
60
-
61
- 1. Ground yourself: read your portfolio and open positions first. Never assume
62
- balances or what is already open.
63
- 2. Scan the watchlist. A candidate is a coin whose short and medium momentum
64
- agree (both up, or both down) and is not already an open position.
65
- 3. Pick the strongest candidate and commit when the read is clear — even a
66
- moderate-confidence one — sized small with a stop. Skip only when the signals
67
- contradict or the data is stale; a quiet tape where your edge is still real
68
- is an act, not a skip.
69
- 4. Quote before you open. Read the liquidation price and confirm it is sane. If
70
- the quote is not eligible, relay the reason and stop.
71
- 5. Open small and protected: enter in the trend direction and set a stop-loss at
72
- open. Place the take-profit a touch wider than the stop.
73
- 6. Stay in sync: poll your trades for any stop / take-profit / liquidation that
74
- fired while you were not looking, and react to what actually happened.
75
-
76
- The hard caps (leverage, margin, watchlist) live in the config blocks above and
53
+ const THESIS_BODY = `# Momentum Futures — strategy
54
+
55
+ You operate a CoinRithm **paper-trading** futures account (50,000 virtual mUSD).
56
+ Everything here is simulated; it is not financial advice and never touches real
57
+ money. Edit this prose freely (any language) — it is your agent's borders.
58
+
59
+ ## Each cycle
60
+
61
+ 1. Ground yourself: read your portfolio and open positions first. Never assume
62
+ balances or what is already open.
63
+ 2. Scan the watchlist. A candidate is a coin whose short and medium momentum
64
+ agree (both up, or both down) and is not already an open position.
65
+ 3. Pick the strongest candidate and commit when the read is clear — even a
66
+ moderate-confidence one — sized small with a stop. Skip only when the signals
67
+ contradict or the data is stale; a quiet tape where your edge is still real
68
+ is an act, not a skip.
69
+ 4. Quote before you open. Read the liquidation price and confirm it is sane. If
70
+ the quote is not eligible, relay the reason and stop.
71
+ 5. Open small and protected: enter in the trend direction and set a stop-loss at
72
+ open. Place the take-profit a touch wider than the stop.
73
+ 6. Stay in sync: poll your trades for any stop / take-profit / liquidation that
74
+ fired while you were not looking, and react to what actually happened.
75
+
76
+ The hard caps (leverage, margin, watchlist) live in the config blocks above and
77
77
  are enforced by the runner — change them there, not in this prose.`;
78
- const PERSONA_STUB = `# Persona
79
-
80
- Decisive and in character. Acts on a clear read — even a moderate-confidence one
81
- — sized small and protected with a stop, and skips only when the read is
82
- contradictory or the data is stale. States its reasoning plainly in its own
83
- voice, and never frames paper results as real-money advice.
78
+ const PERSONA_STUB = `# Persona
79
+
80
+ Decisive and in character. Acts on a clear read — even a moderate-confidence one
81
+ — sized small and protected with a stop, and skips only when the read is
82
+ contradictory or the data is stale. States its reasoning plainly in its own
83
+ voice, and never frames paper results as real-money advice.
84
84
  `;
85
85
  export function buildAgentObject(name, preset) {
86
86
  const p = PRESETS[preset];
@@ -5,7 +5,7 @@ export declare const COINRITHM_API: {
5
5
  readonly kind: "coinrithm-agent-api";
6
6
  readonly baseUrl: "https://api.coinrithm.com";
7
7
  readonly mcpUrl: "https://mcp.coinrithm.com/mcp";
8
- readonly openapiVersion: "1.9.0";
8
+ readonly openapiVersion: "1.11.0";
9
9
  readonly mcpPackage: "@coinrithm/mcp-trading";
10
10
  readonly mcpVersion: string;
11
11
  };
@@ -20,7 +20,7 @@ export const COINRITHM_API = {
20
20
  mcpUrl: "https://mcp.coinrithm.com/mcp",
21
21
  // The API CONTRACT version (openapi.yaml info.version). Versioned independently
22
22
  // from the npm package below — hand-bump this when the OpenAPI contract changes.
23
- openapiVersion: "1.9.0",
23
+ openapiVersion: "1.11.0",
24
24
  mcpPackage: "@coinrithm/mcp-trading",
25
25
  mcpVersion: PACKAGE_VERSION,
26
26
  };
package/dist/client.d.ts CHANGED
@@ -41,6 +41,7 @@ export type ProvenanceReport = {
41
41
  };
42
42
  };
43
43
  export declare const API_KEY_MINT_URL = "https://www.coinrithm.com/en/settings/api-keys";
44
+ export declare const UPSTREAM_TIMEOUT_MS = 35000;
44
45
  export declare class CoinRithmClient {
45
46
  private readonly defaultApiKey?;
46
47
  private readonly baseUrl;
@@ -89,7 +90,52 @@ export declare class CoinRithmClient {
89
90
  }): Promise<ApiResult>;
90
91
  getPublicPmCanonicalDetail(key: string): Promise<ApiResult>;
91
92
  getPublicPmVolumeHistory(): Promise<ApiResult>;
93
+ getPublicPmPriceHistory(source: string, slug: string, query?: {
94
+ interval?: string;
95
+ }): Promise<ApiResult>;
96
+ getPublicPmOrderBook(source: string, slug: string, query?: {
97
+ outcomeExternalMarketId?: string;
98
+ }): Promise<ApiResult>;
99
+ listPublicPmResolved(query?: {
100
+ limit?: number;
101
+ offset?: number;
102
+ fiat?: string;
103
+ source?: string;
104
+ minVolume?: number;
105
+ year?: number;
106
+ month?: number;
107
+ }): Promise<ApiResult>;
108
+ listPublicPmExpiring(query?: {
109
+ limit?: number;
110
+ offset?: number;
111
+ fiat?: string;
112
+ days?: number;
113
+ source?: string;
114
+ }): Promise<ApiResult>;
115
+ listPublicPmSurprises(query?: {
116
+ limit?: number;
117
+ offset?: number;
118
+ fiat?: string;
119
+ source?: string;
120
+ window?: string;
121
+ }): Promise<ApiResult>;
122
+ getPublicInstrumentMarkets(type: string, id: string, query?: {
123
+ horizon?: string;
124
+ includeShortTerm?: boolean;
125
+ limit?: number;
126
+ }): Promise<ApiResult>;
127
+ getPublicTradfiReferences(): Promise<ApiResult>;
128
+ getPublicTradfiReference(ticker: string, query?: {
129
+ window?: string;
130
+ }): Promise<ApiResult>;
92
131
  getPublicCryptoMovers(direction: "gainers" | "losers", limit?: number): Promise<ApiResult>;
132
+ getPublicCoin(slug: string, query?: {
133
+ fiat?: string;
134
+ }): Promise<ApiResult>;
135
+ getPublicCoinCandles(slug: string, query?: {
136
+ range?: string;
137
+ fiat?: string;
138
+ }): Promise<ApiResult>;
93
139
  whoami(apiKey?: string, agentTrace?: AgentTrace): Promise<ApiResult>;
94
140
  getPortfolio(query?: {
95
141
  fiat?: string;
package/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { CALLER_IP_HEADER, callerIp } from "./requestContext.js";
1
2
  import { PM_PAPER_V2_BASE, } from "./pmPaperV2.js";
2
3
  // Thin HTTP client for the CoinRithm agent surface (/api/agent/*).
3
4
  //
@@ -27,12 +28,18 @@ export function log(...args) {
27
28
  function resolveBaseUrl() {
28
29
  return (process.env.COINRITHM_API_URL?.trim() || DEFAULT_BASE_URL).replace(/\/+$/, "");
29
30
  }
30
- // stdio path: a startup key is REQUIRED (single user).
31
+ // stdio path (single user). The key is OPTIONAL since 2026-10-16 (audit):
32
+ // without one the server starts in data-only mode, exactly like the hosted
33
+ // server: keyless tools (pm_data_*, instrument_markets, tradfi_references,
34
+ // crypto_price, get_arena_*, get_crypto_movers) work and
35
+ // every keyed tool returns the structured missing_api_key result. Requiring a
36
+ // key blocked the one-line `npx -y @coinrithm/mcp-trading` data install.
31
37
  export function loadConfig() {
32
38
  const apiKey = process.env.COINRITHM_API_KEY?.trim();
33
39
  if (!apiKey) {
34
- throw new Error("COINRITHM_API_KEY is not set. Mint a key in CoinRithm → Settings → API Keys " +
35
- "and expose it to this MCP server as the COINRITHM_API_KEY environment variable.");
40
+ log("no COINRITHM_API_KEY: data-only mode. Keyless data tools work; paper-trading " +
41
+ "tools need a key from CoinRithm → Settings → API Keys.");
42
+ return { baseUrl: resolveBaseUrl() };
36
43
  }
37
44
  if (!apiKey.startsWith("crk_live_")) {
38
45
  log("warning: COINRITHM_API_KEY does not start with 'crk_live_' — it will likely be rejected.");
@@ -75,6 +82,28 @@ const traceFromBody = (body) => body && typeof body === "object" && "agentTrace"
75
82
  // Where a user mints an API key (Settings -> API Keys). Named in every
76
83
  // missing-key error so an assistant can tell its user where to go.
77
84
  export const API_KEY_MINT_URL = "https://www.coinrithm.com/en/settings/api-keys";
85
+ // Upstream ceiling for every API call (audit 2026-10-16: none existed, so a
86
+ // hung upstream held the tool call open until undici's ~300 s default).
87
+ // Measured before choosing it: the slowest successful hosted calls in 24 h
88
+ // took 26-29 s (cold heavy reads), so 35 s cuts hangs without failing them.
89
+ export const UPSTREAM_TIMEOUT_MS = 35_000;
90
+ const upstreamFailure = (err) => {
91
+ const timedOut = err instanceof Error &&
92
+ (err.name === "TimeoutError" || err.name === "AbortError");
93
+ return {
94
+ ok: false,
95
+ status: 0,
96
+ data: timedOut
97
+ ? {
98
+ error: "upstream_timeout",
99
+ message: `CoinRithm API did not answer within ${UPSTREAM_TIMEOUT_MS / 1000} s. Retry shortly.`,
100
+ }
101
+ : {
102
+ error: "network_error",
103
+ message: err instanceof Error ? err.message : String(err),
104
+ },
105
+ };
106
+ };
78
107
  export class CoinRithmClient {
79
108
  // Default key for the stdio (single-user) path. Undefined in the multi-user
80
109
  // HTTP path, where every call must pass a per-request `apiKey` override.
@@ -94,8 +123,10 @@ export class CoinRithmClient {
94
123
  error: "missing_api_key",
95
124
  message: "No API key for this request. Mint a free key at " +
96
125
  `${API_KEY_MINT_URL}, then on the hosted MCP send ` +
97
- "`Authorization: Bearer crk_live_…`. Public data tools " +
98
- "(pm_data_*, get_arena_*, get_crypto_movers) need no key.",
126
+ "`Authorization: Bearer crk_live_…`, or for the local server " +
127
+ "set COINRITHM_API_KEY. Public data tools (pm_data_*, " +
128
+ "instrument_markets, tradfi_references, crypto_price, " +
129
+ "get_arena_*, get_crypto_movers) need no key.",
99
130
  },
100
131
  };
101
132
  }
@@ -114,24 +145,21 @@ export class CoinRithmClient {
114
145
  applyAgentTraceHeaders(headers, opts.agentTrace ?? traceFromBody(opts.body));
115
146
  if (opts.body !== undefined)
116
147
  headers["Content-Type"] = "application/json";
148
+ const ip = callerIp();
149
+ if (ip)
150
+ headers[CALLER_IP_HEADER] = ip;
117
151
  let res;
118
152
  try {
119
153
  res = await fetch(url.toString(), {
120
154
  method,
121
155
  headers,
122
156
  body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
157
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
123
158
  });
124
159
  }
125
160
  catch (err) {
126
161
  log(`network error calling ${method} ${path}:`, err);
127
- return {
128
- ok: false,
129
- status: 0,
130
- data: {
131
- error: "network_error",
132
- message: err instanceof Error ? err.message : String(err),
133
- },
134
- };
162
+ return upstreamFailure(err);
135
163
  }
136
164
  const text = await res.text();
137
165
  let data = text;
@@ -178,21 +206,19 @@ export class CoinRithmClient {
178
206
  }
179
207
  let res;
180
208
  try {
209
+ const ip = callerIp();
181
210
  res = await fetch(url.toString(), {
182
211
  method: "GET",
183
- headers: { Accept: "application/json" },
212
+ headers: {
213
+ Accept: "application/json",
214
+ ...(ip ? { [CALLER_IP_HEADER]: ip } : {}),
215
+ },
216
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
184
217
  });
185
218
  }
186
219
  catch (err) {
187
220
  log(`network error calling GET ${path}:`, err);
188
- return {
189
- ok: false,
190
- status: 0,
191
- data: {
192
- error: "network_error",
193
- message: err instanceof Error ? err.message : String(err),
194
- },
195
- };
221
+ return upstreamFailure(err);
196
222
  }
197
223
  const text = await res.text();
198
224
  let data = text;
@@ -286,6 +312,38 @@ export class CoinRithmClient {
286
312
  getPublicPmVolumeHistory() {
287
313
  return this.publicRequest("/api/prediction-markets/volume-history");
288
314
  }
315
+ // Native venue price history for ONE outcome (Polymarket and Kalshi; other
316
+ // venues answer 200 with points: []). Points are {t: epoch ms, p: 0-1}.
317
+ getPublicPmPriceHistory(source, slug, query) {
318
+ return this.publicRequest(`/api/prediction-markets/event/${encodeURIComponent(source)}/${encodeURIComponent(slug)}/price-history`, query);
319
+ }
320
+ // Live order book for the same outcome price-history describes, or the
321
+ // outcome named by outcomeExternalMarketId. Prices are 0-1 fractions.
322
+ getPublicPmOrderBook(source, slug, query) {
323
+ return this.publicRequest(`/api/prediction-markets/event/${encodeURIComponent(source)}/${encodeURIComponent(slug)}/orderbook`, query);
324
+ }
325
+ listPublicPmResolved(query) {
326
+ return this.publicRequest("/api/prediction-markets/resolved", query);
327
+ }
328
+ listPublicPmExpiring(query) {
329
+ return this.publicRequest("/api/prediction-markets/expiring", query);
330
+ }
331
+ listPublicPmSurprises(query) {
332
+ return this.publicRequest("/api/prediction-markets/surprises", query);
333
+ }
334
+ // ---- public instrument and TradFi reference data (no key required) ----
335
+ // Prediction-market strike ladders written on one instrument's price. The
336
+ // API returns 400 for any query parameter other than these three.
337
+ getPublicInstrumentMarkets(type, id, query) {
338
+ return this.publicRequest(`/api/instruments/${encodeURIComponent(type)}/${encodeURIComponent(id)}/markets`, query);
339
+ }
340
+ // The catalog accepts no query parameters (any one returns 400).
341
+ getPublicTradfiReferences() {
342
+ return this.publicRequest("/api/market/references");
343
+ }
344
+ getPublicTradfiReference(ticker, query) {
345
+ return this.publicRequest(`/api/market/references/${encodeURIComponent(ticker)}`, query);
346
+ }
289
347
  // ---- public crypto data (no key required) ----
290
348
  // Top 24h movers across the tracked coin universe (user feature request,
291
349
  // 2026-08-18: agents previously could only analyze manually-added pairs).
@@ -296,6 +354,16 @@ export class CoinRithmClient {
296
354
  ? "/api/coins/top-losers"
297
355
  : "/api/coins/top-gainers", { limit });
298
356
  }
357
+ // One coin's profile by slug: price in USD and `fiat`, 24h change, market
358
+ // cap, volume and the live-price as-of (priceUpdatedAt, priceStale).
359
+ // Numerics are decimal strings. An unknown slug is 404 {message}.
360
+ getPublicCoin(slug, query) {
361
+ return this.publicRequest(`/api/coins/${encodeURIComponent(slug)}`, query);
362
+ }
363
+ // OHLC bars by slug; `range` fixes lookback and resolution (1H = 60 x 1m).
364
+ getPublicCoinCandles(slug, query) {
365
+ return this.publicRequest(`/api/coins/${encodeURIComponent(slug)}/candles`, query);
366
+ }
299
367
  // Every method takes an optional trailing `apiKey` (the per-request key for
300
368
  // the multi-user HTTP path). When omitted, the constructor key (stdio) is used.
301
369
  // ---- reads (scope: read) ----
package/dist/http.d.ts CHANGED
@@ -2,8 +2,12 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { CoinRithmClient } from "./client.js";
4
4
  import { type CompletionLogger } from "./httpCompletion.js";
5
+ import { type McpLimitOptions } from "./httpLimits.js";
6
+ import { type DrainState } from "./httpShutdown.js";
5
7
  export declare const MALFORMED_MCP_LINK: RegExp;
6
8
  export declare function createHttpApp(client: CoinRithmClient, options?: {
7
9
  completionLogger?: CompletionLogger;
8
10
  createServer?: () => McpServer;
11
+ limits?: McpLimitOptions;
12
+ drain?: DrainState;
9
13
  }): import("express-serve-static-core").Express;
package/dist/http.js CHANGED
@@ -43,12 +43,17 @@ import { CoinRithmClient, bearerFromHeader, loadHttpConfig, log, } from "./clien
43
43
  import { registerTools } from "./tools.js";
44
44
  import { SERVER_VERSION } from "./version.js";
45
45
  import { observeHttpCompletion, } from "./httpCompletion.js";
46
+ import { createMcpAdmission } from "./httpLimits.js";
47
+ import { installGracefulShutdown } from "./httpShutdown.js";
48
+ import { runWithCaller } from "./requestContext.js";
46
49
  // "/mcp" immediately followed by the markdown "](" of a pasted link, literal or
47
50
  // percent-encoded. Anything else under /mcp is not this family.
48
51
  export const MALFORMED_MCP_LINK = /^\/mcp(?:\]|%5d)(?:\(|%28)/i;
49
52
  // Factory permits isolated localhost SDK tests without opening a listener on import.
50
53
  export function createHttpApp(client, options = {}) {
51
54
  const app = express();
55
+ // Behind Cloudflare and Traefik (audit 2026-10-16: no limit existed).
56
+ const admission = createMcpAdmission(options.limits);
52
57
  const completions = new WeakMap();
53
58
  app.use((req, res, next) => {
54
59
  if (req.method === "POST" && /^\/mcp\/?$/i.test(req.path)) {
@@ -60,11 +65,16 @@ export function createHttpApp(client, options = {}) {
60
65
  // Lightweight, unauthenticated liveness probe (handy for Coolify/uptime checks).
61
66
  // Keep `/healthz` as the deployment contract and expose `/health` as a
62
67
  // compatibility alias for agents and generic uptime monitors.
68
+ // While a stop signal drains the process it answers 503, so the proxy and
69
+ // the image HEALTHCHECK stop sending new work here (httpShutdown.ts).
63
70
  app.get(["/health", "/healthz"], (_req, res) => {
64
- res.json({
65
- ok: true,
71
+ const draining = options.drain?.draining === true;
72
+ res.status(draining ? 503 : 200).json({
73
+ ok: !draining,
66
74
  service: "coinrithm-mcp",
67
75
  transport: "streamable-http",
76
+ version: SERVER_VERSION,
77
+ ...(draining ? { draining: true } : {}),
68
78
  });
69
79
  });
70
80
  // Self-describing landing for humans and validate-before-recommend agents.
@@ -75,7 +85,7 @@ export function createHttpApp(client, options = {}) {
75
85
  res.json({
76
86
  service: "CoinRithm MCP",
77
87
  version: SERVER_VERSION,
78
- description: "Model Context Protocol server for CoinRithm: keyless prediction-market data tools across 12 venues (pm_data_*), keyless crypto movers and Agent Arena reads, plus crypto market context and paper-trading tools (spot, futures, prediction markets) with a crk_live_ API key. Opt-in PM paper v2 and spot reference tools are off unless the server enables them. Paper only, never real money.",
88
+ description: "Model Context Protocol server for CoinRithm: keyless prediction-market data tools across 12 venues (pm_data_*), keyless strike ladders (instrument_markets), TradFi reference prices (tradfi_references), crypto prices (crypto_price), crypto movers and Agent Arena reads, plus crypto market context and paper-trading tools (spot, futures, prediction markets) with a crk_live_ API key. Opt-in PM paper v2 and spot reference tools are off unless the server enables them. Paper only, never real money.",
79
89
  endpoint: {
80
90
  url: "https://mcp.coinrithm.com/mcp",
81
91
  method: "POST",
@@ -129,7 +139,7 @@ export function createHttpApp(client, options = {}) {
129
139
  message: "This is a POST-only streamable-HTTP MCP endpoint. Connect with an MCP client, or GET / for service info.",
130
140
  });
131
141
  });
132
- app.post(["/mcp", MALFORMED_MCP_LINK], async (req, res) => {
142
+ app.post(["/mcp", MALFORMED_MCP_LINK], admission, async (req, res) => {
133
143
  // Per-request auth: read THIS caller's key from the Authorization header,
134
144
  // or from Smithery's non-reserved forwarding header.
135
145
  // It is optional at the transport layer so registries can initialize the
@@ -162,7 +172,9 @@ export function createHttpApp(client, options = {}) {
162
172
  completions.get(res)?.attach(transport);
163
173
  // The transport reads req.headers (→ extra.requestInfo) and req.auth
164
174
  // (→ extra.authInfo); tools.ts picks up the caller's key from there.
165
- await transport.handleRequest(req, res, req.body);
175
+ // Upstream calls made while handling it carry the caller's own
176
+ // address (requestContext.ts), not the container's.
177
+ await runWithCaller(req.headers["cf-connecting-ip"], () => transport.handleRequest(req, res, req.body));
166
178
  }
167
179
  catch (err) {
168
180
  log("http request error:", err);
@@ -180,12 +192,14 @@ export function createHttpApp(client, options = {}) {
180
192
  async function main() {
181
193
  const config = loadHttpConfig(); // no global key — keys arrive per request
182
194
  const client = new CoinRithmClient(config); // constructed WITHOUT a default key
183
- const app = createHttpApp(client);
195
+ const drain = { draining: false };
196
+ const app = createHttpApp(client, { drain });
184
197
  const port = Number(process.env.PORT) || 8787;
185
- app.listen(port, () => {
198
+ const listener = app.listen(port, () => {
186
199
  log(`HTTP MCP listening on :${port}/mcp (multi-user, per-request key). ` +
187
200
  `upstream=${config.baseUrl}. Paper only.`);
188
201
  });
202
+ installGracefulShutdown(listener, { state: drain, log });
189
203
  }
190
204
  if (process.argv[1] &&
191
205
  import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
3
- export declare const COMPLETION_TOOL_NAMES: readonly ["whoami", "get_portfolio", "get_wallet", "list_open_orders", "get_positions", "resolve_symbol", "get_equity_curve", "get_my_trades", "get_market_context", "get_candles", "get_news", "discover_pm_markets", "get_performance", "get_agent_ledger", "export_agent_ledger", "export_run_evidence", "get_arena_leaderboard", "get_arena_agent", "futures_quote", "pm_quote", "spot_quote", "place_spot_order", "cancel_spot_order", "open_futures_position", "set_futures_sl_tp", "close_futures_position", "open_pm_position", "report_pm_opportunity", "pm_data_overview", "pm_data_sources", "pm_data_sources_health", "pm_data_events", "pm_data_event", "pm_data_whales", "pm_data_whale_wallets", "pm_data_whale_wallet", "pm_data_disagreements", "pm_data_calibration", "pm_data_canonical", "pm_data_volume_history", "get_crypto_movers"];
3
+ export declare const COMPLETION_TOOL_NAMES: readonly ["whoami", "get_portfolio", "get_wallet", "list_open_orders", "get_positions", "resolve_symbol", "get_equity_curve", "get_my_trades", "get_market_context", "get_candles", "get_news", "discover_pm_markets", "get_performance", "get_agent_ledger", "export_agent_ledger", "export_run_evidence", "get_arena_leaderboard", "get_arena_agent", "futures_quote", "pm_quote", "spot_quote", "place_spot_order", "cancel_spot_order", "open_futures_position", "set_futures_sl_tp", "close_futures_position", "open_pm_position", "report_pm_opportunity", "pm_data_overview", "pm_data_sources", "pm_data_sources_health", "pm_data_events", "pm_data_event", "pm_data_whales", "pm_data_whale_wallets", "pm_data_whale_wallet", "pm_data_disagreements", "pm_data_calibration", "pm_data_canonical", "pm_data_volume_history", "pm_data_price_history", "pm_data_orderbook", "pm_data_resolved", "pm_data_expiring", "pm_data_surprises", "get_crypto_movers", "instrument_markets", "tradfi_references", "crypto_price"];
4
4
  type Operation = "initialize" | "tools_list" | "tools_call" | "notification" | "other" | "invalid";
5
5
  type Outcome = "result" | "tool_error" | "protocol_error" | "no_response" | "not_applicable";
6
6
  type ToolName = (typeof COMPLETION_TOOL_NAMES)[number] | "unknown" | null;
@@ -43,7 +43,15 @@ export const COMPLETION_TOOL_NAMES = [
43
43
  "pm_data_calibration",
44
44
  "pm_data_canonical",
45
45
  "pm_data_volume_history",
46
+ "pm_data_price_history",
47
+ "pm_data_orderbook",
48
+ "pm_data_resolved",
49
+ "pm_data_expiring",
50
+ "pm_data_surprises",
46
51
  "get_crypto_movers",
52
+ "instrument_markets",
53
+ "tradfi_references",
54
+ "crypto_price",
47
55
  ];
48
56
  const toolNames = new Set(COMPLETION_TOOL_NAMES);
49
57
  const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
@@ -0,0 +1,10 @@
1
+ import type { NextFunction, Request, Response } from "express";
2
+ export type McpLimitOptions = {
3
+ perClientPerMinute?: number;
4
+ maxInFlight?: number;
5
+ now?: () => number;
6
+ };
7
+ export declare const DEFAULT_PER_CLIENT_PER_MINUTE = 120;
8
+ export declare const DEFAULT_MAX_IN_FLIGHT = 64;
9
+ export declare function clientKey(req: Request): string;
10
+ export declare function createMcpAdmission(options?: McpLimitOptions): (req: Request, res: Response, next: NextFunction) => void;
@@ -0,0 +1,76 @@
1
+ // Admission limits for the hosted multi-user MCP (POST /mcp).
2
+ //
3
+ // Audit 2026-10-16: POST /mcp had no rate limit and no concurrency cap, and
4
+ // keyless pm_data_* tools reach the internal API directly (past Cloudflare),
5
+ // so one anonymous client could drive unbounded load into the single-host API.
6
+ // Measured before choosing the numbers (container logs, 24 h to 2026-10-16
7
+ // 01:30 UTC): 3,861 requests, peak about 16 per minute across ALL clients.
8
+ // A per-client allowance of 120 per minute (the API's own per-key baseline)
9
+ // leaves ordinary agents untouched; the in-flight cap bounds a burst.
10
+ //
11
+ // In-memory and per-process on purpose: the service runs as one container.
12
+ // No dependency is added for this (supply-chain surface of an npx package).
13
+ export const DEFAULT_PER_CLIENT_PER_MINUTE = 120;
14
+ export const DEFAULT_MAX_IN_FLIGHT = 64;
15
+ const WINDOW_MS = 60_000;
16
+ const firstHeader = (value) => {
17
+ const raw = Array.isArray(value) ? value[0] : value;
18
+ const first = raw?.split(",")[0]?.trim();
19
+ return first ? first : null;
20
+ };
21
+ // Behind Cloudflare and Traefik: Cloudflare's connecting-IP header names the
22
+ // real client; X-Forwarded-For is the fallback; the socket address last.
23
+ export function clientKey(req) {
24
+ return (firstHeader(req.headers["cf-connecting-ip"]) ??
25
+ firstHeader(req.headers["x-forwarded-for"]) ??
26
+ req.socket?.remoteAddress ??
27
+ "unknown");
28
+ }
29
+ const rpcError = (res, status, retryAfterSeconds, message) => {
30
+ res.setHeader("Retry-After", String(retryAfterSeconds));
31
+ res.status(status).json({
32
+ jsonrpc: "2.0",
33
+ error: { code: -32000, message, data: { retryAfterSeconds } },
34
+ id: null,
35
+ });
36
+ };
37
+ export function createMcpAdmission(options = {}) {
38
+ const perClient = options.perClientPerMinute ?? DEFAULT_PER_CLIENT_PER_MINUTE;
39
+ const maxInFlight = options.maxInFlight ?? DEFAULT_MAX_IN_FLIGHT;
40
+ const now = options.now ?? Date.now;
41
+ let windowStart = now();
42
+ let counts = new Map();
43
+ let inFlight = 0;
44
+ return function mcpAdmission(req, res, next) {
45
+ const t = now();
46
+ if (t - windowStart >= WINDOW_MS) {
47
+ // Fixed window: dropping the whole map bounds memory to one minute of
48
+ // distinct clients.
49
+ windowStart = t;
50
+ counts = new Map();
51
+ }
52
+ const key = clientKey(req);
53
+ const used = (counts.get(key) ?? 0) + 1;
54
+ counts.set(key, used);
55
+ if (used > perClient) {
56
+ const retry = Math.max(1, Math.ceil((windowStart + WINDOW_MS - t) / 1000));
57
+ rpcError(res, 429, retry, `Rate limited: at most ${perClient} MCP requests per minute per client. Retry after ${retry} s.`);
58
+ return;
59
+ }
60
+ if (inFlight >= maxInFlight) {
61
+ rpcError(res, 503, 2, "Server busy: too many MCP requests in flight. Retry shortly.");
62
+ return;
63
+ }
64
+ inFlight += 1;
65
+ let released = false;
66
+ const release = () => {
67
+ if (released)
68
+ return;
69
+ released = true;
70
+ inFlight -= 1;
71
+ };
72
+ res.on("finish", release);
73
+ res.on("close", release);
74
+ next();
75
+ };
76
+ }
@@ -0,0 +1,13 @@
1
+ import type { Server } from "node:http";
2
+ export declare const SHUTDOWN_GRACE_MS = 8000;
3
+ export type DrainState = {
4
+ draining: boolean;
5
+ };
6
+ export declare function installGracefulShutdown(server: Pick<Server, "close" | "closeAllConnections">, options: {
7
+ state: DrainState;
8
+ graceMs?: number;
9
+ log?: (...args: unknown[]) => void;
10
+ exit?: (code: number) => void;
11
+ signals?: NodeJS.Signals[];
12
+ onSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
13
+ }): (signal: NodeJS.Signals) => void;
@@ -0,0 +1,34 @@
1
+ export const SHUTDOWN_GRACE_MS = 8_000;
2
+ export function installGracefulShutdown(server, options) {
3
+ const graceMs = options.graceMs ?? SHUTDOWN_GRACE_MS;
4
+ const exit = options.exit ?? ((code) => process.exit(code));
5
+ const say = options.log ?? (() => { });
6
+ let done = false;
7
+ const finish = (reason) => {
8
+ if (done)
9
+ return;
10
+ done = true;
11
+ say(`shutdown: ${reason}`);
12
+ exit(0);
13
+ };
14
+ const begin = (signal) => {
15
+ if (options.state.draining)
16
+ return;
17
+ options.state.draining = true;
18
+ say(`${signal}: draining, up to ${graceMs} ms for open requests`);
19
+ server.close(() => finish("all requests closed"));
20
+ const timer = setTimeout(() => {
21
+ server.closeAllConnections();
22
+ finish("grace period ended");
23
+ }, graceMs);
24
+ timer.unref?.();
25
+ };
26
+ const listen = options.onSignal ??
27
+ ((signal, handler) => {
28
+ process.once(signal, handler);
29
+ });
30
+ for (const signal of options.signals ?? ["SIGTERM", "SIGINT"]) {
31
+ listen(signal, () => begin(signal));
32
+ }
33
+ return begin;
34
+ }
package/dist/index.js CHANGED
@@ -6,7 +6,8 @@
6
6
  // paper account and place simulated trades using a user-minted API key.
7
7
  //
8
8
  // Config (env):
9
- // COINRITHM_API_KEY (required) crk_live_… key minted in your profile.
9
+ // COINRITHM_API_KEY (optional) crk_live_… key minted in your profile.
10
+ // Without it: data-only mode (keyless tools).
10
11
  // COINRITHM_API_URL (optional) base URL; defaults to https://api.coinrithm.com
11
12
  //
12
13
  // stdout is the JSON-RPC channel — we log ONLY to stderr.
@@ -6,7 +6,7 @@ import { PAPER_NOTE } from "./tools.js";
6
6
  // paper disclaimer on 25 tools, audit 2026-09-29).
7
7
  export const SERVER_INSTRUCTIONS = [
8
8
  "CoinRithm MCP: crypto and prediction-market data from 12 venues, plus paper trading.",
9
- "Start keyless: the pm_data_* tools, get_arena_leaderboard, get_arena_agent and get_crypto_movers need no API key.",
9
+ "Start keyless: the pm_data_* tools, instrument_markets, tradfi_references, crypto_price, get_arena_leaderboard, get_arena_agent and get_crypto_movers need no API key.",
10
10
  "pm_data_events returns open markets by default; pass status=closed or status=all for history.",
11
11
  `Tools that read or act on an account need a key. The user mints one at ${API_KEY_MINT_URL}; start with a read-only key.`,
12
12
  "Every tool returns { httpStatus, ok, ledgerEventId, ledgerStatus, body }: httpStatus is CoinRithm's HTTP status (0 = network error), ok means 2xx, body is the parsed response.",
@@ -0,0 +1,5 @@
1
+ export declare const CALLER_IP_HEADER = "cf-connecting-ip";
2
+ /** Runs `fn` with the caller's address (if it is a valid IP) in scope. */
3
+ export declare function runWithCaller<T>(rawIp: string | string[] | undefined, fn: () => T): T;
4
+ /** The current request's caller address, or undefined (stdio, no header). */
5
+ export declare const callerIp: () => string | undefined;
@@ -0,0 +1,25 @@
1
+ // Per-HTTP-request context for the hosted MCP (audit 2026-10-16, fresh kit
2
+ // audit P1-1). The hosted server calls the API inside the Docker network
3
+ // (http://api:4000), where the API's keyless rate limit buckets by
4
+ // cf-connecting-ip and otherwise by the socket address. Without this every
5
+ // anonymous MCP user shared the MCP container's one bucket (300 requests per
6
+ // 5 minutes), so one busy client could 429 everyone else.
7
+ //
8
+ // http.ts records the caller's Cloudflare-set address for the duration of the
9
+ // request; the client forwards it as cf-connecting-ip on upstream calls. The
10
+ // API trusts that header only because its origin accepts nothing but
11
+ // Cloudflare and this internal network; the value here is the one Cloudflare
12
+ // set on the MCP request, re-validated as an IP. stdio has no context and
13
+ // sends nothing extra.
14
+ import { AsyncLocalStorage } from "node:async_hooks";
15
+ import { isIP } from "node:net";
16
+ const storage = new AsyncLocalStorage();
17
+ export const CALLER_IP_HEADER = "cf-connecting-ip";
18
+ /** Runs `fn` with the caller's address (if it is a valid IP) in scope. */
19
+ export function runWithCaller(rawIp, fn) {
20
+ const value = Array.isArray(rawIp) ? rawIp[0] : rawIp;
21
+ const ip = value?.trim();
22
+ return storage.run({ clientIp: ip && isIP(ip) ? ip : undefined }, fn);
23
+ }
24
+ /** The current request's caller address, or undefined (stdio, no header). */
25
+ export const callerIp = () => storage.getStore()?.clientIp;