chainhint-mcp 1.3.1 → 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/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.3.1";
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
  }
@@ -340,15 +326,8 @@ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity a
340
326
  }
341
327
  if (d.labels?.length)
342
328
  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
- }
329
+ // Never a score or "CLEAN" when there was nothing to score (verdicts.ts).
330
+ lines.push(...lookupRiskLines(d));
352
331
  const sanctionIds = d.sanctions?.identifications ?? [];
353
332
  if (sanctionIds.length) {
354
333
  const names = sanctionIds.map((s) => [s.name, s.program].filter(Boolean).join(" / ")).filter(Boolean);
@@ -497,15 +476,8 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
497
476
  lines.push(...formatExposure("Inflow by category", cx.inflow));
498
477
  }
499
478
  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
- }
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 }));
509
481
  lines.push(`🔗 View full trace: https://chainhint.com/incident/${inc.id}`);
510
482
  lines.push(`*Powered by ChainHint — chainhint.com*`);
511
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.1",
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",