@coinrithm/mcp-trading 0.7.18 → 0.7.19

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/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ ships two binaries — `coinrithm-mcp` (the MCP server) and `coinrithm-agent` (t
5
5
  self-host agent runner) — versioned together. The CoinRithm **API contract** is
6
6
  versioned separately (see `openapi.yaml` `info.version`, currently `1.9.0`).
7
7
 
8
+ ## 0.7.19 (2026-10-16)
9
+
10
+ MCP server:
11
+
12
+ - The local stdio server starts without `COINRITHM_API_KEY` in data-only
13
+ mode, like the hosted server: `npx -y @coinrithm/mcp-trading` works with no
14
+ account. Keyless data tools (`pm_data_*`, `get_arena_*`,
15
+ `get_crypto_movers`) answer; every keyed tool returns the structured
16
+ `missing_api_key` result, which now also names `COINRITHM_API_KEY` for the
17
+ local server. `server.json` marks the key optional.
18
+ - Upstream requests time out after 35 s and report `upstream_timeout`
19
+ instead of hanging the tool call.
20
+ - Hosted HTTP entry: per-client admission (120 requests a minute) and an
21
+ in-flight cap (64), answered as JSON-RPC 429/503 with `Retry-After`; a stop
22
+ signal drains open requests for up to 8 s while `/healthz` answers 503;
23
+ `/health` and `/healthz` report `version`.
24
+
25
+ The default surface stays at 41 tools. Generated against API contract 1.9.0.
26
+
8
27
  ## 0.7.18 (2026-10-15)
9
28
 
10
29
  MCP server:
package/README.md CHANGED
@@ -44,10 +44,18 @@ This package ships two binaries:
44
44
 
45
45
  > **Paper trading only** — virtual funds (50,000 mUSD). Not financial advice.
46
46
 
