agentaudit 3.10.9 → 3.12.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/index.mjs CHANGED
@@ -1,659 +1,795 @@
1
- #!/usr/bin/env node
2
- /**
3
- * AgentAudit MCP Server
4
- *
5
- * Security audit capabilities via Model Context Protocol.
6
- *
7
- * Tools:
8
- * - discover_servers Find locally installed MCP servers + check registry status
9
- * - audit_package Clone a repo, return source code + audit prompt for LLM analysis
10
- * - submit_report Upload a completed audit report to agentaudit.dev
11
- * - check_package Look up a package in the AgentAudit registry
12
- *
13
- * Usage:
14
- * npx agentaudit (starts MCP server via stdio)
15
- * node index.mjs (same)
16
- *
17
- * Configure in Claude/Cursor/Windsurf:
18
- * { "mcpServers": { "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } } }
19
- */
20
-
21
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
22
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
23
- import {
24
- CallToolRequestSchema,
25
- ListToolsRequestSchema,
26
- } from '@modelcontextprotocol/sdk/types.js';
27
- import fs from 'fs';
28
- import os from 'os';
29
- import path from 'path';
30
- import crypto from 'crypto';
31
- import { execSync, execFileSync } from 'child_process';
32
- import { fileURLToPath } from 'url';
33
-
34
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
35
- const SKILL_DIR = path.resolve(__dirname);
36
- const REGISTRY_URL = 'https://agentaudit.dev';
37
- const MAX_FILE_SIZE = 50_000;
38
- const MAX_TOTAL_SIZE = 300_000;
39
- const SKIP_DIRS = new Set([
40
- 'node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build',
41
- '.next', '.nuxt', 'coverage', '.pytest_cache', '.mypy_cache', 'vendor',
42
- 'test', 'tests', '__tests__', 'spec', 'specs', 'docs', 'doc',
43
- 'examples', 'example', 'fixtures', '.vscode', '.idea',
44
- 'e2e', 'benchmark', 'benchmarks', '.tox', '.eggs', 'htmlcov',
45
- ]);
46
- const SKIP_EXTENSIONS = new Set([
47
- '.lock', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff',
48
- '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.zip', '.tar', '.gz',
49
- '.map', '.min.js', '.min.css', '.d.ts', '.pyc', '.pyo', '.so',
50
- '.dylib', '.dll', '.exe', '.bin', '.dat', '.db', '.sqlite',
51
- ]);
52
- const PRIORITY_FILES = [
53
- 'index.js', 'index.ts', 'index.mjs', 'main.js', 'main.ts', 'main.py',
54
- 'app.js', 'app.ts', 'app.py', 'server.js', 'server.ts', 'server.py',
55
- 'cli.js', 'cli.ts', 'cli.py', '__init__.py', '__main__.py',
56
- 'package.json', 'pyproject.toml', 'setup.py', 'setup.cfg',
57
- 'Cargo.toml', 'go.mod', 'SKILL.md', 'skill.md',
58
- 'Makefile', 'Dockerfile', 'docker-compose.yml',
59
- ];
60
-
61
- // ── Credentials ─────────────────────────────────────────
62
-
63
- function loadApiKey() {
64
- if (process.env.AGENTAUDIT_API_KEY) return process.env.AGENTAUDIT_API_KEY;
65
- const home = process.env.HOME || process.env.USERPROFILE || '';
66
- const xdg = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
67
- const paths = [
68
- path.join(SKILL_DIR, 'config', 'credentials.json'),
69
- path.join(xdg, 'agentaudit', 'credentials.json'),
70
- ];
71
- for (const p of paths) {
72
- if (fs.existsSync(p)) {
73
- try {
74
- const key = JSON.parse(fs.readFileSync(p, 'utf8')).api_key;
75
- if (key) return key;
76
- } catch {}
77
- }
78
- }
79
- return '';
80
- }
81
-
82
- function loadAuditPrompt() {
83
- const promptPath = path.join(SKILL_DIR, 'prompts', 'audit-prompt.md');
84
- if (fs.existsSync(promptPath)) return fs.readFileSync(promptPath, 'utf8');
85
- return 'ERROR: audit-prompt.md not found at ' + promptPath;
86
- }
87
-
88
- // ── File Collection ─────────────────────────────────────
89
-
90
- function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0 }) {
91
- if (totalSize.bytes >= MAX_TOTAL_SIZE) return collected;
92
- let entries;
93
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
94
- catch { return collected; }
95
- entries.sort((a, b) => {
96
- const aP = PRIORITY_FILES.includes(a.name) ? 0 : 1;
97
- const bP = PRIORITY_FILES.includes(b.name) ? 0 : 1;
98
- return aP - bP || a.name.localeCompare(b.name);
99
- });
100
- for (const entry of entries) {
101
- if (totalSize.bytes >= MAX_TOTAL_SIZE) break;
102
- const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
103
- const fullPath = path.join(dir, entry.name);
104
- if (entry.isDirectory()) {
105
- // Special: scan .github/workflows/ (security-critical CI/CD files)
106
- if (entry.name === '.github') {
107
- const wfDir = path.join(fullPath, 'workflows');
108
- try { if (fs.statSync(wfDir).isDirectory()) collectFiles(wfDir, relPath + '/workflows', collected, totalSize); } catch {}
109
- continue;
110
- }
111
- if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
112
- collectFiles(fullPath, relPath, collected, totalSize);
113
- } else {
114
- const ext = path.extname(entry.name).toLowerCase();
115
- if (SKIP_EXTENSIONS.has(ext)) continue;
116
- try {
117
- const stat = fs.statSync(fullPath);
118
- if (stat.size > MAX_FILE_SIZE) {
119
- collected.push({ path: relPath, content: `[FILE TOO LARGE: ${stat.size} bytes — skipped]` });
120
- continue;
121
- }
122
- if (stat.size === 0) continue;
123
- const content = fs.readFileSync(fullPath, 'utf8');
124
- totalSize.bytes += content.length;
125
- collected.push({ path: relPath, content });
126
- } catch {}
127
- }
128
- }
129
- return collected;
130
- }
131
-
132
- // ── Package Detection ────────────────────────────────────
133
-
134
- function detectPackageInfo(repoPath, files) {
135
- const info = { type: 'unknown' };
136
- const allContent = files.map(f => f.content).join('\n');
137
- if (allContent.includes('modelcontextprotocol') || allContent.includes('FastMCP') || allContent.includes('mcp.server') || allContent.includes('mcp_server') || allContent.includes('mcp-go')) {
138
- info.type = 'mcp-server';
139
- } else if (files.some(f => f.path.toLowerCase() === 'skill.md')) {
140
- info.type = 'agent-skill';
141
- } else if (allContent.includes('#!/usr/bin/env') || allContent.includes('argparse') || allContent.includes('commander')) {
142
- info.type = 'cli-tool';
143
- } else {
144
- info.type = 'library';
145
- }
146
- return info;
147
- }
148
-
149
- // ── Repo Helpers ────────────────────────────────────────
150
-
151
- function validateGitUrl(url) {
152
- if (/[;&|`$(){}!\n\r]/.test(url)) {
153
- throw new Error(`Rejected URL with suspicious characters: ${url.slice(0, 80)}`);
154
- }
155
- if (!/^(https?:\/\/|git@|git:\/\/|ssh:\/\/)/.test(url) && !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(url)) {
156
- throw new Error(`Invalid repository URL: ${url.slice(0, 80)}`);
157
- }
158
- }
159
-
160
- function cloneRepo(sourceUrl) {
161
- validateGitUrl(sourceUrl);
162
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
163
- try {
164
- execFileSync('git', ['clone', '--depth', '1', sourceUrl, path.join(tmpDir, 'repo')], {
165
- timeout: 30_000, stdio: 'pipe',
166
- });
167
- return path.join(tmpDir, 'repo');
168
- } catch (err) {
169
- throw new Error(`Failed to clone ${sourceUrl}: ${err.message}`);
170
- }
171
- }
172
-
173
- function cleanupRepo(repoPath) {
174
- try { fs.rmSync(path.dirname(repoPath), { recursive: true, force: true }); } catch {}
175
- }
176
-
177
- function slugFromUrl(url) {
178
- const match = url.match(/github\.com\/([^/]+)\/([^/.\s]+)/);
179
- if (match) return match[2].toLowerCase().replace(/[^a-z0-9-]/g, '-');
180
- return url.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 60);
181
- }
182
-
183
- // ── Discover local MCP configs ──────────────────────────
184
-
185
- function discoverMcpServers() {
186
- const home = process.env.HOME || process.env.USERPROFILE || '';
187
- const candidates = [
188
- { platform: 'Claude Desktop', path: path.join(home, '.claude', 'mcp.json') },
189
- { platform: 'Claude Desktop', path: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') },
190
- { platform: 'Claude Desktop', path: path.join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json') },
191
- { platform: 'Claude Desktop', path: path.join(home, '.config', 'claude', 'claude_desktop_config.json') },
192
- { platform: 'Cursor', path: path.join(home, '.cursor', 'mcp.json') },
193
- { platform: 'Windsurf', path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json') },
194
- { platform: 'VS Code', path: path.join(home, '.vscode', 'mcp.json') },
195
- ];
196
-
197
- const results = [];
198
-
199
- for (const c of candidates) {
200
- if (!fs.existsSync(c.path)) {
201
- results.push({ platform: c.platform, config_path: c.path, status: 'not found', servers: [] });
202
- continue;
203
- }
204
- let content;
205
- try { content = JSON.parse(fs.readFileSync(c.path, 'utf8')); }
206
- catch { results.push({ platform: c.platform, config_path: c.path, status: 'parse error', servers: [] }); continue; }
207
-
208
- const serverMap = content.mcpServers || content.servers || {};
209
- const servers = [];
210
- for (const [name, cfg] of Object.entries(serverMap)) {
211
- const allArgs = [cfg.command, ...(cfg.args || [])].filter(Boolean).join(' ');
212
- const npxMatch = allArgs.match(/npx\s+(?:-y\s+)?(@?[a-z0-9][\w./-]*)/i);
213
- const pyMatch = allArgs.match(/(?:uvx|pip run|python -m)\s+(@?[a-z0-9][\w./-]*)/i);
214
- let remoteService = null;
215
- if (cfg.url) {
216
- try {
217
- const hostParts = new URL(cfg.url).hostname.split('.');
218
- remoteService = hostParts.length === 3 ? hostParts[1] : hostParts[0];
219
- } catch {}
220
- }
221
- servers.push({
222
- name,
223
- command: cfg.command || null,
224
- args: cfg.args || [],
225
- url: cfg.url || null,
226
- npm_package: npxMatch?.[1] || null,
227
- pip_package: pyMatch?.[1] || null,
228
- remote_service: remoteService,
229
- });
230
- }
231
- results.push({ platform: c.platform, config_path: c.path, status: 'found', server_count: servers.length, servers });
232
- }
233
-
234
- return results;
235
- }
236
-
237
- async function resolveSourceUrl(server) {
238
- if (server.npm_package) {
239
- try {
240
- const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(server.npm_package)}`, {
241
- signal: AbortSignal.timeout(5000),
242
- });
243
- if (res.ok) {
244
- const data = await res.json();
245
- let repoUrl = data.repository?.url;
246
- if (repoUrl) {
247
- repoUrl = repoUrl.replace(/^git\+/, '').replace(/\.git$/, '').replace(/^ssh:\/\/git@github\.com/, 'https://github.com');
248
- if (repoUrl.startsWith('http')) return repoUrl;
249
- }
250
- }
251
- } catch {}
252
- return `https://www.npmjs.com/package/${server.npm_package}`;
253
- }
254
- if (server.pip_package) {
255
- try {
256
- const res = await fetch(`https://pypi.org/pypi/${encodeURIComponent(server.pip_package)}/json`, {
257
- signal: AbortSignal.timeout(5000),
258
- });
259
- if (res.ok) {
260
- const data = await res.json();
261
- const urls = data.info?.project_urls || {};
262
- const source = urls.Source || urls.Repository || urls.Homepage || urls['Source Code'] || data.info?.home_page;
263
- if (source && source.startsWith('http')) return source;
264
- }
265
- } catch {}
266
- return `https://pypi.org/project/${server.pip_package}/`;
267
- }
268
- // URL-based remote MCP — try npm with common naming patterns
269
- if (server.remote_service) {
270
- for (const tryName of [
271
- `@${server.remote_service}/mcp-server-${server.remote_service}`,
272
- `${server.remote_service}-mcp`,
273
- `mcp-server-${server.remote_service}`,
274
- ]) {
275
- try {
276
- const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(tryName)}`, {
277
- signal: AbortSignal.timeout(3000),
278
- });
279
- if (res.ok) {
280
- const data = await res.json();
281
- let repoUrl = data.repository?.url;
282
- if (repoUrl) {
283
- repoUrl = repoUrl.replace(/^git\+/, '').replace(/\.git$/, '');
284
- if (repoUrl.startsWith('http')) return repoUrl;
285
- }
286
- }
287
- } catch {}
288
- }
289
- }
290
- return null;
291
- }
292
-
293
- async function checkRegistry(slug) {
294
- try {
295
- const res = await fetch(`${REGISTRY_URL}/api/skills/${encodeURIComponent(slug)}`, {
296
- signal: AbortSignal.timeout(5000),
297
- });
298
- if (res.ok) return await res.json();
299
- } catch {}
300
- return null;
301
- }
302
-
303
- // ── MCP Server ───────────────────────────────────────────
304
-
305
- const server = new Server(
306
- { name: 'agentaudit', version: '3.9.8' },
307
- { capabilities: { tools: {} } }
308
- );
309
-
310
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
311
- tools: [
312
- {
313
- name: 'discover_servers',
314
- description: 'Scan local config files to list ALREADY INSTALLED MCP servers (Claude Desktop, Cursor, Windsurf, VS Code). Use ONLY when the user wants to review/list their existing servers. Do NOT use this when the user wants to install, evaluate, or look up a specific package — use check_package for that instead.',
315
- inputSchema: {
316
- type: 'object',
317
- properties: {
318
- check_registry: {
319
- type: 'boolean',
320
- description: 'If true, also check each discovered server against the AgentAudit registry (default: true)',
321
- },
322
- },
323
- },
324
- },
325
- {
326
- name: 'audit_package',
327
- description: 'Deep security audit of a Git repository. Clones the repo and returns source code with a 3-pass audit methodology (UNDERSTAND → DETECT → CLASSIFY). You then analyze the code and call submit_report with findings. Use check_package FIRST to see if an audit already exists — only use this for unaudited packages or when a fresh audit is requested.',
328
- inputSchema: {
329
- type: 'object',
330
- properties: {
331
- source_url: {
332
- type: 'string',
333
- description: 'Git repository URL to audit (e.g., https://github.com/owner/repo)',
334
- },
335
- },
336
- required: ['source_url'],
337
- },
338
- },
339
- {
340
- name: 'submit_report',
341
- description: 'Submit a completed security audit report to the AgentAudit registry (agentaudit.dev). Call this after you have analyzed the code from audit_package. The report becomes publicly available and helps other agents make install decisions.',
342
- inputSchema: {
343
- type: 'object',
344
- properties: {
345
- report: {
346
- type: 'object',
347
- description: 'The audit report JSON object. Required fields: skill_slug, source_url, risk_score (0-100), result (safe|caution|unsafe), findings (array), findings_count, max_severity, package_type.',
348
- },
349
- },
350
- required: ['report'],
351
- },
352
- },
353
- {
354
- name: 'check_package',
355
- description: 'Look up a package in the AgentAudit security registry. USE THIS FIRST whenever the user wants to install, add, evaluate, or learn about a specific MCP server or package. Returns risk score, findings, and official audit status if available. If the package is not yet in the registry, suggests running an audit. This is the go-to tool for any "is this safe?" or "should I install this?" question.',
356
- inputSchema: {
357
- type: 'object',
358
- properties: {
359
- package_name: {
360
- type: 'string',
361
- description: 'Package name or slug to look up (e.g., "fastmcp", "mongodb-mcp-server")',
362
- },
363
- },
364
- required: ['package_name'],
365
- },
366
- },
367
- ],
368
- }));
369
-
370
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
371
- const { name, arguments: args } = request.params;
372
-
373
- switch (name) {
374
-
375
- // ── discover_servers ──────────────────────────────────
376
- case 'discover_servers': {
377
- const doRegistryCheck = args.check_registry !== false;
378
- const configs = discoverMcpServers();
379
- const foundConfigs = configs.filter(c => c.status === 'found');
380
- const allServers = foundConfigs.flatMap(c => c.servers.map(s => ({ ...s, platform: c.platform })));
381
-
382
- let text = `# Discovered MCP Servers\n\n`;
383
- text += `Scanned ${configs.length} config locations. Found ${foundConfigs.length} config(s) with ${allServers.length} server(s).\n\n`;
384
-
385
- for (const config of configs) {
386
- if (config.status === 'not found') continue;
387
- text += `## ${config.platform}\n`;
388
- text += `Config: \`${config.config_path}\`\n\n`;
389
-
390
- if (config.servers.length === 0) {
391
- text += `No servers configured.\n\n`;
392
- continue;
393
- }
394
-
395
- for (const srv of config.servers) {
396
- const slug = srv.npm_package?.replace(/^@/, '').replace(/\//g, '-')
397
- || srv.pip_package?.replace(/[^a-z0-9-]/gi, '-')
398
- || srv.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
399
-
400
- text += `### ${srv.name}\n`;
401
- if (srv.url) {
402
- text += `- URL: \`${srv.url}\`\n`;
403
- } else {
404
- text += `- Command: \`${[srv.command, ...srv.args].filter(Boolean).join(' ')}\`\n`;
405
- }
406
- if (srv.npm_package) text += `- npm: ${srv.npm_package}\n`;
407
- if (srv.pip_package) text += `- pip: ${srv.pip_package}\n`;
408
- if (srv.remote_service) text += `- Service: ${srv.remote_service}\n`;
409
-
410
- if (doRegistryCheck) {
411
- const regData = await checkRegistry(slug);
412
- if (regData) {
413
- const risk = regData.risk_score ?? regData.latest_risk_score ?? 0;
414
- const official = regData.has_official_audit ? ' (official)' : '';
415
- text += `- **Registry: Audited** Risk ${risk}/100${official}\n`;
416
- text += `- Report: ${REGISTRY_URL}/skills/${slug}\n`;
417
- } else {
418
- const sourceUrl = await resolveSourceUrl(srv);
419
- text += `- **Registry: ⚠️ Not audited** — no audit report found\n`;
420
- if (sourceUrl) {
421
- text += `- Source: ${sourceUrl}\n`;
422
- text += `- To audit: call \`audit_package\` with source_url \`${sourceUrl}\`\n`;
423
- } else {
424
- text += `- Source URL unknown — check the package's GitHub/npm page\n`;
425
- text += `- To audit: find the source URL, then call \`audit_package\`\n`;
426
- }
427
- }
428
- }
429
- text += `\n`;
430
- }
431
- }
432
-
433
- if (allServers.length === 0) {
434
- text += `No MCP servers found. Config locations searched:\n`;
435
- text += `- Claude Desktop: ~/.claude/mcp.json\n`;
436
- text += `- Cursor: ~/.cursor/mcp.json\n`;
437
- text += `- Windsurf: ~/.codeium/windsurf/mcp_config.json\n`;
438
- text += `- VS Code: ~/.vscode/mcp.json\n`;
439
- }
440
-
441
- return { content: [{ type: 'text', text }] };
442
- }
443
-
444
- // ── audit_package ─────────────────────────────────────
445
- case 'audit_package': {
446
- const { source_url } = args;
447
- if (!source_url || !source_url.startsWith('http')) {
448
- return { content: [{ type: 'text', text: 'Error: source_url must be a valid HTTP(S) URL' }] };
449
- }
450
-
451
- let repoPath;
452
- try {
453
- repoPath = cloneRepo(source_url);
454
- const files = collectFiles(repoPath);
455
- const slug = slugFromUrl(source_url);
456
- const auditPrompt = loadAuditPrompt();
457
-
458
- // Compute provenance data
459
- const pkgInfo = detectPackageInfo(repoPath, files);
460
- const KNOWN_MCP_LIBS = new Set(['fastmcp', 'jlowin-fastmcp', 'mcp-go', 'fastapi-mcp', 'fastapi_mcp', 'mcp-use', 'mcp-agent']);
461
- const KNOWN_CLI = new Set(['mcp-cli', 'mcp-scan', 'inspector']);
462
- let detectedType = pkgInfo.type === 'unknown' ? 'other' : pkgInfo.type;
463
- if (KNOWN_MCP_LIBS.has(slug)) detectedType = 'library';
464
- if (KNOWN_CLI.has(slug)) detectedType = 'cli-tool';
465
- let commitSha = '';
466
- try { commitSha = execSync('git rev-parse HEAD', { cwd: repoPath, encoding: 'utf8' }).trim(); } catch {}
467
- const hashInput = files.slice().sort((a, b) => a.path.localeCompare(b.path))
468
- .map(f => f.path + '\n' + f.content).join('\n');
469
- const sourceHash = crypto.createHash('sha256').update(hashInput).digest('hex');
470
-
471
- let codeBlock = '';
472
- for (const file of files) {
473
- codeBlock += `\n### FILE: ${file.path}\n\`\`\`\n${file.content}\n\`\`\`\n`;
474
- }
475
-
476
- const response = [
477
- `# Security Audit: ${slug}`,
478
- ``,
479
- `**Source:** ${source_url}`,
480
- `**Files collected:** ${files.length}`,
481
- `**Detected type:** ${detectedType}`,
482
- commitSha ? `**Commit:** ${commitSha}` : '',
483
- `**Source hash:** ${sourceHash}`,
484
- ``,
485
- `## Your Task`,
486
- ``,
487
- `1. Analyze the source code below using the 3-pass audit methodology`,
488
- `2. Call \`submit_report\` with your findings as JSON`,
489
- `3. IMPORTANT: Include the pre-computed provenance fields exactly as shown below`,
490
- ``,
491
- `## Report Format`,
492
- ``,
493
- `Your report JSON must include:`,
494
- '```json',
495
- `{`,
496
- ` "skill_slug": "${slug}",`,
497
- ` "source_url": "${source_url}",`,
498
- ` "package_type": "${detectedType}",`,
499
- ` "audit_model": "<your-model-id, e.g. claude-sonnet-4-20250514>",`,
500
- commitSha ? ` "commit_sha": "${commitSha}",` : '',
501
- ` "source_hash": "${sourceHash}",`,
502
- ` "risk_score": <0-100>,`,
503
- ` "result": "<safe|caution|unsafe>",`,
504
- ` "max_severity": "<none|low|medium|high|critical>",`,
505
- ` "findings_count": <number>,`,
506
- ` "findings": [`,
507
- ` {`,
508
- ` "id": "FINDING_ID",`,
509
- ` "title": "Short title",`,
510
- ` "severity": "<low|medium|high|critical>",`,
511
- ` "category": "<category>",`,
512
- ` "description": "Detailed description",`,
513
- ` "file": "path/to/file.js",`,
514
- ` "line": <line_number>,`,
515
- ` "remediation": "How to fix",`,
516
- ` "confidence": "<low|medium|high>",`,
517
- ` "is_by_design": <true|false>`,
518
- ` }`,
519
- ` ]`,
520
- `}`,
521
- '```',
522
- ``,
523
- `## Audit Methodology`,
524
- ``,
525
- auditPrompt,
526
- ``,
527
- `## Source Code`,
528
- ``,
529
- codeBlock,
530
- ].filter(Boolean).join('\n');
531
-
532
- return { content: [{ type: 'text', text: response }] };
533
- } catch (err) {
534
- return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
535
- } finally {
536
- if (repoPath) cleanupRepo(repoPath);
537
- }
538
- }
539
-
540
- // ── submit_report ─────────────────────────────────────
541
- case 'submit_report': {
542
- const { report } = args;
543
- if (!report || typeof report !== 'object') {
544
- return { content: [{ type: 'text', text: 'Error: report must be a JSON object' }] };
545
- }
546
-
547
- const apiKey = loadApiKey();
548
- if (!apiKey) {
549
- return { content: [{ type: 'text', text: 'Error: No API key configured. Run `npx agentaudit setup` or set AGENTAUDIT_API_KEY.' }] };
550
- }
551
-
552
- const required = ['skill_slug', 'source_url', 'risk_score', 'result'];
553
- for (const field of required) {
554
- if (report[field] == null) {
555
- return { content: [{ type: 'text', text: `Error: Missing required field "${field}" in report` }] };
556
- }
557
- }
558
-
559
- if (!Array.isArray(report.findings)) report.findings = [];
560
- report.findings_count = report.findings.length;
561
- if (!report.max_severity) {
562
- const severities = ['critical', 'high', 'medium', 'low', 'none'];
563
- report.max_severity = report.findings.reduce((max, f) => {
564
- const fi = severities.indexOf(f.severity);
565
- const mi = severities.indexOf(max);
566
- return fi < mi ? f.severity : max;
567
- }, 'none');
568
- }
569
-
570
- try {
571
- const res = await fetch(`${REGISTRY_URL}/api/reports`, {
572
- method: 'POST',
573
- headers: {
574
- 'Authorization': `Bearer ${apiKey}`,
575
- 'Content-Type': 'application/json',
576
- },
577
- body: JSON.stringify(report),
578
- signal: AbortSignal.timeout(60_000),
579
- });
580
-
581
- const body = await res.text();
582
- let data;
583
- try { data = JSON.parse(body); } catch { data = { raw: body }; }
584
-
585
- if (res.ok) {
586
- return { content: [{ type: 'text', text: `✅ Report submitted!\n\nReport ID: ${data.report_id || 'unknown'}\nURL: ${REGISTRY_URL}/skills/${report.skill_slug}\nRisk: ${report.risk_score}/100 (${report.result})\nFindings: ${report.findings_count}` }] };
587
- } else {
588
- return { content: [{ type: 'text', text: `Upload failed (HTTP ${res.status}): ${JSON.stringify(data, null, 2)}` }] };
589
- }
590
- } catch (err) {
591
- return { content: [{ type: 'text', text: `Upload error: ${err.message}` }] };
592
- }
593
- }
594
-
595
- // ── check_package ─────────────────────────────────────
596
- case 'check_package': {
597
- const { package_name } = args;
598
- if (!package_name) {
599
- return { content: [{ type: 'text', text: 'Error: package_name is required' }] };
600
- }
601
-
602
- try {
603
- const res = await fetch(`${REGISTRY_URL}/api/skills/${encodeURIComponent(package_name)}`, {
604
- signal: AbortSignal.timeout(10_000),
605
- });
606
-
607
- if (res.status === 404) {
608
- return { content: [{ type: 'text', text: `⚠️ Package "${package_name}" is NOT in the AgentAudit security registry — it has never been audited.\n\nIMPORTANT: Tell the user that this package has no security audit on record and that installing unaudited packages carries risk. Recommend running a security audit first before installing.\n\nTo run an audit now:\n1. Find the source URL (GitHub repo) for "${package_name}"\n2. Call audit_package with that URL\n3. Analyze the code following the methodology\n4. Call submit_report with your findings\n\nIf the user wants to skip the audit and install anyway, warn them that the package is unvetted.` }] };
609
- }
610
-
611
- const data = await res.json();
612
- const risk = data.risk_score ?? data.latest_risk_score ?? 'unknown';
613
- const official = data.has_official_audit ? '✅ Officially audited' : 'Community audit';
614
- const findings = data.total_findings ?? 0;
615
- const maxSev = data.latest_max_severity;
616
- const auditedAt = data.last_audited_at ? new Date(data.last_audited_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : 'unknown';
617
- const version = data.latest_known_version || null;
618
- const isOutdated = data.is_outdated;
619
-
620
- // Build response with risk assessment FIRST so the agent sees it immediately
621
- let summary = '';
622
-
623
- // Risk assessment at the very top — this is what the agent needs to act on
624
- if (typeof risk === 'number' && risk >= 40) {
625
- summary += `🚨 UNSAFE — DO NOT INSTALL WITHOUT WARNING THE USER!\nRisk Score: ${risk}/100 | ${findings} finding(s)\n\nYou MUST tell the user: "${package_name}" has been flagged as HIGH RISK in a security audit. It has ${findings} security finding(s) and a risk score of ${risk}/100. Installing this package is NOT recommended. Ask the user if they want to proceed despite the risk, and link them to the full report: ${REGISTRY_URL}/skills/${package_name}\n\n`;
626
- } else if (typeof risk === 'number' && risk >= 20) {
627
- summary += `⚠️ MODERATE RISK — Inform the user before installing.\nRisk Score: ${risk}/100 | ${findings} finding(s)\n\nTell the user: "${package_name}" has some security findings (risk ${risk}/100). Recommend they review the report before installing: ${REGISTRY_URL}/skills/${package_name}\n\n`;
628
- } else if (typeof risk === 'number') {
629
- summary += `✅ LOW RISK — Safe to install.\nRisk Score: ${risk}/100 | ${findings} finding(s)\n\nThis package has been audited and appears safe. You can proceed with installation.\n\n`;
630
- }
631
-
632
- if (isOutdated) {
633
- summary += `⚠️ OUTDATED: The package has changed since the last audit. Consider running a fresh audit first.\n\n`;
634
- }
635
-
636
- // Details section
637
- summary += `--- Details ---\n`;
638
- summary += `Package: ${package_name}\n`;
639
- summary += `Status: ${official}\n`;
640
- summary += `Last Audited: ${auditedAt}\n`;
641
- if (version) summary += `Audited Version: ${version}\n`;
642
- if (data.source_url) summary += `Source: ${data.source_url}\n`;
643
- summary += `Registry: ${REGISTRY_URL}/skills/${package_name}\n`;
644
-
645
- return { content: [{ type: 'text', text: summary }] };
646
- } catch (err) {
647
- return { content: [{ type: 'text', text: `Registry lookup failed: ${err.message}` }] };
648
- }
649
- }
650
-
651
- default:
652
- return { content: [{ type: 'text', text: `Unknown tool: ${name}. Available: discover_servers, audit_package, submit_report, check_package` }] };
653
- }
654
- });
655
-
656
- // ── Start ────────────────────────────────────────────────
657
-
658
- const transport = new StdioServerTransport();
659
- await server.connect(transport);
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AgentAudit MCP Server
4
+ *
5
+ * Security audit capabilities via Model Context Protocol.
6
+ *
7
+ * Tools:
8
+ * - discover_servers Find locally installed MCP servers + check registry status
9
+ * - audit_package Clone a repo, return source code + audit prompt for LLM analysis
10
+ * - submit_report Upload a completed audit report to agentaudit.dev
11
+ * - check_package Look up a package in the AgentAudit registry
12
+ *
13
+ * Usage:
14
+ * npx agentaudit (starts MCP server via stdio)
15
+ * node index.mjs (same)
16
+ *
17
+ * Configure in Claude/Cursor/Windsurf:
18
+ * { "mcpServers": { "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } } }
19
+ */
20
+
21
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
22
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
23
+ import {
24
+ CallToolRequestSchema,
25
+ ListToolsRequestSchema,
26
+ } from '@modelcontextprotocol/sdk/types.js';
27
+ import fs from 'fs';
28
+ import os from 'os';
29
+ import path from 'path';
30
+ import { execSync, execFileSync } from 'child_process';
31
+ import { fileURLToPath } from 'url';
32
+ import { scanTools, extractToolDefinitions } from './tool-poisoning-detector.mjs';
33
+
34
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
35
+ const SKILL_DIR = path.resolve(__dirname);
36
+ const REGISTRY_URL = 'https://agentaudit.dev';
37
+ const MAX_FILE_SIZE = 50_000;
38
+ const MAX_TOTAL_SIZE = 300_000;
39
+ const SKIP_DIRS = new Set([
40
+ 'node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build',
41
+ '.next', '.nuxt', 'coverage', '.pytest_cache', '.mypy_cache', 'vendor',
42
+ 'test', 'tests', '__tests__', 'spec', 'specs', 'docs', 'doc',
43
+ 'examples', 'example', 'fixtures', '.github', '.vscode', '.idea',
44
+ 'e2e', 'benchmark', 'benchmarks', '.tox', '.eggs', 'htmlcov',
45
+ ]);
46
+ const SKIP_EXTENSIONS = new Set([
47
+ '.lock', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff',
48
+ '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.zip', '.tar', '.gz',
49
+ '.map', '.min.js', '.min.css', '.d.ts', '.pyc', '.pyo', '.so',
50
+ '.dylib', '.dll', '.exe', '.bin', '.dat', '.db', '.sqlite',
51
+ ]);
52
+ const PRIORITY_FILES = [
53
+ 'index.js', 'index.ts', 'index.mjs', 'main.js', 'main.ts', 'main.py',
54
+ 'app.js', 'app.ts', 'app.py', 'server.js', 'server.ts', 'server.py',
55
+ 'cli.js', 'cli.ts', 'cli.py', '__init__.py', '__main__.py',
56
+ 'package.json', 'pyproject.toml', 'setup.py', 'setup.cfg',
57
+ 'Cargo.toml', 'go.mod', 'SKILL.md', 'skill.md',
58
+ 'Makefile', 'Dockerfile', 'docker-compose.yml',
59
+ ];
60
+
61
+ // ── Credentials ─────────────────────────────────────────
62
+
63
+ function loadApiKey() {
64
+ if (process.env.AGENTAUDIT_API_KEY) return process.env.AGENTAUDIT_API_KEY;
65
+ const home = process.env.HOME || process.env.USERPROFILE || '';
66
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
67
+ const paths = [
68
+ path.join(SKILL_DIR, 'config', 'credentials.json'),
69
+ path.join(xdg, 'agentaudit', 'credentials.json'),
70
+ ];
71
+ for (const p of paths) {
72
+ if (fs.existsSync(p)) {
73
+ try {
74
+ const key = JSON.parse(fs.readFileSync(p, 'utf8')).api_key;
75
+ if (key) return key;
76
+ } catch {}
77
+ }
78
+ }
79
+ return '';
80
+ }
81
+
82
+ function loadAuditPrompt() {
83
+ const promptPath = path.join(SKILL_DIR, 'prompts', 'audit-prompt.md');
84
+ if (fs.existsSync(promptPath)) return fs.readFileSync(promptPath, 'utf8');
85
+ return 'ERROR: audit-prompt.md not found at ' + promptPath;
86
+ }
87
+
88
+ // ── File Collection ─────────────────────────────────────
89
+
90
+ function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0 }) {
91
+ if (totalSize.bytes >= MAX_TOTAL_SIZE) return collected;
92
+ let entries;
93
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
94
+ catch { return collected; }
95
+ entries.sort((a, b) => {
96
+ const aP = PRIORITY_FILES.includes(a.name) ? 0 : 1;
97
+ const bP = PRIORITY_FILES.includes(b.name) ? 0 : 1;
98
+ return aP - bP || a.name.localeCompare(b.name);
99
+ });
100
+ for (const entry of entries) {
101
+ if (totalSize.bytes >= MAX_TOTAL_SIZE) break;
102
+ const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
103
+ const fullPath = path.join(dir, entry.name);
104
+ if (entry.isDirectory()) {
105
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
106
+ collectFiles(fullPath, relPath, collected, totalSize);
107
+ } else {
108
+ const ext = path.extname(entry.name).toLowerCase();
109
+ if (SKIP_EXTENSIONS.has(ext)) continue;
110
+ try {
111
+ const stat = fs.statSync(fullPath);
112
+ if (stat.size > MAX_FILE_SIZE) {
113
+ collected.push({ path: relPath, content: `[FILE TOO LARGE: ${stat.size} bytes — skipped]` });
114
+ continue;
115
+ }
116
+ if (stat.size === 0) continue;
117
+ const content = fs.readFileSync(fullPath, 'utf8');
118
+ totalSize.bytes += content.length;
119
+ collected.push({ path: relPath, content });
120
+ } catch {}
121
+ }
122
+ }
123
+ return collected;
124
+ }
125
+
126
+ // ── Repo Helpers ────────────────────────────────────────
127
+
128
+ function validateGitUrl(url) {
129
+ if (/[;&|`$(){}!\n\r]/.test(url)) {
130
+ throw new Error(`Rejected URL with suspicious characters: ${url.slice(0, 80)}`);
131
+ }
132
+ if (!/^(https?:\/\/|git@|git:\/\/|ssh:\/\/)/.test(url) && !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(url)) {
133
+ throw new Error(`Invalid repository URL: ${url.slice(0, 80)}`);
134
+ }
135
+ }
136
+
137
+ function cloneRepo(sourceUrl) {
138
+ validateGitUrl(sourceUrl);
139
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
140
+ try {
141
+ execFileSync('git', ['clone', '--depth', '1', sourceUrl, path.join(tmpDir, 'repo')], {
142
+ timeout: 30_000, stdio: 'pipe',
143
+ });
144
+ return path.join(tmpDir, 'repo');
145
+ } catch (err) {
146
+ throw new Error(`Failed to clone ${sourceUrl}: ${err.message}`);
147
+ }
148
+ }
149
+
150
+ function cleanupRepo(repoPath) {
151
+ try { fs.rmSync(path.dirname(repoPath), { recursive: true, force: true }); } catch {}
152
+ }
153
+
154
+ function slugFromUrl(url) {
155
+ const match = url.match(/github\.com\/([^/]+)\/([^/.\s]+)/);
156
+ if (match) return match[2].toLowerCase().replace(/[^a-z0-9-]/g, '-');
157
+ return url.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 60);
158
+ }
159
+
160
+ // ── Discover local MCP configs ──────────────────────────
161
+
162
+ function discoverMcpServers() {
163
+ const home = process.env.HOME || process.env.USERPROFILE || '';
164
+ const platform = process.platform;
165
+ const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
166
+
167
+ const candidates = [
168
+ // Claude Desktop
169
+ ...(platform === 'darwin' ? [{ platform: 'Claude Desktop', path: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') }] : []),
170
+ ...(platform === 'win32' ? [{ platform: 'Claude Desktop', path: path.join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json') }] : []),
171
+ ...(platform === 'linux' ? [{ platform: 'Claude Desktop', path: path.join(xdgConfig, 'Claude', 'claude_desktop_config.json') }] : []),
172
+
173
+ // Claude Code
174
+ { platform: 'Claude Code', path: path.join(home, '.claude.json') },
175
+ { platform: 'Claude Code', path: path.join(home, '.claude', 'mcp.json') },
176
+
177
+ // Cursor
178
+ { platform: 'Cursor', path: path.join(home, '.cursor', 'mcp.json') },
179
+
180
+ // Windsurf
181
+ { platform: 'Windsurf', path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json') },
182
+
183
+ // VS Code (uses 'servers' key)
184
+ ...(platform === 'darwin' ? [{ platform: 'VS Code', path: path.join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'), key: 'servers' }] : []),
185
+ ...(platform === 'win32' ? [{ platform: 'VS Code', path: path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'mcp.json'), key: 'servers' }] : []),
186
+ ...(platform === 'linux' ? [{ platform: 'VS Code', path: path.join(xdgConfig, 'Code', 'User', 'mcp.json'), key: 'servers' }] : []),
187
+
188
+ // Cline extension
189
+ ...(platform === 'darwin' ? [{ platform: 'Cline', path: path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json') }] : []),
190
+ ...(platform === 'win32' ? [{ platform: 'Cline', path: path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json') }] : []),
191
+ ...(platform === 'linux' ? [{ platform: 'Cline', path: path.join(xdgConfig, 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json') }] : []),
192
+
193
+ // Roo Code extension
194
+ ...(platform === 'darwin' ? [{ platform: 'Roo Code', path: path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'mcp_settings.json') }] : []),
195
+ ...(platform === 'win32' ? [{ platform: 'Roo Code', path: path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'mcp_settings.json') }] : []),
196
+ ...(platform === 'linux' ? [{ platform: 'Roo Code', path: path.join(xdgConfig, 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'mcp_settings.json') }] : []),
197
+
198
+ // Amazon Q Developer
199
+ { platform: 'Amazon Q', path: path.join(home, '.aws', 'amazonq', 'mcp.json') },
200
+ { platform: 'Amazon Q (IDE)', path: path.join(home, '.aws', 'amazonq', 'default.json') },
201
+
202
+ // Gemini CLI
203
+ { platform: 'Gemini CLI', path: path.join(home, '.gemini', 'settings.json') },
204
+
205
+ // Zed (macOS + Linux)
206
+ ...(platform === 'darwin' ? [{ platform: 'Zed', path: path.join(home, '.zed', 'settings.json'), key: 'context_servers' }] : []),
207
+ ...(platform === 'linux' ? [{ platform: 'Zed', path: path.join(xdgConfig, 'zed', 'settings.json'), key: 'context_servers' }] : []),
208
+
209
+ // Continue.dev
210
+ { platform: 'Continue', path: path.join(home, '.continue', 'config.json') },
211
+
212
+ // Visual Studio (Windows only)
213
+ ...(platform === 'win32' ? [{ platform: 'Visual Studio', path: path.join(home, '.mcp.json') }] : []),
214
+ ];
215
+
216
+ const results = [];
217
+ const seenPaths = new Set();
218
+
219
+ for (const c of candidates) {
220
+ const resolved = path.resolve(c.path);
221
+ if (seenPaths.has(resolved)) continue;
222
+ seenPaths.add(resolved);
223
+
224
+ if (!fs.existsSync(c.path)) {
225
+ results.push({ platform: c.platform, config_path: c.path, status: 'not found', servers: [] });
226
+ continue;
227
+ }
228
+ let content;
229
+ try { content = JSON.parse(fs.readFileSync(c.path, 'utf8')); }
230
+ catch { results.push({ platform: c.platform, config_path: c.path, status: 'parse error', servers: [] }); continue; }
231
+
232
+ // Normalize different key structures
233
+ let serverMap;
234
+ if (c.key === 'context_servers' && content.context_servers) {
235
+ serverMap = {};
236
+ for (const [name, cfg] of Object.entries(content.context_servers)) {
237
+ if (cfg.command && typeof cfg.command === 'object') {
238
+ serverMap[name] = { command: cfg.command.path || cfg.command.command, args: cfg.command.args || [], env: cfg.command.env };
239
+ } else {
240
+ serverMap[name] = cfg;
241
+ }
242
+ }
243
+ } else if (c.key === 'servers' && content.servers && !content.mcpServers) {
244
+ serverMap = content.servers;
245
+ } else {
246
+ serverMap = content.mcpServers || content.servers || {};
247
+ }
248
+
249
+ const servers = [];
250
+ for (const [name, cfg] of Object.entries(serverMap)) {
251
+ const allArgs = [cfg.command, ...(cfg.args || [])].filter(Boolean).join(' ');
252
+ const npxMatch = allArgs.match(/npx\s+(?:-y\s+)?(@?[a-z0-9][\w./-]*)/i);
253
+ const pyMatch = allArgs.match(/(?:uvx|pip run|python -m)\s+(@?[a-z0-9][\w./-]*)/i);
254
+ let remoteService = null;
255
+ if (cfg.url) {
256
+ try {
257
+ const hostParts = new URL(cfg.url).hostname.split('.');
258
+ remoteService = hostParts.length === 3 ? hostParts[1] : hostParts[0];
259
+ } catch {}
260
+ }
261
+ servers.push({
262
+ name,
263
+ command: cfg.command || null,
264
+ args: cfg.args || [],
265
+ url: cfg.url || null,
266
+ npm_package: npxMatch?.[1] || null,
267
+ pip_package: pyMatch?.[1] || null,
268
+ remote_service: remoteService,
269
+ });
270
+ }
271
+ results.push({ platform: c.platform, config_path: c.path, status: 'found', server_count: servers.length, servers });
272
+ }
273
+
274
+ return results;
275
+ }
276
+
277
+ async function resolveSourceUrl(server) {
278
+ if (server.npm_package) {
279
+ try {
280
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(server.npm_package)}`, {
281
+ signal: AbortSignal.timeout(5000),
282
+ });
283
+ if (res.ok) {
284
+ const data = await res.json();
285
+ let repoUrl = data.repository?.url;
286
+ if (repoUrl) {
287
+ repoUrl = repoUrl.replace(/^git\+/, '').replace(/\.git$/, '').replace(/^ssh:\/\/git@github\.com/, 'https://github.com');
288
+ if (repoUrl.startsWith('http')) return repoUrl;
289
+ }
290
+ }
291
+ } catch {}
292
+ return `https://www.npmjs.com/package/${server.npm_package}`;
293
+ }
294
+ if (server.pip_package) {
295
+ try {
296
+ const res = await fetch(`https://pypi.org/pypi/${encodeURIComponent(server.pip_package)}/json`, {
297
+ signal: AbortSignal.timeout(5000),
298
+ });
299
+ if (res.ok) {
300
+ const data = await res.json();
301
+ const urls = data.info?.project_urls || {};
302
+ const source = urls.Source || urls.Repository || urls.Homepage || urls['Source Code'] || data.info?.home_page;
303
+ if (source && source.startsWith('http')) return source;
304
+ }
305
+ } catch {}
306
+ return `https://pypi.org/project/${server.pip_package}/`;
307
+ }
308
+ // URL-based remote MCP — try npm with common naming patterns
309
+ if (server.remote_service) {
310
+ for (const tryName of [
311
+ `@${server.remote_service}/mcp-server-${server.remote_service}`,
312
+ `${server.remote_service}-mcp`,
313
+ `mcp-server-${server.remote_service}`,
314
+ ]) {
315
+ try {
316
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(tryName)}`, {
317
+ signal: AbortSignal.timeout(3000),
318
+ });
319
+ if (res.ok) {
320
+ const data = await res.json();
321
+ let repoUrl = data.repository?.url;
322
+ if (repoUrl) {
323
+ repoUrl = repoUrl.replace(/^git\+/, '').replace(/\.git$/, '');
324
+ if (repoUrl.startsWith('http')) return repoUrl;
325
+ }
326
+ }
327
+ } catch {}
328
+ }
329
+ }
330
+ return null;
331
+ }
332
+
333
+ async function checkRegistry(slug) {
334
+ try {
335
+ const res = await fetch(`${REGISTRY_URL}/api/packages/${encodeURIComponent(slug)}`, {
336
+ signal: AbortSignal.timeout(5000),
337
+ });
338
+ if (res.ok) return await res.json();
339
+ } catch {}
340
+ return null;
341
+ }
342
+
343
+ // ── MCP Server ───────────────────────────────────────────
344
+
345
+ const server = new Server(
346
+ { name: 'agentaudit', version: '3.12.0' },
347
+ { capabilities: { tools: {} } }
348
+ );
349
+
350
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
351
+ tools: [
352
+ {
353
+ name: 'discover_servers',
354
+ description: 'Scan local config files to list ALREADY INSTALLED MCP servers (Claude Desktop, Cursor, Windsurf, VS Code). Use ONLY when the user wants to review/list their existing servers. Do NOT use this when the user wants to install, evaluate, or look up a specific package — use check_package for that instead.',
355
+ inputSchema: {
356
+ type: 'object',
357
+ properties: {
358
+ check_registry: {
359
+ type: 'boolean',
360
+ description: 'If true, also check each discovered server against the AgentAudit registry (default: true)',
361
+ },
362
+ },
363
+ },
364
+ },
365
+ {
366
+ name: 'audit_package',
367
+ description: 'Deep security audit of a Git repository. Clones the repo and returns source code with a 3-pass audit methodology (UNDERSTAND → DETECT → CLASSIFY). You then analyze the code and call submit_report with findings. Use check_package FIRST to see if an audit already exists — only use this for unaudited packages or when a fresh audit is requested.',
368
+ inputSchema: {
369
+ type: 'object',
370
+ properties: {
371
+ source_url: {
372
+ type: 'string',
373
+ description: 'Git repository URL to audit (e.g., https://github.com/owner/repo)',
374
+ },
375
+ },
376
+ required: ['source_url'],
377
+ },
378
+ },
379
+ {
380
+ name: 'submit_report',
381
+ description: 'Submit a completed security audit report to the AgentAudit registry (agentaudit.dev). Call this after you have analyzed the code from audit_package. The report becomes publicly available and helps other agents make install decisions.',
382
+ inputSchema: {
383
+ type: 'object',
384
+ properties: {
385
+ report: {
386
+ type: 'object',
387
+ description: 'The audit report JSON object. Required fields: skill_slug, source_url, risk_score (0-100), result (safe|caution|unsafe), findings (array), findings_count, max_severity, package_type.',
388
+ },
389
+ },
390
+ required: ['report'],
391
+ },
392
+ },
393
+ {
394
+ name: 'check_package',
395
+ description: 'Look up a package in the AgentAudit security registry. USE THIS FIRST whenever the user wants to install, add, evaluate, or learn about a specific MCP server or package. Returns risk score, findings, and official audit status if available. If the package is not yet in the registry, suggests running an audit. This is the go-to tool for any "is this safe?" or "should I install this?" question.',
396
+ inputSchema: {
397
+ type: 'object',
398
+ properties: {
399
+ package_name: {
400
+ type: 'string',
401
+ description: 'Package name or slug to look up (e.g., "fastmcp", "mongodb-mcp-server")',
402
+ },
403
+ },
404
+ required: ['package_name'],
405
+ },
406
+ },
407
+ {
408
+ name: 'scan_tool_poisoning',
409
+ description: 'Scan MCP tool definitions for hidden instructions, unicode tricks, obfuscated payloads, and manipulation patterns. Use this to check if a server\'s tools contain poisoning indicators (prompt injection in descriptions, zero-width characters, cross-tool manipulation, homoglyph attacks). Provide tool definitions directly OR a source_url to extract them from code.',
410
+ inputSchema: {
411
+ type: 'object',
412
+ properties: {
413
+ tool_definitions: {
414
+ type: 'array',
415
+ description: 'Array of tool definition objects to scan. Each object should have: name (string), description (string), inputSchema (object, optional).',
416
+ items: {
417
+ type: 'object',
418
+ properties: {
419
+ name: { type: 'string' },
420
+ description: { type: 'string' },
421
+ inputSchema: { type: 'object' },
422
+ },
423
+ required: ['name'],
424
+ },
425
+ },
426
+ source_url: {
427
+ type: 'string',
428
+ description: 'Git repository URL. If provided (and no tool_definitions), will clone the repo and attempt to statically extract tool definitions from source code.',
429
+ },
430
+ server_name: {
431
+ type: 'string',
432
+ description: 'Name of the MCP server being scanned (for reporting purposes).',
433
+ },
434
+ },
435
+ },
436
+ },
437
+ ],
438
+ }));
439
+
440
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
441
+ const { name, arguments: args } = request.params;
442
+
443
+ switch (name) {
444
+
445
+ // ── discover_servers ──────────────────────────────────
446
+ case 'discover_servers': {
447
+ const doRegistryCheck = args.check_registry !== false;
448
+ const configs = discoverMcpServers();
449
+ const foundConfigs = configs.filter(c => c.status === 'found');
450
+ const allServers = foundConfigs.flatMap(c => c.servers.map(s => ({ ...s, platform: c.platform })));
451
+
452
+ let text = `# Discovered MCP Servers\n\n`;
453
+ text += `Scanned ${configs.length} config locations. Found ${foundConfigs.length} config(s) with ${allServers.length} server(s).\n\n`;
454
+
455
+ for (const config of configs) {
456
+ if (config.status === 'not found') continue;
457
+ text += `## ${config.platform}\n`;
458
+ text += `Config: \`${config.config_path}\`\n\n`;
459
+
460
+ if (config.servers.length === 0) {
461
+ text += `No servers configured.\n\n`;
462
+ continue;
463
+ }
464
+
465
+ for (const srv of config.servers) {
466
+ const slug = srv.npm_package?.replace(/^@/, '').replace(/\//g, '-')
467
+ || srv.pip_package?.replace(/[^a-z0-9-]/gi, '-')
468
+ || srv.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
469
+
470
+ text += `### ${srv.name}\n`;
471
+ if (srv.url) {
472
+ text += `- URL: \`${srv.url}\`\n`;
473
+ } else {
474
+ text += `- Command: \`${[srv.command, ...srv.args].filter(Boolean).join(' ')}\`\n`;
475
+ }
476
+ if (srv.npm_package) text += `- npm: ${srv.npm_package}\n`;
477
+ if (srv.pip_package) text += `- pip: ${srv.pip_package}\n`;
478
+ if (srv.remote_service) text += `- Service: ${srv.remote_service}\n`;
479
+
480
+ if (doRegistryCheck) {
481
+ const regData = await checkRegistry(slug);
482
+ if (regData) {
483
+ const risk = regData.risk_score ?? regData.latest_risk_score ?? 0;
484
+ const official = regData.has_official_audit ? ' (official)' : '';
485
+ text += `- **Registry: ✅ Audited** — Risk ${risk}/100${official}\n`;
486
+ text += `- Report: ${REGISTRY_URL}/packages/${slug}\n`;
487
+ } else {
488
+ const sourceUrl = await resolveSourceUrl(srv);
489
+ text += `- **Registry: ⚠️ Not audited** no audit report found\n`;
490
+ if (sourceUrl) {
491
+ text += `- Source: ${sourceUrl}\n`;
492
+ text += `- To audit: call \`audit_package\` with source_url \`${sourceUrl}\`\n`;
493
+ } else {
494
+ text += `- Source URL unknown — check the package's GitHub/npm page\n`;
495
+ text += `- To audit: find the source URL, then call \`audit_package\`\n`;
496
+ }
497
+ }
498
+ }
499
+ text += `\n`;
500
+ }
501
+ }
502
+
503
+ if (allServers.length === 0) {
504
+ text += `No MCP servers found. Config locations searched:\n`;
505
+ text += `- Claude Desktop: ~/.claude/mcp.json\n`;
506
+ text += `- Cursor: ~/.cursor/mcp.json\n`;
507
+ text += `- Windsurf: ~/.codeium/windsurf/mcp_config.json\n`;
508
+ text += `- VS Code: ~/.vscode/mcp.json\n`;
509
+ }
510
+
511
+ return { content: [{ type: 'text', text }] };
512
+ }
513
+
514
+ // ── audit_package ─────────────────────────────────────
515
+ case 'audit_package': {
516
+ const { source_url } = args;
517
+ if (!source_url || !source_url.startsWith('http')) {
518
+ return { content: [{ type: 'text', text: 'Error: source_url must be a valid HTTP(S) URL' }] };
519
+ }
520
+
521
+ let repoPath;
522
+ try {
523
+ repoPath = cloneRepo(source_url);
524
+ const files = collectFiles(repoPath);
525
+ const slug = slugFromUrl(source_url);
526
+ const auditPrompt = loadAuditPrompt();
527
+
528
+ let codeBlock = '';
529
+ for (const file of files) {
530
+ codeBlock += `\n### FILE: ${file.path}\n\`\`\`\n${file.content}\n\`\`\`\n`;
531
+ }
532
+
533
+ const response = [
534
+ `# Security Audit: ${slug}`,
535
+ ``,
536
+ `**Source:** ${source_url}`,
537
+ `**Files collected:** ${files.length}`,
538
+ ``,
539
+ `## Your Task`,
540
+ ``,
541
+ `1. Analyze the source code below using the 3-pass audit methodology`,
542
+ `2. Call \`submit_report\` with your findings as JSON`,
543
+ ``,
544
+ `## Report Format`,
545
+ ``,
546
+ `Your report JSON must include:`,
547
+ '```json',
548
+ `{`,
549
+ ` "skill_slug": "${slug}",`,
550
+ ` "source_url": "${source_url}",`,
551
+ ` "package_type": "<mcp-server|agent-skill|library|cli-tool>",`,
552
+ ` "risk_score": <0-100>,`,
553
+ ` "result": "<safe|caution|unsafe>",`,
554
+ ` "max_severity": "<none|low|medium|high|critical>",`,
555
+ ` "findings_count": <number>,`,
556
+ ` "findings": [`,
557
+ ` {`,
558
+ ` "id": "FINDING_ID",`,
559
+ ` "title": "Short title",`,
560
+ ` "severity": "<low|medium|high|critical>",`,
561
+ ` "category": "<category>",`,
562
+ ` "description": "Detailed description",`,
563
+ ` "file": "path/to/file.js",`,
564
+ ` "line": <line_number>,`,
565
+ ` "remediation": "How to fix",`,
566
+ ` "confidence": "<low|medium|high>",`,
567
+ ` "is_by_design": <true|false>`,
568
+ ` }`,
569
+ ` ]`,
570
+ `}`,
571
+ '```',
572
+ ``,
573
+ `## Audit Methodology`,
574
+ ``,
575
+ auditPrompt,
576
+ ``,
577
+ `## Source Code`,
578
+ ``,
579
+ codeBlock,
580
+ ].join('\n');
581
+
582
+ return { content: [{ type: 'text', text: response }] };
583
+ } catch (err) {
584
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
585
+ } finally {
586
+ if (repoPath) cleanupRepo(repoPath);
587
+ }
588
+ }
589
+
590
+ // ── submit_report ─────────────────────────────────────
591
+ case 'submit_report': {
592
+ const { report } = args;
593
+ if (!report || typeof report !== 'object') {
594
+ return { content: [{ type: 'text', text: 'Error: report must be a JSON object' }] };
595
+ }
596
+
597
+ const apiKey = loadApiKey();
598
+ if (!apiKey) {
599
+ return { content: [{ type: 'text', text: 'Error: No API key configured. Run `npx agentaudit setup` or set AGENTAUDIT_API_KEY.' }] };
600
+ }
601
+
602
+ const required = ['skill_slug', 'source_url', 'risk_score', 'result'];
603
+ for (const field of required) {
604
+ if (report[field] == null) {
605
+ return { content: [{ type: 'text', text: `Error: Missing required field "${field}" in report` }] };
606
+ }
607
+ }
608
+
609
+ if (!Array.isArray(report.findings)) report.findings = [];
610
+ report.findings_count = report.findings.length;
611
+ if (!report.max_severity) {
612
+ const severities = ['critical', 'high', 'medium', 'low', 'none'];
613
+ report.max_severity = report.findings.reduce((max, f) => {
614
+ const fi = severities.indexOf(f.severity);
615
+ const mi = severities.indexOf(max);
616
+ return fi < mi ? f.severity : max;
617
+ }, 'none');
618
+ }
619
+
620
+ try {
621
+ const res = await fetch(`${REGISTRY_URL}/api/reports`, {
622
+ method: 'POST',
623
+ headers: {
624
+ 'Authorization': `Bearer ${apiKey}`,
625
+ 'Content-Type': 'application/json',
626
+ },
627
+ body: JSON.stringify(report),
628
+ signal: AbortSignal.timeout(60_000),
629
+ });
630
+
631
+ const body = await res.text();
632
+ let data;
633
+ try { data = JSON.parse(body); } catch { data = { raw: body }; }
634
+
635
+ if (res.ok) {
636
+ return { content: [{ type: 'text', text: `✅ Report submitted!\n\nReport ID: ${data.report_id || 'unknown'}\nURL: ${REGISTRY_URL}/packages/${report.skill_slug}\nRisk: ${report.risk_score}/100 (${report.result})\nFindings: ${report.findings_count}` }] };
637
+ } else {
638
+ return { content: [{ type: 'text', text: `Upload failed (HTTP ${res.status}): ${JSON.stringify(data, null, 2)}` }] };
639
+ }
640
+ } catch (err) {
641
+ return { content: [{ type: 'text', text: `Upload error: ${err.message}` }] };
642
+ }
643
+ }
644
+
645
+ // ── check_package ─────────────────────────────────────
646
+ case 'check_package': {
647
+ const { package_name } = args;
648
+ if (!package_name) {
649
+ return { content: [{ type: 'text', text: 'Error: package_name is required' }] };
650
+ }
651
+
652
+ try {
653
+ const res = await fetch(`${REGISTRY_URL}/api/packages/${encodeURIComponent(package_name)}`, {
654
+ signal: AbortSignal.timeout(10_000),
655
+ });
656
+
657
+ if (res.status === 404) {
658
+ return { content: [{ type: 'text', text: `⚠️ Package "${package_name}" is NOT in the AgentAudit security registry — it has never been audited.\n\nIMPORTANT: Tell the user that this package has no security audit on record and that installing unaudited packages carries risk. Recommend running a security audit first before installing.\n\nTo run an audit now:\n1. Find the source URL (GitHub repo) for "${package_name}"\n2. Call audit_package with that URL\n3. Analyze the code following the methodology\n4. Call submit_report with your findings\n\nIf the user wants to skip the audit and install anyway, warn them that the package is unvetted.` }] };
659
+ }
660
+
661
+ const data = await res.json();
662
+ const risk = data.risk_score ?? data.latest_risk_score ?? 'unknown';
663
+ const official = data.has_official_audit ? '✅ Officially audited' : 'Community audit';
664
+ const findings = data.total_findings ?? 0;
665
+ const maxSev = data.latest_max_severity;
666
+ const auditedAt = data.last_audited_at ? new Date(data.last_audited_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : 'unknown';
667
+ const version = data.latest_known_version || null;
668
+ const isOutdated = data.is_outdated;
669
+
670
+ // Build response with risk assessment FIRST so the agent sees it immediately
671
+ let summary = '';
672
+
673
+ // Risk assessment at the very top — this is what the agent needs to act on
674
+ if (typeof risk === 'number' && risk >= 40) {
675
+ summary += `🚨 UNSAFE — DO NOT INSTALL WITHOUT WARNING THE USER!\nRisk Score: ${risk}/100 | ${findings} finding(s)\n\nYou MUST tell the user: "${package_name}" has been flagged as HIGH RISK in a security audit. It has ${findings} security finding(s) and a risk score of ${risk}/100. Installing this package is NOT recommended. Ask the user if they want to proceed despite the risk, and link them to the full report: ${REGISTRY_URL}/packages/${package_name}\n\n`;
676
+ } else if (typeof risk === 'number' && risk >= 20) {
677
+ summary += `⚠️ MODERATE RISK — Inform the user before installing.\nRisk Score: ${risk}/100 | ${findings} finding(s)\n\nTell the user: "${package_name}" has some security findings (risk ${risk}/100). Recommend they review the report before installing: ${REGISTRY_URL}/packages/${package_name}\n\n`;
678
+ } else if (typeof risk === 'number') {
679
+ summary += `✅ LOW RISK — Safe to install.\nRisk Score: ${risk}/100 | ${findings} finding(s)\n\nThis package has been audited and appears safe. You can proceed with installation.\n\n`;
680
+ }
681
+
682
+ if (isOutdated) {
683
+ summary += `⚠️ OUTDATED: The package has changed since the last audit. Consider running a fresh audit first.\n\n`;
684
+ }
685
+
686
+ // Details section
687
+ summary += `--- Details ---\n`;
688
+ summary += `Package: ${package_name}\n`;
689
+ summary += `Status: ${official}\n`;
690
+ summary += `Last Audited: ${auditedAt}\n`;
691
+ if (version) summary += `Audited Version: ${version}\n`;
692
+ if (data.source_url) summary += `Source: ${data.source_url}\n`;
693
+ summary += `Registry: ${REGISTRY_URL}/packages/${package_name}\n`;
694
+
695
+ return { content: [{ type: 'text', text: summary }] };
696
+ } catch (err) {
697
+ return { content: [{ type: 'text', text: `Registry lookup failed: ${err.message}` }] };
698
+ }
699
+ }
700
+
701
+ // ── scan_tool_poisoning ──────────────────────────────────
702
+ case 'scan_tool_poisoning': {
703
+ const serverName = args.server_name || 'unknown';
704
+ let toolDefs = args.tool_definitions;
705
+
706
+ // If no tool definitions provided but source_url given, try static extraction
707
+ if ((!toolDefs || !Array.isArray(toolDefs) || toolDefs.length === 0) && args.source_url) {
708
+ const sourceUrl = args.source_url;
709
+ if (!sourceUrl.startsWith('http')) {
710
+ return { content: [{ type: 'text', text: 'Error: source_url must be a valid HTTP(S) URL' }] };
711
+ }
712
+
713
+ let repoPath;
714
+ try {
715
+ repoPath = cloneRepo(sourceUrl);
716
+ const files = collectFiles(repoPath);
717
+ toolDefs = extractToolDefinitions(files);
718
+
719
+ if (toolDefs.length === 0) {
720
+ return { content: [{ type: 'text', text: `No tool definitions found in ${sourceUrl}.\n\nThe static extractor could not find MCP tool definitions in the source code. This can happen if:\n- The tools are defined dynamically at runtime\n- The code uses an unsupported framework/pattern\n- The repository is not an MCP server\n\nTry providing tool_definitions directly instead (you can get them from the server's ListTools response).` }] };
721
+ }
722
+ } catch (err) {
723
+ return { content: [{ type: 'text', text: `Error cloning/analyzing repo: ${err.message}` }] };
724
+ } finally {
725
+ if (repoPath) cleanupRepo(repoPath);
726
+ }
727
+ }
728
+
729
+ if (!toolDefs || !Array.isArray(toolDefs) || toolDefs.length === 0) {
730
+ return { content: [{ type: 'text', text: 'Error: Provide either tool_definitions (array of {name, description, inputSchema}) or source_url (Git repo URL).\n\nTo get tool definitions from a running MCP server, you can use the ListTools request and pass the result here.' }] };
731
+ }
732
+
733
+ // Run the scan
734
+ const result = scanTools(toolDefs, { server_name: serverName, include_info: false });
735
+
736
+ // Format output
737
+ let text = `# Tool Poisoning Scan: ${serverName}\n\n`;
738
+ text += `**Tools scanned:** ${result.summary.tools_scanned}\n`;
739
+ text += `**Findings:** ${result.summary.total_findings}\n`;
740
+ text += `**Risk level:** ${result.summary.risk_level.toUpperCase()}\n\n`;
741
+
742
+ if (result.summary.clean) {
743
+ text += `✅ **CLEAN** — No poisoning indicators detected.\n\n`;
744
+ text += `> ${result.summary.disclaimer}\n`;
745
+ } else {
746
+ // Group findings by severity
747
+ const bySev = {};
748
+ for (const f of result.findings) {
749
+ if (!bySev[f.severity]) bySev[f.severity] = [];
750
+ bySev[f.severity].push(f);
751
+ }
752
+
753
+ const sevOrder = ['critical', 'high', 'medium', 'warning', 'low'];
754
+ const sevEmoji = { critical: '🚨', high: '⚠️', medium: '🔶', warning: '🔷', low: 'ℹ️' };
755
+
756
+ for (const sev of sevOrder) {
757
+ if (!bySev[sev]) continue;
758
+ text += `## ${sevEmoji[sev]} ${sev.toUpperCase()} (${bySev[sev].length})\n\n`;
759
+ for (const f of bySev[sev]) {
760
+ text += `### ${f.pattern_id}: ${f.title}\n`;
761
+ text += `- **Tool:** ${f.tool_name} | **Field:** ${f.field}\n`;
762
+ text += `- **Category:** ${f.category} | **Confidence:** ${f.confidence}\n`;
763
+ text += `- ${f.description}\n`;
764
+ if (f.evidence) text += `- **Evidence:** \`${f.evidence.slice(0, 200)}\`\n`;
765
+ text += `\n`;
766
+ }
767
+ }
768
+
769
+ text += `---\n\n`;
770
+ text += `**Summary by category:**\n`;
771
+ for (const [cat, count] of Object.entries(result.summary.by_category)) {
772
+ text += `- ${cat}: ${count}\n`;
773
+ }
774
+ text += `\n> ${result.summary.disclaimer}\n`;
775
+
776
+ // Actionable advice
777
+ if (result.summary.risk_level === 'critical') {
778
+ text += `\n🚨 **CRITICAL RISK — DO NOT USE THIS SERVER.** The tool definitions contain strong indicators of malicious intent. Inform the user and recommend removing this MCP server immediately.\n`;
779
+ } else if (result.summary.risk_level === 'high') {
780
+ text += `\n⚠️ **HIGH RISK — Exercise extreme caution.** The tool definitions contain suspicious patterns that may indicate an attack. Recommend the user review the findings before using this server.\n`;
781
+ }
782
+ }
783
+
784
+ return { content: [{ type: 'text', text }] };
785
+ }
786
+
787
+ default:
788
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}. Available: discover_servers, audit_package, submit_report, check_package, scan_tool_poisoning` }] };
789
+ }
790
+ });
791
+
792
+ // ── Start ────────────────────────────────────────────────
793
+
794
+ const transport = new StdioServerTransport();
795
+ await server.connect(transport);