chainhint-mcp 1.0.1 → 1.2.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 CHANGED
@@ -4,16 +4,16 @@ Crypto risk intelligence for Claude Desktop, Cursor, and any MCP-compatible AI.
4
4
 
5
5
  ## Tools
6
6
 
7
- | Tool | Description |
8
- |------|-------------|
9
- | `check_wallet_risk` | Fast risk score, entity, labels, sanctions hit (54M+ labeled addresses). **Needs API key** |
10
- | `lookup_address` | Deep address report — entity, risk factors, GoPlus flags, counterparty exposure, balance. No key needed |
11
- | `get_trace_status` | Fund-trace summary for a public hack incident — hops, endpoints by type (exchange/mixer/bridge/defi), exposure. No key needed |
7
+ | Tool | Description | Limit |
8
+ |------|-------------|-------|
9
+ | `check_wallet_risk` | Fast risk score, entity, labels, sanctions hit (54M+ labeled addresses) | **3 / day** free · 10,000 / day with key |
10
+ | `lookup_address` | Deep address report — entity, risk factors, GoPlus flags, counterparty exposure, balance | **10 / day** free · 100 / hour with key |
11
+ | `get_trace_status` | Fund-trace summary for a public hack incident — hops, endpoints by type (exchange/mixer/bridge/defi), exposure | Unlimited |
12
12
 
13
13
  ## Requirements
14
14
 
15
15
  - Node.js 18+