47
- ## Version 0.7.17
48
-
49
- The default tool surface is unchanged at 41 tools. Two server-wide opt-in
50
- flags add tools that stay off unless enabled: the
47
+ ## Version 0.7.19
48
+
49
+ The local server starts without `COINRITHM_API_KEY` in data-only mode, so
50
+ `npx -y @coinrithm/mcp-trading` gives prediction-market and crypto data with
51
+ no account; keyed tools return a structured `missing_api_key` result until a
52
+ key is set. Upstream calls time out after 35 s (`upstream_timeout`). The
53
+ hosted server adds per-client limits (120 requests a minute, 64 in flight),
54
+ drains open requests on deploy and reports its version on `/health`. The
55
+ default surface stays at 41 tools. See [CHANGELOG.md](./CHANGELOG.md).
56
+
57
+ Version 0.7.17 kept the default tool surface at 41 tools. Two server-wide
58
+ opt-in flags add tools that stay off unless enabled: the
51
59
  [PM paper v2 tools](#opt-in-pm-paper-v2-tools) for configured house keys and
52
60
  the [spot reference tool](#opt-in-spot-reference-tool).
53
61
 
@@ -95,7 +103,10 @@ for the exact opt-in policy and persistence contract.
95
103
  ## Quick start
96
104
 
97
105
  ```bash
98
- # Run the MCP server with your CoinRithm key (no install needed):
106
+ # Prediction-market and crypto data only, no account or key needed:
107
+ npx -y @coinrithm/mcp-trading
108
+
109
+ # Add your CoinRithm key to enable paper trading (no install needed):
99
110
  COINRITHM_API_KEY=crk_live_… npx -y @coinrithm/mcp-trading
100
111
  ```
101
112
 
@@ -178,13 +189,13 @@ agent file.
178
189
 
179
190
  ## Configure (stdio)
180
191
 
181
- | Env var | Required | Default | Notes |
182
- | ---------------------------------------- | ---------------- | --------------------------- | ----------------------------------------------------------------------------------------- |
183
- | `COINRITHM_API_KEY` | yes (stdio only) | — | A `crk_live_…` key from CoinRithm → Settings → API Keys. **Ignored by the HTTP entry.** |
184
- | `COINRITHM_API_URL` | no | `https://api.coinrithm.com` | Upstream base URL (live) |
185
- | `PORT` | no | `8787` | HTTP entry only |
186
- | `COINRITHM_PM_PAPER_V2_TOOLS_ENABLED` | no | off | `true` adds the [PM paper v2 tools](#opt-in-pm-paper-v2-tools) for configured house keys. |
187
- | `COINRITHM_SPOT_REFERENCE_TOOLS_ENABLED` | no | off | `true` adds [`get_spot_reference`](#opt-in-spot-reference-tool). |
192
+ | Env var | Required | Default | Notes |
193
+ | ---------------------------------------- | --------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------- |
194
+ | `COINRITHM_API_KEY` | no (stdio only) | — | A `crk_live_…` key from CoinRithm → Settings → API Keys. Without it, data tools only. **Ignored by the HTTP entry.** |
195
+ | `COINRITHM_API_URL` | no | `https://api.coinrithm.com` | Upstream base URL (live) |
196
+ | `PORT` | no | `8787` | HTTP entry only |
197
+ | `COINRITHM_PM_PAPER_V2_TOOLS_ENABLED` | no | off | `true` adds the [PM paper v2 tools](#opt-in-pm-paper-v2-tools) for configured house keys. |
198
+ | `COINRITHM_SPOT_REFERENCE_TOOLS_ENABLED` | no | off | `true` adds [`get_spot_reference`](#opt-in-spot-reference-tool). |
188
199
 
189
200
  ## Run
190
201
 
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;
package/dist/client.js CHANGED
@@ -27,12 +27,17 @@ export function log(...args) {
27
27
  function resolveBaseUrl() {
28
28
  return (process.env.COINRITHM_API_URL?.trim() || DEFAULT_BASE_URL).replace(/\/+$/, "");
29
29
  }
30
- // stdio path: a startup key is REQUIRED (single user).
30
+ // stdio path (single user). The key is OPTIONAL since 2026-10-16 (audit):
31
+ // without one the server starts in data-only mode, exactly like the hosted
32
+ // server: keyless tools (pm_data_*, get_arena_*, get_crypto_movers) work and
33
+ // every keyed tool returns the structured missing_api_key result. Requiring a
34
+ // key blocked the one-line `npx -y @coinrithm/mcp-trading` data install.
31
35
  export function loadConfig() {
32
36
  const apiKey = process.env.COINRITHM_API_KEY?.trim();
33
37
  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.");
38
+ log("no COINRITHM_API_KEY: data-only mode. Keyless data tools work; paper-trading " +
39
+ "tools need a key from CoinRithm → Settings → API Keys.");
40
+ return { baseUrl: resolveBaseUrl() };
36
41
  }
37
42
  if (!apiKey.startsWith("crk_live_")) {
38
43
  log("warning: COINRITHM_API_KEY does not start with 'crk_live_' — it will likely be rejected.");
@@ -75,6 +80,28 @@ const traceFromBody = (body) => body && typeof body === "object" && "agentTrace"
75
80
  // Where a user mints an API key (Settings -> API Keys). Named in every
76
81
  // missing-key error so an assistant can tell its user where to go.
77
82
  export const API_KEY_MINT_URL = "https://www.coinrithm.com/en/settings/api-keys";
83
+ // Upstream ceiling for every API call (audit 2026-10-16: none existed, so a
84
+ // hung upstream held the tool call open until undici's ~300 s default).
85
+ // Measured before choosing it: the slowest successful hosted calls in 24 h
86
+ // took 26-29 s (cold heavy reads), so 35 s cuts hangs without failing them.
87
+ export const UPSTREAM_TIMEOUT_MS = 35_000;
88
+ const upstreamFailure = (err) => {
89
+ const timedOut = err instanceof Error &&
90
+ (err.name === "TimeoutError" || err.name === "AbortError");
91
+ return {
92
+ ok: false,
93
+ status: 0,
94
+ data: timedOut
95
+ ? {
96
+ error: "upstream_timeout",
97
+ message: `CoinRithm API did not answer within ${UPSTREAM_TIMEOUT_MS / 1000} s. Retry shortly.`,
98
+ }
99
+ : {
100
+ error: "network_error",
101
+ message: err instanceof Error ? err.message : String(err),
102
+ },
103
+ };
104
+ };
78
105
  export class CoinRithmClient {
79
106
  // Default key for the stdio (single-user) path. Undefined in the multi-user
80
107
  // HTTP path, where every call must pass a per-request `apiKey` override.
@@ -94,7 +121,8 @@ export class CoinRithmClient {
94
121
  error: "missing_api_key",
95
122
  message: "No API key for this request. Mint a free key at " +
96
123
  `${API_KEY_MINT_URL}, then on the hosted MCP send ` +
97
- "`Authorization: Bearer crk_live_…`. Public data tools " +
124
+ "`Authorization: Bearer crk_live_…`, or for the local server " +
125
+ "set COINRITHM_API_KEY. Public data tools " +
98
126
  "(pm_data_*, get_arena_*, get_crypto_movers) need no key.",
99
127
  },
100
128
  };
@@ -120,18 +148,12 @@ export class CoinRithmClient {
120
148
  method,
121
149
  headers,
122
150
  body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
151
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
123
152
  });
124
153
  }
125
154
  catch (err) {
126
155
  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
- };
156
+ return upstreamFailure(err);
135
157
  }
136
158
  const text = await res.text();
137
159
  let data = text;
@@ -181,18 +203,12 @@ export class CoinRithmClient {
181
203
  res = await fetch(url.toString(), {
182
204
  method: "GET",
183
205
  headers: { Accept: "application/json" },
206
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
184
207
  });
185
208
  }
186
209
  catch (err) {
187
210
  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
- };
211
+ return upstreamFailure(err);
196
212
  }
197
213
  const text = await res.text();
198
214
  let data = text;
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,16 @@ 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";
46
48
  // "/mcp" immediately followed by the markdown "](" of a pasted link, literal or
47
49
  // percent-encoded. Anything else under /mcp is not this family.
48
50
  export const MALFORMED_MCP_LINK = /^\/mcp(?:\]|%5d)(?:\(|%28)/i;
49
51
  // Factory permits isolated localhost SDK tests without opening a listener on import.
50
52
  export function createHttpApp(client, options = {}) {
51
53
  const app = express();
54
+ // Behind Cloudflare and Traefik (audit 2026-10-16: no limit existed).
55
+ const admission = createMcpAdmission(options.limits);
52
56
  const completions = new WeakMap();
53
57
  app.use((req, res, next) => {
54
58
  if (req.method === "POST" && /^\/mcp\/?$/i.test(req.path)) {
@@ -60,11 +64,16 @@ export function createHttpApp(client, options = {}) {
60
64
  // Lightweight, unauthenticated liveness probe (handy for Coolify/uptime checks).
61
65
  // Keep `/healthz` as the deployment contract and expose `/health` as a
62
66
  // compatibility alias for agents and generic uptime monitors.
67
+ // While a stop signal drains the process it answers 503, so the proxy and
68
+ // the image HEALTHCHECK stop sending new work here (httpShutdown.ts).
63
69
  app.get(["/health", "/healthz"], (_req, res) => {
64
- res.json({
65
- ok: true,
70
+ const draining = options.drain?.draining === true;
71
+ res.status(draining ? 503 : 200).json({
72
+ ok: !draining,
66
73
  service: "coinrithm-mcp",
67
74
  transport: "streamable-http",
75
+ version: SERVER_VERSION,
76
+ ...(draining ? { draining: true } : {}),
68
77
  });
69
78
  });
70
79
  // Self-describing landing for humans and validate-before-recommend agents.
@@ -129,7 +138,7 @@ export function createHttpApp(client, options = {}) {
129
138
  message: "This is a POST-only streamable-HTTP MCP endpoint. Connect with an MCP client, or GET / for service info.",
130
139
  });
131
140
  });
132
- app.post(["/mcp", MALFORMED_MCP_LINK], async (req, res) => {
141
+ app.post(["/mcp", MALFORMED_MCP_LINK], admission, async (req, res) => {
133
142
  // Per-request auth: read THIS caller's key from the Authorization header,
134
143
  // or from Smithery's non-reserved forwarding header.
135
144
  // It is optional at the transport layer so registries can initialize the
@@ -180,12 +189,14 @@ export function createHttpApp(client, options = {}) {
180
189
  async function main() {
181
190
  const config = loadHttpConfig(); // no global key — keys arrive per request
182
191
  const client = new CoinRithmClient(config); // constructed WITHOUT a default key
183
- const app = createHttpApp(client);
192
+ const drain = { draining: false };
193
+ const app = createHttpApp(client, { drain });
184
194
  const port = Number(process.env.PORT) || 8787;
185
- app.listen(port, () => {
195
+ const listener = app.listen(port, () => {
186
196
  log(`HTTP MCP listening on :${port}/mcp (multi-user, per-request key). ` +
187
197
  `upstream=${config.baseUrl}. Paper only.`);
188
198
  });
199
+ installGracefulShutdown(listener, { state: drain, log });
189
200
  }
190
201
  if (process.argv[1] &&
191
202
  import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinrithm/mcp-trading",
3
- "version": "0.7.18",
3
+ "version": "0.7.19",
4
4
  "mcpName": "io.github.CoinRithm/mcp-trading",
5
5
  "description": "CoinRithm MCP server (coinrithm-mcp) and self-host agent runner (coinrithm-agent): keyless prediction-market data across 12 venues, plus paper trading of crypto spot, futures, and prediction markets with a user-minted API key.",
6
6
  "type": "module",