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 +7 -35
- package/dist/verdicts.d.ts +59 -0
- package/dist/verdicts.js +110 -0
- package/package.json +4 -3
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.
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
501
|
-
|
|
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;
|
package/dist/verdicts.js
ADDED
|
@@ -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.
|
|
4
|
-
"description": "ChainHint MCP server
|
|
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",
|