chain-insights 0.44.0 → 0.45.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.
Files changed (33) hide show
  1. package/README.md +8 -6
  2. package/bin/install.cjs +2 -4
  3. package/dist/cache-location-BO2XkfgI.mjs +11 -0
  4. package/dist/cache-location-BO2XkfgI.mjs.map +1 -0
  5. package/dist/cache-location-CBVzUIxq.cjs +23 -0
  6. package/dist/{capabilities-A8fsfP24.cjs → capabilities-BGkL9yAr.cjs} +6 -0
  7. package/dist/{capabilities-DfWL6n_g.mjs → capabilities-Cur8x6c7.mjs} +2 -2
  8. package/dist/{capabilities-DfWL6n_g.mjs.map → capabilities-Cur8x6c7.mjs.map} +1 -1
  9. package/dist/cli.cjs +32 -6
  10. package/dist/cli.mjs +32 -6
  11. package/dist/cli.mjs.map +1 -1
  12. package/dist/graph-schema-D1orCe5F.mjs +914 -0
  13. package/dist/graph-schema-D1orCe5F.mjs.map +1 -0
  14. package/dist/graph-schema-znb_9MYq.cjs +972 -0
  15. package/dist/mcp-proxy.cjs +43 -462
  16. package/dist/mcp-proxy.d.cts.map +1 -1
  17. package/dist/mcp-proxy.d.mts.map +1 -1
  18. package/dist/mcp-proxy.mjs +35 -454
  19. package/dist/mcp-proxy.mjs.map +1 -1
  20. package/dist/public-tools-CZyeptJQ.mjs.map +1 -1
  21. package/dist/{schema-cache-DJdU4MIP.mjs → schema-cache-BT-kHy_r.mjs} +4 -5
  22. package/dist/schema-cache-BT-kHy_r.mjs.map +1 -0
  23. package/dist/{schema-cache-n7Gbhvsj.cjs → schema-cache-DwddPLcY.cjs} +3 -5
  24. package/docs/architecture.md +2 -0
  25. package/docs/graph-query-compatibility.md +4 -5
  26. package/docs/graph-tools.md +22 -14
  27. package/docs/mcp-proxy.md +17 -5
  28. package/docs/stability.md +2 -1
  29. package/package.json +1 -1
  30. package/skills/chain-insights-cypher/SKILL.md +137 -67
  31. package/skills/chain-insights-cypher/agents/openai.yaml +1 -1
  32. package/dist/schema-cache-DJdU4MIP.mjs.map +0 -1
  33. package/skills/chain-insights-schema-evm/SKILL.md +0 -541
@@ -3,7 +3,8 @@ import { t as PaymentRequiredError } from "./client-gsLi528K.mjs";
3
3
  import { i as isAppOnlyTool, n as PUBLIC_MCP_TOOL_ALLOWED_ARGS, r as PUBLIC_MCP_TOOL_REQUIRED_ARGS, t as HIDDEN_REMOTE_TOOL_NAMES } from "./tool-visibility-P9wETx-d.mjs";
4
4
  import { t as primitiveBackendUsageStatus } from "./usage-status-CXOnrq-s.mjs";
5
5
  import { n as unavailableSubscriptionStatus } from "./subscription-status-CJ-DaMW9.mjs";
6
- import { n as mirrorGraphNetworkCapabilities } from "./capabilities-DfWL6n_g.mjs";
6
+ import { r as mirrorGraphNetworkCapabilities } from "./capabilities-Cur8x6c7.mjs";
7
+ import { a as handleMetaSchema, c as GRAPH_EXPAND_TITLE, l as GRAPH_EXPAND_TOOL, n as META_SCHEMA_DESCRIPTION, o as FLOWS_MAX_OFFSET, r as META_SCHEMA_TITLE, s as GRAPH_EXPAND_DESCRIPTION, t as GraphSchemaError, u as handleGraphExpand } from "./graph-schema-D1orCe5F.mjs";
7
8
  import { r as appendActionLog, t as actionLogSignalsFromResult } from "./action-log-D49Par9P.mjs";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import path from "node:path";
@@ -70,461 +71,10 @@ function routingHintLines() {
70
71
  return [`- Pick the layer first. ${ROUTING_RULE} 1. ${ROUTING_LINES[0]} 2. ${ROUTING_LINES[1]} 3. ${ROUTING_LINES[2]} ${ROUTING_NO_ADDRESS_ON_CHAIN} ${ROUTING_TWO_FIT} ${ROUTING_HANDOFF} ${ROUTING_NOT_SERVED} The lookups that USE chain serves are listed in chain_admission.lookups of meta_network_capabilities. Read every limit that the server publishes from meta_network_capabilities (chain_admission and, when the server sends them, topology_admission and facts_admission). Never write a limit down.`, `- Act on a refusal by its class. ${moveLines().join(" ")}`];
71
72
  }
72
73
  //#endregion