16
- - Optional: ChainHint Agency plan API key (`ch_live_...`) from [chainhint.com/settings](https://chainhint.com/settings) only `check_wallet_risk` needs it
16
+ - Nothing else. **No API key needed to start** — every tool has a free daily allowance (per IP). An Agency plan key (`ch_live_...`, [chainhint.com/settings](https://chainhint.com/settings)) lifts the limits.
17
17
 
18
18
  ## Install
19
19
 
@@ -38,10 +38,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
38
38
  "mcpServers": {
39
39
  "chainhint": {
40
40
  "command": "npx",
41
- "args": ["-y", "chainhint-mcp"],
42
- "env": {
43
- "CHAINHINT_API_KEY": "ch_live_your_key_here"
44
- }
41
+ "args": ["-y", "chainhint-mcp"]
45
42
  }
46
43
  }
47
44
  }
@@ -56,15 +53,39 @@ Add to `.cursor/mcp.json` in your project (or global `~/.cursor/mcp.json`):
56
53
  "mcpServers": {
57
54
  "chainhint": {
58
55
  "command": "npx",
59
- "args": ["-y", "chainhint-mcp"],
60
- "env": {
61
- "CHAINHINT_API_KEY": "ch_live_your_key_here"
62
- }
56
+ "args": ["-y", "chainhint-mcp"]
63
57
  }
64
58
  }
65
59
  }
66
60
  ```
67
61
 
62
+ ## Claude Code
63
+
64
+ ```bash
65
+ claude mcp add chainhint -- npx -y chainhint-mcp
66
+ ```
67
+
68
+ ## Lifting the free limits
69
+
70
+ Add an Agency key to the server entry (any client):
71
+
72
+ ```json
73
+ "env": { "CHAINHINT_API_KEY": "ch_live_your_key_here" }
74
+ ```
75
+
76
+ ## What the agent sees
77
+
78
+ ```
79
+ ## Wallet Risk Report: 0x47666f...9486e2
80
+ **Chain:** ethereum
81
+ **Risk Score:** 85/100 — **CRITICAL**
82
+ **Entity:** Bybit Hack Exploiter (hacker)
83
+ **Labels:** Hacker/Exploiter, Bybit Hack Exploiter
84
+ **In ChainHint DB:** yes (sources: chainhint:manual)
85
+
86
+ Free tier: 2 of 3 checks left today — set CHAINHINT_API_KEY (Agency plan, https://chainhint.com/pricing) for 10,000/day.
87
+ ```
88
+
68
89
  ## Development (no build step)
69
90
 
70
91
  ```bash
@@ -75,7 +96,7 @@ npm run dev
75
96
 
76
97
  | Variable | Required | Description |
77
98
  |----------|----------|-------------|
78
- | `CHAINHINT_API_KEY` | — | API key from chainhint.com (Agency plan). Required only for `check_wallet_risk` |
99
+ | `CHAINHINT_API_KEY` | — | Agency plan key from chainhint.com. Lifts the free limits (3 checks + 10 lookups per day) to 10,000 / day |
79
100
  | `CHAINHINT_API_URL` | — | Override API base URL (default: production) |
80
101
  | `CHAINHINT_SUPABASE_ANON_KEY` | — | Override anon key for get_trace_status (public incidents) |
81
102
 
package/dist/index.d.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  * - lookup_address → address-lookup API (entity, risk factors, exposure, balance) — public
11
11
  * - get_trace_status → public incident fund-trace summary (endpoints, hops, exposure) — public
12
12
  *
13
- * Auth: CHAINHINT_API_KEY env var (Agency plan API key: ch_live_...) is only
14
- * required for check_wallet_risk. The other two tools hit public endpoints.
13
+ * Auth: works without a key. check_wallet_risk has a free tier of 3 checks
14
+ * per IP per day; lookup_address 10 per day; get_trace_status is unlimited.
15
+ * CHAINHINT_API_KEY (Agency plan, ch_live_...) lifts the limits to 10,000/day.
15
16
  */
16
17
  export {};
package/dist/index.js CHANGED
@@ -10,8 +10,9 @@
10
10
  * - lookup_address → address-lookup API (entity, risk factors, exposure, balance) — public
11
11
  * - get_trace_status → public incident fund-trace summary (endpoints, hops, exposure) — public
12
12
  *
13
- * Auth: CHAINHINT_API_KEY env var (Agency plan API key: ch_live_...) is only
14
- * required for check_wallet_risk. The other two tools hit public endpoints.
13
+ * Auth: works without a key. check_wallet_risk has a free tier of 3 checks
14
+ * per IP per day; lookup_address 10 per day; get_trace_status is unlimited.
15
+ * CHAINHINT_API_KEY (Agency plan, ch_live_...) lifts the limits to 10,000/day.
15
16
  */
16
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -21,30 +22,55 @@ const API_KEY = process.env.CHAINHINT_API_KEY;
21
22
  const BASE_URL = process.env.CHAINHINT_API_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co/functions/v1";
22
23
  const SUPABASE_URL = process.env.CHAINHINT_SUPABASE_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co";
23
24
  const SUPABASE_ANON_KEY = process.env.CHAINHINT_SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtqaXdmd3ltbnV6eHJpb2toY2prIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI3MzkxODgsImV4cCI6MjA4ODMxNTE4OH0.VqzzF_jI8zF072cbjWEDbYo3PnMDlIPy621iWkXEqyo";
24
- const NO_KEY_MESSAGE = "check_wallet_risk requires CHAINHINT_API_KEY (Agency plan, ch_live_...). " +
25
- "Get one at chainhint.com → Settings → API Keys, or use lookup_address which needs no key.";
25
+ const VERSION = "1.1.0";
26
+ const USER_AGENT = `chainhint-mcp/${VERSION}`;
27
+ const FREE_CHECKS_PER_DAY = 3;
28
+ const UPGRADE_HINT = "set CHAINHINT_API_KEY (Agency plan, https://chainhint.com/pricing) for 10,000/day";
26
29
  if (!API_KEY) {
27
- console.error("[chainhint-mcp] WARN: CHAINHINT_API_KEY is not set check_wallet_risk will be unavailable.");
28
- console.error(" lookup_address and get_trace_status work without a key.");
30
+ console.error(`[chainhint-mcp] No CHAINHINT_API_KEY running on the free tier (${FREE_CHECKS_PER_DAY} wallet checks + 10 lookups per day per IP).`);
29
31
  }
30
- // ── HTTP helpers ──────────────────────────────────────────────────────────────
31
- async function apiGet(path, params, opts) {
32
+ async function apiGet(path, params) {
32
33
  const url = new URL(`${BASE_URL}${path}`);
33
34
  for (const [k, v] of Object.entries(params)) {
34
35
  if (v)
35
36
  url.searchParams.set(k, v);
36
37
  }
37
- const headers = { "Content-Type": "application/json" };
38
- if (opts.auth && API_KEY) {
38
+ // User-Agent lets the backend attribute traffic (api_usage_log.caller) and
39
+ // apply the MCP-specific free allowance. The key, when present, goes in
40
+ // X-Api-Key so both wallet-reputation and address-lookup recognise it.
41
+ const headers = {
42
+ "Content-Type": "application/json",
43
+ "User-Agent": USER_AGENT,
44
+ };
45
+ if (API_KEY) {
39
46
  headers["X-Api-Key"] = API_KEY;
40
47
  headers["Authorization"] = `Bearer ${API_KEY}`;
41
48
  }
42
49
  const res = await fetch(url.toString(), { headers });
43
50
  const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` })));
44
51
  if (!res.ok) {
45
- throw new Error(body?.error ?? `HTTP ${res.status}: ${url.toString()}`);
52
+ let msg = body?.error ?? `HTTP ${res.status}: ${url.toString()}`;
53
+ if (res.status === 429 && body?.reset_at) {
54
+ msg += ` Resets at ${new Date(body.reset_at * 1000).toISOString()}.`;
55
+ }
56
+ throw new Error(msg);
46
57
  }
47
- return body;
58
+ return { body, headers: res.headers };
59
+ }
60
+ /** "Free tier: 2 of 3 checks left today — set CHAINHINT_API_KEY …" or null. */
61
+ function quotaLine(headers, what) {
62
+ if (headers.get("x-chainhint-tier") !== "free")
63
+ return null;
64
+ const limit = Number(headers.get("x-ratelimit-limit"));
65
+ const remaining = Number(headers.get("x-ratelimit-remaining"));
66
+ if (!Number.isFinite(limit) || !Number.isFinite(remaining))
67
+ return null;
68
+ // Bypass-listed IPs get MAX_SAFE_INTEGER from the backend — no quota to report.
69
+ if (limit >= 1_000_000)
70
+ return null;
71
+ const reset = Number(headers.get("x-ratelimit-reset"));
72
+ const resetStr = Number.isFinite(reset) && reset > 0 ? ` (resets ${new Date(reset * 1000).toISOString().slice(0, 16)}Z)` : "";
73
+ return `Free tier: ${remaining} of ${limit} ${what} left today${resetStr} — ${UPGRADE_HINT}.`;
48
74
  }
49
75
  async function supabaseGet(table, params) {
50
76
  const url = new URL(`${SUPABASE_URL}/rest/v1/${table}`);
@@ -79,14 +105,78 @@ function formatRiskLevel(score) {
79
105
  function truncateAddr(addr) {
80
106
  return addr.length > 12 ? `${addr.slice(0, 8)}...${addr.slice(-6)}` : addr;
81
107
  }
108
+ // Same tiers as the site's formatUsd (src/lib/utils.ts): $1.4B, $115.0M, $629.4K.
109
+ // KIR-45: this used to stop at "M" and print "$1409.97M" for the Bybit loss.
82
110
  function usd(n) {
83
111
  if (n == null || !Number.isFinite(n))
84
112
  return "Unknown";
85
- if (Math.abs(n) >= 1_000_000)
86
- return `$${(n / 1_000_000).toFixed(2)}M`;
87
- if (Math.abs(n) >= 1_000)
88
- return `$${(n / 1_000).toFixed(1)}K`;
89
- return `$${n.toFixed(2)}`;
113
+ const abs = Math.abs(n);
114
+ if (abs >= 1e15)
115
+ return "Unknown";
116
+ if (abs >= 1e12)
117
+ return `$${(n / 1e12).toFixed(1)}T`;
118
+ if (abs >= 1e9)
119
+ return `$${(n / 1e9).toFixed(1)}B`;
120
+ if (abs >= 1e6)
121
+ return `$${(n / 1e6).toFixed(1)}M`;
122
+ if (abs >= 1e3)
123
+ return `$${(n / 1e3).toFixed(1)}K`;
124
+ return `$${n.toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
125
+ }
126
+ // Hop count as the site counts it (src/lib/traceReconciliation deriveDepthMap):
127
+ // shortest-path depth from the attacker over the edges, max over reachable
128
+ // nodes — NOT max(edge.depth), which the tracer assigns as it walks (Bybit:
129
+ // 6 vs the site's 5).
130
+ function hopCount(edges, attacker) {
131
+ const src = (attacker ?? "").toLowerCase();
132
+ if (!src || !edges.length)
133
+ return 0;
134
+ const out = new Map();
135
+ for (const e of edges) {
136
+ const f = (e.from ?? "").toLowerCase(), t = (e.to ?? "").toLowerCase();
137
+ if (!f || !t)
138
+ continue;
139
+ (out.get(f) ?? out.set(f, []).get(f)).push(t);
140
+ }
141
+ const depth = new Map([[src, 0]]);
142
+ const queue = [src];
143
+ let max = 0;
144
+ while (queue.length) {
145
+ const a = queue.shift();
146
+ const d = depth.get(a);
147
+ for (const b of out.get(a) ?? []) {
148
+ if (depth.has(b))
149
+ continue;
150
+ depth.set(b, d + 1);
151
+ if (d + 1 > max)
152
+ max = d + 1;
153
+ queue.push(b);
154
+ }
155
+ }
156
+ return max;
157
+ }
158
+ // Endpoint buckets as the site's Flow Summary (src/lib/flowSummary.ts): the
159
+ // entity CATEGORY decides — an attacker wallet retyped "exchange" is not an
160
+ // exchange, a no-KYC swap is not a freeze target.
161
+ const ATTACKER_CATEGORIES = new Set(["hacker", "scam", "exploit"]);
162
+ const NO_KYC_TOKENS = ["changenow", "fixedfloat", "simpleswap", "sideshift", "stealthex", "changelly", "letsexchange", "godex", "swapuz", "exolix", "exch.cx", "exch.sc", "exch.net", "ff.io"];
163
+ function endpointBucket(category, type, name) {
164
+ const c = (category ?? "").toLowerCase();
165
+ const t = (type ?? "").toLowerCase();
166
+ const n = (name ?? "").toLowerCase().replace(/[\s_-]/g, "");
167
+ if (ATTACKER_CATEGORIES.has(c))
168
+ return "attacker-attributed";
169
+ if (c === "sanctioned" || c === "high_risk_exchange" || NO_KYC_TOKENS.some((k) => n.includes(k.replace(/[\s_-]/g, ""))))
170
+ return "risky";
171
+ if (c === "mixer" || t === "mixer")
172
+ return "mixer";
173
+ if (c === "bridge" || t === "bridge")
174
+ return "bridge";
175
+ if (c === "exchange" || t === "exchange")
176
+ return "exchange";
177
+ if (c === "defi" || c === "dex" || t === "defi" || t === "dex")
178
+ return "defi";
179
+ return "unknown";
90
180
  }
91
181
  // EVM addresses are case-insensitive and stored lowercase; base58 chains
92
182
  // (BTC/TRON/SOL/TON) are case-sensitive — never lowercase those.
@@ -106,31 +196,58 @@ function formatExposure(label, buckets) {
106
196
  }),
107
197
  ];
108
198
  }
199
+ /** Public incident whose attacker_address matches, or null (errors swallowed — best effort). */
200
+ async function findPublicIncidentByAttacker(address) {
201
+ try {
202
+ const rows = await supabaseGet("public_incidents_view", {
203
+ select: "id,title,chain,amount_usd,estimated_loss_usd,risk_score",
204
+ attacker_address: `eq.${normalizeAddr(address)}`,
205
+ limit: "1",
206
+ });
207
+ return rows[0] ?? null;
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
109
213
  // ── MCP Server ────────────────────────────────────────────────────────────────
110
214
  const server = new McpServer({
111
215
  name: "chainhint",
112
- version: "1.0.1",
216
+ version: VERSION,
113
217
  });
114
218
  // ── Tool 1: check_wallet_risk ─────────────────────────────────────────────────
115
- server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address against ChainHint's 54M+ labeled address database (12 chains). Returns risk score 0-100, risk level (clean/low/medium/high/critical/sanctioned), entity name and category, labels, and sanctions hit. Requires CHAINHINT_API_KEY (Agency plan). For a keyless, deeper look (risk factors, exposure, balance) use lookup_address.", {
219
+ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address against ChainHint's 54M+ labeled address database (12 chains). Returns risk score 0-100, risk level (clean/low/medium/high/critical/sanctioned), entity name and category, labels, and sanctions hit. Use it to decide allow/warn/block before paying or interacting with a counterparty wallet. Free: 3 checks per day without a key; CHAINHINT_API_KEY (Agency plan) lifts it to 10,000/day. For a deeper report (risk factors, exposure, balance) use lookup_address.", {
116
220
  address: z.string().describe("Wallet address to check (EVM 0x..., Bitcoin, or Solana)"),
117
221
  chain: z.string().optional().describe("Blockchain: ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin (default: auto-detect from address format)"),
118
222
  }, async ({ address, chain }) => {
119
- if (!API_KEY) {
120
- return { content: [{ type: "text", text: NO_KEY_MESSAGE }] };
121
- }
122
223
  try {
123
224
  const params = { address };
124
225
  if (chain)
125
226
  params.chain = chain;
126
227
  // wallet-reputation returns a flat object (no {success,data} wrapper).
127
- const d = await apiGet("/wallet-reputation", params, { auth: true });
228
+ const { body: d, headers } = await apiGet("/wallet-reputation", params);
128
229
  const lines = [
129
230
  `## Wallet Risk Report: ${truncateAddr(d.address)}`,
130
231
  `**Address:** ${d.address}`,
131
232
  `**Chain:** ${d.chain}`,
132
- `**Risk Score:** ${d.risk_score}/100 — **${(d.risk_level ?? formatRiskLevel(d.risk_score)).toUpperCase()}**`,
133
233
  ];
234
+ // Canon (same as chainhint.com and the TG bot): an address that is not in
235
+ // the labeled database has NO DATA — that is not evidence it is clean.
236
+ // The API still returns risk_score 0 / "clean" for not-found, so the
237
+ // wording is fixed here, and public incidents are cross-checked so a
238
+ // known hack attacker that never got an `addresses` row is not
239
+ // presented as unknown.
240
+ if (!d.found_in_db) {
241
+ lines.push(`**Risk:** ⚪ NO DATA — address is not in ChainHint's labeled database. This is not evidence it is clean.`);
242
+ const inc = await findPublicIncidentByAttacker(d.address);
243
+ if (inc) {
244
+ lines.push(`⚠️ **Known attacker in a public hack incident:** ${inc.title ?? "Unnamed incident"} (${inc.chain}, loss ${usd(inc.amount_usd ?? inc.estimated_loss_usd)}${inc.risk_score != null ? `, incident risk ${inc.risk_score}/100` : ""}). Treat as HIGH risk.`, `🔗 https://chainhint.com/incident/${inc.id}`);
245
+ }
246
+ lines.push(`Use lookup_address for an on-chain assessment (risk factors, GoPlus flags, counterparty exposure).`);
247
+ }
248
+ else {
249
+ lines.push(`**Risk Score:** ${d.risk_score}/100 — **${(d.risk_level ?? formatRiskLevel(d.risk_score)).toUpperCase()}**`);
250
+ }
134
251
  if (d.sanctions?.hit) {
135
252
  lines.push(`⛔ **SANCTIONED / OFAC-linked**`);
136
253
  }
@@ -151,6 +268,9 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
151
268
  lines.push(`**Type:** ${d.is_contract ? "Smart Contract" : "EOA (wallet)"}`);
152
269
  lines.push(`**In ChainHint DB:** ${d.found_in_db ? "yes" : "no"}${d.sources?.length ? ` (sources: ${d.sources.join(", ")})` : ""}`);
153
270
  lines.push(`**Checked at:** ${d.checked_at}`);
271
+ const quota = quotaLine(headers, "checks");
272
+ if (quota)
273
+ lines.push(``, quota);
154
274
  lines.push(`\n*Powered by ChainHint — chainhint.com*`);
155
275
  return { content: [{ type: "text", text: lines.join("\n") }] };
156
276
  }
@@ -159,7 +279,7 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
159
279
  }
160
280
  });
161
281
  // ── Tool 2: lookup_address ────────────────────────────────────────────────────
162
- server.tool("lookup_address", "Detailed lookup of a blockchain address: entity attribution, risk score with the factors behind it, sanctions and GoPlus security flags, counterparty exposure (where funds came from / went to, by category with named entities), balance, token count and transaction count. Works without an API key. Supports EVM chains, Bitcoin, Solana, TRON, TON.", {
282
+ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity attribution, risk score with the factors behind it, sanctions and GoPlus security flags, counterparty exposure (where funds came from / went to, by category with named entities), balance, token count and transaction count. Free: 10 lookups per day without a key; CHAINHINT_API_KEY lifts it. Supports EVM chains, Bitcoin, Solana, TRON, TON.", {
163
283
  address: z.string().describe("Blockchain address to look up"),
164
284
  chain: z.string().optional().describe("Blockchain (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin, tron, ton)"),
165
285
  }, async ({ address, chain }) => {
@@ -167,7 +287,7 @@ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity a
167
287
  const params = { address };
168
288
  if (chain)
169
289
  params.chain = chain;
170
- const data = await apiGet("/address-lookup", params, { auth: false });
290
+ const { body: data, headers: lookupHeaders } = await apiGet("/address-lookup", params);
171
291
  if (!data.success || !data.data) {
172
292
  return { content: [{ type: "text", text: `Error: ${data.error ?? "Unknown error"}` }] };
173
293
  }
@@ -223,6 +343,9 @@ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity a
223
343
  lines.push(...formatExposure("Inflow by category", ex.inflow));
224
344
  lines.push(...formatExposure("Outflow by category", ex.outflow));
225
345
  }
346
+ const lookupQuota = quotaLine(lookupHeaders, "lookups");
347
+ if (lookupQuota)
348
+ lines.push(``, lookupQuota);
226
349
  lines.push(``, `🔗 https://chainhint.com/address/${d.address}?chain=${d.chain}`);
227
350
  lines.push(`*Powered by ChainHint — chainhint.com*`);
228
351
  return { content: [{ type: "text", text: lines.join("\n") }] };
@@ -264,7 +387,8 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
264
387
  };
265
388
  }
