mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-13 19:51:24 +08:00
feat: MCP inventory (ecc.mcp.v1) — unified cross-harness MCP config view (#2146)
* 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%.
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
'use strict';
|
||||
|
||||
const MCP_SCHEMA_VERSION = 'ecc.mcp.v1';
|
||||
|
||||
// Env keys whose values are almost always secrets. Used only to flag a server
|
||||
// as carrying credentials; values are NEVER copied into the canonical record.
|
||||
const SECRET_KEY_PATTERN = /(token|secret|key|password|passwd|auth|credential|api[_-]?key|access[_-]?key|private)/i;
|
||||
|
||||
const REDACTED = '***';
|
||||
|
||||
// Known secret value prefixes (provider API keys) plus a high-entropy fallback.
|
||||
const SECRET_VALUE_PATTERNS = [
|
||||
/^sk-[A-Za-z0-9_-]{16,}$/i, // OpenAI / Anthropic (sk-ant-...)
|
||||
/^ghp_[A-Za-z0-9]{16,}$/, // GitHub PAT (classic)
|
||||
/^github_pat_[A-Za-z0-9_]{16,}$/, // GitHub PAT (fine-grained)
|
||||
/^gh[oprs]_[A-Za-z0-9]{16,}$/, // other GitHub tokens
|
||||
/^sm_[A-Za-z0-9_-]{16,}$/, // Supermemory
|
||||
/^AIza[A-Za-z0-9_-]{16,}$/, // Google API key
|
||||
/^xox[baprs]-[A-Za-z0-9-]{10,}$/, // Slack
|
||||
/^(pb|sk|pk|rk)_(live|test)_[A-Za-z0-9]{12,}$/i // Stripe / PostBridge-style
|
||||
];
|
||||
|
||||
// A CLI flag whose following value is a secret (e.g. --modelApiKey sk-...).
|
||||
const SECRET_FLAG_PATTERN = /(^|[-_])(api[-_]?key|apikey|token|secret|password|passwd|auth|credential|access[-_]?key|private[-_]?key)$/i;
|
||||
|
||||
function looksLikeSecretValue(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (SECRET_VALUE_PATTERNS.some(pattern => pattern.test(value))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// High-entropy fallback: a long opaque token (letters AND digits, no path or
|
||||
// package separators) is almost certainly a credential, not a flag value.
|
||||
return value.length >= 32
|
||||
&& /^[A-Za-z0-9_+/=.-]+$/.test(value)
|
||||
&& /[A-Za-z]/.test(value)
|
||||
&& /[0-9]/.test(value)
|
||||
&& !value.includes('/')
|
||||
&& !value.includes('@');
|
||||
}
|
||||
|
||||
// Redact secret values from a command arg vector: any token that looks like a
|
||||
// credential, or any token that immediately follows a secret-named flag. The
|
||||
// flag names themselves are preserved so the command shape stays legible.
|
||||
function redactArgs(args) {
|
||||
const list = Array.isArray(args) ? args : [];
|
||||
const result = [];
|
||||
|
||||
for (let index = 0; index < list.length; index += 1) {
|
||||
const current = list[index];
|
||||
if (typeof current !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline form: --flag=secret
|
||||
const inlineMatch = current.match(/^(--?[A-Za-z0-9_-]+)=(.+)$/);
|
||||
if (inlineMatch && (SECRET_FLAG_PATTERN.test(inlineMatch[1].replace(/^--?/, '')) || looksLikeSecretValue(inlineMatch[2]))) {
|
||||
result.push(`${inlineMatch[1]}=${REDACTED}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const previous = index > 0 ? list[index - 1] : null;
|
||||
const followsSecretFlag = typeof previous === 'string'
|
||||
&& /^--?[A-Za-z0-9_-]+$/.test(previous)
|
||||
&& SECRET_FLAG_PATTERN.test(previous.replace(/^--?/, ''));
|
||||
|
||||
if (followsSecretFlag || looksLikeSecretValue(current)) {
|
||||
result.push(REDACTED);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(current);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Redact embedded credentials in a server URL (userinfo + token query params).
|
||||
function redactUrl(url) {
|
||||
if (typeof url !== 'string' || url.length === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
let safe = url.replace(/\/\/[^/@]+@/, `//${REDACTED}@`);
|
||||
safe = safe.replace(/([?&](?:token|key|api[_-]?key|access[_-]?token|secret)=)[^&]+/gi, `$1${REDACTED}`);
|
||||
return safe;
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function asStringArray(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter(item => typeof item === 'string');
|
||||
}
|
||||
|
||||
// Normalize a transport label across harnesses:
|
||||
// Claude: type "stdio" | "http" | "sse"
|
||||
// OpenCode: type "local" (stdio) | "remote" (http/sse)
|
||||
// Codex: no type; presence of url => http, else stdio
|
||||
function normalizeTransport(rawType, { url } = {}) {
|
||||
const type = typeof rawType === 'string' ? rawType.toLowerCase() : '';
|
||||
|
||||
if (type === 'http' || type === 'streamable-http' || type === 'streamable_http') {
|
||||
return 'http';
|
||||
}
|
||||
|
||||
if (type === 'sse') {
|
||||
return 'sse';
|
||||
}
|
||||
|
||||
if (type === 'stdio' || type === 'local') {
|
||||
return 'stdio';
|
||||
}
|
||||
|
||||
if (type === 'remote') {
|
||||
return url ? 'http' : 'stdio';
|
||||
}
|
||||
|
||||
return url ? 'http' : 'stdio';
|
||||
}
|
||||
|
||||
// Extract env KEY names only (never values). Flags whether any key looks secret.
|
||||
function summarizeEnv(env) {
|
||||
if (!isObject(env)) {
|
||||
return { envKeys: [], hasSecrets: false };
|
||||
}
|
||||
|
||||
const envKeys = Object.keys(env).sort();
|
||||
const hasSecrets = envKeys.some(key => SECRET_KEY_PATTERN.test(key));
|
||||
return { envKeys, hasSecrets };
|
||||
}
|
||||
|
||||
// A stable identity for de-duplication across harnesses. Two server configs
|
||||
// with the same transport + command + args + url collapse to one logical
|
||||
// server even if their names differ slightly.
|
||||
function buildSignature({ transport, command, args, url }) {
|
||||
if (transport === 'http' || transport === 'sse') {
|
||||
return `${transport}:${url || ''}`;
|
||||
}
|
||||
|
||||
const argString = asStringArray(args).join(' ');
|
||||
return `stdio:${[command, argString].filter(Boolean).join(' ')}`.trim();
|
||||
}
|
||||
|
||||
// Normalize a single raw server entry (from any reader) to ecc.mcp.v1 shape.
|
||||
// rawServer fields the readers already pre-split: name, type, command, args,
|
||||
// url, env, enabled, source { harness, scope, configPath }.
|
||||
function normalizeServerEntry(rawServer) {
|
||||
const name = asNonEmptyString(rawServer.name) || 'unknown';
|
||||
const command = asNonEmptyString(rawServer.command);
|
||||
const rawUrl = asNonEmptyString(rawServer.url);
|
||||
const rawArgs = asStringArray(rawServer.args);
|
||||
const transport = normalizeTransport(rawServer.type, { url: rawUrl });
|
||||
const { envKeys, hasSecrets } = summarizeEnv(rawServer.env);
|
||||
|
||||
// Secrets can hide in args (e.g. --modelApiKey sk-...) and URLs, not just
|
||||
// env. Redact before anything is stored or hashed into the signature.
|
||||
const args = redactArgs(rawArgs);
|
||||
const url = redactUrl(rawUrl);
|
||||
const argsCarrySecret = rawArgs.length !== args.length
|
||||
|| rawArgs.some((value, index) => value !== args[index]);
|
||||
const urlCarriesSecret = rawUrl !== url;
|
||||
|
||||
const source = isObject(rawServer.source) ? rawServer.source : {};
|
||||
|
||||
return {
|
||||
name,
|
||||
transport,
|
||||
command: transport === 'stdio' ? command : null,
|
||||
args: transport === 'stdio' ? args : [],
|
||||
url: transport === 'stdio' ? null : url,
|
||||
envKeys,
|
||||
hasSecrets: hasSecrets || argsCarrySecret || urlCarriesSecret,
|
||||
enabled: rawServer.enabled === false ? false : true,
|
||||
signature: buildSignature({ transport, command, args, url }),
|
||||
sources: [{
|
||||
harness: asNonEmptyString(source.harness) || 'unknown',
|
||||
scope: asNonEmptyString(source.scope) || 'user',
|
||||
configPath: asNonEmptyString(source.configPath) || null
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Merge many per-harness server records into a deduplicated inventory keyed by
|
||||
// logical server name. Records that share a name are merged; their sources are
|
||||
// concatenated and their signatures compared for drift.
|
||||
function mergeServers(serverRecords) {
|
||||
const byName = new Map();
|
||||
|
||||
for (const record of serverRecords) {
|
||||
const existing = byName.get(record.name);
|
||||
if (!existing) {
|
||||
byName.set(record.name, {
|
||||
...record,
|
||||
signatures: [record.signature],
|
||||
sources: [...record.sources]
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.sources.push(...record.sources);
|
||||
existing.signatures.push(record.signature);
|
||||
existing.hasSecrets = existing.hasSecrets || record.hasSecrets;
|
||||
// Union of env keys observed across harnesses.
|
||||
existing.envKeys = Array.from(new Set([...existing.envKeys, ...record.envKeys])).sort();
|
||||
}
|
||||
|
||||
return Array.from(byName.values()).map(server => {
|
||||
const uniqueSignatures = Array.from(new Set(server.signatures));
|
||||
const { signatures, ...rest } = server;
|
||||
return {
|
||||
...rest,
|
||||
harnessCount: server.sources.length,
|
||||
consistent: uniqueSignatures.length <= 1
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildFragmentation(mergedServers) {
|
||||
return mergedServers
|
||||
.filter(server => server.harnessCount > 1)
|
||||
.map(server => ({
|
||||
name: server.name,
|
||||
harnessCount: server.harnessCount,
|
||||
harnesses: server.sources.map(source => source.harness),
|
||||
consistent: server.consistent
|
||||
}))
|
||||
.sort((a, b) => b.harnessCount - a.harnessCount || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function buildInventory(serverRecords) {
|
||||
const merged = mergeServers(serverRecords).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const fragmentation = buildFragmentation(merged);
|
||||
const harnesses = new Set();
|
||||
let serversWithSecrets = 0;
|
||||
|
||||
for (const server of merged) {
|
||||
server.sources.forEach(source => harnesses.add(source.harness));
|
||||
if (server.hasSecrets) {
|
||||
serversWithSecrets += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: MCP_SCHEMA_VERSION,
|
||||
servers: merged,
|
||||
fragmentation,
|
||||
aggregates: {
|
||||
serverCount: merged.length,
|
||||
harnessCount: harnesses.size,
|
||||
duplicateServerCount: fragmentation.length,
|
||||
inconsistentServerCount: fragmentation.filter(item => !item.consistent).length,
|
||||
serversWithSecrets
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MCP_SCHEMA_VERSION,
|
||||
SECRET_KEY_PATTERN,
|
||||
REDACTED,
|
||||
looksLikeSecretValue,
|
||||
redactArgs,
|
||||
redactUrl,
|
||||
normalizeTransport,
|
||||
summarizeEnv,
|
||||
buildSignature,
|
||||
normalizeServerEntry,
|
||||
mergeServers,
|
||||
buildFragmentation,
|
||||
buildInventory
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
const { normalizeServerEntry, buildInventory } = require('./canonical-mcp');
|
||||
const { readClaudeCodeMcp } = require('./readers/claude-code');
|
||||
const { readCodexMcp } = require('./readers/codex');
|
||||
const { readOpencodeMcp } = require('./readers/opencode');
|
||||
|
||||
const DEFAULT_READERS = Object.freeze({
|
||||
'claude-code': readClaudeCodeMcp,
|
||||
codex: readCodexMcp,
|
||||
opencode: readOpencodeMcp
|
||||
});
|
||||
|
||||
// Collect MCP server configs from every harness reader, normalize each raw
|
||||
// entry to ecc.mcp.v1, then merge into a single deduplicated inventory with a
|
||||
// fragmentation report. Secrets are stripped during normalization (only env
|
||||
// key names survive), so the returned inventory is safe to print or persist.
|
||||
function collectMcpInventory(options = {}) {
|
||||
const readers = options.readers || DEFAULT_READERS;
|
||||
const readerOptions = options.readerOptions || {};
|
||||
|
||||
const rawRecords = [];
|
||||
for (const [harness, reader] of Object.entries(readers)) {
|
||||
if (typeof reader !== 'function') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = reader(readerOptions[harness] || readerOptions.shared || {});
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
|
||||
if (Array.isArray(entries)) {
|
||||
rawRecords.push(...entries);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = rawRecords.map(normalizeServerEntry);
|
||||
return buildInventory(normalized);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectMcpInventory,
|
||||
DEFAULT_READERS
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// Claude Code stores MCP servers under "mcpServers" in ~/.claude.json (user
|
||||
// scope) and in project-local .mcp.json files (project scope). Each entry:
|
||||
// { type: "stdio"|"http"|"sse", command, args[], env{}, url }
|
||||
function mapClaudeServer(name, raw, source) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
type: typeof raw.type === 'string' ? raw.type : (raw.url ? 'http' : 'stdio'),
|
||||
command: raw.command || null,
|
||||
args: Array.isArray(raw.args) ? raw.args : [],
|
||||
url: raw.url || null,
|
||||
env: raw.env && typeof raw.env === 'object' ? raw.env : {},
|
||||
enabled: raw.disabled === true ? false : true,
|
||||
source
|
||||
};
|
||||
}
|
||||
|
||||
function readMcpServersBlock(filePath, scope) {
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const block = parsed && typeof parsed.mcpServers === 'object' && parsed.mcpServers
|
||||
? parsed.mcpServers
|
||||
: {};
|
||||
|
||||
return Object.entries(block)
|
||||
.map(([name, raw]) => mapClaudeServer(name, raw, {
|
||||
harness: 'claude-code',
|
||||
scope,
|
||||
configPath: filePath
|
||||
}))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function readClaudeCodeMcp(options = {}) {
|
||||
const homeDir = options.homeDir || os.homedir();
|
||||
const userConfig = options.userConfigPath || path.join(homeDir, '.claude.json');
|
||||
const projectConfigPaths = Array.isArray(options.projectConfigPaths)
|
||||
? options.projectConfigPaths
|
||||
: [];
|
||||
|
||||
const records = [
|
||||
...readMcpServersBlock(userConfig, 'user')
|
||||
];
|
||||
|
||||
for (const projectPath of projectConfigPaths) {
|
||||
records.push(...readMcpServersBlock(projectPath, 'project'));
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readClaudeCodeMcp,
|
||||
mapClaudeServer
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// Codex stores MCP servers in ~/.codex/config.toml as TOML tables:
|
||||
// [mcp_servers.NAME]
|
||||
// command = "npx"
|
||||
// args = ["-y", "pkg"]
|
||||
// url = "https://..." # http transport
|
||||
// [mcp_servers.NAME.env] # secret values live here
|
||||
// [mcp_servers.NAME.http_headers]
|
||||
// We parse with @iarna/toml when available and fall back to a minimal
|
||||
// section parser so the reader degrades gracefully without the dependency.
|
||||
function loadTomlParser(parseTomlImpl) {
|
||||
if (typeof parseTomlImpl === 'function') {
|
||||
return parseTomlImpl;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
return require('@iarna/toml').parse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapCodexServer(name, raw, configPath) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = raw.url ? 'http' : 'stdio';
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
command: typeof raw.command === 'string' ? raw.command : null,
|
||||
args: Array.isArray(raw.args) ? raw.args : [],
|
||||
url: typeof raw.url === 'string' ? raw.url : null,
|
||||
env: raw.env && typeof raw.env === 'object' ? raw.env : {},
|
||||
enabled: raw.enabled === false ? false : true,
|
||||
source: {
|
||||
harness: 'codex',
|
||||
scope: 'user',
|
||||
configPath
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readCodexMcp(options = {}) {
|
||||
const homeDir = options.homeDir || os.homedir();
|
||||
const configPath = options.configPath || path.join(homeDir, '.codex', 'config.toml');
|
||||
|
||||
if (!fs.existsSync(configPath) || !fs.statSync(configPath).isFile()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parseToml = loadTomlParser(options.parseTomlImpl);
|
||||
if (!parseToml) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseToml(fs.readFileSync(configPath, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const block = parsed && typeof parsed.mcp_servers === 'object' && parsed.mcp_servers
|
||||
? parsed.mcp_servers
|
||||
: {};
|
||||
|
||||
return Object.entries(block)
|
||||
.map(([name, raw]) => mapCodexServer(name, raw, configPath))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readCodexMcp,
|
||||
mapCodexServer
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// OpenCode stores MCP servers under "mcp" in ~/.config/opencode/opencode.json.
|
||||
// Shape differs from Claude/Codex:
|
||||
// { type: "local"|"remote", command: ["npx","-y","pkg"], environment: {},
|
||||
// enabled: bool, url: "https://..." }
|
||||
// command is an ARRAY (binary + args combined); environment (not env) holds
|
||||
// secrets; type "local" => stdio, "remote" => http/sse.
|
||||
function mapOpencodeServer(name, raw, configPath) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const commandArray = Array.isArray(raw.command) ? raw.command.filter(item => typeof item === 'string') : [];
|
||||
const [command, ...args] = commandArray;
|
||||
|
||||
return {
|
||||
name,
|
||||
type: typeof raw.type === 'string' ? raw.type : (raw.url ? 'remote' : 'local'),
|
||||
command: command || (typeof raw.command === 'string' ? raw.command : null),
|
||||
args: command ? args : (Array.isArray(raw.args) ? raw.args : []),
|
||||
url: typeof raw.url === 'string' ? raw.url : null,
|
||||
env: raw.environment && typeof raw.environment === 'object'
|
||||
? raw.environment
|
||||
: (raw.env && typeof raw.env === 'object' ? raw.env : {}),
|
||||
enabled: raw.enabled === false ? false : true,
|
||||
source: {
|
||||
harness: 'opencode',
|
||||
scope: 'user',
|
||||
configPath
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readOpencodeMcp(options = {}) {
|
||||
const homeDir = options.homeDir || os.homedir();
|
||||
const candidatePaths = options.configPath
|
||||
? [options.configPath]
|
||||
: [
|
||||
path.join(homeDir, '.config', 'opencode', 'opencode.json'),
|
||||
path.join(homeDir, '.config', 'opencode', 'config.json'),
|
||||
path.join(homeDir, '.opencode.json')
|
||||
];
|
||||
|
||||
const configPath = candidatePaths.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
|
||||
if (!configPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const block = parsed && typeof parsed.mcp === 'object' && parsed.mcp
|
||||
? parsed.mcp
|
||||
: (parsed && typeof parsed.mcpServers === 'object' && parsed.mcpServers ? parsed.mcpServers : {});
|
||||
|
||||
return Object.entries(block)
|
||||
.map(([name, raw]) => mapOpencodeServer(name, raw, configPath))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readOpencodeMcp,
|
||||
mapOpencodeServer
|
||||
};
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { collectMcpInventory } = require('./lib/mcp-inventory/collect');
|
||||
|
||||
function parseArgs(argv = process.argv) {
|
||||
const args = argv.slice(2);
|
||||
const options = { json: false, fragmentedOnly: false, help: false };
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg === '--json') {
|
||||
options.json = true;
|
||||
} else if (arg === '--fragmented' || arg === '--fragmented-only') {
|
||||
options.fragmentedOnly = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
options.help = true;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
'Usage: mcp-inventory [options]',
|
||||
'',
|
||||
'Read MCP server configs across every installed harness (Claude Code,',
|
||||
'Codex, OpenCode), normalize them to ecc.mcp.v1, and report which servers',
|
||||
'are configured in more than one harness. Secrets are never printed; only',
|
||||
'env key names are shown.',
|
||||
'',
|
||||
'Options:',
|
||||
' --json Print the full ecc.mcp.v1 inventory as JSON',
|
||||
' --fragmented Only show servers configured in 2+ harnesses',
|
||||
' -h, --help Show this help'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function formatHumanReport(inventory, options = {}) {
|
||||
const lines = [];
|
||||
const { aggregates, servers, fragmentation } = inventory;
|
||||
|
||||
lines.push('MCP Inventory (ecc.mcp.v1)');
|
||||
lines.push(
|
||||
` ${aggregates.serverCount} servers across ${aggregates.harnessCount} harnesses, `
|
||||
+ `${aggregates.duplicateServerCount} configured in 2+ harnesses `
|
||||
+ `(${aggregates.inconsistentServerCount} inconsistent), `
|
||||
+ `${aggregates.serversWithSecrets} carry secrets`
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
if (fragmentation.length > 0) {
|
||||
lines.push('Fragmented servers (configure-once candidates):');
|
||||
for (const item of fragmentation) {
|
||||
const flag = item.consistent ? 'consistent' : 'DRIFT';
|
||||
lines.push(` ${item.name} x${item.harnessCount} [${item.harnesses.join(', ')}] ${flag}`);
|
||||
}
|
||||
lines.push('');
|
||||
} else {
|
||||
lines.push('No servers are configured in more than one harness.');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (!options.fragmentedOnly) {
|
||||
lines.push('All servers:');
|
||||
for (const server of servers) {
|
||||
const transport = server.transport === 'stdio'
|
||||
? `stdio:${[server.command, ...server.args].filter(Boolean).join(' ')}`
|
||||
: `${server.transport}:${server.url || ''}`;
|
||||
const secretFlag = server.hasSecrets ? ' (secrets)' : '';
|
||||
const disabledFlag = server.enabled ? '' : ' (disabled)';
|
||||
lines.push(` ${server.name} -> ${transport}${secretFlag}${disabledFlag}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function main(argv = process.argv) {
|
||||
const options = parseArgs(argv);
|
||||
|
||||
if (options.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const inventory = collectMcpInventory();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(inventory, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(formatHumanReport(inventory, options));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseArgs,
|
||||
usage,
|
||||
formatHumanReport,
|
||||
main
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
'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);
|
||||
Reference in New Issue
Block a user