chainhint-mcp 1.3.1 → 1.3.3

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.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Address evidence wording — MIRROR of the main repo's
3
+ * backend/supabase/functions/_shared/evidence-labels.ts (migration 129).
4
+ * Change both or neither; test/evidence.test.mjs pins the strings.
5
+ *
6
+ * Rule (owner decision 2026-09-14): "Sanctioned" is printed only for a
7
+ * sanctions designation. A law-enforcement attribution, an issuer freeze, a
8
+ * national seizure order and an industry blocklist each say what they are and
9
+ * that they are not a sanctions designation.
10
+ */
11
+ export declare const EVIDENCE_CLASSES: readonly ["sanctions_designation", "law_enforcement_attribution", "issuer_freeze", "national_seizure_order", "industry_blocklist", "analyst_verified"];
12
+ export type EvidenceClass = typeof EVIDENCE_CLASSES[number];
13
+ export interface EvidenceItem {
14
+ class: EvidenceClass | string;
15
+ authority: string;
16
+ subject?: string | null;
17
+ document_ref?: string | null;
18
+ document_url?: string | null;
19
+ document_date?: string | null;
20
+ legal_basis?: string | null;
21
+ token?: string | null;
22
+ chain?: string | null;
23
+ first_seen_at?: string | null;
24
+ }
25
+ export declare const NOT_A_SANCTIONS_DESIGNATION = "Not a sanctions designation.";
26
+ /** "⛔ Sanctioned — US OFAC · … " / "🧊 Frozen by issuer — Tether (USDT, TRON) · … Not a sanctions designation." */
27
+ export declare function evidenceText(e: EvidenceItem): string | null;
28
+ /** Designations first, then by strength of proof; unknown classes dropped. */
29
+ export declare function evidenceLines(items: ReadonlyArray<EvidenceItem> | null | undefined): string[];
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Address evidence wording — MIRROR of the main repo's
3
+ * backend/supabase/functions/_shared/evidence-labels.ts (migration 129).
4
+ * Change both or neither; test/evidence.test.mjs pins the strings.
5
+ *
6
+ * Rule (owner decision 2026-09-14): "Sanctioned" is printed only for a
7
+ * sanctions designation. A law-enforcement attribution, an issuer freeze, a
8
+ * national seizure order and an industry blocklist each say what they are and
9
+ * that they are not a sanctions designation.
10
+ */
11
+ export const EVIDENCE_CLASSES = [
12
+ "sanctions_designation",
13
+ "law_enforcement_attribution",
14
+ "issuer_freeze",
15
+ "national_seizure_order",
16
+ "industry_blocklist",
17
+ "analyst_verified",
18
+ ];
19
+ export const NOT_A_SANCTIONS_DESIGNATION = "Not a sanctions designation.";
20
+ const TITLES = {
21
+ sanctions_designation: { icon: "⛔", title: "Sanctioned" },
22
+ law_enforcement_attribution: { icon: "🚩", title: "Law-enforcement attribution" },
23
+ issuer_freeze: { icon: "🧊", title: "Frozen by issuer" },
24
+ national_seizure_order: { icon: "🏛️", title: "National seizure order" },
25
+ industry_blocklist: { icon: "⚠️", title: "Industry blocklist" },
26
+ analyst_verified: { icon: "🔎", title: "Analyst-verified" },
27
+ };
28
+ const CHAIN_NAMES = { bsc: "BSC", tron: "TRON", ton: "TON" };
29
+ function isEvidenceClass(v) {
30
+ return typeof v === "string" && EVIDENCE_CLASSES.includes(v);
31
+ }
32
+ function chainName(chain) {
33
+ const c = (chain ?? "").trim().toLowerCase();
34
+ if (!c)
35
+ return "";
36
+ return CHAIN_NAMES[c] ?? c.charAt(0).toUpperCase() + c.slice(1);
37
+ }
38
+ function day(value) {
39
+ if (!value)
40
+ return null;
41
+ const m = /^(\d{4}-\d{2}-\d{2})/.exec(value.trim());
42
+ return m ? m[1] : null;
43
+ }
44
+ const join = (parts, sep) => parts.filter((p) => !!p && p.trim() !== "").join(sep);
45
+ function attributionLine(e) {
46
+ const date = day(e.document_date);
47
+ let head = join([e.authority, e.document_ref], ", ");
48
+ if (date)
49
+ head = `${head} (${date})`;
50
+ return e.subject ? `${head}: ${e.subject}` : head;
51
+ }
52
+ /** "⛔ Sanctioned — US OFAC · … " / "🧊 Frozen by issuer — Tether (USDT, TRON) · … Not a sanctions designation." */
53
+ export function evidenceText(e) {
54
+ if (!isEvidenceClass(e.class))
55
+ return null;
56
+ const { icon, title } = TITLES[e.class];
57
+ const date = day(e.document_date);
58
+ let line;
59
+ switch (e.class) {
60
+ case "sanctions_designation":
61
+ line = join([e.authority, e.document_ref ?? e.legal_basis, date, e.subject], " · ");
62
+ break;
63
+ case "issuer_freeze": {
64
+ const what = join([e.token, chainName(e.chain)], ", ");
65
+ const when = date ?? join(["first seen by ChainHint", day(e.first_seen_at)], " ");
66
+ line = join([what ? `${e.authority} (${what})` : e.authority, when], " · ");
67
+ break;
68
+ }
69
+ case "law_enforcement_attribution":
70
+ case "national_seizure_order":
71
+ line = attributionLine(e);
72
+ break;
73
+ default:
74
+ line = join([e.authority, e.subject, date ?? (e.first_seen_at ? `first seen by ChainHint ${day(e.first_seen_at)}` : null)], " · ");
75
+ }
76
+ const caveat = e.class === "sanctions_designation" ? null : NOT_A_SANCTIONS_DESIGNATION;
77
+ return `${icon} ${caveat ? `${title} — ${line}. ${caveat}` : `${title} — ${line}`}`;
78
+ }
79
+ /** Designations first, then by strength of proof; unknown classes dropped. */
80
+ export function evidenceLines(items) {
81
+ if (!items?.length)
82
+ return [];
83
+ const rank = (c) => EVIDENCE_CLASSES.indexOf(c);
84
+ return [...items]
85
+ .filter((e) => isEvidenceClass(e.class))
86
+ .sort((a, b) => rank(a.class) - rank(b.class))
87
+ .map((e) => `- ${evidenceText(e)}`);
88
+ }
package/dist/index.js CHANGED
@@ -17,12 +17,14 @@
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";
21
+ import { evidenceLines } from "./evidence.js";
20
22
  // ── Config ────────────────────────────────────────────────────────────────────
