chain-insights 0.37.2 → 0.40.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 (69) hide show
  1. package/README.md +25 -1
  2. package/bin/install.cjs +59 -2
  3. package/dist/{action-log-diTHyMbV.mjs → action-log-D49Par9P.mjs} +2 -2
  4. package/dist/{action-log-diTHyMbV.mjs.map → action-log-D49Par9P.mjs.map} +1 -1
  5. package/dist/{active-BQopLul8.mjs → active-Dkf8AB1R.mjs} +2 -2
  6. package/dist/{active-BQopLul8.mjs.map → active-Dkf8AB1R.mjs.map} +1 -1
  7. package/dist/apps/claude-view.html +117 -0
  8. package/dist/{capabilities-C8DQSfyW.mjs → capabilities-BOOUnAeV.mjs} +3 -3
  9. package/dist/{capabilities-C8DQSfyW.mjs.map → capabilities-BOOUnAeV.mjs.map} +1 -1
  10. package/dist/cli.cjs +26 -24
  11. package/dist/cli.mjs +49 -47
  12. package/dist/cli.mjs.map +1 -1
  13. package/dist/{client-CHYskPnG.mjs → client-gsLi528K.mjs} +4 -4
  14. package/dist/{client-CHYskPnG.mjs.map → client-gsLi528K.mjs.map} +1 -1
  15. package/dist/{config-DbbPiGk0.mjs → config-Br6VEK0O.mjs} +3 -3
  16. package/dist/{config-DbbPiGk0.mjs.map → config-Br6VEK0O.mjs.map} +1 -1
  17. package/dist/{config-B542AM4w.cjs → config-CPUhkz0Z.cjs} +1 -1
  18. package/dist/{create-C0buxEfU.mjs → create-CpfZq2fx.mjs} +2 -2
  19. package/dist/{create-C0buxEfU.mjs.map → create-CpfZq2fx.mjs.map} +1 -1
  20. package/dist/index.cjs +5 -4
  21. package/dist/index.d.cts.map +1 -1
  22. package/dist/index.d.mts.map +1 -1
  23. package/dist/index.mjs +7 -6
  24. package/dist/mcp-proxy.cjs +459 -34
  25. package/dist/mcp-proxy.d.cts +7 -0
  26. package/dist/mcp-proxy.d.cts.map +1 -1
  27. package/dist/mcp-proxy.d.mts +7 -0
  28. package/dist/mcp-proxy.d.mts.map +1 -1
  29. package/dist/mcp-proxy.mjs +466 -44
  30. package/dist/mcp-proxy.mjs.map +1 -1
  31. package/dist/{public-tools-DWONw7kZ.cjs → public-tools-3yTC17cA.cjs} +1 -6
  32. package/dist/{public-tools-CDpFHbZ8.mjs → public-tools-CZyeptJQ.mjs} +3 -8
  33. package/dist/public-tools-CZyeptJQ.mjs.map +1 -0
  34. package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-8H4AJuhK.mjs} +1 -0
  35. package/dist/{schema-B0OuHW_l.mjs → schema-KjZqCHbu.mjs} +3 -3
  36. package/dist/schema-KjZqCHbu.mjs.map +1 -0
  37. package/dist/schema-cache-DJdU4MIP.mjs.map +1 -1
  38. package/dist/{schema-CtFWYoJz.cjs → schema-tX0U0-VI.cjs} +1 -1
  39. package/dist/{subscription-status-MyeqOmbA.mjs → subscription-status-CJ-DaMW9.mjs} +2 -2
  40. package/dist/{subscription-status-MyeqOmbA.mjs.map → subscription-status-CJ-DaMW9.mjs.map} +1 -1
  41. package/dist/{tool-visibility-CyZqYnQA.cjs → tool-visibility-Boxtlbtr.cjs} +19 -1
  42. package/dist/{tool-visibility-CJV9HezW.mjs → tool-visibility-IB5nHCZ_.mjs} +16 -4
  43. package/dist/tool-visibility-IB5nHCZ_.mjs.map +1 -0
  44. package/dist/{tools-BRQAPF2R.mjs → tools-BnThuvE2.mjs} +3 -3
  45. package/dist/{tools-BRQAPF2R.mjs.map → tools-BnThuvE2.mjs.map} +1 -1
  46. package/dist/topup-server-BIbSfvX5.cjs +146 -0
  47. package/dist/{topup-server-YN3iHrHW.cjs → topup-server-Bh7MlzRt.cjs} +485 -239
  48. package/dist/{topup-server-BLdF1SFQ.mjs → topup-server-BrkPhqJG.mjs} +488 -242
  49. package/dist/topup-server-BrkPhqJG.mjs.map +1 -0
  50. package/dist/topup-server-DzeM_YAt.mjs +131 -0
  51. package/dist/topup-server-DzeM_YAt.mjs.map +1 -0
  52. package/dist/{usage-status-CHiOPA-2.mjs → usage-status-CXOnrq-s.mjs} +2 -2
  53. package/dist/{usage-status-CHiOPA-2.mjs.map → usage-status-CXOnrq-s.mjs.map} +1 -1
  54. package/dist/{wallet-COcm4oIb.mjs → wallet-GNDl3FkR.mjs} +2 -2
  55. package/dist/{wallet-COcm4oIb.mjs.map → wallet-GNDl3FkR.mjs.map} +1 -1
  56. package/docs/graph-query-compatibility.md +27 -10
  57. package/docs/graph-tools.md +40 -16
  58. package/docs/mcp-proxy.md +39 -8
  59. package/docs/stability.md +2 -1
  60. package/package.json +5 -6
  61. package/skills/chain-insights-cypher/SKILL.md +56 -23
  62. package/skills/chain-insights-schema-evm/SKILL.md +38 -27
  63. package/dist/html-generator-BCDtYcGs.mjs +0 -16
  64. package/dist/html-generator-BCDtYcGs.mjs.map +0 -1
  65. package/dist/html-generator-DFwMRWN3.cjs +0 -16
  66. package/dist/public-tools-CDpFHbZ8.mjs.map +0 -1
  67. package/dist/schema-B0OuHW_l.mjs.map +0 -1
  68. package/dist/tool-visibility-CJV9HezW.mjs.map +0 -1
  69. package/dist/topup-server-BLdF1SFQ.mjs.map +0 -1
@@ -2,13 +2,15 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_rolldown_runtime = require("./rolldown-runtime-C0BPl7ul.cjs");
3
3
  const require_version = require("./version-CO9Or_YV.cjs");
4
4
  const require_client = require("./client-Ypw9BNbn.cjs");
5
- const require_tool_visibility = require("./tool-visibility-CyZqYnQA.cjs");
5
+ const require_tool_visibility = require("./tool-visibility-Boxtlbtr.cjs");
6
6
  const require_usage_status = require("./usage-status-BdwwgYsu.cjs");
7
7
  const require_subscription_status = require("./subscription-status-Cm7LqY_h.cjs");
8
8
  const require_capabilities = require("./capabilities-BbAQ4Tfa.cjs");
9
9
  const require_action_log = require("./action-log-HniMMF7f.cjs");
10
+ let node_url = require("node:url");
10
11
  let node_path = require("node:path");
11
12
  node_path = require_rolldown_runtime.__toESM(node_path, 1);
13
+ let node_fs = require("node:fs");
12
14
  let node_fs_promises = require("node:fs/promises");
13
15
  let zod = require("zod");
14
16
  zod = require_rolldown_runtime.__toESM(zod, 1);
