chainhint-mcp 1.0.0 → 1.0.1

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
@@ -6,14 +6,14 @@ Crypto risk intelligence for Claude Desktop, Cursor, and any MCP-compatible AI.
6
6
 
7
7
  | Tool | Description |
8
8
  |------|-------------|
9
- | `check_wallet_risk` | Risk score, entity label, sanctions check for any wallet |
10
- | `lookup_address` | Full address details — entity, category, tx history |
11
- | `get_trace_status` | Fund trace status for hack incidentswhere did the money go? |
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 incidenthops, endpoints by type (exchange/mixer/bridge/defi), exposure. No key needed |
12
12
 
13
13
  ## Requirements
14
14
 
15
15
  - Node.js 18+
16
- - ChainHint Agency plan API key (`ch_live_...`) from [chainhint.com/settings](https://chainhint.com/settings)
16
+ - Optional: ChainHint Agency plan API key (`ch_live_...`) from [chainhint.com/settings](https://chainhint.com/settings) — only `check_wallet_risk` needs it
17
17
 
18
18
  ## Install
19
19
 
@@ -75,9 +75,9 @@ npm run dev
75
75
 
76
76
  | Variable | Required | Description |
77
77
  |----------|----------|-------------|
78
- | `CHAINHINT_API_KEY` | | API key from chainhint.com (Agency plan) |
78
+ | `CHAINHINT_API_KEY` | | API key from chainhint.com (Agency plan). Required only for `check_wallet_risk` |
79
79
  | `CHAINHINT_API_URL` | — | Override API base URL (default: production) |
80
- | `CHAINHINT_SUPABASE_ANON_KEY` | — | For get_trace_status (public incidents) |
80
+ | `CHAINHINT_SUPABASE_ANON_KEY` | — | Override anon key for get_trace_status (public incidents) |
81
81
 
82
82
  ## Example prompts
83
83
 
package/dist/index.d.ts CHANGED
@@ -6,10 +6,11 @@
6
6
  * and any MCP-compatible AI client.
7
7
  *
8
8
  * Tools:
9
- * - check_wallet_risk → wallet-reputation API (risk score, labels, sanctions)
10
- * - lookup_address → address-lookup API (entity, category, transaction history)
11
- * - get_trace_status → incident fund trace status (flow graph summary)
9
+ * - check_wallet_risk → wallet-reputation API (risk score, labels, sanctions) — needs API key
10
+ * - lookup_address → address-lookup API (entity, risk factors, exposure, balance) — public
11
+ * - get_trace_status → public incident fund-trace summary (endpoints, hops, exposure) — public
12
12
  *
13
- * Auth: set CHAINHINT_API_KEY env var (Agency plan API key: ch_live_...)
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.
14
15
  */
15
16
  export {};
package/dist/index.js CHANGED
@@ -6,11 +6,12 @@
6
6
  * and any MCP-compatible AI client.
7
7
  *
8
8
  * Tools:
9
- * - check_wallet_risk → wallet-reputation API (risk score, labels, sanctions)
10
- * - lookup_address → address-lookup API (entity, category, transaction history)
11
- * - get_trace_status → incident fund trace status (flow graph summary)
9
+ * - check_wallet_risk → wallet-reputation API (risk score, labels, sanctions) — needs API key
10
+ * - lookup_address → address-lookup API (entity, risk factors, exposure, balance) — public
11
+ * - get_trace_status → public incident fund-trace summary (endpoints, hops, exposure) — public
12
12
  *
13
- * Auth: set CHAINHINT_API_KEY env var (Agency plan API key: ch_live_...)
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.
14
15
  */
15
16
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
17
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -20,26 +21,26 @@ const API_KEY = process.env.CHAINHINT_API_KEY;
20
21
  const BASE_URL = process.env.CHAINHINT_API_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co/functions/v1";
21
22
  const SUPABASE_URL = process.env.CHAINHINT_SUPABASE_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co";
22
23
  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.";
23
26
  if (!API_KEY) {
24
- console.error("[chainhint-mcp] ERROR: CHAINHINT_API_KEY is not set.");
25
- console.error(" Get your API key from chainhint.com → Settings → API Keys (Agency plan required)");
26
- process.exit(1);
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.");
27
29
  }
28
30
  // ── HTTP helpers ──────────────────────────────────────────────────────────────
29
- async function apiGet(path, params = {}) {
31
+ async function apiGet(path, params, opts) {
30
32
  const url = new URL(`${BASE_URL}${path}`);
31
33
  for (const [k, v] of Object.entries(params)) {
32
34
  if (v)
33
35
  url.searchParams.set(k, v);
34
36
  }
35
- const res = await fetch(url.toString(), {
36
- headers: {
37
- "X-Api-Key": API_KEY,
38
- "Authorization": `Bearer ${API_KEY}`,
39
- "Content-Type": "application/json",
40
- },
41
- });
42
- const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
37
+ const headers = { "Content-Type": "application/json" };
38
+ if (opts.auth && API_KEY) {
39
+ headers["X-Api-Key"] = API_KEY;
40
+ headers["Authorization"] = `Bearer ${API_KEY}`;
41
+ }
42
+ const res = await fetch(url.toString(), { headers });
43
+ const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` })));
43
44
  if (!res.ok) {
44
45
  throw new Error(body?.error ?? `HTTP ${res.status}: ${url.toString()}`);
45
46
  }
@@ -54,7 +55,6 @@ async function supabaseGet(table, params) {
54
55
  headers: {
55
56
  "apikey": SUPABASE_ANON_KEY,
56
57
  "Authorization": `Bearer ${SUPABASE_ANON_KEY}`,
57
- "X-Api-Key": API_KEY,
58
58
  "Accept": "application/json",
59
59
  },
60
60
  });
@@ -79,55 +79,78 @@ function formatRiskLevel(score) {
79
79
  function truncateAddr(addr) {
80
80
  return addr.length > 12 ? `${addr.slice(0, 8)}...${addr.slice(-6)}` : addr;
81
81
  }
82
+ function usd(n) {
83
+ if (n == null || !Number.isFinite(n))
84
+ 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)}`;
90
+ }
91
+ // EVM addresses are case-insensitive and stored lowercase; base58 chains
92
+ // (BTC/TRON/SOL/TON) are case-sensitive — never lowercase those.
93
+ function normalizeAddr(addr) {
94
+ const a = addr.trim();
95
+ return a.startsWith("0x") ? a.toLowerCase() : a;
96
+ }
97
+ function formatExposure(label, buckets) {
98
+ if (!buckets?.length)
99
+ return [];
100
+ const sorted = [...buckets].sort((a, b) => (b.usd ?? 0) - (a.usd ?? 0)).slice(0, 5);
101
+ return [
102
+ `**${label}:**`,
103
+ ...sorted.map((b) => {
104
+ const who = b.top_entities?.length ? ` — ${b.top_entities.slice(0, 3).join(", ")}` : "";
105
+ return `- ${b.category}: ${usd(b.usd)} (${b.pct}%, ${b.counterparties} counterpart${b.counterparties === 1 ? "y" : "ies"})${who}`;
106
+ }),
107
+ ];
108
+ }
82
109
  // ── MCP Server ────────────────────────────────────────────────────────────────
83
110
  const server = new McpServer({
84
111
  name: "chainhint",
85
- version: "1.0.0",
112
+ version: "1.0.1",
86
113
  });
87
114
  // ── Tool 1: check_wallet_risk ─────────────────────────────────────────────────
88
- server.tool("check_wallet_risk", "Check the risk score and labels for a crypto wallet address. Returns risk level (CLEAN/LOW/MEDIUM/HIGH/CRITICAL), entity label, category, sanctions status, and transaction statistics. Use this to assess whether a wallet is associated with hacks, scams, mixers, or sanctioned entities.", {
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.", {
89
116
  address: z.string().describe("Wallet address to check (EVM 0x..., Bitcoin, or Solana)"),
90
117
  chain: z.string().optional().describe("Blockchain: ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin (default: auto-detect from address format)"),
91
118
  }, async ({ address, chain }) => {
119
+ if (!API_KEY) {
120
+ return { content: [{ type: "text", text: NO_KEY_MESSAGE }] };
121
+ }
92
122
  try {
93
123
  const params = { address };
94
124
  if (chain)
95
125
  params.chain = chain;
96
- const data = await apiGet("/wallet-reputation", params);
97
- if (!data.success || !data.data) {
98
- return { content: [{ type: "text", text: `Error: ${data.error ?? "Unknown error"}` }] };
99
- }
100
- const d = data.data;
101
- const risk = d.risk;
102
- const entity = d.entity;
103
- const sanctions = d.sanctions;
104
- const stats = d.stats;
126
+ // wallet-reputation returns a flat object (no {success,data} wrapper).
127
+ const d = await apiGet("/wallet-reputation", params, { auth: true });
105
128
  const lines = [
106
129
  `## Wallet Risk Report: ${truncateAddr(d.address)}`,
130
+ `**Address:** ${d.address}`,
107
131
  `**Chain:** ${d.chain}`,
108
- `**Risk Score:** ${risk?.score ?? "N/A"}/100 — **${risk?.level ?? formatRiskLevel(risk?.score ?? 0)}**`,
132
+ `**Risk Score:** ${d.risk_score}/100 — **${(d.risk_level ?? formatRiskLevel(d.risk_score)).toUpperCase()}**`,
109
133
  ];
110
- if (entity?.name) {
111
- lines.push(`**Entity:** ${entity.name} (${entity.category})`);
112
- if (entity.label)
113
- lines.push(`**Label:** ${entity.label}`);
134
+ if (d.sanctions?.hit) {
135
+ lines.push(`⛔ **SANCTIONED / OFAC-linked**`);
114
136
  }
115
- else {
116
- lines.push(`**Entity:** Unknown / unlabeled`);
137
+ if (d.entity?.name) {
138
+ const sub = d.entity.subcategory ? ` / ${d.entity.subcategory}` : "";
139
+ const ver = d.entity.verified ? ", verified" : "";
140
+ lines.push(`**Entity:** ${d.entity.name} (${d.entity.category}${sub}${ver})`);
117
141
  }
118
- if (sanctions?.is_sanctioned) {
119
- lines.push(`⛔ **SANCTIONED** — Programs: ${sanctions.programs.join(", ")}`);
120
- }
121
- if (risk?.flags?.length) {
122
- lines.push(`**Risk Flags:** ${risk.flags.join(", ")}`);
142
+ else if (d.category) {
143
+ lines.push(`**Category:** ${d.category}`);
123
144
  }
124
- if (stats) {
125
- lines.push(`**Transactions:** ${stats.tx_count?.toLocaleString() ?? "N/A"}`);
126
- if (stats.first_seen)
127
- lines.push(`**First seen:** ${stats.first_seen.slice(0, 10)}`);
128
- if (stats.last_seen)
129
- lines.push(`**Last seen:** ${stats.last_seen.slice(0, 10)}`);
145
+ else {
146
+ lines.push(`**Entity:** Unknown / unlabeled`);
130
147
  }
148
+ if (d.labels?.length)
149
+ lines.push(`**Labels:** ${[...new Set(d.labels)].join(", ")}`);
150
+ if (d.is_contract != null)
151
+ lines.push(`**Type:** ${d.is_contract ? "Smart Contract" : "EOA (wallet)"}`);
152
+ lines.push(`**In ChainHint DB:** ${d.found_in_db ? "yes" : "no"}${d.sources?.length ? ` (sources: ${d.sources.join(", ")})` : ""}`);
153
+ lines.push(`**Checked at:** ${d.checked_at}`);
131
154
  lines.push(`\n*Powered by ChainHint — chainhint.com*`);
132
155
  return { content: [{ type: "text", text: lines.join("\n") }] };
133
156
  }
@@ -136,57 +159,79 @@ server.tool("check_wallet_risk", "Check the risk score and labels for a crypto w
136
159
  }
137
160
  });
138
161
  // ── Tool 2: lookup_address ────────────────────────────────────────────────────
139
- server.tool("lookup_address", "Look up detailed information about a blockchain address including entity label, risk assessment, transaction history, and known associations. More detailed than check_wallet_risk includes transaction counts, token holdings summary, and entity metadata.", {
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.", {
140
163
  address: z.string().describe("Blockchain address to look up"),
141
- chain: z.string().optional().describe("Blockchain (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin)"),
164
+ chain: z.string().optional().describe("Blockchain (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin, tron, ton)"),
142
165
  }, async ({ address, chain }) => {
143
166
  try {
144
167
  const params = { address };
145
168
  if (chain)
146
169
  params.chain = chain;
147
- const data = await apiGet("/address-lookup", params);
170
+ const data = await apiGet("/address-lookup", params, { auth: false });
148
171
  if (!data.success || !data.data) {
149
172
  return { content: [{ type: "text", text: `Error: ${data.error ?? "Unknown error"}` }] };
150
173
  }
151
174
  const d = data.data;
152
175
  const lines = [
153
176
  `## Address Lookup: ${truncateAddr(d.address)}`,
177
+ `**Address:** ${d.address}`,
154
178
  `**Chain:** ${d.chain}`,
155
179
  `**Type:** ${d.is_contract ? "Smart Contract" : "EOA (wallet)"}`,
156
180
  ];
157
- if (d.entity)
158
- lines.push(`**Entity:** ${d.entity}`);
159
- if (d.label)
160
- lines.push(`**Label:** ${d.label}`);
161
- if (d.category)
162
- lines.push(`**Category:** ${d.category}`);
163
- if (d.balance)
164
- lines.push(`**Balance:** ${d.balance}`);
165
- const score = d.risk?.score;
166
- if (score !== undefined) {
167
- lines.push(`**Risk Score:** ${score}/100 — ${formatRiskLevel(score)}`);
181
+ if (d.entity?.name) {
182
+ const sub = d.entity.subcategory ? ` / ${d.entity.subcategory}` : "";
183
+ const conf = d.entity.confidence != null ? `, confidence ${Math.round(d.entity.confidence * 100)}%` : "";
184
+ lines.push(`**Entity:** ${d.entity.name} (${d.entity.category}${sub}${conf})`);
185
+ }
186
+ else {
187
+ lines.push(`**Entity:** Unknown / unlabeled`);
168
188
  }
169
- if (d.risk?.flags?.length) {
170
- lines.push(`**Flags:** ${d.risk.flags.join(", ")}`);
189
+ if (d.labels?.length)
190
+ lines.push(`**Labels:** ${[...new Set(d.labels)].join(", ")}`);
191
+ if (d.risk) {
192
+ const level = (d.risk.level ?? formatRiskLevel(d.risk.score)).toUpperCase();
193
+ lines.push(`**Risk Score:** ${d.risk.score}/100 — **${level}**`);
194
+ const factors = Object.entries(d.risk.factors ?? {}).filter(([, v]) => v).map(([k]) => k);
195
+ if (factors.length)
196
+ lines.push(`**Risk Factors:** ${factors.join(", ")}`);
197
+ if (d.risk.details?.length)
198
+ lines.push(`**Risk Details:** ${d.risk.details.join("; ")}`);
171
199
  }
172
- if (d.sanctions?.is_sanctioned) {
173
- lines.push(`⛔ **SANCTIONED**`);
200
+ const sanctionIds = d.sanctions?.identifications ?? [];
201
+ if (sanctionIds.length) {
202
+ const names = sanctionIds.map((s) => [s.name, s.program].filter(Boolean).join(" / ")).filter(Boolean);
203
+ lines.push(`⛔ **SANCTIONED** — ${names.join("; ") || `${sanctionIds.length} identification(s)`}`);
174
204
  }
175
- if (d.tx_count)
205
+ const goplusFlags = Object.entries(d.goplus ?? {})
206
+ .filter(([k, v]) => v === "1" && k !== "contract_address")
207
+ .map(([k]) => k);
208
+ if (goplusFlags.length)
209
+ lines.push(`**GoPlus Security Flags:** ${goplusFlags.join(", ")}`);
210
+ if (d.balance_usd != null)
211
+ lines.push(`**Balance:** ${usd(d.balance_usd)}${d.tokens?.length ? ` across ${d.tokens.length} asset(s)` : ""}`);
212
+ if (d.tx_count != null)
176
213
  lines.push(`**Transactions:** ${d.tx_count.toLocaleString()}`);
177
- if (d.first_seen)
178
- lines.push(`**First seen:** ${d.first_seen.slice(0, 10)}`);
179
- if (d.last_seen)
180
- lines.push(`**Last seen:** ${d.last_seen.slice(0, 10)}`);
181
- lines.push(`\n*Powered by ChainHint chainhint.com*`);
214
+ const ex = d.exposure;
215
+ if (ex && (ex.inflow?.length || ex.outflow?.length)) {
216
+ lines.push(``, `### Counterparty Exposure (${ex.window ?? "recent transfers"})`);
217
+ if (ex.risk?.level) {
218
+ lines.push(`**Exposure Risk:** ${ex.risk.level.toUpperCase()} (${ex.risk.pct}% of volume to/from risky counterparties)`);
219
+ if (ex.risk.details?.length)
220
+ lines.push(...ex.risk.details.map((s) => `- ${s}`));
221
+ }
222
+ lines.push(`**Volume:** in ${usd(ex.total_in_usd)}, out ${usd(ex.total_out_usd)}; ${ex.counterparties_identified ?? 0}/${ex.counterparties_total ?? 0} counterparties identified`);
223
+ lines.push(...formatExposure("Inflow by category", ex.inflow));
224
+ lines.push(...formatExposure("Outflow by category", ex.outflow));
225
+ }
226
+ lines.push(``, `🔗 https://chainhint.com/address/${d.address}?chain=${d.chain}`);
227
+ lines.push(`*Powered by ChainHint — chainhint.com*`);
182
228
  return { content: [{ type: "text", text: lines.join("\n") }] };
183
229
  }
184
230
  catch (err) {
185
231
  return { content: [{ type: "text", text: `Error looking up address: ${err.message}` }] };
186
232
  }
187
233
  });
188
- // ── Tool 3: get_trace_status ──────────────────────────────────────────────────
189
- server.tool("get_trace_status", "Get the fund trace status for a crypto hack incident. Returns how stolen funds moved — number of hops, total amount traced, known endpoints (exchanges, mixers, bridges), and current movement status (in_transit, mixing, reached_exchange, dormant). Useful for incident response and understanding where stolen funds went.", {
234
+ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypto hack incident on ChainHint. Returns incident status, estimated loss, trace depth (hops) and graph size, where the stolen funds ended up (endpoints grouped by type: exchange, mixer, bridge, defi, unknown — with named entities and USD amounts), and the attacker's counterparty exposure. Look up by attacker address or ChainHint incident UUID. Works without an API key.", {
190
235
  attacker_address: z.string().optional().describe("Attacker wallet address to look up incident by"),
191
236
  incident_id: z.string().optional().describe("Incident UUID (from chainhint.com) — alternative to attacker_address"),
192
237
  }, async ({ attacker_address, incident_id }) => {
@@ -194,21 +239,20 @@ server.tool("get_trace_status", "Get the fund trace status for a crypto hack inc
194
239
  if (!attacker_address && !incident_id) {
195
240
  return { content: [{ type: "text", text: "Error: provide either attacker_address or incident_id" }] };
196
241
  }
197
- // Query public incidents via Supabase REST
198
- let queryParams = {
199
- select: "id,title,status,chain,attacker_address,amount_usd,estimated_loss_usd,created_at,source",
200
- is_public: "eq.true",
242
+ // public_incidents_view is the canonical anonymous read path (SECURITY DEFINER,
243
+ // exposes only is_public rows and only public-safe columns).
244
+ const queryParams = {
245
+ select: "id,title,status,chain,attacker_address,amount_usd,estimated_loss_usd,risk_score,incident_type,hack_date,display_date,created_at,updated_at,source,endpoints,flow_graph,counterparty_exposure",
201
246
  limit: "1",
202
247
  order: "created_at.desc",
203
248
  };
204
249
  if (incident_id) {
205
250
  queryParams["id"] = `eq.${incident_id}`;
206
- delete queryParams["is_public"];
207
251
  }
208
252
  else if (attacker_address) {
209
- queryParams["attacker_address"] = `eq.${attacker_address.toLowerCase()}`;
253
+ queryParams["attacker_address"] = `eq.${normalizeAddr(attacker_address)}`;
210
254
  }
211
- const rows = await supabaseGet("incidents", queryParams);
255
+ const rows = await supabaseGet("public_incidents_view", queryParams);
212
256
  if (!rows?.length) {
213
257
  return {
214
258
  content: [{
@@ -221,25 +265,86 @@ server.tool("get_trace_status", "Get the fund trace status for a crypto hack inc
221
265
  }
222
266
  const inc = rows[0];
223
267
  const lossUsd = inc.amount_usd ?? inc.estimated_loss_usd;
268
+ const date = inc.display_date ?? inc.hack_date ?? inc.created_at;
269
+ const status = (inc.status ?? "unknown").toLowerCase();
224
270
  const lines = [
225
271
  `## Fund Trace: ${inc.title ?? "Unnamed Incident"}`,
226
272
  `**Incident ID:** ${inc.id}`,
227
273
  `**Chain:** ${inc.chain}`,
228
- `**Status:** ${inc.status.toUpperCase()}`,
229
- `**Attacker:** ${truncateAddr(inc.attacker_address)}`,
230
- `**Loss:** ${lossUsd ? `$${(lossUsd / 1_000_000).toFixed(2)}M` : "Unknown"}`,
231
- `**Date:** ${inc.created_at.slice(0, 10)}`,
274
+ `**Status:** ${status.toUpperCase()}`,
275
+ `**Attacker:** ${inc.attacker_address}`,
276
+ `**Loss:** ${usd(lossUsd)}`,
277
+ `**Date:** ${date.slice(0, 10)}`,
232
278
  ];
233
- if (inc.status.toLowerCase() === "traced") {
234
- lines.push(`\n✅ Trace complete — view full flow graph and counterparty exposure on ChainHint.`);
279
+ if (inc.incident_type)
280
+ lines.push(`**Type:** ${inc.incident_type}`);
281
+ if (inc.risk_score != null)
282
+ lines.push(`**Risk Score:** ${inc.risk_score}/100`);
283
+ if (inc.source)
284
+ lines.push(`**Source:** ${inc.source}`);
285
+ if (inc.updated_at)
286
+ lines.push(`**Last traced:** ${inc.updated_at.slice(0, 10)}`);
287
+ const edges = inc.flow_graph?.edges ?? [];
288
+ const nodes = inc.flow_graph?.nodes ?? [];
289
+ if (edges.length) {
290
+ const maxDepth = edges.reduce((m, e) => Math.max(m, e.depth ?? 0), 0);
291
+ lines.push(``, `### Trace Graph`);
292
+ lines.push(`**Hops traced:** ${maxDepth} · **Addresses:** ${nodes.length} · **Transfers:** ${edges.length}`);
293
+ }
294
+ const endpoints = inc.endpoints ?? [];
295
+ if (endpoints.length) {
296
+ const byType = new Map();
297
+ for (const e of endpoints) {
298
+ const t = e.type ?? "unknown";
299
+ const b = byType.get(t) ?? { usd: 0, n: 0, entities: new Map() };
300
+ b.usd += e.amount_usd ?? 0;
301
+ b.n += 1;
302
+ const name = e.entity_name ?? e.entity;
303
+ if (name)
304
+ b.entities.set(name, (b.entities.get(name) ?? 0) + (e.amount_usd ?? 0));
305
+ byType.set(t, b);
306
+ }
307
+ const totalUsd = [...byType.values()].reduce((s, b) => s + b.usd, 0);
308
+ lines.push(``, `### Where the funds went (${endpoints.length} endpoints, ${usd(totalUsd)} tracked)`);
309
+ for (const [t, b] of [...byType.entries()].sort((a, b) => b[1].usd - a[1].usd)) {
310
+ const top = [...b.entities.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([n]) => n);
311
+ const pct = totalUsd > 0 ? ` (${((b.usd / totalUsd) * 100).toFixed(1)}%)` : "";
312
+ lines.push(`- **${t}**: ${usd(b.usd)}${pct} across ${b.n} address(es)${top.length ? ` — ${top.join(", ")}` : ""}`);
313
+ }
314
+ const topEndpoints = [...endpoints]
315
+ .filter((e) => (e.amount_usd ?? 0) > 0)
316
+ .sort((a, b) => (b.amount_usd ?? 0) - (a.amount_usd ?? 0))
317
+ .slice(0, 5);
318
+ if (topEndpoints.length) {
319
+ lines.push(`**Largest endpoints:**`);
320
+ for (const e of topEndpoints) {
321
+ const name = e.entity_name ?? e.entity ?? "unattributed";
322
+ lines.push(`- ${truncateAddr(e.address)} — ${name} (${e.type ?? "unknown"}): ${usd(e.amount_usd)}`);
323
+ }
324
+ }
325
+ }
326
+ const cx = inc.counterparty_exposure;
327
+ if (cx?.risk?.level || cx?.outflow?.length || cx?.inflow?.length) {
328
+ lines.push(``, `### Attacker Counterparty Exposure`);
329
+ if (cx.risk?.level) {
330
+ lines.push(`**Exposure Risk:** ${cx.risk.level.toUpperCase()} (${cx.risk.pct}%)`);
331
+ if (cx.risk.details?.length)
332
+ lines.push(...cx.risk.details.slice(0, 5).map((s) => `- ${s}`));
333
+ }
334
+ lines.push(...formatExposure("Outflow by category", cx.outflow));
335
+ lines.push(...formatExposure("Inflow by category", cx.inflow));
336
+ }
337
+ lines.push(``);
338
+ if (status === "traced") {
339
+ lines.push(`✅ Trace complete — full flow graph and counterparty exposure on ChainHint.`);
235
340
  }
236
- else if (inc.status.toLowerCase() === "analyzing") {
237
- lines.push(`\n⏳ Trace in progress...`);
341
+ else if (status === "analyzing") {
342
+ lines.push(`⏳ Trace in progress...`);
238
343
  }
239
344
  else {
240
- lines.push(`\n⚠️ Status: ${inc.status}`);
345
+ lines.push(`⚠️ Status: ${status}`);
241
346
  }
242
- lines.push(`\n🔗 View full trace: https://chainhint.com/incident/${inc.id}`);
347
+ lines.push(`🔗 View full trace: https://chainhint.com/incident/${inc.id}`);
243
348
  lines.push(`*Powered by ChainHint — chainhint.com*`);
244
349
  return { content: [{ type: "text", text: lines.join("\n") }] };
245
350
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chainhint-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "ChainHint MCP server — crypto risk intelligence tools for Claude Desktop and Cursor",
5
5
  "license": "MIT",
6
6
  "repository": {