By default Claude Code defers MCP tool schemas (names-only, ~120 tok; full
schemas on demand via tool search). CA-TOK-006 detects config-file signals that
force the FULL schemas into the always-loaded prefix every turn:
- settings.json env.ENABLE_TOOL_SEARCH="false" (high)
- "ToolSearch" in permissions.deny (high)
- configured model is a Haiku model (medium)
- per-server .mcp.json alwaysLoad:true (CC v2.1.121+) (high)
auto[:N] is threshold mode (info, not a trigger).
New engine lib/mcp-deferral.mjs: pure assessMcpDeferral({settings,mcpServers})
(unit-tested, no IO) + thin IO wrapper assessMcpDeferralForRepo shared by TOK and
GAP. Severity scales with aggregate forced-upfront tokens (medium-confidence
reasons cap at medium). feature-gap cliOverMcpLeverFinding fires only as a
companion to CA-TOK-006 (prefer gh/aws/gcloud over MCP for common ops).
Honest scoping (Verifiseringsplikt): triggers on config files ONLY — never
process.env shell vars. Vertex / custom ANTHROPIC_BASE_URL / runtime /model
switch are launch state (would flap snapshots machine-dependently), so they are
DISCLOSED in every finding, not triggered. Tool-level anthropic/alwaysLoad and
claude.ai connectors likewise disclosed. Mechanism verified 2026-06-23 against
code.claude.com/docs (context-window.md, mcp.md#configure-tool-search +
#exempt-a-server-from-deferral, costs.md); the prefix-cache invalidation claim
was NOT-CONFIRMED in docs and is not asserted.
alwaysLoad added to CA-MCP VALID_SERVER_FIELDS (no longer flagged as unknown).
active-config-reader surfaces per-server alwaysLoad. Byte-stable: CA-TOK-006
fires only on new conditions; frozen v5.0.0 + SC-5 snapshots untouched.
Tests 1215 -> 1239 (engine 16, integration 5, lever 2, mcp-field guard 1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
143 lines
5.5 KiB
JavaScript
143 lines
5.5 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',
|
|
// alwaysLoad: exempt a server from MCP tool-schema deferral (CC v2.1.121+).
|
|
// Verified against code.claude.com/docs/en/mcp.md#exempt-a-server-from-deferral 2026-06-23.
|
|
'alwaysLoad',
|
|
]);
|
|
|
|
// 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);
|
|
}
|