@coinrithm/mcp-trading 0.7.7 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +86 -69
  3. package/dist/agent/act.js +19 -6
  4. package/dist/agent/capitalSizing.d.ts +32 -0
  5. package/dist/agent/capitalSizing.js +270 -0
  6. package/dist/agent/client.d.ts +8 -1
  7. package/dist/agent/client.js +98 -39
  8. package/dist/agent/decision.d.ts +392 -0
  9. package/dist/agent/decision.js +177 -0
  10. package/dist/agent/decisionReceipt.d.ts +48 -0
  11. package/dist/agent/decisionReceipt.js +619 -0
  12. package/dist/agent/decisionValidator.d.ts +19 -2
  13. package/dist/agent/decisionValidator.js +74 -3
  14. package/dist/agent/engine.d.ts +1 -0
  15. package/dist/agent/engine.js +1 -0
  16. package/dist/agent/gate.js +14 -11
  17. package/dist/agent/observe.js +175 -34
  18. package/dist/agent/pmContext.d.ts +13 -0
  19. package/dist/agent/pmContext.js +136 -0
  20. package/dist/agent/prompt.d.ts +11 -1
  21. package/dist/agent/prompt.js +135 -7
  22. package/dist/agent/providerCapabilities.d.ts +3 -0
  23. package/dist/agent/providerCapabilities.js +41 -3
  24. package/dist/agent/providers.d.ts +2 -1
  25. package/dist/agent/providers.js +222 -86
  26. package/dist/agent/resolve.js +1 -0
  27. package/dist/agent/runner.d.ts +4 -1
  28. package/dist/agent/runner.js +369 -38
  29. package/dist/agent/scorecard.js +7 -1
  30. package/dist/agent/skill.js +17 -0
  31. package/dist/agent/skillValidator.d.ts +1 -0
  32. package/dist/agent/skillValidator.js +56 -0
  33. package/dist/agent/state.js +23 -2
  34. package/dist/agent/strictLint.js +19 -0
  35. package/dist/agent/templates.js +4 -0
  36. package/dist/agent/thesis.d.ts +40 -0
  37. package/dist/agent/thesis.js +319 -0
  38. package/dist/agent/types.d.ts +127 -0
  39. package/dist/client.js +3 -2
  40. package/dist/http.d.ts +7 -1
  41. package/dist/http.js +36 -16
  42. package/dist/httpCompletion.d.ts +33 -0
  43. package/dist/httpCompletion.js +220 -0
  44. package/dist/retryAfter.d.ts +1 -0
  45. package/dist/retryAfter.js +16 -0
  46. package/dist/tools.js +19 -1
  47. package/package.json +4 -2
@@ -1,4 +1,5 @@
1
1
  import { IndicatorSet } from "./indicators.js";
2
+ import type { DecisionInputRecord } from "./decisionReceipt.js";
2
3
  export declare const SPEC_VERSION = "coinrithm.agent.v1";
3
4
  export type Venue = "spot" | "futures" | "pm";
4
5
  export declare const VENUES: readonly Venue[];
@@ -39,6 +40,38 @@ export interface LimitsConfig {
39
40
  maxDailyLossMusd: number;
40
41
  maxOpenMarginMusd: number;
41
42
  }
