@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.
- package/CHANGELOG.md +942 -903
- package/README.md +539 -488
- package/dist/agent/templates.js +30 -30
- package/dist/agent/version.d.ts +1 -1
- package/dist/agent/version.js +1 -1
- package/dist/client.d.ts +46 -0
- package/dist/client.js +90 -22
- package/dist/http.d.ts +4 -0
- package/dist/http.js +21 -7
- package/dist/httpCompletion.d.ts +1 -1
- package/dist/httpCompletion.js +8 -0
- package/dist/httpLimits.d.ts +10 -0
- package/dist/httpLimits.js +76 -0
- package/dist/httpShutdown.d.ts +13 -0
- package/dist/httpShutdown.js +34 -0
- package/dist/index.js +2 -1
- package/dist/instructions.js +1 -1
- package/dist/requestContext.d.ts +5 -0
- package/dist/requestContext.js +25 -0
- package/dist/tools.d.ts +44 -0
- package/dist/tools.js +809 -0
- package/package.json +100 -100
package/dist/agent/templates.js
CHANGED
|
@@ -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];
|
package/dist/agent/version.d.ts
CHANGED
|
@@ -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.
|
|
8
|
+
readonly openapiVersion: "1.11.0";
|
|
9
9
|
readonly mcpPackage: "@coinrithm/mcp-trading";
|
|
10
10
|
readonly mcpVersion: string;
|
|
11
11
|
};
|
package/dist/agent/version.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
35
|
-
"
|
|
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_
|
|
98
|
-
"
|
|
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: {
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|
package/dist/httpCompletion.d.ts
CHANGED
|
@@ -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;
|
package/dist/httpCompletion.js
CHANGED
|
@@ -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 (
|
|
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/dist/instructions.js
CHANGED
|
@@ -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;
|