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
@@ -1,31 +1,380 @@
1
1
  import { n as PACKAGE_VERSION } from "./version-BA3J8hu4.mjs";
2
- import { t as PaymentRequiredError } from "./client-CHYskPnG.mjs";
3
- import { n as PUBLIC_MCP_TOOL_ALLOWED_ARGS, r as PUBLIC_MCP_TOOL_REQUIRED_ARGS, t as HIDDEN_REMOTE_TOOL_NAMES } from "./tool-visibility-CJV9HezW.mjs";
4
- import { t as primitiveBackendUsageStatus } from "./usage-status-CHiOPA-2.mjs";
5
- import { n as unavailableSubscriptionStatus } from "./subscription-status-MyeqOmbA.mjs";
6
- import { n as mirrorGraphNetworkCapabilities } from "./capabilities-C8DQSfyW.mjs";
7
- import { r as appendActionLog, t as actionLogSignalsFromResult } from "./action-log-diTHyMbV.mjs";
2
+ import { t as PaymentRequiredError } from "./client-gsLi528K.mjs";
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-IB5nHCZ_.mjs";
4
+ import { t as primitiveBackendUsageStatus } from "./usage-status-CXOnrq-s.mjs";
5
+ import { n as unavailableSubscriptionStatus } from "./subscription-status-CJ-DaMW9.mjs";
6
+ import { n as mirrorGraphNetworkCapabilities } from "./capabilities-BOOUnAeV.mjs";
7
+ import { r as appendActionLog, t as actionLogSignalsFromResult } from "./action-log-D49Par9P.mjs";
8
+ import { fileURLToPath } from "node:url";
8
9
  import path from "node:path";
10
+ import { readFileSync } from "node:fs";
9
11
  import { appendFile, mkdir } from "node:fs/promises";
10
12
  import * as z from "zod";
