chainhint-mcp 1.3.0 → 1.3.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 CHANGED
@@ -86,6 +86,8 @@ Add an Agency key to the server entry (any client):
86
86
  Free tier: 2 of 3 checks left today — set CHAINHINT_API_KEY (Agency plan, https://chainhint.com/pricing) for 10,000/day.
87
87
  ```
88
88
 
89
+ When the address is in ChainHint's agent-infrastructure registry (agent-token launchpads, deployer factories, routers, payment facilitators, known agent wallets), the report adds an `🤖` line, e.g. *Known agent infrastructure: Virtuals launchpad* — a registry fact you can verify at its source, not a behavioural classification and not part of the risk score.
90
+
89
91
  ## Development (no build step)
90
92
 
91
93
  ```bash
package/dist/index.js CHANGED
@@ -17,12 +17,13 @@
17
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
19
  import { z } from "zod";
20
+ import { apiErrorMessage, formatRiskLevel, lookupRiskLines, traceStatusLine } from "./verdicts.js";
20
21
  // ── Config ────────────────────────────────────────────────────────────────────
21
22
  const API_KEY = process.env.CHAINHINT_API_KEY;
22
23
  const BASE_URL = process.env.CHAINHINT_API_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co/functions/v1";
23
24
  const SUPABASE_URL = process.env.CHAINHINT_SUPABASE_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co";
24
25
  const SUPABASE_ANON_KEY = process.env.CHAINHINT_SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtqaXdmd3ltbnV6eHJpb2toY2prIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI3MzkxODgsImV4cCI6MjA4ODMxNTE4OH0.VqzzF_jI8zF072cbjWEDbYo3PnMDlIPy621iWkXEqyo";
25
- const VERSION = "1.1.0";
26
+ const VERSION = "1.3.2";
26
27
  const USER_AGENT = `chainhint-mcp/${VERSION}`;
27
28
  const FREE_CHECKS_PER_DAY = 3;
28
29
  const UPGRADE_HINT = "set CHAINHINT_API_KEY (Agency plan, https://chainhint.com/pricing) for 10,000/day";
@@ -49,11 +50,7 @@ async function apiGet(path, params) {
49
50
  const res = await fetch(url.toString(), { headers });
50
51
  const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` })));
51
52
  if (!res.ok) {
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);
53
+ throw new Error(apiErrorMessage(res.status, body?.error, url.toString(), body?.reset_at));
57
54
  }
58
55
  return { body, headers: res.headers };
59
56
  }
@@ -91,17 +88,6 @@ async function supabaseGet(table, params) {
91
88
  return res.json();
92
89
  }
93
90
  // ── Format helpers ────────────────────────────────────────────────────────────
94
- function formatRiskLevel(score) {
95
- if (score >= 90)
96
- return "CRITICAL";
97
- if (score >= 70)
98
- return "HIGH";
99
- if (score >= 40)
100
- return "MEDIUM";
101
- if (score >= 10)
102
- return "LOW";
103
- return "CLEAN";
104
- }
105
91
  function truncateAddr(addr) {
106
92
  return addr.length > 12 ? `${addr.slice(0, 8)}...${addr.slice(-6)}` : addr;
107
93
  }
@@ -196,11 +182,29 @@ function formatExposure(label, buckets) {
196
182
  }),
197
183
  ];
198
184
  }
185
+ /**
186
+ * Same rule as chainhint.com (src/lib/attackerAttribution.ts): an address is
187
+ * "the attacker" only when the attribution was checked against the exploit
188
+ * transaction (attacker_address_source = analyst_verified). Everything else is
189
+ * a report — shown as one, and never as a HIGH-risk verdict.
190
+ */
191
+ function attackerAttribution(inc) {
192
+ if (!inc.attacker_address)
193
+ return { verified: false, label: "Attacker not identified" };
194
+ if (inc.attacker_address_source === "analyst_verified")
195
+ return { verified: true, label: "Attacker" };
196
+ return {
197
+ verified: false,
198
+ label: inc.source === "defillama"
199
+ ? "Reported attacker (DeFiLlama), unverified"
200
+ : "Reported attacker (user-provided), unverified",
201
+ };
202
+ }
199
203
  /** Public incident whose attacker_address matches, or null (errors swallowed — best effort). */
200
204
  async function findPublicIncidentByAttacker(address) {
201
205
  try {
202
206
  const rows = await supabaseGet("public_incidents_view", {
203
- select: "id,title,chain,amount_usd,estimated_loss_usd,risk_score",
207
+ select: "id,title,chain,amount_usd,estimated_loss_usd,risk_score,source,attacker_address_source",
204
208
  attacker_address: `eq.${normalizeAddr(address)}`,
205
209
  limit: "1",
206
210
  });
@@ -241,7 +245,11 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
241
245
  lines.push(`**Risk:** âšĒ NO DATA — address is not in ChainHint's labeled database. This is not evidence it is clean.`);
242
246
  const inc = await findPublicIncidentByAttacker(d.address);
243
247
  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}`);
248
+ const attribution = attackerAttribution({ attacker_address: d.address, ...inc });
249
+ const where = `${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` : ""})`;
250
+ lines.push(attribution.verified
251
+ ? `âš ī¸ **Verified attacker in a public hack incident:** ${where}. The attribution was checked against the exploit transaction. Treat as HIGH risk.`
252
+ : `â„šī¸ **${attribution.label}, in a public hack incident:** ${where}. This attribution has not been verified against the exploit transaction — treat it as a lead to check, not as a risk verdict.`, `🔗 https://chainhint.com/incident/${inc.id}`);
245
253
  }
246
254
  lines.push(`Use lookup_address for an on-chain assessment (risk factors, GoPlus flags, counterparty exposure).`);
247
255
  }
