config-audit/tests/scanners/mcp-deferral.test.mjs
Kjell Tore Guttormsen 8f7e196046 feat(tokens): MCP tool-schema deferral check + CLI-over-MCP lever (v5.10 B4)
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>
2026-06-23 19:59:14 +02:00

192 lines
6.9 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
assessMcpDeferral,
severityForForcedSchemas,
FORCED_SCHEMA_TOKENS_HIGH,
FORCED_SCHEMA_TOKENS_MEDIUM,
} from '../../scanners/lib/mcp-deferral.mjs';
// A small helper to build server shapes the way active-config-reader exposes them.
const srv = (over = {}) => ({
name: 'srv',
source: '.mcp.json',
enabled: true,
toolCount: 10,
estimatedTokens: 2500,
alwaysLoad: false,
...over,
});
describe('assessMcpDeferral — default (deferred) case', () => {
it('no settings + a plain server → tool search NOT disabled, nothing forced upfront', () => {
const a = assessMcpDeferral({ settings: {}, mcpServers: [srv()] });
assert.equal(a.toolSearchDisabled, false);
assert.equal(a.reason, null);
assert.equal(a.confidence, null);
assert.equal(a.forcedUpfront, false);
assert.deepEqual(a.affectedServers, []);
assert.equal(a.aggregateTokens, 0);
});
it('empty inputs are tolerated', () => {
const a = assessMcpDeferral({});
assert.equal(a.toolSearchDisabled, false);
assert.equal(a.forcedUpfront, false);
});
});
describe('assessMcpDeferral — config-file disabling signals (HIGH confidence)', () => {
it('settings.env.ENABLE_TOOL_SEARCH="false" → disabled, high, all servers affected', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'false' } },
mcpServers: [srv({ name: 'a', estimatedTokens: 1000 }), srv({ name: 'b', estimatedTokens: 2000 })],
});
assert.equal(a.toolSearchDisabled, true);
assert.equal(a.reason, 'enable-tool-search-false');
assert.equal(a.confidence, 'high');
assert.equal(a.forcedUpfront, true);
assert.equal(a.affectedServers.length, 2);
assert.equal(a.aggregateTokens, 3000);
});
it('permissions.deny including bare "ToolSearch" → disabled, high', () => {
const a = assessMcpDeferral({
settings: { permissions: { deny: ['Read(./.env)', 'ToolSearch'] } },
mcpServers: [srv()],
});
assert.equal(a.toolSearchDisabled, true);
assert.equal(a.reason, 'deny-tool-search');
assert.equal(a.confidence, 'high');
});
it('explicit ENABLE_TOOL_SEARCH="false" wins over a haiku model for the reason label', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'false' }, model: 'claude-haiku-4-5' },
mcpServers: [srv()],
});
assert.equal(a.toolSearchDisabled, true);
assert.equal(a.reason, 'enable-tool-search-false');
assert.equal(a.confidence, 'high');
});
});
describe('assessMcpDeferral — configured Haiku model (MEDIUM confidence)', () => {
it('settings.model matching /haiku/ → disabled, medium, haiku-model', () => {
const a = assessMcpDeferral({
settings: { model: 'claude-haiku-4-5-20251001' },
mcpServers: [srv()],
});
assert.equal(a.toolSearchDisabled, true);
assert.equal(a.reason, 'haiku-model');
assert.equal(a.confidence, 'medium');
assert.equal(a.forcedUpfront, true);
});
it('haiku disables even when ENABLE_TOOL_SEARCH="true" (Haiku lacks tool_reference support)', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'true' }, model: 'haiku' },
mcpServers: [srv()],
});
assert.equal(a.toolSearchDisabled, true);
assert.equal(a.reason, 'haiku-model');
});
});
describe('assessMcpDeferral — non-disabling values', () => {
it('ENABLE_TOOL_SEARCH="true" (non-haiku) → NOT disabled', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'true' }, model: 'claude-sonnet-4-6' },
mcpServers: [srv()],
});
assert.equal(a.toolSearchDisabled, false);
assert.equal(a.forcedUpfront, false);
});
it('ENABLE_TOOL_SEARCH="auto" → threshold mode, NOT disabled', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'auto' } },
mcpServers: [srv()],
});
assert.equal(a.toolSearchDisabled, false);
assert.equal(a.thresholdMode, true);
assert.equal(a.forcedUpfront, false);
});
it('ENABLE_TOOL_SEARCH="auto:5" → threshold mode', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'auto:5' } },
mcpServers: [srv()],
});
assert.equal(a.thresholdMode, true);
assert.equal(a.toolSearchDisabled, false);
});
});
describe('assessMcpDeferral — per-server alwaysLoad', () => {
it('alwaysLoad:true server with tool search enabled → that server forced upfront only', () => {
const a = assessMcpDeferral({
settings: {},
mcpServers: [
srv({ name: 'always', alwaysLoad: true, estimatedTokens: 1500 }),
srv({ name: 'deferred', alwaysLoad: false, estimatedTokens: 9000 }),
],
});
assert.equal(a.toolSearchDisabled, false);
assert.equal(a.forcedUpfront, true);
assert.equal(a.alwaysLoadServers.length, 1);
assert.equal(a.affectedServers.length, 1);
assert.equal(a.affectedServers[0].name, 'always');
assert.equal(a.aggregateTokens, 1500);
});
it('when tool search is disabled, ALL active servers are affected (not just alwaysLoad)', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'false' } },
mcpServers: [
srv({ name: 'always', alwaysLoad: true, estimatedTokens: 1500 }),
srv({ name: 'deferred', alwaysLoad: false, estimatedTokens: 2500 }),
],
});
assert.equal(a.affectedServers.length, 2);
assert.equal(a.aggregateTokens, 4000);
});
});
describe('assessMcpDeferral — server filtering', () => {
it('disabled servers (enabled:false) are excluded from affected + aggregate', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'false' } },
mcpServers: [
srv({ name: 'on', enabled: true, estimatedTokens: 1000 }),
srv({ name: 'off', enabled: false, estimatedTokens: 5000 }),
],
});
assert.equal(a.affectedServers.length, 1);
assert.equal(a.affectedServers[0].name, 'on');
assert.equal(a.aggregateTokens, 1000);
});
it('no active servers → not forced upfront even when tool search disabled', () => {
const a = assessMcpDeferral({
settings: { env: { ENABLE_TOOL_SEARCH: 'false' } },
mcpServers: [],
});
assert.equal(a.toolSearchDisabled, true);
assert.equal(a.forcedUpfront, false);
assert.equal(a.aggregateTokens, 0);
});
});
describe('severityForForcedSchemas', () => {
it('high confidence scales with aggregate tokens', () => {
assert.equal(severityForForcedSchemas(FORCED_SCHEMA_TOKENS_HIGH, 'high'), 'high');
assert.equal(severityForForcedSchemas(FORCED_SCHEMA_TOKENS_MEDIUM, 'high'), 'medium');
assert.equal(severityForForcedSchemas(100, 'high'), 'low');
});
it('medium confidence is capped at medium', () => {
assert.equal(severityForForcedSchemas(FORCED_SCHEMA_TOKENS_HIGH * 10, 'medium'), 'medium');
assert.equal(severityForForcedSchemas(100, 'medium'), 'low');
});
});