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
+284
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
};
+47
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
};
@@ -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
};