@@ -318,15 +326,8 @@ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity a
318
326
  }
319
327
  if (d.labels?.length)
320
328
  lines.push(`**Labels:** ${[...new Set(d.labels)].join(", ")}`);
321
- if (d.risk) {
322
- const level = (d.risk.level ?? formatRiskLevel(d.risk.score)).toUpperCase();
323
- lines.push(`**Risk Score:** ${d.risk.score}/100 — **${level}**`);
324
- const factors = Object.entries(d.risk.factors ?? {}).filter(([, v]) => v).map(([k]) => k);
325
- if (factors.length)
326
- lines.push(`**Risk Factors:** ${factors.join(", ")}`);
327
- if (d.risk.details?.length)
328
- lines.push(`**Risk Details:** ${d.risk.details.join("; ")}`);
329
- }
329
+ // Never a score or "CLEAN" when there was nothing to score (verdicts.ts).
330
+ lines.push(...lookupRiskLines(d));
330
331
  const sanctionIds = d.sanctions?.identifications ?? [];
331
332
  if (sanctionIds.length) {
332
333
  const names = sanctionIds.map((s) => [s.name, s.program].filter(Boolean).join(" / ")).filter(Boolean);
@@ -375,7 +376,7 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
375
376
  // public_incidents_view is the canonical anonymous read path (SECURITY DEFINER,
376
377
  // exposes only is_public rows and only public-safe columns).
377
378
  const queryParams = {
378
- 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",
379
+ select: "id,title,status,chain,attacker_address,attacker_address_source,amount_usd,estimated_loss_usd,risk_score,incident_type,hack_date,display_date,created_at,updated_at,source,endpoints,flow_graph,counterparty_exposure",
379
380
  limit: "1",
380
381
  order: "created_at.desc",
381
382
  };
@@ -406,7 +407,7 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
406
407
  `**Incident ID:** ${inc.id}`,
407
408
  `**Chain:** ${inc.chain}`,
408
409
  `**Status:** ${status.toUpperCase()}`,
409
- `**Attacker:** ${inc.attacker_address}`,
410
+ `**${attackerAttribution(inc).label}:** ${inc.attacker_address ?? "—"}`,
410
411
  `**Loss:** ${usd(lossUsd)}`,
411
412
  `**Date:** ${date.slice(0, 10)}`,
412
413
  ];
@@ -475,15 +476,8 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
475
476
  lines.push(...formatExposure("Inflow by category", cx.inflow));
476
477
  }
477
478
  lines.push(``);
478
- if (status === "traced") {
479
- lines.push(`✅ Trace complete — full flow graph and counterparty exposure on ChainHint.`);
480
- }
481
- else if (status === "analyzing") {
482
- lines.push(`âŗ Trace in progress...`);
483
- }
484
- else {
485
- lines.push(`âš ī¸ Status: ${status}`);
486
- }
479
+ // "✅ Trace complete" only for a trace that holds transfers (verdicts.ts).
480
+ lines.push(traceStatusLine({ status, chain: inc.chain, attacker_address: inc.attacker_address, edgeCount: edges.length }));
487
481
  lines.push(`🔗 View full trace: https://chainhint.com/incident/${inc.id}`);
488
482
  lines.push(`*Powered by ChainHint — chainhint.com*`);
489
483
  return { content: [{ type: "text", text: lines.join("\n") }] };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Verdict wording — pure functions, tested in test/verdicts.test.mjs.
