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.
- package/dist/mcp-proxy.cjs +143 -30
- package/dist/mcp-proxy.d.cts.map +1 -1
- package/dist/mcp-proxy.d.mts.map +1 -1
- package/dist/mcp-proxy.mjs +143 -30
- package/dist/mcp-proxy.mjs.map +1 -1
- package/docs/graph-tools.md +1 -1
- package/package.json +3 -2
- package/skills/chain-insights-cypher/SKILL.md +13 -6
package/dist/mcp-proxy.cjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
endpoint: graphMcpEndpoint
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
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",
|
package/dist/mcp-proxy.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp-proxy.d.cts","names":[],"sources":["../src/mcp/proxy.ts"],"mappings":";;
|
|
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"}
|
package/dist/mcp-proxy.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp-proxy.d.mts","names":[],"sources":["../src/mcp/proxy.ts"],"mappings":";;
|
|
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"}
|
package/dist/mcp-proxy.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
endpoint: graphMcpEndpoint
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
-
|
|
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",
|