@@ -16,20 +18,367 @@ let _modelcontextprotocol_sdk_server_mcp_js = require("@modelcontextprotocol/sdk
16
18
  let _modelcontextprotocol_sdk_server_stdio_js = require("@modelcontextprotocol/sdk/server/stdio.js");
17
19
  let _modelcontextprotocol_sdk_client_index_js = require("@modelcontextprotocol/sdk/client/index.js");
18
20
  let _modelcontextprotocol_sdk_client_streamableHttp_js = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
21
+ //#region src/mcp/flows.ts
22
+ /**
23
+ * The money-flow view tools of the local proxy: money_flows and graph_expand.
24
+ *
25
+ * money_flows answers one robinhood address with its most recent senders and
26
+ * receivers: a short text the model reads, and chain-insights.flows.v1 in
27
+ * structuredContent, which the Chain Insights view (ui://chain-insights/view)
28
+ * draws. graph_expand is the same answer for the next page of one address. The
29
+ * view calls it when the investigator clicks an address; its _meta says
30
+ * visibility ["app"], so a host keeps it away from the model.
31
+ *
32
+ * Both tools are composed on the client PC from three anchored graph_query
33
+ * reads (the node, its outgoing FLOWS_TO and its incoming FLOWS_TO, newest
34
+ * first), sent through the proxy's remote client, the same path and payment
35
+ * wrapping a graph_query call takes. Each read is billed as a graph_query.
36
+ * Every argument is checked before the first read, so a refused call reaches
37
+ * no graph endpoint and costs nothing.
38
+ */
39
+ const FLOWS_SCHEMA = "chain-insights.flows.v1";
40
+ const MONEY_FLOWS_TOOL = "money_flows";
41
+ const GRAPH_EXPAND_TOOL = "graph_expand";
42
+ const FLOWS_NETWORK = "robinhood";
43
+ /** Where a graph_expand page may start on each side, at most. */
44
+ const FLOWS_MAX_OFFSET = 1e4;
45
+ const INVALID_ADDRESS_TEXT = "invalid_address: a full 0x address of 40 hexadecimal characters is needed, for example 0x04911a118f11c75667e4d0dfb8e640af5a353550";
46
+ const OFFSET_TOO_LARGE_TEXT = `invalid_offset: in_offset and out_offset must each be at most ${FLOWS_MAX_OFFSET}`;
47
+ const OFFSET_NEGATIVE_TEXT = "invalid_offset: in_offset and out_offset must each be a whole number, 0 or more";
48
+ const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/;
49
+ const CODE_PREFIX = /^([a-z][a-z0-9_]*):\s*/;
50
+ /**
51
+ * The address lower-cased, or null when it is not 0x and 40 hexadecimal
52
+ * characters. Only an address that passes is ever written into a read, so the
53
+ * reads can carry it as a literal.
54
+ */
55
+ function normalizeFlowsAddress(raw) {
56
+ if (typeof raw !== "string") return null;
57
+ const address = raw.trim().toLowerCase();
58
+ return ADDRESS_PATTERN.test(address) ? address : null;
59
+ }
60
+ 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";
61
+ 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";
62
+ const PAGE_ORDER = " ORDER BY f.last_seen_timestamp DESC, b.address";
63
+ /**
64
+ * The three anchored reads of one page. Each side asks for one row more than
65
+ * it shows: the extra row says another page exists.
66
+ */
67
+ function flowsReadsFor(address, inOffset, outOffset) {
68
+ const page = (offset) => `${PAGE_ORDER}${offset > 0 ? ` SKIP ${offset}` : ""} LIMIT 13`;
69
+ return [
70
+ {
71
+ id: "node",
72
+ query: `USE topology MATCH (b:Address {address: '${address}'}) RETURN ${NODE_FIELDS} LIMIT 1`
73
+ },
74
+ {
75
+ id: "out",
76
+ query: `USE topology MATCH (a:Address {address: '${address}'})-[f:FLOWS_TO]->(b:Address) RETURN ${PARTY_FIELDS}${page(outOffset)}`
77
+ },
78
+ {
79
+ id: "in",
80
+ query: `USE topology MATCH (b:Address)-[f:FLOWS_TO]->(a:Address {address: '${address}'}) RETURN ${PARTY_FIELDS}${page(inOffset)}`
81
+ }
82
+ ];
83
+ }
84
+ /** The role word of an address, first match wins. */
85
+ function flowsRole(row) {
86
+ if (row.is_exchange === true) return "exchange";
87
+ if (row.is_sanctioned === true) return "sanctioned";
88
+ if (row.is_scam === true) return "scam";
89
+ if (row.is_victim === true) return "victim";
90
+ if (row.pool === true) return "pool";
91
+ return "other";
92
+ }
93
+ function flowsFloat(value) {
94
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
95
+ }
96
+ function flowsInt(value) {
97
+ return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 0;
98
+ }
99
+ function flowsNodeOf(row) {
100
+ const labels = Array.isArray(row.labels) ? row.labels.filter((label) => typeof label === "string") : [];
101
+ return {
102
+ address: typeof row.address === "string" ? row.address : "",
103
+ role: flowsRole(row),
104
+ labels,
105
+ total_in_usd: flowsFloat(row.total_in_usd),
106
+ total_out_usd: flowsFloat(row.total_out_usd),
107
+ degree_in: flowsInt(row.degree_in),
108
+ degree_out: flowsInt(row.degree_out)
109
+ };
110
+ }
111
+ function assembleFlowView(network, page, senders, receivers, truncated) {
112
+ const center = flowsNodeOf(page.center);
113
+ const view = {
114
+ schema: FLOWS_SCHEMA,
115
+ network,
116
+ center: center.address,
117
+ nodes: [center],
118
+ edges: [],
119
+ cursor: {
120
+ in_offset: page.inOffset + senders.length,
121
+ out_offset: page.outOffset + receivers.length
122
+ },
123
+ truncated
124
+ };
125
+ const seen = /* @__PURE__ */ new Set([center.address]);
126
+ const add = (row, incoming) => {
127
+ const node = flowsNodeOf(row);
128
+ if (!seen.has(node.address)) {
129
+ seen.add(node.address);
130
+ view.nodes.push(node);
131
+ }
132
+ view.edges.push({
133
+ from: incoming ? node.address : center.address,
134
+ to: incoming ? center.address : node.address,
135
+ usd: flowsFloat(row.usd),
136
+ tx_count: flowsInt(row.tx_count),
137
+ first_seen_ms: flowsInt(row.first_seen),
138
+ last_seen_ms: flowsInt(row.last_seen)
139
+ });
140
+ };
141
+ for (const row of senders) add(row, true);
142
+ for (const row of receivers) add(row, false);
143
+ return view;
144
+ }
145
+ function fitsFlowBudget(view) {
146
+ return view.nodes.length <= 60 && JSON.stringify(view).length < 4e4;
147
+ }
148
+ /**
149
+ * flows.v1 from one page: the centre first, then the senders and the
150
+ * receivers, each newest first, at most FLOWS_PER_SIDE a side. When the view
151
+ * data would hold more than FLOWS_MAX_NODES addresses or reach FLOWS_MAX_CHARS
152
+ * characters, the oldest links go first, the view is marked truncated, and the
153
+ * cursor still says where the next page starts.
154
+ */
155
+ function buildFlowView(network, page) {
156
+ let truncated = page.senders.length > 12 || page.receivers.length > 12;
157
+ let senders = page.senders.slice(0, 12);
158
+ let receivers = page.receivers.slice(0, 12);
159
+ for (;;) {
160
+ const view = assembleFlowView(network, page, senders, receivers, truncated);
161
+ if (fitsFlowBudget(view) || senders.length + receivers.length === 0) return view;
162
+ truncated = true;
163
+ if (senders.length >= receivers.length) senders = senders.slice(0, -1);
164
+ else receivers = receivers.slice(0, -1);
165
+ }
166
+ }
167
+ function formatFlowsUsd(value) {
168
+ if (!(value > 0)) return "$0";
169
+ if (value < .01) return "under $0.01";
170
+ const whole = Math.floor(value);
171
+ const cents = Math.round((value - whole) * 100);
172
+ if (cents === 100) return formatFlowsUsd(whole + 1);
173
+ return `$${whole.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${String(cents).padStart(2, "0")}`;
174
+ }
175
+ function formatFlowsAge(lastSeenMs, now) {
176
+ if (lastSeenMs <= 0) return "at an unknown time";
177
+ const date = new Date(lastSeenMs).toISOString().slice(0, 10);
178
+ const days = Math.trunc((now.getTime() - lastSeenMs) / 864e5);
179
+ if (days < 1) return `${date} (today)`;
180
+ if (days === 1) return `${date} (1 day ago)`;
181
+ return `${date} (${days} days ago)`;
182
+ }
183
+ /** The role word, with up to three address labels beside it. */
184
+ function roleText(node) {
185
+ if (!node) return "other";
186
+ if (node.labels.length === 0) return node.role;
187
+ return `${node.role} (${node.labels.slice(0, 3).join(", ")})`;
188
+ }
189
+ function sideLines(title, parties) {
190
+ if (parties.length === 0) return [`${title}, newest first: none.`];
191
+ const lines = [`${title}, newest first (${parties.length}):`];
192
+ const perLine = parties.length > 6 ? 2 : 1;
193
+ for (let start = 0; start < parties.length; start += perLine) lines.push(`- ${parties.slice(start, start + perLine).join("; ")}`);
194
+ return lines;
195
+ }
196
+ /**
197
+ * The text the model reads: the centre, its role and lifetime totals, then
198
+ * every counterparty of the view with its USD, transaction count and last-seen
199
+ * date and age. At most FLOWS_SUMMARY_MAX_LINES lines.
200
+ */
201
+ function flowsSummary(view, now) {
202
+ const nodes = new Map(view.nodes.map((node) => [node.address, node]));
203
+ const center = nodes.get(view.center);
204
+ 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)}`;
205
+ const senders = [];
206
+ const receivers = [];
207
+ for (const edge of view.edges) if (edge.to === view.center) senders.push(party(edge.from, edge));
208
+ else receivers.push(party(edge.to, edge));
209
+ const lines = [
210
+ `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.`,
211
+ ...sideLines("Recent senders", senders),
212
+ ...sideLines("Recent receivers", receivers)
213
+ ];
214
+ if (view.truncated) lines.push("More flows exist than shown; the view loads the next page on click.");
215
+ return lines.slice(0, 20).join("\n");
216
+ }
217
+ function errorResult(text) {
218
+ return {
219
+ content: [{
220
+ type: "text",
221
+ text
222
+ }],
223
+ isError: true
224
+ };
225
+ }
226
+ function isRecord$1(value) {
227
+ return !!value && typeof value === "object" && !Array.isArray(value);
228
+ }
229
+ function answerText(answer) {
230
+ return (answer.content ?? []).map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").filter(Boolean).join("\n").trim();
231
+ }
232
+ /**
233
+ * The refusal of a read that did not answer, naming its code first:
234
+ * error_detail.code when the graph endpoint sent its typed refusal, the code
235
+ * its text starts with otherwise, and graph_query_failed when it names none.
236
+ * The view marks the address it was expanding.
237
+ */
238
+ function refusalText(answer) {
239
+ const text = answerText(answer);
240
+ const detail = isRecord$1(answer.structuredContent) ? answer.structuredContent.error_detail : void 0;
241
+ const detailCode = isRecord$1(detail) && typeof detail.code === "string" ? detail.code : "";
242
+ const textCode = CODE_PREFIX.exec(text)?.[1] ?? "";
243
+ const code = detailCode || textCode || "graph_query_failed";
244
+ const rest = textCode === code ? text.replace(CODE_PREFIX, "") : text;
245
+ return rest ? `${code}: ${rest}` : `${code}: the graph endpoint refused the read`;
246
+ }
247
+ function rowsOf(answer) {
248
+ const facts = isRecord$1(answer.structuredContent) ? answer.structuredContent.facts : void 0;
249
+ const query = isRecord$1(facts) ? facts.query : void 0;
250
+ const results = isRecord$1(query) ? query.results : void 0;
251
+ return Array.isArray(results) ? results.filter(isRecord$1) : null;
252
+ }
253
+ function checkNetworkAndAddress(args) {
254
+ const rawNetwork = typeof args.network === "string" ? args.network.trim().toLowerCase() : "";
255
+ if (!rawNetwork) return { error: "invalid_network: network is required; pass robinhood" };
256
+ if (rawNetwork !== "robinhood") return { error: `invalid_network: money flows read the robinhood topology graph only; got "${String(args.network).trim()}"` };
257
+ const address = normalizeFlowsAddress(args.address);
258
+ if (!address) return { error: INVALID_ADDRESS_TEXT };
259
+ return {
260
+ network: FLOWS_NETWORK,
261
+ address
262
+ };
263
+ }
264
+ function checkOffset(value) {
265
+ if (value === void 0 || value === null) return 0;
266
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return { error: OFFSET_NEGATIVE_TEXT };
267
+ if (value > 1e4) return { error: OFFSET_TOO_LARGE_TEXT };
268
+ return value;
269
+ }
270
+ /**
271
+ * Read and answer one page. The reads run one after another: the node first,
272
+ * so an address the graph does not hold costs one read, not three; then the
273
+ * receivers and the senders. The first read that does not answer ends the call.
274
+ */
275
+ async function runFlows(deps, network, address, inOffset, outOffset) {
276
+ const unavailable = deps.unavailable?.();
277
+ if (unavailable) return errorResult(unavailable);
278
+ const rows = {};
279
+ for (const read of flowsReadsFor(address, inOffset, outOffset)) {
280
+ let answer;
281
+ try {
282
+ answer = await deps.graphQuery({
283
+ network,
284
+ query: read.query
285
+ });
286
+ } catch (err) {
287
+ return errorResult(deps.describeFailure(err));
288
+ }
289
+ if (answer.isError === true) return errorResult(refusalText(answer));
290
+ const found = rowsOf(answer);
291
+ if (!found) return errorResult("graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows");
292
+ rows[read.id] = found;
293
+ if (read.id === "node" && found.length === 0) return errorResult(`${address} is not in the ${network} topology graph`);
294
+ }
295
+ const view = buildFlowView(network, {
296
+ center: rows.node?.[0] ?? {},
297
+ senders: rows.in ?? [],
298
+ receivers: rows.out ?? [],
299
+ inOffset,
300
+ outOffset
301
+ });
302
+ return {
303
+ content: [{
304
+ type: "text",
305
+ text: flowsSummary(view, deps.now ? deps.now() : /* @__PURE__ */ new Date())
306
+ }],
307
+ structuredContent: view,
308
+ isError: false
309
+ };
310
+ }
311
+ /** money_flows {address, network}: the first page of one address. */
312
+ async function handleMoneyFlows(args, deps) {
313
+ const checked = checkNetworkAndAddress(isRecord$1(args) ? args : {});
314
+ if ("error" in checked) return errorResult(checked.error);
315
+ return runFlows(deps, checked.network, checked.address, 0, 0);
316
+ }
317
+ /** graph_expand {network, address, in_offset, out_offset}: the next page of one address. */
318
+ async function handleGraphExpand(args, deps) {
319
+ const record = isRecord$1(args) ? args : {};
320
+ const checked = checkNetworkAndAddress(record);
321
+ if ("error" in checked) return errorResult(checked.error);
322
+ const inOffset = checkOffset(record.in_offset);
323
+ if (typeof inOffset !== "number") return errorResult(inOffset.error);
324
+ const outOffset = checkOffset(record.out_offset);
325
+ if (typeof outOffset !== "number") return errorResult(outOffset.error);
326
+ return runFlows(deps, checked.network, checked.address, inOffset, outOffset);
327
+ }
328
+ const MONEY_FLOWS_DESCRIPTION = "Show the most recent money flows around one robinhood address, read from the Chain Insights topology graph: the address with its role and lifetime USD totals, its 12 most recent senders and its 12 most recent receivers, each link with its lifetime USD, transaction count and last-seen time. The answer is a text summary, and an interactive graph in hosts that draw MCP apps. An address not in the graph is an error. Read-only; billed as three graph_query topology reads, or one when the address is not in the graph.";
329
+ const GRAPH_EXPAND_DESCRIPTION = "Load the next page of senders and receivers of one address into an open money-flow view. Called by the view when the investigator clicks an address. Read-only; billed as three graph_query topology reads, nothing when it is refused before a read.";
330
+ /** The line of the server instructions that tells a model what money_flows answers. */
331
+ const MONEY_FLOWS_INSTRUCTION = "Use money_flows to see who sent money to one robinhood address and where it went: its 12 most recent senders and receivers, each with lifetime USD, transaction count and last-seen time.";
332
+ //#endregion
19
333
  //#region src/mcp/proxy.ts
20
334
  const LOCAL_TOOL_NAMES = /* @__PURE__ */ new Set([
21
335
  "meta_network_capabilities",
22
336
  "meta_usage_status",
23
337
  "meta_subscription_status",
24
338
  "meta_help",
25
- "wallet_balance"
339
+ "wallet_balance",
340
+ "wallet_topup",
341
+ MONEY_FLOWS_TOOL,
342
+ GRAPH_EXPAND_TOOL
26
343
  ]);
344
+ const APP_ONLY_LOCAL_TOOL_NAMES = /* @__PURE__ */ new Set([GRAPH_EXPAND_TOOL]);
27
345
  const GRAPH_ARRAY_KEYS = [
28
346
  "nodes",
29
347
  "edges",
30
348
  "flows",
31
349
  "edge_anchors"
32
350
  ];
351
+ const CLAUDE_VIEW_URI = "ui://chain-insights/view";
352
+ const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
353
+ const VIEW_TOOL_META = {
354
+ ui: { resourceUri: CLAUDE_VIEW_URI },
355
+ "ui/resourceUri": CLAUDE_VIEW_URI
356
+ };
357
+ const APP_ONLY_TOOL_META = { ui: {
358
+ resourceUri: CLAUDE_VIEW_URI,
359
+ visibility: ["app"]
360
+ } };
361
+ const VIEW_RESOURCE_META = { ui: {
362
+ prefersBorder: false,
363
+ csp: { resourceDomains: ["https://assets.claude.ai"] }
364
+ } };
365
+ const VIEW_TOOL_ANNOTATIONS = { readOnlyHint: true };
366
+ const CLAUDE_VIEW_FILE = "claude-view.html";
367
+ let claudeViewHtml;
368
+ /**
369
+ * The view file shipped in the package: dist/apps/claude-view.html next to the
370
+ * built proxy, or src/mcp/apps/claude-view.html when run from source. Read once.
371
+ */
372
+ function readClaudeViewHtml() {
373
+ if (claudeViewHtml !== void 0) return claudeViewHtml;
374
+ const here = node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
375
+ const candidates = [node_path.default.join(here, "apps", CLAUDE_VIEW_FILE), node_path.default.join(here, "..", "src", "mcp", "apps", CLAUDE_VIEW_FILE)];
376
+ for (const candidate of candidates) try {
377
+ claudeViewHtml = (0, node_fs.readFileSync)(candidate, "utf8");
378
+ return claudeViewHtml;
379
+ } catch {}
380
+ throw new Error(`The Chain Insights view file ${CLAUDE_VIEW_FILE} is missing from the package`);
381
+ }
33
382
  function resolveMcpProxyMode(env = process.env) {
34
383
  const raw = env["CHAIN_INSIGHTS_MCP_PROXY_MODE"]?.trim().toLowerCase();
35
384
  if (!raw || raw === "stateless") return "stateless";
@@ -48,6 +397,10 @@ const KNOWN_PUBLIC_TOOL_DESCRIPTIONS = {
48
397
  graph_query: `Run a read-only GQL/Cypher query through the Chain Insights graph endpoint. ${GRAPH_LAYERS_TEXT} Preserve full addresses exactly.`,
49
398
  graph_query_batch: "Run multiple read-only GQL/Cypher queries through the Chain Insights graph endpoint in one paid batch. Prefer this for related topology/facts reads."
50
399
  };
400
+ const KNOWN_PUBLIC_TOOL_TITLES = {
401
+ graph_query: "Graph Query",
402
+ graph_query_batch: "Graph Query Batch"
403
+ };
51
404
  const FALLBACK_GRAPH_PRIMITIVE_TOOL_NAMES = ["graph_query", "graph_query_batch"];
52
405
  const NETWORK_SCHEMA = zod.string().min(1).describe("Network to query. Call meta_network_capabilities first and pass a name GraphRAG advertised. CIA does not pick a default network.");
53
406
  const EMPTY_INPUT_SCHEMA = zod.strictObject({});
@@ -56,7 +409,8 @@ const CHAIN_INSIGHTS_WORKFLOW = [
56
409
  "Workflow:",
57
410
  "1. Do not call investigation tools until required arguments are known. Network is required; use meta_network_capabilities to check supported networks and available tools, or ask the user if missing.",
58
411
  "2. Use aml_address_risk for single-address enrichment. Use graph_query(_batch) for graph-level questions that aml_address_risk does not answer.",
59
- "3. Preserve tool summaries and structured facts as returned. Keep full blockchain addresses intact."
412
+ `3. ${MONEY_FLOWS_INSTRUCTION}`,
413
+ "4. Preserve tool summaries and structured facts as returned. Keep full blockchain addresses intact."
60
414
  ].join("\n");
61
415
  const GRAPH_SCHEMA_HINTS = [
62
416
  "Graph query hints:",
@@ -65,10 +419,10 @@ const GRAPH_SCHEMA_HINTS = [
65
419
  "- The graph is address-grain. The only topology money node label is Address, keyed by the raw chain-native H160 address on EVM networks, for example 0x1874a43d7c6d888f9eda3d22a3a49704e3cadb24. The network value on Address nodes matches the tool argument. There is no separate identity key.",
66
420
  "- Address nodes carry address, network, labels, and the role flags is_exchange, is_scam, is_victim and is_sanctioned. (:Address)-[:LINKED]-(:Address) is an undirected ownership-overlay edge (basis derived/associated, plus confidence, source_event, declared_owner, owner_state) asserting the two addresses are controlled by the same actor. LINKED is served on the topology graph only. Enumerate LINKED neighbors with MATCH (a:Address {address: $addr})-[l:LINKED]-(b:Address) RETURN b.address, b.network, l.basis, l.confidence.",
67
421
  "- Labels hold role words, never detector names: Exchange (plus the exchange name), Scam, Victim and Sanctioned (plus the entity name). Each role is also a node label and a flag on the Address: :Exchange and is_exchange follow the exchange label, :Scam and is_scam the risk label, :Victim and is_victim the protection label, :Sanctioned and is_sanctioned the sanctioned label. Each flag is present only when true and absent otherwise, never false: test IS NOT NULL or IS NULL, not = false. A withdrawn label removes its node label and its flag.",
68
- "- Address nodes also carry a risk verdict (risk_score float, risk_level string) plus base activity rollups: degree_in/degree_out/degree_total (distinct counterparty addresses), tx_in_count/tx_out_count/tx_total_count, total_in_usd/total_out_usd/total_volume_usd, net_flow_usd (in minus out; positive = net receiver) — all computed from external flows only — and first_activity_timestamp/last_activity_timestamp/activity_span_days, which include all flows (self-loops included). FLOWS_TO edges carry exactly tx_count, amount_usd_sum (total money flow, token and native value merged), first_seen_timestamp, last_seen_timestamp. Lifetime aggregates are the only serving window. Averages are computed inline (amount_usd_sum / toFloat(tx_count)). tx_count counts token and native transfers plus internal native transfers (a contract sending ETH during a call), and amount_usd_sum prices them all. USE facts TRANSFER lists the first group only, so a pair can have tx_count above 0 and no TRANSFER row, and no MCP read lists internal transfers yet. An address-only TRANSFER read covers a recent window (90 days today) and a link covers all time: bound the read with block_date to read further back. For a pair with token or native transfers, a transaction anchor resolves through USE facts: MATCH (a:Address {address: $from})-[t:TRANSFER]->(b:Address {address: $to}) RETURN t.tx_id ORDER BY t.block_timestamp ASC LIMIT 1.",
422
+ "- Address nodes also carry a risk verdict (risk_score float, risk_level string) plus base activity rollups: degree_in/degree_out/degree_total (distinct counterparty addresses), tx_in_count/tx_out_count/tx_total_count, total_in_usd/total_out_usd/total_volume_usd, net_flow_usd (in minus out; positive = net receiver) — all computed from external flows only — and first_activity_timestamp/last_activity_timestamp/activity_span_days, which include all flows (self-loops included). FLOWS_TO edges carry exactly tx_count, amount_usd_sum (total money flow, token and native value merged), first_seen_timestamp, last_seen_timestamp. Lifetime aggregates are the only serving window. Averages are computed inline (amount_usd_sum / toFloat(tx_count)). tx_count counts token and native transfers plus internal native transfers (a contract sending ETH during a call), and amount_usd_sum prices them all. USE facts TRANSFER lists the first group only, so a pair can have tx_count above 0 and no TRANSFER row, and no MCP read lists internal transfers yet. A link covers all time and a USE facts read covers one day: read the transfers of a pair one day at a time. For a pair with token or native transfers, a transaction anchor resolves through USE facts, on the UTC day of the first_seen_timestamp or last_seen_timestamp of the link: MATCH (a:Address {address: $from})-[t:TRANSFER]->(b:Address {address: $to}) WHERE t.block_date = \"YYYY-MM-DD\" RETURN t.tx_id, t.block_timestamp LIMIT 1.",
69
423
  "- For actor-level exposure (AC11), UNION FLOWS_TO and SWAPPED reachability over one visible LINKED hop instead of expanding through the LINKED edge itself: MATCH (a:Address {address: $addr})-[:LINKED]-(owned:Address)-[r:FLOWS_TO|SWAPPED]-(b:Address) WHERE NOT a:Pool AND NOT owned:Pool AND owned.address <> b.address AND a.address <> b.address RETURN owned.address, b.address, type(r), coalesce(r.amount_usd_sum, r.bought_usd).",
70
424
  "- The risk verdict lives on topology nodes (risk_score float, risk_level string). An absent risk_score means UNSCORED: the model gave the address no verdict, which is no signal and never low risk. Labels and per-label risk also live on the address node: the labels array plus three parallel lists, label_risk_labels, label_risk_levels and label_risk_updated_timestamps, where entry i of each list is one label row. USE facts serves bounded single-event rows (TRANSFER, SWAP, LIQUIDITY_ADD, LIQUIDITY_REMOVE and BRIDGE_CROSSING edges) only; lifetime address metrics (degrees, totals, activity window) are node properties on USE topology.",
71
- "- (from:Address)-[t:TRANSFER]->(to:Address) on USE facts returns individual transfer rows, not aggregates, with properties amount, amount_usd, asset_symbol, asset_contract, tx_id, block_height, block_timestamp, event_index, edge_index, price_usd, and price_missing. Every TRANSFER query — row-select or count()/sum() aggregate — requires an indexed predicate: address equality on either endpoint (for example {address: \"...\"} on from or to), a WHERE t.tx_id = \"...\" equality (on EVM networks tx_id is the 0x transaction hash), or a bare WHERE t.block_date = \"YYYY-MM-DD\" bound, which t.block_timestamp bounds in epoch milliseconds may narrow to a time window; a bare LIMIT with no indexed predicate is rejected, since facts_transfers_view is a full transfer-history table, not a small per-address dimension view.",
425
+ "- (from:Address)-[t:TRANSFER]->(to:Address) on USE facts returns individual transfer rows, not aggregates, with properties amount, amount_usd, asset_symbol, asset_contract, tx_id, block_height, block_timestamp, event_index, edge_index, price_usd, and price_missing. A facts read names an address pair with one day, or one tx_id, whether it is a TRANSFER row-select or a count()/sum() aggregate: both endpoint addresses as {address: \"...\"} on from and to, with WHERE t.block_date = \"YYYY-MM-DD\", or WHERE t.tx_id = \"...\" on its own (on EVM networks tx_id is the 0x transaction hash). t.block_timestamp bounds in epoch milliseconds may narrow the day to a time window. One address, a day alone, a window of days, a block range or a bare LIMIT is not enough, and a facts read has one relationship and takes no ORDER BY.",
72
426
  "- Facts graph labels include Address; the TRANSFER, SWAP, LIQUIDITY_ADD, LIQUIDITY_REMOVE and BRIDGE_CROSSING relationships each connect two Address nodes. Facts address keys match topology address values exactly.",
73
427
  "- Topology relationships include FLOWS_TO, SWAPPED, OPERATED_BY, LINKED, and RISK_PROXIMITY between Address nodes, ADDED_LIQUIDITY and REMOVED_LIQUIDITY to and from Pool nodes, and BRIDGED to and from Chain nodes.",
74
428
  "- (:Address)-[:OPERATED_BY]->(:Address) is the directed owner-to-operator edge: the transaction sender that moved the owner's tokens (ERC-20/721), or the event operator (ERC-1155); not the approved spender. Aggregate properties: tx_count, amount_usd_sum, first_seen_timestamp, last_seen_timestamp, and optional token_standard (ERC20/ERC721/ERC1155; absent when the pair is mixed-standard). Valuation fields: valuation_tracked_count, valued_count and the reason counters missing_valuation_price_count, unknown_quantity_count, unrepresentable_quantity_count and usd_range_count. valuation_complete is true when valued_count equals valuation_tracked_count, valuation_coverage_ratio is valued_count divided by valuation_tracked_count, and amount_usd_sum counts a transfer with no USD value as 0, so it is a floor unless valuation_complete is true. One edge per owner/operator pair; direct transfers with no operator create none; an owner never operates for itself. Topology-only, and a topology fact — not a risk label. Probe: MATCH (owner:Address)-[operation:OPERATED_BY]->(operator:Address {address: $addr}) RETURN owner.address AS owner, operation.tx_count AS tx_count ORDER BY operation.tx_count DESC LIMIT 10.",
@@ -77,9 +431,10 @@ const GRAPH_SCHEMA_HINTS = [
77
431
  "- Pool is a second label on an Address: the pool of a swap route or liquidity event, with liquidity_added_usd, liquidity_removed_usd, liquidity_net_usd, provider_count and receiver_count. (:Address)-[:ADDED_LIQUIDITY]->(:Pool) and (:Pool)-[:REMOVED_LIQUIDITY]->(:Address) join named providers and receivers to the pool, with event_count, totals_raw, usd, unpriced_count, first_seen_height and last_seen_height; REMOVED_LIQUIDITY adds fees_raw, receiver_added_usd (USD the receiver itself added to the same pool) and receiver_provided. A receiver's profit from a pool is usd minus receiver_added_usd: receiver_provided false is a drain by a stranger, true with a large profit is the creator's rug pull.",
78
432
  "- (:Address)-[:BRIDGED]->(:Chain) is outbound bridge use and (:Chain)-[:BRIDGED]->(:Address) inbound, with kinds, events, totals_raw, first_height, last_height and last_bridge_event_id. A Chain node (network, address) is the remote bridge endpoint, never an Address, so no FLOWS_TO walk passes through it.",
79
433
  "- Pool trace rule, for every trace: 1. Enter a `:Pool` on any edge. 2. Leave a `:Pool` only on `REMOVED_LIQUIDITY`, to the address the liquidity was paid to. 3. Never leave a `:Pool` on `FLOWS_TO`. 4. Across a swap, follow `SWAPPED` from payer to recipient, between two different addresses. Do not walk through the pool. Rug-pull probe: MATCH (victim:Address {address: $addr})-[paid:FLOWS_TO]->(pool:Pool)-[removal:REMOVED_LIQUIDITY]->(receiver:Address) WHERE NOT victim:Pool AND receiver.address <> victim.address RETURN pool.address AS pool_address, receiver.address AS receiver_address, removal.usd AS removed_usd, removal.receiver_added_usd AS receiver_added_usd, removal.receiver_provided AS receiver_provided LIMIT 25.",
80
- "- USE facts serves the single events. (payer:Address)-[s:SWAP]->(recipient:Address) is one row per swap route, self swaps included, with strength, reason, route_id, pools, families and the sold_ and bought_ asset, amount and usd columns. (provider:Address)-[:LIQUIDITY_ADD]->(pool:Address) and (pool:Address)-[:LIQUIDITY_REMOVE]->(receiver:Address) are one row per liquidity event, with party_state and evidence_state. SWAP and LIQUIDITY_* need an address equality on either endpoint or a tx_id equality. (sender:Address)-[c:BRIDGE_CROSSING]->(recipient:Address) is one row per bridge event and needs a bare block_date bound or a tx_id equality. USD comes from the daily price services, never from a swap: with no price, USD is empty and the matching price_missing column is true (price_missing on TRANSFER, sold_price_missing and bought_price_missing on SWAP, amount0_price_missing and amount1_price_missing on LIQUIDITY_*). block_timestamp on TRANSFER, SWAP and LIQUIDITY_* rows is epoch milliseconds, in filters and in results; BRIDGE_CROSSING has none.",
434
+ "- USE facts serves the single events. (payer:Address)-[s:SWAP]->(recipient:Address) is one row per swap route, self swaps included, with strength, reason, route_id, pools, families and the sold_ and bought_ asset, amount and usd columns. (provider:Address)-[:LIQUIDITY_ADD]->(pool:Address) and (pool:Address)-[:LIQUIDITY_REMOVE]->(receiver:Address) are one row per liquidity event, with party_state and evidence_state. (sender:Address)-[c:BRIDGE_CROSSING]->(recipient:Address) is one row per bridge event. SWAP, LIQUIDITY_* and BRIDGE_CROSSING take the same rule as TRANSFER: an address pair with one day, or one tx_id. USD comes from the daily price services, never from a swap: with no price, USD is empty and the matching price_missing column is true (price_missing on TRANSFER, sold_price_missing and bought_price_missing on SWAP, amount0_price_missing and amount1_price_missing on LIQUIDITY_*). block_timestamp on TRANSFER, SWAP and LIQUIDITY_* rows is epoch milliseconds, in filters and in results; BRIDGE_CROSSING has none.",
435
+ "- Temporary, until graph server issue 1121 is fixed. Do not return, filter or order by pools in a USE facts SWAP read. Every such read fails at the warehouse query memory limit with facts query could not be completed: by address, by day and by tx_id. Only pools is built by the failing part of the warehouse view. pool_keys and families come from the main read. Read SWAP rows by tx_id, or by the payer, the recipient and one day, and leave pools out. For the pools of a swap, read SWAPPED.pools 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.",
81
436
  "- 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.",
82
- "- 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. ANY SHORTEST and ALL SHORTEST take the same pattern. 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.",
437
+ "- 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.",
83
438
  "- Start schema discovery with endpoint-safe property reads: MATCH (n:Address) WHERE n.address IS NOT NULL RETURN n.address AS address, n.network AS network, n.labels AS labels, n.risk_score AS risk_score, n.risk_level AS risk_level LIMIT 20",
84
439
  "- 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",
85
440
  "- 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.",
@@ -96,6 +451,7 @@ const SERVER_INSTRUCTIONS = [
96
451
  const STATELESS_SERVER_INSTRUCTIONS = [
97
452
  "Chain Insights is running as a stateless AML proxy for a host application.",
98
453
  "Use meta_network_capabilities first when network support is unknown, then call aml_address_risk, graph_query, or graph_query_batch as needed.",
454
+ MONEY_FLOWS_INSTRUCTION,
99
455
  "Use wallet_balance to inspect the local payment wallet when payment setup is needed.",
100
456
  GRAPH_SCHEMA_HINTS,
101
457
  "Presentation rules: preserve tool summaries as returned; never truncate blockchain addresses or identity_resolution audit mappings."
@@ -134,6 +490,17 @@ function fallbackGraphPrimitiveTools() {
134
490
  * server (subscription_status). Returns null — never throws — when no wallet
135
491
  * is configured; callers degrade to an unavailable-shape result.
136
492
  */
493
+ /**
494
+ * Local payment wallet address for the top-up view: null when no wallet file
495
+ * exists, and an error when the file exists but cannot be read. Reads the
496
+ * address only; nothing is signed or sent.
497
+ */
498
+ async function localTopupWalletAddress() {
499
+ const { isWalletConfigured } = await Promise.resolve().then(() => require("./wallet-B3hAr70M.cjs")).then((n) => n.wallet_exports);
500
+ if (!await isWalletConfigured()) return null;
501
+ const { getWalletAccount } = await Promise.resolve().then(() => require("./tools-BHfphgy9.cjs")).then((n) => n.tools_exports);
502
+ return (await getWalletAccount()).address;
503
+ }
137
504
  async function localSubscriptionWalletAddress() {
138
505
  try {
139
506
  const { getWalletAccount } = await Promise.resolve().then(() => require("./tools-BHfphgy9.cjs")).then((n) => n.tools_exports);
@@ -338,6 +705,17 @@ function knownPublicToolAnnotations(toolName) {
338
705
  openWorldHint: true
339
706
  };
340
707
  }
708
+ /**
709
+ * The text a call to the graph endpoint answers with when it throws: payment
710
+ * guidance for a payment failure, the error otherwise. Shared by every proxied
711
+ * tool and by the money-flow reads.
712
+ */
713
+ function remoteCallFailureText(toolName, err) {
714
+ if (err instanceof require_client.PaymentRequiredError) return err.message;
715
+ const msg = err.message ?? String(err);
716
+ if (/\b402\b/.test(msg) || msg.toLowerCase().includes("payment")) return `Payment required for ${toolName}. This tool costs USDC on Base via x402 micropayments. Next steps: run \`cia wallet ready\` to check funding and finish one-time payment setup, run \`cia wallet topup\` if it says the wallet needs USDC, or \`cia access-key set <key>\` if you have been given test access.`;
717
+ return `MCP call failed: ${msg}`;
718
+ }
341
719
  function promptResult(text, description) {
342
720
  return {
343
721
  description,
@@ -445,8 +823,6 @@ function sanitizeStructuredValue(value) {
445
823
  function sanitizeRemoteMeta(metaValue) {
446
824
  if (!metaValue || typeof metaValue !== "object" || Array.isArray(metaValue)) return void 0;
447
825
  const meta = { ...metaValue };
448
- delete meta.ui;
449
- delete meta["ui/resourceUri"];
450
826
  const chainInsights = meta.chainInsights;
451
827
  if (chainInsights && typeof chainInsights === "object" && !Array.isArray(chainInsights)) {
452
828
  const { graph: _graph, ...withoutGraph } = chainInsights;
@@ -455,10 +831,13 @@ function sanitizeRemoteMeta(metaValue) {
455
831
  }
456
832
  return Object.keys(meta).length > 0 ? meta : void 0;
457
833
  }
458
- function normalizeRemoteToolResult(result) {
834
+ function stripsGraphPayload(toolName) {
835
+ return toolName.startsWith("aml_");
836
+ }
837
+ function normalizeRemoteToolResult(toolName, result) {
459
838
  return {
460
839
  content: result.content ?? [],
461
- structuredContent: sanitizeStructuredContentForGraphPayload(result.structuredContent),
840
+ structuredContent: stripsGraphPayload(toolName) ? sanitizeStructuredContentForGraphPayload(result.structuredContent) : result.structuredContent,
462
841
  _meta: sanitizeRemoteMeta(result._meta),
463
842
  isError: result.isError
464
843
  };
@@ -493,7 +872,7 @@ function jsonTextResult(structuredContent) {
493
872
  * All diagnostic output goes to console.error() or process.stderr.write().
494
873
  */
495
874
  async function createProxy() {
496
- const { loadConfig } = await Promise.resolve().then(() => require("./config-B542AM4w.cjs")).then((n) => n.config_exports);
875
+ const { loadConfig } = await Promise.resolve().then(() => require("./config-CPUhkz0Z.cjs")).then((n) => n.config_exports);
497
876
  const { activeDataDir, findActiveWorkspace } = await Promise.resolve().then(() => require("./active-Bc13hJIm.cjs")).then((n) => n.active_exports);
498
877
  const { createConfiguredGraphMcpFetch, resolveGraphMcpEndpoint } = await Promise.resolve().then(() => require("./client-Ypw9BNbn.cjs")).then((n) => n.client_exports);
499
878
  const { loadSchema, saveSchema } = await Promise.resolve().then(() => require("./schema-cache-n7Gbhvsj.cjs"));
@@ -629,6 +1008,7 @@ async function createProxy() {
629
1008
  title: "Usage Status",
630
1009
  description: KNOWN_PUBLIC_TOOL_DESCRIPTIONS.meta_usage_status,
631
1010
  inputSchema: EMPTY_INPUT_SCHEMA,
1011
+ _meta: VIEW_TOOL_META,
632
1012
  annotations: {
633
1013
  readOnlyHint: true,
634
1014
  destructiveHint: false,
@@ -676,6 +1056,7 @@ async function createProxy() {
676
1056
  title: "Subscription Status",
677
1057
  description: KNOWN_PUBLIC_TOOL_DESCRIPTIONS.meta_subscription_status,
678
1058
  inputSchema: EMPTY_INPUT_SCHEMA,
1059
+ _meta: VIEW_TOOL_META,
679
1060
  annotations: {
680
1061
  readOnlyHint: true,
681
1062
  destructiveHint: false,
@@ -770,7 +1151,7 @@ async function createProxy() {
770
1151
  }],
771
1152
  isError: true
772
1153
  };
773
- const { runAmlAddressRisk } = await Promise.resolve().then(() => require("./public-tools-DWONw7kZ.cjs"));
1154
+ const { runAmlAddressRisk } = await Promise.resolve().then(() => require("./public-tools-3yTC17cA.cjs"));
774
1155
  const result = await runAmlAddressRisk(remoteClient, {
775
1156
  address,
776
1157
  network,
@@ -802,6 +1183,39 @@ async function createProxy() {
802
1183
  };
803
1184
  }
804
1185
  });
1186
+ const flowsDependencies = {
1187
+ graphQuery: async (args) => {
1188
+ const options = remoteToolRequestOptions("graph_query");
1189
+ return await remoteClient.callTool({
1190
+ name: "graph_query",
1191
+ arguments: args
1192
+ }, void 0, options);
1193
+ },
1194
+ describeFailure: (err) => remoteCallFailureText("graph_query", err),
1195
+ unavailable: () => remoteConnected ? void 0 : `${remoteUnavailableMessage ?? `Chain Insights Graph is not connected at ${graphMcpEndpoint}`}. Restart the Chain Insights MCP proxy after the endpoint is reachable.`
1196
+ };
1197
+ server.registerTool(MONEY_FLOWS_TOOL, {
1198
+ title: "Money flows",
1199
+ description: MONEY_FLOWS_DESCRIPTION,
1200
+ inputSchema: {
1201
+ address: zod.string().describe("One robinhood address: 0x and 40 hexadecimal characters."),
1202
+ network: zod.string().describe("The network. Only robinhood is served.")
1203
+ },
1204
+ annotations: VIEW_TOOL_ANNOTATIONS,
1205
+ _meta: VIEW_TOOL_META
1206
+ }, async (args) => handleMoneyFlows(args, flowsDependencies));
1207
+ server.registerTool(GRAPH_EXPAND_TOOL, {
1208
+ title: "Expand a money-flow node",
1209
+ description: GRAPH_EXPAND_DESCRIPTION,
1210
+ inputSchema: {
1211
+ network: zod.string(),
1212
+ address: zod.string(),
1213
+ in_offset: zod.number().int().min(0).max(FLOWS_MAX_OFFSET).optional(),
1214
+ out_offset: zod.number().int().min(0).max(FLOWS_MAX_OFFSET).optional()
1215
+ },
1216
+ annotations: VIEW_TOOL_ANNOTATIONS,
1217
+ _meta: APP_ONLY_TOOL_META
1218
+ }, async (args) => handleGraphExpand(args, flowsDependencies));
805
1219
  server.registerTool("meta_help", {
806
1220
  title: "Chain Insights Help",
807
1221
  description: KNOWN_PUBLIC_TOOL_DESCRIPTIONS.meta_help,
@@ -827,6 +1241,7 @@ async function createProxy() {
827
1241
  "- aml_address_risk: screen one blockchain address; optionally compare it with another address.",
828
1242
  "- graph_query: run read-only GQL/Cypher through the universal graph endpoint. Use USE topology or USE facts.",
829
1243
  "- graph_query_batch: run related read-only graph-language queries through one paid graph call.",
1244
+ "- money_flows: show the most recent senders and receivers of one robinhood address, with an interactive graph in hosts that draw MCP apps.",
830
1245
  "",
831
1246
  "Wallet tools:",
832
1247
  "- wallet_balance: show the local payment wallet address, payment network, token, and amount.",
@@ -840,7 +1255,8 @@ async function createProxy() {
840
1255
  "- meta_subscription_status: check the caller CIA subscription window end, daily allowance, consumption, and tier.",
841
1256
  "- aml_address_risk: screen one blockchain address; optionally compare it with another address.",
842
1257
  "- graph_query: run read-only GQL/Cypher through the universal graph endpoint. Use USE topology or USE facts.",
843
- "- graph_query_batch: run related read-only graph-language queries through one paid graph call."
1258
+ "- graph_query_batch: run related read-only graph-language queries through one paid graph call.",
1259
+ "- money_flows: show the most recent senders and receivers of one robinhood address, with an interactive graph in hosts that draw MCP apps."
844
1260
  ].join("\n")
845
1261
  }],
846
1262
  isError: false
@@ -848,6 +1264,7 @@ async function createProxy() {
848
1264
  for (const tool of tools ?? []) {
849
1265
  if (require_tool_visibility.HIDDEN_REMOTE_TOOL_NAMES.has(tool.name)) continue;
850
1266
  if (LOCAL_TOOL_NAMES.has(tool.name)) continue;
1267
+ if (require_tool_visibility.isAppOnlyTool(tool)) continue;
851
1268
  const inputSchema = knownPublicToolInputSchema(tool.name) ?? zod.object({}).passthrough();
852
1269
  const handler = async (args) => {
853
1270
  try {
@@ -872,43 +1289,48 @@ async function createProxy() {
872
1289
  arguments: normalizedArgs
873
1290
  };
874
1291
  const requestOptions = remoteToolRequestOptions(tool.name);
875
- return normalizeRemoteToolResult(requestOptions ? await remoteClient.callTool(request, void 0, requestOptions) : await remoteClient.callTool(request));
1292
+ const result = requestOptions ? await remoteClient.callTool(request, void 0, requestOptions) : await remoteClient.callTool(request);
1293
+ return normalizeRemoteToolResult(tool.name, result);
876
1294
  } catch (err) {
877
- if (err instanceof require_client.PaymentRequiredError) return {
878
- content: [{
879
- type: "text",
880
- text: err.message
881
- }],
882
- isError: true
883
- };
884
- const msg = err.message ?? String(err);
885
- if (/\b402\b/.test(msg) || msg.toLowerCase().includes("payment")) return {
886
- content: [{
887
- type: "text",
888
- text: `Payment required for ${tool.name}. This tool costs USDC on Base via x402 micropayments. Next steps: run \`cia wallet ready\` to check funding and finish one-time payment setup, run \`cia wallet topup\` if it says the wallet needs USDC, or \`cia access-key set <key>\` if you have been given test access.`
889
- }],
890
- isError: true
891
- };
892
1295
  return {
893
1296
  content: [{
894
1297
  type: "text",
895
- text: `MCP call failed: ${msg}`
1298
+ text: remoteCallFailureText(tool.name, err)
896
1299
  }],
897
1300
  isError: true
898
1301
  };
899
1302
  }
900
1303
  };
1304
+ const annotations = knownPublicToolAnnotations(tool.name);
1305
+ const drawsView = tool.name === "graph_query";
901
1306
  const toolConfig = {
902
- title: tool.title,
1307
+ title: tool.title ?? KNOWN_PUBLIC_TOOL_TITLES[tool.name],
903
1308
  description: claudeFacingToolDescription(tool),
904
1309
  inputSchema,
905
- ...knownPublicToolAnnotations(tool.name) ? { annotations: knownPublicToolAnnotations(tool.name) } : {}
1310
+ ...annotations ? { annotations } : {},
1311
+ ...drawsView ? { _meta: VIEW_TOOL_META } : {}
906
1312
  };
907
1313
  server.registerTool(tool.name, toolConfig, handler);
908
1314
  }
1315
+ const { registerTopupView, TOPUP_VIEW_URI } = await Promise.resolve().then(() => require("./topup-server-Bh7MlzRt.cjs")).then((n) => n.topup_server_exports);
1316
+ await registerTopupView(server, { walletAddress: localTopupWalletAddress });
1317
+ server.registerResource("Chain Insights view", CLAUDE_VIEW_URI, {
1318
+ title: "Chain Insights view",
1319
+ description: "The Chain Insights view for hosts that draw MCP apps: money flows, query results and balance, in light and dark.",
1320
+ mimeType: MCP_APP_MIME_TYPE,
1321
+ _meta: VIEW_RESOURCE_META
1322
+ }, async () => ({ contents: [{
1323
+ uri: CLAUDE_VIEW_URI,
1324
+ mimeType: MCP_APP_MIME_TYPE,
1325
+ text: readClaudeViewHtml(),
1326
+ _meta: VIEW_RESOURCE_META
1327
+ }] }));
909
1328
  const transport = new _modelcontextprotocol_sdk_server_stdio_js.StdioServerTransport();
910
1329
  await server.connect(transport);
911
- await logger.info("proxy.ready", { tools: [...LOCAL_TOOL_NAMES, ...(tools ?? []).map((tool) => tool.name).filter((name) => !require_tool_visibility.HIDDEN_REMOTE_TOOL_NAMES.has(name) && !LOCAL_TOOL_NAMES.has(name))].length });
1330
+ await logger.info("proxy.ready", {
1331
+ tools: [...[...LOCAL_TOOL_NAMES].filter((name) => !APP_ONLY_LOCAL_TOOL_NAMES.has(name)), ...(tools ?? []).filter((tool) => !require_tool_visibility.isAppOnlyTool(tool)).map((tool) => tool.name).filter((name) => !require_tool_visibility.HIDDEN_REMOTE_TOOL_NAMES.has(name) && !LOCAL_TOOL_NAMES.has(name))].length,
1332
+ ui_resources: [CLAUDE_VIEW_URI, TOPUP_VIEW_URI]
1333
+ });
912
1334
  const shutdown = async () => {
913
1335
  await logger.info("proxy.shutdown");
914
1336
  transport.close();
@@ -926,6 +1348,9 @@ if (process.argv[1] && require("url").pathToFileURL(__filename).href.includes(pr
926
1348
  process.exit(1);
927
1349
  });
928
1350
  //#endregion
1351
+ exports.CLAUDE_VIEW_URI = CLAUDE_VIEW_URI;
1352
+ exports.MCP_APP_MIME_TYPE = MCP_APP_MIME_TYPE;
929
1353
  exports.createProxy = createProxy;
930
1354
  exports.knownPublicToolInputSchema = knownPublicToolInputSchema;
1355
+ exports.readClaudeViewHtml = readClaudeViewHtml;
931
1356
  exports.resolveMcpProxyMode = resolveMcpProxyMode;