21
23
  const API_KEY = process.env.CHAINHINT_API_KEY;
22
24
  const BASE_URL = process.env.CHAINHINT_API_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co/functions/v1";
23
25
  const SUPABASE_URL = process.env.CHAINHINT_SUPABASE_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co";
24
26
  const SUPABASE_ANON_KEY = process.env.CHAINHINT_SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtqaXdmd3ltbnV6eHJpb2toY2prIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI3MzkxODgsImV4cCI6MjA4ODMxNTE4OH0.VqzzF_jI8zF072cbjWEDbYo3PnMDlIPy621iWkXEqyo";
25
- const VERSION = "1.3.1";
27
+ const VERSION = "1.3.3";
26
28
  const USER_AGENT = `chainhint-mcp/${VERSION}`;
27
29
  const FREE_CHECKS_PER_DAY = 3;
28
30
  const UPGRADE_HINT = "set CHAINHINT_API_KEY (Agency plan, https://chainhint.com/pricing) for 10,000/day";
@@ -49,11 +51,7 @@ async function apiGet(path, params) {
49
51
  const res = await fetch(url.toString(), { headers });
50
52
  const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` })));
51
53
  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);
54
+ throw new Error(apiErrorMessage(res.status, body?.error, url.toString(), body?.reset_at));
57
55
  }
58
56
  return { body, headers: res.headers };
59
57
  }
@@ -91,17 +89,6 @@ async function supabaseGet(table, params) {
91
89
  return res.json();
92
90
  }
93
91
  // ── 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
92
  function truncateAddr(addr) {
106
93
  return addr.length > 12 ? `${addr.slice(0, 8)}...${addr.slice(-6)}` : addr;
107
94
  }
@@ -270,9 +257,14 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
270
257
  else {
271
258
  lines.push(`**Risk Score:** ${d.risk_score}/100 — **${(d.risk_level ?? formatRiskLevel(d.risk_score)).toUpperCase()}**`);
272
259
  }
273
- if (d.sanctions?.hit) {
274
- lines.push(`⛔ **SANCTIONED / OFAC-linked**`);
260
+ // sanctions.hit comes only from a sanctions designation (API since migration 129);
261
+ // the evidence lines say which authority and that other classes are not sanctions.
262
+ const walletEvidence = evidenceLines(d.evidence);
263
+ if (d.sanctions?.hit && !walletEvidence.some((l) => l.startsWith("- ⛔"))) {
264
+ lines.push(`⛔ **SANCTIONED**`);
275
265
  }
266
+ if (walletEvidence.length)
267
+ lines.push(`**Evidence:**`, ...walletEvidence);
276
268
  if (d.entity?.name) {
277
269
  const sub = d.entity.subcategory ? ` / ${d.entity.subcategory}` : "";
278
270
  const ver = d.entity.verified ? ", verified" : "";
@@ -340,15 +332,11 @@ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity a
340
332
  }
341
333
  if (d.labels?.length)
342
334
  lines.push(`**Labels:** ${[...new Set(d.labels)].join(", ")}`);
343
- if (d.risk) {
344
- const level = (d.risk.level ?? formatRiskLevel(d.risk.score)).toUpperCase();
345
- lines.push(`**Risk Score:** ${d.risk.score}/100 — **${level}**`);
346
- const factors = Object.entries(d.risk.factors ?? {}).filter(([, v]) => v).map(([k]) => k);
347
- if (factors.length)
348
- lines.push(`**Risk Factors:** ${factors.join(", ")}`);
349
- if (d.risk.details?.length)
350
- lines.push(`**Risk Details:** ${d.risk.details.join("; ")}`);
351
- }
335
+ // Never a score or "CLEAN" when there was nothing to score (verdicts.ts).
336
+ lines.push(...lookupRiskLines(d));
337
+ const lookupEvidence = evidenceLines(d.evidence);
338
+ if (lookupEvidence.length)
339
+ lines.push(`**Evidence:**`, ...lookupEvidence);
352
340
  const sanctionIds = d.sanctions?.identifications ?? [];
353
341
  if (sanctionIds.length) {
354
342
  const names = sanctionIds.map((s) => [s.name, s.program].filter(Boolean).join(" / ")).filter(Boolean);
@@ -497,15 +485,8 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
497
485
  lines.push(...formatExposure("Inflow by category", cx.inflow));
498
486
  }
499
487
  lines.push(``);
500
- if (status === "traced") {
501
- lines.push(`✅ Trace complete full flow graph and counterparty exposure on ChainHint.`);
502
- }
503
- else if (status === "analyzing") {
504
- lines.push(`⏳ Trace in progress...`);
505
- }
506
- else {
507
- lines.push(`⚠️ Status: ${status}`);
508
- }
488
+ // "✅ Trace complete" only for a trace that holds transfers (verdicts.ts).
489
+ lines.push(traceStatusLine({ status, chain: inc.chain, attacker_address: inc.attacker_address, edgeCount: edges.length }));
509
490
  lines.push(`🔗 View full trace: https://chainhint.com/incident/${inc.id}`);
510
491
  lines.push(`*Powered by ChainHint — chainhint.com*`);
511
492
  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.1",
4
- "description": "ChainHint MCP server crypto risk intelligence tools for Claude Desktop and Cursor",
3
+ "version": "1.3.3",
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",