/** * MCP Scanner — MCP Configuration Validator * Validates .mcp.json files: server types, trust levels, env vars, unknown fields. * Finding IDs: CA-MCP-NNN */ import { readTextFile } from './lib/file-discovery.mjs'; import { finding, scannerResult } from './lib/output.mjs'; import { SEVERITY } from './lib/severity.mjs'; import { parseJson } from './lib/yaml-parser.mjs'; import { truncate } from './lib/string-utils.mjs'; const SCANNER = 'MCP'; const VALID_SERVER_TYPES = new Set(['stdio', 'http', 'sse']); const VALID_TRUST_LEVELS = new Set(['workspace', 'trusted', 'untrusted']); const VALID_SERVER_FIELDS = new Set([ 'type', 'command', 'args', 'env', 'url', 'headers', 'timeout', 'trust', ]); const ENV_VAR_PATTERN = /\$\{([^}]+)\}/g; /** * Scan all .mcp.json files discovered. * @param {string} targetPath * @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery * @returns {Promise} */ export async function scan(targetPath, discovery) { const start = Date.now(); const mcpFiles = discovery.files.filter(f => f.type === 'mcp-json'); const findings = []; let filesScanned = 0; if (mcpFiles.length === 0) { return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start); } for (const file of mcpFiles) { const content = await readTextFile(file.absPath); if (!content) continue; filesScanned++; const parsed = parseJson(content); if (!parsed) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.critical, title: 'Invalid JSON in MCP config', description: `${file.relPath}: Failed to parse as JSON.`, file: file.absPath, recommendation: 'Fix JSON syntax errors. Use a JSON validator to check the file.', })); continue; } const servers = parsed.mcpServers || parsed; if (typeof servers !== 'object' || Array.isArray(servers)) continue; for (const [name, config] of Object.entries(servers)) { if (!config || typeof config !== 'object' || Array.isArray(config)) continue; // Check server type if (config.type && !VALID_SERVER_TYPES.has(config.type)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.high, title: 'Unknown MCP server type', description: `${file.relPath}: Server "${name}" has unknown type "${config.type}".`, file: file.absPath, evidence: `type: "${config.type}"`, recommendation: `Use one of: stdio, http, sse. Got "${config.type}".`, })); } // SSE → HTTP recommendation if (config.type === 'sse') { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.info, title: 'SSE server type — consider HTTP', description: `${file.relPath}: Server "${name}" uses "sse" type. The "http" type is the current standard.`, file: file.absPath, evidence: `type: "sse"`, recommendation: 'Migrate from "sse" to "http" type for better compatibility.', })); } // Check trust level if (!config.trust) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Missing trust level', description: `${file.relPath}: Server "${name}" has no trust level configured.`, file: file.absPath, recommendation: 'Add "trust": "workspace"|"trusted"|"untrusted" to explicitly set the trust level.', })); } else if (!VALID_TRUST_LEVELS.has(config.trust)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.high, title: 'Invalid trust level', description: `${file.relPath}: Server "${name}" has invalid trust level "${config.trust}".`, file: file.absPath, evidence: `trust: "${config.trust}"`, recommendation: 'Use one of: workspace, trusted, untrusted.', })); } // Check for env var references in args without env block if (Array.isArray(config.args)) { for (const arg of config.args) { if (typeof arg !== 'string') continue; let match; ENV_VAR_PATTERN.lastIndex = 0; while ((match = ENV_VAR_PATTERN.exec(arg)) !== null) { const varName = match[1]; const hasEnvBlock = config.env && typeof config.env === 'object' && varName in config.env; if (!hasEnvBlock) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Unreferenced env var in args', description: `${file.relPath}: Server "${name}" references \${${varName}} in args but has no env block defining it.`, file: file.absPath, evidence: truncate(arg, 80), recommendation: `Add an "env" block with "${varName}" or remove the variable reference.`, })); } } } } // Check for unknown fields for (const key of Object.keys(config)) { if (!VALID_SERVER_FIELDS.has(key)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Unknown MCP server field', description: `${file.relPath}: Server "${name}" has unknown field "${key}".`, file: file.absPath, evidence: `${key}: ${truncate(JSON.stringify(config[key]), 60)}`, recommendation: `Remove or correct "${key}". Valid fields: ${[...VALID_SERVER_FIELDS].join(', ')}.`, })); } } } } return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start); }