mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-11 02:33:10 +08:00
* feat: add MCP inventory (ecc.mcp.v1) across harnesses Read-only MCP-gateway groundwork: discover MCP server configs across every installed harness, normalize to a canonical ecc.mcp.v1 inventory, redact secrets, and report which servers are configured in 2+ harnesses (the configure-N-times pain). The read+dedup side of a unified gateway, mirroring how the session-adapter layer started read-only. Readers (per-harness config formats): - claude-code: ~/.claude.json mcpServers + project .mcp.json - codex: ~/.codex/config.toml [mcp_servers.*] TOML via @iarna/toml - opencode: ~/.config/opencode/opencode.json mcp block (command ARRAY) canonical-mcp.js: - normalize transport labels (local=>stdio, remote=>http) to stdio/http/sse - merge servers by name across harnesses; flag DRIFT when signatures differ - fragmentation report + aggregates - SECRET REDACTION: env values stripped to key names; secrets in args (--modelApiKey sk-ant-...), inline --flag=secret, and URL userinfo/token query params all redacted before storage AND before the dedup signature. scripts/mcp-inventory.js: CLI (--json, --fragmented, --help). tests/lib/mcp-inventory.test.js: 12 tests incl. a regression for the real arg-carried-secret leak found while smoke-testing on live configs. Tests: 12/0. Real-data smoke: 33 servers across 3 harnesses, 21 configured in 2+ harnesses (7 drift); secret-leak audit clean. * test: cover reader error paths, collect skip-logic, and CLI main() for mcp-inventory Lift global branch coverage past the 80% gate (was 79.86%). Adds 6 tests exercising: missing-file/malformed-JSON/missing-block reader fallbacks, codex no-parser path, collect skipping non-function readers and swallowing reader errors, CLI usage()/main() help+json+human paths, and formatHumanReport no-fragmentation + fragmented-only branches. Also scrub a real API-key fragment that had leaked into a test fixture; all secret-like fixtures are now obviously-fake FAKE... tokens. mcp-inventory.js branch 30%->93%, collect.js ->100%. Global branch 80.33%.
325 lines
14 KiB
JavaScript
325 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const {
|
|
MCP_SCHEMA_VERSION,
|
|
normalizeTransport,
|
|
summarizeEnv,
|
|
buildSignature,
|
|
redactArgs,
|
|
redactUrl,
|
|
normalizeServerEntry,
|
|
buildInventory
|
|
} = require('../../scripts/lib/mcp-inventory/canonical-mcp');
|
|
const { readClaudeCodeMcp } = require('../../scripts/lib/mcp-inventory/readers/claude-code');
|
|
const { readCodexMcp } = require('../../scripts/lib/mcp-inventory/readers/codex');
|
|
const { readOpencodeMcp } = require('../../scripts/lib/mcp-inventory/readers/opencode');
|
|
const { collectMcpInventory } = require('../../scripts/lib/mcp-inventory/collect');
|
|
const { formatHumanReport, parseArgs, usage, main } = require('../../scripts/mcp-inventory');
|
|
|
|
console.log('=== Testing mcp-inventory ===\n');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
passed += 1;
|
|
console.log(` ok - ${name}`);
|
|
} catch (error) {
|
|
failed += 1;
|
|
console.log(` FAIL - ${name}`);
|
|
console.log(` ${error && error.message}`);
|
|
}
|
|
}
|
|
|
|
function tmpHome() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-mcp-home-'));
|
|
}
|
|
|
|
const GITHUB_STDIO = {
|
|
command: 'npx',
|
|
args: ['-y', '@modelcontextprotocol/server-github'],
|
|
type: 'stdio'
|
|
};
|
|
|
|
test('normalizeTransport maps harness-specific labels to stdio/http/sse', () => {
|
|
assert.strictEqual(normalizeTransport('stdio'), 'stdio');
|
|
assert.strictEqual(normalizeTransport('local'), 'stdio');
|
|
assert.strictEqual(normalizeTransport('remote', { url: 'https://x' }), 'http');
|
|
assert.strictEqual(normalizeTransport('http'), 'http');
|
|
assert.strictEqual(normalizeTransport('sse'), 'sse');
|
|
assert.strictEqual(normalizeTransport(undefined, { url: 'https://x' }), 'http');
|
|
assert.strictEqual(normalizeTransport(undefined), 'stdio');
|
|
});
|
|
|
|
test('summarizeEnv returns key names only and flags secrets', () => {
|
|
const result = summarizeEnv({ GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_real_secret', FOO: 'bar' });
|
|
assert.deepStrictEqual(result.envKeys, ['FOO', 'GITHUB_PERSONAL_ACCESS_TOKEN']);
|
|
assert.strictEqual(result.hasSecrets, true);
|
|
assert.strictEqual(summarizeEnv({ DEBUG: '1' }).hasSecrets, false);
|
|
});
|
|
|
|
test('normalizeServerEntry strips secret values, keeps only env key names', () => {
|
|
const record = normalizeServerEntry({
|
|
name: 'github', ...GITHUB_STDIO,
|
|
env: { GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_must_not_leak' },
|
|
source: { harness: 'claude-code', scope: 'user', configPath: '/x/.claude.json' }
|
|
});
|
|
|
|
const serialized = JSON.stringify(record);
|
|
assert.ok(!serialized.includes('ghp_must_not_leak'), 'secret value must not appear in normalized record');
|
|
assert.deepStrictEqual(record.envKeys, ['GITHUB_PERSONAL_ACCESS_TOKEN']);
|
|
assert.strictEqual(record.hasSecrets, true);
|
|
assert.strictEqual(record.transport, 'stdio');
|
|
assert.strictEqual(record.command, 'npx');
|
|
});
|
|
|
|
test('redactArgs strips secrets in args (value, --flag value, --flag=value forms)', () => {
|
|
// Real-world leak: browserbase passes the Anthropic key as a CLI arg.
|
|
const out = redactArgs([
|
|
'-y', '@browserbasehq/mcp-server-browserbase',
|
|
'--modelName', 'claude-3-7-sonnet-latest',
|
|
'--modelApiKey', 'sk-ant-api03-FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE000000',
|
|
'--token=ghp_FAKEFAKEFAKEFAKEFAKEFAKE000000'
|
|
]);
|
|
const serialized = out.join(' ');
|
|
assert.ok(!serialized.includes('sk-ant-api03'), 'must redact secret after --modelApiKey');
|
|
assert.ok(!serialized.includes('ghp_0123456789'), 'must redact inline --token=secret');
|
|
assert.ok(out.includes('claude-3-7-sonnet-latest'), 'must keep non-secret flag values');
|
|
assert.ok(out.includes('@browserbasehq/mcp-server-browserbase'), 'must keep package names');
|
|
assert.ok(out.includes('--modelApiKey'), 'must keep the flag name itself');
|
|
});
|
|
|
|
test('redactUrl strips userinfo and token query params', () => {
|
|
assert.strictEqual(redactUrl('https://user:pass@mcp.example.com/sse'), 'https://***@mcp.example.com/sse');
|
|
assert.ok(!redactUrl('https://mcp.example.com/sse?token=ghp_FAKEFAKEFAKEFAKEFAKE').includes('ghp_abcdef'));
|
|
});
|
|
|
|
test('normalizeServerEntry redacts secrets hidden in args, not just env, and flags hasSecrets', () => {
|
|
const record = normalizeServerEntry({
|
|
name: 'browserbase', type: 'stdio', command: 'npx',
|
|
args: ['-y', 'mcp-server-browserbase', '--modelApiKey', 'sk-ant-api03-FAKEMUSTNOTAPPEAR000000000000'],
|
|
source: { harness: 'claude-code' }
|
|
});
|
|
const serialized = JSON.stringify(record);
|
|
assert.ok(!serialized.includes('sk-ant-api03-FAKEMUSTNOTAPPEAR'), 'arg secret must not appear anywhere (incl. signature)');
|
|
assert.ok(!record.signature.includes('sk-ant'), 'signature must be built from redacted args');
|
|
assert.strictEqual(record.hasSecrets, true, 'arg-carried secret should set hasSecrets');
|
|
});
|
|
|
|
test('buildSignature collapses identical stdio configs and distinguishes http', () => {
|
|
const a = buildSignature({ transport: 'stdio', command: 'npx', args: ['-y', 'pkg'] });
|
|
const b = buildSignature({ transport: 'stdio', command: 'npx', args: ['-y', 'pkg'] });
|
|
assert.strictEqual(a, b);
|
|
assert.notStrictEqual(a, buildSignature({ transport: 'http', url: 'https://x' }));
|
|
});
|
|
|
|
test('claude-code reader parses ~/.claude.json mcpServers + project .mcp.json', () => {
|
|
const home = tmpHome();
|
|
fs.writeFileSync(path.join(home, '.claude.json'), JSON.stringify({
|
|
mcpServers: { github: { ...GITHUB_STDIO, env: { GITHUB_PERSONAL_ACCESS_TOKEN: 'x' } } }
|
|
}), 'utf8');
|
|
const projectFile = path.join(home, '.mcp.json');
|
|
fs.writeFileSync(projectFile, JSON.stringify({
|
|
mcpServers: { localtool: { command: 'node', args: ['server.js'], type: 'stdio' } }
|
|
}), 'utf8');
|
|
|
|
const records = readClaudeCodeMcp({ homeDir: home, projectConfigPaths: [projectFile] });
|
|
const names = records.map(r => r.name).sort();
|
|
assert.deepStrictEqual(names, ['github', 'localtool']);
|
|
assert.strictEqual(records.find(r => r.name === 'github').source.scope, 'user');
|
|
assert.strictEqual(records.find(r => r.name === 'localtool').source.scope, 'project');
|
|
});
|
|
|
|
test('codex reader parses [mcp_servers.*] TOML tables', () => {
|
|
const home = tmpHome();
|
|
fs.mkdirSync(path.join(home, '.codex'), { recursive: true });
|
|
fs.writeFileSync(path.join(home, '.codex', 'config.toml'), [
|
|
'[mcp_servers.github]',
|
|
'command = "npx"',
|
|
'args = ["-y", "@modelcontextprotocol/server-github"]',
|
|
'',
|
|
'[mcp_servers.github.env]',
|
|
'GITHUB_PERSONAL_ACCESS_TOKEN = "ghp_codex_secret"',
|
|
'',
|
|
'[mcp_servers.remotehub]',
|
|
'url = "https://mcp.example.com/sse"'
|
|
].join('\n'), 'utf8');
|
|
|
|
const records = readCodexMcp({ homeDir: home });
|
|
const github = records.find(r => r.name === 'github');
|
|
const remote = records.find(r => r.name === 'remotehub');
|
|
assert.ok(github, 'parses stdio server');
|
|
assert.strictEqual(github.command, 'npx');
|
|
assert.strictEqual(github.source.harness, 'codex');
|
|
assert.ok(remote && remote.url === 'https://mcp.example.com/sse', 'parses http/url server');
|
|
});
|
|
|
|
test('opencode reader splits command array and reads environment', () => {
|
|
const home = tmpHome();
|
|
fs.mkdirSync(path.join(home, '.config', 'opencode'), { recursive: true });
|
|
fs.writeFileSync(path.join(home, '.config', 'opencode', 'opencode.json'), JSON.stringify({
|
|
mcp: {
|
|
github: {
|
|
type: 'local',
|
|
command: ['npx', '-y', '@modelcontextprotocol/server-github'],
|
|
environment: { GITHUB_TOKEN: 'github_pat_secret' },
|
|
enabled: true
|
|
},
|
|
disabledtool: { type: 'local', command: ['foo'], enabled: false }
|
|
}
|
|
}), 'utf8');
|
|
|
|
const records = readOpencodeMcp({ homeDir: home });
|
|
const github = records.find(r => r.name === 'github');
|
|
assert.strictEqual(github.command, 'npx');
|
|
assert.deepStrictEqual(github.args, ['-y', '@modelcontextprotocol/server-github']);
|
|
assert.deepStrictEqual(Object.keys(github.env), ['GITHUB_TOKEN']);
|
|
assert.strictEqual(records.find(r => r.name === 'disabledtool').enabled, false);
|
|
});
|
|
|
|
test('collectMcpInventory merges harnesses, detects fragmentation + drift, redacts secrets', () => {
|
|
const home = tmpHome();
|
|
// claude + opencode agree on github (consistent); codex github uses a
|
|
// different command (drift). github appears in all 3 => fragmentation x3.
|
|
fs.writeFileSync(path.join(home, '.claude.json'), JSON.stringify({
|
|
mcpServers: { github: { ...GITHUB_STDIO, env: { GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_secret_claude' } } }
|
|
}), 'utf8');
|
|
fs.mkdirSync(path.join(home, '.config', 'opencode'), { recursive: true });
|
|
fs.writeFileSync(path.join(home, '.config', 'opencode', 'opencode.json'), JSON.stringify({
|
|
mcp: { github: { type: 'local', command: ['npx', '-y', '@modelcontextprotocol/server-github'] } }
|
|
}), 'utf8');
|
|
fs.mkdirSync(path.join(home, '.codex'), { recursive: true });
|
|
fs.writeFileSync(path.join(home, '.codex', 'config.toml'), [
|
|
'[mcp_servers.github]',
|
|
'command = "docker"',
|
|
'args = ["run", "ghcr.io/github/mcp"]',
|
|
'[mcp_servers.solo]',
|
|
'command = "node"'
|
|
].join('\n'), 'utf8');
|
|
|
|
const inventory = collectMcpInventory({
|
|
readerOptions: { 'claude-code': { homeDir: home }, codex: { homeDir: home }, opencode: { homeDir: home } }
|
|
});
|
|
|
|
assert.strictEqual(inventory.schemaVersion, MCP_SCHEMA_VERSION);
|
|
assert.ok(!JSON.stringify(inventory).includes('ghp_secret_claude'), 'no secret values in inventory');
|
|
|
|
const github = inventory.servers.find(s => s.name === 'github');
|
|
assert.strictEqual(github.harnessCount, 3);
|
|
assert.strictEqual(github.consistent, false, 'codex docker command should flag drift');
|
|
|
|
const frag = inventory.fragmentation.find(f => f.name === 'github');
|
|
assert.strictEqual(frag.harnessCount, 3);
|
|
assert.deepStrictEqual(frag.harnesses.sort(), ['claude-code', 'codex', 'opencode']);
|
|
|
|
assert.strictEqual(inventory.aggregates.serverCount, 2);
|
|
assert.strictEqual(inventory.aggregates.harnessCount, 3);
|
|
assert.strictEqual(inventory.aggregates.duplicateServerCount, 1);
|
|
assert.strictEqual(inventory.aggregates.inconsistentServerCount, 1);
|
|
});
|
|
|
|
test('CLI parseArgs + human report render fragmentation', () => {
|
|
assert.deepStrictEqual(parseArgs(['node', 's', '--json']), { json: true, fragmentedOnly: false, help: false });
|
|
const inventory = buildInventory([
|
|
normalizeServerEntry({ name: 'github', ...GITHUB_STDIO, source: { harness: 'claude-code' } }),
|
|
normalizeServerEntry({ name: 'github', ...GITHUB_STDIO, source: { harness: 'codex' } })
|
|
]);
|
|
const report = formatHumanReport(inventory);
|
|
assert.ok(report.includes('github'), 'report names the fragmented server');
|
|
assert.ok(report.includes('x2'), 'report shows the harness count');
|
|
});
|
|
|
|
|
|
// --- branch/error coverage: readers degrade gracefully, CLI main(), collect skips ---
|
|
|
|
function captureStdout(fn) {
|
|
const original = console.log;
|
|
const lines = [];
|
|
console.log = (...args) => lines.push(args.join(' '));
|
|
try {
|
|
fn();
|
|
} finally {
|
|
console.log = original;
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
test('readers return [] for missing files, malformed JSON, and missing blocks', () => {
|
|
const home = tmpHome();
|
|
assert.deepStrictEqual(readClaudeCodeMcp({ homeDir: home }), []);
|
|
assert.deepStrictEqual(readCodexMcp({ homeDir: home }), []);
|
|
assert.deepStrictEqual(readOpencodeMcp({ homeDir: home }), []);
|
|
|
|
fs.writeFileSync(path.join(home, '.claude.json'), '{not valid json', 'utf8');
|
|
assert.deepStrictEqual(readClaudeCodeMcp({ homeDir: home }), []);
|
|
|
|
fs.writeFileSync(path.join(home, '.claude.json'), JSON.stringify({ other: true }), 'utf8');
|
|
assert.deepStrictEqual(readClaudeCodeMcp({ homeDir: home }), []);
|
|
|
|
fs.mkdirSync(path.join(home, '.config', 'opencode'), { recursive: true });
|
|
fs.writeFileSync(path.join(home, '.config', 'opencode', 'opencode.json'), 'nope', 'utf8');
|
|
assert.deepStrictEqual(readOpencodeMcp({ homeDir: home }), []);
|
|
|
|
fs.mkdirSync(path.join(home, '.codex'), { recursive: true });
|
|
fs.writeFileSync(path.join(home, '.codex', 'config.toml'), 'this = = broken', 'utf8');
|
|
assert.deepStrictEqual(readCodexMcp({ homeDir: home }), []);
|
|
});
|
|
|
|
test('codex reader returns [] when no TOML parser is available', () => {
|
|
const home = tmpHome();
|
|
fs.mkdirSync(path.join(home, '.codex'), { recursive: true });
|
|
fs.writeFileSync(path.join(home, '.codex', 'config.toml'), '[mcp_servers.x]\ncommand = "node"\n', 'utf8');
|
|
assert.deepStrictEqual(readCodexMcp({ homeDir: home, parseTomlImpl: () => { throw new Error('no parser'); } }), []);
|
|
});
|
|
|
|
test('collect skips non-function readers and swallows reader errors', () => {
|
|
const inv = collectMcpInventory({
|
|
readers: {
|
|
good: () => ([{ name: 'a', type: 'stdio', command: 'node', source: { harness: 'good' } }]),
|
|
broken: () => { throw new Error('reader blew up'); },
|
|
notAFunction: 'nope'
|
|
}
|
|
});
|
|
assert.strictEqual(inv.servers.length, 1);
|
|
assert.strictEqual(inv.servers[0].name, 'a');
|
|
});
|
|
|
|
test('CLI usage() and parseArgs cover help/fragmented flags', () => {
|
|
assert.ok(usage().includes('mcp-inventory'));
|
|
assert.deepStrictEqual(parseArgs(['node', 's', '-h']), { json: false, fragmentedOnly: false, help: true });
|
|
assert.deepStrictEqual(parseArgs(['node', 's', '--fragmented']), { json: false, fragmentedOnly: true, help: false });
|
|
});
|
|
|
|
test('CLI main() renders help, JSON, and human output paths', () => {
|
|
const help = captureStdout(() => main(['node', 's', '--help']));
|
|
assert.ok(help.includes('Usage: mcp-inventory'));
|
|
|
|
const jsonOut = captureStdout(() => main(['node', 's', '--json']));
|
|
const parsed = JSON.parse(jsonOut);
|
|
assert.strictEqual(parsed.schemaVersion, MCP_SCHEMA_VERSION);
|
|
|
|
const human = captureStdout(() => main(['node', 's']));
|
|
assert.ok(human.includes('MCP Inventory'));
|
|
});
|
|
|
|
test('formatHumanReport handles the no-fragmentation and fragmented-only branches', () => {
|
|
const solo = buildInventory([
|
|
normalizeServerEntry({ name: 'solo', ...GITHUB_STDIO, source: { harness: 'claude-code' } })
|
|
]);
|
|
const report = formatHumanReport(solo);
|
|
assert.ok(report.includes('No servers are configured in more than one harness'));
|
|
|
|
const fragOnly = formatHumanReport(solo, { fragmentedOnly: true });
|
|
assert.ok(!fragOnly.includes('All servers:'), 'fragmented-only omits the all-servers list');
|
|
});
|
|
|
|
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
|
if (failed > 0) process.exit(1);
|