chain-insights 0.54.2 → 0.55.0

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.
@@ -102,7 +102,7 @@ const ROUTING_TWO_FIT = "A transaction is a chain question: read it on `USE chai
102
102
  /** The key that each layer hands to the next. Each key keeps its name. */
103
103
  const ROUTING_HANDOFF = "The layers hand each other keys, and each key keeps its name. Topology gives the pair and the first and last seen time. Facts gives the `tx_id` and the `block_height`. Chain takes an `address`, a `tx_id`, a `block_height` or a `block_hash`, and gives the `block_date` of a transaction or a block.";
104
104
  /** A whole-chain question is a time-bounded topology search; a server without search mode refuses it once. */
105
- const ROUTING_NOT_SERVED = "A question about the whole chain (recent activity, the biggest senders, a top list) is a `USE topology` search: bound it by time with `f.last_seen_timestamp >= <epoch ms>` on the `FLOWS_TO` link, then sort and `LIMIT`. A search has 10 s. If the server refuses it with `anchor_missing`, it does not serve searches yet: say so in one line and ask for an address.";
105
+ const ROUTING_NOT_SERVED = "A question about the whole chain (recent activity, the biggest senders, a top list) is a `USE topology` search: bound it by time with `f.last_seen_timestamp >= <epoch ms>` on the `FLOWS_TO` link, then sort and `LIMIT`. A search has 10 s. If the server refuses it with `anchor_missing`, or stops it with `query_timeout`, it cannot search the whole chain now: say so in one line and ask for an address. A narrower window times out the same way, so do not send it again.";
106
106
  /** What a refusal carries, and which part of it decides the move. */
107
107
  const MOVE_INTRO = "A refused, killed, busy or failed query comes back with `error_detail`: `code`, `rule`, `class`, `fix` and `example`. The `class` decides your next move.";
108
108
  /** The move for each class of the refusal envelope. */
@@ -346,6 +346,135 @@ async function startViewServer(options) {
346
346
  })
347
347
  };
348
348
  }
