chainhint-mcp 1.0.1 → 1.1.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
@@ -4,16 +4,16 @@ Crypto risk intelligence for Claude Desktop, Cursor, and any MCP-compatible AI.
4
4
 
5
5
  ## Tools
6
6
 
7
- | Tool | Description |
8
- |------|-------------|
9
- | `check_wallet_risk` | Fast risk score, entity, labels, sanctions hit (54M+ labeled addresses). **Needs API key** |
10
- | `lookup_address` | Deep address report — entity, risk factors, GoPlus flags, counterparty exposure, balance. No key needed |
11
- | `get_trace_status` | Fund-trace summary for a public hack incident — hops, endpoints by type (exchange/mixer/bridge/defi), exposure. No key needed |
7
+ | Tool | Description | Limit |
8
+ |------|-------------|-------|
9
+ | `check_wallet_risk` | Fast risk score, entity, labels, sanctions hit (54M+ labeled addresses) | **3 / day** free · 10,000 / day with key |
10
+ | `lookup_address` | Deep address report — entity, risk factors, GoPlus flags, counterparty exposure, balance | **10 / day** free · 100 / hour with key |
11
+ | `get_trace_status` | Fund-trace summary for a public hack incident — hops, endpoints by type (exchange/mixer/bridge/defi), exposure | Unlimited |
12
12
 
13
13
  ## Requirements
14
14
 
15
15
  - Node.js 18+
