chain-insights 0.44.1 → 0.46.2
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 +10 -7
- package/dist/cache-location-BO2XkfgI.mjs +11 -0
- package/dist/cache-location-BO2XkfgI.mjs.map +1 -0
- package/dist/cache-location-CBVzUIxq.cjs +23 -0
- package/dist/{capabilities-A8fsfP24.cjs → capabilities-BGkL9yAr.cjs} +6 -0
- package/dist/{capabilities-DfWL6n_g.mjs → capabilities-Cur8x6c7.mjs} +2 -2
- package/dist/capabilities-Cur8x6c7.mjs.map +1 -0
- package/dist/cli.cjs +32 -6
- package/dist/cli.mjs +32 -6
- package/dist/cli.mjs.map +1 -1
- package/dist/graph-schema-D1orCe5F.mjs +914 -0
- package/dist/graph-schema-D1orCe5F.mjs.map +1 -0
- package/dist/graph-schema-znb_9MYq.cjs +972 -0
- package/dist/mcp-proxy.cjs +74 -480
- package/dist/mcp-proxy.d.cts.map +1 -1
- package/dist/mcp-proxy.d.mts.map +1 -1
- package/dist/mcp-proxy.mjs +66 -472
- package/dist/mcp-proxy.mjs.map +1 -1
- package/dist/public-tools-CZyeptJQ.mjs.map +1 -1
- package/dist/{schema-cache-DJdU4MIP.mjs → schema-cache-BT-kHy_r.mjs} +4 -5
- package/dist/schema-cache-BT-kHy_r.mjs.map +1 -0
- package/dist/{schema-cache-n7Gbhvsj.cjs → schema-cache-DwddPLcY.cjs} +3 -5
- package/docs/architecture.md +2 -0
- package/docs/graph-query-compatibility.md +56 -46
- package/docs/graph-tools.md +87 -37
- package/docs/mcp-proxy.md +13 -0
- package/docs/stability.md +2 -1
- package/package.json +1 -1
- package/skills/chain-insights-cypher/SKILL.md +108 -44
- package/dist/capabilities-DfWL6n_g.mjs.map +0 -1
- package/dist/schema-cache-DJdU4MIP.mjs.map +0 -1
package/dist/mcp-proxy.mjs
CHANGED
|
@@ -3,7 +3,8 @@ import { t as PaymentRequiredError } from "./client-gsLi528K.mjs";
|
|
|
3
3
|
import { i as isAppOnlyTool, n as PUBLIC_MCP_TOOL_ALLOWED_ARGS, r as PUBLIC_MCP_TOOL_REQUIRED_ARGS, t as HIDDEN_REMOTE_TOOL_NAMES } from "./tool-visibility-P9wETx-d.mjs";
|
|
4
4
|
import { t as primitiveBackendUsageStatus } from "./usage-status-CXOnrq-s.mjs";
|
|
5
5
|
import { n as unavailableSubscriptionStatus } from "./subscription-status-CJ-DaMW9.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import { r as mirrorGraphNetworkCapabilities } from "./capabilities-Cur8x6c7.mjs";
|
|
7
|
+
import { a as handleMetaSchema, c as GRAPH_EXPAND_TITLE, l as GRAPH_EXPAND_TOOL, n as META_SCHEMA_DESCRIPTION, o as FLOWS_MAX_OFFSET, r as META_SCHEMA_TITLE, s as GRAPH_EXPAND_DESCRIPTION, t as GraphSchemaError, u as handleGraphExpand } from "./graph-schema-D1orCe5F.mjs";
|
|
7
8
|
import { r as appendActionLog, t as actionLogSignalsFromResult } from "./action-log-D49Par9P.mjs";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
10
|
import path from "node:path";
|
|
@@ -16,21 +17,21 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
16
17
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
17
18
|
//#region src/mcp/layer-routing.ts
|
|
18
19
|
/** The routing rule, in one sentence. The server states it in the same words. */
|
|
19
|
-
const ROUTING_RULE = "Route by what
|
|
20
|
+
const ROUTING_RULE = "Route by what the question names: an address, a pair with a day, a transaction or a block. Topology searches outward from an address, while facts and chain look up one known thing.";
|
|
20
21
|
/** The rule in three lines, one for each layer. */
|
|
21
22
|
const ROUTING_LINES = [
|
|
22
|
-
"I
|
|
23
|
-
"I know
|
|
24
|
-
"I want the chain's own record of a transaction by its
|
|
23
|
+
"I know an address and want its links, senders, receivers, hops or a route: `USE topology`, anchored on that address.",
|
|
24
|
+
"I know both addresses of a pair and one day, and want the rows of that day: `USE facts`.",
|
|
25
|
+
"I want the chain's own record of a transaction by its `tx_id`, a block by its `block_height` or `block_hash`, an address at one block (`Address`), or the head: `USE chain`."
|
|
25
26
|
];
|
|
26
|
-
/** The chain layer
|
|
27
|
-
const
|
|
27
|
+
/** The chain layer reads one address at one block. A search for counterparties stays on topology. */
|
|
28
|
+
const ROUTING_ADDRESS_ON_CHAIN = "`USE chain` with `Address` reads the balance, the nonce or the kind of one address, now or at a past block with `at_block`. A past block must be at least `chain_admission.at_block_min_depth` blocks below the tip. To find the counterparties of an address, search `USE topology`, then read the pair and one day on `USE facts`.";
|
|
28
29
|
/** What to do when two layers fit. */
|
|
29
|
-
const ROUTING_TWO_FIT = "
|
|
30
|
-
/** The key that each layer hands to the next. */
|
|
31
|
-
const ROUTING_HANDOFF = "The layers hand each other keys. Topology gives the pair and the first and last seen time.
|
|
32
|
-
/** A question that no layer serves. */
|
|
33
|
-
const ROUTING_NOT_SERVED = "A list, a range or a whole-chain question
|
|
30
|
+
const ROUTING_TWO_FIT = "A transaction is a chain question: read it on `USE chain` by its `tx_id`. Chain gives its `block_date`; the transfers between a pair on that day are a `USE facts` read.";
|
|
31
|
+
/** The key that each layer hands to the next. Each key keeps its name. */
|
|
32
|
+
const ROUTING_HANDOFF = "The layers hand each other keys, and each key keeps its name. Topology gives the pair and the first and last seen time. Facts gives the `tx_id` and the `block_height`. Chain takes an `address`, a `tx_id`, a `block_height` or a `block_hash`, and gives the `block_date` of a transaction or a block.";
|
|
33
|
+
/** A question that no layer serves today. The agent says so once and asks for an anchor. */
|
|
34
|
+
const ROUTING_NOT_SERVED = "A list, a range or a whole-chain question names no address, pair, transaction or block, so no layer serves it today. Say so in one line, ask the user for one of those, and send no workaround query.";
|
|
34
35
|
/** What a refusal carries, and which part of it decides the move. */
|
|
35
36
|
const MOVE_INTRO = "A refused, killed, busy or failed query comes back with `error_detail`: `code`, `rule`, `class`, `fix` and `example`. The `class` decides your next move.";
|
|
36
37
|
/** The move for each class of the refusal envelope. */
|
|
@@ -63,468 +64,26 @@ function moveLines() {
|
|
|
63
64
|
];
|
|
64
65
|
}
|
|
65
66
|
/**
|
|
66
|
-
* The
|
|
67
|
-
* one
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return [`- Pick the layer first. ${ROUTING_RULE} 1. ${ROUTING_LINES[0]} 2. ${ROUTING_LINES[1]} 3. ${ROUTING_LINES[2]} ${ROUTING_NO_ADDRESS_ON_CHAIN} ${ROUTING_TWO_FIT} ${ROUTING_HANDOFF} ${ROUTING_NOT_SERVED} The lookups that USE chain serves are listed in chain_admission.lookups of meta_network_capabilities. Read every limit that the server publishes from meta_network_capabilities (chain_admission and, when the server sends them, topology_admission and facts_admission). Never write a limit down.`, `- Act on a refusal by its class. ${moveLines().join(" ")}`];
|
|
71
|
-
}
|
|
72
|
-
//#endregion
|
|
73
|
-
//#region src/mcp/flows.ts
|
|
74
|
-
/**
|
|
75
|
-
* graph_expand, the one dedicated tool of the local proxy, called by the Chain
|
|
76
|
-
* Insights view (ui://chain-insights/view) when the investigator clicks. Its
|
|
77
|
-
* _meta says visibility ["app"], so a host keeps it away from the model: the
|
|
78
|
-
* model writes its own graph_query, and the view draws the rows by their column
|
|
79
|
-
* names.
|
|
80
|
-
*
|
|
81
|
-
* Two forms, told apart by the arguments:
|
|
82
|
-
*
|
|
83
|
-
* - node, {network, address, in_offset?, out_offset?}: one address with its
|
|
84
|
-
* newest senders and receivers, a text summary and chain-insights.flows.v1 in
|
|
85
|
-
* structuredContent. Composed from three anchored graph_query reads on the
|
|
86
|
-
* topology graph (the node, its outgoing FLOWS_TO and its incoming FLOWS_TO,
|
|
87
|
-
* newest first).
|
|
88
|
-
* - link, {network, from, to, day}: the transfers between two known addresses
|
|
89
|
-
* on one UTC day, newest first, at most 50, a short text summary and
|
|
90
|
-
* chain-insights.transfers.v1 in structuredContent. One anchored USE facts
|
|
91
|
-
* graph_query read.
|
|
92
|
-
*
|
|
93
|
-
* Every read goes through the proxy's remote client, the same path and payment
|
|
94
|
-
* wrapping a graph_query call takes, and is billed as a graph_query. Every
|
|
95
|
-
* argument is checked before the first read, so a refused call reaches no graph
|
|
96
|
-
* endpoint and costs nothing.
|
|
97
|
-
*/
|
|
98
|
-
const FLOWS_SCHEMA = "chain-insights.flows.v1";
|
|
99
|
-
const GRAPH_EXPAND_TOOL = "graph_expand";
|
|
100
|
-
const FLOWS_NETWORK = "robinhood";
|
|
101
|
-
/** Where a graph_expand page may start on each side, at most. */
|
|
102
|
-
const FLOWS_MAX_OFFSET = 1e4;
|
|
103
|
-
const TRANSFERS_SCHEMA = "chain-insights.transfers.v1";
|
|
104
|
-
const INVALID_ADDRESS_TEXT = "invalid_address: a full 0x address of 40 hexadecimal characters is needed, for example 0x04911a118f11c75667e4d0dfb8e640af5a353550";
|
|
105
|
-
const OFFSET_TOO_LARGE_TEXT = `invalid_offset: in_offset and out_offset must each be at most ${FLOWS_MAX_OFFSET}`;
|
|
106
|
-
const OFFSET_NEGATIVE_TEXT = "invalid_offset: in_offset and out_offset must each be a whole number, 0 or more";
|
|
107
|
-
const BOTH_FORMS_TEXT = "invalid_arguments: pass address (with in_offset and out_offset) to expand a node, or from, to and day to list the transfers of a link, not both";
|
|
108
|
-
const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/;
|
|
109
|
-
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
110
|
-
const CODE_PREFIX = /^([a-z][a-z0-9_]*):\s*/;
|
|
111
|
-
/**
|
|
112
|
-
* The address lower-cased, or null when it is not 0x and 40 hexadecimal
|
|
113
|
-
* characters. Only an address that passes is ever written into a read, so the
|
|
114
|
-
* reads can carry it as a literal.
|
|
115
|
-
*/
|
|
116
|
-
function normalizeFlowsAddress(raw) {
|
|
117
|
-
if (typeof raw !== "string") return null;
|
|
118
|
-
const address = raw.trim().toLowerCase();
|
|
119
|
-
return ADDRESS_PATTERN.test(address) ? address : null;
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* The day as YYYY-MM-DD when it is a real calendar date, or null. Only a day
|
|
123
|
-
* that passes is ever written into a read.
|
|
124
|
-
*/
|
|
125
|
-
function normalizeFlowsDay(raw) {
|
|
126
|
-
if (typeof raw !== "string") return null;
|
|
127
|
-
const day = raw.trim();
|
|
128
|
-
if (!DAY_PATTERN.test(day)) return null;
|
|
129
|
-
const time = Date.parse(`${day}T00:00:00Z`);
|
|
130
|
-
if (Number.isNaN(time) || new Date(time).toISOString().slice(0, 10) !== day) return null;
|
|
131
|
-
return day;
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* The one facts read of a link: the transfers from one address to another on
|
|
135
|
-
* one day. A facts read names the pair with one day, takes no ORDER BY and
|
|
136
|
-
* holds at most 200 rows, so the read asks for the whole cap and the order is
|
|
137
|
-
* made in buildTransfersView. Both addresses and the day have passed their
|
|
138
|
-
* checks, so they are written into the read as literals.
|
|
139
|
-
*/
|
|
140
|
-
function transfersReadFor(from, to, day) {
|
|
141
|
-
return `USE facts MATCH (a:Address {address: "${from}"})-[t:TRANSFER]->(b:Address {address: "${to}"}) WHERE t.block_date = "${day}" RETURN t.tx_id AS tx_id, t.block_timestamp AS block_timestamp, t.amount AS amount, t.asset_symbol AS asset_symbol, t.amount_usd AS amount_usd LIMIT 200`;
|
|
142
|
-
}
|
|
143
|
-
const NODE_FIELDS = "b.address AS address, b.labels AS labels, b.is_exchange AS is_exchange, b.is_sanctioned AS is_sanctioned, b.is_scam AS is_scam, b.is_victim AS is_victim, b:Pool AS pool, b.total_in_usd AS total_in_usd, b.total_out_usd AS total_out_usd, b.degree_in AS degree_in, b.degree_out AS degree_out";
|
|
144
|
-
const PARTY_FIELDS = NODE_FIELDS + ", f.amount_usd_sum AS usd, f.tx_count AS tx_count, " + "f.first_seen_timestamp AS first_seen, f.last_seen_timestamp AS last_seen";
|
|
145
|
-
const PAGE_ORDER = " ORDER BY f.last_seen_timestamp DESC, b.address";
|
|
146
|
-
/**
|
|
147
|
-
* The three anchored reads of one page. Each side asks for one row more than
|
|
148
|
-
* it shows: the extra row says another page exists.
|
|
149
|
-
*/
|
|
150
|
-
function flowsReadsFor(address, inOffset, outOffset) {
|
|
151
|
-
const page = (offset) => `${PAGE_ORDER}${offset > 0 ? ` SKIP ${offset}` : ""} LIMIT 13`;
|
|
152
|
-
return [
|
|
153
|
-
{
|
|
154
|
-
id: "node",
|
|
155
|
-
query: `USE topology MATCH (b:Address {address: '${address}'}) RETURN ${NODE_FIELDS} LIMIT 1`
|
|
156
|
-
},
|
|
157
|
-
{
|
|
158
|
-
id: "out",
|
|
159
|
-
query: `USE topology MATCH (a:Address {address: '${address}'})-[f:FLOWS_TO]->(b:Address) RETURN ${PARTY_FIELDS}${page(outOffset)}`
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
id: "in",
|
|
163
|
-
query: `USE topology MATCH (b:Address)-[f:FLOWS_TO]->(a:Address {address: '${address}'}) RETURN ${PARTY_FIELDS}${page(inOffset)}`
|
|
164
|
-
}
|
|
165
|
-
];
|
|
166
|
-
}
|
|
167
|
-
/** The role word of an address, first match wins. */
|
|
168
|
-
function flowsRole(row) {
|
|
169
|
-
if (row.is_exchange === true) return "exchange";
|
|
170
|
-
if (row.is_sanctioned === true) return "sanctioned";
|
|
171
|
-
if (row.is_scam === true) return "scam";
|
|
172
|
-
if (row.is_victim === true) return "victim";
|
|
173
|
-
if (row.pool === true) return "pool";
|
|
174
|
-
return "other";
|
|
175
|
-
}
|
|
176
|
-
function flowsFloat(value) {
|
|
177
|
-
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
178
|
-
}
|
|
179
|
-
function flowsInt(value) {
|
|
180
|
-
return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 0;
|
|
181
|
-
}
|
|
182
|
-
function flowsNodeOf(row) {
|
|
183
|
-
const labels = Array.isArray(row.labels) ? row.labels.filter((label) => typeof label === "string") : [];
|
|
184
|
-
return {
|
|
185
|
-
address: typeof row.address === "string" ? row.address : "",
|
|
186
|
-
role: flowsRole(row),
|
|
187
|
-
labels,
|
|
188
|
-
total_in_usd: flowsFloat(row.total_in_usd),
|
|
189
|
-
total_out_usd: flowsFloat(row.total_out_usd),
|
|
190
|
-
degree_in: flowsInt(row.degree_in),
|
|
191
|
-
degree_out: flowsInt(row.degree_out)
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
function assembleFlowView(network, page, senders, receivers, truncated) {
|
|
195
|
-
const center = flowsNodeOf(page.center);
|
|
196
|
-
const view = {
|
|
197
|
-
schema: FLOWS_SCHEMA,
|
|
198
|
-
network,
|
|
199
|
-
center: center.address,
|
|
200
|
-
nodes: [center],
|
|
201
|
-
edges: [],
|
|
202
|
-
cursor: {
|
|
203
|
-
in_offset: page.inOffset + senders.length,
|
|
204
|
-
out_offset: page.outOffset + receivers.length
|
|
205
|
-
},
|
|
206
|
-
truncated
|
|
207
|
-
};
|
|
208
|
-
const seen = /* @__PURE__ */ new Set([center.address]);
|
|
209
|
-
const add = (row, incoming) => {
|
|
210
|
-
const node = flowsNodeOf(row);
|
|
211
|
-
if (!seen.has(node.address)) {
|
|
212
|
-
seen.add(node.address);
|
|
213
|
-
view.nodes.push(node);
|
|
214
|
-
}
|
|
215
|
-
view.edges.push({
|
|
216
|
-
from: incoming ? node.address : center.address,
|
|
217
|
-
to: incoming ? center.address : node.address,
|
|
218
|
-
usd: flowsFloat(row.usd),
|
|
219
|
-
tx_count: flowsInt(row.tx_count),
|
|
220
|
-
first_seen_ms: flowsInt(row.first_seen),
|
|
221
|
-
last_seen_ms: flowsInt(row.last_seen)
|
|
222
|
-
});
|
|
223
|
-
};
|
|
224
|
-
for (const row of senders) add(row, true);
|
|
225
|
-
for (const row of receivers) add(row, false);
|
|
226
|
-
return view;
|
|
227
|
-
}
|
|
228
|
-
function fitsFlowBudget(view) {
|
|
229
|
-
return view.nodes.length <= 60 && JSON.stringify(view).length < 4e4;
|
|
230
|
-
}
|
|
231
|
-
/**
|
|
232
|
-
* flows.v1 from one page: the centre first, then the senders and the
|
|
233
|
-
* receivers, each newest first, at most FLOWS_PER_SIDE a side. When the view
|
|
234
|
-
* data would hold more than FLOWS_MAX_NODES addresses or reach FLOWS_MAX_CHARS
|
|
235
|
-
* characters, the oldest links go first, the view is marked truncated, and the
|
|
236
|
-
* cursor still says where the next page starts.
|
|
67
|
+
* The routing rule and its three lines, with the question that no layer serves,
|
|
68
|
+
* as one paragraph. The proxy serves it first among its instructions: a host
|
|
69
|
+
* that keeps only the start of the instructions (Claude Code keeps 2 KB) still
|
|
70
|
+
* shows the agent where each kind of question goes, and which kind goes nowhere.
|
|
237
71
|
*/
|
|
238
|
-
function
|
|
239
|
-
|
|
240
|
-
let senders = page.senders.slice(0, 12);
|
|
241
|
-
let receivers = page.receivers.slice(0, 12);
|
|
242
|
-
for (;;) {
|
|
243
|
-
const view = assembleFlowView(network, page, senders, receivers, truncated);
|
|
244
|
-
if (fitsFlowBudget(view) || senders.length + receivers.length === 0) return view;
|
|
245
|
-
truncated = true;
|
|
246
|
-
if (senders.length >= receivers.length) senders = senders.slice(0, -1);
|
|
247
|
-
else receivers = receivers.slice(0, -1);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
function formatFlowsUsd(value) {
|
|
251
|
-
if (!(value > 0)) return "$0";
|
|
252
|
-
if (value < .01) return "under $0.01";
|
|
253
|
-
const whole = Math.floor(value);
|
|
254
|
-
const cents = Math.round((value - whole) * 100);
|
|
255
|
-
if (cents === 100) return formatFlowsUsd(whole + 1);
|
|
256
|
-
return `$${whole.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${String(cents).padStart(2, "0")}`;
|
|
257
|
-
}
|
|
258
|
-
function formatFlowsAge(lastSeenMs, now) {
|
|
259
|
-
if (lastSeenMs <= 0) return "at an unknown time";
|
|
260
|
-
const date = new Date(lastSeenMs).toISOString().slice(0, 10);
|
|
261
|
-
const days = Math.trunc((now.getTime() - lastSeenMs) / 864e5);
|
|
262
|
-
if (days < 1) return `${date} (less than a day ago)`;
|
|
263
|
-
if (days === 1) return `${date} (1 day ago)`;
|
|
264
|
-
return `${date} (${days} days ago)`;
|
|
265
|
-
}
|
|
266
|
-
/** The role word, with up to three address labels beside it. */
|
|
267
|
-
function roleText(node) {
|
|
268
|
-
if (!node) return "other";
|
|
269
|
-
if (node.labels.length === 0) return node.role;
|
|
270
|
-
return `${node.role} (${node.labels.slice(0, 3).join(", ")})`;
|
|
271
|
-
}
|
|
272
|
-
function sideLines(title, parties) {
|
|
273
|
-
if (parties.length === 0) return [`${title}, newest first: none.`];
|
|
274
|
-
const lines = [`${title}, newest first (${parties.length}):`];
|
|
275
|
-
const perLine = parties.length > 6 ? 2 : 1;
|
|
276
|
-
for (let start = 0; start < parties.length; start += perLine) lines.push(`- ${parties.slice(start, start + perLine).join("; ")}`);
|
|
277
|
-
return lines;
|
|
72
|
+
function routingHead() {
|
|
73
|
+
return `Pick the layer first. ${ROUTING_RULE} 1. ${ROUTING_LINES[0]} 2. ${ROUTING_LINES[1]} 3. ${ROUTING_LINES[2]} ${ROUTING_NOT_SERVED}`;
|
|
278
74
|
}
|
|
279
75
|
/**
|
|
280
|
-
* The
|
|
281
|
-
*
|
|
282
|
-
* date and age. At most FLOWS_SUMMARY_MAX_LINES lines.
|
|
283
|
-
*/
|
|
284
|
-
function flowsSummary(view, now) {
|
|
285
|
-
const nodes = new Map(view.nodes.map((node) => [node.address, node]));
|
|
286
|
-
const center = nodes.get(view.center);
|
|
287
|
-
const party = (address, edge) => `${address} ${roleText(nodes.get(address))}, ${formatFlowsUsd(edge.usd)} in ${edge.tx_count} tx, last seen ${formatFlowsAge(edge.last_seen_ms, now)}`;
|
|
288
|
-
const senders = [];
|
|
289
|
-
const receivers = [];
|
|
290
|
-
for (const edge of view.edges) if (edge.to === view.center) senders.push(party(edge.from, edge));
|
|
291
|
-
else receivers.push(party(edge.to, edge));
|
|
292
|
-
const body = [
|
|
293
|
-
`Money flows of ${view.center} on ${view.network}: ${roleText(center)}; lifetime ${formatFlowsUsd(center?.total_in_usd ?? 0)} in, ${formatFlowsUsd(center?.total_out_usd ?? 0)} out; ${center?.degree_in ?? 0} senders and ${center?.degree_out ?? 0} receivers in the topology graph.`,
|
|
294
|
-
...sideLines("Recent senders", senders),
|
|
295
|
-
...sideLines("Recent receivers", receivers)
|
|
296
|
-
].slice(0, 18);
|
|
297
|
-
const footer = pagingLines(view, senders.length, receivers.length, center);
|
|
298
|
-
return [...body, ...footer].join("\n");
|
|
299
|
-
}
|
|
300
|
-
/** Where this page sits, and that the picture loads the rest when an address is clicked. */
|
|
301
|
-
function pagingLines(view, shownSenders, shownReceivers, center) {
|
|
302
|
-
const totalIn = center?.degree_in ?? 0;
|
|
303
|
-
const totalOut = center?.degree_out ?? 0;
|
|
304
|
-
const { in_offset: nextIn, out_offset: nextOut } = view.cursor;
|
|
305
|
-
const range = (shown, next, total) => shown === 0 ? `none of ${total}` : `${next - shown + 1} to ${next} of ${total}`;
|
|
306
|
-
const where = `Showing senders ${range(shownSenders, nextIn, totalIn)} and receivers ${range(shownReceivers, nextOut, totalOut)}, newest first.`;
|
|
307
|
-
if (!view.truncated && nextIn >= totalIn && nextOut >= totalOut) return [where];
|
|
308
|
-
return [where, "More senders and receivers load when an address in the picture is clicked."];
|
|
309
|
-
}
|
|
310
|
-
/** A finite number, or a decimal string read as one, or null. */
|
|
311
|
-
function flowsNumberOrNull(value) {
|
|
312
|
-
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
313
|
-
if (typeof value === "string" && value.trim() !== "") {
|
|
314
|
-
const parsed = Number(value);
|
|
315
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
316
|
-
}
|
|
317
|
-
return null;
|
|
318
|
-
}
|
|
319
|
-
function transferOf(row) {
|
|
320
|
-
const amount = typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : typeof row.amount === "string" && row.amount.trim() !== "" ? row.amount : null;
|
|
321
|
-
return {
|
|
322
|
-
tx_id: typeof row.tx_id === "string" ? row.tx_id : "",
|
|
323
|
-
block_timestamp: flowsNumberOrNull(row.block_timestamp),
|
|
324
|
-
amount,
|
|
325
|
-
asset_symbol: typeof row.asset_symbol === "string" && row.asset_symbol !== "" ? row.asset_symbol : null,
|
|
326
|
-
amount_usd: flowsNumberOrNull(row.amount_usd)
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
/**
|
|
330
|
-
* chain-insights.transfers.v1 from the rows of one facts read: newest first,
|
|
331
|
-
* at most TRANSFERS_MAX, truncated when more rows than that came back. The
|
|
332
|
-
* facts layer takes no ORDER BY, so the order is made here.
|
|
333
|
-
*/
|
|
334
|
-
function buildTransfersView(network, from, to, day, rows) {
|
|
335
|
-
const newestFirst = rows.map(transferOf).sort((a, b) => (b.block_timestamp ?? -Infinity) - (a.block_timestamp ?? -Infinity));
|
|
336
|
-
return {
|
|
337
|
-
schema: TRANSFERS_SCHEMA,
|
|
338
|
-
network,
|
|
339
|
-
from,
|
|
340
|
-
to,
|
|
341
|
-
day,
|
|
342
|
-
transfers: newestFirst.slice(0, 50),
|
|
343
|
-
truncated: newestFirst.length > 50
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
function transferLine(transfer) {
|
|
347
|
-
return `- ${transfer.block_timestamp === null ? "at an unknown time" : `${new Date(transfer.block_timestamp).toISOString().slice(0, 19).replace("T", " ")} UTC`}, ${transfer.amount === null ? "an unknown amount" : String(transfer.amount)}${transfer.asset_symbol ? ` ${transfer.asset_symbol}` : ""}, ${transfer.amount_usd === null ? "no USD price" : formatFlowsUsd(transfer.amount_usd)}, tx ${transfer.tx_id}`;
|
|
348
|
-
}
|
|
349
|
-
/** The short text of a link answer: the pair, the day, the count and the newest few transfers. */
|
|
350
|
-
function transfersSummary(view) {
|
|
351
|
-
const head = `Transfers from ${view.from} to ${view.to} on ${view.day} (UTC) on ${view.network}`;
|
|
352
|
-
if (view.transfers.length === 0) return `${head}: none.`;
|
|
353
|
-
const lines = [`${head}: ${view.truncated ? `the newest ${view.transfers.length}, and the day holds more` : `${view.transfers.length}`}, newest first.`, ...view.transfers.slice(0, 5).map(transferLine)];
|
|
354
|
-
const rest = view.transfers.length - 5;
|
|
355
|
-
if (rest > 0) lines.push(`- and ${rest} more in the picture.`);
|
|
356
|
-
return lines.join("\n");
|
|
357
|
-
}
|
|
358
|
-
function errorResult(text) {
|
|
359
|
-
return {
|
|
360
|
-
content: [{
|
|
361
|
-
type: "text",
|
|
362
|
-
text
|
|
363
|
-
}],
|
|
364
|
-
isError: true
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
function isRecord$1(value) {
|
|
368
|
-
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
369
|
-
}
|
|
370
|
-
function answerText(answer) {
|
|
371
|
-
return (answer.content ?? []).map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").filter(Boolean).join("\n").trim();
|
|
372
|
-
}
|
|
373
|
-
/**
|
|
374
|
-
* The refusal of a read that did not answer, naming its code first:
|
|
375
|
-
* error_detail.code when the graph endpoint sent its typed refusal, the code
|
|
376
|
-
* its text starts with otherwise, and graph_query_failed when it names none.
|
|
377
|
-
* The view marks the address it was expanding.
|
|
378
|
-
*/
|
|
379
|
-
function refusalText(answer) {
|
|
380
|
-
const text = answerText(answer);
|
|
381
|
-
const detail = isRecord$1(answer.structuredContent) ? answer.structuredContent.error_detail : void 0;
|
|
382
|
-
const detailCode = isRecord$1(detail) && typeof detail.code === "string" ? detail.code : "";
|
|
383
|
-
const textCode = CODE_PREFIX.exec(text)?.[1] ?? "";
|
|
384
|
-
const code = detailCode || textCode || "graph_query_failed";
|
|
385
|
-
const rest = textCode === code ? text.replace(CODE_PREFIX, "") : text;
|
|
386
|
-
return rest ? `${code}: ${rest}` : `${code}: the graph endpoint refused the read`;
|
|
387
|
-
}
|
|
388
|
-
function rowsOf(answer) {
|
|
389
|
-
const facts = isRecord$1(answer.structuredContent) ? answer.structuredContent.facts : void 0;
|
|
390
|
-
const query = isRecord$1(facts) ? facts.query : void 0;
|
|
391
|
-
const results = isRecord$1(query) ? query.results : void 0;
|
|
392
|
-
return Array.isArray(results) ? results.filter(isRecord$1) : null;
|
|
393
|
-
}
|
|
394
|
-
function checkNetwork(args) {
|
|
395
|
-
const rawNetwork = typeof args.network === "string" ? args.network.trim().toLowerCase() : "";
|
|
396
|
-
if (!rawNetwork) return { error: "invalid_network: network is required; pass robinhood" };
|
|
397
|
-
if (rawNetwork !== "robinhood") return { error: `invalid_network: graph_expand reads the robinhood graph only; got "${String(args.network).trim()}"` };
|
|
398
|
-
return FLOWS_NETWORK;
|
|
399
|
-
}
|
|
400
|
-
/** The address of a node or of one end of a link; `name` says which end in a link refusal. */
|
|
401
|
-
function checkAddress(raw, name) {
|
|
402
|
-
const address = normalizeFlowsAddress(raw);
|
|
403
|
-
if (address) return address;
|
|
404
|
-
return { error: name ? INVALID_ADDRESS_TEXT.replace("invalid_address:", `invalid_address (${name}):`) : INVALID_ADDRESS_TEXT };
|
|
405
|
-
}
|
|
406
|
-
function checkDay(raw) {
|
|
407
|
-
return normalizeFlowsDay(raw) ?? { error: "invalid_day: day must be a calendar date written YYYY-MM-DD (UTC), for example 2026-07-10" };
|
|
408
|
-
}
|
|
409
|
-
function checkOffset(value) {
|
|
410
|
-
if (value === void 0 || value === null) return 0;
|
|
411
|
-
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return { error: OFFSET_NEGATIVE_TEXT };
|
|
412
|
-
if (value > 1e4) return { error: OFFSET_TOO_LARGE_TEXT };
|
|
413
|
-
return value;
|
|
414
|
-
}
|
|
415
|
-
/**
|
|
416
|
-
* Read and answer one node page. The reads run one after another: the node
|
|
417
|
-
* first, so an address the graph does not hold costs one read, not three; then
|
|
418
|
-
* the receivers and the senders. The first read that does not answer ends the
|
|
419
|
-
* call.
|
|
420
|
-
*/
|
|
421
|
-
async function runNode(deps, network, address, inOffset, outOffset) {
|
|
422
|
-
const unavailable = deps.unavailable?.();
|
|
423
|
-
if (unavailable) return errorResult(unavailable);
|
|
424
|
-
const rows = {};
|
|
425
|
-
for (const read of flowsReadsFor(address, inOffset, outOffset)) {
|
|
426
|
-
let answer;
|
|
427
|
-
try {
|
|
428
|
-
answer = await deps.graphQuery({
|
|
429
|
-
network,
|
|
430
|
-
query: read.query
|
|
431
|
-
});
|
|
432
|
-
} catch (err) {
|
|
433
|
-
return errorResult(deps.describeFailure(err));
|
|
434
|
-
}
|
|
435
|
-
if (answer.isError === true) return errorResult(refusalText(answer));
|
|
436
|
-
const found = rowsOf(answer);
|
|
437
|
-
if (!found) return errorResult("graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows");
|
|
438
|
-
rows[read.id] = found;
|
|
439
|
-
if (read.id === "node" && found.length === 0) return errorResult(`${address} is not in the ${network} topology graph`);
|
|
440
|
-
}
|
|
441
|
-
const view = buildFlowView(network, {
|
|
442
|
-
center: rows.node?.[0] ?? {},
|
|
443
|
-
senders: rows.in ?? [],
|
|
444
|
-
receivers: rows.out ?? [],
|
|
445
|
-
inOffset,
|
|
446
|
-
outOffset
|
|
447
|
-
});
|
|
448
|
-
return {
|
|
449
|
-
content: [{
|
|
450
|
-
type: "text",
|
|
451
|
-
text: flowsSummary(view, deps.now ? deps.now() : /* @__PURE__ */ new Date())
|
|
452
|
-
}],
|
|
453
|
-
structuredContent: view,
|
|
454
|
-
isError: false
|
|
455
|
-
};
|
|
456
|
-
}
|
|
457
|
-
/** Read and answer the transfers of one link on one day: a single facts read. */
|
|
458
|
-
async function runLink(deps, network, from, to, day) {
|
|
459
|
-
const unavailable = deps.unavailable?.();
|
|
460
|
-
if (unavailable) return errorResult(unavailable);
|
|
461
|
-
let answer;
|
|
462
|
-
try {
|
|
463
|
-
answer = await deps.graphQuery({
|
|
464
|
-
network,
|
|
465
|
-
query: transfersReadFor(from, to, day)
|
|
466
|
-
});
|
|
467
|
-
} catch (err) {
|
|
468
|
-
return errorResult(deps.describeFailure(err));
|
|
469
|
-
}
|
|
470
|
-
if (answer.isError === true) return errorResult(refusalText(answer));
|
|
471
|
-
const rows = rowsOf(answer);
|
|
472
|
-
if (!rows) return errorResult("graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows");
|
|
473
|
-
const view = buildTransfersView(network, from, to, day, rows);
|
|
474
|
-
return {
|
|
475
|
-
content: [{
|
|
476
|
-
type: "text",
|
|
477
|
-
text: transfersSummary(view)
|
|
478
|
-
}],
|
|
479
|
-
structuredContent: view,
|
|
480
|
-
isError: false
|
|
481
|
-
};
|
|
482
|
-
}
|
|
483
|
-
function given(record, keys) {
|
|
484
|
-
return keys.some((key) => record[key] !== void 0 && record[key] !== null);
|
|
485
|
-
}
|
|
486
|
-
/**
|
|
487
|
-
* graph_expand, the node form {network, address, in_offset?, out_offset?} or the
|
|
488
|
-
* link form {network, from, to, day}. Every argument is checked before the
|
|
489
|
-
* first read.
|
|
76
|
+
* The two hints that the MCP proxy serves for routing and for refusals. Each is
|
|
77
|
+
* one line of the served instructions.
|
|
490
78
|
*/
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const network = checkNetwork(record);
|
|
494
|
-
if (typeof network !== "string") return errorResult(network.error);
|
|
495
|
-
const isLink = given(record, [
|
|
496
|
-
"from",
|
|
497
|
-
"to",
|
|
498
|
-
"day"
|
|
499
|
-
]);
|
|
500
|
-
if (isLink && given(record, [
|
|
501
|
-
"address",
|
|
502
|
-
"in_offset",
|
|
503
|
-
"out_offset"
|
|
504
|
-
])) return errorResult(BOTH_FORMS_TEXT);
|
|
505
|
-
if (isLink) {
|
|
506
|
-
const from = checkAddress(record.from, "from");
|
|
507
|
-
if (typeof from !== "string") return errorResult(from.error);
|
|
508
|
-
const to = checkAddress(record.to, "to");
|
|
509
|
-
if (typeof to !== "string") return errorResult(to.error);
|
|
510
|
-
const day = checkDay(record.day);
|
|
511
|
-
if (typeof day !== "string") return errorResult(day.error);
|
|
512
|
-
return runLink(deps, network, from, to, day);
|
|
513
|
-
}
|
|
514
|
-
const address = checkAddress(record.address);
|
|
515
|
-
if (typeof address !== "string") return errorResult(address.error);
|
|
516
|
-
const inOffset = checkOffset(record.in_offset);
|
|
517
|
-
if (typeof inOffset !== "number") return errorResult(inOffset.error);
|
|
518
|
-
const outOffset = checkOffset(record.out_offset);
|
|
519
|
-
if (typeof outOffset !== "number") return errorResult(outOffset.error);
|
|
520
|
-
return runNode(deps, network, address, inOffset, outOffset);
|
|
79
|
+
function routingHintLines() {
|
|
80
|
+
return [`- Pick the layer first. ${ROUTING_RULE} 1. ${ROUTING_LINES[0]} 2. ${ROUTING_LINES[1]} 3. ${ROUTING_LINES[2]} ${ROUTING_NOT_SERVED} ${ROUTING_ADDRESS_ON_CHAIN} ${ROUTING_TWO_FIT} ${ROUTING_HANDOFF} The lookups that USE chain serves are listed in chain_admission.lookups of meta_network_capabilities. Read every limit that the server publishes from meta_network_capabilities (chain_admission and, when the server sends them, topology_admission and facts_admission). Never write a limit down.`, `- Act on a refusal by its class. ${moveLines().join(" ")}`];
|
|
521
81
|
}
|
|
522
|
-
const GRAPH_EXPAND_TITLE = "Expand a node or a link in the picture";
|
|
523
|
-
const GRAPH_EXPAND_DESCRIPTION = "Expand an open Chain Insights picture. Called by the view when the investigator clicks, never by the model. A node, {network, address, in_offset?, out_offset?}, loads the newest senders and receivers of one address (12 a side, from the offsets); billed as three graph_query topology reads. A link, {network, from, to, day}, lists the transfers between two addresses on one UTC day (YYYY-MM-DD), newest first, at most 50; billed as one graph_query facts read. Read-only; an argument refused before a read costs nothing.";
|
|
524
82
|
//#endregion
|
|
525
83
|
//#region src/mcp/proxy.ts
|
|
526
84
|
const LOCAL_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
527
85
|
"meta_network_capabilities",
|
|
86
|
+
"meta_schema",
|
|
528
87
|
"meta_usage_status",
|
|
529
88
|
"meta_subscription_status",
|
|
530
89
|
"meta_help",
|
|
@@ -577,14 +136,15 @@ function resolveMcpProxyMode(env = process.env) {
|
|
|
577
136
|
if (raw === "no-workspace" || raw === "workspace-less") return "stateless";
|
|
578
137
|
throw new Error(`CHAIN_INSIGHTS_MCP_PROXY_MODE must be workspace or stateless; got "${raw}"`);
|
|
579
138
|
}
|
|
580
|
-
const GRAPH_LAYERS_TEXT = "Use USE topology for topology (address/FLOWS_TO/OPERATED_BY/LINKED graph with SWAPPED, ADDED_LIQUIDITY, REMOVED_LIQUIDITY, BRIDGED and the Pool label, unified recent+historical) and USE facts for bounded TRANSFER, SWAP, LIQUIDITY_ADD, LIQUIDITY_REMOVE and BRIDGE_CROSSING rows and enrichment, and USE chain for the chain node's own record of one known transaction, block or the head.";
|
|
139
|
+
const GRAPH_LAYERS_TEXT = "Use USE topology for topology (address/FLOWS_TO/OPERATED_BY/LINKED graph with SWAPPED, ADDED_LIQUIDITY, REMOVED_LIQUIDITY, BRIDGED and the Pool label, unified recent+historical) and USE facts for bounded TRANSFER, SWAP, LIQUIDITY_ADD, LIQUIDITY_REMOVE and BRIDGE_CROSSING rows and enrichment, and USE chain for the chain node's own record of one known transaction, block, address or the head.";
|
|
581
140
|
const KNOWN_PUBLIC_TOOL_DESCRIPTIONS = {
|
|
582
141
|
meta_network_capabilities: "Return the current Chain Insights network and tool support matrix.",
|
|
142
|
+
meta_schema: META_SCHEMA_DESCRIPTION,
|
|
583
143
|
meta_usage_status: "Return the caller's public free graph_query quota for the current UTC day.",
|
|
584
144
|
meta_subscription_status: "Return the caller's CIA subscription window end, daily allowance, consumption, and tier.",
|
|
585
145
|
meta_help: "Show a short guide to Chain Insights tools and workflow.",
|
|
586
146
|
wallet_balance: "Show the local Chain Insights payment wallet address, payment network, token, and amount.",
|
|
587
|
-
graph_query: `Run a read-only GQL/Cypher query through the Chain Insights graph endpoint. ${GRAPH_LAYERS_TEXT} Preserve full addresses exactly.`,
|
|
147
|
+
graph_query: `Run a read-only GQL/Cypher query through the Chain Insights graph endpoint. ${GRAPH_LAYERS_TEXT} ${ROUTING_NOT_SERVED} Preserve full addresses exactly.`,
|
|
588
148
|
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."
|
|
589
149
|
};
|
|
590
150
|
const KNOWN_PUBLIC_TOOL_TITLES = {
|
|
@@ -613,13 +173,14 @@ const GRAPH_SCHEMA_HINTS = [
|
|
|
613
173
|
"- Call meta_network_capabilities first. Pass network= exactly as GraphRAG advertised it. CIA does not pick a default network.",
|
|
614
174
|
...routingHintLines(),
|
|
615
175
|
"- Coverage is per kind of data. coverage.complete_through_block is the floor of USE facts: below it every raw relationship is indexed. graph_progress.complete_through_block is the floor of USE topology, and graph_progress.layers gives each link kind its own height. An empty answer above the floor may mean not indexed yet, not absent on chain. An absent chain_tip_block means the indexers are catching up: read freshness.max_data_age_seconds for the lag.",
|
|
616
|
-
"- 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.
|
|
176
|
+
"- 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. There is no separate identity key. network is the query's network on every node and relationship, never stored, except on :Chain, where it names the remote chain.",
|
|
177
|
+
"- An Address carries a kind from chain facts. topology carries :Account or :Contract as a second label on an Address (:Contract:SmartAccount for a contract wallet), with is_contract kept beside :Contract, and an address that only received has none. facts serves no kind, and a facts read that names :Account or :Contract is refused: ask USE topology or USE chain. chain Address returns is_contract, nonce and delegated_to at the newest block, or at the block named by at_block. block_timestamp is epoch milliseconds on every layer.",
|
|
617
178
|
"- 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 LIMIT 25.",
|
|
618
179
|
"- 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.",
|
|
619
180
|
"- Address nodes carry 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 all three, so the rows of a pair are the transfers its link counts, up to the height the link was built to. A link covers all time and a USE facts read covers one day: read the transfers of a pair one day at a time. A transaction anchor of a pair 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.",
|
|
620
181
|
"- 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) LIMIT 50.",
|
|
621
182
|
"- Labels and per-label risk 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. No overall risk score is served. 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.",
|
|
622
|
-
"- (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, kind, price_usd, and price_missing. Every TRANSFER row has a kind: token, native or internal. An internal row is ETH a contract sends while it runs a call, such as the ETH leg of a wrap. Add t.kind = \"internal\" to the pair and the day to read only the internal rows. A facts read names an address pair with one day,
|
|
183
|
+
"- (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, kind, price_usd, and price_missing. Every TRANSFER row has a kind: token, native or internal. An internal row is ETH a contract sends while it runs a call, such as the ETH leg of a wrap. Add t.kind = \"internal\" to the pair and the day to read only the internal rows. A facts read names an address pair with one day, 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\". A transaction is read on USE chain by its tx_id, not on facts. 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. A facts read holds at most 200 rows in a reply, has one relationship and no hop, and takes no ORDER BY: sort the page yourself.",
|
|
623
184
|
"- 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.",
|
|
624
185
|
"- 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.",
|
|
625
186
|
"- (: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.",
|
|
@@ -628,20 +189,23 @@ const GRAPH_SCHEMA_HINTS = [
|
|
|
628
189
|
"- 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.",
|
|
629
190
|
"- (: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.",
|
|
630
191
|
"- 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.",
|
|
631
|
-
"- 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 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
|
|
192
|
+
"- 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 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. 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.",
|
|
632
193
|
"- A USE facts SWAP row carries no route: it has no pools and no families column. A read that names one of them, to return it, to filter on it or to order by it, is refused. The route of a swap stays a topology question. For the pools of the swaps of an address, read SWAPPED.pools and SWAPPED.families on USE topology, anchored on the payer or the recipient. SWAPPED has one link per payer, recipient, sold asset and bought asset, so its pools cover every route on the link, not one route.",
|
|
633
194
|
"- Traversal rule: for BFS, fixed-hop fallback, shortest-path, or manual FLOWS_TO traversal, exchange hot wallets are terminal endpoints only. Do not expand from, through, or classify exchange nodes as deposit, suspect, or intermediate candidates; filter every non-terminal node with is_exchange IS NULL. is_exchange is absent unless true, so a labelled node with no is_exchange is walked through, and is_scam, is_victim and is_sanctioned do not end a walk. At a Pool, follow the pool trace rule above.",
|
|
634
195
|
"- 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.",
|
|
196
|
+
"- Call meta_schema {network} first when you need field names; it is cached for 24 hours. The two discovery reads below are the fallback.",
|
|
635
197
|
"- Start schema discovery with endpoint-safe property reads: MATCH (n:Address) RETURN n.address AS address, n.network AS network, n.labels AS labels, n.last_activity_timestamp AS last_activity_timestamp LIMIT 20",
|
|
636
198
|
"- Relationship discovery: MATCH (:Address)-[r:FLOWS_TO]->(:Address) RETURN r.amount_usd_sum AS amount_usd_sum, r.tx_count AS tx_count LIMIT 20",
|
|
637
199
|
"- 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.",
|
|
638
|
-
"- graph_query uses the active Chain Insights graph endpoint. Select the graph with USE topology for topology (address/FLOWS_TO/OPERATED_BY/LINKED graph with SWAPPED, ADDED_LIQUIDITY, REMOVED_LIQUIDITY, BRIDGED and the Pool label, unified recent+historical) and USE facts for bounded TRANSFER, SWAP, LIQUIDITY_ADD, LIQUIDITY_REMOVE and BRIDGE_CROSSING rows and enrichment, and USE chain for one keyed lookup on the chain node: a transaction by
|
|
200
|
+
"- graph_query uses the active Chain Insights graph endpoint. Select the graph with USE topology for topology (address/FLOWS_TO/OPERATED_BY/LINKED graph with SWAPPED, ADDED_LIQUIDITY, REMOVED_LIQUIDITY, BRIDGED and the Pool label, unified recent+historical) and USE facts for bounded TRANSFER, SWAP, LIQUIDITY_ADD, LIQUIDITY_REMOVE and BRIDGE_CROSSING rows and enrichment, and USE chain for one keyed lookup on the chain node: a transaction by tx_id, a block by block_height or block_hash, an address at one block, or the head. On topology, address is the node grain, not the topology name.",
|
|
639
201
|
"- All graph_query calls are read-only. Never use CREATE, INSERT, MERGE, SET, DELETE, REMOVE, DROP, DETACH, ADD, CONNECT, DISCONNECT, ALTER, TRUNCATE, GRANT, or REVOKE.",
|
|
640
202
|
"- Use USE facts graph patterns for fact and enrichment reads. Do not query internal table namespaces directly."
|
|
641
203
|
].join("\n");
|
|
204
|
+
const ROUTING_HEAD = routingHead();
|
|
642
205
|
const SERVER_INSTRUCTIONS = [
|
|
643
206
|
"Chain Insights is an AML and graph-analysis MCP server for AI agents.",
|
|
644
207
|
CHAIN_INSIGHTS_WORKFLOW,
|
|
208
|
+
ROUTING_HEAD,
|
|
645
209
|
PICTURE_RULES,
|
|
646
210
|
GRAPH_SCHEMA_HINTS,
|
|
647
211
|
"Presentation rules: preserve tool summaries as returned; never truncate blockchain addresses or identity_resolution audit mappings."
|
|
@@ -649,6 +213,7 @@ const SERVER_INSTRUCTIONS = [
|
|
|
649
213
|
const STATELESS_SERVER_INSTRUCTIONS = [
|
|
650
214
|
"Chain Insights is running as a stateless AML proxy for a host application.",
|
|
651
215
|
"Use meta_network_capabilities first when network support is unknown, then call graph_query or graph_query_batch as needed.",
|
|
216
|
+
ROUTING_HEAD,
|
|
652
217
|
PICTURE_RULES,
|
|
653
218
|
"Use wallet_balance to inspect the local payment wallet when payment setup is needed.",
|
|
654
219
|
GRAPH_SCHEMA_HINTS,
|
|
@@ -926,6 +491,11 @@ function registerLocalPrompts(server) {
|
|
|
926
491
|
description: "Inspect supported networks and available tools before selecting a network.",
|
|
927
492
|
argsSchema: {}
|
|
928
493
|
}, async () => promptResult("Use Chain Insights meta_network_capabilities. Report only the supported networks and available tools exactly as returned; do not infer unsupported networks.", "Network capabilities"));
|
|
494
|
+
server.registerPrompt("meta-schema", {
|
|
495
|
+
title: "Graph Schema",
|
|
496
|
+
description: "Read the live graph schema of one network before writing a query.",
|
|
497
|
+
argsSchema: { network: NETWORK_SCHEMA }
|
|
498
|
+
}, async ({ network }) => promptResult(`Use Chain Insights meta_schema with network ${network}. Report the labels, link types and fields exactly as returned; do not invent fields.`, "Graph schema"));
|
|
929
499
|
server.registerPrompt("meta-usage-status", {
|
|
930
500
|
title: "Usage Status",
|
|
931
501
|
description: "Check the caller's public free graph_query quota.",
|
|
@@ -1050,7 +620,7 @@ async function createProxy() {
|
|
|
1050
620
|
const { loadConfig } = await import("./config-9MabJh5R.mjs").then((n) => n.t);
|
|
1051
621
|
const { activeDataDir, findActiveWorkspace } = await import("./active-Dkf8AB1R.mjs").then((n) => n.t);
|
|
1052
622
|
const { createConfiguredGraphMcpFetch, resolveGraphMcpEndpoint } = await import("./client-gsLi528K.mjs").then((n) => n.r);
|
|
1053
|
-
const { loadSchema, saveSchema } = await import("./schema-cache-
|
|
623
|
+
const { loadSchema, saveSchema } = await import("./schema-cache-BT-kHy_r.mjs");
|
|
1054
624
|
const proxyMode = resolveMcpProxyMode();
|
|
1055
625
|
const workspaceArtifactsEnabled = proxyMode === "workspace";
|
|
1056
626
|
const loadedConfig = await loadConfig();
|
|
@@ -1179,6 +749,28 @@ async function createProxy() {
|
|
|
1179
749
|
}
|
|
1180
750
|
return jsonTextResult(cleanNetworkCapabilities(void 0));
|
|
1181
751
|
});
|
|
752
|
+
const schemaDependencies = {
|
|
753
|
+
endpoint: graphMcpEndpoint,
|
|
754
|
+
withClient: async (fn) => {
|
|
755
|
+
if (!remoteConnected) throw new GraphSchemaError(`${remoteUnavailableMessage ?? `Chain Insights Graph is not connected at ${graphMcpEndpoint}`}. Restart the Chain Insights MCP proxy after the endpoint is reachable.`);
|
|
756
|
+
return fn(remoteClient);
|
|
757
|
+
},
|
|
758
|
+
describeFailure: (err) => remoteCallFailureText("meta_schema", err)
|
|
759
|
+
};
|
|
760
|
+
server.registerTool("meta_schema", {
|
|
761
|
+
title: META_SCHEMA_TITLE,
|
|
762
|
+
description: KNOWN_PUBLIC_TOOL_DESCRIPTIONS.meta_schema,
|
|
763
|
+
inputSchema: {
|
|
764
|
+
network: NETWORK_SCHEMA,
|
|
765
|
+
refresh: z.boolean().optional().describe("Rebuild the schema now instead of reading the 24 hour cache.")
|
|
766
|
+
},
|
|
767
|
+
annotations: {
|
|
768
|
+
readOnlyHint: true,
|
|
769
|
+
destructiveHint: false,
|
|
770
|
+
idempotentHint: true,
|
|
771
|
+
openWorldHint: true
|
|
772
|
+
}
|
|
773
|
+
}, async (args) => handleMetaSchema(args, schemaDependencies));
|
|
1182
774
|
server.registerTool("meta_usage_status", {
|
|
1183
775
|
title: "Usage Status",
|
|
1184
776
|
description: KNOWN_PUBLIC_TOOL_DESCRIPTIONS.meta_usage_status,
|
|
@@ -1348,6 +940,7 @@ async function createProxy() {
|
|
|
1348
940
|
"",
|
|
1349
941
|
"Investigation tools:",
|
|
1350
942
|
"- meta_network_capabilities: inspect supported networks and available tools.",
|
|
943
|
+
"- meta_schema: read the live graph schema of one network: labels, link types and field names. Cached for 24 hours.",
|
|
1351
944
|
"- meta_usage_status: check the caller public free graph_query quota.",
|
|
1352
945
|
"- meta_subscription_status: check the caller CIA subscription window end, daily allowance, consumption, and tier.",
|
|
1353
946
|
"- graph_query: run read-only GQL/Cypher through the universal graph endpoint. Use USE topology or USE facts.",
|
|
@@ -1363,6 +956,7 @@ async function createProxy() {
|
|
|
1363
956
|
"",
|
|
1364
957
|
"Available graph-backed tools:",
|
|
1365
958
|
"- meta_network_capabilities: inspect supported networks and available tools.",
|
|
959
|
+
"- meta_schema: read the live graph schema of one network: labels, link types and field names. Cached for 24 hours.",
|
|
1366
960
|
"- meta_usage_status: check the caller public free graph_query quota.",
|
|
1367
961
|
"- meta_subscription_status: check the caller CIA subscription window end, daily allowance, consumption, and tier.",
|
|
1368
962
|
"- graph_query: run read-only GQL/Cypher through the universal graph endpoint. Use USE topology or USE facts.",
|