11
13
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
14
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
15
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
14
16
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17
+ //#region src/mcp/flows.ts
18
+ /**
19
+ * The money-flow view tools of the local proxy: money_flows and graph_expand.
20
+ *
21
+ * money_flows answers one robinhood address with its most recent senders and
22
+ * receivers: a short text the model reads, and chain-insights.flows.v1 in
23
+ * structuredContent, which the Chain Insights view (ui://chain-insights/view)
24
+ * draws. graph_expand is the same answer for the next page of one address. The
25
+ * view calls it when the investigator clicks an address; its _meta says
26
+ * visibility ["app"], so a host keeps it away from the model.
27
+ *
28
+ * Both tools are composed on the client PC from three anchored graph_query
29
+ * reads (the node, its outgoing FLOWS_TO and its incoming FLOWS_TO, newest
30
+ * first), sent through the proxy's remote client, the same path and payment
31
+ * wrapping a graph_query call takes. Each read is billed as a graph_query.
32
+ * Every argument is checked before the first read, so a refused call reaches
33
+ * no graph endpoint and costs nothing.
34
+ */
35
+ const FLOWS_SCHEMA = "chain-insights.flows.v1";
36
+ const MONEY_FLOWS_TOOL = "money_flows";
37
+ const GRAPH_EXPAND_TOOL = "graph_expand";
38
+ const FLOWS_NETWORK = "robinhood";
39
+ /** Where a graph_expand page may start on each side, at most. */
40
+ const FLOWS_MAX_OFFSET = 1e4;
41
+ const INVALID_ADDRESS_TEXT = "invalid_address: a full 0x address of 40 hexadecimal characters is needed, for example 0x04911a118f11c75667e4d0dfb8e640af5a353550";
42
+ const OFFSET_TOO_LARGE_TEXT = `invalid_offset: in_offset and out_offset must each be at most ${FLOWS_MAX_OFFSET}`;
43
+ const OFFSET_NEGATIVE_TEXT = "invalid_offset: in_offset and out_offset must each be a whole number, 0 or more";
44
+ const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/;
45
+ const CODE_PREFIX = /^([a-z][a-z0-9_]*):\s*/;
46
+ /**
47
+ * The address lower-cased, or null when it is not 0x and 40 hexadecimal
48
+ * characters. Only an address that passes is ever written into a read, so the
49
+ * reads can carry it as a literal.
50
+ */
51
+ function normalizeFlowsAddress(raw) {
52
+ if (typeof raw !== "string") return null;
53
+ const address = raw.trim().toLowerCase();
54
+ return ADDRESS_PATTERN.test(address) ? address : null;
55
+ }
56
+ 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";
57
+ 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";
58
+ const PAGE_ORDER = " ORDER BY f.last_seen_timestamp DESC, b.address";
59
+ /**
60
+ * The three anchored reads of one page. Each side asks for one row more than
61
+ * it shows: the extra row says another page exists.
62
+ */
63
+ function flowsReadsFor(address, inOffset, outOffset) {
64
+ const page = (offset) => `${PAGE_ORDER}${offset > 0 ? ` SKIP ${offset}` : ""} LIMIT 13`;
65
+ return [
66
+ {
67
+ id: "node",
68
+ query: `USE topology MATCH (b:Address {address: '${address}'}) RETURN ${NODE_FIELDS} LIMIT 1`
69
+ },
70
+ {
71
+ id: "out",
72
+ query: `USE topology MATCH (a:Address {address: '${address}'})-[f:FLOWS_TO]->(b:Address) RETURN ${PARTY_FIELDS}${page(outOffset)}`
73
+ },
74
+ {
75
+ id: "in",
76
+ query: `USE topology MATCH (b:Address)-[f:FLOWS_TO]->(a:Address {address: '${address}'}) RETURN ${PARTY_FIELDS}${page(inOffset)}`
77
+ }
78
+ ];
79
+ }
80
+ /** The role word of an address, first match wins. */
81
+ function flowsRole(row) {
82
+ if (row.is_exchange === true) return "exchange";
83
+ if (row.is_sanctioned === true) return "sanctioned";
84
+ if (row.is_scam === true) return "scam";
85
+ if (row.is_victim === true) return "victim";
86
+ if (row.pool === true) return "pool";
87
+ return "other";
88
+ }
89
+ function flowsFloat(value) {
90
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
91
+ }
92
+ function flowsInt(value) {
93
+ return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 0;
94
+ }
95
+ function flowsNodeOf(row) {
96
+ const labels = Array.isArray(row.labels) ? row.labels.filter((label) => typeof label === "string") : [];
97
+ return {
98
+ address: typeof row.address === "string" ? row.address : "",
99
+ role: flowsRole(row),
100
+ labels,
101
+ total_in_usd: flowsFloat(row.total_in_usd),
102
+ total_out_usd: flowsFloat(row.total_out_usd),
103
+ degree_in: flowsInt(row.degree_in),
104
+ degree_out: flowsInt(row.degree_out)
105
+ };
106
+ }
107
+ function assembleFlowView(network, page, senders, receivers, truncated) {
108
+ const center = flowsNodeOf(page.center);
109
+ const view = {
110
+ schema: FLOWS_SCHEMA,
111
+ network,
112
+ center: center.address,
113
+ nodes: [center],
114
+ edges: [],
115
+ cursor: {
116
+ in_offset: page.inOffset + senders.length,
117
+ out_offset: page.outOffset + receivers.length
118
+ },
119
+ truncated
120
+ };
121
+ const seen = /* @__PURE__ */ new Set([center.address]);
122
+ const add = (row, incoming) => {
123
+ const node = flowsNodeOf(row);
124
+ if (!seen.has(node.address)) {
125
+ seen.add(node.address);
126
+ view.nodes.push(node);
127
+ }
128
+ view.edges.push({
129
+ from: incoming ? node.address : center.address,
130
+ to: incoming ? center.address : node.address,
131
+ usd: flowsFloat(row.usd),
132
+ tx_count: flowsInt(row.tx_count),
133
+ first_seen_ms: flowsInt(row.first_seen),
134
+ last_seen_ms: flowsInt(row.last_seen)
135
+ });
136
+ };
137
+ for (const row of senders) add(row, true);
138
+ for (const row of receivers) add(row, false);
139
+ return view;
140
+ }
141
+ function fitsFlowBudget(view) {
142
+ return view.nodes.length <= 60 && JSON.stringify(view).length < 4e4;
143
+ }
144
+ /**
145
+ * flows.v1 from one page: the centre first, then the senders and the
146
+ * receivers, each newest first, at most FLOWS_PER_SIDE a side. When the view
147
+ * data would hold more than FLOWS_MAX_NODES addresses or reach FLOWS_MAX_CHARS
148
+ * characters, the oldest links go first, the view is marked truncated, and the
149
+ * cursor still says where the next page starts.
150
+ */
151
+ function buildFlowView(network, page) {
152
+ let truncated = page.senders.length > 12 || page.receivers.length > 12;
153
+ let senders = page.senders.slice(0, 12);
154
+ let receivers = page.receivers.slice(0, 12);
155
+ for (;;) {
156
+ const view = assembleFlowView(network, page, senders, receivers, truncated);
157
+ if (fitsFlowBudget(view) || senders.length + receivers.length === 0) return view;
158
+ truncated = true;
159
+ if (senders.length >= receivers.length) senders = senders.slice(0, -1);
160
+ else receivers = receivers.slice(0, -1);
161
+ }
162
+ }
163
+ function formatFlowsUsd(value) {
164
+ if (!(value > 0)) return "$0";
165
+ if (value < .01) return "under $0.01";
166
+ const whole = Math.floor(value);
167
+ const cents = Math.round((value - whole) * 100);
168
+ if (cents === 100) return formatFlowsUsd(whole + 1);
169
+ return `$${whole.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${String(cents).padStart(2, "0")}`;
170
+ }
171
+ function formatFlowsAge(lastSeenMs, now) {
172
+ if (lastSeenMs <= 0) return "at an unknown time";
173
+ const date = new Date(lastSeenMs).toISOString().slice(0, 10);
174
+ const days = Math.trunc((now.getTime() - lastSeenMs) / 864e5);
175
+ if (days < 1) return `${date} (today)`;
176
+ if (days === 1) return `${date} (1 day ago)`;
177
+ return `${date} (${days} days ago)`;
178
+ }
179
+ /** The role word, with up to three address labels beside it. */
180
+ function roleText(node) {
181
+ if (!node) return "other";
182
+ if (node.labels.length === 0) return node.role;
183
+ return `${node.role} (${node.labels.slice(0, 3).join(", ")})`;
184
+ }
185
+ function sideLines(title, parties) {
186
+ if (parties.length === 0) return [`${title}, newest first: none.`];
187
+ const lines = [`${title}, newest first (${parties.length}):`];
188
+ const perLine = parties.length > 6 ? 2 : 1;
189
+ for (let start = 0; start < parties.length; start += perLine) lines.push(`- ${parties.slice(start, start + perLine).join("; ")}`);
190
+ return lines;
191
+ }
192
+ /**
193
+ * The text the model reads: the centre, its role and lifetime totals, then
194
+ * every counterparty of the view with its USD, transaction count and last-seen
195
+ * date and age. At most FLOWS_SUMMARY_MAX_LINES lines.
196
+ */
197
+ function flowsSummary(view, now) {
198
+ const nodes = new Map(view.nodes.map((node) => [node.address, node]));
199
+ const center = nodes.get(view.center);
200
+ 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)}`;
201
+ const senders = [];
202
+ const receivers = [];
203
+ for (const edge of view.edges) if (edge.to === view.center) senders.push(party(edge.from, edge));
204
+ else receivers.push(party(edge.to, edge));
205
+ const lines = [
206
+ `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.`,
207
+ ...sideLines("Recent senders", senders),
208
+ ...sideLines("Recent receivers", receivers)
209
+ ];
210
+ if (view.truncated) lines.push("More flows exist than shown; the view loads the next page on click.");
211
+ return lines.slice(0, 20).join("\n");
212
+ }
213
+ function errorResult(text) {
214
+ return {
215
+ content: [{
216
+ type: "text",
217
+ text
218
+ }],
219
+ isError: true
220
+ };
221
+ }
222
+ function isRecord$1(value) {
223
+ return !!value && typeof value === "object" && !Array.isArray(value);
224
+ }
225
+ function answerText(answer) {
226
+ return (answer.content ?? []).map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").filter(Boolean).join("\n").trim();
227
+ }
228
+ /**
229
+ * The refusal of a read that did not answer, naming its code first:
230
+ * error_detail.code when the graph endpoint sent its typed refusal, the code
231
+ * its text starts with otherwise, and graph_query_failed when it names none.
232
+ * The view marks the address it was expanding.
233
+ */
234
+ function refusalText(answer) {
235
+ const text = answerText(answer);
236
+ const detail = isRecord$1(answer.structuredContent) ? answer.structuredContent.error_detail : void 0;
237
+ const detailCode = isRecord$1(detail) && typeof detail.code === "string" ? detail.code : "";
238
+ const textCode = CODE_PREFIX.exec(text)?.[1] ?? "";
239
+ const code = detailCode || textCode || "graph_query_failed";
240
+ const rest = textCode === code ? text.replace(CODE_PREFIX, "") : text;
241
+ return rest ? `${code}: ${rest}` : `${code}: the graph endpoint refused the read`;
242
+ }
243
+ function rowsOf(answer) {
244
+ const facts = isRecord$1(answer.structuredContent) ? answer.structuredContent.facts : void 0;
245
+ const query = isRecord$1(facts) ? facts.query : void 0;
246
+ const results = isRecord$1(query) ? query.results : void 0;
247
+ return Array.isArray(results) ? results.filter(isRecord$1) : null;
248
+ }
249
+ function checkNetworkAndAddress(args) {
250
+ const rawNetwork = typeof args.network === "string" ? args.network.trim().toLowerCase() : "";
251
+ if (!rawNetwork) return { error: "invalid_network: network is required; pass robinhood" };
252
+ if (rawNetwork !== "robinhood") return { error: `invalid_network: money flows read the robinhood topology graph only; got "${String(args.network).trim()}"` };
253
+ const address = normalizeFlowsAddress(args.address);
254
+ if (!address) return { error: INVALID_ADDRESS_TEXT };
255
+ return {
256
+ network: FLOWS_NETWORK,
257
+ address
258
+ };
259
+ }
260
+ function checkOffset(value) {
261
+ if (value === void 0 || value === null) return 0;
262
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return { error: OFFSET_NEGATIVE_TEXT };
263
+ if (value > 1e4) return { error: OFFSET_TOO_LARGE_TEXT };
264
+ return value;
265
+ }
266
+ /**
267
+ * Read and answer one page. The reads run one after another: the node first,
268
+ * so an address the graph does not hold costs one read, not three; then the
269
+ * receivers and the senders. The first read that does not answer ends the call.
270
+ */
271
+ async function runFlows(deps, network, address, inOffset, outOffset) {
272
+ const unavailable = deps.unavailable?.();
273
+ if (unavailable) return errorResult(unavailable);
274
+ const rows = {};
275
+ for (const read of flowsReadsFor(address, inOffset, outOffset)) {
276
+ let answer;
277
+ try {
278
+ answer = await deps.graphQuery({
279
+ network,
280
+ query: read.query
281
+ });
282
+ } catch (err) {
283
+ return errorResult(deps.describeFailure(err));
284
+ }
285
+ if (answer.isError === true) return errorResult(refusalText(answer));
286
+ const found = rowsOf(answer);
287
+ if (!found) return errorResult("graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows");
288
+ rows[read.id] = found;
289
+ if (read.id === "node" && found.length === 0) return errorResult(`${address} is not in the ${network} topology graph`);
290
+ }
291
+ const view = buildFlowView(network, {
292
+ center: rows.node?.[0] ?? {},
293
+ senders: rows.in ?? [],
294
+ receivers: rows.out ?? [],
295
+ inOffset,
296
+ outOffset
297
+ });
298
+ return {
299
+ content: [{
300
+ type: "text",
301
+ text: flowsSummary(view, deps.now ? deps.now() : /* @__PURE__ */ new Date())
302
+ }],
303
+ structuredContent: view,
304
+ isError: false
305
+ };
306
+ }
307
+ /** money_flows {address, network}: the first page of one address. */
308
+ async function handleMoneyFlows(args, deps) {
309
+ const checked = checkNetworkAndAddress(isRecord$1(args) ? args : {});
310
+ if ("error" in checked) return errorResult(checked.error);
311
+ return runFlows(deps, checked.network, checked.address, 0, 0);
312
+ }
313
+ /** graph_expand {network, address, in_offset, out_offset}: the next page of one address. */
314
+ async function handleGraphExpand(args, deps) {
315
+ const record = isRecord$1(args) ? args : {};
316
+ const checked = checkNetworkAndAddress(record);
317
+ if ("error" in checked) return errorResult(checked.error);
318
+ const inOffset = checkOffset(record.in_offset);
319
+ if (typeof inOffset !== "number") return errorResult(inOffset.error);
320
+ const outOffset = checkOffset(record.out_offset);
321
+ if (typeof outOffset !== "number") return errorResult(outOffset.error);
322
+ return runFlows(deps, checked.network, checked.address, inOffset, outOffset);
323
+ }
324
+ 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.";
325
+ 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.";
326
+ /** The line of the server instructions that tells a model what money_flows answers. */
327
+ 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.";
328
+ //#endregion
15
329
  //#region src/mcp/proxy.ts
16
330
  const LOCAL_TOOL_NAMES = /* @__PURE__ */ new Set([
17
331
  "meta_network_capabilities",
18
332
  "meta_usage_status",
19
333
  "meta_subscription_status",
20
334
  "meta_help",
21
- "wallet_balance"
335
+ "wallet_balance",
336
+ "wallet_topup",
337
+ MONEY_FLOWS_TOOL,
338
+ GRAPH_EXPAND_TOOL
22
339
  ]);
340
+ const APP_ONLY_LOCAL_TOOL_NAMES = /* @__PURE__ */ new Set([GRAPH_EXPAND_TOOL]);
23
341
  const GRAPH_ARRAY_KEYS = [
24
342
  "nodes",
25
343
  "edges",
26
344
  "flows",
27
345
  "edge_anchors"
28
346
  ];
347
+ const CLAUDE_VIEW_URI = "ui://chain-insights/view";
348
+ const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
349
+ const VIEW_TOOL_META = {
350
+ ui: { resourceUri: CLAUDE_VIEW_URI },
351
+ "ui/resourceUri": CLAUDE_VIEW_URI
352
+ };
353
+ const APP_ONLY_TOOL_META = { ui: {
354
+ resourceUri: CLAUDE_VIEW_URI,
355
+ visibility: ["app"]
356
+ } };
357
+ const VIEW_RESOURCE_META = { ui: {
358
+ prefersBorder: false,
359
+ csp: { resourceDomains: ["https://assets.claude.ai"] }
360
+ } };
361
+ const VIEW_TOOL_ANNOTATIONS = { readOnlyHint: true };
362
+ const CLAUDE_VIEW_FILE = "claude-view.html";
363
+ let claudeViewHtml;
364
+ /**
365
+ * The view file shipped in the package: dist/apps/claude-view.html next to the
366
+ * built proxy, or src/mcp/apps/claude-view.html when run from source. Read once.
367
+ */
368
+ function readClaudeViewHtml() {
369
+ if (claudeViewHtml !== void 0) return claudeViewHtml;
370
+ const here = path.dirname(fileURLToPath(import.meta.url));
371
+ const candidates = [path.join(here, "apps", CLAUDE_VIEW_FILE), path.join(here, "..", "src", "mcp", "apps", CLAUDE_VIEW_FILE)];
372
+ for (const candidate of candidates) try {
373
+ claudeViewHtml = readFileSync(candidate, "utf8");
374
+ return claudeViewHtml;
375
+ } catch {}
376
+ throw new Error(`The Chain Insights view file ${CLAUDE_VIEW_FILE} is missing from the package`);
377
+ }
29
378
  function resolveMcpProxyMode(env = process.env) {
30
379
  const raw = env["CHAIN_INSIGHTS_MCP_PROXY_MODE"]?.trim().toLowerCase();
31
380
  if (!raw || raw === "stateless") return "stateless";
@@ -44,6 +393,10 @@ const KNOWN_PUBLIC_TOOL_DESCRIPTIONS = {
44
393
  graph_query: `Run a read-only GQL/Cypher query through the Chain Insights graph endpoint. ${GRAPH_LAYERS_TEXT} Preserve full addresses exactly.`,
45
394
  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."
46
395
  };
396
+ const KNOWN_PUBLIC_TOOL_TITLES = {
397
+ graph_query: "Graph Query",
398
+ graph_query_batch: "Graph Query Batch"
399
+ };
47
400
  const FALLBACK_GRAPH_PRIMITIVE_TOOL_NAMES = ["graph_query", "graph_query_batch"];
48
401
  const NETWORK_SCHEMA = z.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.");
49
402
  const EMPTY_INPUT_SCHEMA = z.strictObject({});
@@ -52,7 +405,8 @@ const CHAIN_INSIGHTS_WORKFLOW = [
52
405
  "Workflow:",
53
406
  "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.",
54
407
  "2. Use aml_address_risk for single-address enrichment. Use graph_query(_batch) for graph-level questions that aml_address_risk does not answer.",
55
- "3. Preserve tool summaries and structured facts as returned. Keep full blockchain addresses intact."
408
+ `3. ${MONEY_FLOWS_INSTRUCTION}`,
409
+ "4. Preserve tool summaries and structured facts as returned. Keep full blockchain addresses intact."
56
410
  ].join("\n");
57
411
  const GRAPH_SCHEMA_HINTS = [
58
412
  "Graph query hints:",
@@ -61,10 +415,10 @@ const GRAPH_SCHEMA_HINTS = [
61
415
  "- 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.",
62
416
  "- 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.",
63
417
  "- 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.",
64
- "- 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.",
418
+ "- 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.",
65
419
  "- 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).",
66
420
  "- 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.",
67
- "- (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.",
421
+ "- (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.",
68
422
  "- 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.",
69
423
  "- 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.",
70
424
  "- (: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.",
@@ -73,9 +427,10 @@ const GRAPH_SCHEMA_HINTS = [
73
427
  "- 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.",
74
428
  "- (: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.",
75
429
  "- 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.",
76
- "- 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.",
430
+ "- 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.",
431
+ "- 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.",
77
432
  "- 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.",
78
- "- 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.",
433
+ "- 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.",
79
434
  "- 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",
80
435
  "- 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",
81
436
  "- 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.",
@@ -92,6 +447,7 @@ const SERVER_INSTRUCTIONS = [
92
447
  const STATELESS_SERVER_INSTRUCTIONS = [
93
448
  "Chain Insights is running as a stateless AML proxy for a host application.",
94
449
  "Use meta_network_capabilities first when network support is unknown, then call aml_address_risk, graph_query, or graph_query_batch as needed.",
450
+ MONEY_FLOWS_INSTRUCTION,
95
451
  "Use wallet_balance to inspect the local payment wallet when payment setup is needed.",
96
452
  GRAPH_SCHEMA_HINTS,
97
453
  "Presentation rules: preserve tool summaries as returned; never truncate blockchain addresses or identity_resolution audit mappings."
@@ -130,9 +486,20 @@ function fallbackGraphPrimitiveTools() {
130
486
  * server (subscription_status). Returns null — never throws — when no wallet
131
487
  * is configured; callers degrade to an unavailable-shape result.
132
488
  */
489
+ /**
490
+ * Local payment wallet address for the top-up view: null when no wallet file
491
+ * exists, and an error when the file exists but cannot be read. Reads the
492
+ * address only; nothing is signed or sent.
493
+ */
494
+ async function localTopupWalletAddress() {
495
+ const { isWalletConfigured } = await import("./wallet-GNDl3FkR.mjs").then((n) => n.s);
496
+ if (!await isWalletConfigured()) return null;
497
+ const { getWalletAccount } = await import("./tools-BnThuvE2.mjs").then((n) => n.p);
498
+ return (await getWalletAccount()).address;
499
+ }
133
500
  async function localSubscriptionWalletAddress() {
134
501
  try {
135
- const { getWalletAccount } = await import("./tools-BRQAPF2R.mjs").then((n) => n.p);
502
+ const { getWalletAccount } = await import("./tools-BnThuvE2.mjs").then((n) => n.p);
136
503
  return (await getWalletAccount()).address;
137
504
  } catch {
138
505
  return null;
@@ -334,6 +701,17 @@ function knownPublicToolAnnotations(toolName) {
334
701
  openWorldHint: true
335
702
  };
336
703
  }
704
+ /**
705
+ * The text a call to the graph endpoint answers with when it throws: payment
706
+ * guidance for a payment failure, the error otherwise. Shared by every proxied
707
+ * tool and by the money-flow reads.
708
+ */
709
+ function remoteCallFailureText(toolName, err) {
710
+ if (err instanceof PaymentRequiredError) return err.message;
711
+ const msg = err.message ?? String(err);
712
+ 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.`;
713
+ return `MCP call failed: ${msg}`;
714
+ }
337
715
  function promptResult(text, description) {
338
716
  return {
339
717
  description,
@@ -441,8 +819,6 @@ function sanitizeStructuredValue(value) {
441
819
  function sanitizeRemoteMeta(metaValue) {
442
820
  if (!metaValue || typeof metaValue !== "object" || Array.isArray(metaValue)) return void 0;
443
821
  const meta = { ...metaValue };
444
- delete meta.ui;
445
- delete meta["ui/resourceUri"];
446
822
  const chainInsights = meta.chainInsights;
447
823
  if (chainInsights && typeof chainInsights === "object" && !Array.isArray(chainInsights)) {
448
824
  const { graph: _graph, ...withoutGraph } = chainInsights;
@@ -451,10 +827,13 @@ function sanitizeRemoteMeta(metaValue) {
451
827
  }
452
828
  return Object.keys(meta).length > 0 ? meta : void 0;
453
829
  }
454
- function normalizeRemoteToolResult(result) {
830
+ function stripsGraphPayload(toolName) {
831
+ return toolName.startsWith("aml_");
832
+ }
833
+ function normalizeRemoteToolResult(toolName, result) {
455
834
  return {
456
835
  content: result.content ?? [],
457
- structuredContent: sanitizeStructuredContentForGraphPayload(result.structuredContent),
836
+ structuredContent: stripsGraphPayload(toolName) ? sanitizeStructuredContentForGraphPayload(result.structuredContent) : result.structuredContent,
458
837
  _meta: sanitizeRemoteMeta(result._meta),
459
838
  isError: result.isError
460
839
  };
@@ -489,9 +868,9 @@ function jsonTextResult(structuredContent) {
489
868
  * All diagnostic output goes to console.error() or process.stderr.write().
490
869
  */
491
870
  async function createProxy() {
492
- const { loadConfig } = await import("./config-DbbPiGk0.mjs").then((n) => n.t);
493
- const { activeDataDir, findActiveWorkspace } = await import("./active-BQopLul8.mjs").then((n) => n.t);
494
- const { createConfiguredGraphMcpFetch, resolveGraphMcpEndpoint } = await import("./client-CHYskPnG.mjs").then((n) => n.r);
871
+ const { loadConfig } = await import("./config-Br6VEK0O.mjs").then((n) => n.t);
872
+ const { activeDataDir, findActiveWorkspace } = await import("./active-Dkf8AB1R.mjs").then((n) => n.t);
873
+ const { createConfiguredGraphMcpFetch, resolveGraphMcpEndpoint } = await import("./client-gsLi528K.mjs").then((n) => n.r);
495
874
  const { loadSchema, saveSchema } = await import("./schema-cache-DJdU4MIP.mjs");
496
875
  const proxyMode = resolveMcpProxyMode();
497
876
  const workspaceArtifactsEnabled = proxyMode === "workspace";
@@ -625,6 +1004,7 @@ async function createProxy() {
625
1004
  title: "Usage Status",
626
1005
  description: KNOWN_PUBLIC_TOOL_DESCRIPTIONS.meta_usage_status,
627
1006
  inputSchema: EMPTY_INPUT_SCHEMA,
1007
+ _meta: VIEW_TOOL_META,
628
1008
  annotations: {
629
1009
  readOnlyHint: true,
630
1010
  destructiveHint: false,
@@ -672,6 +1052,7 @@ async function createProxy() {
672
1052
  title: "Subscription Status",
673
1053
  description: KNOWN_PUBLIC_TOOL_DESCRIPTIONS.meta_subscription_status,
674
1054
  inputSchema: EMPTY_INPUT_SCHEMA,
1055
+ _meta: VIEW_TOOL_META,
675
1056
  annotations: {
676
1057
  readOnlyHint: true,
677
1058
  destructiveHint: false,
@@ -722,7 +1103,7 @@ async function createProxy() {
722
1103
  }
723
1104
  }, async () => {
724
1105
  try {
725
- const { formatWalletBalanceResult, getWalletAccount, getWalletBalanceResult } = await import("./tools-BRQAPF2R.mjs").then((n) => n.p);
1106
+ const { formatWalletBalanceResult, getWalletAccount, getWalletBalanceResult } = await import("./tools-BnThuvE2.mjs").then((n) => n.p);
726
1107
  const structuredContent = await getWalletBalanceResult(await getWalletAccount());
727
1108
  return {
728
1109
  content: [{
@@ -766,7 +1147,7 @@ async function createProxy() {
766
1147
  }],
767
1148
  isError: true
768
1149
  };
769
- const { runAmlAddressRisk } = await import("./public-tools-CDpFHbZ8.mjs");
1150
+ const { runAmlAddressRisk } = await import("./public-tools-CZyeptJQ.mjs");
770
1151
  const result = await runAmlAddressRisk(remoteClient, {
771
1152
  address,
772
1153
  network,
@@ -798,6 +1179,39 @@ async function createProxy() {
798
1179
  };
799
1180
  }
800
1181
  });
1182
+ const flowsDependencies = {
1183
+ graphQuery: async (args) => {
1184
+ const options = remoteToolRequestOptions("graph_query");
1185
+ return await remoteClient.callTool({
1186
+ name: "graph_query",
1187
+ arguments: args
1188
+ }, void 0, options);
1189
+ },
1190
+ describeFailure: (err) => remoteCallFailureText("graph_query", err),
1191
+ unavailable: () => remoteConnected ? void 0 : `${remoteUnavailableMessage ?? `Chain Insights Graph is not connected at ${graphMcpEndpoint}`}. Restart the Chain Insights MCP proxy after the endpoint is reachable.`
1192
+ };
1193
+ server.registerTool(MONEY_FLOWS_TOOL, {
1194
+ title: "Money flows",
1195
+ description: MONEY_FLOWS_DESCRIPTION,
1196
+ inputSchema: {
1197
+ address: z.string().describe("One robinhood address: 0x and 40 hexadecimal characters."),
1198
+ network: z.string().describe("The network. Only robinhood is served.")
1199
+ },
1200
+ annotations: VIEW_TOOL_ANNOTATIONS,
1201
+ _meta: VIEW_TOOL_META
1202
+ }, async (args) => handleMoneyFlows(args, flowsDependencies));
1203
+ server.registerTool(GRAPH_EXPAND_TOOL, {
1204
+ title: "Expand a money-flow node",
1205
+ description: GRAPH_EXPAND_DESCRIPTION,
1206
+ inputSchema: {
1207
+ network: z.string(),
1208
+ address: z.string(),
1209
+ in_offset: z.number().int().min(0).max(FLOWS_MAX_OFFSET).optional(),
1210
+ out_offset: z.number().int().min(0).max(FLOWS_MAX_OFFSET).optional()
1211
+ },
1212
+ annotations: VIEW_TOOL_ANNOTATIONS,
1213
+ _meta: APP_ONLY_TOOL_META
1214
+ }, async (args) => handleGraphExpand(args, flowsDependencies));
801
1215
  server.registerTool("meta_help", {
802
1216
  title: "Chain Insights Help",
803
1217
  description: KNOWN_PUBLIC_TOOL_DESCRIPTIONS.meta_help,
@@ -823,6 +1237,7 @@ async function createProxy() {
823
1237
  "- aml_address_risk: screen one blockchain address; optionally compare it with another address.",
824
1238
  "- graph_query: run read-only GQL/Cypher through the universal graph endpoint. Use USE topology or USE facts.",
825
1239
  "- graph_query_batch: run related read-only graph-language queries through one paid graph call.",
1240
+ "- money_flows: show the most recent senders and receivers of one robinhood address, with an interactive graph in hosts that draw MCP apps.",
826
1241
  "",
827
1242
  "Wallet tools:",
828
1243
  "- wallet_balance: show the local payment wallet address, payment network, token, and amount.",
@@ -836,7 +1251,8 @@ async function createProxy() {
836
1251
  "- meta_subscription_status: check the caller CIA subscription window end, daily allowance, consumption, and tier.",
837
1252
  "- aml_address_risk: screen one blockchain address; optionally compare it with another address.",
838
1253
  "- graph_query: run read-only GQL/Cypher through the universal graph endpoint. Use USE topology or USE facts.",
839
- "- graph_query_batch: run related read-only graph-language queries through one paid graph call."
1254
+ "- graph_query_batch: run related read-only graph-language queries through one paid graph call.",
1255
+ "- money_flows: show the most recent senders and receivers of one robinhood address, with an interactive graph in hosts that draw MCP apps."
840
1256
  ].join("\n")
841
1257
  }],
842
1258
  isError: false
@@ -844,6 +1260,7 @@ async function createProxy() {
844
1260
  for (const tool of tools ?? []) {
845
1261
  if (HIDDEN_REMOTE_TOOL_NAMES.has(tool.name)) continue;
846
1262
  if (LOCAL_TOOL_NAMES.has(tool.name)) continue;
1263
+ if (isAppOnlyTool(tool)) continue;
847
1264
  const inputSchema = knownPublicToolInputSchema(tool.name) ?? z.object({}).passthrough();
848
1265
  const handler = async (args) => {
849
1266
  try {
@@ -868,43 +1285,48 @@ async function createProxy() {
868
1285
  arguments: normalizedArgs
869
1286
  };
870
1287
  const requestOptions = remoteToolRequestOptions(tool.name);
871
- return normalizeRemoteToolResult(requestOptions ? await remoteClient.callTool(request, void 0, requestOptions) : await remoteClient.callTool(request));
1288
+ const result = requestOptions ? await remoteClient.callTool(request, void 0, requestOptions) : await remoteClient.callTool(request);
1289
+ return normalizeRemoteToolResult(tool.name, result);
872
1290
  } catch (err) {
873
- if (err instanceof PaymentRequiredError) return {
874
- content: [{
875
- type: "text",
876
- text: err.message
877
- }],
878
- isError: true
879
- };
880
- const msg = err.message ?? String(err);
881
- if (/\b402\b/.test(msg) || msg.toLowerCase().includes("payment")) return {
882
- content: [{
883
- type: "text",
884
- 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.`
885
- }],
886
- isError: true
887
- };
888
1291
  return {
889
1292
  content: [{
890
1293
  type: "text",
891
- text: `MCP call failed: ${msg}`
1294
+ text: remoteCallFailureText(tool.name, err)
892
1295
  }],
893
1296
  isError: true
894
1297
  };
895
1298
  }
896
1299
  };
1300
+ const annotations = knownPublicToolAnnotations(tool.name);
1301
+ const drawsView = tool.name === "graph_query";
897
1302
  const toolConfig = {
898
- title: tool.title,
1303
+ title: tool.title ?? KNOWN_PUBLIC_TOOL_TITLES[tool.name],
899
1304
  description: claudeFacingToolDescription(tool),
900
1305
  inputSchema,
901
- ...knownPublicToolAnnotations(tool.name) ? { annotations: knownPublicToolAnnotations(tool.name) } : {}
1306
+ ...annotations ? { annotations } : {},
1307
+ ...drawsView ? { _meta: VIEW_TOOL_META } : {}
902
1308
  };
903
1309
  server.registerTool(tool.name, toolConfig, handler);
904
1310
  }
1311
+ const { registerTopupView, TOPUP_VIEW_URI } = await import("./topup-server-BrkPhqJG.mjs").then((n) => n.i);
1312
+ await registerTopupView(server, { walletAddress: localTopupWalletAddress });
1313
+ server.registerResource("Chain Insights view", CLAUDE_VIEW_URI, {
1314
+ title: "Chain Insights view",
1315
+ description: "The Chain Insights view for hosts that draw MCP apps: money flows, query results and balance, in light and dark.",
1316
+ mimeType: MCP_APP_MIME_TYPE,
1317
+ _meta: VIEW_RESOURCE_META
1318
+ }, async () => ({ contents: [{
1319
+ uri: CLAUDE_VIEW_URI,
1320
+ mimeType: MCP_APP_MIME_TYPE,
1321
+ text: readClaudeViewHtml(),
1322
+ _meta: VIEW_RESOURCE_META
1323
+ }] }));
905
1324
  const transport = new StdioServerTransport();
906
1325
  await server.connect(transport);
907
- await logger.info("proxy.ready", { tools: [...LOCAL_TOOL_NAMES, ...(tools ?? []).map((tool) => tool.name).filter((name) => !HIDDEN_REMOTE_TOOL_NAMES.has(name) && !LOCAL_TOOL_NAMES.has(name))].length });
1326
+ await logger.info("proxy.ready", {
1327
+ tools: [...[...LOCAL_TOOL_NAMES].filter((name) => !APP_ONLY_LOCAL_TOOL_NAMES.has(name)), ...(tools ?? []).filter((tool) => !isAppOnlyTool(tool)).map((tool) => tool.name).filter((name) => !HIDDEN_REMOTE_TOOL_NAMES.has(name) && !LOCAL_TOOL_NAMES.has(name))].length,
1328
+ ui_resources: [CLAUDE_VIEW_URI, TOPUP_VIEW_URI]
1329
+ });
908
1330
  const shutdown = async () => {
909
1331
  await logger.info("proxy.shutdown");
910
1332
  transport.close();
@@ -922,6 +1344,6 @@ if (process.argv[1] && import.meta.url.includes(process.argv[1].replace(/\\/g, "
922
1344
  process.exit(1);
923
1345
  });
924
1346
  //#endregion
925
- export { createProxy, knownPublicToolInputSchema, resolveMcpProxyMode };
1347
+ export { CLAUDE_VIEW_URI, MCP_APP_MIME_TYPE, createProxy, knownPublicToolInputSchema, readClaudeViewHtml, resolveMcpProxyMode };
926
1348
 
927
1349
  //# sourceMappingURL=mcp-proxy.mjs.map