chain-insights 0.54.3 → 0.55.1

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. */
@@ -375,6 +375,117 @@ function flowsRecipeReturn(from, to, link = "f") {
375
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
376
  }
377
377
  //#endregion
378
+ //#region src/mcp/remote-connect.ts
379
+ /**
380
+ * Connects a client to a fresh transport. The SDK leaves a transport attached
381
+ * when `connect` fails after `start` (an initialize answered 429), and the
382
+ * next `connect` then throws "Already connected to a transport", so every try
383
+ * closes first. Seen in round 83 of the 2026-10-10 test loop.
384
+ */
385
+ async function connectFresh(client, transport) {
386
+ if (typeof client.close === "function") await client.close().catch(() => void 0);
387
+ await client.connect(transport);
388
+ }
389
+ const BUSY = /* @__PURE__ */ new Set([
390
+ 429,
391
+ 502,
392
+ 503,
393
+ 504
394
+ ]);
395
+ const NOT_STREAMABLE = /* @__PURE__ */ new Set([404, 405]);
396
+ /** The HTTP status an SDK transport error carries, from its `code` or its text. */
397
+ function httpStatusOf(err) {
398
+ if (err && typeof err === "object") {
399
+ const code = err.code;
400
+ if (typeof code === "number" && code >= 100 && code < 600) return code;
401
+ const message = err.message;
402
+ if (typeof message === "string") {
403
+ const m = /\b(4\d\d|5\d\d)\b/.exec(message);
404
+ if (m) return Number(m[1]);
405
+ }
406
+ }
407
+ return null;
408
+ }
409
+ /** Milliseconds to wait before the next try: Retry-After when the error names one, else 1 s doubling. */
410
+ function busyDelayMs(err, attempt) {
411
+ const retryAfter = err?.retryAfter;
412
+ if (typeof retryAfter === "number" && retryAfter > 0) return Math.min(retryAfter * 1e3, 3e4);
413
+ return Math.min(1e3 * 2 ** (attempt - 1), 8e3);
414
+ }
415
+ function describeRemoteFailure(endpoint, err, status) {
416
+ 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.`;
417
+ return `Chain Insights Graph unreachable at ${endpoint}: ${err instanceof Error ? err.message : String(err)}`;
418
+ }
419
+ async function connectRemote(options) {
420
+ const attempts = Math.max(1, options.attempts ?? 5);
421
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
422
+ const log = options.log ?? (() => void 0);
423
+ let lastErr = null;
424
+ let lastStatus = null;
425
+ for (let attempt = 1; attempt <= attempts; attempt++) try {
426
+ await options.connectStreamable();
427
+ await log("remote.connect", {
428
+ transport: "streamable_http",
429
+ endpoint: options.endpoint,
430
+ attempt
431
+ });
432
+ return {
433
+ connected: true,
434
+ transport: "streamable_http",
435
+ retryable: false,
436
+ attempts: attempt
437
+ };
438
+ } catch (err) {
439
+ lastErr = err;
440
+ lastStatus = httpStatusOf(err);
441
+ await log("remote.connect_failed", {
442
+ transport: "streamable_http",
443
+ endpoint: options.endpoint,
444
+ attempt,
445
+ status: lastStatus
446
+ });
447
+ if (lastStatus !== null && BUSY.has(lastStatus) && attempt < attempts) {
448
+ await sleep(busyDelayMs(err, attempt));
449
+ continue;
450
+ }
451
+ break;
452
+ }
453
+ if (!(lastStatus !== null && BUSY.has(lastStatus))) try {
454
+ await options.connectSse();
455
+ await log("remote.connect", {
456
+ transport: "sse",
457
+ endpoint: options.endpoint
458
+ });
459
+ return {
460
+ connected: true,
461
+ transport: "sse",
462
+ retryable: false,
463
+ attempts
464
+ };
465
+ } catch (err2) {
466
+ await log("remote.connect_failed", {
467
+ transport: "sse",
468
+ endpoint: options.endpoint,
469
+ status: httpStatusOf(err2)
470
+ });
471
+ const named = lastStatus !== null && NOT_STREAMABLE.has(lastStatus) ? err2 : lastErr;
472
+ return {
473
+ connected: false,
474
+ transport: null,
475
+ retryable: false,
476
+ attempts,
477
+ message: describeRemoteFailure(options.endpoint, named, null)
478
+ };
479
+ }
480
+ return {
481
+ connected: false,
482
+ transport: null,
483
+ retryable: true,
484
+ attempts,
485
+ message: describeRemoteFailure(options.endpoint, lastErr, lastStatus)
486
+ };
487
+ }
488
+ //#endregion
378
489
  //#region src/mcp/proxy.ts
379
490
  const LOCAL_TOOL_NAMES = /* @__PURE__ */ new Set([
380
491
  "meta_network_capabilities",
@@ -472,7 +583,7 @@ const PICTURE_HELP_LINE = "In hosts that draw views, a graph_query answer with f
472
583
  const RULES_FIRST = "Rules first: if the chain-insights-cypher skill is not loaded in this chat, call meta_rules once (send {}) before the first graph_query or graph_query_batch, and follow what it returns. When the skill is loaded, do not call meta_rules.";
473
584
  const ANSWER_RULES = [
474
585
  "Answer rules, for every reply to the user:",
475
- "1. Write every address in full: 0x plus 40 hex characters, 42 in all, in sentences, lists, tables and summaries. Never shorten one with ... or …. The drawn picture may shorten its labels; your text never does.",
586
+ "1. Write every address in full: 0x plus 40 hex characters, 42 in all, in sentences, lists, tables and summaries. Never shorten one with ... or …, not even a repeat mention of an address named earlier. The drawn picture may shorten its labels; your text never does.",
476
587
  "2. Do not print the query, its USE line, its LIMIT, the epoch numbers you computed, or a Ref: line, unless the user asks how a number was found.",
477
588
  "3. Tokens are not dollars: a transfer moves tokens worth an amount at the day price. Say \"tokens worth about 183 USD on 2026-10-06\", never \"sent 183 USD\".",
478
589
  "4. Never draw a graph or a chart in text, ASCII or Mermaid: the host draws the picture from the columns. Write a short answer beside it."
@@ -997,37 +1108,35 @@ async function createProxy() {
997
1108
  remoteUnavailableMessage = `Chain Insights Graph setup unavailable at ${graphMcpEndpoint}: ${err.message}`;
998
1109
  process.stderr.write(`Chain Insights MCP graph tools unavailable: ${remoteUnavailableMessage}. Local Chain Insights tools are still available.\n`);
999
1110
  }
1000
- if (mcpFetch) try {
1001
- await remoteClient.connect(new _modelcontextprotocol_sdk_client_streamableHttp_js.StreamableHTTPClientTransport(new URL(graphMcpEndpoint), { fetch: mcpFetch }));
1002
- remoteConnected = true;
1003
- await logger.info("remote.connect", {
1004
- transport: "streamable_http",
1005
- endpoint: graphMcpEndpoint
1006
- });
1007
- } catch {
1008
- await logger.error("remote.connect_failed", {
1009
- transport: "streamable_http",
1010
- endpoint: graphMcpEndpoint
1111
+ let remoteRetryable = false;
1112
+ const connectRemoteOnce = async () => {
1113
+ if (!mcpFetch) return;
1114
+ const fetchForRemote = mcpFetch;
1115
+ const result = await connectRemote({
1116
+ endpoint: graphMcpEndpoint,
1117
+ connectStreamable: () => connectFresh(remoteClient, new _modelcontextprotocol_sdk_client_streamableHttp_js.StreamableHTTPClientTransport(new URL(graphMcpEndpoint), { fetch: fetchForRemote })),
1118
+ connectSse: async () => {
1119
+ const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
1120
+ await connectFresh(remoteClient, new SSEClientTransport(new URL(graphMcpEndpoint), { fetch: fetchForRemote }));
1121
+ },
1122
+ log: (event, fields) => event === "remote.connect" ? logger.info(event, fields) : logger.error(event, fields)
1011
1123
  });
1012
- try {
1013
- const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
1014
- await remoteClient.connect(new SSEClientTransport(new URL(graphMcpEndpoint), { fetch: mcpFetch }));
1015
- remoteConnected = true;
1016
- await logger.info("remote.connect", {
1017
- transport: "sse",
1018
- endpoint: graphMcpEndpoint
1019
- });
1020
- } catch (err2) {
1021
- await logger.error("remote.connect_failed", {
1022
- transport: "sse",
1023
- endpoint: graphMcpEndpoint,
1024
- error: errorForLog(err2)
1025
- });
1026
- remoteUnavailableMessage = `Chain Insights Graph unreachable at ${graphMcpEndpoint}: ${err2.message}`;
1124
+ remoteConnected = result.connected;
1125
+ remoteRetryable = result.retryable;
1126
+ if (result.connected) {
1127
+ remoteUnavailableMessage = void 0;
1128
+ installRemoteCypherLogging(remoteClient, logger);
1129
+ } else {
1130
+ remoteUnavailableMessage = result.message;
1027
1131
  process.stderr.write(`Chain Insights MCP graph tools unavailable: ${remoteUnavailableMessage}. Local Chain Insights tools are still available.\n`);
1028
1132
  }
1029
- }
1030
- if (remoteConnected) installRemoteCypherLogging(remoteClient, logger);
1133
+ };
1134
+ await connectRemoteOnce();
1135
+ /** A graph call on a proxy that lost the endpoint to a busy signal tries to connect again, once. */
1136
+ const ensureRemote = async () => {
1137
+ if (remoteConnected || !remoteRetryable) return;
1138
+ await connectRemoteOnce();
1139
+ };
1031
1140
  let tools = await loadSchema(graphMcpEndpoint);
1032
1141
  if (!tools && remoteConnected) {
1033
1142
  tools = (await remoteClient.listTools()).tools;
@@ -1093,6 +1202,7 @@ async function createProxy() {
1093
1202
  const schemaDependencies = {
1094
1203
  endpoint: graphMcpEndpoint,
1095
1204
  withClient: async (fn) => {
1205
+ await ensureRemote();
1096
1206
  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.`);
