mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-11 02:33:10 +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:
284
scripts/lib/mcp-inventory/canonical-mcp.js
Normal file
284
scripts/lib/mcp-inventory/canonical-mcp.js
Normal 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
scripts/lib/mcp-inventory/collect.js
Normal file
47
scripts/lib/mcp-inventory/collect.js
Normal 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
|
||||
};
|
||||
73
scripts/lib/mcp-inventory/readers/claude-code.js
Normal file
73
scripts/lib/mcp-inventory/readers/claude-code.js
Normal 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
|
||||
};
|
||||
83
scripts/lib/mcp-inventory/readers/codex.js
Normal file
83
scripts/lib/mcp-inventory/readers/codex.js
Normal 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
|
||||
};
|
||||
73
scripts/lib/mcp-inventory/readers/opencode.js
Normal file
73
scripts/lib/mcp-inventory/readers/opencode.js
Normal 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
106
scripts/mcp-inventory.js
Executable 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
|
||||
};
|
||||
Reference in New Issue
Block a user