3
+ *
4
+ * One rule, the same as chainhint.com (KIR-83): when ChainHint could not read
5
+ * the data, the tool says so. A missing input is never printed as "CLEAN",
6
+ * "0/100" or "Trace complete".
7
+ *
8
+ * 1.3.1 and earlier printed "Risk Score: 0/100 — CLEAN" for a Solana address
9
+ * whose transfer history could not be read (the API already returned
10
+ * risk_status "unavailable") and "✅ Trace complete" for an incident whose
11
+ * stored trace had no transfers.
12
+ */
13
+ /**
14
+ * The 12 chains ChainHint traces. Mirror of CHAINS in the backend
15
+ * (backend/supabase/functions/_shared/address-validation.ts in the main repo).
16
+ */
17
+ export declare const TRACEABLE_CHAINS: readonly ["ethereum", "bsc", "polygon", "arbitrum", "optimism", "base", "avalanche", "gnosis", "bitcoin", "solana", "tron", "ton"];
18
+ export declare function isTraceableChain(chain: string | null | undefined): boolean;
19
+ /** Same sentence as the backend's tracingUnavailableMessage. */
20
+ export declare function tracingUnavailableMessage(chain: string | null | undefined): string;
21
+ export declare function formatRiskLevel(score: number): string;
22
+ export type DataUnavailable = {
23
+ provider?: string;
24
+ reason?: string;
25
+ };
26
+ export interface LookupRiskInput {
27
+ risk?: {
28
+ score: number;
29
+ level?: string;
30
+ factors?: Record<string, boolean>;
31
+ details?: string[];
32
+ };
33
+ /** address-lookup: "scored" | "insufficient_data" | "unavailable". Absent on old API versions. */
34
+ risk_status?: string;
35
+ data_availability?: {
36
+ transfers?: string;
37
+ provider?: string;
38
+ reason?: string;
39
+ detail?: string;
40
+ };
41
+ exposure?: {
42
+ data_unavailable?: DataUnavailable | null;
43
+ } | null;
44
+ }
45
+ /** The risk lines of lookup_address. Never a score when there was nothing to score. */
46
+ export declare function lookupRiskLines(d: LookupRiskInput): string[];
47
+ export interface TraceStatusInput {
48
+ status: string | null | undefined;
49
+ chain: string | null | undefined;
50
+ attacker_address: string | null | undefined;
51
+ edgeCount: number;
52
+ }
53
+ /** The closing status line of get_trace_status. "✅" only for a trace that holds transfers. */
54
+ export declare function traceStatusLine(inc: TraceStatusInput): string;
55
+ /**
56
+ * HTTP errors. A 422 is ChainHint's final answer ("Tracing not available for
57
+ * Sui") — say so, so an agent does not retry it as a transient failure.
58
+ */
59
+ export declare function apiErrorMessage(status: number, bodyError: string | undefined, url: string, resetAt?: number): string;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Verdict wording — pure functions, tested in test/verdicts.test.mjs.
3
+ *
4
+ * One rule, the same as chainhint.com (KIR-83): when ChainHint could not read
5
+ * the data, the tool says so. A missing input is never printed as "CLEAN",
6
+ * "0/100" or "Trace complete".
7
+ *
8
+ * 1.3.1 and earlier printed "Risk Score: 0/100 — CLEAN" for a Solana address
9
+ * whose transfer history could not be read (the API already returned
10
+ * risk_status "unavailable") and "✅ Trace complete" for an incident whose
11
+ * stored trace had no transfers.
12
+ */
13
+ /**
14
+ * The 12 chains ChainHint traces. Mirror of CHAINS in the backend
15
+ * (backend/supabase/functions/_shared/address-validation.ts in the main repo).
16
+ */
17
+ export const TRACEABLE_CHAINS = [
18
+ "ethereum", "bsc", "polygon", "arbitrum", "optimism", "base",
19
+ "avalanche", "gnosis", "bitcoin", "solana", "tron", "ton",
20
+ ];
21
+ export function isTraceableChain(chain) {
22
+ return typeof chain === "string" && TRACEABLE_CHAINS.includes(chain.trim().toLowerCase());
23
+ }
24
+ /** Same sentence as the backend's tracingUnavailableMessage. */
25
+ export function tracingUnavailableMessage(chain) {
26
+ const c = (chain ?? "").trim();
27
+ if (!c || c.toLowerCase() === "unknown")
28
+ return "Tracing not available — the chain is unknown";
29
+ return `Tracing not available for ${c.charAt(0).toUpperCase()}${c.slice(1)}`;
30
+ }
31
+ export function formatRiskLevel(score) {
32
+ if (score >= 90)
33
+ return "CRITICAL";
34
+ if (score >= 70)
35
+ return "HIGH";
36
+ if (score >= 40)
37
+ return "MEDIUM";
38
+ if (score >= 10)
39
+ return "LOW";
40
+ return "CLEAN";
41
+ }
42
+ function unavailableDetail(d) {
43
+ const provider = d.data_availability?.provider ?? d.exposure?.data_unavailable?.provider;
44
+ return d.data_availability?.detail
45
+ ?? `transfer data${provider ? ` (${provider})` : ""} could not be read`;
46
+ }
47
+ /** The API's detail may already say "not a clean result" — don't say it twice. */
48
+ function notClean(detail) {
49
+ return /not a clean result/i.test(detail) ? "" : " This is not a clean result.";
50
+ }
51
+ /** The risk lines of lookup_address. Never a score when there was nothing to score. */
52
+ export function lookupRiskLines(d) {
53
+ if (d.risk_status === "unavailable") {
54
+ const why = unavailableDetail(d);
55
+ return [`**Risk:** âšĒ DATA UNAVAILABLE — ${why}.${notClean(why)} No risk score was computed.`];
56
+ }
57
+ if (d.risk_status === "insufficient_data") {
58
+ return [
59
+ `**Risk:** âšĒ NOT SCORED — insufficient data. This is not evidence the address is clean.`,
60
+ ];
61
+ }
62
+ if (!d.risk)
63
+ return [];
64
+ const level = (d.risk.level ?? formatRiskLevel(d.risk.score)).toUpperCase();
65
+ const lines = [`**Risk Score:** ${d.risk.score}/100 — **${level}**`];
66
+ const factors = Object.entries(d.risk.factors ?? {}).filter(([, v]) => v).map(([k]) => k);
67
+ if (factors.length)
68
+ lines.push(`**Risk Factors:** ${factors.join(", ")}`);
69
+ if (d.risk.details?.length)
70
+ lines.push(`**Risk Details:** ${d.risk.details.join("; ")}`);
71
+ // Scored from other inputs (DB entity, sanctions), but the transfer side was missing.
72
+ if (d.exposure?.data_unavailable || d.data_availability?.transfers === "unavailable") {
73
+ const why = unavailableDetail(d);
74
+ lines.push(`**Counterparty Exposure:** not scored — ${why}.${notClean(why)} The score above does not include transfer exposure.`);
75
+ }
76
+ return lines;
77
+ }
78
+ /** The closing status line of get_trace_status. "✅" only for a trace that holds transfers. */
79
+ export function traceStatusLine(inc) {
80
+ const status = (inc.status ?? "unknown").toLowerCase();
81
+ if (!isTraceableChain(inc.chain)) {
82
+ return `âšĒ ${tracingUnavailableMessage(inc.chain)}. ChainHint traces 12 networks; this incident and its loss figure come from the hack feed, and no fund flow was fetched. That is final, and it is not evidence that the funds did not move.`;
83
+ }
84
+ if (inc.edgeCount > 0 && (status === "traced" || status === "monitoring")) {
85
+ return `✅ Trace complete — full flow graph and counterparty exposure on ChainHint.`;
86
+ }
87
+ if (status === "analyzing")
88
+ return `âŗ Trace in progress...`;
89
+ if (!inc.attacker_address) {
90
+ return `âšĒ Not traced — this incident has no attacker address to trace from.`;
91
+ }
92
+ if (status === "traced" || status === "monitoring") {
93
+ return `âš ī¸ No transfers in the stored trace. This is not evidence that the funds are dormant: the chain's transfer provider may have been unavailable, or the address may not belong to this chain.`;
94
+ }
95
+ return `âš ī¸ Status: ${status}`;
96
+ }
97
+ /**
98
+ * HTTP errors. A 422 is ChainHint's final answer ("Tracing not available for
99
+ * Sui") — say so, so an agent does not retry it as a transient failure.
100
+ */
101
+ export function apiErrorMessage(status, bodyError, url, resetAt) {
102
+ let msg = bodyError ?? `HTTP ${status}: ${url}`;
103
+ if (status === 422)
104
+ msg += " (final answer — not a temporary error; do not retry)";
105
+ if (status === 429 && resetAt)
106
+ msg += ` Resets at ${new Date(resetAt * 1000).toISOString()}.`;
107
+ if (status === 503)
108
+ msg += " (the data provider is unavailable — this is not a clean result)";
109
+ return msg;
110
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "chainhint-mcp",
3
- "version": "1.3.0",
4
- "description": "ChainHint MCP server — crypto risk intelligence tools for Claude Desktop and Cursor",
3
+ "version": "1.3.2",
4
+ "description": "ChainHint MCP server \u2014 crypto risk intelligence tools for Claude Desktop and Cursor",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -35,7 +35,8 @@
35
35
  "build": "tsc",
36
36
  "dev": "tsx src/index.ts",
37
37
  "start": "node dist/index.js",
38
- "prepublishOnly": "npm run build"
38
+ "prepublishOnly": "npm run build",
39
+ "test": "npm run build && node --test test/*.test.mjs"
39
40
  },
40
41
  "dependencies": {
41
42
  "@modelcontextprotocol/sdk": "^1.0.0",