1097
1207
  return fn(remoteClient);
1098
1208
  },
@@ -1125,6 +1235,7 @@ async function createProxy() {
1125
1235
  }
1126
1236
  }, async () => {
1127
1237
  try {
1238
+ await ensureRemote();
1128
1239
  if (!remoteConnected) return {
1129
1240
  content: [{
1130
1241
  type: "text",
@@ -1173,6 +1284,7 @@ async function createProxy() {
1173
1284
  }
1174
1285
  }, async () => {
1175
1286
  try {
1287
+ await ensureRemote();
1176
1288
  if (!remoteConnected) return jsonTextResult(require_subscription_status.unavailableSubscriptionStatus(graphMcpEndpoint, remoteUnavailableMessage ?? `Chain Insights Graph is not connected at ${graphMcpEndpoint}`));
1177
1289
  if (!remoteToolNames.has("subscription_status")) return jsonTextResult(require_subscription_status.unavailableSubscriptionStatus(graphMcpEndpoint, "The graph backend exposes primitive graph tools but no subscription_status tool."));
1178
1290
  const walletAddress = await localSubscriptionWalletAddress();
@@ -1384,6 +1496,7 @@ async function createProxy() {
1384
1496
  const inputSchema = knownPublicToolInputSchema(tool.name) ?? zod.object({}).passthrough();
1385
1497
  const handler = async (args) => {
1386
1498
  try {
1499
+ await ensureRemote();
1387
1500
  if (!remoteConnected) return {
1388
1501
  content: [{
1389
1502
  type: "text",
@@ -1 +1 @@
1
- {"version":3,"file":"mcp-proxy.d.cts","names":[],"sources":["../src/mcp/proxy.ts"],"mappings":";;qBAmEa;qBACA;;;;;wBAyBG;YAkBJ;wBAEI,oBAAoB,MAAK,OAAO,aAA2B;KAsCtE,iBAAiB,eAAe,EAAE;qBAgB1B;wBAyGG,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"}
@@ -1 +1 @@
1
- {"version":3,"file":"mcp-proxy.d.mts","names":[],"sources":["../src/mcp/proxy.ts"],"mappings":";;qBAmEa;qBACA;;;;;wBAyBG;YAkBJ;wBAEI,oBAAoB,MAAK,OAAO,aAA2B;KAsCtE,iBAAiB,eAAe,EAAE;qBAgB1B;wBAyGG,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. */
@@ -371,6 +371,117 @@ function flowsRecipeReturn(from, to, link = "f") {
371
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
372
  }
373
373
  //#endregion
374
+ //#region src/mcp/remote-connect.ts
375
+ /**
376
+ * Connects a client to a fresh transport. The SDK leaves a transport attached
377
+ * when `connect` fails after `start` (an initialize answered 429), and the
378
+ * next `connect` then throws "Already connected to a transport", so every try
379
+ * closes first. Seen in round 83 of the 2026-10-10 test loop.
380
+ */
381
+ async function connectFresh(client, transport) {
382
+ if (typeof client.close === "function") await client.close().catch(() => void 0);
383
+ await client.connect(transport);
384
+ }
385
+ const BUSY = /* @__PURE__ */ new Set([
386
+ 429,
387
+ 502,
388
+ 503,
389
+ 504
390
+ ]);
391
+ const NOT_STREAMABLE = /* @__PURE__ */ new Set([404, 405]);
392
+ /** The HTTP status an SDK transport error carries, from its `code` or its text. */
393
+ function httpStatusOf(err) {
394
+ if (err && typeof err === "object") {
395
+ const code = err.code;
396
+ if (typeof code === "number" && code >= 100 && code < 600) return code;
397
+ const message = err.message;
398
+ if (typeof message === "string") {
399
+ const m = /\b(4\d\d|5\d\d)\b/.exec(message);
400
+ if (m) return Number(m[1]);
401
+ }
402
+ }
403
+ return null;
404
+ }
405
+ /** Milliseconds to wait before the next try: Retry-After when the error names one, else 1 s doubling. */
406
+ function busyDelayMs(err, attempt) {
407
+ const retryAfter = err?.retryAfter;
408
+ if (typeof retryAfter === "number" && retryAfter > 0) return Math.min(retryAfter * 1e3, 3e4);
409
+ return Math.min(1e3 * 2 ** (attempt - 1), 8e3);
410
+ }
411
+ function describeRemoteFailure(endpoint, err, status) {
412
+ 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.`;
413
+ return `Chain Insights Graph unreachable at ${endpoint}: ${err instanceof Error ? err.message : String(err)}`;
414
+ }
415
+ async function connectRemote(options) {
416
+ const attempts = Math.max(1, options.attempts ?? 5);
417
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
418
+ const log = options.log ?? (() => void 0);
419
+ let lastErr = null;
420
+ let lastStatus = null;
421
+ for (let attempt = 1; attempt <= attempts; attempt++) try {
422
+ await options.connectStreamable();
423
+ await log("remote.connect", {
424
+ transport: "streamable_http",
425
+ endpoint: options.endpoint,
426
+ attempt
427
+ });
428
+ return {
429
+ connected: true,
430
+ transport: "streamable_http",
431
+ retryable: false,
432
+ attempts: attempt
433
+ };
434
+ } catch (err) {
435
+ lastErr = err;
436
+ lastStatus = httpStatusOf(err);
437
+ await log("remote.connect_failed", {
438
+ transport: "streamable_http",
439
+ endpoint: options.endpoint,
440
+ attempt,
441
+ status: lastStatus
442
+ });
443
+ if (lastStatus !== null && BUSY.has(lastStatus) && attempt < attempts) {
444
+ await sleep(busyDelayMs(err, attempt));
445
+ continue;
446
+ }
447
+ break;
448
+ }
449
+ if (!(lastStatus !== null && BUSY.has(lastStatus))) try {
450
+ await options.connectSse();
451
+ await log("remote.connect", {
452
+ transport: "sse",
453
+ endpoint: options.endpoint
454
+ });
455
+ return {
456
+ connected: true,
457
+ transport: "sse",
458
+ retryable: false,
459
+ attempts
460
+ };
461
+ } catch (err2) {
462
+ await log("remote.connect_failed", {
463
+ transport: "sse",
464
+ endpoint: options.endpoint,
465
+ status: httpStatusOf(err2)
466
+ });
467
+ const named = lastStatus !== null && NOT_STREAMABLE.has(lastStatus) ? err2 : lastErr;
468
+ return {
469
+ connected: false,
470
+ transport: null,
471
+ retryable: false,
472
+ attempts,
473
+ message: describeRemoteFailure(options.endpoint, named, null)
474
+ };
475
+ }
476
+ return {
477
+ connected: false,
478
+ transport: null,
479
+ retryable: true,
480
+ attempts,
481
+ message: describeRemoteFailure(options.endpoint, lastErr, lastStatus)
482
+ };
483
+ }
484
+ //#endregion
374
485
  //#region src/mcp/proxy.ts
375
486
  const LOCAL_TOOL_NAMES = /* @__PURE__ */ new Set([
376
487
  "meta_network_capabilities",
@@ -468,7 +579,7 @@ const PICTURE_HELP_LINE = "In hosts that draw views, a graph_query answer with f
468
579
  const RULES_FIRST = "Rules first: if the chain-insights-cypher skill is not loaded in this chat, call meta_rules once (send {}) before the first graph_query or graph_query_batch, and follow what it returns. When the skill is loaded, do not call meta_rules.";
469
580
  const ANSWER_RULES = [
470
581
  "Answer rules, for every reply to the user:",
471
- "1. Write every address in full: 0x plus 40 hex characters, 42 in all, in sentences, lists, tables and summaries. Never shorten one with ... or …. The drawn picture may shorten its labels; your text never does.",
582
+ "1. Write every address in full: 0x plus 40 hex characters, 42 in all, in sentences, lists, tables and summaries. Never shorten one with ... or …, not even a repeat mention of an address named earlier. The drawn picture may shorten its labels; your text never does.",
472
583
  "2. Do not print the query, its USE line, its LIMIT, the epoch numbers you computed, or a Ref: line, unless the user asks how a number was found.",
473
584
  "3. Tokens are not dollars: a transfer moves tokens worth an amount at the day price. Say \"tokens worth about 183 USD on 2026-10-06\", never \"sent 183 USD\".",
474
585
  "4. Never draw a graph or a chart in text, ASCII or Mermaid: the host draws the picture from the columns. Write a short answer beside it."
@@ -993,37 +1104,35 @@ async function createProxy() {
993
1104
  remoteUnavailableMessage = `Chain Insights Graph setup unavailable at ${graphMcpEndpoint}: ${err.message}`;
994
1105
  process.stderr.write(`Chain Insights MCP graph tools unavailable: ${remoteUnavailableMessage}. Local Chain Insights tools are still available.\n`);
995
1106
  }
996
- if (mcpFetch) try {
997
- await remoteClient.connect(new StreamableHTTPClientTransport(new URL(graphMcpEndpoint), { fetch: mcpFetch }));
998
- remoteConnected = true;
999
- await logger.info("remote.connect", {
1000
- transport: "streamable_http",
1001
- endpoint: graphMcpEndpoint
1002
- });
1003
- } catch {
1004
- await logger.error("remote.connect_failed", {
1005
- transport: "streamable_http",
1006
- endpoint: graphMcpEndpoint
1107
+ let remoteRetryable = false;
1108
+ const connectRemoteOnce = async () => {
1109
+ if (!mcpFetch) return;
1110
+ const fetchForRemote = mcpFetch;
1111
+ const result = await connectRemote({
1112
+ endpoint: graphMcpEndpoint,
1113
+ connectStreamable: () => connectFresh(remoteClient, new StreamableHTTPClientTransport(new URL(graphMcpEndpoint), { fetch: fetchForRemote })),
1114
+ connectSse: async () => {
1115
+ const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
1116
+ await connectFresh(remoteClient, new SSEClientTransport(new URL(graphMcpEndpoint), { fetch: fetchForRemote }));
1117
+ },
1118
+ log: (event, fields) => event === "remote.connect" ? logger.info(event, fields) : logger.error(event, fields)
1007
1119
  });
1008
- try {
1009
- const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
1010
- await remoteClient.connect(new SSEClientTransport(new URL(graphMcpEndpoint), { fetch: mcpFetch }));
1011
- remoteConnected = true;
1012
- await logger.info("remote.connect", {
1013
- transport: "sse",
1014
- endpoint: graphMcpEndpoint
1015
- });
1016
- } catch (err2) {
1017
- await logger.error("remote.connect_failed", {
1018
- transport: "sse",
1019
- endpoint: graphMcpEndpoint,
1020
- error: errorForLog(err2)
1021
- });
1022
- remoteUnavailableMessage = `Chain Insights Graph unreachable at ${graphMcpEndpoint}: ${err2.message}`;
1120
+ remoteConnected = result.connected;
1121
+ remoteRetryable = result.retryable;
1122
+ if (result.connected) {
1123
+ remoteUnavailableMessage = void 0;
1124
+ installRemoteCypherLogging(remoteClient, logger);
1125
+ } else {
1126
+ remoteUnavailableMessage = result.message;
1023
1127
  process.stderr.write(`Chain Insights MCP graph tools unavailable: ${remoteUnavailableMessage}. Local Chain Insights tools are still available.\n`);
1024
1128
  }
1025
- }
1026
- if (remoteConnected) installRemoteCypherLogging(remoteClient, logger);
1129
+ };
1130
+ await connectRemoteOnce();
1131
+ /** A graph call on a proxy that lost the endpoint to a busy signal tries to connect again, once. */
1132
+ const ensureRemote = async () => {
1133
+ if (remoteConnected || !remoteRetryable) return;
1134
+ await connectRemoteOnce();
1135
+ };
1027
1136
  let tools = await loadSchema(graphMcpEndpoint);
1028
1137
  if (!tools && remoteConnected) {
1029
1138
  tools = (await remoteClient.listTools()).tools;
@@ -1089,6 +1198,7 @@ async function createProxy() {
1089
1198
  const schemaDependencies = {
1090
1199
  endpoint: graphMcpEndpoint,
1091
1200
  withClient: async (fn) => {
1201
+ await ensureRemote();
1092
1202
  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.`);
1093
1203
  return fn(remoteClient);
1094
1204
  },
@@ -1121,6 +1231,7 @@ async function createProxy() {
1121
1231
  }
1122
1232
  }, async () => {
1123
1233
  try {
1234
+ await ensureRemote();
1124
1235
  if (!remoteConnected) return {
1125
1236
  content: [{
1126
1237
  type: "text",
@@ -1169,6 +1280,7 @@ async function createProxy() {
1169
1280
  }
1170
1281
  }, async () => {
1171
1282
  try {
1283
+ await ensureRemote();
1172
1284
  if (!remoteConnected) return jsonTextResult(unavailableSubscriptionStatus(graphMcpEndpoint, remoteUnavailableMessage ?? `Chain Insights Graph is not connected at ${graphMcpEndpoint}`));
1173
1285
  if (!remoteToolNames.has("subscription_status")) return jsonTextResult(unavailableSubscriptionStatus(graphMcpEndpoint, "The graph backend exposes primitive graph tools but no subscription_status tool."));
1174
1286
  const walletAddress = await localSubscriptionWalletAddress();
@@ -1380,6 +1492,7 @@ async function createProxy() {
1380
1492
  const inputSchema = knownPublicToolInputSchema(tool.name) ?? z.object({}).passthrough();
1381
1493
  const handler = async (args) => {
1382
1494
  try {
1495
+ await ensureRemote();
1383
1496
  if (!remoteConnected) return {
1384
1497
  content: [{
1385
1498
  type: "text",