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.
- package/README.md +25 -1
- package/bin/install.cjs +59 -2
- package/dist/{action-log-diTHyMbV.mjs → action-log-D49Par9P.mjs} +2 -2
- package/dist/{action-log-diTHyMbV.mjs.map → action-log-D49Par9P.mjs.map} +1 -1
- package/dist/{active-BQopLul8.mjs → active-Dkf8AB1R.mjs} +2 -2
- package/dist/{active-BQopLul8.mjs.map → active-Dkf8AB1R.mjs.map} +1 -1
- package/dist/apps/claude-view.html +117 -0
- package/dist/{capabilities-C8DQSfyW.mjs → capabilities-BOOUnAeV.mjs} +3 -3
- package/dist/{capabilities-C8DQSfyW.mjs.map → capabilities-BOOUnAeV.mjs.map} +1 -1
- package/dist/cli.cjs +26 -24
- package/dist/cli.mjs +49 -47
- package/dist/cli.mjs.map +1 -1
- package/dist/{client-CHYskPnG.mjs → client-gsLi528K.mjs} +4 -4
- package/dist/{client-CHYskPnG.mjs.map → client-gsLi528K.mjs.map} +1 -1
- package/dist/{config-DbbPiGk0.mjs → config-Br6VEK0O.mjs} +3 -3
- package/dist/{config-DbbPiGk0.mjs.map → config-Br6VEK0O.mjs.map} +1 -1
- package/dist/{config-B542AM4w.cjs → config-CPUhkz0Z.cjs} +1 -1
- package/dist/{create-C0buxEfU.mjs → create-CpfZq2fx.mjs} +2 -2
- package/dist/{create-C0buxEfU.mjs.map → create-CpfZq2fx.mjs.map} +1 -1
- package/dist/index.cjs +5 -4
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +7 -6
- package/dist/mcp-proxy.cjs +459 -34
- package/dist/mcp-proxy.d.cts +7 -0
- package/dist/mcp-proxy.d.cts.map +1 -1
- package/dist/mcp-proxy.d.mts +7 -0
- package/dist/mcp-proxy.d.mts.map +1 -1
- package/dist/mcp-proxy.mjs +466 -44
- package/dist/mcp-proxy.mjs.map +1 -1
- package/dist/{public-tools-DWONw7kZ.cjs → public-tools-3yTC17cA.cjs} +1 -6
- package/dist/{public-tools-CDpFHbZ8.mjs → public-tools-CZyeptJQ.mjs} +3 -8
- package/dist/public-tools-CZyeptJQ.mjs.map +1 -0
- package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-8H4AJuhK.mjs} +1 -0
- package/dist/{schema-B0OuHW_l.mjs → schema-KjZqCHbu.mjs} +3 -3
- package/dist/schema-KjZqCHbu.mjs.map +1 -0
- package/dist/schema-cache-DJdU4MIP.mjs.map +1 -1
- package/dist/{schema-CtFWYoJz.cjs → schema-tX0U0-VI.cjs} +1 -1
- package/dist/{subscription-status-MyeqOmbA.mjs → subscription-status-CJ-DaMW9.mjs} +2 -2
- package/dist/{subscription-status-MyeqOmbA.mjs.map → subscription-status-CJ-DaMW9.mjs.map} +1 -1
- package/dist/{tool-visibility-CyZqYnQA.cjs → tool-visibility-Boxtlbtr.cjs} +19 -1
- package/dist/{tool-visibility-CJV9HezW.mjs → tool-visibility-IB5nHCZ_.mjs} +16 -4
- package/dist/tool-visibility-IB5nHCZ_.mjs.map +1 -0
- package/dist/{tools-BRQAPF2R.mjs → tools-BnThuvE2.mjs} +3 -3
- package/dist/{tools-BRQAPF2R.mjs.map → tools-BnThuvE2.mjs.map} +1 -1
- package/dist/topup-server-BIbSfvX5.cjs +146 -0
- package/dist/{topup-server-YN3iHrHW.cjs → topup-server-Bh7MlzRt.cjs} +485 -239
- package/dist/{topup-server-BLdF1SFQ.mjs → topup-server-BrkPhqJG.mjs} +488 -242
- package/dist/topup-server-BrkPhqJG.mjs.map +1 -0
- package/dist/topup-server-DzeM_YAt.mjs +131 -0
- package/dist/topup-server-DzeM_YAt.mjs.map +1 -0
- package/dist/{usage-status-CHiOPA-2.mjs → usage-status-CXOnrq-s.mjs} +2 -2
- package/dist/{usage-status-CHiOPA-2.mjs.map → usage-status-CXOnrq-s.mjs.map} +1 -1
- package/dist/{wallet-COcm4oIb.mjs → wallet-GNDl3FkR.mjs} +2 -2
- package/dist/{wallet-COcm4oIb.mjs.map → wallet-GNDl3FkR.mjs.map} +1 -1
- package/docs/graph-query-compatibility.md +27 -10
- package/docs/graph-tools.md +40 -16
- package/docs/mcp-proxy.md +39 -8
- package/docs/stability.md +2 -1
- package/package.json +5 -6
- package/skills/chain-insights-cypher/SKILL.md +56 -23
- package/skills/chain-insights-schema-evm/SKILL.md +38 -27
- package/dist/html-generator-BCDtYcGs.mjs +0 -16
- package/dist/html-generator-BCDtYcGs.mjs.map +0 -1
- package/dist/html-generator-DFwMRWN3.cjs +0 -16
- package/dist/public-tools-CDpFHbZ8.mjs.map +0 -1
- package/dist/schema-B0OuHW_l.mjs.map +0 -1
- package/dist/tool-visibility-CJV9HezW.mjs.map +0 -1
- package/dist/topup-server-BLdF1SFQ.mjs.map +0 -1
package/dist/mcp-proxy.cjs
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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:
|
|
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
|
-
...
|
|
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", {
|
|
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;
|