config-audit/scanners/mcp-config-validator.mjs
Kjell Tore Guttormsen b3c572ad46 fix(mcp-config-validator): remove invented trust field (verify-first)
`.mcp.json` has no per-server `trust` key — verified 2026-06-18 against
code.claude.com/docs/en/mcp + /settings. MCP server approval is
dialog/settings-based (enableAllProjectMcpServers / enabledMcpjsonServers /
disabledMcpjsonServers), never a JSON field. The scanner's "Missing trust
level" (CA-MCP-001, medium) and "Invalid trust level" (high) were false
positives flagging a field that does not exist.

- scanner: delete both trust checks + VALID_TRUST_LEVELS; drop `trust` from
  VALID_SERVER_FIELDS so a stray `trust` is now flagged as an unknown field
- humanizer: remove the two trust-level entries
- knowledge (5 files): point to the real approval mechanism, not a trust field
- fixtures: scrub `trust` (incl. the invalid "local" in optimal-setup)
- tests: flip assertions (no trust-level finding; stray trust -> unknown
  field) + add knowledge-staleness re-freeze guards
- snapshots: reseed (marketplace-medium .mcp.json -8 tokens, hermetic)
- gap-matrix: mark the trust verify-first item DONE

Suite: 853/853 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
2026-06-18 14:22:56 +02:00

140 lines
5.3 KiB
JavaScript

/**
* MCP Scanner — MCP Configuration Validator
* Validates .mcp.json files: server types, 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']);
// No `trust` field: MCP server approval is dialog/settings-based
// (enableAllProjectMcpServers / enabledMcpjsonServers / disabledMcpjsonServers),
// not a per-server .mcp.json field. Verified against code.claude.com/docs 2026-06-18.
const VALID_SERVER_FIELDS = new Set([
'type', 'command', 'args', 'env', 'url', 'headers', 'timeout',
]);
// Match only bare ${IDENTIFIER} references. POSIX expansions like ${VAR%pattern}
// or ${VAR:-default} contain operators and are skipped — Claude Code resolves
// them at launch (CC 2.1.142), so they are not config-defined env references.
const ENV_VAR_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
// Auto-injected by Claude Code at runtime — never require an env block (CC 2.1.139).
const AUTO_INJECTED_ENV_VARS = new Set(['CLAUDE_PROJECT_DIR']);
/**
* Scan all .mcp.json files discovered.
* @param {string} targetPath
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
* @returns {Promise<object>}
*/
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 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];
if (AUTO_INJECTED_ENV_VARS.has(varName)) continue;
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);
}