@fino314-oss/contract-scanner-mcp 1.0.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.
Files changed (4) hide show
  1. package/README.md +120 -0
  2. package/package.json +23 -0
  3. package/scanner.js +447 -0
  4. package/server.js +270 -0
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # Contract Security Scanner — MCP Server
2
+
3
+ Scan any Base L2 smart contract for security risks directly from your AI assistant.
4
+
5
+ **3 tools exposed:**
6
+ - `scan_contract` — Full security scan (source verification, risky selectors, age, activity)
7
+ - `batch_scan` — Compare up to 5 contracts side by side
8
+ - `interpret_risk` — Get an actionable recommendation (SAFE / CAUTION / HIGH_RISK / DO_NOT_USE)
9
+
10
+ **Risk score: 0-100.** Analyzes: mint/blacklist/backdoor functions, proxy patterns, source verification, contract age, transaction activity.
11
+
12
+ ---
13
+
14
+ ## Installation
15
+
16
+ ### Claude Desktop
17
+
18
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
19
+
20
+ ```json
21
+ {
22
+ "mcpServers": {
23
+ "contract-scanner": {
24
+ "command": "node",
25
+ "args": ["/Users/sam/Desktop/samDev/p8/mcp/server.js"]
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ Restart Claude Desktop. The tools appear automatically.
32
+
33
+ ---
34
+
35
+ ### Cursor
36
+
37
+ Add to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "contract-scanner": {
43
+ "command": "node",
44
+ "args": ["/Users/sam/Desktop/samDev/p8/mcp/server.js"]
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ ---
51
+
52
+ ### Cline (VS Code extension)
53
+
54
+ 1. Open Cline settings → MCP Servers → Add server
55
+ 2. Set type: `stdio`
56
+ 3. Command: `node /Users/sam/Desktop/samDev/p8/mcp/server.js`
57
+
58
+ ---
59
+
60
+ ### Any MCP client (generic)
61
+
62
+ The server uses **stdio transport** — just pipe JSON-RPC messages:
63
+
64
+ ```bash
65
+ node /Users/sam/Desktop/samDev/p8/mcp/server.js
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Usage examples
71
+
72
+ Once connected, just ask your AI assistant naturally:
73
+
74
+ ```
75
+ "Scan this contract before I approve: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
76
+
77
+ "Compare the risk of these 3 Aave clones: 0x... 0x... 0x..."
78
+
79
+ "Is this token safe to buy? 0x4ed4e862860bed51a9570b96d89af5e1b0efefed"
80
+ ```
81
+
82
+ ---
83
+
84
+ ## What gets analyzed
85
+
86
+ | Check | Source |
87
+ |-------|--------|
88
+ | Source code verified? | BaseScan API |
89
+ | Mint / burn functions | Bytecode selector scan |
90
+ | Pause / freeze | Bytecode selector scan |
91
+ | Blacklist / whitelist | Bytecode selector scan |
92
+ | Backdoors (rescueTokens, withdrawAll) | Bytecode selector scan |
93
+ | Upgradeable proxy | BaseScan + delegatecall detection |
94
+ | Contract age | BaseScan transaction history |
95
+ | Activity level | BaseScan recent txs |
96
+
97
+ ---
98
+
99
+ ## Risk scoring
100
+
101
+ | Score | Label | Meaning |
102
+ |-------|-------|---------|
103
+ | 0-9 | SAFE | No red flags |
104
+ | 10-29 | LOW | Minor concerns |
105
+ | 30-49 | MEDIUM | Elevated risk — review before interacting |
106
+ | 50-69 | HIGH | Significant risk — small amounts only |
107
+ | 70+ | CRITICAL | Avoid — potential rug or backdoor |
108
+
109
+ ---
110
+
111
+ ## Technical notes
112
+
113
+ - **Chain**: Base L2 only (`https://mainnet.base.org`)
114
+ - **API**: BaseScan free tier (no key needed for basic checks; set `BASESCAN_API_KEY` env var for full source analysis)
115
+ - **No wallet needed**: read-only RPC calls only
116
+ - **Latency**: ~2-5s per contract (network dependent)
117
+
118
+ ---
119
+
120
+ *Built on Base. Agent wallet: `0x804dd2cE4aA3296831c880139040e4326df13c6e`*
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@fino314-oss/contract-scanner-mcp",
3
+ "version": "1.0.0",
4
+ "mcpName": "io.github.fino-oss/contract-scanner",
5
+ "description": "MCP server that scans Base L2 smart contracts for security risks. Detects mint/blacklist/backdoor/proxy patterns. Risk score 0-100.",
6
+ "type": "module",
7
+ "main": "server.js",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/fino-oss/contract-scanner-mcp.git"
11
+ },
12
+ "scripts": {
13
+ "start": "node server.js",
14
+ "inspect": "npx @modelcontextprotocol/inspector node server.js"
15
+ },
16
+ "keywords": ["mcp", "blockchain", "security", "base", "solidity", "smart-contracts", "defi"],
17
+ "license": "MIT",
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.0.0",
20
+ "ethers": "^6.0.0",
21
+ "zod": "^3.0.0"
22
+ }
23
+ }
package/scanner.js ADDED
@@ -0,0 +1,447 @@
1
+ /**
2
+ * scanner.js — Contract Security Scanner
3
+ *
4
+ * Analyzes an EVM contract address and returns a risk score + findings.
5
+ * Uses: Base RPC (free) + Etherscan API (free tier, 100k/day)
6
+ *
7
+ * Usage:
8
+ * node research/scanner.js <contractAddress>
9
+ * node research/scanner.js 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
10
+ */
11
+
12
+ import { ethers } from 'ethers';
13
+ import { createHash } from 'crypto';
14
+
15
+ // ─── Config ──────────────────────────────────────────────────────────────────
16
+
17
+ const BASE_RPC = 'https://mainnet.base.org';
18
+ const ETHERSCAN_BASE = 'https://api.etherscan.io/v2/api'; // V2 endpoint (chain-agnostic)
19
+ const ETHERSCAN_CHAIN = '8453'; // Base L2 chain ID
20
+ const ETHERSCAN_KEY = process.env.BASESCAN_API_KEY || 'YourApiKeyToken';
21
+
22
+ const provider = new ethers.JsonRpcProvider(BASE_RPC);
23
+
24
+ // ─── Known Risk Patterns in Bytecode ─────────────────────────────────────────
25
+
26
+ // Solidity function selectors for risky functions
27
+ const RISKY_SELECTORS = {
28
+ // Ownership / control
29
+ 'f2fde38b': { name: 'transferOwnership(address)', severity: 'info', label: 'transferOwnership' },
30
+ '715018a6': { name: 'renounceOwnership()', severity: 'good', label: 'renounceOwnership' },
31
+ 'e30c3978': { name: 'pendingOwner()', severity: 'info', label: 'pendingOwner' },
32
+
33
+ // Minting / supply control
34
+ '40c10f19': { name: 'mint(address,uint256)', severity: 'high', label: 'mint' },
35
+ 'a0712d68': { name: 'mint(uint256)', severity: 'high', label: 'mint' },
36
+ '1249c58b': { name: 'mint()', severity: 'high', label: 'mint' },
37
+ '4e6ec247': { name: 'mintTokens(address,uint256)', severity: 'high', label: 'mint' },
38
+
39
+ // Pause / freeze
40
+ '8456cb59': { name: 'pause()', severity: 'medium', label: 'pause' },
41
+ '3f4ba83a': { name: 'unpause()', severity: 'medium', label: 'unpause' },
42
+ 'bedb86fb': { name: 'pause(bool)', severity: 'medium', label: 'pause' },
43
+
44
+ // Blacklist / whitelist
45
+ 'f9f92be4': { name: 'blacklist(address)', severity: 'high', label: 'blacklist' },
46
+ '1a895266': { name: 'blacklist(address,bool)', severity: 'high', label: 'blacklist' },
47
+ '42966c68': { name: 'burn(uint256)', severity: 'info', label: 'burn' },
48
+ '79cc6790': { name: 'burnFrom(address,uint256)', severity: 'info', label: 'burnFrom' },
49
+
50
+ // Fee manipulation
51
+ '518ab2a8': { name: 'setFee(uint256)', severity: 'medium', label: 'setFee' },
52
+ '3d3e3c6f': { name: 'setTaxFee(uint256)', severity: 'medium', label: 'setTaxFee' },
53
+
54
+ // Hidden backdoors (common in rugs)
55
+ 'e0f7392b': { name: 'rescueTokens()', severity: 'critical', label: 'rescueTokens' },
56
+ 'f1b9e7d8': { name: 'withdrawAll()', severity: 'critical', label: 'withdrawAll' },
57
+ };
58
+
59
+ // ERC20 standard selectors (good signs)
60
+ const ERC20_SELECTORS = ['18160ddd', 'dd62ed3e', '095ea7b3', '23b872dd', 'a9059cbb', '70a08231'];
61
+
62
+ // Proxy patterns (bytecode starts with these)
63
+ const PROXY_PATTERNS = [
64
+ { prefix: '3d602d', label: 'EIP-1167 Minimal Proxy (Clone)' },
65
+ { prefix: '6080604052', label: 'Standard Contract (not a proxy)' }, // not a proxy
66
+ { prefix: '363d3d373d3d3d363d73', label: 'EIP-1167 Minimal Proxy' },
67
+ ];
68
+
69
+ // ─── Etherscan API helpers ────────────────────────────────────────────────────
70
+
71
+ async function fetchEtherscan(params) {
72
+ const url = new URL(ETHERSCAN_BASE);
73
+ url.searchParams.set('chainid', ETHERSCAN_CHAIN);
74
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
75
+ url.searchParams.set('apikey', ETHERSCAN_KEY);
76
+
77
+ try {
78
+ const res = await fetch(url.toString());
79
+ const data = await res.json();
80
+ if (data.status === '1') return data.result;
81
+ return null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ async function getContractSource(address) {
88
+ return fetchEtherscan({
89
+ module: 'contract',
90
+ action: 'getsourcecode',
91
+ address,
92
+ });
93
+ }
94
+
95
+ async function getContractABI(address) {
96
+ return fetchEtherscan({
97
+ module: 'contract',
98
+ action: 'getabi',
99
+ address,
100
+ });
101
+ }
102
+
103
+ async function getTxCount(address) {
104
+ const result = await fetchEtherscan({
105
+ module: 'account',
106
+ action: 'txlist',
107
+ address,
108
+ startblock: 0,
109
+ endblock: 99999999,
110
+ page: 1,
111
+ offset: 10,
112
+ sort: 'desc',
113
+ });
114
+ return result ? result.length : 0;
115
+ }
116
+
117
+ // ─── Analysis functions ───────────────────────────────────────────────────────
118
+
119
+ function analyzeSelectors(bytecode) {
120
+ const found = [];
121
+ const hex = bytecode.toLowerCase().slice(2); // remove 0x
122
+
123
+ for (const [selector, info] of Object.entries(RISKY_SELECTORS)) {
124
+ if (hex.includes(selector)) {
125
+ found.push({ selector, ...info });
126
+ }
127
+ }
128
+
129
+ // Check ERC20 compliance
130
+ const erc20Count = ERC20_SELECTORS.filter(s => hex.includes(s)).length;
131
+ const isERC20 = erc20Count >= 4;
132
+
133
+ return { found, isERC20, erc20Count };
134
+ }
135
+
136
+ function detectProxy(bytecode) {
137
+ const hex = bytecode.toLowerCase();
138
+ for (const p of PROXY_PATTERNS) {
139
+ if (hex.includes(p.prefix) && p.label !== 'Standard Contract (not a proxy)') {
140
+ return { isProxy: true, type: p.label };
141
+ }
142
+ }
143
+ // Check for delegatecall pattern (0xf4 opcode = delegatecall)
144
+ // In hex: f4 appears frequently in non-proxy contracts too, look for pattern
145
+ const hasDelegatecall = hex.includes('5af4');
146
+ return { isProxy: hasDelegatecall, type: hasDelegatecall ? 'Potential Proxy (delegatecall found)' : 'Not a proxy' };
147
+ }
148
+
149
+ function computeRiskScore(findings) {
150
+ let score = 0;
151
+ const weights = { critical: 30, high: 20, medium: 10, info: 3, good: -10 };
152
+
153
+ for (const f of findings) {
154
+ score += (weights[f.severity] || 0);
155
+ }
156
+
157
+ return Math.max(0, Math.min(100, score));
158
+ }
159
+
160
+ // ─── Main scanner ─────────────────────────────────────────────────────────────
161
+
162
+ export async function scanContract(address) {
163
+ const findings = [];
164
+ const meta = {};
165
+
166
+ console.log(`\n🔍 Scanning ${address} on Base...`);
167
+
168
+ // 1. Basic on-chain info
169
+ const [code, txCount] = await Promise.all([
170
+ provider.getCode(address),
171
+ provider.getTransactionCount(address),
172
+ ]);
173
+
174
+ if (code === '0x') {
175
+ return {
176
+ error: 'Not a contract',
177
+ address,
178
+ riskScore: 0,
179
+ findings: [],
180
+ summary: 'NOT_A_CONTRACT',
181
+ };
182
+ }
183
+
184
+ meta.bytecodeSize = (code.length - 2) / 2; // bytes
185
+ meta.deployerTxCount = txCount;
186
+
187
+ console.log(` Bytecode: ${meta.bytecodeSize} bytes`);
188
+
189
+ // 2. Source code verification
190
+ const sourceInfo = await getContractSource(address);
191
+ if (sourceInfo && sourceInfo[0]) {
192
+ const s = sourceInfo[0];
193
+ meta.contractName = s.ContractName || 'Unknown';
194
+ meta.compiler = s.CompilerVersion || '?';
195
+ meta.sourceVerified = s.SourceCode && s.SourceCode.length > 0;
196
+ meta.isProxy = s.Proxy === '1';
197
+ meta.implementation = s.Implementation || null;
198
+
199
+ console.log(` Name: ${meta.contractName}`);
200
+ console.log(` Verified: ${meta.sourceVerified}`);
201
+
202
+ if (!meta.sourceVerified) {
203
+ findings.push({
204
+ severity: 'high',
205
+ label: 'unverified_source',
206
+ name: 'Source code not verified',
207
+ detail: 'Cannot inspect source code — high risk of hidden backdoors',
208
+ });
209
+ } else {
210
+ findings.push({
211
+ severity: 'good',
212
+ label: 'verified_source',
213
+ name: 'Source code verified',
214
+ detail: `Contract: ${meta.contractName}`,
215
+ });
216
+ }
217
+
218
+ if (meta.isProxy) {
219
+ findings.push({
220
+ severity: 'medium',
221
+ label: 'upgradeable_proxy',
222
+ name: 'Upgradeable proxy',
223
+ detail: `Implementation: ${meta.implementation || 'unknown'} — owner can upgrade the logic`,
224
+ });
225
+ }
226
+ } else {
227
+ meta.sourceVerified = false;
228
+ findings.push({
229
+ severity: 'high',
230
+ label: 'unverified_source',
231
+ name: 'Source code not verified',
232
+ detail: 'BaseScan has no source code for this contract',
233
+ });
234
+ }
235
+
236
+ // 3. Bytecode selector analysis
237
+ const { found: selectorFindings, isERC20 } = analyzeSelectors(code);
238
+ meta.isERC20 = isERC20;
239
+
240
+ if (isERC20) {
241
+ findings.push({
242
+ severity: 'good',
243
+ label: 'erc20_compliant',
244
+ name: 'ERC20 compliant',
245
+ detail: 'Standard transfer/approve/allowance functions detected',
246
+ });
247
+ }
248
+
249
+ for (const sf of selectorFindings) {
250
+ findings.push({
251
+ severity: sf.severity,
252
+ label: sf.label,
253
+ name: sf.name,
254
+ detail: `Selector 0x${sf.selector} found in bytecode`,
255
+ });
256
+ }
257
+
258
+ // 4. Proxy bytecode detection
259
+ const proxyInfo = detectProxy(code);
260
+ if (proxyInfo.isProxy && !meta.isProxy) {
261
+ findings.push({
262
+ severity: 'medium',
263
+ label: 'proxy_pattern',
264
+ name: 'Proxy pattern detected in bytecode',
265
+ detail: proxyInfo.type,
266
+ });
267
+ }
268
+
269
+ // 5. Age check
270
+ try {
271
+ const txs = await fetchEtherscan({
272
+ module: 'account',
273
+ action: 'txlist',
274
+ address,
275
+ startblock: 0,
276
+ endblock: 99999999,
277
+ page: 1,
278
+ offset: 1,
279
+ sort: 'asc',
280
+ });
281
+ if (txs && txs[0]) {
282
+ const deployTs = parseInt(txs[0].timeStamp);
283
+ const agedays = (Date.now() / 1000 - deployTs) / 86400;
284
+ meta.agedays = Math.round(agedays);
285
+ meta.deployer = txs[0].from;
286
+
287
+ if (agedays < 7) {
288
+ findings.push({
289
+ severity: 'high',
290
+ label: 'new_contract',
291
+ name: 'Contract deployed < 7 days ago',
292
+ detail: `Deployed ${meta.agedays} days ago by ${meta.deployer}`,
293
+ });
294
+ } else if (agedays < 30) {
295
+ findings.push({
296
+ severity: 'medium',
297
+ label: 'recent_contract',
298
+ name: 'Contract deployed < 30 days ago',
299
+ detail: `Deployed ${meta.agedays} days ago`,
300
+ });
301
+ } else {
302
+ findings.push({
303
+ severity: 'good',
304
+ label: 'established_contract',
305
+ name: `Contract established (${meta.agedays} days old)`,
306
+ detail: `Deployed ${meta.agedays} days ago`,
307
+ });
308
+ }
309
+ }
310
+ } catch { /* skip age check */ }
311
+
312
+ // 6. Transaction volume (activity check)
313
+ try {
314
+ const recentTxs = await fetchEtherscan({
315
+ module: 'account',
316
+ action: 'txlist',
317
+ address,
318
+ startblock: 0,
319
+ endblock: 99999999,
320
+ page: 1,
321
+ offset: 100,
322
+ sort: 'desc',
323
+ });
324
+ meta.recentTxCount = recentTxs ? recentTxs.length : 0;
325
+
326
+ if (meta.recentTxCount === 0) {
327
+ findings.push({
328
+ severity: 'medium',
329
+ label: 'no_activity',
330
+ name: 'No recent transactions',
331
+ detail: 'Contract has no transaction history — low usage or brand new',
332
+ });
333
+ } else if (meta.recentTxCount >= 10) {
334
+ findings.push({
335
+ severity: 'good',
336
+ label: 'active_contract',
337
+ name: `Active contract (${meta.recentTxCount}+ recent txs)`,
338
+ detail: 'Contract shows regular usage',
339
+ });
340
+ }
341
+ } catch { /* skip */ }
342
+
343
+ // ─── Compute risk score ────────────────────────────────────────────────────
344
+
345
+ const riskScore = computeRiskScore(findings);
346
+ const riskLabel = riskScore >= 70 ? 'CRITICAL' :
347
+ riskScore >= 50 ? 'HIGH' :
348
+ riskScore >= 30 ? 'MEDIUM' :
349
+ riskScore >= 10 ? 'LOW' : 'SAFE';
350
+
351
+ // ─── Build result ─────────────────────────────────────────────────────────
352
+
353
+ const criticalFindings = findings.filter(f => f.severity === 'critical');
354
+ const highFindings = findings.filter(f => f.severity === 'high');
355
+ const mediumFindings = findings.filter(f => f.severity === 'medium');
356
+ const goodFindings = findings.filter(f => f.severity === 'good');
357
+
358
+ const summaryParts = [
359
+ `RISK:${riskScore}/${riskLabel}`,
360
+ criticalFindings.length > 0 ? `critical:${criticalFindings.map(f => f.label).join(',')}` : null,
361
+ highFindings.length > 0 ? `high:${highFindings.map(f => f.label).join(',')}` : null,
362
+ mediumFindings.length > 0 ? `medium:${mediumFindings.map(f => f.label).join(',')}` : null,
363
+ `age:${meta.agedays ?? '?'}d`,
364
+ meta.sourceVerified ? 'verified:YES' : 'verified:NO',
365
+ ].filter(Boolean).join(' | ');
366
+
367
+ const result = {
368
+ address,
369
+ timestamp: new Date().toISOString(),
370
+ riskScore,
371
+ riskLabel,
372
+ meta,
373
+ findings,
374
+ summary: summaryParts.slice(0, 500), // max 500 chars for on-chain
375
+ criticalCount: criticalFindings.length,
376
+ highCount: highFindings.length,
377
+ mediumCount: mediumFindings.length,
378
+ goodCount: goodFindings.length,
379
+ };
380
+
381
+ // Compute result hash (keccak256 of full JSON)
382
+ const resultJson = JSON.stringify(result);
383
+ result.resultHash = '0x' + createHash('sha256').update(resultJson).digest('hex');
384
+ // Note: using sha256 here, contract uses bytes32 — compatible
385
+
386
+ return result;
387
+ }
388
+
389
+ // ─── Print helper ──────────────────────────────────────────────────────────────
390
+
391
+ function printResult(result) {
392
+ if (result.error) {
393
+ console.log(`\n❌ Error: ${result.error}`);
394
+ return;
395
+ }
396
+
397
+ const emoji = result.riskScore >= 70 ? '🔴' :
398
+ result.riskScore >= 50 ? '🟠' :
399
+ result.riskScore >= 30 ? '🟡' :
400
+ result.riskScore >= 10 ? '🟢' : '✅';
401
+
402
+ console.log(`\n${'═'.repeat(60)}`);
403
+ console.log(`${emoji} RISK SCORE: ${result.riskScore}/100 — ${result.riskLabel}`);
404
+ console.log(`${'═'.repeat(60)}`);
405
+ console.log(`Contract : ${result.address}`);
406
+ console.log(`Name : ${result.meta.contractName || '?'}`);
407
+ console.log(`Age : ${result.meta.agedays ?? '?'} days`);
408
+ console.log(`Verified : ${result.meta.sourceVerified ? '✅ Yes' : '❌ No'}`);
409
+ console.log(`Bytecode : ${result.meta.bytecodeSize} bytes`);
410
+ console.log(`Txs (recent): ${result.meta.recentTxCount ?? '?'}`);
411
+ console.log(`\nFindings:`);
412
+
413
+ const severityOrder = ['critical', 'high', 'medium', 'info', 'good'];
414
+ const sorted = [...result.findings].sort((a, b) =>
415
+ severityOrder.indexOf(a.severity) - severityOrder.indexOf(b.severity)
416
+ );
417
+
418
+ for (const f of sorted) {
419
+ const icon = { critical: '🔴', high: '🟠', medium: '🟡', info: 'ℹ️ ', good: '✅' }[f.severity] || '·';
420
+ console.log(` ${icon} [${f.severity.toUpperCase().padEnd(8)}] ${f.name}`);
421
+ if (f.detail) console.log(` ${f.detail}`);
422
+ }
423
+
424
+ console.log(`\nSummary (on-chain):`);
425
+ console.log(` ${result.summary}`);
426
+ console.log(`\nResult hash: ${result.resultHash}`);
427
+ console.log(`${'═'.repeat(60)}\n`);
428
+ }
429
+
430
+ // ─── CLI entry point ──────────────────────────────────────────────────────────
431
+
432
+ if (process.argv[1]?.endsWith('scanner.js')) {
433
+ const address = process.argv[2];
434
+ if (!address) {
435
+ console.error('Usage: node research/scanner.js <contractAddress>');
436
+ process.exit(1);
437
+ }
438
+
439
+ scanContract(address)
440
+ .then(result => {
441
+ printResult(result);
442
+ })
443
+ .catch(err => {
444
+ console.error('Scan failed:', err.message);
445
+ process.exit(1);
446
+ });
447
+ }
package/server.js ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * MCP Server — Contract Security Scanner
3
+ *
4
+ * Exposes the p8 contract scanner as an MCP tool.
5
+ * Compatible with: Claude Desktop, Cursor, Cline, any MCP client.
6
+ *
7
+ * Usage (stdio):
8
+ * node /path/to/p8/mcp/server.js
9
+ *
10
+ * Tools exposed:
11
+ * scan_contract(address) — Full security scan of a Base contract
12
+ * scan_contract_quick(address) — Bytecode-only scan (no Etherscan API needed)
13
+ */
14
+
15
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
16
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
+ import { z } from 'zod';
18
+ import { scanContract } from './scanner.js';
19
+
20
+ // ─── Server ───────────────────────────────────────────────────────────────────
21
+
22
+ const server = new McpServer({
23
+ name: 'contract-scanner',
24
+ version: '1.0.0',
25
+ });
26
+
27
+ // ─── Tool: scan_contract ──────────────────────────────────────────────────────
28
+
29
+ server.tool(
30
+ 'scan_contract',
31
+ `Scans an EVM smart contract on Base L2 for security risks.
32
+
33
+ Analyzes:
34
+ - Source code verification status
35
+ - Risky function selectors (mint, blacklist, pause, backdoors, etc.)
36
+ - Proxy / upgradeability patterns
37
+ - Contract age (new contracts = higher risk)
38
+ - Transaction activity
39
+
40
+ Returns a risk score (0-100) and categorized findings (critical/high/medium/good).
41
+
42
+ Use this before:
43
+ - Interacting with an unknown contract
44
+ - Approving a token spend
45
+ - Investing in a DeFi protocol
46
+ - Auditing a smart contract`,
47
+ {
48
+ address: z
49
+ .string()
50
+ .regex(/^0x[0-9a-fA-F]{40}$/, 'Must be a valid EVM address (0x + 40 hex chars)')
51
+ .describe('Contract address on Base L2 to scan'),
52
+ include_raw: z
53
+ .boolean()
54
+ .optional()
55
+ .default(false)
56
+ .describe('Include raw findings array in response (default: false)'),
57
+ },
58
+ async ({ address, include_raw }) => {
59
+ try {
60
+ const result = await scanContract(address);
61
+
62
+ if (result.error) {
63
+ return {
64
+ content: [{
65
+ type: 'text',
66
+ text: `❌ **Scan failed**: ${result.error}\n\nAddress \`${address}\` is not a deployed contract on Base.`,
67
+ }],
68
+ isError: true,
69
+ };
70
+ }
71
+
72
+ // Risk emoji
73
+ const emoji = result.riskScore >= 70 ? '🔴' :
74
+ result.riskScore >= 50 ? '🟠' :
75
+ result.riskScore >= 30 ? '🟡' :
76
+ result.riskScore >= 10 ? '🟢' : '✅';
77
+
78
+ // Build markdown response
79
+ const lines = [
80
+ `## ${emoji} Risk Score: ${result.riskScore}/100 — ${result.riskLabel}`,
81
+ ``,
82
+ `**Contract**: \`${result.address}\``,
83
+ `**Name**: ${result.meta.contractName || 'Unknown'}`,
84
+ `**Age**: ${result.meta.agedays ?? '?'} days`,
85
+ `**Source verified**: ${result.meta.sourceVerified ? '✅ Yes' : '❌ No'}`,
86
+ `**Bytecode**: ${result.meta.bytecodeSize} bytes`,
87
+ `**Recent txs**: ${result.meta.recentTxCount ?? '?'}`,
88
+ ``,
89
+ ];
90
+
91
+ // Group findings by severity
92
+ const bySeverity = { critical: [], high: [], medium: [], info: [], good: [] };
93
+ for (const f of result.findings) {
94
+ (bySeverity[f.severity] || bySeverity.info).push(f);
95
+ }
96
+
97
+ const icons = { critical: '🔴', high: '🟠', medium: '🟡', info: 'ℹ️', good: '✅' };
98
+
99
+ for (const [sev, items] of Object.entries(bySeverity)) {
100
+ if (items.length === 0) continue;
101
+ lines.push(`### ${icons[sev]} ${sev.toUpperCase()} (${items.length})`);
102
+ for (const f of items) {
103
+ lines.push(`- **${f.name}**${f.detail ? `: ${f.detail}` : ''}`);
104
+ }
105
+ lines.push('');
106
+ }
107
+
108
+ // Summary
109
+ lines.push(`---`);
110
+ lines.push(`**On-chain summary**: \`${result.summary}\``);
111
+ lines.push(`**Result hash**: \`${result.resultHash}\``);
112
+ lines.push(`**Scanned at**: ${result.timestamp}`);
113
+
114
+ const text = lines.join('\n');
115
+
116
+ const content = [{ type: 'text', text }];
117
+
118
+ if (include_raw) {
119
+ content.push({
120
+ type: 'text',
121
+ text: `\n\n<details>\n<summary>Raw JSON</summary>\n\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\`\n</details>`,
122
+ });
123
+ }
124
+
125
+ return { content };
126
+
127
+ } catch (err) {
128
+ return {
129
+ content: [{
130
+ type: 'text',
131
+ text: `❌ **Scanner error**: ${err.message}\n\nPlease check the address and try again.`,
132
+ }],
133
+ isError: true,
134
+ };
135
+ }
136
+ }
137
+ );
138
+
139
+ // ─── Tool: batch_scan ────────────────────────────────────────────────────────
140
+
141
+ server.tool(
142
+ 'batch_scan',
143
+ `Scans multiple contracts at once and returns a risk comparison table.
144
+
145
+ Useful for:
146
+ - Comparing multiple DeFi protocols before choosing one
147
+ - Auditing all contracts in a project
148
+ - Quick portfolio risk assessment
149
+
150
+ Scans up to 5 contracts in parallel.`,
151
+ {
152
+ addresses: z
153
+ .array(z.string().regex(/^0x[0-9a-fA-F]{40}$/))
154
+ .min(2)
155
+ .max(5)
156
+ .describe('List of 2-5 contract addresses on Base L2'),
157
+ },
158
+ async ({ addresses }) => {
159
+ try {
160
+ // Scan all in parallel
161
+ const results = await Promise.allSettled(
162
+ addresses.map(addr => scanContract(addr))
163
+ );
164
+
165
+ const lines = [
166
+ `## 📊 Batch Scan — ${addresses.length} contracts`,
167
+ ``,
168
+ `| Contract | Name | Risk | Score | Verified | Age |`,
169
+ `|----------|------|------|-------|----------|-----|`,
170
+ ];
171
+
172
+ for (let i = 0; i < results.length; i++) {
173
+ const r = results[i];
174
+ if (r.status === 'rejected' || r.value?.error) {
175
+ lines.push(`| \`${addresses[i].slice(0, 10)}...\` | ERROR | ❓ | N/A | N/A | N/A |`);
176
+ continue;
177
+ }
178
+ const v = r.value;
179
+ const emoji = v.riskScore >= 70 ? '🔴' :
180
+ v.riskScore >= 50 ? '🟠' :
181
+ v.riskScore >= 30 ? '🟡' :
182
+ v.riskScore >= 10 ? '🟢' : '✅';
183
+
184
+ lines.push(
185
+ `| \`${v.address.slice(0, 10)}...\` | ${v.meta.contractName || '?'} | ${emoji} ${v.riskLabel} | ${v.riskScore}/100 | ${v.meta.sourceVerified ? '✅' : '❌'} | ${v.meta.agedays ?? '?'}d |`
186
+ );
187
+ }
188
+
189
+ lines.push('');
190
+ lines.push(`_Scanned at ${new Date().toISOString()}_`);
191
+
192
+ return {
193
+ content: [{ type: 'text', text: lines.join('\n') }],
194
+ };
195
+
196
+ } catch (err) {
197
+ return {
198
+ content: [{ type: 'text', text: `❌ Batch scan error: ${err.message}` }],
199
+ isError: true,
200
+ };
201
+ }
202
+ }
203
+ );
204
+
205
+ // ─── Tool: interpret_risk ────────────────────────────────────────────────────
206
+
207
+ server.tool(
208
+ 'interpret_risk',
209
+ `Given a risk score and findings from a scan, provides an actionable recommendation.
210
+
211
+ Returns: SAFE_TO_USE / PROCEED_WITH_CAUTION / HIGH_RISK / DO_NOT_USE`,
212
+ {
213
+ risk_score: z.number().min(0).max(100).describe('Risk score from scan_contract'),
214
+ risk_label: z.enum(['SAFE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']).describe('Risk label from scan_contract'),
215
+ has_unverified_source: z.boolean().describe('Whether source code is unverified'),
216
+ has_backdoor: z.boolean().optional().default(false).describe('Whether rescueTokens/withdrawAll detected'),
217
+ context: z.enum(['token', 'defi', 'nft', 'unknown']).optional().default('unknown').describe('Type of contract being evaluated'),
218
+ },
219
+ async ({ risk_score, risk_label, has_unverified_source, has_backdoor, context }) => {
220
+ let verdict = '';
221
+ let explanation = '';
222
+ let actions = [];
223
+
224
+ if (has_backdoor) {
225
+ verdict = '🔴 DO_NOT_USE';
226
+ explanation = 'Backdoor functions detected (rescueTokens/withdrawAll). This contract can drain funds.';
227
+ actions = ['Do not approve any token spend', 'Do not deposit funds', 'Warn your community'];
228
+ } else if (risk_score >= 70 || (has_unverified_source && risk_score >= 40)) {
229
+ verdict = '🟠 HIGH_RISK';
230
+ explanation = `Score ${risk_score}/100 indicates significant risk. ${has_unverified_source ? 'Unverified source makes it impossible to audit fully.' : ''}`;
231
+ actions = ['Only interact with small amounts to test', 'Check if audited by a firm', 'Look for team doxxing'];
232
+ } else if (risk_score >= 30) {
233
+ verdict = '🟡 PROCEED_WITH_CAUTION';
234
+ explanation = `Score ${risk_score}/100. Some elevated-risk patterns found but no critical issues.`;
235
+ actions = [
236
+ 'Check if mint/pause are behind a timelock',
237
+ context === 'defi' ? 'Verify TVL history before depositing' : null,
238
+ 'Read the contract documentation',
239
+ ].filter(Boolean);
240
+ } else {
241
+ verdict = '✅ SAFE_TO_USE';
242
+ explanation = `Score ${risk_score}/100. No critical issues detected. Standard risk for a ${context} contract.`;
243
+ actions = ['Standard due diligence still recommended', 'Monitor for contract upgrades if proxy'];
244
+ }
245
+
246
+ const text = [
247
+ `## ${verdict}`,
248
+ ``,
249
+ explanation,
250
+ ``,
251
+ `**Recommended actions:**`,
252
+ ...actions.map(a => `- ${a}`),
253
+ ].join('\n');
254
+
255
+ return { content: [{ type: 'text', text }] };
256
+ }
257
+ );
258
+
259
+ // ─── Start ────────────────────────────────────────────────────────────────────
260
+
261
+ async function main() {
262
+ const transport = new StdioServerTransport();
263
+ await server.connect(transport);
264
+ // MCP servers communicate via stdio — no console.log here
265
+ }
266
+
267
+ main().catch(err => {
268
+ process.stderr.write(`MCP server error: ${err.message}\n`);
269
+ process.exit(1);
270
+ });