@coinrithm/mcp-trading 0.7.8 → 0.7.9

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.
@@ -49,6 +49,7 @@ export function buildDecisionInputRecord(input) {
49
49
  const budget = buildDailyRiskBudget(input.spec, input.state);
50
50
  const record = {
51
51
  version: "coinrithm.decision-input.v1",
52
+ projectionVersion: "coinrithm.decision-input-projection.v2",
52
53
  visibility: "private",
53
54
  completeness: "partial",
54
55
  phase: input.phase,
@@ -149,6 +150,12 @@ export function buildDecisionInputRecord(input) {
149
150
  ema20AboveEma50: bool(obj(r.indicators).ema20AboveEma50),
150
151
  brokeRecentHigh: bool(obj(r.indicators).brokeRecentHigh),
151
152
  brokeRecentLow: bool(obj(r.indicators).brokeRecentLow),
153
+ bollinger: numeric(obj(obj(r.indicators).bollinger), [
154
+ "upper",
155
+ "mid",
156
+ "lower",
157
+ ]),
158
+ recent20: numeric(obj(obj(r.indicators).recent20), ["high", "low"]),
152
159
  },
153
160
  fundamentals: numeric(obj(r.fundamentals), [
154
161
  "marketCapRank",
@@ -156,11 +163,18 @@ export function buildDecisionInputRecord(input) {
156
163
  "volume24hUsd",
157
164
  ]),
158
165
  }));