73
- //#region src/mcp/flows.ts
74
- /**
75
- * graph_expand, the one dedicated tool of the local proxy, called by the Chain
76
- * Insights view (ui://chain-insights/view) when the investigator clicks. Its
77
- * _meta says visibility ["app"], so a host keeps it away from the model: the
78
- * model writes its own graph_query, and the view draws the rows by their column
79
- * names.
80
- *
81
- * Two forms, told apart by the arguments:
82
- *
83
- * - node, {network, address, in_offset?, out_offset?}: one address with its
84
- * newest senders and receivers, a text summary and chain-insights.flows.v1 in
85
- * structuredContent. Composed from three anchored graph_query reads on the
86
- * topology graph (the node, its outgoing FLOWS_TO and its incoming FLOWS_TO,
87
- * newest first).
88
- * - link, {network, from, to, day}: the transfers between two known addresses
89
- * on one UTC day, newest first, at most 50, a short text summary and
90
- * chain-insights.transfers.v1 in structuredContent. One anchored USE facts
91
- * graph_query read.
92
- *
93
- * Every read goes through the proxy's remote client, the same path and payment
94
- * wrapping a graph_query call takes, and is billed as a graph_query. Every
95
- * argument is checked before the first read, so a refused call reaches no graph
96
- * endpoint and costs nothing.
97
- */
98
- const FLOWS_SCHEMA = "chain-insights.flows.v1";
99
- const GRAPH_EXPAND_TOOL = "graph_expand";
100
- const FLOWS_NETWORK = "robinhood";
101
- /** Where a graph_expand page may start on each side, at most. */
102
- const FLOWS_MAX_OFFSET = 1e4;
103
- const TRANSFERS_SCHEMA = "chain-insights.transfers.v1";
104
- const INVALID_ADDRESS_TEXT = "invalid_address: a full 0x address of 40 hexadecimal characters is needed, for example 0x04911a118f11c75667e4d0dfb8e640af5a353550";
105
- const OFFSET_TOO_LARGE_TEXT = `invalid_offset: in_offset and out_offset must each be at most ${FLOWS_MAX_OFFSET}`;
106
- const OFFSET_NEGATIVE_TEXT = "invalid_offset: in_offset and out_offset must each be a whole number, 0 or more";
107
- const BOTH_FORMS_TEXT = "invalid_arguments: pass address (with in_offset and out_offset) to expand a node, or from, to and day to list the transfers of a link, not both";
108
- const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/;
109
- const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
110
- const CODE_PREFIX = /^([a-z][a-z0-9_]*):\s*/;
111
- /**
112
- * The address lower-cased, or null when it is not 0x and 40 hexadecimal
113
- * characters. Only an address that passes is ever written into a read, so the
114
- * reads can carry it as a literal.
115
- */
116
- function normalizeFlowsAddress(raw) {
117
- if (typeof raw !== "string") return null;
118
- const address = raw.trim().toLowerCase();
119
- return ADDRESS_PATTERN.test(address) ? address : null;
120
- }
121
- /**
122
- * The day as YYYY-MM-DD when it is a real calendar date, or null. Only a day
123
- * that passes is ever written into a read.
124
- */
125
- function normalizeFlowsDay(raw) {
126
- if (typeof raw !== "string") return null;
127
- const day = raw.trim();
128
- if (!DAY_PATTERN.test(day)) return null;
129
- const time = Date.parse(`${day}T00:00:00Z`);
130
- if (Number.isNaN(time) || new Date(time).toISOString().slice(0, 10) !== day) return null;
131
- return day;
132
- }
133
- /**
134
- * The one facts read of a link: the transfers from one address to another on
135
- * one day. A facts read names the pair with one day, takes no ORDER BY and
136
- * holds at most 200 rows, so the read asks for the whole cap and the order is
137
- * made in buildTransfersView. Both addresses and the day have passed their
138
- * checks, so they are written into the read as literals.
139
- */
140
- function transfersReadFor(from, to, day) {
141
- return `USE facts MATCH (a:Address {address: "${from}"})-[t:TRANSFER]->(b:Address {address: "${to}"}) WHERE t.block_date = "${day}" RETURN t.tx_id AS tx_id, t.block_timestamp AS block_timestamp, t.amount AS amount, t.asset_symbol AS asset_symbol, t.amount_usd AS amount_usd LIMIT 200`;
142
- }
143
- const NODE_FIELDS = "b.address AS address, b.labels AS labels, b.is_exchange AS is_exchange, b.is_sanctioned AS is_sanctioned, b.is_scam AS is_scam, b.is_victim AS is_victim, b:Pool AS pool, b.total_in_usd AS total_in_usd, b.total_out_usd AS total_out_usd, b.degree_in AS degree_in, b.degree_out AS degree_out";
144
- const PARTY_FIELDS = NODE_FIELDS + ", f.amount_usd_sum AS usd, f.tx_count AS tx_count, " + "f.first_seen_timestamp AS first_seen, f.last_seen_timestamp AS last_seen";
145
- const PAGE_ORDER = " ORDER BY f.last_seen_timestamp DESC, b.address";
146
- /**
147
- * The three anchored reads of one page. Each side asks for one row more than
148
- * it shows: the extra row says another page exists.
149
- */
150
- function flowsReadsFor(address, inOffset, outOffset) {
151
- const page = (offset) => `${PAGE_ORDER}${offset > 0 ? ` SKIP ${offset}` : ""} LIMIT 13`;
152
- return [
153
- {
154
- id: "node",
155
- query: `USE topology MATCH (b:Address {address: '${address}'}) RETURN ${NODE_FIELDS} LIMIT 1`
156
- },
157
- {
158
- id: "out",
159
- query: `USE topology MATCH (a:Address {address: '${address}'})-[f:FLOWS_TO]->(b:Address) RETURN ${PARTY_FIELDS}${page(outOffset)}`
160
- },
161
- {
162
- id: "in",
163
- query: `USE topology MATCH (b:Address)-[f:FLOWS_TO]->(a:Address {address: '${address}'}) RETURN ${PARTY_FIELDS}${page(inOffset)}`
164
- }
165
- ];
166
- }
167
- /** The role word of an address, first match wins. */
168
- function flowsRole(row) {
169
- if (row.is_exchange === true) return "exchange";
170
- if (row.is_sanctioned === true) return "sanctioned";
171
- if (row.is_scam === true) return "scam";
172
- if (row.is_victim === true) return "victim";
173
- if (row.pool === true) return "pool";
174
- return "other";
175
- }
176
- function flowsFloat(value) {
177
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
178
- }
179
- function flowsInt(value) {
180
- return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 0;
181
- }
182
- function flowsNodeOf(row) {
183
- const labels = Array.isArray(row.labels) ? row.labels.filter((label) => typeof label === "string") : [];
184
- return {
185
- address: typeof row.address === "string" ? row.address : "",
186
- role: flowsRole(row),
187
- labels,
188
- total_in_usd: flowsFloat(row.total_in_usd),
189
- total_out_usd: flowsFloat(row.total_out_usd),
190
- degree_in: flowsInt(row.degree_in),
191
- degree_out: flowsInt(row.degree_out)
192
- };
193
- }
194
- function assembleFlowView(network, page, senders, receivers, truncated) {
195
- const center = flowsNodeOf(page.center);
196
- const view = {
197
- schema: FLOWS_SCHEMA,
198
- network,
199
- center: center.address,
200
- nodes: [center],
201
- edges: [],
202
- cursor: {
203
- in_offset: page.inOffset + senders.length,
204
- out_offset: page.outOffset + receivers.length
205
- },
206
- truncated
207
- };
208
- const seen = /* @__PURE__ */ new Set([center.address]);
209
- const add = (row, incoming) => {
210
- const node = flowsNodeOf(row);
211
- if (!seen.has(node.address)) {
212
- seen.add(node.address);
213
- view.nodes.push(node);
214
- }
215
- view.edges.push({
216
- from: incoming ? node.address : center.address,
217
- to: incoming ? center.address : node.address,
218
- usd: flowsFloat(row.usd),
219
- tx_count: flowsInt(row.tx_count),
220
- first_seen_ms: flowsInt(row.first_seen),
221
- last_seen_ms: flowsInt(row.last_seen)
222
- });
223
- };
224
- for (const row of senders) add(row, true);
225
- for (const row of receivers) add(row, false);
226
- return view;
227
- }
228
- function fitsFlowBudget(view) {
229
- return view.nodes.length <= 60 && JSON.stringify(view).length < 4e4;
230
- }
231
- /**
232
- * flows.v1 from one page: the centre first, then the senders and the
233
- * receivers, each newest first, at most FLOWS_PER_SIDE a side. When the view
234
- * data would hold more than FLOWS_MAX_NODES addresses or reach FLOWS_MAX_CHARS
235
- * characters, the oldest links go first, the view is marked truncated, and the
236
- * cursor still says where the next page starts.
237
- */
238
- function buildFlowView(network, page) {
239
- let truncated = page.senders.length > 12 || page.receivers.length > 12;
240
- let senders = page.senders.slice(0, 12);
241
- let receivers = page.receivers.slice(0, 12);
242
- for (;;) {
243
- const view = assembleFlowView(network, page, senders, receivers, truncated);
244
- if (fitsFlowBudget(view) || senders.length + receivers.length === 0) return view;
245
- truncated = true;
246
- if (senders.length >= receivers.length) senders = senders.slice(0, -1);
247
- else receivers = receivers.slice(0, -1);
248
- }
249
- }
250
- function formatFlowsUsd(value) {
251
- if (!(value > 0)) return "$0";
252
- if (value < .01) return "under $0.01";
253
- const whole = Math.floor(value);
254
- const cents = Math.round((value - whole) * 100);
255
- if (cents === 100) return formatFlowsUsd(whole + 1);
256
- return `$${whole.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${String(cents).padStart(2, "0")}`;
257
- }
258
- function formatFlowsAge(lastSeenMs, now) {
259
- if (lastSeenMs <= 0) return "at an unknown time";
260
- const date = new Date(lastSeenMs).toISOString().slice(0, 10);
261
- const days = Math.trunc((now.getTime() - lastSeenMs) / 864e5);
262
- if (days < 1) return `${date} (less than a day ago)`;
263
- if (days === 1) return `${date} (1 day ago)`;
264
- return `${date} (${days} days ago)`;
265
- }
266
- /** The role word, with up to three address labels beside it. */
267
- function roleText(node) {
268
- if (!node) return "other";
269
- if (node.labels.length === 0) return node.role;
270
- return `${node.role} (${node.labels.slice(0, 3).join(", ")})`;
271
- }
272
- function sideLines(title, parties) {
273
- if (parties.length === 0) return [`${title}, newest first: none.`];
274
- const lines = [`${title}, newest first (${parties.length}):`];
275
- const perLine = parties.length > 6 ? 2 : 1;
276
- for (let start = 0; start < parties.length; start += perLine) lines.push(`- ${parties.slice(start, start + perLine).join("; ")}`);
277
- return lines;
278
- }
279
- /**
280
- * The text of a node answer: the centre, its role and lifetime totals, then
281
- * every counterparty of the view with its USD, transaction count and last-seen
282
- * date and age. At most FLOWS_SUMMARY_MAX_LINES lines.
283
- */
284
- function flowsSummary(view, now) {
285
- const nodes = new Map(view.nodes.map((node) => [node.address, node]));
286
- const center = nodes.get(view.center);
287
- const party = (address, edge) => `${address} ${roleText(nodes.get(address))}, ${formatFlowsUsd(edge.usd)} in ${edge.tx_count} tx, last seen ${formatFlowsAge(edge.last_seen_ms, now)}`;
288
- const senders = [];
289
- const receivers = [];
290
- for (const edge of view.edges) if (edge.to === view.center) senders.push(party(edge.from, edge));
291
- else receivers.push(party(edge.to, edge));
292
- const body = [
293
- `Money flows of ${view.center} on ${view.network}: ${roleText(center)}; lifetime ${formatFlowsUsd(center?.total_in_usd ?? 0)} in, ${formatFlowsUsd(center?.total_out_usd ?? 0)} out; ${center?.degree_in ?? 0} senders and ${center?.degree_out ?? 0} receivers in the topology graph.`,
294
- ...sideLines("Recent senders", senders),
295
- ...sideLines("Recent receivers", receivers)
296
- ].slice(0, 18);
297
- const footer = pagingLines(view, senders.length, receivers.length, center);
298
- return [...body, ...footer].join("\n");
299
- }
300
- /** Where this page sits, and that the picture loads the rest when an address is clicked. */
301
- function pagingLines(view, shownSenders, shownReceivers, center) {
302
- const totalIn = center?.degree_in ?? 0;
303
- const totalOut = center?.degree_out ?? 0;
304
- const { in_offset: nextIn, out_offset: nextOut } = view.cursor;
305
- const range = (shown, next, total) => shown === 0 ? `none of ${total}` : `${next - shown + 1} to ${next} of ${total}`;
306
- const where = `Showing senders ${range(shownSenders, nextIn, totalIn)} and receivers ${range(shownReceivers, nextOut, totalOut)}, newest first.`;
307
- if (!view.truncated && nextIn >= totalIn && nextOut >= totalOut) return [where];
308
- return [where, "More senders and receivers load when an address in the picture is clicked."];
309
- }
310
- /** A finite number, or a decimal string read as one, or null. */
311
- function flowsNumberOrNull(value) {
312
- if (typeof value === "number") return Number.isFinite(value) ? value : null;
313
- if (typeof value === "string" && value.trim() !== "") {
314
- const parsed = Number(value);
315
- return Number.isFinite(parsed) ? parsed : null;
316
- }
317
- return null;
318
- }
319
- function transferOf(row) {
320
- const amount = typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : typeof row.amount === "string" && row.amount.trim() !== "" ? row.amount : null;
321
- return {
322
- tx_id: typeof row.tx_id === "string" ? row.tx_id : "",
323
- block_timestamp: flowsNumberOrNull(row.block_timestamp),
324
- amount,
325
- asset_symbol: typeof row.asset_symbol === "string" && row.asset_symbol !== "" ? row.asset_symbol : null,
326
- amount_usd: flowsNumberOrNull(row.amount_usd)
327
- };
328
- }
329
- /**
330
- * chain-insights.transfers.v1 from the rows of one facts read: newest first,
331
- * at most TRANSFERS_MAX, truncated when more rows than that came back. The
332
- * facts layer takes no ORDER BY, so the order is made here.
333
- */
334
- function buildTransfersView(network, from, to, day, rows) {
335
- const newestFirst = rows.map(transferOf).sort((a, b) => (b.block_timestamp ?? -Infinity) - (a.block_timestamp ?? -Infinity));
336
- return {
337
- schema: TRANSFERS_SCHEMA,
338
- network,
339
- from,
340
- to,
341
- day,
342
- transfers: newestFirst.slice(0, 50),
343
- truncated: newestFirst.length > 50
344
- };
345
- }
346
- function transferLine(transfer) {
347
- return `- ${transfer.block_timestamp === null ? "at an unknown time" : `${new Date(transfer.block_timestamp).toISOString().slice(0, 19).replace("T", " ")} UTC`}, ${transfer.amount === null ? "an unknown amount" : String(transfer.amount)}${transfer.asset_symbol ? ` ${transfer.asset_symbol}` : ""}, ${transfer.amount_usd === null ? "no USD price" : formatFlowsUsd(transfer.amount_usd)}, tx ${transfer.tx_id}`;
348
- }
349
- /** The short text of a link answer: the pair, the day, the count and the newest few transfers. */
350
- function transfersSummary(view) {
351
- const head = `Transfers from ${view.from} to ${view.to} on ${view.day} (UTC) on ${view.network}`;
352
- if (view.transfers.length === 0) return `${head}: none.`;
353
- const lines = [`${head}: ${view.truncated ? `the newest ${view.transfers.length}, and the day holds more` : `${view.transfers.length}`}, newest first.`, ...view.transfers.slice(0, 5).map(transferLine)];
354
- const rest = view.transfers.length - 5;
355
- if (rest > 0) lines.push(`- and ${rest} more in the picture.`);
356
- return lines.join("\n");
357
- }
358
- function errorResult(text) {
359
- return {
360
- content: [{
361
- type: "text",
362
- text
363
- }],
364
- isError: true
365
- };
366
- }
367
- function isRecord$1(value) {
368
- return !!value && typeof value === "object" && !Array.isArray(value);
369
- }
370
- function answerText(answer) {
371
- return (answer.content ?? []).map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").filter(Boolean).join("\n").trim();
372
- }
373
- /**
374
- * The refusal of a read that did not answer, naming its code first:
375
- * error_detail.code when the graph endpoint sent its typed refusal, the code
376
- * its text starts with otherwise, and graph_query_failed when it names none.
377
- * The view marks the address it was expanding.
378
- */
379
- function refusalText(answer) {
380
- const text = answerText(answer);
381
- const detail = isRecord$1(answer.structuredContent) ? answer.structuredContent.error_detail : void 0;
382
- const detailCode = isRecord$1(detail) && typeof detail.code === "string" ? detail.code : "";
383
- const textCode = CODE_PREFIX.exec(text)?.[1] ?? "";
384
- const code = detailCode || textCode || "graph_query_failed";
385
- const rest = textCode === code ? text.replace(CODE_PREFIX, "") : text;
386
- return rest ? `${code}: ${rest}` : `${code}: the graph endpoint refused the read`;
387
- }
388
- function rowsOf(answer) {
389
- const facts = isRecord$1(answer.structuredContent) ? answer.structuredContent.facts : void 0;
390
- const query = isRecord$1(facts) ? facts.query : void 0;
391
- const results = isRecord$1(query) ? query.results : void 0;
392
- return Array.isArray(results) ? results.filter(isRecord$1) : null;
393
- }
394
- function checkNetwork(args) {
395
- const rawNetwork = typeof args.network === "string" ? args.network.trim().toLowerCase() : "";
396
- if (!rawNetwork) return { error: "invalid_network: network is required; pass robinhood" };
397
- if (rawNetwork !== "robinhood") return { error: `invalid_network: graph_expand reads the robinhood graph only; got "${String(args.network).trim()}"` };
398
- return FLOWS_NETWORK;
399
- }
400
- /** The address of a node or of one end of a link; `name` says which end in a link refusal. */
401
- function checkAddress(raw, name) {
402
- const address = normalizeFlowsAddress(raw);
403
- if (address) return address;
404
- return { error: name ? INVALID_ADDRESS_TEXT.replace("invalid_address:", `invalid_address (${name}):`) : INVALID_ADDRESS_TEXT };
405
- }
406
- function checkDay(raw) {
407
- return normalizeFlowsDay(raw) ?? { error: "invalid_day: day must be a calendar date written YYYY-MM-DD (UTC), for example 2026-07-10" };
408
- }
409
- function checkOffset(value) {
410
- if (value === void 0 || value === null) return 0;
411
- if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return { error: OFFSET_NEGATIVE_TEXT };
412
- if (value > 1e4) return { error: OFFSET_TOO_LARGE_TEXT };
413
- return value;
414
- }
415
- /**
416
- * Read and answer one node page. The reads run one after another: the node
417
- * first, so an address the graph does not hold costs one read, not three; then
418
- * the receivers and the senders. The first read that does not answer ends the
419
- * call.
420
- */
421
- async function runNode(deps, network, address, inOffset, outOffset) {
422
- const unavailable = deps.unavailable?.();
423
- if (unavailable) return errorResult(unavailable);
424
- const rows = {};
425
- for (const read of flowsReadsFor(address, inOffset, outOffset)) {
426
- let answer;
427
- try {
428
- answer = await deps.graphQuery({
429
- network,
430
- query: read.query
431
- });
432
- } catch (err) {
433
- return errorResult(deps.describeFailure(err));
434
- }
435
- if (answer.isError === true) return errorResult(refusalText(answer));
436
- const found = rowsOf(answer);
437
- if (!found) return errorResult("graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows");
438
- rows[read.id] = found;
439
- if (read.id === "node" && found.length === 0) return errorResult(`${address} is not in the ${network} topology graph`);
440
- }
441
- const view = buildFlowView(network, {
442
- center: rows.node?.[0] ?? {},
443
- senders: rows.in ?? [],
444
- receivers: rows.out ?? [],
445
- inOffset,
446
- outOffset
447
- });
448
- return {
449
- content: [{
450
- type: "text",
451
- text: flowsSummary(view, deps.now ? deps.now() : /* @__PURE__ */ new Date())
452
- }],
453
- structuredContent: view,
454
- isError: false
455
- };
456
- }
457
- /** Read and answer the transfers of one link on one day: a single facts read. */
458
- async function runLink(deps, network, from, to, day) {
459
- const unavailable = deps.unavailable?.();
460
- if (unavailable) return errorResult(unavailable);
461
- let answer;
462
- try {
463
- answer = await deps.graphQuery({
464
- network,
465
- query: transfersReadFor(from, to, day)
466
- });
467
- } catch (err) {
468
- return errorResult(deps.describeFailure(err));
469
- }
470
- if (answer.isError === true) return errorResult(refusalText(answer));
471
- const rows = rowsOf(answer);
472
- if (!rows) return errorResult("graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows");
473
- const view = buildTransfersView(network, from, to, day, rows);
474
- return {
475
- content: [{
476
- type: "text",
477
- text: transfersSummary(view)
478
- }],
479
- structuredContent: view,
480
- isError: false
481
- };
482
- }
483
- function given(record, keys) {
484
- return keys.some((key) => record[key] !== void 0 && record[key] !== null);
485
- }
486
- /**
487
- * graph_expand, the node form {network, address, in_offset?, out_offset?} or the
488
- * link form {network, from, to, day}. Every argument is checked before the
489
- * first read.
490
- */
491
- async function handleGraphExpand(args, deps) {
492
- const record = isRecord$1(args) ? args : {};
493
- const network = checkNetwork(record);
494
- if (typeof network !== "string") return errorResult(network.error);
495
- const isLink = given(record, [
496
- "from",
497
- "to",
498
- "day"
499
- ]);
500
- if (isLink && given(record, [
501
- "address",
502
- "in_offset",
503
- "out_offset"
504
- ])) return errorResult(BOTH_FORMS_TEXT);
505
- if (isLink) {
506
- const from = checkAddress(record.from, "from");
507
- if (typeof from !== "string") return errorResult(from.error);
508
- const to = checkAddress(record.to, "to");
509
- if (typeof to !== "string") return errorResult(to.error);
510
- const day = checkDay(record.day);
511
- if (typeof day !== "string") return errorResult(day.error);
512
- return runLink(deps, network, from, to, day);
513
- }
514
- const address = checkAddress(record.address);
515
- if (typeof address !== "string") return errorResult(address.error);
516
- const inOffset = checkOffset(record.in_offset);
517
- if (typeof inOffset !== "number") return errorResult(inOffset.error);
518
- const outOffset = checkOffset(record.out_offset);
519
- if (typeof outOffset !== "number") return errorResult(outOffset.error);
520
- return runNode(deps, network, address, inOffset, outOffset);
521
- }
522
- const GRAPH_EXPAND_TITLE = "Expand a node or a link in the picture";
523
- const GRAPH_EXPAND_DESCRIPTION = "Expand an open Chain Insights picture. Called by the view when the investigator clicks, never by the model. A node, {network, address, in_offset?, out_offset?}, loads the newest senders and receivers of one address (12 a side, from the offsets); billed as three graph_query topology reads. A link, {network, from, to, day}, lists the transfers between two addresses on one UTC day (YYYY-MM-DD), newest first, at most 50; billed as one graph_query facts read. Read-only; an argument refused before a read costs nothing.";
524
- //#endregion
525
74
  //#region src/mcp/proxy.ts