43
+ /** Opt-in paper experiment. Percent fields are percentage POINTS, not fractions. */
44
+ export interface CapitalSizingPolicy {
45
+ version: "equity_fraction_v1";
46
+ futuresRiskPct: number;
47
+ pmMaxLossPct: number;
48
+ perTicketCapitalPct: number;
49
+ totalCapitalPct: number;
50
+ cashReservePct: number;
51
+ minRewardRisk: number;
52
+ }
53
+ export type CapitalBook = {
54
+ status: "unavailable";
55
+ reason: string;
56
+ } | {
57
+ status: "ready";
58
+ walletId: number;
59
+ /** Marked spot + collateral, reduced by known negative position marks.
60
+ * Positive open-position gains are excluded; this is NOT complete MTM. */
61
+ conservativeEquityMusd: number;
62
+ cashAvailableMusd: number;
63
+ committedCapitalMusd: number;
64
+ };
65
+ export interface CapitalSizingAdjustment {
66
+ version: "equity_fraction_v1";
67
+ basis: "owned_collateral_spot_marked_negative_position_marks_only";
68
+ proposedAmountMusd?: number;
69
+ sizedAmountMusd?: number;
70
+ conservativeEquityMusd?: number;
71
+ riskBudgetMusd?: number;
72
+ /** Runner safety estimate, not a claim that the venue charges this fee. */
73
+ feeBufferBps?: number;
74
+ }
42
75
  export interface AbstentionConfig {
43
76
  onStaleData: boolean;
44
77
  onWeakSignal: boolean;
@@ -72,6 +105,7 @@ export interface AgentSpec {
72
105
  model?: ModelConfig;
73
106
  venues: Venue[];
74
107
  risk: RiskConfig;
108
+ capitalSizing?: CapitalSizingPolicy;
75
109
  limits: LimitsConfig;
76
110
  abstention: AbstentionConfig;
77
111
  sync: SyncConfig;
@@ -95,6 +129,8 @@ export declare const fail: (code: string, reason: string) => ValidationResult;
95
129
  export interface Freshness {
96
130
  status: string;
97
131
  ageSeconds?: number;
132
+ asOf?: string;
133
+ basis?: string;
98
134
  }
99
135
  export interface WatchEntry {
100
136
  symbol: string;
@@ -108,10 +144,25 @@ export interface WatchEntry {
108
144
  freshness?: Freshness;
109
145
  indicators?: IndicatorSet;
110
146
  discovered?: boolean;
147
+ slug?: string;
148
+ fundamentals?: CoinFundamentals;
149
+ }
150
+ export interface CoinFundamentals {
151
+ categories?: string[];
152
+ marketCapRank?: number;
153
+ marketCapUsd?: number;
154
+ volume24hUsd?: number;
155
+ headlines?: Array<{
156
+ title: string;
157
+ at?: string;
158
+ importance?: number;
159
+ sentiment?: string;
160
+ }>;
111
161
  }
112
162
  export interface OpenPosition {
113
163
  venue: Venue;
114
164
  id: number;
165
+ walletId?: number;
115
166
  coinId?: string;
116
167
  symbol?: string;
117
168
  side?: string;
@@ -124,6 +175,8 @@ export interface OpenPosition {
124
175
  liquidationPrice?: number;
125
176
  stopLossPrice?: number;
126
177
  takeProfitPrice?: number;
178
+ openedAt?: string;
179
+ thesis?: ThesisView;
127
180
  }
128
181
  export interface SpotOrder {
129
182
  id: number;
@@ -136,12 +189,19 @@ export interface SpotOrder {
136
189
  }
137
190
  export interface PmPosition {
138
191
  id: number;
192
+ walletId?: number;
139
193
  source?: string;
140
194
  slug?: string;
141
195
  outcomeExternalMarketId?: string;
142
196
  stakeMusd?: number;
143
197
  unrealizedPnlMusd?: number;
144
198
  status?: string;
199
+ title?: string;
200
+ side?: string;
201
+ entryProbability?: number;
202
+ currentProbability?: number;
203
+ openedAt?: string;
204
+ thesis?: ThesisView;
145
205
  }
146
206
  export interface PmResolution {
147
207
  id: number;
@@ -162,6 +222,27 @@ export interface PmMarket {
162
222
  title?: string;
163
223
  freshness?: Freshness;
164
224
  volumeUsd?: number;
225
+ endDate?: string;
226
+ liquidityUsd?: number;
227
+ quality?: PmQuality;
228
+ decisionSupport?: PmDecisionSupport;
229
+ }
230
+ export interface PmQuality {
231
+ decisionEligible?: boolean;
232
+ warningReasons: string[];
233
+ blockReasons: string[];
234
+ policyVersion?: string;
235
+ assessedAt?: string;
236
+ reasonsOmitted?: boolean;
237
+ }
238
+ export interface PmDecisionSupport {
239
+ qualityScore?: number;
240
+ qualityTier?: string;
241
+ qualityCapReason?: string | null;
242
+ spreadTier?: string;
243
+ liquidityTier?: string;
244
+ volumeTier?: string;
245
+ flags?: Partial<Record<"thinMarket" | "inactiveMarket" | "highAmbiguity" | "nearResolution" | "staleData", boolean>>;
165
246
  }
166
247
  export interface SetupSignal {
167
248
  symbol: string;
@@ -177,6 +258,7 @@ export interface NewsItem {
177
258
  sentiment?: string;
178
259
  importance?: number;
179
260
  ageHours?: number;
261
+ publishedAt?: string;
180
262
  coins?: string[];
181
263
  }
182
264
  export interface Observation {
@@ -184,6 +266,7 @@ export interface Observation {
184
266
  scopes: string[];
185
267
  cashAvailableMusd: number | null;
186
268
  equityMusd: number | null;
269
+ capitalBook?: CapitalBook;
187
270
  openPositions: OpenPosition[];
188
271
  openOrders: SpotOrder[];
189
272
  pmPositions: PmPosition[];
@@ -206,6 +289,35 @@ export interface Observation {
206
289
  priceUsd?: number;
207
290
  }>;
208
291
  }
292
+ export interface ThesisInvalidation {
293
+ priceBelow?: number;
294
+ priceAbove?: number;
295
+ probabilityBelow?: number;
296
+ probabilityAbove?: number;
297
+ maxHoldMinutes?: number;
298
+ catalyst?: string;
299
+ }
300
+ export interface Thesis {
301
+ summary: string;
302
+ invalidation: ThesisInvalidation;
303
+ }
304
+ export interface PositionThesis extends Thesis {
305
+ venue: Venue;
306
+ positionId: number;
307
+ symbol?: string;
308
+ side?: string;
309
+ source?: string;
310
+ slug?: string;
311
+ outcomeExternalMarketId?: string;
312
+ openedAt: string;
313
+ entryPrice?: number;
314
+ entryProbability?: number;
315
+ }
316
+ export interface ThesisView extends Thesis {
317
+ holdMinutes: number;
318
+ status: "intact" | "invalidated";
319
+ invalidatedBy?: string;
320
+ }
209
321
  export type ProposedAction = {
210
322
  type: "futures_open";
211
323
  symbol: string;
@@ -216,6 +328,7 @@ export type ProposedAction = {
216
328
  takeProfitPrice?: number | null;
217
329
  confidence?: number;
218
330
  rationaleSummary?: string;
331
+ thesis?: Thesis;
219
332
  } | {
220
333
  type: "futures_close";
221
334
  positionId: number;
@@ -237,6 +350,7 @@ export type ProposedAction = {
237
350
  stopPrice?: number;
238
351
  confidence?: number;
239
352
  rationaleSummary?: string;
353
+ thesis?: Thesis;
240
354
  } | {
241
355
  type: "spot_cancel";
242
356
  orderId: number;
@@ -250,6 +364,7 @@ export type ProposedAction = {
250
364
  confidence?: number;
251
365
  rationaleSummary?: string;
252
366
  forecastProbability?: number;
367
+ thesis?: Thesis;
253
368
  };
254
369
  export type ActionVenue = Venue;
255
370
  export declare function actionVenue(a: ProposedAction): ActionVenue;
@@ -273,7 +388,14 @@ export interface QuoteEvidence {
273
388
  entryPrice?: number;
274
389
  liquidationPrice?: number;
275
390
  executionPrice?: number;
391
+ entryProbability?: number;
392
+ stakeMusd?: number;
393
+ sharesEstimate?: number;
276
394
  estimatedCostMusd?: number;
395
+ estimatedFeeMusd?: number;
396
+ futuresFeeBps?: number;
397
+ estimatedEntryFeeMusd?: number;
398
+ cashRequiredMusd?: number;
277
399
  freshness?: Freshness;
278
400
  openBlocked?: boolean;
279
401
  openBlockReasons?: unknown;
@@ -282,6 +404,7 @@ export interface RunState {
282
404
  runId: string;
283
405
  cyclesRun: number;
284
406
  writesToday: number;
407
+ riskIncreasesToday: number;
285
408
  realizedPnlMusd: number;
286
409
  peakRealizedMusd: number;
287
410
  consecutiveRejectCycles: number;
@@ -304,6 +427,7 @@ export interface RunState {
304
427
  at: string;
305
428
  did: string;
306
429
  }>;
430
+ theses?: Record<string, PositionThesis>;
307
431
  }
308
432
  export interface AgentTrace {
309
433
  runId?: string;
@@ -330,8 +454,11 @@ export interface PlannedAction {
330
454
  quote?: QuoteEvidence;
331
455
  executed?: boolean;
332
456
  result?: unknown;
457
+ capitalSizing?: CapitalSizingAdjustment;
333
458
  }
334
459
  export interface CycleResult {
460
+ /** Private non-enumerable evidence. Never forward through AgentTrace/public APIs. */
461
+ decisionInputRecord?: DecisionInputRecord;
335
462
  decision: "skip" | "act";
336
463
  skipReason?: string;
337
464
  rationale?: string;
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 {};
@@ -0,0 +1,220 @@
1
+ import { bearerFromHeader, log } from "./client.js";
2
+ import { SERVER_VERSION } from "./version.js";
3
+ // Explicit, server-owned dimensions; tests compare this with actual tools/list.
4
+ // Never retain a caller's unknown tool name, even truncated or hashed.
5
+ export const COMPLETION_TOOL_NAMES = [
6
+ "whoami",
7
+ "get_portfolio",
8
+ "get_wallet",
9
+ "list_open_orders",
10
+ "get_positions",
11
+ "resolve_symbol",
12
+ "get_equity_curve",
13
+ "get_my_trades",
14
+ "get_market_context",
15
+ "get_candles",
16
+ "discover_pm_markets",
17
+ "get_performance",
18
+ "get_agent_ledger",
19
+ "export_agent_ledger",
20
+ "export_run_evidence",
21
+ "get_arena_leaderboard",
22
+ "get_arena_agent",
23
+ "futures_quote",
24
+ "pm_quote",
25
+ "spot_quote",
26
+ "place_spot_order",
27
+ "cancel_spot_order",
28
+ "open_futures_position",
29
+ "set_futures_sl_tp",
30
+ "close_futures_position",
31
+ "open_pm_position",
32
+ "report_pm_opportunity",
33
+ "pm_data_overview",
34
+ "pm_data_sources",
35
+ "pm_data_sources_health",
36
+ "pm_data_events",
37
+ "pm_data_event",
38
+ "pm_data_whales",
39
+ "pm_data_disagreements",
40
+ "pm_data_calibration",
41
+ "pm_data_canonical",
42
+ "pm_data_volume_history",
43
+ "get_crypto_movers",
44
+ ];
45
+ const toolNames = new Set(COMPLETION_TOOL_NAMES);
46
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
47
+ ? value
48
+ : undefined;
49
+ const status = (value) => typeof value === "number" &&
50
+ Number.isInteger(value) &&
51
+ (value === 0 || (value >= 100 && value <= 599))
52
+ ? value
53
+ : null;
54
+ const empty = (operation = "invalid", tool = null) => ({
55
+ operation,
56
+ tool,
57
+ rpc_outcome: "no_response",
58
+ result_http_status: null,
59
+ result_ok: null,
60
+ });
61
+ /** Attach before JSON parsing; malformed/rejected HTTP requests remain invalid,
62
+ * no_response, with their actual HTTP status. Do not read bodies or raw errors.
63
+ * Authentication and caller origin are deliberately NOT inferred from headers.
64
+ */
65
+ export function observeHttpCompletion(req, res, logger = (line) => log(line)) {
66
+ const started = performance.now();
67
+ const credentialSupplied = Boolean(bearerFromHeader(req.headers.authorization) ??
68
+ bearerFromHeader(req.headers["x-coinrithm-api-key"]));
69
+ const records = [];
70
+ // IDs are transient correlations only, never copied into records/logs. Clear
71
+ // on completion so a late tool callback cannot retain IDs or emit twice.
72
+ const pending = new Map();
73
+ let completed = false;
74
+ let delivery;
75
+ let sendsInFlight = 0;
76
+ const emit = () => {
77
+ if (completed || !delivery || (delivery === "finished" && sendsInFlight))
78
+ return;
79
+ completed = true;
80
+ res.off("finish", onFinish);
81
+ res.off("close", onClose);
82
+ res.off("error", onAbort);
83
+ const elapsed = performance.now() - started;
84
+ const duration = Number.isFinite(elapsed)
85
+ ? Math.max(0, Math.round(elapsed * 1000) / 1000)
86
+ : 0;
87
+ const completedAt = new Date().toISOString();
88
+ // A pre-header disconnect has no HTTP status; Node's default 200 is not one.
89
+ const httpStatus = res.headersSent ? status(res.statusCode) : null;
90
+ const snapshots = records.length ? records : [empty()];
91
+ for (const rpc of snapshots) {
92
+ const record = {
93
+ event: "mcp_completion",
94
+ schema_version: 1,
95
+ completed_at: completedAt,
96
+ duration_ms: duration,
97
+ service_version: SERVER_VERSION,
98
+ transport: "streamable_http",
99
+ ...rpc,
100
+ credential_supplied: credentialSupplied,
101
+ http_status: httpStatus,
102
+ delivery,
103
+ };
104
+ try {
105
+ logger(JSON.stringify(record));
106
+ }
107
+ catch {
108
+ // Observability must never change a tool response or recurse into logging.
109
+ }
110
+ }
111
+ records.length = 0;
112
+ pending.clear();
113
+ };
114
+ const onFinish = () => {
115
+ delivery = "finished";
116
+ emit();
117
+ };
118
+ const onAbort = () => {
119
+ delivery = "aborted";
120
+ // A computed response in an interrupted stream is not confirmed delivered.
121
+ // In particular, a still-pending send may reject after this close event.
122
+ for (const rpc of records) {
123
+ if (rpc.rpc_outcome !== "not_applicable") {
124
+ rpc.rpc_outcome = "no_response";
125
+ rpc.result_http_status = null;
126
+ rpc.result_ok = null;
127
+ }
128
+ }
129
+ emit();
130
+ };
131
+ const onClose = () => {
132
+ if (!res.writableFinished)
133
+ onAbort();
134
+ else
135
+ onFinish();
136
+ };
137
+ res.once("finish", onFinish);
138
+ res.once("close", onClose);
139
+ res.once("error", onAbort);
140
+ const incoming = (message) => {
141
+ if (completed)
142
+ return;
143
+ if (!("method" in message)) {
144
+ records.push({ ...empty("other"), rpc_outcome: "not_applicable" });
145
+ return;
146
+ }
147
+ if (!("id" in message)) {
148
+ records.push({ ...empty("notification"), rpc_outcome: "not_applicable" });
149
+ return;
150
+ }
151
+ const operation = message.method === "initialize"
152
+ ? "initialize"
153
+ : message.method === "tools/list"
154
+ ? "tools_list"
155
+ : message.method === "tools/call"
156
+ ? "tools_call"
157
+ : "other";
158
+ const name = operation === "tools_call" ? object(message.params)?.name : undefined;
159
+ const tool = operation !== "tools_call"
160
+ ? null
161
+ : typeof name === "string" && toolNames.has(name)
162
+ ? name
163
+ : "unknown";
164
+ const rpc = empty(operation, tool);
165
+ records.push(rpc);
166
+ pending.set(message.id, [...(pending.get(message.id) ?? []), rpc]);
167
+ };
168
+ return {
169
+ attach(transport) {
170
+ // Call AFTER server.connect (which installs SDK callbacks), before handling
171
+ // this request. Observe only SDK-accepted messages, not unvalidated bodies.
172
+ const onmessage = transport.onmessage;
173
+ transport.onmessage = (message, extra) => {
174
+ incoming(message);
175
+ onmessage?.(message, extra);
176
+ };
177
+ const send = transport.send.bind(transport);
178
+ transport.send = async (message, options) => {
179
+ const matches = "id" in message ? pending.get(message.id) : undefined;
180
+ // Duplicate IDs are ambiguous; never attribute one response twice.
181
+ const rpc = !completed && matches?.length === 1 ? matches[0] : undefined;
182
+ if (!rpc || (!("result" in message) && !("error" in message))) {
183
+ return send(message, options);
184
+ }
185
+ // This is the SDK's FINAL result, after input/output schema validation.
186
+ rpc.result_http_status = null;
187
+ rpc.result_ok = null;
188
+ if ("error" in message)
189
+ rpc.rpc_outcome = "protocol_error";
190
+ else {
191
+ const result = object(message.result);
192
+ const structured = object(result?.structuredContent);
193
+ rpc.rpc_outcome =
194
+ rpc.operation === "tools_call" && result?.isError === true
195
+ ? "tool_error"
196
+ : "result";
197
+ if (rpc.operation === "tools_call") {
198
+ rpc.result_http_status = status(structured?.httpStatus);
199
+ rpc.result_ok =
200
+ typeof structured?.ok === "boolean" ? structured.ok : null;
201
+ }
202
+ }
203
+ sendsInFlight += 1;
204
+ try {
205
+ return await send(message, options);
206
+ }
207
+ catch (error) {
208
+ rpc.rpc_outcome = "no_response";
209
+ rpc.result_http_status = null;
210
+ rpc.result_ok = null;
211
+ throw error;
212
+ }
213
+ finally {
214
+ sendsInFlight -= 1;
215
+ emit();
216
+ }
217
+ };
218
+ },
219
+ };
220
+ }
@@ -0,0 +1 @@
1
+ export declare function retryAfterSeconds(value: string | null, now?: number): number | undefined;
@@ -0,0 +1,16 @@
1
+ // Retry-After permits a delay in seconds or an HTTP date. Missing/invalid
2
+ // evidence stays absent, so each caller can apply its explicit fallback.
3
+ export function retryAfterSeconds(value, now = Date.now()) {
4
+ const raw = value?.trim();
5
+ if (!raw)
6
+ return undefined;
7
+ if (/^\d+(?:\.\d+)?$/.test(raw)) {
8
+ const seconds = Number(raw);
9
+ return Number.isFinite(seconds) ? seconds : undefined;
10
+ }
11
+ // Avoid Date.parse treating malformed numeric delays (e.g. "-1") as dates.
12
+ if (!/^[A-Za-z]{3}(?:,|day,|\s)/.test(raw))
13
+ return undefined;
14
+ const date = Date.parse(raw);
15
+ return Number.isFinite(date) ? Math.max(0, (date - now) / 1000) : undefined;
16
+ }