166
+ // Context-only movers are not executable candidates. Preserve their order
167
+ // and numeric facts without copying provider names or arbitrary free text.
168
+ add("universeMovers", obs.universeMovers, (r) => ({
169
+ symbol: id(r.symbol, 20),
170
+ ...numeric(r, ["change24hPct", "priceUsd"]),
171
+ }));
159
172
  add("futuresPositions", obs.openPositions, (r) => ({
160
173
  id: num(r.id),
161
174
  symbol: id(r.symbol, 20),
162
175
  coinId: id(r.coinId, 32),
163
176
  side: code(r.side, ["long", "short"]),
177
+ openedAt: sourceTimestamp(r.openedAt) ?? null,
164
178
  ...numeric(r, [
165
179
  "leverage",
166
180
  "marginMusd",
@@ -238,7 +252,6 @@ export function buildDecisionInputRecord(input) {
238
252
  ["news", obs.news],
239
253
  ["pmResolutions", obs.pmResolutions],
240
254
  ["newClosedTrades", obs.newClosedTrades],
241
- ["universeMovers", obs.universeMovers],
242
255
  ["journal", input.state.journal],
243
256
  ]) {
244
257
  record.counts[name] = {
@@ -247,7 +260,7 @@ export function buildDecisionInputRecord(input) {
247
260
  omitted: arr(values).length,
248
261
  };
249
262
  }
250
- record.omissions.push("unlisted_fields_and_nested_indicators_excluded");
263
+ record.omissions.push("unlisted_fields_excluded");
251
264
  if (Object.values(record.counts).some((v) => v.omitted > 0))
252
265
  record.omissions.push("list_rows_omitted");
253
266
  // Leave room for fixed outcome/error metadata. Drop the largest remaining
@@ -303,6 +316,7 @@ const OMISSIONS = [
303
316
  "config_fingerprint_unavailable",
304
317
  "observation_not_available",
305
318
  "unlisted_fields_and_nested_indicators_excluded",
319
+ "unlisted_fields_excluded",
306
320
  "list_rows_omitted",
307
321
  "byte_budget_exceeded",
308
322
  "evidence_capture_failed",
@@ -327,6 +341,7 @@ const LIST_KEYS = {
327
341
  "symbol",
328
342
  "coinId",
329
343
  "side",
344
+ "openedAt",
330
345
  "leverage",
331
346
  "marginMusd",
332
347
  "unrealizedPnlMusd",
@@ -361,6 +376,7 @@ const LIST_KEYS = {
361
376
  "decisionSupport",
362
377
  ],
363
378
  signals: ["symbol", "kind", "bias", "strength", "held"],
379
+ universeMovers: ["symbol", "change24hPct", "priceUsd"],
364
380
  };
365
381
  const NESTED_KEYS = {
366
382
  freshness: ["status", "ageSeconds", "asOf", "basis"],
@@ -391,7 +407,11 @@ const NESTED_KEYS = {
391
407
  "ema20AboveEma50",
392
408
  "brokeRecentHigh",
393
409
  "brokeRecentLow",
410
+ "bollinger",
411
+ "recent20",
394
412
  ],
413
+ bollinger: ["upper", "mid", "lower"],
414
+ recent20: ["high", "low"],
395
415
  fundamentals: ["marketCapRank", "marketCapUsd", "volume24hUsd"],
396
416
  };
397
417
  function keysOnly(value, keys) {
@@ -408,7 +428,7 @@ function validRow(value, keys) {
408
428
  return true;
409
429
  if (NESTED_KEYS[key])
410
430
  return validRow(v, NESTED_KEYS[key]);
411
- if (key === "asOf" || key === "assessedAt")
431
+ if (key === "asOf" || key === "assessedAt" || key === "openedAt")
412
432
  return sourceTimestamp(v) === v;
413
433
  if (key === "basis")
414
434
  return code(v, FRESHNESS_BASES) !== null;
@@ -489,6 +509,7 @@ export function sanitizeDecisionInputRecord(value) {
489
509
  const r = JSON.parse(encoded);
490
510
  if (!keysOnly(r, [
491
511
  "version",
512
+ "projectionVersion",
492
513
  "visibility",
493
514
  "completeness",
494
515
  "phase",
@@ -506,6 +527,9 @@ export function sanitizeDecisionInputRecord(value) {
506
527
  "omissions",
507
528
  ]))
508
529
  return undefined;
530
+ if (r.projectionVersion !== undefined &&
531
+ r.projectionVersion !== "coinrithm.decision-input-projection.v2")
532
+ return undefined;
509
533
  if (r.version !== "coinrithm.decision-input.v1" ||
510
534
  r.visibility !== "private" ||
511
535
  r.completeness !== "partial")
@@ -58,27 +58,27 @@ export function evaluateGate(observation, state, policy, nowMs) {
58
58
  // evaluate PREDICTION MARKETS — they carry edge even when crypto prices are flat,
59
59
  // so an agent on a quiet tape shouldn't go dark on PM. At most once per cooldown
60
60
  // (gated on the last LLM call, which any fire resets), so it's not every cycle.
61
+ let pmPeriodic = false;
61
62
  if (codeList.length === 0) {
62
63
  const pmAvailable = observation.pmMarkets.length > 0;
63
64
  const sinceLastCall = state.lastLlmCallAt == null ? Infinity : nowMs - state.lastLlmCallAt;
64
65
  if (pmAvailable &&
65
66
  policy.pmEvalCooldownMinutes > 0 &&
66
67
  sinceLastCall >= policy.pmEvalCooldownMinutes * 60_000) {
68
+ pmPeriodic = true;
69
+ codeList.push("PM_PERIODIC");
70
+ }
71
+ else {
67
72
  return {
68
- fire: true,
69
- codes: ["PM_PERIODIC"],
70
- reason: "PM periodic eval (quiet price tape)",
73
+ fire: false,
74
+ codes: [],
75
+ reason: "no trigger (flat tape, no open position)",
71
76
  };
72
77
  }
73
- return {
74
- fire: false,
75
- codes: [],
76
- reason: "no trigger (flat tape, no open position)",
77
- };
78
78
  }
79
79
  // A real trigger exists. Open positions are NEVER starved by budget/debounce
80
80
  // (managing a live position is always allowed); the caps below only throttle
81
- // fresh entry-only cycles so a chop-storm of entry setups can't burn the budget.
81
+ // fresh entry-only cycles, including periodic PM evaluations.
82
82
  if (!hasPosition) {
83
83
  if (policy.maxLlmCallsPerHour > 0) {
84
84
  const recent = (state.llmCallTimestamps ?? []).filter((t) => nowMs - t < 3_600_000);
@@ -90,7 +90,8 @@ export function evaluateGate(observation, state, policy, nowMs) {
90
90
  };
91
91
  }
92
92
  }
93
- if (policy.debounceMinutes > 0) {
93
+ // PM already passed its own cooldown; debounce only crypto-entry triggers.
94
+ if (!pmPeriodic && policy.debounceMinutes > 0) {
94
95
  const fp = [...codeList].sort().join(",");
95
96
  if (state.lastTriggerFingerprint === fp &&
96
97
  state.lastLlmCallAt != null &&
@@ -106,7 +107,9 @@ export function evaluateGate(observation, state, policy, nowMs) {
106
107
  return {
107
108
  fire: true,
108
109
  codes: codeList,
109
- reason: `triggers: ${codeList.join(",")}`,
110
+ reason: pmPeriodic
111
+ ? "PM periodic eval (quiet price tape)"
112
+ : `triggers: ${codeList.join(",")}`,
110
113
  };
111
114
  }
112
115
  // Record that this cycle spent an LLM call — feeds the budget + debounce next
@@ -20,7 +20,7 @@ export interface DecideRouteMeta {
20
20
  profile: "fast" | "strong" | "configured";
21
21
  effectiveProvider?: string;
22
22
  effectiveModel?: string;
23
- reason: "configured" | "circuit_fallback" | "capacity_fallback" | "provider_fallback" | "malformed_fallback" | "byo";
23
+ reason: "configured" | "configured_direct" | "circuit_fallback" | "capacity_fallback" | "provider_fallback" | "malformed_fallback" | "byo";
24
24
  attempts: DecideRouteAttempt[];
25
25
  }
26
26
  export type DecideResult = {
@@ -36,6 +36,7 @@ export type DecideResult = {
36
36
  error: string;
37
37
  status?: number;
38
38
  retryAfterMs?: number;
39
+ retryableHttpFailure?: true;
39
40
  deferred?: boolean;
40
41
  route?: DecideRouteMeta;
41
42
  };
@@ -2,6 +2,7 @@
2
2
  // never from an agent file. One call returns one chunk of text that must be a
3
3
  // single structured-JSON decision (parsed in decision.ts). No free-form tool
4
4
  // execution — the model only proposes; the runner disposes.
5
+ import { retryAfterSeconds } from "../retryAfter.js";
5
6
  import { chatShapeFor, buildChatBody, DECISION_TOOL_NAME, NVIDIA_BASE_URL as CAP_NVIDIA_BASE_URL, } from "./providerCapabilities.js";
6
7
  // NVIDIA NIM is OpenAI-compatible; the `nvidia` preset hard-wires the hosted
7
8
  // endpoint so an agent only needs `{ provider: nvidia, name: "<model id>" }`.
@@ -27,6 +28,7 @@ const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai
27
28
  // (the recurring Leo/70B timeout). A real hang still aborts -> retried next cadence.
28
29
  // MUST stay below the scheduler's RUN_LOCK_SECONDS and HEARTBEAT_STALE_MS.
29
30
  const DEFAULT_TIMEOUT_MS = 300_000;
31
+ const RETRYABLE_SERVER_STATUSES = new Set([500, 502, 503, 504]);
30
32
  // Per-route request quirks (reasoning toggles, token param, temperature) live
31
33
  // in the capability table — providerCapabilities.ts is the single source; this
32
34
  // module only assembles and sends.
@@ -58,22 +60,24 @@ function callError(err, timeoutMs) {
58
60
  }
59
61
  return err instanceof Error ? err.message : String(err);
60
62
  }
63
+ // Error text reaches retained cycle evidence. Redact before truncating so a
64
+ // credential crossing the size boundary cannot leave a partial key behind.
65
+ function safeProviderError(text, apiKey) {
66
+ return (apiKey ? text.split(apiKey).join("[redacted]") : text)
67
+ .replace(/Bearer\s+[^\s"'<>\\]+/gi, "Bearer [redacted]")
68
+ .replace(/(?:nvapi-|crk_live_|sk-|sk_live_|ghp_|AIza)[A-Za-z0-9_-]+/g, "[redacted]")
69
+ .replace(/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[redacted]")
70
+ .replace(/((?:api[_-]?key|access[_-]?token|password|secret)["']?\s*[:=]\s*["']?)[^\s"',}&<>]+/gi, "$1[redacted]")
71
+ .slice(0, 2000);
72
+ }
61
73
  // Parse a Retry-After header (delta-seconds or HTTP-date) into ms, capped at
62
74
  // one hour — a provider asking for more is treated as "an hour, then re-probe".
63
75
  const RETRY_AFTER_CAP_MS = 3_600_000;
64
76
  function retryAfterMs(res) {
65
- const raw = res.headers.get("retry-after");
66
- if (!raw)
67
- return undefined;
68
- const secs = Number(raw);
69
- if (Number.isFinite(secs) && secs >= 0) {
70
- return Math.min(Math.round(secs * 1000), RETRY_AFTER_CAP_MS);
71
- }
72
- const at = Date.parse(raw);
73
- if (!Number.isFinite(at))
74
- return undefined;
75
- const ms = at - Date.now();
76
- return ms > 0 ? Math.min(ms, RETRY_AFTER_CAP_MS) : 0;
77
+ const seconds = retryAfterSeconds(res.headers.get("retry-after"));
78
+ return seconds === undefined
79
+ ? undefined
80
+ : Math.min(Math.round(seconds * 1000), RETRY_AFTER_CAP_MS);
77
81
  }
78
82
  function envKey(provider, env) {
79
83
  switch (provider) {
@@ -165,7 +169,7 @@ class AnthropicProvider {
165
169
  ok: false,
166
170
  // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
167
171
  // unbounded provider error page must not bloat the ledger row.
168
- error: `anthropic HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
172
+ error: `anthropic HTTP ${res.status}: ${safeProviderError(await res.text(), this.apiKey)}`,
169
173
  status: res.status,
170
174
  retryAfterMs: retryAfterMs(res),
171
175
  };
@@ -186,9 +190,9 @@ class AnthropicProvider {
186
190
  catch (err) {
187
191
  return {
188
192
  ok: false,
189
- error: failureResponse
193
+ error: safeProviderError(failureResponse
190
194
  ? `anthropic HTTP ${failureResponse.status}: ${callError(err, timeoutMs)}`
191
- : callError(err, timeoutMs),
195
+ : callError(err, timeoutMs), this.apiKey),
192
196
  ...(failureResponse
193
197
  ? {
194
198
  status: failureResponse.status,
@@ -240,9 +244,12 @@ class OpenAiCompatProvider {
240
244
  ok: false,
241
245
  // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
242
246
  // unbounded provider error page must not bloat the ledger row.
243
- error: `provider HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
247
+ error: `provider HTTP ${res.status}: ${safeProviderError(await res.text(), this.apiKey)}`,
244
248
  status: res.status,
245
249
  retryAfterMs: retryAfterMs(res),
250
+ ...(RETRYABLE_SERVER_STATUSES.has(res.status)
251
+ ? { retryableHttpFailure: true }
252
+ : {}),
246
253
  };
247
254
  }
248
255
  const json = (await res.json());
@@ -263,9 +270,9 @@ class OpenAiCompatProvider {
263
270
  catch (err) {
264
271
  return {
265
272
  ok: false,
266
- error: failureResponse
273
+ error: safeProviderError(failureResponse
267
274
  ? `provider HTTP ${failureResponse.status}: ${callError(err, timeoutMs)}`
268
- : callError(err, timeoutMs),
275
+ : callError(err, timeoutMs), this.apiKey),
269
276
  ...(failureResponse
270
277
  ? {
271
278
  status: failureResponse.status,
@@ -276,6 +283,86 @@ class OpenAiCompatProvider {
276
283
  }
277
284
  }
278
285
  }
286
+ // A direct NVIDIA 5xx previously lost the whole cycle until the next cadence.
287
+ // Retry only an explicit server refusal, once, on the identical configured
288
+ // route. The shared router owns its own attempt/capacity budget and MUST NOT
289
+ // receive this wrapper. No auth/404/429, network, timeout or decision repair
290
+ // retries; no model substitution or request-parameter changes.
291
+ class SameModelRetryProvider {
292
+ delegate;
293
+ provider;
294
+ model;
295
+ label;
296
+ constructor(delegate, provider, model) {
297
+ this.delegate = delegate;
298
+ this.provider = provider;
299
+ this.model = model;
300
+ this.label = delegate.label;
301
+ }
302
+ async decide(input) {
303
+ const started = Date.now();
304
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
305
+ const first = await this.delegate.decide(input);
306
+ const firstFinished = Date.now();
307
+ if (first.ok || !first.retryableHttpFailure) {
308
+ return first;
309
+ }
310
+ const delayMs = Math.max(1000, first.retryAfterMs ?? 0);
311
+ // Honor the provider's cooldown; a long one waits for the next cycle. Both
312
+ // attempts and this backoff share the ORIGINAL deadline, never two 5m caps.
313
+ if (delayMs > 5000 || delayMs >= timeoutMs - (firstFinished - started)) {
314
+ return first;
315
+ }
316
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
317
+ const retryStarted = Date.now();
318
+ const remainingMs = timeoutMs - (retryStarted - started);
319
+ if (remainingMs <= 0)
320
+ return first;
321
+ const result = await this.delegate.decide({
322
+ ...input,
323
+ timeoutMs: remainingMs,
324
+ });
325
+ const attempt = (res, latencyMs) => ({
326
+ provider: this.provider,
327
+ model: this.model,
328
+ outcome: res.ok ? "success" : "failed",
329
+ latencyMs,
330
+ ...(!res.ok
331
+ ? {
332
+ error: res.error,
333
+ status: res.status,
334
+ retryAfterMs: res.retryAfterMs,
335
+ ...(res.status !== undefined
336
+ ? {
337
+ failureClass: res.status === 429
338
+ ? "capacity"
339
+ : res.status >= 500
340
+ ? "transient"
341
+ : "permanent",
342
+ }
343
+ : {}),
344
+ }
345
+ : {}),
346
+ });
347
+ return {
348
+ // Usage, when present, describes only the final response. The preceding
349
+ // 5xx did not report usage; attempt evidence is not total billed tokens
350
+ // or a certificate that returned text passed the trading-decision parser.
351
+ ...result,
352
+ route: {
353
+ policyVersion: "coinrithm.configured-same-model-retry.v1",
354
+ profile: "configured",
355
+ effectiveProvider: this.provider,
356
+ effectiveModel: this.model,
357
+ reason: "configured_direct",
358
+ attempts: [
359
+ attempt(first, firstFinished - started),
360
+ attempt(result, Date.now() - retryStarted),
361
+ ],
362
+ },
363
+ };
364
+ }
365
+ }
279
366
  // Build the provider from the spec's model block + env key. Throws a clear
280
367
  // error if no model is configured (self-host requires one) or the env key is
281
368
  // missing. fetch is injectable for tests.
@@ -308,7 +395,10 @@ export function selectProvider(spec, env, fetchFn = fetch) {
308
395
  if (!resolvedBase) {
309
396
  throw new Error("openai-compatible provider needs model.baseUrl");
310
397
  }
311
- return new OpenAiCompatProvider(provider, name, key, resolvedBase, fetchFn);
398
+ const direct = new OpenAiCompatProvider(provider, name, key, resolvedBase, fetchFn);
399
+ return provider === "nvidia"
400
+ ? new SameModelRetryProvider(direct, provider, name)
401
+ : direct;
312
402
  }
313
403
  // Build a provider for an EXPLICIT route + raw key (no spec, no env) — the
314
404
  // decision probe's entry point. Same classes as selectProvider, so a probe
@@ -1291,13 +1291,18 @@ async function runCycleCore(deps, capture) {
1291
1291
  state.consecutiveExecFailures =
1292
1292
  anyExecFailed && !anyExecuted ? state.consecutiveExecFailures + 1 : 0;
1293
1293
  state.rateLimitHits = client.rateLimitHits ?? state.rateLimitHits;
1294
- // Slice-3 memory: journal the accepted move(s) + the thesis behind them so next
1295
- // cycle has continuity (manage with memory of WHY; don't re-open what we just did).
1294
+ // Memory describes completed moves, not policy acceptance. A backend rejection,
1295
+ // lost response, or dry-run plan must not become "opened" / "trailed stop" in
1296
+ // the next model prompt. Attempts remain in planned[] and the audit ledger.
1296
1297
  const moves = planned
1297
- .filter((p) => p.accepted)
1298
+ .filter((p) => p.accepted && p.executed === true)
1298
1299
  .map((p) => summarizeAction(p.action));
1299
1300
  if (moves.length > 0) {
1300
- const did = `${moves.join("; ")}${rationale ? ` — ${rationale.slice(0, 90)}` : ""}`;
1301
+ // A cycle-wide rationale may describe a failed/rejected sibling action.
1302
+ // Keep it only when every proposed action executed; per-action theses are
1303
+ // already included by summarizeAction for confirmed opens.
1304
+ const confirmedRationale = moves.length === planned.length ? rationale : undefined;
1305
+ const did = `${moves.join("; ")}${confirmedRationale ? ` — ${confirmedRationale.slice(0, 90)}` : ""}`;
1301
1306
  state.journal = [
1302
1307
  ...(state.journal ?? []),
1303
1308
  { at: observation.asOf, did },
@@ -1,6 +1,7 @@
1
1
  // Local run state: the cursor, dedupe set, daily counters, and the kill-switch
2
2
  // inputs. Persisted to a JSON file so a re-run resumes where it left off.
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, rmSync, } from "node:fs";
4
+ import { randomUUID } from "node:crypto";
4
5
  import { dirname } from "node:path";
5
6
  import { dayKey } from "./util.js";
6
7
  import { asObj, asNum } from "./extract.js";
@@ -59,8 +60,22 @@ export function loadState(file, runId) {
59
60
  export function saveState(file, state) {
60
61
  if (!file)
61
62
  return;
63
+ const serialized = JSON.stringify(state, null, 2);
62
64
  mkdirSync(dirname(file), { recursive: true });
63
- writeFileSync(file, JSON.stringify(state, null, 2), "utf8");
65
+ // A sibling file keeps rename on the same filesystem. Readers see either
66
+ // the previous complete state or the new one, never a truncated JSON write.
67
+ const temporary = `${file}.${randomUUID()}.tmp`;
68
+ try {
69
+ writeFileSync(temporary, serialized, {
70
+ encoding: "utf8",
71
+ flag: "wx",
72
+ mode: 0o600,
73
+ });
74
+ renameSync(temporary, file);
75
+ }
76
+ finally {
77
+ rmSync(temporary, { force: true });
78
+ }
64
79
  }
65
80
  export function rollDay(state) {
66
81
  const today = dayKey();
@@ -182,6 +182,10 @@ export function ejectFiles(fm, body) {
182
182
  };
183
183
  if (fm.sizing)
184
184
  agentFm.sizing = fm.sizing;
185
+ if (fm.capitalSizing !== undefined)
186
+ agentFm.capitalSizing = fm.capitalSizing;
187
+ if (fm.triggerPolicy !== undefined)
188
+ agentFm.triggerPolicy = fm.triggerPolicy;
185
189
  if (fm.objective)
186
190
  agentFm.objective = fm.objective;
187
191
  if (fm.capabilities)
package/dist/client.js CHANGED
@@ -17,6 +17,7 @@
17
17
  //
18
18
  // IMPORTANT: this module must NEVER write to stdout (stdout is the MCP JSON-RPC
19
19
  // channel). All diagnostics go to stderr via the logger below.
20
+ import { retryAfterSeconds } from "./retryAfter.js";
20
21
  export const DEFAULT_BASE_URL = "https://api.coinrithm.com";
21
22
  export function log(...args) {
22
23
  // stderr only — stdout is reserved for the MCP protocol.
@@ -139,12 +140,12 @@ export class CoinRithmClient {
139
140
  if (res.status === 429) {
140
141
  // Surface the back-off contract so an agent can pace itself instead of
141
142
  // hammering: 120 req/min per key baseline, 20 trade-writes/min.
142
- const retryAfter = Number(res.headers.get("retry-after"));
143
+ const retryAfter = retryAfterSeconds(res.headers.get("retry-after"));
143
144
  data = {
144
145
  ...(typeof data === "object" && data !== null
145
146
  ? data
146
147
  : { error: String(data) }),
147
- retryAfterSeconds: Number.isFinite(retryAfter) ? retryAfter : null,
148
+ retryAfterSeconds: retryAfter ?? null,
148
149
  hint: "Rate limited. Wait retryAfterSeconds (or the Retry-After header) before retrying; pace future calls using the RateLimit-Remaining response header.",
149
150
  };
150
151
  }
package/dist/http.d.ts CHANGED
@@ -1,2 +1,8 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { CoinRithmClient } from "./client.js";
4
+ import { type CompletionLogger } from "./httpCompletion.js";
5
+ export declare function createHttpApp(client: CoinRithmClient, options?: {
6
+ completionLogger?: CompletionLogger;
7
+ createServer?: () => McpServer;
8
+ }): import("express-serve-static-core").Express;
package/dist/http.js CHANGED
@@ -24,8 +24,8 @@
24
24
  // forwards as `extra.authInfo`, giving requestKey() a second source. Either
25
25
  // way the caller's own key — and only that key — is used for their tool call.
26
26
  // - Unauthenticated MCP initialization and tool-list introspection are allowed
27
- // so registries can verify the server. Actual tool calls without a key return
28
- // a structured 401 from CoinRithmClient before any upstream request is made.
27
+ // so registries can verify the server. Public data tools are also keyless;
28
+ // protected tools return a structured 401 when their key is missing.
29
29
  //
30
30
  // Config (env):
31
31
  // COINRITHM_API_URL (optional) upstream base URL (default production).
@@ -35,15 +35,23 @@
35
35
  // the correct isolation model for a multi-user, per-request-keyed surface — no
36
36
  // session state is shared between users.
37
37
  import express from "express";
38
+ import { pathToFileURL } from "node:url";
38
39
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
39
40
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
40
41
  import { CoinRithmClient, bearerFromHeader, loadHttpConfig, log, } from "./client.js";
41
42
  import { registerTools } from "./tools.js";
42
43
  import { SERVER_VERSION } from "./version.js";
43
- async function main() {
44
- const config = loadHttpConfig(); // no global key — keys arrive per request
45
- const client = new CoinRithmClient(config); // constructed WITHOUT a default key
44
+ import { observeHttpCompletion, } from "./httpCompletion.js";
45
+ // Factory permits isolated localhost SDK tests without opening a listener on import.
46
+ export function createHttpApp(client, options = {}) {
46
47
  const app = express();
48
+ const completions = new WeakMap();
49
+ app.use((req, res, next) => {
50
+ if (req.method === "POST" && /^\/mcp\/?$/i.test(req.path)) {
51
+ completions.set(res, observeHttpCompletion(req, res, options.completionLogger));
52
+ }
53
+ next();
54
+ });
47
55
  app.use(express.json());
48
56
  // Lightweight, unauthenticated liveness probe (handy for Coolify/uptime checks).
49
57
  // Keep `/healthz` as the deployment contract and expose `/health` as a
@@ -110,8 +118,8 @@ async function main() {
110
118
  // Per-request auth: read THIS caller's key from the Authorization header,
111
119
  // or from Smithery's non-reserved forwarding header.
112
120
  // It is optional at the transport layer so registries can initialize the
113
- // server and list tool schemas. Tool handlers still require a key and return
114
- // a structured 401 if one is missing.
121
+ // server and list tool schemas. Public data tools are keyless; protected
122
+ // tool handlers return a structured 401 if their key is missing.
115
123
  const apiKey = bearerFromHeader(req.headers.authorization) ??
116
124
  bearerFromHeader(req.headers["x-coinrithm-api-key"]);
117
125
  // Belt-and-suspenders: also expose the token via the SDK's authInfo channel.
@@ -120,11 +128,13 @@ async function main() {
120
128
  if (apiKey) {
121
129
  req.auth = { token: apiKey, clientId: "coinrithm-key", scopes: [] };
122
130
  }
123
- const server = new McpServer({
124
- name: "coinrithm-trading",
125
- version: SERVER_VERSION,
126
- });
127
- registerTools(server, client);
131
+ const server = options.createServer?.() ??
132
+ new McpServer({
133
+ name: "coinrithm-trading",
134
+ version: SERVER_VERSION,
135
+ });
136
+ if (!options.createServer)
137
+ registerTools(server, client);
128
138
  const transport = new StreamableHTTPServerTransport({
129
139
  sessionIdGenerator: undefined, // stateless: no cross-request/user state
130
140
  });
@@ -134,6 +144,7 @@ async function main() {
134
144
  });
135
145
  try {
136
146
  await server.connect(transport);
147
+ completions.get(res)?.attach(transport);
137
148
  // The transport reads req.headers (→ extra.requestInfo) and req.auth
138
149
  // (→ extra.authInfo); tools.ts picks up the caller's key from there.
139
150
  await transport.handleRequest(req, res, req.body);
@@ -149,13 +160,22 @@ async function main() {
149
160
  }
150
161
  }
151
162
  });
163
+ return app;
164
+ }
165
+ async function main() {
166
+ const config = loadHttpConfig(); // no global key — keys arrive per request
167
+ const client = new CoinRithmClient(config); // constructed WITHOUT a default key
168
+ const app = createHttpApp(client);
152
169
  const port = Number(process.env.PORT) || 8787;
153
170
  app.listen(port, () => {
154
171
  log(`HTTP MCP listening on :${port}/mcp (multi-user, per-request key). ` +
155
172
  `upstream=${config.baseUrl}. Paper only.`);
156
173
  });
157
174
  }
158
- main().catch((err) => {
159
- log("fatal:", err instanceof Error ? err.message : err);
160
- process.exit(1);
161
- });
175
+ if (process.argv[1] &&
176
+ import.meta.url === pathToFileURL(process.argv[1]).href) {
177
+ main().catch((err) => {
178
+ log("fatal:", err instanceof Error ? err.message : err);
179
+ process.exit(1);
180
+ });
181
+ }
@@ -0,0 +1,33 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
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", "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_disagreements", "pm_data_calibration", "pm_data_canonical", "pm_data_volume_history", "get_crypto_movers"];
4
+ type Operation = "initialize" | "tools_list" | "tools_call" | "notification" | "other" | "invalid";
5
+ type Outcome = "result" | "tool_error" | "protocol_error" | "no_response" | "not_applicable";
6
+ type ToolName = (typeof COMPLETION_TOOL_NAMES)[number] | "unknown" | null;
7
+ type RpcCompletion = {
8
+ operation: Operation;
9
+ tool: ToolName;
10
+ rpc_outcome: Outcome;
11
+ result_http_status: number | null;
12
+ result_ok: boolean | null;
13
+ };
14
+ export type HttpCompletionRecord = Readonly<RpcCompletion & {
15
+ event: "mcp_completion";
16
+ schema_version: 1;
17
+ completed_at: string;
18
+ duration_ms: number;
19
+ service_version: string;
20
+ transport: "streamable_http";
21
+ credential_supplied: boolean;
22
+ http_status: number | null;
23
+ delivery: "finished" | "aborted";
24
+ }>;
25
+ export type CompletionLogger = (line: string) => void;
26
+ /** Attach before JSON parsing; malformed/rejected HTTP requests remain invalid,
27
+ * no_response, with their actual HTTP status. Do not read bodies or raw errors.
28
+ * Authentication and caller origin are deliberately NOT inferred from headers.
29
+ */
30
+ export declare function observeHttpCompletion(req: IncomingMessage, res: ServerResponse, logger?: CompletionLogger): {
31
+ attach: (transport: Transport) => void;
32
+ };
33
+ export {};