526
75
  const LOCAL_TOOL_NAMES = /* @__PURE__ */ new Set([
527
76
  "meta_network_capabilities",
77
+ "meta_schema",
528
78
  "meta_usage_status",
529
79
  "meta_subscription_status",
530
80
  "meta_help",
@@ -580,6 +130,7 @@ function resolveMcpProxyMode(env = process.env) {
580
130
  const GRAPH_LAYERS_TEXT = "Use USE topology for topology (address/FLOWS_TO/OPERATED_BY/LINKED graph with SWAPPED, ADDED_LIQUIDITY, REMOVED_LIQUIDITY, BRIDGED and the Pool label, unified recent+historical) and USE facts for bounded TRANSFER, SWAP, LIQUIDITY_ADD, LIQUIDITY_REMOVE and BRIDGE_CROSSING rows and enrichment, and USE chain for the chain node's own record of one known transaction, block or the head.";
581
131
  const KNOWN_PUBLIC_TOOL_DESCRIPTIONS = {
582
132
  meta_network_capabilities: "Return the current Chain Insights network and tool support matrix.",
133
+ meta_schema: META_SCHEMA_DESCRIPTION,
583
134
  meta_usage_status: "Return the caller's public free graph_query quota for the current UTC day.",
584
135
  meta_subscription_status: "Return the caller's CIA subscription window end, daily allowance, consumption, and tier.",
585
136
  meta_help: "Show a short guide to Chain Insights tools and workflow.",
@@ -632,6 +183,7 @@ const GRAPH_SCHEMA_HINTS = [
632
183
  "- A USE facts SWAP row carries no route: it has no pools and no families column. A read that names one of them, to return it, to filter on it or to order by it, is refused. The route of a swap stays a topology question. For the pools of the swaps of an address, read SWAPPED.pools and SWAPPED.families on USE topology, anchored on the payer or the recipient. SWAPPED has one link per payer, recipient, sold asset and bought asset, so its pools cover every route on the link, not one route.",
633
184
  "- Traversal rule: for BFS, fixed-hop fallback, shortest-path, or manual FLOWS_TO traversal, exchange hot wallets are terminal endpoints only. Do not expand from, through, or classify exchange nodes as deposit, suspect, or intermediate candidates; filter every non-terminal node with is_exchange IS NULL. is_exchange is absent unless true, so a labelled node with no is_exchange is walked through, and is_scam, is_victim and is_sanctioned do not end a walk. At a Pool, follow the pool trace rule above.",
634
185
  "- Pool guard: a trace walks FLOWS_TO and SWAPPED, so it crosses a swap from payer to recipient without passing through the pool. A walk may end at a Pool, but never starts at one or passes through one: its start and every address in its middle stay off a Pool. A fixed-hop walk adds WHERE NOT src:Pool AND NOT mid:Pool, each its own AND term, never inside an OR. A quantified or shortest-path walk puts the guards inside the path pattern, on the start and on up to 4 guarded hops before one last hop: MATCH p = SHORTEST 1 (a:Address {address: $from} WHERE NOT a:Pool) (()-[:FLOWS_TO|SWAPPED]-(via:Address) WHERE NOT via:Pool){0,4} ()-[:FLOWS_TO|SWAPPED]-(b:Address {address: $to}) RETURN [n IN nodes(p) | n.address] AS route LIMIT 5. ANY SHORTEST takes the same pattern. A route search asks for one path. An open target from one address: MATCH SHORTEST 1 (a:Address {address: $addr} WHERE NOT a:Pool) (()-[:FLOWS_TO|SWAPPED]-(via:Address) WHERE NOT via:Pool){0,4} ()-[:FLOWS_TO|SWAPPED]-(b:Address) RETURN b.address LIMIT 50. Use these shapes as written, changing only the addresses and the RETURN. A WHERE placed after a SHORTEST pattern runs after the shortest route is chosen, so it drops a route that crosses a pool instead of finding the route that avoids it.",
186
+ "- Call meta_schema {network} first when you need field names; it is cached for 24 hours. The two discovery reads below are the fallback.",
635
187
  "- Start schema discovery with endpoint-safe property reads: MATCH (n:Address) RETURN n.address AS address, n.network AS network, n.labels AS labels, n.last_activity_timestamp AS last_activity_timestamp LIMIT 20",
636
188
  "- Relationship discovery: MATCH (:Address)-[r:FLOWS_TO]->(:Address) RETURN r.amount_usd_sum AS amount_usd_sum, r.tx_count AS tx_count LIMIT 20",
637
189
  "- Anchor every topology read that filters on a link property: put an address in its pattern. Without one, a read starts from every link of the type it names, and you should not count on the filter to narrow that: WHERE x.strength = 'swap' on SWAPPED checks every SWAPPED link, LIMIT stops the read only after enough rows match, and a filter that matches few or none can run to the 60 s topology limit and fail with query_timeout. Example: MATCH (a:Address {address: $addr})-[x:SWAPPED]->(b:Address) WHERE x.swap_count >= 2 RETURN b.address, x.swap_count LIMIT 25. Pick an address with few links: degree_out and degree_in are a rough guide, because they count neighbours, not links, and an address with hundreds of thousands of neighbours can fail the same way. Discovery probes with LIMIT and no filter stay valid. The queries of one batch share a 100 s budget; USE facts queries stop at 30 s.",
@@ -926,6 +478,11 @@ function registerLocalPrompts(server) {
926
478
  description: "Inspect supported networks and available tools before selecting a network.",
927
479
  argsSchema: {}
928
480
  }, async () => promptResult("Use Chain Insights meta_network_capabilities. Report only the supported networks and available tools exactly as returned; do not infer unsupported networks.", "Network capabilities"));
481
+ server.registerPrompt("meta-schema", {
482
+ title: "Graph Schema",
483
+ description: "Read the live graph schema of one network before writing a query.",
484
+ argsSchema: { network: NETWORK_SCHEMA }
485
+ }, async ({ network }) => promptResult(`Use Chain Insights meta_schema with network ${network}. Report the labels, link types and fields exactly as returned; do not invent fields.`, "Graph schema"));
929
486
  server.registerPrompt("meta-usage-status", {
930
487
  title: "Usage Status",
931
488
  description: "Check the caller's public free graph_query quota.",
@@ -1050,7 +607,7 @@ async function createProxy() {
1050
607
  const { loadConfig } = await import("./config-9MabJh5R.mjs").then((n) => n.t);
1051
608
  const { activeDataDir, findActiveWorkspace } = await import("./active-Dkf8AB1R.mjs").then((n) => n.t);
1052
609
  const { createConfiguredGraphMcpFetch, resolveGraphMcpEndpoint } = await import("./client-gsLi528K.mjs").then((n) => n.r);
1053
- const { loadSchema, saveSchema } = await import("./schema-cache-DJdU4MIP.mjs");
610
+ const { loadSchema, saveSchema } = await import("./schema-cache-BT-kHy_r.mjs");
1054
611
  const proxyMode = resolveMcpProxyMode();
1055
612
  const workspaceArtifactsEnabled = proxyMode === "workspace";
1056
613
  const loadedConfig = await loadConfig();
@@ -1179,6 +736,28 @@ async function createProxy() {
1179
736
  }
1180
737
  return jsonTextResult(cleanNetworkCapabilities(void 0));
1181
738
  });
739
+ const schemaDependencies = {
740
+ endpoint: graphMcpEndpoint,
741
+ withClient: async (fn) => {
742
+ 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.`);
743
+ return fn(remoteClient);
744
+ },
745
+ describeFailure: (err) => remoteCallFailureText("meta_schema", err)
746
+ };
747
+ server.registerTool("meta_schema", {
748
+ title: META_SCHEMA_TITLE,
749
+ description: KNOWN_PUBLIC_TOOL_DESCRIPTIONS.meta_schema,
750
+ inputSchema: {
751
+ network: NETWORK_SCHEMA,
752
+ refresh: z.boolean().optional().describe("Rebuild the schema now instead of reading the 24 hour cache.")
753
+ },
754
+ annotations: {
755
+ readOnlyHint: true,
756
+ destructiveHint: false,
757
+ idempotentHint: true,
758
+ openWorldHint: true
759
+ }
760
+ }, async (args) => handleMetaSchema(args, schemaDependencies));
1182
761
  server.registerTool("meta_usage_status", {
1183
762
  title: "Usage Status",
1184
763
  description: KNOWN_PUBLIC_TOOL_DESCRIPTIONS.meta_usage_status,
@@ -1348,6 +927,7 @@ async function createProxy() {
1348
927
  "",
1349
928
  "Investigation tools:",
1350
929
  "- meta_network_capabilities: inspect supported networks and available tools.",
930
+ "- meta_schema: read the live graph schema of one network: labels, link types and field names. Cached for 24 hours.",
1351
931
  "- meta_usage_status: check the caller public free graph_query quota.",
1352
932
  "- meta_subscription_status: check the caller CIA subscription window end, daily allowance, consumption, and tier.",
1353
933
  "- graph_query: run read-only GQL/Cypher through the universal graph endpoint. Use USE topology or USE facts.",
@@ -1363,6 +943,7 @@ async function createProxy() {
1363
943
  "",
1364
944
  "Available graph-backed tools:",
1365
945
  "- meta_network_capabilities: inspect supported networks and available tools.",
946
+ "- meta_schema: read the live graph schema of one network: labels, link types and field names. Cached for 24 hours.",
1366
947
  "- meta_usage_status: check the caller public free graph_query quota.",
1367
948
  "- meta_subscription_status: check the caller CIA subscription window end, daily allowance, consumption, and tier.",
1368
949
  "- graph_query: run read-only GQL/Cypher through the universal graph endpoint. Use USE topology or USE facts.",