16
- - Optional: ChainHint Agency plan API key (`ch_live_...`) from [chainhint.com/settings](https://chainhint.com/settings) only `check_wallet_risk` needs it
16
+ - Nothing else. **No API key needed to start** — every tool has a free daily allowance (per IP). An Agency plan key (`ch_live_...`, [chainhint.com/settings](https://chainhint.com/settings)) lifts the limits.
17
17
 
18
18
  ## Install
19
19
 
@@ -38,10 +38,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
38
38
  "mcpServers": {
39
39
  "chainhint": {
40
40
  "command": "npx",
41
- "args": ["-y", "chainhint-mcp"],
42
- "env": {
43
- "CHAINHINT_API_KEY": "ch_live_your_key_here"
44
- }
41
+ "args": ["-y", "chainhint-mcp"]
45
42
  }
46
43
  }
47
44
  }
@@ -56,15 +53,39 @@ Add to `.cursor/mcp.json` in your project (or global `~/.cursor/mcp.json`):
56
53
  "mcpServers": {
57
54
  "chainhint": {
58
55
  "command": "npx",
59
- "args": ["-y", "chainhint-mcp"],
60
- "env": {
61
- "CHAINHINT_API_KEY": "ch_live_your_key_here"
62
- }
56
+ "args": ["-y", "chainhint-mcp"]
63
57
  }
64
58
  }
65
59
  }
66
60
  ```
67
61
 
62
+ ## Claude Code
63
+
64
+ ```bash
65
+ claude mcp add chainhint -- npx -y chainhint-mcp
66
+ ```
67
+
68
+ ## Lifting the free limits
69
+
70
+ Add an Agency key to the server entry (any client):
71
+
72
+ ```json
73
+ "env": { "CHAINHINT_API_KEY": "ch_live_your_key_here" }
74
+ ```
75
+
76
+ ## What the agent sees
77
+
78
+ ```
79
+ ## Wallet Risk Report: 0x47666f...9486e2
80
+ **Chain:** ethereum
81
+ **Risk Score:** 85/100 — **CRITICAL**
82
+ **Entity:** Bybit Hack Exploiter (hacker)
83
+ **Labels:** Hacker/Exploiter, Bybit Hack Exploiter
84
+ **In ChainHint DB:** yes (sources: chainhint:manual)
85
+
86
+ Free tier: 2 of 3 checks left today — set CHAINHINT_API_KEY (Agency plan, https://chainhint.com/pricing) for 10,000/day.
87
+ ```
88
+
68
89
  ## Development (no build step)
69
90
 
70
91
  ```bash
@@ -75,7 +96,7 @@ npm run dev
75
96
 
76
97
  | Variable | Required | Description |
77
98
  |----------|----------|-------------|
78
- | `CHAINHINT_API_KEY` | — | API key from chainhint.com (Agency plan). Required only for `check_wallet_risk` |
99
+ | `CHAINHINT_API_KEY` | — | Agency plan key from chainhint.com. Lifts the free limits (3 checks + 10 lookups per day) to 10,000 / day |
79
100
  | `CHAINHINT_API_URL` | — | Override API base URL (default: production) |
80
101
  | `CHAINHINT_SUPABASE_ANON_KEY` | — | Override anon key for get_trace_status (public incidents) |
81
102
 
package/dist/index.d.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  * - lookup_address → address-lookup API (entity, risk factors, exposure, balance) — public
11
11
  * - get_trace_status → public incident fund-trace summary (endpoints, hops, exposure) — public
12
12
  *
13
- * Auth: CHAINHINT_API_KEY env var (Agency plan API key: ch_live_...) is only
14
- * required for check_wallet_risk. The other two tools hit public endpoints.
13
+ * Auth: works without a key. check_wallet_risk has a free tier of 3 checks
14
+ * per IP per day; lookup_address 10 per day; get_trace_status is unlimited.
15
+ * CHAINHINT_API_KEY (Agency plan, ch_live_...) lifts the limits to 10,000/day.
15
16
  */
16
17
  export {};
package/dist/index.js CHANGED
@@ -10,8 +10,9 @@
10
10
  * - lookup_address → address-lookup API (entity, risk factors, exposure, balance) — public
11
11
  * - get_trace_status → public incident fund-trace summary (endpoints, hops, exposure) — public
12
12
  *
13
- * Auth: CHAINHINT_API_KEY env var (Agency plan API key: ch_live_...) is only
14
- * required for check_wallet_risk. The other two tools hit public endpoints.
13
+ * Auth: works without a key. check_wallet_risk has a free tier of 3 checks
14
+ * per IP per day; lookup_address 10 per day; get_trace_status is unlimited.
15
+ * CHAINHINT_API_KEY (Agency plan, ch_live_...) lifts the limits to 10,000/day.
15
16
  */
16
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -21,30 +22,55 @@ const API_KEY = process.env.CHAINHINT_API_KEY;
21
22
  const BASE_URL = process.env.CHAINHINT_API_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co/functions/v1";
22
23
  const SUPABASE_URL = process.env.CHAINHINT_SUPABASE_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co";
23
24
  const SUPABASE_ANON_KEY = process.env.CHAINHINT_SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtqaXdmd3ltbnV6eHJpb2toY2prIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI3MzkxODgsImV4cCI6MjA4ODMxNTE4OH0.VqzzF_jI8zF072cbjWEDbYo3PnMDlIPy621iWkXEqyo";
24
- const NO_KEY_MESSAGE = "check_wallet_risk requires CHAINHINT_API_KEY (Agency plan, ch_live_...). " +
25
- "Get one at chainhint.com → Settings → API Keys, or use lookup_address which needs no key.";
25
+ const VERSION = "1.1.0";
26
+ const USER_AGENT = `chainhint-mcp/${VERSION}`;
27
+ const FREE_CHECKS_PER_DAY = 3;
28
+ const UPGRADE_HINT = "set CHAINHINT_API_KEY (Agency plan, https://chainhint.com/pricing) for 10,000/day";
26
29
  if (!API_KEY) {
27
- console.error("[chainhint-mcp] WARN: CHAINHINT_API_KEY is not set check_wallet_risk will be unavailable.");
28
- console.error(" lookup_address and get_trace_status work without a key.");
30
+ console.error(`[chainhint-mcp] No CHAINHINT_API_KEY running on the free tier (${FREE_CHECKS_PER_DAY} wallet checks + 10 lookups per day per IP).`);
29
31
  }
30
- // ── HTTP helpers ──────────────────────────────────────────────────────────────
31
- async function apiGet(path, params, opts) {
32
+ async function apiGet(path, params) {
32
33
  const url = new URL(`${BASE_URL}${path}`);
33
34
  for (const [k, v] of Object.entries(params)) {
34
35
  if (v)
35
36
  url.searchParams.set(k, v);
36
37
  }
37
- const headers = { "Content-Type": "application/json" };
38
- if (opts.auth && API_KEY) {
38
+ // User-Agent lets the backend attribute traffic (api_usage_log.caller) and
39
+ // apply the MCP-specific free allowance. The key, when present, goes in
40
+ // X-Api-Key so both wallet-reputation and address-lookup recognise it.
41
+ const headers = {
42
+ "Content-Type": "application/json",
43
+ "User-Agent": USER_AGENT,
44
+ };
45
+ if (API_KEY) {
39
46
  headers["X-Api-Key"] = API_KEY;
40
47
  headers["Authorization"] = `Bearer ${API_KEY}`;
41
48
  }
42
49
  const res = await fetch(url.toString(), { headers });
43
50
  const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` })));
44
51
  if (!res.ok) {
45
- throw new Error(body?.error ?? `HTTP ${res.status}: ${url.toString()}`);
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);
46
57
  }
47
- return body;
58
+ return { body, headers: res.headers };
59
+ }
60
+ /** "Free tier: 2 of 3 checks left today — set CHAINHINT_API_KEY …" or null. */
61
+ function quotaLine(headers, what) {
62
+ if (headers.get("x-chainhint-tier") !== "free")
63
+ return null;
64
+ const limit = Number(headers.get("x-ratelimit-limit"));
65
+ const remaining = Number(headers.get("x-ratelimit-remaining"));
66
+ if (!Number.isFinite(limit) || !Number.isFinite(remaining))
67
+ return null;
68
+ // Bypass-listed IPs get MAX_SAFE_INTEGER from the backend — no quota to report.
69
+ if (limit >= 1_000_000)
70
+ return null;
71
+ const reset = Number(headers.get("x-ratelimit-reset"));
72
+ const resetStr = Number.isFinite(reset) && reset > 0 ? ` (resets ${new Date(reset * 1000).toISOString().slice(0, 16)}Z)` : "";
73
+ return `Free tier: ${remaining} of ${limit} ${what} left today${resetStr} — ${UPGRADE_HINT}.`;
48
74
  }
49
75
  async function supabaseGet(table, params) {
50
76
  const url = new URL(`${SUPABASE_URL}/rest/v1/${table}`);
@@ -106,31 +132,58 @@ function formatExposure(label, buckets) {
106
132
  }),
107
133
  ];
108
134
  }
135
+ /** Public incident whose attacker_address matches, or null (errors swallowed — best effort). */
136
+ async function findPublicIncidentByAttacker(address) {
137
+ try {
138
+ const rows = await supabaseGet("public_incidents_view", {
139
+ select: "id,title,chain,amount_usd,estimated_loss_usd,risk_score",
140
+ attacker_address: `eq.${normalizeAddr(address)}`,
141
+ limit: "1",
142
+ });
143
+ return rows[0] ?? null;
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
109
149
  // ── MCP Server ────────────────────────────────────────────────────────────────
110
150
  const server = new McpServer({
111
151
  name: "chainhint",
112
- version: "1.0.1",
152
+ version: VERSION,
113
153
  });
114
154
  // ── Tool 1: check_wallet_risk ─────────────────────────────────────────────────
115
- 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. Requires CHAINHINT_API_KEY (Agency plan). For a keyless, deeper look (risk factors, exposure, balance) use lookup_address.", {
155
+ 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.", {
116
156
  address: z.string().describe("Wallet address to check (EVM 0x..., Bitcoin, or Solana)"),
117
157
  chain: z.string().optional().describe("Blockchain: ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin (default: auto-detect from address format)"),
118
158
  }, async ({ address, chain }) => {
119
- if (!API_KEY) {
120
- return { content: [{ type: "text", text: NO_KEY_MESSAGE }] };
121
- }
122
159
  try {
123
160
  const params = { address };
124
161
  if (chain)
125
162
  params.chain = chain;
126
163
  // wallet-reputation returns a flat object (no {success,data} wrapper).
127
- const d = await apiGet("/wallet-reputation", params, { auth: true });
164
+ const { body: d, headers } = await apiGet("/wallet-reputation", params);
128
165
  const lines = [
129
166
  `## Wallet Risk Report: ${truncateAddr(d.address)}`,
130
167
  `**Address:** ${d.address}`,
131
168
  `**Chain:** ${d.chain}`,
132
- `**Risk Score:** ${d.risk_score}/100 — **${(d.risk_level ?? formatRiskLevel(d.risk_score)).toUpperCase()}**`,
133
169
  ];
170
+ // Canon (same as chainhint.com and the TG bot): an address that is not in
171
+ // the labeled database has NO DATA — that is not evidence it is clean.
172
+ // The API still returns risk_score 0 / "clean" for not-found, so the
173
+ // wording is fixed here, and public incidents are cross-checked so a
174
+ // known hack attacker that never got an `addresses` row is not
175
+ // presented as unknown.
176
+ if (!d.found_in_db) {
177
+ lines.push(`**Risk:** ⚪ NO DATA — address is not in ChainHint's labeled database. This is not evidence it is clean.`);
178
+ const inc = await findPublicIncidentByAttacker(d.address);
179
+ if (inc) {
180
+ 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}`);
181
+ }
182
+ lines.push(`Use lookup_address for an on-chain assessment (risk factors, GoPlus flags, counterparty exposure).`);
183
+ }
184
+ else {
185
+ lines.push(`**Risk Score:** ${d.risk_score}/100 — **${(d.risk_level ?? formatRiskLevel(d.risk_score)).toUpperCase()}**`);
186
+ }
134
187
  if (d.sanctions?.hit) {
135
188
  lines.push(`⛔ **SANCTIONED / OFAC-linked**`);
136
189
  }
@@ -151,6 +204,9 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
151
204
  lines.push(`**Type:** ${d.is_contract ? "Smart Contract" : "EOA (wallet)"}`);
152
205
  lines.push(`**In ChainHint DB:** ${d.found_in_db ? "yes" : "no"}${d.sources?.length ? ` (sources: ${d.sources.join(", ")})` : ""}`);
153
206
  lines.push(`**Checked at:** ${d.checked_at}`);
207
+ const quota = quotaLine(headers, "checks");
208
+ if (quota)
209
+ lines.push(``, quota);
154
210
  lines.push(`\n*Powered by ChainHint — chainhint.com*`);
155
211
  return { content: [{ type: "text", text: lines.join("\n") }] };
156
212
  }
@@ -159,7 +215,7 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
159
215
  }
160
216
  });
161
217
  // ── Tool 2: lookup_address ────────────────────────────────────────────────────
162
- 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. Works without an API key. Supports EVM chains, Bitcoin, Solana, TRON, TON.", {
218
+ 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.", {
163
219
  address: z.string().describe("Blockchain address to look up"),
164
220
  chain: z.string().optional().describe("Blockchain (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin, tron, ton)"),
165
221
  }, async ({ address, chain }) => {
@@ -167,7 +223,7 @@ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity a
167
223
  const params = { address };
168
224
  if (chain)
169
225
  params.chain = chain;
170
- const data = await apiGet("/address-lookup", params, { auth: false });
226
+ const { body: data, headers: lookupHeaders } = await apiGet("/address-lookup", params);
171
227
  if (!data.success || !data.data) {
172
228
  return { content: [{ type: "text", text: `Error: ${data.error ?? "Unknown error"}` }] };
173
229
  }
@@ -223,6 +279,9 @@ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity a
223
279
  lines.push(...formatExposure("Inflow by category", ex.inflow));
224
280
  lines.push(...formatExposure("Outflow by category", ex.outflow));
225
281
  }
282
+ const lookupQuota = quotaLine(lookupHeaders, "lookups");
283
+ if (lookupQuota)
284
+ lines.push(``, lookupQuota);
226
285
  lines.push(``, `🔗 https://chainhint.com/address/${d.address}?chain=${d.chain}`);
227
286
  lines.push(`*Powered by ChainHint — chainhint.com*`);
228
287
  return { content: [{ type: "text", text: lines.join("\n") }] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chainhint-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "ChainHint MCP server — crypto risk intelligence tools for Claude Desktop and Cursor",
5
5
  "license": "MIT",
6
6
  "repository": {