From 7113b5bf63694b716f8b2413c5919824a82fc095 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 6 Jun 2026 03:55:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20MCP=20inventory=20(ecc.mcp.v1)=20?= =?UTF-8?q?=E2=80=94=20unified=20cross-harness=20MCP=20config=20view=20(#2?= =?UTF-8?q?146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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%. --- scripts/lib/mcp-inventory/canonical-mcp.js | 284 +++++++++++++++ scripts/lib/mcp-inventory/collect.js | 47 +++ .../lib/mcp-inventory/readers/claude-code.js | 73 ++++ scripts/lib/mcp-inventory/readers/codex.js | 83 +++++ scripts/lib/mcp-inventory/readers/opencode.js | 73 ++++ scripts/mcp-inventory.js | 106 ++++++ tests/lib/mcp-inventory.test.js | 324 ++++++++++++++++++ 7 files changed, 990 insertions(+) create mode 100644 scripts/lib/mcp-inventory/canonical-mcp.js create mode 100644 scripts/lib/mcp-inventory/collect.js create mode 100644 scripts/lib/mcp-inventory/readers/claude-code.js create mode 100644 scripts/lib/mcp-inventory/readers/codex.js create mode 100644 scripts/lib/mcp-inventory/readers/opencode.js create mode 100755 scripts/mcp-inventory.js create mode 100644 tests/lib/mcp-inventory.test.js diff --git a/scripts/lib/mcp-inventory/canonical-mcp.js b/scripts/lib/mcp-inventory/canonical-mcp.js new file mode 100644 index 00000000..cfe1cc03 --- /dev/null +++ b/scripts/lib/mcp-inventory/canonical-mcp.js @@ -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 +}; diff --git a/scripts/lib/mcp-inventory/collect.js b/scripts/lib/mcp-inventory/collect.js new file mode 100644 index 00000000..9d14f002 --- /dev/null +++ b/scripts/lib/mcp-inventory/collect.js @@ -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 +}; diff --git a/scripts/lib/mcp-inventory/readers/claude-code.js b/scripts/lib/mcp-inventory/readers/claude-code.js new file mode 100644 index 00000000..9d903ca6 --- /dev/null +++ b/scripts/lib/mcp-inventory/readers/claude-code.js @@ -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 +}; diff --git a/scripts/lib/mcp-inventory/readers/codex.js b/scripts/lib/mcp-inventory/readers/codex.js new file mode 100644 index 00000000..39237c8b --- /dev/null +++ b/scripts/lib/mcp-inventory/readers/codex.js @@ -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 +}; diff --git a/scripts/lib/mcp-inventory/readers/opencode.js b/scripts/lib/mcp-inventory/readers/opencode.js new file mode 100644 index 00000000..5e1a5a1f --- /dev/null +++ b/scripts/lib/mcp-inventory/readers/opencode.js @@ -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 +}; diff --git a/scripts/mcp-inventory.js b/scripts/mcp-inventory.js new file mode 100755 index 00000000..2d91740a --- /dev/null +++ b/scripts/mcp-inventory.js @@ -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 +}; diff --git a/tests/lib/mcp-inventory.test.js b/tests/lib/mcp-inventory.test.js new file mode 100644 index 00000000..1b113b8b --- /dev/null +++ b/tests/lib/mcp-inventory.test.js @@ -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);