chainhint-mcp 1.3.3 → 1.4.0

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,7 +6,7 @@ Crypto risk intelligence for Claude Desktop, Cursor, and any MCP-compatible AI.
6
6
 
7
7
  | Tool | Description | Limit |
8
8
  |------|-------------|-------|
9
- | `check_wallet_risk` | Fast risk score, entity, labels, sanctions hit (54M+ labeled addresses) | **3 / day** free · 10,000 / day with key |
9
+ | `check_wallet_risk` | Fast risk score, entity, labels, sanctions hit — 45M+ attributed addresses across 12 chains, including 485K+ linked to illicit activity and 1,000+ sanctioned addresses across 5 authorities (OFAC, EU, AU, JP, FR) | **3 / day** free · 10,000 / day with key |
10
10
  | `lookup_address` | Deep address report — entity, risk factors, GoPlus flags, counterparty exposure, balance | **10 / day** free · 100 / hour with key |
11
11
  | `get_trace_status` | Fund-trace summary for a public hack incident — hops, endpoints by type (exchange/mixer/bridge/defi), exposure | Unlimited |
12
12
 
@@ -88,6 +88,14 @@ Free tier: 2 of 3 checks left today — set CHAINHINT_API_KEY (Agency plan, http
88
88
 
89
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
90
 
91
+ Evidence and entity lines say what the finding is, never more:
92
+
93
+ - `⛔ Sanctioned` only for a sanctions designation; an issuer freeze, a law-enforcement attribution, a seizure order or a blocklist each says *Not a sanctions designation.*
94
+ - A freeze of the tokens held by a token's own contract (Tether blacklisted the USDT held by the USDT contract) prints *Tether blacklisted tokens held by this contract* — it carries no risk weight and is not a "Frozen by issuer" verdict.
95
+ - An entity whose attribution is unconfirmed is printed as a candidate (*⚠️ Attribution unconfirmed*) and a venue that no longer operates is marked *Venue closed* or *Bankrupt*.
96
+
97
+ Addresses: EVM `0x…`, Bitcoin, Solana, TRON and TON — for TON any mainnet form works (friendly `EQ…`/`UQ…`/`Ef…`/`Uf…` or raw `0:<hex>`); testnet `kQ…`/`0Q…` is not covered.
98
+
91
99
  ## Development (no build step)
92
100
 
93
101
  ```bash
@@ -96,11 +104,13 @@ npm run dev
96
104
 
97
105
  ## Environment Variables
98
106
 
107
+ > **Upgrade to 1.4.0.** Versions before 1.4.0 send ChainHint's legacy Supabase anon key, which is being retired: `get_trace_status` on those versions will return 401. `check_wallet_risk` and `lookup_address` are unaffected. `npx chainhint-mcp@latest` picks up 1.4.0.
108
+
99
109
  | Variable | Required | Description |
100
110
  |----------|----------|-------------|
101
111
  | `CHAINHINT_API_KEY` | — | Agency plan key from chainhint.com. Lifts the free limits (3 checks + 10 lookups per day) to 10,000 / day |
102
112
  | `CHAINHINT_API_URL` | — | Override API base URL (default: production) |
103
- | `CHAINHINT_SUPABASE_ANON_KEY` | — | Override anon key for get_trace_status (public incidents) |
113
+ | `CHAINHINT_SUPABASE_PUBLISHABLE_KEY` | — | Override the Supabase publishable key used by get_trace_status (public incidents). Sent only in `apikey`. The pre-1.4.0 name `CHAINHINT_SUPABASE_ANON_KEY` is still read |
104
114
 
105
115
  ## Example prompts
106
116
 
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Address input handling — pure functions, tested in test/address.test.mjs.
3
+ *
4
+ * The MCP does not validate addresses: the ChainHint API does (400 with a
5
+ * format hint) and resolves every spelling of an address to its stored form
6
+ * (main repo: backend/supabase/functions/_shared/address-canon.ts). The tools
7
+ * only trim and lower-case EVM hex. Anything else is passed exactly as typed:
8
+ * base58 chains (BTC/TRON/SOL) are case-sensitive, and TON friendly addresses
9
+ * (EQ…/UQ…/Ef…/Uf…) carry a checksum over their case.
10
+ */
11
+ /** EVM → lower case; every other chain's address is kept as typed (trimmed). */
12
+ export declare function normalizeAddr(addr: string): string;
13
+ /**
14
+ * Shape of a TON address, no checksum: "raw" (0:/-1: + 64 hex), "friendly"
15
+ * (48-char mainnet EQ/UQ/Ef/Uf), "testnet" (kQ/0Q/kf/0f — ChainHint does not
16
+ * cover testnet), or null. Informational only; the API decides validity.
17
+ */
18
+ export declare function tonAddressForm(addr: string): "raw" | "friendly" | "testnet" | null;
19
+ /** Tool parameter text: which address spellings the tools accept. */
20
+ export declare const ADDRESS_FORMS = "EVM 0x\u2026, Bitcoin (1\u2026/3\u2026/bc1\u2026), Solana, TRON (T\u2026), or TON \u2014 mainnet friendly EQ\u2026/UQ\u2026/Ef\u2026/Uf\u2026 or raw 0:<hex>/-1:<hex>, any form finds the same address (testnet kQ\u2026/0Q\u2026 is not covered)";
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Address input handling — pure functions, tested in test/address.test.mjs.
3
+ *
4
+ * The MCP does not validate addresses: the ChainHint API does (400 with a
5
+ * format hint) and resolves every spelling of an address to its stored form
6
+ * (main repo: backend/supabase/functions/_shared/address-canon.ts). The tools
7
+ * only trim and lower-case EVM hex. Anything else is passed exactly as typed:
8
+ * base58 chains (BTC/TRON/SOL) are case-sensitive, and TON friendly addresses
9
+ * (EQ…/UQ…/Ef…/Uf…) carry a checksum over their case.
10
+ */
11
+ /** EVM → lower case; every other chain's address is kept as typed (trimmed). */
12
+ export function normalizeAddr(addr) {
13
+ const a = addr.trim();
14
+ return /^0x[0-9a-fA-F]{40}$/.test(a) ? a.toLowerCase() : a;
15
+ }
16
+ /**
17
+ * Shape of a TON address, no checksum: "raw" (0:/-1: + 64 hex), "friendly"
18
+ * (48-char mainnet EQ/UQ/Ef/Uf), "testnet" (kQ/0Q/kf/0f — ChainHint does not
19
+ * cover testnet), or null. Informational only; the API decides validity.
20
+ */
21
+ export function tonAddressForm(addr) {
22
+ const a = addr.trim();
23
+ if (/^(0|-1):[0-9a-fA-F]{64}$/.test(a))
24
+ return "raw";
25
+ if (!/^[A-Za-z0-9_+/-]{48}$/.test(a))
26
+ return null;
27
+ if (/^(EQ|UQ|Ef|Uf)/.test(a))
28
+ return "friendly";
29
+ if (/^(kQ|0Q|kf|0f)/.test(a))
30
+ return "testnet";
31
+ return null;
32
+ }
33
+ /** Tool parameter text: which address spellings the tools accept. */
34
+ export const ADDRESS_FORMS = "EVM 0x…, Bitcoin (1…/3…/bc1…), Solana, TRON (T…), or TON — mainnet friendly EQ…/UQ…/Ef…/Uf… or raw 0:<hex>/-1:<hex>, any form finds the same address (testnet kQ…/0Q… is not covered)";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Public database figures — the ChainHint canon (owner decision 2026-09-15,
3
+ * main repo migration 143, public/llms.txt). test/canon.test.mjs pins it and
4
+ * forbids the retired address / entity counts in the source and the README.
5
+ * Change only together with the site.
6
+ */
7
+ export declare const ATTRIBUTED_ADDRESSES = "45M+";
8
+ export declare const ILLICIT_ADDRESSES = "485K+";
9
+ export declare const SANCTIONED_ADDRESSES = "1,000+";
10
+ export declare const SANCTIONS_AUTHORITIES = "OFAC, EU, AU, JP, FR";
11
+ /** "45M+ attributed addresses across 12 chains, including 485K+ …" */
12
+ export declare const DATABASE_CANON = "45M+ attributed addresses across 12 chains, including 485K+ addresses linked to illicit activity and 1,000+ sanctioned addresses across 5 authorities (OFAC, EU, AU, JP, FR)";
package/dist/canon.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Public database figures — the ChainHint canon (owner decision 2026-09-15,
3
+ * main repo migration 143, public/llms.txt). test/canon.test.mjs pins it and
4
+ * forbids the retired address / entity counts in the source and the README.
5
+ * Change only together with the site.
6
+ */
7
+ export const ATTRIBUTED_ADDRESSES = "45M+";
8
+ export const ILLICIT_ADDRESSES = "485K+";
9
+ export const SANCTIONED_ADDRESSES = "1,000+";
10
+ export const SANCTIONS_AUTHORITIES = "OFAC, EU, AU, JP, FR";
11
+ /** "45M+ attributed addresses across 12 chains, including 485K+ …" */
12
+ export const DATABASE_CANON = `${ATTRIBUTED_ADDRESSES} attributed addresses across 12 chains, including ${ILLICIT_ADDRESSES} addresses linked to illicit activity and ${SANCTIONED_ADDRESSES} sanctioned addresses across 5 authorities (${SANCTIONS_AUTHORITIES})`;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Entity wording — pure functions, tested in test/entity.test.mjs.
3
+ *
4
+ * Mirrors two optional entity fields the ChainHint API adds with migration 144
5
+ * (main repo: backend/supabase/functions/_shared/entity-attribution.ts,
6
+ * src/lib/entityCategory.ts):
7
+ *
8
+ * attribution_unconfirmed: true — the named actor is a CANDIDATE, not a
9
+ * finding (e.g. Flipside-sourced Coinbase forwarders). wallet-reputation
10
+ * then answers top-level `category: "unknown"`. Shown, marked, never acted
11
+ * on: not a known entity, not a freeze target, not a reason to lower risk.
12
+ * operating_status: "closed" | "bankrupt" — the venue no longer operates.
13
+ *
14
+ * Both are optional: an API response without them prints exactly what 1.3.3
15
+ * printed.
16
+ */
17
+ export interface EntityBlock {
18
+ name: string;
19
+ category?: string | null;
20
+ subcategory?: string | null;
21
+ verified?: boolean | null;
22
+ /** 0..1 (address-lookup). */
23
+ confidence?: number | null;
24
+ attribution_unconfirmed?: boolean | null;
25
+ operating_status?: string | null;
26
+ }
27
+ export type OperatingStatus = "closed" | "bankrupt";
28
+ export declare function isUnconfirmedAttribution(e: EntityBlock | null | undefined): boolean;
29
+ /** "closed" | "bankrupt" when set; unknown values are ignored (same as the backend). */
30
+ export declare function entityOperatingStatus(e: EntityBlock | null | undefined): OperatingStatus | null;
31
+ /** The sentences printed after the entity, in order. Empty when there is nothing to say. */
32
+ export declare function entityMarkers(e: EntityBlock | null | undefined): string[];
33
+ /**
34
+ * "**Entity:** Binance (exchange / hot_wallet, verified)" — plus the markers.
35
+ * An unconfirmed attribution prints its stored category as a candidate and
36
+ * never as verified.
37
+ */
38
+ export declare function entityLine(e: EntityBlock, opts?: {
39
+ showVerified?: boolean;
40
+ }): string;
package/dist/entity.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Entity wording — pure functions, tested in test/entity.test.mjs.
3
+ *
4
+ * Mirrors two optional entity fields the ChainHint API adds with migration 144
5
+ * (main repo: backend/supabase/functions/_shared/entity-attribution.ts,
6
+ * src/lib/entityCategory.ts):
7
+ *
8
+ * attribution_unconfirmed: true — the named actor is a CANDIDATE, not a
9
+ * finding (e.g. Flipside-sourced Coinbase forwarders). wallet-reputation
10
+ * then answers top-level `category: "unknown"`. Shown, marked, never acted
11
+ * on: not a known entity, not a freeze target, not a reason to lower risk.
12
+ * operating_status: "closed" | "bankrupt" — the venue no longer operates.
13
+ *
14
+ * Both are optional: an API response without them prints exactly what 1.3.3
15
+ * printed.
16
+ */
17
+ export function isUnconfirmedAttribution(e) {
18
+ return e?.attribution_unconfirmed === true;
19
+ }
20
+ /** "closed" | "bankrupt" when set; unknown values are ignored (same as the backend). */
21
+ export function entityOperatingStatus(e) {
22
+ const s = (e?.operating_status ?? "").toLowerCase();
23
+ return s === "closed" || s === "bankrupt" ? s : null;
24
+ }
25
+ /** The sentences printed after the entity, in order. Empty when there is nothing to say. */
26
+ export function entityMarkers(e) {
27
+ const out = [];
28
+ if (isUnconfirmedAttribution(e)) {
29
+ out.push("⚠️ Attribution unconfirmed — the named actor is a candidate, not a finding. Do not treat it as a known entity, a freeze target or a reason to lower risk.");
30
+ }
31
+ const status = entityOperatingStatus(e);
32
+ if (status === "closed")
33
+ out.push("Venue closed — a freeze request may not reach a compliance desk.");
34
+ if (status === "bankrupt")
35
+ out.push("Bankrupt — address the estate / trustee.");
36
+ return out;
37
+ }
38
+ /**
39
+ * "**Entity:** Binance (exchange / hot_wallet, verified)" — plus the markers.
40
+ * An unconfirmed attribution prints its stored category as a candidate and
41
+ * never as verified.
42
+ */
43
+ export function entityLine(e, opts = {}) {
44
+ const unconfirmed = isUnconfirmedAttribution(e);
45
+ const cat = [e.category, e.subcategory].filter((p) => !!p && p.trim() !== "").join(" / ");
46
+ const parts = [];
47
+ if (cat)
48
+ parts.push(unconfirmed ? `candidate: ${cat}` : cat);
49
+ if (!unconfirmed && opts.showVerified && e.verified)
50
+ parts.push("verified");
51
+ if (!unconfirmed && e.confidence != null && Number.isFinite(e.confidence)) {
52
+ parts.push(`confidence ${Math.round(e.confidence * 100)}%`);
53
+ }
54
+ const head = `**Entity:** ${e.name}${parts.length ? ` (${parts.join(", ")})` : ""}`;
55
+ const markers = entityMarkers(e);
56
+ return markers.length ? `${head} — ${markers.join(" ")}` : head;
57
+ }
@@ -21,9 +21,27 @@ export interface EvidenceItem {
21
21
  token?: string | null;
22
22
  chain?: string | null;
23
23
  first_seen_at?: string | null;
24
+ /**
25
+ * API since 2026-09-15: true only on an issuer freeze whose freezing token
26
+ * contract IS the looked-up address (Tether blacklisted the USDT held by the
27
+ * USDT contract). Scored 0 with no floor; printed as the backend's risk
28
+ * detail line, never as "Frozen by issuer".
29
+ */
30
+ held_by_token_contract?: boolean;
24
31
  }
25
32
  export declare const NOT_A_SANCTIONS_DESIGNATION = "Not a sanctions designation.";
33
+ /** True for the one freeze that says nothing about the address as a counterparty. */
34
+ export declare function isOwnTokenContractFreeze(e: Pick<EvidenceItem, "class" | "held_by_token_contract">): boolean;
35
+ /**
36
+ * Same sentence as the backend's risk detail line (risk-engine.ts):
37
+ * "Tether blacklisted tokens held by this contract" — issuers de-duplicated.
38
+ */
39
+ export declare function ownTokenContractFreezeText(items: ReadonlyArray<Pick<EvidenceItem, "authority">>): string;
26
40
  /** "⛔ Sanctioned — US OFAC · … " / "🧊 Frozen by issuer — Tether (USDT, TRON) · … Not a sanctions designation." */
27
41
  export declare function evidenceText(e: EvidenceItem): string | null;
28
- /** Designations first, then by strength of proof; unknown classes dropped. */
42
+ /**
43
+ * Designations first, then by strength of proof; unknown classes dropped.
44
+ * Freezes of tokens held by the looked-up token contract itself close the
45
+ * list as one line in the backend's wording — never a "Frozen by issuer" line.
46
+ */
29
47
  export declare function evidenceLines(items: ReadonlyArray<EvidenceItem> | null | undefined): string[];
package/dist/evidence.js CHANGED
@@ -49,10 +49,24 @@ function attributionLine(e) {
49
49
  head = `${head} (${date})`;
50
50
  return e.subject ? `${head}: ${e.subject}` : head;
51
51
  }
52
+ /** True for the one freeze that says nothing about the address as a counterparty. */
53
+ export function isOwnTokenContractFreeze(e) {
54
+ return e.class === "issuer_freeze" && e.held_by_token_contract === true;
55
+ }
56
+ /**
57
+ * Same sentence as the backend's risk detail line (risk-engine.ts):
58
+ * "Tether blacklisted tokens held by this contract" — issuers de-duplicated.
59
+ */
60
+ export function ownTokenContractFreezeText(items) {
61
+ const issuers = [...new Set(items.map((e) => e.authority))].join(", ");
62
+ return `${issuers} blacklisted tokens held by this contract`;
63
+ }
52
64
  /** "⛔ Sanctioned — US OFAC · … " / "🧊 Frozen by issuer — Tether (USDT, TRON) · … Not a sanctions designation." */
53
65
  export function evidenceText(e) {
54
66
  if (!isEvidenceClass(e.class))
55
67
  return null;
68
+ if (isOwnTokenContractFreeze(e))
69
+ return ownTokenContractFreezeText([e]);
56
70
  const { icon, title } = TITLES[e.class];
57
71
  const date = day(e.document_date);
58
72
  let line;
@@ -76,13 +90,22 @@ export function evidenceText(e) {
76
90
  const caveat = e.class === "sanctions_designation" ? null : NOT_A_SANCTIONS_DESIGNATION;
77
91
  return `${icon} ${caveat ? `${title} — ${line}. ${caveat}` : `${title} — ${line}`}`;
78
92
  }
79
- /** Designations first, then by strength of proof; unknown classes dropped. */
93
+ /**
94
+ * Designations first, then by strength of proof; unknown classes dropped.
95
+ * Freezes of tokens held by the looked-up token contract itself close the
96
+ * list as one line in the backend's wording — never a "Frozen by issuer" line.
97
+ */
80
98
  export function evidenceLines(items) {
81
99
  if (!items?.length)
82
100
  return [];
83
101
  const rank = (c) => EVIDENCE_CLASSES.indexOf(c);
84
- return [...items]
85
- .filter((e) => isEvidenceClass(e.class))
102
+ const known = items.filter((e) => isEvidenceClass(e.class));
103
+ const own = known.filter(isOwnTokenContractFreeze);
104
+ const lines = known
105
+ .filter((e) => !isOwnTokenContractFreeze(e))
86
106
  .sort((a, b) => rank(a.class) - rank(b.class))
87
107
  .map((e) => `- ${evidenceText(e)}`);
108
+ if (own.length)
109
+ lines.push(`- ${ownTokenContractFreezeText(own)}`);
110
+ return lines;
88
111
  }
package/dist/index.js CHANGED
@@ -19,12 +19,17 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
19
19
  import { z } from "zod";
20
20
  import { apiErrorMessage, formatRiskLevel, lookupRiskLines, traceStatusLine } from "./verdicts.js";
21
21
  import { evidenceLines } from "./evidence.js";
22
+ import { entityLine } from "./entity.js";
23
+ import { ADDRESS_FORMS, normalizeAddr } from "./address.js";
24
+ import { DATABASE_CANON } from "./canon.js";
25
+ import { DEFAULT_SUPABASE_KEY, isSupabaseKeyConfigured, resolveSupabaseKey, supabaseRestHeaders } from "./supabase-rest.js";
22
26
  // ── Config ────────────────────────────────────────────────────────────────────
23
27
  const API_KEY = process.env.CHAINHINT_API_KEY;
24
28
  const BASE_URL = process.env.CHAINHINT_API_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co/functions/v1";
25
29
  const SUPABASE_URL = process.env.CHAINHINT_SUPABASE_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co";
26
- const SUPABASE_ANON_KEY = process.env.CHAINHINT_SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtqaXdmd3ltbnV6eHJpb2toY2prIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI3MzkxODgsImV4cCI6MjA4ODMxNTE4OH0.VqzzF_jI8zF072cbjWEDbYo3PnMDlIPy621iWkXEqyo";
27
- const VERSION = "1.3.3";
30
+ // Publishable key, sent only in `apikey` (never as a Bearer token) — see supabase-rest.ts.
31
+ const SUPABASE_KEY = resolveSupabaseKey(process.env, DEFAULT_SUPABASE_KEY);
32
+ const VERSION = "1.4.0";
28
33
  const USER_AGENT = `chainhint-mcp/${VERSION}`;
29
34
  const FREE_CHECKS_PER_DAY = 3;
30
35
  const UPGRADE_HINT = "set CHAINHINT_API_KEY (Agency plan, https://chainhint.com/pricing) for 10,000/day";
@@ -75,12 +80,11 @@ async function supabaseGet(table, params) {
75
80
  for (const [k, v] of Object.entries(params)) {
76
81
  url.searchParams.set(k, v);
77
82
  }
83
+ if (!isSupabaseKeyConfigured(SUPABASE_KEY)) {
84
+ throw new Error("get_trace_status is not configured: no Supabase publishable key (set CHAINHINT_SUPABASE_PUBLISHABLE_KEY or upgrade chainhint-mcp)");
85
+ }
78
86
  const res = await fetch(url.toString(), {
79
- headers: {
80
- "apikey": SUPABASE_ANON_KEY,
81
- "Authorization": `Bearer ${SUPABASE_ANON_KEY}`,
82
- "Accept": "application/json",
83
- },
87
+ headers: supabaseRestHeaders(SUPABASE_KEY),
84
88
  });
85
89
  if (!res.ok) {
86
90
  const err = await res.text();
@@ -165,12 +169,6 @@ function endpointBucket(category, type, name) {
165
169
  return "defi";
166
170
  return "unknown";
167
171
  }
168
- // EVM addresses are case-insensitive and stored lowercase; base58 chains
169
- // (BTC/TRON/SOL/TON) are case-sensitive — never lowercase those.
170
- function normalizeAddr(addr) {
171
- const a = addr.trim();
172
- return a.startsWith("0x") ? a.toLowerCase() : a;
173
- }
174
172
  function formatExposure(label, buckets) {
175
173
  if (!buckets?.length)
176
174
  return [];
@@ -221,8 +219,8 @@ const server = new McpServer({
221
219
  version: VERSION,
222
220
  });
223
221
  // ── Tool 1: check_wallet_risk ─────────────────────────────────────────────────
224
- 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. Use it to decide allow/warn/block before paying or interacting with a counterparty wallet. Free: 3 checks per day without a key; CHAINHINT_API_KEY (Agency plan) lifts it to 10,000/day. For a deeper report (risk factors, exposure, balance) use lookup_address.", {
225
- address: z.string().describe("Wallet address to check (EVM 0x..., Bitcoin, or Solana)"),
222
+ server.tool("check_wallet_risk", `Fast risk check for a crypto wallet address against ChainHint's entity database ${DATABASE_CANON}. Returns risk score 0-100, risk level (clean/low/medium/high/critical/sanctioned), entity name and category, labels, and sanctions hit. Use it to decide allow/warn/block before paying or interacting with a counterparty wallet. Free: 3 checks per day without a key; CHAINHINT_API_KEY (Agency plan) lifts it to 10,000/day. For a deeper report (risk factors, exposure, balance) use lookup_address.`, {
223
+ address: z.string().describe(`Wallet address to check: ${ADDRESS_FORMS}`),
226
224
  chain: z.string().optional().describe("Blockchain: ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, gnosis, bitcoin, solana, tron, ton (default: auto-detect from address format; the response chain is the address family for bitcoin/solana/tron/ton)"),
227
225
  }, async ({ address, chain }) => {
228
226
  try {
@@ -237,13 +235,13 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
237
235
  `**Chain:** ${d.chain}`,
238
236
  ];
239
237
  // Canon (same as chainhint.com and the TG bot): an address that is not in
240
- // the labeled database has NO DATA — that is not evidence it is clean.
238
+ // the entity database has NO DATA — that is not evidence it is clean.
241
239
  // The API still returns risk_score 0 / "clean" for not-found, so the
242
240
  // wording is fixed here, and public incidents are cross-checked so a
243
241
  // known hack attacker that never got an `addresses` row is not
244
242
  // presented as unknown.
245
243
  if (!d.found_in_db) {
246
- lines.push(`**Risk:** ⚪ NO DATA — address is not in ChainHint's labeled database. This is not evidence it is clean.`);
244
+ lines.push(`**Risk:** ⚪ NO DATA — address is not in ChainHint's entity database. This is not evidence it is clean.`);
247
245
  const inc = await findPublicIncidentByAttacker(d.address);
248
246
  if (inc) {
249
247
  const attribution = attackerAttribution({ attacker_address: d.address, ...inc });
@@ -266,9 +264,8 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
266
264
  if (walletEvidence.length)
267
265
  lines.push(`**Evidence:**`, ...walletEvidence);
268
266
  if (d.entity?.name) {
269
- const sub = d.entity.subcategory ? ` / ${d.entity.subcategory}` : "";
270
- const ver = d.entity.verified ? ", verified" : "";
271
- lines.push(`**Entity:** ${d.entity.name} (${d.entity.category}${sub}${ver})`);
267
+ // An unconfirmed attribution is marked; the API's top-level category is then "unknown".
268
+ lines.push(entityLine(d.entity, { showVerified: true }));
272
269
  }
273
270
  else if (d.category) {
274
271
  lines.push(`**Category:** ${d.category}`);
@@ -300,7 +297,7 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
300
297
  });
301
298
  // ── Tool 2: lookup_address ────────────────────────────────────────────────────
302
299
  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. Free: 10 lookups per day without a key; CHAINHINT_API_KEY lifts it. Supports EVM chains, Bitcoin, Solana, TRON, TON.", {
303
- address: z.string().describe("Blockchain address to look up"),
300
+ address: z.string().describe(`Blockchain address to look up: ${ADDRESS_FORMS}`),
304
301
  chain: z.string().optional().describe("Blockchain (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, gnosis, bitcoin, solana, tron, ton)"),
305
302
  }, async ({ address, chain }) => {
306
303
  try {
@@ -319,9 +316,7 @@ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity a
319
316
  `**Type:** ${d.is_contract ? "Smart Contract" : "EOA (wallet)"}`,
320
317
  ];
321
318
  if (d.entity?.name) {
322
- const sub = d.entity.subcategory ? ` / ${d.entity.subcategory}` : "";
323
- const conf = d.entity.confidence != null ? `, confidence ${Math.round(d.entity.confidence * 100)}%` : "";
324
- lines.push(`**Entity:** ${d.entity.name} (${d.entity.category}${sub}${conf})`);
319
+ lines.push(entityLine(d.entity));
325
320
  }
326
321
  else {
327
322
  lines.push(`**Entity:** Unknown / unlabeled`);
@@ -0,0 +1,21 @@
1
+ /** Env var for the project key (1.4.0+). */
2
+ export declare const PUBLISHABLE_KEY_ENV = "CHAINHINT_SUPABASE_PUBLISHABLE_KEY";
3
+ /** Pre-1.4.0 name, still honoured so existing configs keep their override. */
4
+ export declare const LEGACY_ANON_KEY_ENV = "CHAINHINT_SUPABASE_ANON_KEY";
5
+ /** Env override first (new name, then the legacy name), else the built-in default. */
6
+ export declare function resolveSupabaseKey(env: Record<string, string | undefined>, fallback: string): string;
7
+ /** Headers for an anonymous REST read of a public view: key in apikey only. */
8
+ export declare function supabaseRestHeaders(key: string): Record<string, string>;
9
+ /** Marker the default carries until the owner pastes the real publishable key. */
10
+ export declare const PUBLISHABLE_KEY_PLACEHOLDER = "sb_publishable_PASTE_BEFORE_PUBLISH";
11
+ /**
12
+ * ChainHint's Supabase publishable key (sb_publishable_…) — public by design, the
13
+ * same value the chainhint.com bundle ships. Paste it here before `npm publish`;
14
+ * `prepublishOnly` (scripts/check-publish.mjs) refuses to publish while this is the
15
+ * placeholder, a JWT or anything that is not a publishable key.
16
+ */
17
+ export declare const DEFAULT_SUPABASE_KEY: string;
18
+ /** Why `key` must not be the published default, or null when it is a real publishable key. */
19
+ export declare function publishKeyProblem(key: string): string | null;
20
+ /** true when REST reads can run: an override or a real default. */
21
+ export declare function isSupabaseKeyConfigured(key: string): boolean;
@@ -0,0 +1,44 @@
1
+ // KIR-81 (1.4.0): the Supabase project key is a publishable key
2
+ // (`sb_publishable_…`), not a JWT. It is sent ONLY in the `apikey` header —
3
+ // never as `Authorization: Bearer`, which PostgREST would try to verify as a
4
+ // JWT. Versions < 1.4.0 sent the legacy anon JWT in both headers and stop
5
+ // working when the project disables its legacy API keys.
6
+ /** Env var for the project key (1.4.0+). */
7
+ export const PUBLISHABLE_KEY_ENV = "CHAINHINT_SUPABASE_PUBLISHABLE_KEY";
8
+ /** Pre-1.4.0 name, still honoured so existing configs keep their override. */
9
+ export const LEGACY_ANON_KEY_ENV = "CHAINHINT_SUPABASE_ANON_KEY";
10
+ /** Env override first (new name, then the legacy name), else the built-in default. */
11
+ export function resolveSupabaseKey(env, fallback) {
12
+ const fromEnv = env[PUBLISHABLE_KEY_ENV] || env[LEGACY_ANON_KEY_ENV];
13
+ return fromEnv && fromEnv.trim() ? fromEnv.trim() : fallback;
14
+ }
15
+ /** Headers for an anonymous REST read of a public view: key in apikey only. */
16
+ export function supabaseRestHeaders(key) {
17
+ return { apikey: key, Accept: "application/json" };
18
+ }
19
+ /** Marker the default carries until the owner pastes the real publishable key. */
20
+ export const PUBLISHABLE_KEY_PLACEHOLDER = "sb_publishable_PASTE_BEFORE_PUBLISH";
21
+ /**
22
+ * ChainHint's Supabase publishable key (sb_publishable_…) — public by design, the
23
+ * same value the chainhint.com bundle ships. Paste it here before `npm publish`;
24
+ * `prepublishOnly` (scripts/check-publish.mjs) refuses to publish while this is the
25
+ * placeholder, a JWT or anything that is not a publishable key.
26
+ */
27
+ export const DEFAULT_SUPABASE_KEY = "sb_publishable_BpalXiQKLHEpDqyNpt2Tkw_JYkuIGBK";
28
+ /** Why `key` must not be the published default, or null when it is a real publishable key. */
29
+ export function publishKeyProblem(key) {
30
+ const k = (key ?? "").trim();
31
+ if (k === PUBLISHABLE_KEY_PLACEHOLDER)
32
+ return "the default key is still the placeholder";
33
+ if (k.startsWith("eyJ"))
34
+ return "the default key is a JWT (legacy anon key) — use the sb_publishable_ key";
35
+ if (k.startsWith("sb_secret_"))
36
+ return "the default key is a SECRET key — never ship it";
37
+ if (!/^sb_publishable_[A-Za-z0-9_-]{16,}$/.test(k))
38
+ return "the default key does not look like an sb_publishable_ key";
39
+ return null;
40
+ }
41
+ /** true when REST reads can run: an override or a real default. */
42
+ export function isSupabaseKeyConfigured(key) {
43
+ return publishKeyProblem(key) === null || ((key ?? "").trim() !== "" && (key ?? "").trim() !== PUBLISHABLE_KEY_PLACEHOLDER && !(key ?? "").trim().startsWith("eyJ"));
44
+ }
@@ -19,6 +19,13 @@ export declare function isTraceableChain(chain: string | null | undefined): bool
19
19
  /** Same sentence as the backend's tracingUnavailableMessage. */
20
20
  export declare function tracingUnavailableMessage(chain: string | null | undefined): string;
21
21
  export declare function formatRiskLevel(score: number): string;
22
+ /**
23
+ * Risk factors the API still returns in the response shape but no longer
24
+ * scores (always false). Never printed, whatever an old or cached response says.
25
+ * contract_not_verified: retired 2026-09-15 (risk-engine.ts) — the contract
26
+ * flag it read was wrong on every audited row.
27
+ */
28
+ export declare const RETIRED_RISK_FACTORS: readonly string[];
22
29
  export type DataUnavailable = {
23
30
  provider?: string;
24
31
  reason?: string;
package/dist/verdicts.js CHANGED
@@ -39,6 +39,13 @@ export function formatRiskLevel(score) {
39
39
  return "LOW";
40
40
  return "CLEAN";
41
41
  }
42
+ /**
43
+ * Risk factors the API still returns in the response shape but no longer
44
+ * scores (always false). Never printed, whatever an old or cached response says.
45
+ * contract_not_verified: retired 2026-09-15 (risk-engine.ts) — the contract
46
+ * flag it read was wrong on every audited row.
47
+ */
48
+ export const RETIRED_RISK_FACTORS = ["contract_not_verified"];
42
49
  function unavailableDetail(d) {
43
50
  const provider = d.data_availability?.provider ?? d.exposure?.data_unavailable?.provider;
44
51
  return d.data_availability?.detail
@@ -63,7 +70,9 @@ export function lookupRiskLines(d) {
63
70
  return [];
64
71
  const level = (d.risk.level ?? formatRiskLevel(d.risk.score)).toUpperCase();
65
72
  const lines = [`**Risk Score:** ${d.risk.score}/100 — **${level}**`];
66
- const factors = Object.entries(d.risk.factors ?? {}).filter(([, v]) => v).map(([k]) => k);
73
+ const factors = Object.entries(d.risk.factors ?? {})
74
+ .filter(([k, v]) => v === true && !RETIRED_RISK_FACTORS.includes(k))
75
+ .map(([k]) => k);
67
76
  if (factors.length)
68
77
  lines.push(`**Risk Factors:** ${factors.join(", ")}`);
69
78
  if (d.risk.details?.length)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "chainhint-mcp",
3
- "version": "1.3.3",
4
- "description": "ChainHint MCP server \u2014 crypto risk intelligence tools for Claude Desktop and Cursor",
3
+ "version": "1.4.0",
4
+ "description": "ChainHint MCP server crypto risk intelligence tools for Claude Desktop and Cursor",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -35,8 +35,9 @@
35
35
  "build": "tsc",
36
36
  "dev": "tsx src/index.ts",
37
37
  "start": "node dist/index.js",
38
- "prepublishOnly": "npm run build",
39
- "test": "npm run build && node --test test/*.test.mjs"
38
+ "prepublishOnly": "npm run build && node scripts/check-publish.mjs",
39
+ "test": "npm run build && node --test test/*.test.mjs",
40
+ "check:publish": "npm run build && node scripts/check-publish.mjs"
40
41
  },
41
42
  "dependencies": {
42
43
  "@modelcontextprotocol/sdk": "^1.0.0",