349
+ Object.freeze(["from_address", "to_address"]);
350
+ /** What a FLOWS_TO link carries: the link panel of the view shows each one. */
351
+ const GRAPH_LINK_COLUMNS = Object.freeze([
352
+ "amount_usd_sum",
353
+ "tx_count",
354
+ "first_seen_timestamp",
355
+ "last_seen_timestamp"
356
+ ]);
357
+ /** What the two addresses of a link carry: labels and the exchange mark on the node. */
358
+ const GRAPH_NODE_COLUMNS = Object.freeze([
359
+ "from_labels",
360
+ "to_labels",
361
+ "from_is_exchange",
362
+ "to_is_exchange"
363
+ ]);
364
+ /** The kind of a link, when a query joins several link types. */
365
+ const GRAPH_KIND_COLUMNS = Object.freeze(["link_kind"]);
366
+ /** Every optional column, in the order the hint names them. */
367
+ const GRAPH_OPTIONAL_COLUMNS = Object.freeze([
368
+ ...GRAPH_LINK_COLUMNS,
369
+ ...GRAPH_KIND_COLUMNS,
370
+ ...GRAPH_NODE_COLUMNS
371
+ ]);
372
+ Object.freeze([...GRAPH_LINK_COLUMNS, ...GRAPH_NODE_COLUMNS]);
373
+ /** The `RETURN` tail of a FLOWS_TO recipe over link `f` between `from` and `to`. */
374
+ function flowsRecipeReturn(from, to, link = "f") {
375
+ return `${from}.address AS from_address, ${to}.address AS to_address, ${link}.amount_usd_sum AS amount_usd_sum, ${link}.tx_count AS tx_count, ${link}.first_seen_timestamp AS first_seen_timestamp, ${link}.last_seen_timestamp AS last_seen_timestamp, ${from}.labels AS from_labels, ${to}.labels AS to_labels, ${from}.is_exchange AS from_is_exchange, ${to}.is_exchange AS to_is_exchange`;
376
+ }
377
+ //#endregion
378
+ //#region src/mcp/remote-connect.ts
379
+ const BUSY = /* @__PURE__ */ new Set([
380
+ 429,
381
+ 502,
382
+ 503,
383
+ 504
384
+ ]);
385
+ const NOT_STREAMABLE = /* @__PURE__ */ new Set([404, 405]);
386
+ /** The HTTP status an SDK transport error carries, from its `code` or its text. */
387
+ function httpStatusOf(err) {
388
+ if (err && typeof err === "object") {
389
+ const code = err.code;
390
+ if (typeof code === "number" && code >= 100 && code < 600) return code;
391
+ const message = err.message;
392
+ if (typeof message === "string") {
393
+ const m = /\b(4\d\d|5\d\d)\b/.exec(message);
394
+ if (m) return Number(m[1]);
395
+ }
396
+ }
397
+ return null;
398
+ }
399
+ /** Milliseconds to wait before the next try: Retry-After when the error names one, else 1 s doubling. */
400
+ function busyDelayMs(err, attempt) {
401
+ const retryAfter = err?.retryAfter;
402
+ if (typeof retryAfter === "number" && retryAfter > 0) return Math.min(retryAfter * 1e3, 3e4);
403
+ return Math.min(1e3 * 2 ** (attempt - 1), 8e3);
404
+ }
405
+ function describeRemoteFailure(endpoint, err, status) {
406
+ if (status !== null && BUSY.has(status)) return `Chain Insights Graph is at capacity at ${endpoint} (HTTP ${status}, class capacity): wait at least 5 seconds, then send the same query once.`;
407
+ return `Chain Insights Graph unreachable at ${endpoint}: ${err instanceof Error ? err.message : String(err)}`;
408
+ }
409
+ async function connectRemote(options) {
410
+ const attempts = Math.max(1, options.attempts ?? 5);
411
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
412
+ const log = options.log ?? (() => void 0);
413
+ let lastErr = null;
414
+ let lastStatus = null;
415
+ for (let attempt = 1; attempt <= attempts; attempt++) try {
416
+ await options.connectStreamable();
417
+ await log("remote.connect", {
418
+ transport: "streamable_http",
419
+ endpoint: options.endpoint,
420
+ attempt
421
+ });
422
+ return {
423
+ connected: true,
424
+ transport: "streamable_http",
425
+ retryable: false,
426
+ attempts: attempt
427
+ };
428
+ } catch (err) {
429
+ lastErr = err;
430
+ lastStatus = httpStatusOf(err);
431
+ await log("remote.connect_failed", {
432
+ transport: "streamable_http",
433
+ endpoint: options.endpoint,
434
+ attempt,
435
+ status: lastStatus
436
+ });
437
+ if (lastStatus !== null && BUSY.has(lastStatus) && attempt < attempts) {
438
+ await sleep(busyDelayMs(err, attempt));
439
+ continue;
440
+ }
441
+ break;
442
+ }
443
+ if (!(lastStatus !== null && BUSY.has(lastStatus))) try {
444
+ await options.connectSse();
445
+ await log("remote.connect", {
446
+ transport: "sse",
447
+ endpoint: options.endpoint
448
+ });
449
+ return {
450
+ connected: true,
451
+ transport: "sse",
452
+ retryable: false,
453
+ attempts
454
+ };
455
+ } catch (err2) {
456
+ await log("remote.connect_failed", {
457
+ transport: "sse",
458
+ endpoint: options.endpoint,
459
+ status: httpStatusOf(err2)
460
+ });
461
+ const named = lastStatus !== null && NOT_STREAMABLE.has(lastStatus) ? err2 : lastErr;
462
+ return {
463
+ connected: false,
464
+ transport: null,
465
+ retryable: false,
466
+ attempts,
467
+ message: describeRemoteFailure(options.endpoint, named, null)
468
+ };
469
+ }
470
+ return {
471
+ connected: false,
472
+ transport: null,
473
+ retryable: true,
474
+ attempts,
475
+ message: describeRemoteFailure(options.endpoint, lastErr, lastStatus)
476
+ };
477
+ }
349
478
  //#endregion
350
479
  //#region src/mcp/proxy.ts