266
389
  const inc = rows[0];
267
- const lossUsd = inc.amount_usd ?? inc.estimated_loss_usd;
390
+ // The site's figure: stored estimated_loss_usd first (TraceFacts.displayedLossUsd), amount_usd as fallback.
391
+ const lossUsd = inc.estimated_loss_usd ?? inc.amount_usd;
268
392
  const date = inc.display_date ?? inc.hack_date ?? inc.created_at;
269
393
  const status = (inc.status ?? "unknown").toLowerCase();
270
394
  const lines = [
@@ -287,15 +411,15 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
287
411
  const edges = inc.flow_graph?.edges ?? [];
288
412
  const nodes = inc.flow_graph?.nodes ?? [];
289
413
  if (edges.length) {
290
- const maxDepth = edges.reduce((m, e) => Math.max(m, e.depth ?? 0), 0);
414
+ const hops = hopCount(edges, inc.attacker_address);
291
415
  lines.push(``, `### Trace Graph`);
292
- lines.push(`**Hops traced:** ${maxDepth} · **Addresses:** ${nodes.length} · **Transfers:** ${edges.length}`);
416
+ lines.push(`**Hops traced:** ${hops} · **Addresses:** ${nodes.length} · **Transfers:** ${edges.length}`);
293
417
  }
294
418
  const endpoints = inc.endpoints ?? [];
295
419
  if (endpoints.length) {
296
420
  const byType = new Map();
297
421
  for (const e of endpoints) {
298
- const t = e.type ?? "unknown";
422
+ const t = endpointBucket(e.entity_category, e.type, e.entity_name ?? e.entity);
299
423
  const b = byType.get(t) ?? { usd: 0, n: 0, entities: new Map() };
300
424
  b.usd += e.amount_usd ?? 0;
301
425
  b.n += 1;
@@ -305,7 +429,8 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
305
429
  byType.set(t, b);
306
430
  }
307
431
  const totalUsd = [...byType.values()].reduce((s, b) => s + b.usd, 0);
308
- lines.push(``, `### Where the funds went (${endpoints.length} endpoints, ${usd(totalUsd)} tracked)`);
432
+ // Σ endpoint amounts counts every hop's inflow (multi-hop), so it is larger than the loss — name the base.
433
+ lines.push(``, `### Where the funds went (${endpoints.length} endpoints, ${usd(totalUsd)} observed at endpoints — not the loss figure)`);
309
434
  for (const [t, b] of [...byType.entries()].sort((a, b) => b[1].usd - a[1].usd)) {
310
435
  const top = [...b.entities.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([n]) => n);
311
436
  const pct = totalUsd > 0 ? ` (${((b.usd / totalUsd) * 100).toFixed(1)}%)` : "";
@@ -319,7 +444,7 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
319
444
  lines.push(`**Largest endpoints:**`);
320
445
  for (const e of topEndpoints) {
321
446
  const name = e.entity_name ?? e.entity ?? "unattributed";
322
- lines.push(`- ${truncateAddr(e.address)} — ${name} (${e.type ?? "unknown"}): ${usd(e.amount_usd)}`);
447
+ lines.push(`- ${truncateAddr(e.address)} — ${name} (${endpointBucket(e.entity_category, e.type, e.entity_name ?? e.entity)}): ${usd(e.amount_usd)}`);
323
448
  }
324
449
  }
325
450
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chainhint-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "ChainHint MCP server — crypto risk intelligence tools for Claude Desktop and Cursor",
5
5
  "license": "MIT",
6
6
  "repository": {