Skip to content

Commit 7113b5b

Browse files
authored
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%.
1 parent ab5e17f commit 7113b5b

7 files changed

Lines changed: 990 additions & 0 deletions

File tree

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
'use strict';
2+
3+
const MCP_SCHEMA_VERSION = 'ecc.mcp.v1';
4+
5+
// Env keys whose values are almost always secrets. Used only to flag a server
6+
// as carrying credentials; values are NEVER copied into the canonical record.
7+
const SECRET_KEY_PATTERN = /(token|secret|key|password|passwd|auth|credential|api[_-]?key|access[_-]?key|private)/i;
8+
9+
const REDACTED = '***';
10+
11+
// Known secret value prefixes (provider API keys) plus a high-entropy fallback.
12+
const SECRET_VALUE_PATTERNS = [
13+
/^sk-[A-Za-z0-9_-]{16,}$/i, // OpenAI / Anthropic (sk-ant-...)
14+
/^ghp_[A-Za-z0-9]{16,}$/, // GitHub PAT (classic)
15+
/^github_pat_[A-Za-z0-9_]{16,}$/, // GitHub PAT (fine-grained)
16+
/^gh[oprs]_[A-Za-z0-9]{16,}$/, // other GitHub tokens
17+
/^sm_[A-Za-z0-9_-]{16,}$/, // Supermemory
18+
/^AIza[A-Za-z0-9_-]{16,}$/, // Google API key
19+
/^xox[baprs]-[A-Za-z0-9-]{10,}$/, // Slack
20+
/^(pb|sk|pk|rk)_(live|test)_[A-Za-z0-9]{12,}$/i // Stripe / PostBridge-style
21+
];
22+
23+
// A CLI flag whose following value is a secret (e.g. --modelApiKey sk-...).
24+
const SECRET_FLAG_PATTERN = /(^|[-_])(api[-_]?key|apikey|token|secret|password|passwd|auth|credential|access[-_]?key|private[-_]?key)$/i;
25+
26+
function looksLikeSecretValue(value) {
27+
if (typeof value !== 'string') {
28+
return false;
29+
}
30+
31+
if (SECRET_VALUE_PATTERNS.some(pattern => pattern.test(value))) {
32+
return true;
33+
}
34+
35+
// High-entropy fallback: a long opaque token (letters AND digits, no path or
36+
// package separators) is almost certainly a credential, not a flag value.
37+
return value.length >= 32
38+
&& /^[A-Za-z0-9_+/=.-]+$/.test(value)
39+
&& /[A-Za-z]/.test(value)
40+
&& /[0-9]/.test(value)
41+
&& !value.includes('/')
42+
&& !value.includes('@');
43+
}
44+
45+
// Redact secret values from a command arg vector: any token that looks like a
46+
// credential, or any token that immediately follows a secret-named flag. The
47+
// flag names themselves are preserved so the command shape stays legible.
48+
function redactArgs(args) {
49+
const list = Array.isArray(args) ? args : [];
50+
const result = [];
51+
52+
for (let index = 0; index < list.length; index += 1) {
53+
const current = list[index];
54+
if (typeof current !== 'string') {
55+
continue;
56+
}
57+
58+
// Inline form: --flag=secret
59+
const inlineMatch = current.match(/^(--?[A-Za-z0-9_-]+)=(.+)$/);
60+
if (inlineMatch && (SECRET_FLAG_PATTERN.test(inlineMatch[1].replace(/^--?/, '')) || looksLikeSecretValue(inlineMatch[2]))) {
61+
result.push(`${inlineMatch[1]}=${REDACTED}`);
62+
continue;
63+
}
64+
65+
const previous = index > 0 ? list[index - 1] : null;
66+
const followsSecretFlag = typeof previous === 'string'
67+
&& /^--?[A-Za-z0-9_-]+$/.test(previous)
68+
&& SECRET_FLAG_PATTERN.test(previous.replace(/^--?/, ''));
69+
70+
if (followsSecretFlag || looksLikeSecretValue(current)) {
71+
result.push(REDACTED);
72+
continue;
73+
}
74+
75+
result.push(current);
76+
}
77+
78+
return result;
79+
}
80+
81+
// Redact embedded credentials in a server URL (userinfo + token query params).
82+
function redactUrl(url) {
83+
if (typeof url !== 'string' || url.length === 0) {
84+
return url;
85+
}
86+
87+
let safe = url.replace(/\/\/[^/@]+@/, `//${REDACTED}@`);
88+
safe = safe.replace(/([?&](?:token|key|api[_-]?key|access[_-]?token|secret)=)[^&]+/gi, `$1${REDACTED}`);
89+
return safe;
90+
}
91+
92+
function isObject(value) {
93+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
94+
}
95+
96+
function asNonEmptyString(value) {
97+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
98+
}
99+
100+
function asStringArray(value) {
101+
if (!Array.isArray(value)) {
102+
return [];
103+
}
104+
105+
return value.filter(item => typeof item === 'string');
106+
}
107+
108+
// Normalize a transport label across harnesses:
109+
// Claude: type "stdio" | "http" | "sse"
110+
// OpenCode: type "local" (stdio) | "remote" (http/sse)
111+
// Codex: no type; presence of url => http, else stdio
112+
function normalizeTransport(rawType, { url } = {}) {
113+
const type = typeof rawType === 'string' ? rawType.toLowerCase() : '';
114+
115+
if (type === 'http' || type === 'streamable-http' || type === 'streamable_http') {
116+
return 'http';
117+
}
118+
119+
if (type === 'sse') {
120+
return 'sse';
121+
}
122+
123+
if (type === 'stdio' || type === 'local') {
124+
return 'stdio';
125+
}
126+
127+
if (type === 'remote') {
128+
return url ? 'http' : 'stdio';
129+
}
130+
131+
return url ? 'http' : 'stdio';
132+
}
133+
134+
// Extract env KEY names only (never values). Flags whether any key looks secret.
135+
function summarizeEnv(env) {
136+
if (!isObject(env)) {
137+
return { envKeys: [], hasSecrets: false };
138+
}
139+
140+
const envKeys = Object.keys(env).sort();
141+
const hasSecrets = envKeys.some(key => SECRET_KEY_PATTERN.test(key));
142+
return { envKeys, hasSecrets };
143+
}
144+
145+
// A stable identity for de-duplication across harnesses. Two server configs
146+
// with the same transport + command + args + url collapse to one logical
147+
// server even if their names differ slightly.
148+
function buildSignature({ transport, command, args, url }) {
149+
if (transport === 'http' || transport === 'sse') {
150+
return `${transport}:${url || ''}`;
151+
}
152+
153+
const argString = asStringArray(args).join(' ');
154+
return `stdio:${[command, argString].filter(Boolean).join(' ')}`.trim();
155+
}
156+
157+
// Normalize a single raw server entry (from any reader) to ecc.mcp.v1 shape.
158+
// rawServer fields the readers already pre-split: name, type, command, args,
159+
// url, env, enabled, source { harness, scope, configPath }.
160+
function normalizeServerEntry(rawServer) {
161+
const name = asNonEmptyString(rawServer.name) || 'unknown';
162+
const command = asNonEmptyString(rawServer.command);
163+
const rawUrl = asNonEmptyString(rawServer.url);
164+
const rawArgs = asStringArray(rawServer.args);
165+
const transport = normalizeTransport(rawServer.type, { url: rawUrl });
166+
const { envKeys, hasSecrets } = summarizeEnv(rawServer.env);
167+
168+
// Secrets can hide in args (e.g. --modelApiKey sk-...) and URLs, not just
169+
// env. Redact before anything is stored or hashed into the signature.
170+
const args = redactArgs(rawArgs);
171+
const url = redactUrl(rawUrl);
172+
const argsCarrySecret = rawArgs.length !== args.length
173+
|| rawArgs.some((value, index) => value !== args[index]);
174+
const urlCarriesSecret = rawUrl !== url;
175+
176+
const source = isObject(rawServer.source) ? rawServer.source : {};
177+
178+
return {
179+
name,
180+
transport,
181+
command: transport === 'stdio' ? command : null,
182+
args: transport === 'stdio' ? args : [],
183+
url: transport === 'stdio' ? null : url,
184+
envKeys,
185+
hasSecrets: hasSecrets || argsCarrySecret || urlCarriesSecret,
186+
enabled: rawServer.enabled === false ? false : true,
187+
signature: buildSignature({ transport, command, args, url }),
188+
sources: [{
189+
harness: asNonEmptyString(source.harness) || 'unknown',
190+
scope: asNonEmptyString(source.scope) || 'user',
191+
configPath: asNonEmptyString(source.configPath) || null
192+
}]
193+
};
194+
}
195+
196+
// Merge many per-harness server records into a deduplicated inventory keyed by
197+
// logical server name. Records that share a name are merged; their sources are
198+
// concatenated and their signatures compared for drift.
199+
function mergeServers(serverRecords) {
200+
const byName = new Map();
201+
202+
for (const record of serverRecords) {
203+
const existing = byName.get(record.name);
204+
if (!existing) {
205+
byName.set(record.name, {
206+
...record,
207+
signatures: [record.signature],
208+
sources: [...record.sources]
209+
});
210+
continue;
211+
}
212+
213+
existing.sources.push(...record.sources);
214+
existing.signatures.push(record.signature);
215+
existing.hasSecrets = existing.hasSecrets || record.hasSecrets;
216+
// Union of env keys observed across harnesses.
217+
existing.envKeys = Array.from(new Set([...existing.envKeys, ...record.envKeys])).sort();
218+
}
219+
220+
return Array.from(byName.values()).map(server => {
221+
const uniqueSignatures = Array.from(new Set(server.signatures));
222+
const { signatures, ...rest } = server;
223+
return {
224+
...rest,
225+
harnessCount: server.sources.length,
226+
consistent: uniqueSignatures.length <= 1
227+
};
228+
});
229+
}
230+
231+
function buildFragmentation(mergedServers) {
232+
return mergedServers
233+
.filter(server => server.harnessCount > 1)
234+
.map(server => ({
235+
name: server.name,
236+
harnessCount: server.harnessCount,
237+
harnesses: server.sources.map(source => source.harness),
238+
consistent: server.consistent
239+
}))
240+
.sort((a, b) => b.harnessCount - a.harnessCount || a.name.localeCompare(b.name));
241+
}
242+
243+
function buildInventory(serverRecords) {
244+
const merged = mergeServers(serverRecords).sort((a, b) => a.name.localeCompare(b.name));
245+
const fragmentation = buildFragmentation(merged);
246+
const harnesses = new Set();
247+
let serversWithSecrets = 0;
248+
249+
for (const server of merged) {
250+
server.sources.forEach(source => harnesses.add(source.harness));
251+
if (server.hasSecrets) {
252+
serversWithSecrets += 1;
253+
}
254+
}
255+
256+
return {
257+
schemaVersion: MCP_SCHEMA_VERSION,
258+
servers: merged,
259+
fragmentation,
260+
aggregates: {
261+
serverCount: merged.length,
262+
harnessCount: harnesses.size,
263+
duplicateServerCount: fragmentation.length,
264+
inconsistentServerCount: fragmentation.filter(item => !item.consistent).length,
265+
serversWithSecrets
266+
}
267+
};
268+
}
269+
270+
module.exports = {
271+
MCP_SCHEMA_VERSION,
272+
SECRET_KEY_PATTERN,
273+
REDACTED,
274+
looksLikeSecretValue,
275+
redactArgs,
276+
redactUrl,
277+
normalizeTransport,
278+
summarizeEnv,
279+
buildSignature,
280+
normalizeServerEntry,
281+
mergeServers,
282+
buildFragmentation,
283+
buildInventory
284+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use strict';
2+
3+
const { normalizeServerEntry, buildInventory } = require('./canonical-mcp');
4+
const { readClaudeCodeMcp } = require('./readers/claude-code');
5+
const { readCodexMcp } = require('./readers/codex');
6+
const { readOpencodeMcp } = require('./readers/opencode');
7+
8+
const DEFAULT_READERS = Object.freeze({
9+
'claude-code': readClaudeCodeMcp,
10+
codex: readCodexMcp,
11+
opencode: readOpencodeMcp
12+
});
13+
14+
// Collect MCP server configs from every harness reader, normalize each raw
15+
// entry to ecc.mcp.v1, then merge into a single deduplicated inventory with a
16+
// fragmentation report. Secrets are stripped during normalization (only env
17+
// key names survive), so the returned inventory is safe to print or persist.
18+
function collectMcpInventory(options = {}) {
19+
const readers = options.readers || DEFAULT_READERS;
20+
const readerOptions = options.readerOptions || {};
21+
22+
const rawRecords = [];
23+
for (const [harness, reader] of Object.entries(readers)) {
24+
if (typeof reader !== 'function') {
25+
continue;
26+
}
27+
28+
let entries;
29+
try {
30+
entries = reader(readerOptions[harness] || readerOptions.shared || {});
31+
} catch {
32+
entries = [];
33+
}
34+
35+
if (Array.isArray(entries)) {
36+
rawRecords.push(...entries);
37+
}
38+
}
39+
40+
const normalized = rawRecords.map(normalizeServerEntry);
41+
return buildInventory(normalized);
42+
}
43+
44+
module.exports = {
45+
collectMcpInventory,
46+
DEFAULT_READERS
47+
};

0 commit comments

Comments
 (0)