351
480
  const LOCAL_TOOL_NAMES = /* @__PURE__ */ new Set([
@@ -433,7 +562,7 @@ const EMPTY_INPUT_SCHEMA = zod.strictObject({});
433
562
  const REMOTE_GRAPH_TOOL_REQUEST_TIMEOUT_MS = 9e5;
434
563
  const PICTURE_RULES = [
435
564
  "Pictures: hosts that draw views, such as Claude Desktop and the Codex app, draw a graph_query answer from its column names. No other tool is needed. Choose the picture by the question: who sent to whom, flows and routes are a graph; how something changed over time is a time series; a list, a lookup or the details of one thing is a table.",
436
- "- Rows with from_address and to_address columns draw a graph of at most 5,000 addresses. Optional columns: amount_usd_sum, tx_count, first_seen_timestamp, last_seen_timestamp, link_kind, from_labels, to_labels. Example: USE topology MATCH (a:Address {address: $addr})-[f:FLOWS_TO]->(b:Address) WHERE NOT a:Pool RETURN a.address AS from_address, b.address AS to_address, f.amount_usd_sum AS amount_usd_sum, f.tx_count AS tx_count, f.first_seen_timestamp AS first_seen_timestamp, f.last_seen_timestamp AS last_seen_timestamp ORDER BY f.last_seen_timestamp DESC LIMIT 25",
565
+ `- Rows with from_address and to_address columns draw a graph of at most 5,000 addresses. Optional columns: ${GRAPH_OPTIONAL_COLUMNS.join(", ")}. A recipe that draws a graph returns all of them; a column it leaves out shows as Unavailable in the drawn link. Example: USE topology MATCH (a:Address {address: $addr})-[f:FLOWS_TO]->(b:Address) WHERE NOT a:Pool RETURN ${flowsRecipeReturn("a", "b")} ORDER BY f.last_seen_timestamp DESC LIMIT 25`,
437
566
  "- Rows with a day, date, hour, week, month or *_timestamp column, number columns, no address in any value and at least 3 distinct times draw a time series. Within one day, the transfers of a known pair: USE facts MATCH (a:Address {address: $from})-[t:TRANSFER]->(b:Address {address: $to}) WHERE t.block_date = \"YYYY-MM-DD\" RETURN t.block_timestamp AS block_timestamp, t.amount_usd AS amount_usd LIMIT 200",
438
567
  "- Across days: facts reads one day at a time, so send graph_query_batch with one query per day (3 to 20 days), each query id the day as \"YYYY-MM-DD\", each returning one total row: USE facts MATCH (a:Address {address: $from})-[t:TRANSFER]->(b:Address {address: $to}) WHERE t.block_date = \"YYYY-MM-DD\" RETURN count(t) AS tx_count, sum(t.amount_usd) AS amount_usd_sum. The view joins the days into one time series. Do not group by day or name several days in one read: both are refused.",
439
568
  "- Any other rows draw a table.",
@@ -969,37 +1098,35 @@ async function createProxy() {
969
1098
  remoteUnavailableMessage = `Chain Insights Graph setup unavailable at ${graphMcpEndpoint}: ${err.message}`;
970
1099
  process.stderr.write(`Chain Insights MCP graph tools unavailable: ${remoteUnavailableMessage}. Local Chain Insights tools are still available.\n`);
971
1100
  }
972
- if (mcpFetch) try {
973
- await remoteClient.connect(new _modelcontextprotocol_sdk_client_streamableHttp_js.StreamableHTTPClientTransport(new URL(graphMcpEndpoint), { fetch: mcpFetch }));
974
- remoteConnected = true;
975
- await logger.info("remote.connect", {
976
- transport: "streamable_http",
977
- endpoint: graphMcpEndpoint
978
- });
979
- } catch {
980
- await logger.error("remote.connect_failed", {
981
- transport: "streamable_http",
982
- endpoint: graphMcpEndpoint
1101
+ let remoteRetryable = false;
1102
+ const connectRemoteOnce = async () => {
1103
+ if (!mcpFetch) return;
1104
+ const fetchForRemote = mcpFetch;
1105
+ const result = await connectRemote({
1106
+ endpoint: graphMcpEndpoint,
1107
+ connectStreamable: () => remoteClient.connect(new _modelcontextprotocol_sdk_client_streamableHttp_js.StreamableHTTPClientTransport(new URL(graphMcpEndpoint), { fetch: fetchForRemote })),
1108
+ connectSse: async () => {
1109
+ const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
1110
+ await remoteClient.connect(new SSEClientTransport(new URL(graphMcpEndpoint), { fetch: fetchForRemote }));
1111
+ },
1112
+ log: (event, fields) => event === "remote.connect" ? logger.info(event, fields) : logger.error(event, fields)
983
1113
  });
984
- try {
985
- const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
986
- await remoteClient.connect(new SSEClientTransport(new URL(graphMcpEndpoint), { fetch: mcpFetch }));
987
- remoteConnected = true;
988
- await logger.info("remote.connect", {
989
- transport: "sse",
990
- endpoint: graphMcpEndpoint
991
- });
992
- } catch (err2) {
993
- await logger.error("remote.connect_failed", {
994
- transport: "sse",
995
- endpoint: graphMcpEndpoint,
996
- error: errorForLog(err2)
997
- });
998
- remoteUnavailableMessage = `Chain Insights Graph unreachable at ${graphMcpEndpoint}: ${err2.message}`;
1114
+ remoteConnected = result.connected;
1115
+ remoteRetryable = result.retryable;
1116
+ if (result.connected) {
1117
+ remoteUnavailableMessage = void 0;
1118
+ installRemoteCypherLogging(remoteClient, logger);
1119
+ } else {
1120
+ remoteUnavailableMessage = result.message;
999
1121
  process.stderr.write(`Chain Insights MCP graph tools unavailable: ${remoteUnavailableMessage}. Local Chain Insights tools are still available.\n`);
1000
1122
  }
1001
- }
1002
- if (remoteConnected) installRemoteCypherLogging(remoteClient, logger);
1123
+ };
1124
+ await connectRemoteOnce();
1125
+ /** A graph call on a proxy that lost the endpoint to a busy signal tries to connect again, once. */
1126
+ const ensureRemote = async () => {
1127
+ if (remoteConnected || !remoteRetryable) return;
1128
+ await connectRemoteOnce();
1129
+ };
1003
1130
  let tools = await loadSchema(graphMcpEndpoint);
1004
1131
  if (!tools && remoteConnected) {
1005
1132
  tools = (await remoteClient.listTools()).tools;
@@ -1065,6 +1192,7 @@ async function createProxy() {
1065
1192
  const schemaDependencies = {
1066
1193
  endpoint: graphMcpEndpoint,
1067
1194
  withClient: async (fn) => {
1195
+ await ensureRemote();
1068
1196
  if (!remoteConnected) throw new require_graph_schema.GraphSchemaError(`${remoteUnavailableMessage ?? `Chain Insights Graph is not connected at ${graphMcpEndpoint}`}. Restart the Chain Insights MCP proxy after the endpoint is reachable.`);
1069
1197
  return fn(remoteClient);
1070
1198
  },
@@ -1097,6 +1225,7 @@ async function createProxy() {
1097
1225
  }
1098
1226
  }, async () => {
1099
1227
  try {
1228
+ await ensureRemote();
1100
1229
  if (!remoteConnected) return {
1101
1230
  content: [{
1102
1231
  type: "text",
@@ -1145,6 +1274,7 @@ async function createProxy() {
1145
1274
  }
1146
1275
  }, async () => {
1147
1276
  try {
1277
+ await ensureRemote();
1148
1278
  if (!remoteConnected) return jsonTextResult(require_subscription_status.unavailableSubscriptionStatus(graphMcpEndpoint, remoteUnavailableMessage ?? `Chain Insights Graph is not connected at ${graphMcpEndpoint}`));
1149
1279
  if (!remoteToolNames.has("subscription_status")) return jsonTextResult(require_subscription_status.unavailableSubscriptionStatus(graphMcpEndpoint, "The graph backend exposes primitive graph tools but no subscription_status tool."));
1150
1280
  const walletAddress = await localSubscriptionWalletAddress();
@@ -1356,6 +1486,7 @@ async function createProxy() {
1356
1486
  const inputSchema = knownPublicToolInputSchema(tool.name) ?? zod.object({}).passthrough();
1357
1487
  const handler = async (args) => {
1358
1488
  try {
1489
+ await ensureRemote();
1359
1490
  if (!remoteConnected) return {
1360
1491
  content: [{
1361
1492
  type: "text",
@@ -1441,6 +1572,7 @@ if (process.argv[1] && require("url").pathToFileURL(__filename).href.includes(pr
1441
1572
  //#endregion
1442
1573
  exports.CLAUDE_VIEW_URI = CLAUDE_VIEW_URI;
1443
1574
  exports.MCP_APP_MIME_TYPE = MCP_APP_MIME_TYPE;
1575
+ exports.PICTURE_RULES = PICTURE_RULES;
1444
1576
  exports.createProxy = createProxy;
1445
1577
  exports.knownPublicToolInputSchema = knownPublicToolInputSchema;
1446
1578
  exports.readClaudeViewHtml = readClaudeViewHtml;
@@ -10,6 +10,7 @@ export declare function readClaudeViewHtml(): string;
10
10
  export type McpProxyMode = 'workspace' | 'stateless';
11
11
  export declare function resolveMcpProxyMode(env?: NodeJS.ProcessEnv): McpProxyMode;
12
12
  type ToolInputShape = Record<string, z.ZodTypeAny>;
13
+ export declare const PICTURE_RULES: string;
13
14
  export declare function knownPublicToolInputSchema(toolName: string): ToolInputShape | null;
14
15
  /**
15
16
  * Core proxy logic — exported so tests can inject dependencies directly.
@@ -1 +1 @@
1
- {"version":3,"file":"mcp-proxy.d.cts","names":[],"sources":["../src/mcp/proxy.ts"],"mappings":";;qBAkEa;qBACA;;;;;wBAyBG;YAkBJ;wBAEI,oBAAoB,MAAK,OAAO,aAA2B;KAsCtE,iBAAiB,eAAe,EAAE;wBAyHvB,2BAA2B,mBAAmB;;;;;;;;wBA+lBxC,eAAe"}
1
+ {"version":3,"file":"mcp-proxy.d.cts","names":[],"sources":["../src/mcp/proxy.ts"],"mappings":";;qBAoEa;qBACA;;;;;wBAyBG;YAkBJ;wBAEI,oBAAoB,MAAK,OAAO,aAA2B;KAsCtE,iBAAiB,eAAe,EAAE;qBAgB1B;wBAyGG,2BAA2B,mBAAmB;;;;;;;;wBA+lBxC,eAAe"}
@@ -10,6 +10,7 @@ export declare function readClaudeViewHtml(): string;
10
10
  export type McpProxyMode = 'workspace' | 'stateless';
11
11
  export declare function resolveMcpProxyMode(env?: NodeJS.ProcessEnv): McpProxyMode;
12
12
  type ToolInputShape = Record<string, z.ZodTypeAny>;
13
+ export declare const PICTURE_RULES: string;
13
14
  export declare function knownPublicToolInputSchema(toolName: string): ToolInputShape | null;
14
15
  /**
15
16
  * Core proxy logic — exported so tests can inject dependencies directly.
@@ -1 +1 @@
1
- {"version":3,"file":"mcp-proxy.d.mts","names":[],"sources":["../src/mcp/proxy.ts"],"mappings":";;qBAkEa;qBACA;;;;;wBAyBG;YAkBJ;wBAEI,oBAAoB,MAAK,OAAO,aAA2B;KAsCtE,iBAAiB,eAAe,EAAE;wBAyHvB,2BAA2B,mBAAmB;;;;;;;;wBA+lBxC,eAAe"}
1
+ {"version":3,"file":"mcp-proxy.d.mts","names":[],"sources":["../src/mcp/proxy.ts"],"mappings":";;qBAoEa;qBACA;;;;;wBAyBG;YAkBJ;wBAEI,oBAAoB,MAAK,OAAO,aAA2B;KAsCtE,iBAAiB,eAAe,EAAE;qBAgB1B;wBAyGG,2BAA2B,mBAAmB;;;;;;;;wBA+lBxC,eAAe"}
@@ -98,7 +98,7 @@ const ROUTING_TWO_FIT = "A transaction is a chain question: read it on `USE chai
98
98
  /** The key that each layer hands to the next. Each key keeps its name. */
99
99
  const ROUTING_HANDOFF = "The layers hand each other keys, and each key keeps its name. Topology gives the pair and the first and last seen time. Facts gives the `tx_id` and the `block_height`. Chain takes an `address`, a `tx_id`, a `block_height` or a `block_hash`, and gives the `block_date` of a transaction or a block.";
100
100
  /** A whole-chain question is a time-bounded topology search; a server without search mode refuses it once. */
101
- const ROUTING_NOT_SERVED = "A question about the whole chain (recent activity, the biggest senders, a top list) is a `USE topology` search: bound it by time with `f.last_seen_timestamp >= <epoch ms>` on the `FLOWS_TO` link, then sort and `LIMIT`. A search has 10 s. If the server refuses it with `anchor_missing`, it does not serve searches yet: say so in one line and ask for an address.";
101
+ const ROUTING_NOT_SERVED = "A question about the whole chain (recent activity, the biggest senders, a top list) is a `USE topology` search: bound it by time with `f.last_seen_timestamp >= <epoch ms>` on the `FLOWS_TO` link, then sort and `LIMIT`. A search has 10 s. If the server refuses it with `anchor_missing`, or stops it with `query_timeout`, it cannot search the whole chain now: say so in one line and ask for an address. A narrower window times out the same way, so do not send it again.";
102
102
  /** What a refusal carries, and which part of it decides the move. */
103
103
  const MOVE_INTRO = "A refused, killed, busy or failed query comes back with `error_detail`: `code`, `rule`, `class`, `fix` and `example`. The `class` decides your next move.";
104
104
  /** The move for each class of the refusal envelope. */
@@ -342,6 +342,135 @@ async function startViewServer(options) {
342
342
  })
343
343
  };
344
344
  }
345
+ Object.freeze(["from_address", "to_address"]);
346
+ /** What a FLOWS_TO link carries: the link panel of the view shows each one. */
347
+ const GRAPH_LINK_COLUMNS = Object.freeze([
348
+ "amount_usd_sum",
349
+ "tx_count",
350
+ "first_seen_timestamp",
351
+ "last_seen_timestamp"
352
+ ]);
353
+ /** What the two addresses of a link carry: labels and the exchange mark on the node. */
354
+ const GRAPH_NODE_COLUMNS = Object.freeze([
355
+ "from_labels",
356
+ "to_labels",
357
+ "from_is_exchange",
358
+ "to_is_exchange"
359
+ ]);
360
+ /** The kind of a link, when a query joins several link types. */
361
+ const GRAPH_KIND_COLUMNS = Object.freeze(["link_kind"]);
362
+ /** Every optional column, in the order the hint names them. */
363
+ const GRAPH_OPTIONAL_COLUMNS = Object.freeze([
364
+ ...GRAPH_LINK_COLUMNS,
365
+ ...GRAPH_KIND_COLUMNS,
366
+ ...GRAPH_NODE_COLUMNS
367
+ ]);
368
+ Object.freeze([...GRAPH_LINK_COLUMNS, ...GRAPH_NODE_COLUMNS]);
369
+ /** The `RETURN` tail of a FLOWS_TO recipe over link `f` between `from` and `to`. */
370
+ function flowsRecipeReturn(from, to, link = "f") {
371
+ return `${from}.address AS from_address, ${to}.address AS to_address, ${link}.amount_usd_sum AS amount_usd_sum, ${link}.tx_count AS tx_count, ${link}.first_seen_timestamp AS first_seen_timestamp, ${link}.last_seen_timestamp AS last_seen_timestamp, ${from}.labels AS from_labels, ${to}.labels AS to_labels, ${from}.is_exchange AS from_is_exchange, ${to}.is_exchange AS to_is_exchange`;
372
+ }
373
+ //#endregion
374
+ //#region src/mcp/remote-connect.ts
375
+ const BUSY = /* @__PURE__ */ new Set([
376
+ 429,
377
+ 502,
378
+ 503,
379
+ 504
380
+ ]);
381
+ const NOT_STREAMABLE = /* @__PURE__ */ new Set([404, 405]);
382
+ /** The HTTP status an SDK transport error carries, from its `code` or its text. */
383
+ function httpStatusOf(err) {
384
+ if (err && typeof err === "object") {
385
+ const code = err.code;
386
+ if (typeof code === "number" && code >= 100 && code < 600) return code;
387
+ const message = err.message;
388
+ if (typeof message === "string") {
389
+ const m = /\b(4\d\d|5\d\d)\b/.exec(message);
390
+ if (m) return Number(m[1]);
391
+ }
392
+ }
393
+ return null;
394
+ }
395
+ /** Milliseconds to wait before the next try: Retry-After when the error names one, else 1 s doubling. */
396
+ function busyDelayMs(err, attempt) {
397
+ const retryAfter = err?.retryAfter;
398
+ if (typeof retryAfter === "number" && retryAfter > 0) return Math.min(retryAfter * 1e3, 3e4);
399
+ return Math.min(1e3 * 2 ** (attempt - 1), 8e3);
400
+ }
401
+ function describeRemoteFailure(endpoint, err, status) {
402
+ if (status !== null && BUSY.has(status)) return `Chain Insights Graph is at capacity at ${endpoint} (HTTP ${status}, class capacity): wait at least 5 seconds, then send the same query once.`;
403
+ return `Chain Insights Graph unreachable at ${endpoint}: ${err instanceof Error ? err.message : String(err)}`;
404
+ }
405
+ async function connectRemote(options) {
406
+ const attempts = Math.max(1, options.attempts ?? 5);
407
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
408
+ const log = options.log ?? (() => void 0);
409
+ let lastErr = null;
410
+ let lastStatus = null;
411
+ for (let attempt = 1; attempt <= attempts; attempt++) try {
412
+ await options.connectStreamable();
413
+ await log("remote.connect", {
414
+ transport: "streamable_http",
415
+ endpoint: options.endpoint,
416
+ attempt
417
+ });
418
+ return {
419
+ connected: true,
420
+ transport: "streamable_http",
421
+ retryable: false,
422
+ attempts: attempt
423
+ };
424
+ } catch (err) {
425
+ lastErr = err;
426
+ lastStatus = httpStatusOf(err);
427
+ await log("remote.connect_failed", {
428
+ transport: "streamable_http",
429
+ endpoint: options.endpoint,
430
+ attempt,
431
+ status: lastStatus
432
+ });
433
+ if (lastStatus !== null && BUSY.has(lastStatus) && attempt < attempts) {
434
+ await sleep(busyDelayMs(err, attempt));
435
+ continue;
436
+ }
437
+ break;
438
+ }
439
+ if (!(lastStatus !== null && BUSY.has(lastStatus))) try {
440
+ await options.connectSse();
441
+ await log("remote.connect", {
442
+ transport: "sse",
443
+ endpoint: options.endpoint
444
+ });
445
+ return {
446
+ connected: true,
447
+ transport: "sse",
448
+ retryable: false,
449
+ attempts
450
+ };
451
+ } catch (err2) {
452
+ await log("remote.connect_failed", {
453
+ transport: "sse",
454
+ endpoint: options.endpoint,
455
+ status: httpStatusOf(err2)
456
+ });
457
+ const named = lastStatus !== null && NOT_STREAMABLE.has(lastStatus) ? err2 : lastErr;
458
+ return {
459
+ connected: false,
460
+ transport: null,
461
+ retryable: false,
462
+ attempts,
463
+ message: describeRemoteFailure(options.endpoint, named, null)
464
+ };
465
+ }
466
+ return {
467
+ connected: false,
468
+ transport: null,
469
+ retryable: true,
470
+ attempts,
471
+ message: describeRemoteFailure(options.endpoint, lastErr, lastStatus)
472
+ };
473
+ }
345
474
  //#endregion
346
475
  //#region src/mcp/proxy.ts
347
476
  const LOCAL_TOOL_NAMES = /* @__PURE__ */ new Set([
@@ -429,7 +558,7 @@ const EMPTY_INPUT_SCHEMA = z.strictObject({});
429
558
  const REMOTE_GRAPH_TOOL_REQUEST_TIMEOUT_MS = 9e5;
430
559
  const PICTURE_RULES = [
431
560
  "Pictures: hosts that draw views, such as Claude Desktop and the Codex app, draw a graph_query answer from its column names. No other tool is needed. Choose the picture by the question: who sent to whom, flows and routes are a graph; how something changed over time is a time series; a list, a lookup or the details of one thing is a table.",
432
- "- Rows with from_address and to_address columns draw a graph of at most 5,000 addresses. Optional columns: amount_usd_sum, tx_count, first_seen_timestamp, last_seen_timestamp, link_kind, from_labels, to_labels. Example: USE topology MATCH (a:Address {address: $addr})-[f:FLOWS_TO]->(b:Address) WHERE NOT a:Pool RETURN a.address AS from_address, b.address AS to_address, f.amount_usd_sum AS amount_usd_sum, f.tx_count AS tx_count, f.first_seen_timestamp AS first_seen_timestamp, f.last_seen_timestamp AS last_seen_timestamp ORDER BY f.last_seen_timestamp DESC LIMIT 25",
561
+ `- Rows with from_address and to_address columns draw a graph of at most 5,000 addresses. Optional columns: ${GRAPH_OPTIONAL_COLUMNS.join(", ")}. A recipe that draws a graph returns all of them; a column it leaves out shows as Unavailable in the drawn link. Example: USE topology MATCH (a:Address {address: $addr})-[f:FLOWS_TO]->(b:Address) WHERE NOT a:Pool RETURN ${flowsRecipeReturn("a", "b")} ORDER BY f.last_seen_timestamp DESC LIMIT 25`,
433
562
  "- Rows with a day, date, hour, week, month or *_timestamp column, number columns, no address in any value and at least 3 distinct times draw a time series. Within one day, the transfers of a known pair: USE facts MATCH (a:Address {address: $from})-[t:TRANSFER]->(b:Address {address: $to}) WHERE t.block_date = \"YYYY-MM-DD\" RETURN t.block_timestamp AS block_timestamp, t.amount_usd AS amount_usd LIMIT 200",
434
563
  "- Across days: facts reads one day at a time, so send graph_query_batch with one query per day (3 to 20 days), each query id the day as \"YYYY-MM-DD\", each returning one total row: USE facts MATCH (a:Address {address: $from})-[t:TRANSFER]->(b:Address {address: $to}) WHERE t.block_date = \"YYYY-MM-DD\" RETURN count(t) AS tx_count, sum(t.amount_usd) AS amount_usd_sum. The view joins the days into one time series. Do not group by day or name several days in one read: both are refused.",
435
564
  "- Any other rows draw a table.",
@@ -965,37 +1094,35 @@ async function createProxy() {
965
1094
  remoteUnavailableMessage = `Chain Insights Graph setup unavailable at ${graphMcpEndpoint}: ${err.message}`;
966
1095
  process.stderr.write(`Chain Insights MCP graph tools unavailable: ${remoteUnavailableMessage}. Local Chain Insights tools are still available.\n`);
967
1096
  }
968
- if (mcpFetch) try {
969
- await remoteClient.connect(new StreamableHTTPClientTransport(new URL(graphMcpEndpoint), { fetch: mcpFetch }));
970
- remoteConnected = true;
971
- await logger.info("remote.connect", {
972
- transport: "streamable_http",
973
- endpoint: graphMcpEndpoint
974
- });
975
- } catch {
976
- await logger.error("remote.connect_failed", {
977
- transport: "streamable_http",
978
- endpoint: graphMcpEndpoint
1097
+ let remoteRetryable = false;
1098
+ const connectRemoteOnce = async () => {
1099
+ if (!mcpFetch) return;
1100
+ const fetchForRemote = mcpFetch;
1101
+ const result = await connectRemote({
1102
+ endpoint: graphMcpEndpoint,
1103
+ connectStreamable: () => remoteClient.connect(new StreamableHTTPClientTransport(new URL(graphMcpEndpoint), { fetch: fetchForRemote })),
1104
+ connectSse: async () => {
1105
+ const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
1106
+ await remoteClient.connect(new SSEClientTransport(new URL(graphMcpEndpoint), { fetch: fetchForRemote }));
1107
+ },
1108
+ log: (event, fields) => event === "remote.connect" ? logger.info(event, fields) : logger.error(event, fields)
979
1109
  });
980
- try {
981
- const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
982
- await remoteClient.connect(new SSEClientTransport(new URL(graphMcpEndpoint), { fetch: mcpFetch }));
983
- remoteConnected = true;
984
- await logger.info("remote.connect", {
985
- transport: "sse",
986
- endpoint: graphMcpEndpoint
987
- });
988
- } catch (err2) {
989
- await logger.error("remote.connect_failed", {
990
- transport: "sse",
991
- endpoint: graphMcpEndpoint,
992
- error: errorForLog(err2)
993
- });
994
- remoteUnavailableMessage = `Chain Insights Graph unreachable at ${graphMcpEndpoint}: ${err2.message}`;
1110
+ remoteConnected = result.connected;
1111
+ remoteRetryable = result.retryable;
1112
+ if (result.connected) {
1113
+ remoteUnavailableMessage = void 0;
1114
+ installRemoteCypherLogging(remoteClient, logger);
1115
+ } else {
1116
+ remoteUnavailableMessage = result.message;
995
1117
  process.stderr.write(`Chain Insights MCP graph tools unavailable: ${remoteUnavailableMessage}. Local Chain Insights tools are still available.\n`);
996
1118
  }
997
- }
998
- if (remoteConnected) installRemoteCypherLogging(remoteClient, logger);
1119
+ };
1120
+ await connectRemoteOnce();
1121
+ /** A graph call on a proxy that lost the endpoint to a busy signal tries to connect again, once. */
1122
+ const ensureRemote = async () => {
1123
+ if (remoteConnected || !remoteRetryable) return;
1124
+ await connectRemoteOnce();
1125
+ };
999
1126
  let tools = await loadSchema(graphMcpEndpoint);
1000
1127
  if (!tools && remoteConnected) {
1001
1128
  tools = (await remoteClient.listTools()).tools;
@@ -1061,6 +1188,7 @@ async function createProxy() {
1061
1188
  const schemaDependencies = {
1062
1189
  endpoint: graphMcpEndpoint,
1063
1190
  withClient: async (fn) => {
1191
+ await ensureRemote();
1064
1192
  if (!remoteConnected) throw new GraphSchemaError(`${remoteUnavailableMessage ?? `Chain Insights Graph is not connected at ${graphMcpEndpoint}`}. Restart the Chain Insights MCP proxy after the endpoint is reachable.`);
1065
1193
  return fn(remoteClient);
1066
1194
  },
@@ -1093,6 +1221,7 @@ async function createProxy() {
1093
1221
  }
1094
1222
  }, async () => {
1095
1223
  try {
1224
+ await ensureRemote();
1096
1225
  if (!remoteConnected) return {
1097
1226
  content: [{
1098
1227
  type: "text",
@@ -1141,6 +1270,7 @@ async function createProxy() {
1141
1270
  }
1142
1271
  }, async () => {
1143
1272
  try {
1273
+ await ensureRemote();
1144
1274
  if (!remoteConnected) return jsonTextResult(unavailableSubscriptionStatus(graphMcpEndpoint, remoteUnavailableMessage ?? `Chain Insights Graph is not connected at ${graphMcpEndpoint}`));
1145
1275
  if (!remoteToolNames.has("subscription_status")) return jsonTextResult(unavailableSubscriptionStatus(graphMcpEndpoint, "The graph backend exposes primitive graph tools but no subscription_status tool."));
1146
1276
  const walletAddress = await localSubscriptionWalletAddress();
@@ -1352,6 +1482,7 @@ async function createProxy() {
1352
1482
  const inputSchema = knownPublicToolInputSchema(tool.name) ?? z.object({}).passthrough();
1353
1483
  const handler = async (args) => {
1354
1484
  try {
1485
+ await ensureRemote();
1355
1486
  if (!remoteConnected) return {
1356
1487
  content: [{
1357
1488
  type: "text",
@@ -1435,6 +1566,6 @@ if (process.argv[1] && import.meta.url.includes(process.argv[1].replace(/\\/g, "
1435
1566
  process.exit(1);
1436
1567
  });
1437
1568
  //#endregion
1438
- export { CLAUDE_VIEW_URI, MCP_APP_MIME_TYPE, createProxy, knownPublicToolInputSchema, readClaudeViewHtml, resolveMcpProxyMode };
1569
+ export { CLAUDE_VIEW_URI, MCP_APP_MIME_TYPE, PICTURE_RULES, createProxy, knownPublicToolInputSchema, readClaudeViewHtml, resolveMcpProxyMode };
1439
1570
 
1440
1571
  //# sourceMappingURL=mcp-proxy.mjs.map