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.
- package/dist/mcp-proxy.cjs +162 -30
- package/dist/mcp-proxy.d.cts +1 -0
- package/dist/mcp-proxy.d.cts.map +1 -1
- package/dist/mcp-proxy.d.mts +1 -0
- package/dist/mcp-proxy.d.mts.map +1 -1
- package/dist/mcp-proxy.mjs +162 -31
- package/dist/mcp-proxy.mjs.map +1 -1
- package/docs/graph-tools.md +1 -1
- package/docs/mcp-proxy.md +3 -1
- package/package.json +1 -1
- package/skills/chain-insights-cypher/SKILL.md +14 -9
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. */
|
|
@@ -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
|
-
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
endpoint: graphMcpEndpoint
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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
|
-
|
|
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;
|
package/dist/mcp-proxy.d.cts
CHANGED
|
@@ -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.
|
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
CHANGED
|
@@ -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.
|
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. */
|
|
@@ -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
|
-
|
|
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
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
endpoint: graphMcpEndpoint
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
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
|