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:
Affaan Mustafa
2026-06-06 03:55:17 +08:00
committed by GitHub
parent ab5e17fea9
commit 7113b5bf63
7 changed files with 990 additions and 0 deletions

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

106
scripts/mcp-inventory.js Executable file
View File

@@ -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
};