@getmcpm/cli
Advanced tools
| #!/usr/bin/env node | ||
| import { | ||
| coloredOutput | ||
| } from "./chunk-E3T224S3.js"; | ||
| import { | ||
| isConfineBackendAvailable, | ||
| isWrapped | ||
| } from "./chunk-WYSMWP2R.js"; | ||
| import { | ||
| sanitizeForTerminal | ||
| } from "./chunk-FEXJHHDM.js"; | ||
| import { | ||
| getAdapter | ||
| } from "./chunk-W4IAFBUN.js"; | ||
| import { | ||
| isSupportedPlatform, | ||
| parsePlaceholder | ||
| } from "./chunk-GZ3WCRLG.js"; | ||
| import { | ||
| CLIENT_IDS, | ||
| getConfigPath | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import { | ||
| detectSecretLabels | ||
| } from "./chunk-MZCNQU2K.js"; | ||
| // src/utils/format-entry.ts | ||
| function formatMcpEntryCommand(entry, fallback = "\u2014") { | ||
| if (entry.url) return entry.url; | ||
| if (entry.command) { | ||
| const args = entry.args?.join(" ") ?? ""; | ||
| return args ? `${entry.command} ${args}` : entry.command; | ||
| } | ||
| return fallback; | ||
| } | ||
| // src/commands/doctor.ts | ||
| import { access } from "fs/promises"; | ||
| // src/config/drift.ts | ||
| async function collectClientStates(deps) { | ||
| const clients = await deps.detectClients(); | ||
| const states = []; | ||
| for (const clientId of clients) { | ||
| try { | ||
| const servers = await deps.getAdapter(clientId).read(deps.getPath(clientId)); | ||
| states.push({ clientId, servers }); | ||
| } catch { | ||
| } | ||
| } | ||
| return states; | ||
| } | ||
| function fieldProjection(entry) { | ||
| return { | ||
| command: entry.command ?? "", | ||
| args: JSON.stringify(entry.args ?? []), | ||
| "env keys": JSON.stringify(Object.keys(entry.env ?? {}).sort()), | ||
| url: entry.url ?? "", | ||
| "header keys": JSON.stringify(Object.keys(entry.headers ?? {}).sort()) | ||
| }; | ||
| } | ||
| var COMPARED_FIELDS = ["command", "args", "env keys", "url", "header keys"]; | ||
| function divergingFields(entries) { | ||
| const projections = entries.map(fieldProjection); | ||
| return COMPARED_FIELDS.filter((field) => { | ||
| const distinct = new Set(projections.map((p) => p[field])); | ||
| return distinct.size > 1; | ||
| }); | ||
| } | ||
| function buildDriftModel(states) { | ||
| const clients = states.map((s) => s.clientId).sort(); | ||
| const byName = /* @__PURE__ */ new Map(); | ||
| for (const { clientId, servers: servers2 } of states) { | ||
| for (const [name, entry] of Object.entries(servers2)) { | ||
| const list = byName.get(name) ?? []; | ||
| list.push({ clientId, entry }); | ||
| byName.set(name, list); | ||
| } | ||
| } | ||
| const servers = []; | ||
| for (const name of [...byName.keys()].sort()) { | ||
| const holders = byName.get(name); | ||
| const present = holders.map((h) => h.clientId).sort(); | ||
| const presentSet = new Set(present); | ||
| const absent = clients.filter((c) => !presentSet.has(c)); | ||
| const fields = holders.length > 1 ? divergingFields(holders.map((h) => h.entry)) : []; | ||
| const conflict = fields.length > 0; | ||
| servers.push({ | ||
| name, | ||
| present, | ||
| absent, | ||
| conflict, | ||
| ...conflict ? { conflictFields: fields } : {} | ||
| }); | ||
| } | ||
| const drifted = servers.filter((s) => s.absent.length > 0 || s.conflict).length; | ||
| return { clients, servers, inSync: servers.length - drifted, drifted }; | ||
| } | ||
| // src/scanner/config-secrets.ts | ||
| var GENERIC_LABEL = "secret-named key holds a plaintext value"; | ||
| var SECRET_KEY_RE = /(?:^|_)(?:PASSWORD|PASSWD|PASSPHRASE|SECRET|TOKEN|PAT|APIKEY|AUTHORIZATION|CREDENTIALS?|(?:API|ACCESS|PRIVATE|SECRET|SESSION|SIGNING|ENCRYPTION)_KEY)(?:_|$)/; | ||
| var NON_SECRET_QUALIFIER_RE = /(?:^|_)(?:URL|URI|ENDPOINT|HOST|PORT|ID|NAME|PATH|FILE|DIR|ENABLED|DISABLED|TYPE|MODE|REGION|TIMEOUT|VERSION|PUBLIC|FORMAT|HEADER|PREFIX|SUFFIX|COUNT|SIZE|TTL|EXPIRY|EXPIRES|ISSUER|AUDIENCE|ALGORITHM|ALG|SCOPE|METHOD)(?:_|$)/; | ||
| function normalizeKey(key) { | ||
| return key.toUpperCase().replace(/-/g, "_"); | ||
| } | ||
| function keyLooksSecret(key) { | ||
| const k = normalizeKey(key); | ||
| return SECRET_KEY_RE.test(k) && !NON_SECRET_QUALIFIER_RE.test(k); | ||
| } | ||
| function valueLooksPlaintextSecret(value) { | ||
| const v = value.trim(); | ||
| if (v.length < 6) return false; | ||
| if (parsePlaceholder(value) !== null) return false; | ||
| if (/\$\{[^}]*\}/.test(v)) return false; | ||
| if (/^\$[A-Za-z_]/.test(v)) return false; | ||
| if (/^%[A-Za-z_][A-Za-z0-9_]*%([\\/].*)?$/.test(v)) return false; | ||
| if (/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return false; | ||
| if (/^[~./]/.test(v) || /^[A-Za-z]:[\\/]/.test(v) || /^\\\\/.test(v)) return false; | ||
| if (/^(true|false|\d+)$/i.test(v)) return false; | ||
| return true; | ||
| } | ||
| function scanMap(server, field, map) { | ||
| if (!map) return []; | ||
| const out = []; | ||
| for (const [key, value] of Object.entries(map)) { | ||
| if (typeof value !== "string") continue; | ||
| if (parsePlaceholder(value) !== null) continue; | ||
| const labels = detectSecretLabels(value); | ||
| if (labels.length > 0) { | ||
| out.push({ server, field, key, label: labels.join(", ") }); | ||
| continue; | ||
| } | ||
| if (keyLooksSecret(key) && valueLooksPlaintextSecret(value)) { | ||
| out.push({ server, field, key, label: GENERIC_LABEL }); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| function scanServerConfigSecrets(server, entry) { | ||
| return [...scanMap(server, "env", entry.env), ...scanMap(server, "header", entry.headers)]; | ||
| } | ||
| function scanConfigSecrets(servers) { | ||
| return Object.entries(servers).flatMap(([name, entry]) => scanServerConfigSecrets(name, entry)); | ||
| } | ||
| // src/commands/doctor.ts | ||
| import "commander"; | ||
| import os from "os"; | ||
| import { execFile } from "child_process"; | ||
| var RUNTIMES = ["npx", "uvx", "docker"]; | ||
| var CLIENT_LABELS = { | ||
| "claude-desktop": "Claude Desktop", | ||
| "claude-code": "Claude Code", | ||
| cursor: "Cursor", | ||
| vscode: "VS Code", | ||
| windsurf: "Windsurf", | ||
| "gemini-cli": "Gemini CLI" | ||
| }; | ||
| var RUNTIME_INSTALL_HINTS = { | ||
| npx: "install Node.js from https://nodejs.org", | ||
| uvx: "install uv from https://docs.astral.sh/uv/", | ||
| docker: "install Docker from https://docs.docker.com/get-docker/" | ||
| }; | ||
| async function buildDoctorModel(deps) { | ||
| const { getAdapter: getAdapter2, getConfigPath: getConfigPath2, checkConfigExists, execCheck } = deps; | ||
| const reads = await Promise.all( | ||
| CLIENT_IDS.map(async (clientId) => { | ||
| const exists = await checkConfigExists(clientId); | ||
| if (!exists) return { clientId, read: { exists: false, malformed: false, servers: null } }; | ||
| try { | ||
| const servers = await getAdapter2(clientId).read(getConfigPath2(clientId)); | ||
| return { clientId, read: { exists: true, malformed: false, servers } }; | ||
| } catch { | ||
| return { clientId, read: { exists: true, malformed: true, servers: null } }; | ||
| } | ||
| }) | ||
| ); | ||
| const issues = []; | ||
| const clients = reads.map(({ clientId, read }) => { | ||
| const label = CLIENT_LABELS[clientId]; | ||
| if (read.malformed) { | ||
| issues.push({ | ||
| kind: "malformed-config", | ||
| message: `Config file for ${label} is malformed \u2014 fix the JSON syntax.` | ||
| }); | ||
| } | ||
| const servers = read.servers ?? {}; | ||
| const entries = Object.values(servers); | ||
| return { | ||
| id: clientId, | ||
| label, | ||
| exists: read.exists, | ||
| malformed: read.malformed, | ||
| serverCount: entries.length, | ||
| guardedCount: entries.filter(isWrapped).length | ||
| }; | ||
| }); | ||
| const runtimes = await Promise.all( | ||
| RUNTIMES.map(async (name) => ({ name, available: await execCheck(name) })) | ||
| ); | ||
| const runtimeAvailable = new Map(runtimes.map((r) => [r.name, r.available])); | ||
| for (const { clientId, read } of reads) { | ||
| if (!read.servers) continue; | ||
| for (const [serverName, entry] of Object.entries(read.servers)) { | ||
| const cmd = entry.command; | ||
| if (!cmd) continue; | ||
| if (RUNTIMES.includes(cmd) && runtimeAvailable.get(cmd) === false) { | ||
| issues.push({ | ||
| kind: "missing-runtime", | ||
| message: `Server '${serverName}' in ${CLIENT_LABELS[clientId]} uses '${cmd}' but ${cmd} is not installed.` | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| const driftStates = reads.flatMap( | ||
| ({ clientId, read }) => read.servers ? [{ clientId, servers: read.servers }] : [] | ||
| ); | ||
| const crossClient = driftStates.length >= 2 ? toCrossClient(driftStates) : null; | ||
| const secrets = reads.flatMap( | ||
| ({ clientId, read }) => read.servers ? scanConfigSecrets(read.servers).map((f) => ({ client: clientId, ...f })) : [] | ||
| ); | ||
| return { | ||
| schemaVersion: 1, | ||
| clients, | ||
| runtimes, | ||
| crossClient, | ||
| secrets, | ||
| issues, | ||
| ok: issues.length === 0 | ||
| }; | ||
| } | ||
| function toCrossClient(states) { | ||
| const drift = buildDriftModel(states); | ||
| const entries = []; | ||
| for (const server of drift.servers) { | ||
| if (server.conflict) { | ||
| entries.push({ | ||
| name: server.name, | ||
| kind: "conflict", | ||
| present: [...server.present], | ||
| absent: [...server.absent], | ||
| fields: server.conflictFields ? [...server.conflictFields] : void 0 | ||
| }); | ||
| } else if (server.absent.length > 0) { | ||
| entries.push({ | ||
| name: server.name, | ||
| kind: "absent", | ||
| present: [...server.present], | ||
| absent: [...server.absent] | ||
| }); | ||
| } | ||
| } | ||
| return { | ||
| consistent: drift.drifted === 0, | ||
| clientCount: drift.clients.length, | ||
| serverCount: drift.servers.length, | ||
| drift: entries | ||
| }; | ||
| } | ||
| function renderDoctorText(model, output) { | ||
| output(""); | ||
| output("mcpm doctor"); | ||
| output(""); | ||
| for (const c of model.clients) { | ||
| if (!c.exists) { | ||
| output(` \u2717 ${c.label} \u2014 config not found`); | ||
| } else if (c.malformed) { | ||
| output(` \u2717 ${c.label} \u2014 config malformed (JSON parse error)`); | ||
| } else { | ||
| const word = c.serverCount === 1 ? "server" : "servers"; | ||
| output(` \u2713 ${c.label} \u2014 config found, ${c.serverCount} ${word}`); | ||
| } | ||
| } | ||
| output(""); | ||
| output("Runtimes:"); | ||
| for (const r of model.runtimes) { | ||
| if (r.available) { | ||
| output(` \u2713 ${r.name} available`); | ||
| } else { | ||
| output(` \u2717 ${r.name} not found \u2014 ${RUNTIME_INSTALL_HINTS[r.name]}`); | ||
| } | ||
| } | ||
| if (model.crossClient) { | ||
| const cc = model.crossClient; | ||
| output(""); | ||
| output("Cross-client (advisory):"); | ||
| if (cc.consistent) { | ||
| const word = cc.serverCount === 1 ? "server" : "servers"; | ||
| output(` \u2713 ${cc.serverCount} ${word} consistent across ${cc.clientCount} clients`); | ||
| } else { | ||
| for (const d of cc.drift) { | ||
| if (d.kind === "conflict") { | ||
| output(` \u26A0 ${d.name} \u2014 config differs (${d.fields.join(", ")}) across ${d.present.join(", ")}`); | ||
| } else { | ||
| output(` \u26A0 ${d.name} \u2014 in ${d.present.join(", ")}; missing in ${d.absent.join(", ")}`); | ||
| } | ||
| } | ||
| output(" Run `mcpm sync --check` for the full matrix (advisory, not a failure)."); | ||
| } | ||
| } | ||
| if (model.secrets.length > 0) { | ||
| output(""); | ||
| output("Plaintext secrets (advisory):"); | ||
| for (const s of model.secrets) { | ||
| output( | ||
| ` \u26A0 ${s.client} \xB7 ${sanitizeForTerminal(s.server)} \xB7 ${s.field} '${sanitizeForTerminal(s.key)}' \u2014 ${s.label}` | ||
| ); | ||
| } | ||
| if (model.secrets.some((s) => s.field === "env")) { | ||
| output( | ||
| " Move env secrets to the encrypted store: `mcpm secrets set <server> <KEY>` or re-install with `--secrets keychain`." | ||
| ); | ||
| } | ||
| if (model.secrets.some((s) => s.field === "header")) { | ||
| output( | ||
| " Header secrets have no keychain path yet \u2014 rotate the credential and keep it out of committed config." | ||
| ); | ||
| } | ||
| } | ||
| if (model.issues.length > 0) { | ||
| output(""); | ||
| output("Issues:"); | ||
| for (const issue of model.issues) { | ||
| output(` \u26A0 ${issue.message}`); | ||
| } | ||
| output(""); | ||
| output("Critical issues found. Run the commands above to resolve them."); | ||
| return; | ||
| } | ||
| output(""); | ||
| output("No critical issues found."); | ||
| } | ||
| function buildDoctorReport(model, env) { | ||
| return { | ||
| schemaVersion: 1, | ||
| mcpm: env.mcpm, | ||
| node: env.node, | ||
| os: `${env.platform} ${env.arch} ${env.osRelease}`, | ||
| confineBackend: env.confineBackend, | ||
| secretStore: env.secretStore, | ||
| // Redaction: drop the label + every server name; keep only counts. | ||
| clients: model.clients.map(({ id, exists, malformed, serverCount, guardedCount }) => ({ | ||
| id, | ||
| exists, | ||
| malformed, | ||
| serverCount, | ||
| guardedCount | ||
| })), | ||
| runtimes: model.runtimes, | ||
| issues: { | ||
| malformedConfigs: model.issues.filter((i) => i.kind === "malformed-config").length, | ||
| missingRuntime: model.issues.filter((i) => i.kind === "missing-runtime").length, | ||
| plaintextSecrets: model.secrets.length | ||
| } | ||
| }; | ||
| } | ||
| function renderReportText(r) { | ||
| const lines = []; | ||
| lines.push("mcpm doctor --report (redacted \u2014 no server names or args)"); | ||
| lines.push(`mcpm: ${r.mcpm}`); | ||
| lines.push(`node: ${r.node}`); | ||
| lines.push(`os: ${r.os}`); | ||
| lines.push(`confine backend: ${r.confineBackend ? "available" : "unavailable"}`); | ||
| lines.push(`secret store: ${r.secretStore}`); | ||
| lines.push(""); | ||
| lines.push("clients:"); | ||
| for (const c of r.clients) { | ||
| if (!c.exists) { | ||
| lines.push(` ${c.id}: not found`); | ||
| } else if (c.malformed) { | ||
| lines.push(` ${c.id}: config malformed`); | ||
| } else { | ||
| const guarded = c.guardedCount > 0 ? `, ${c.guardedCount} guarded` : ""; | ||
| lines.push(` ${c.id}: ${c.serverCount} servers${guarded}`); | ||
| } | ||
| } | ||
| lines.push("runtimes:"); | ||
| for (const rt of r.runtimes) { | ||
| lines.push(` ${rt.name}: ${rt.available ? "available" : "missing"}`); | ||
| } | ||
| lines.push( | ||
| `issues: ${r.issues.malformedConfigs} malformed config(s), ${r.issues.missingRuntime} missing-runtime, ${r.issues.plaintextSecrets} plaintext secret(s)` | ||
| ); | ||
| return lines.join("\n"); | ||
| } | ||
| async function doctorHandler(deps, opts = {}) { | ||
| const model = await buildDoctorModel(deps); | ||
| if (opts.report) { | ||
| const env = opts.reportEnv ?? gatherReportEnv(); | ||
| deps.output(renderReportText(buildDoctorReport(model, env))); | ||
| } else if (opts.json) { | ||
| deps.output(JSON.stringify(model, null, 2)); | ||
| } else { | ||
| renderDoctorText(model, deps.output); | ||
| } | ||
| return model.ok ? 0 : 1; | ||
| } | ||
| function makeCheckConfigExists(getConfigPathFn) { | ||
| return async (clientId) => { | ||
| try { | ||
| await access(getConfigPathFn(clientId)); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| }; | ||
| } | ||
| var checkConfigExistsDefault = makeCheckConfigExists(getConfigPath); | ||
| var ALLOWED_RUNTIME_CMDS = /* @__PURE__ */ new Set(["npx", "uvx", "docker"]); | ||
| function execCheckDefault(cmd) { | ||
| if (!ALLOWED_RUNTIME_CMDS.has(cmd)) return Promise.resolve(false); | ||
| return new Promise((resolve) => { | ||
| const which = process.platform === "win32" ? "where" : "which"; | ||
| execFile(which, [cmd], (err) => { | ||
| resolve(err === null); | ||
| }); | ||
| }); | ||
| } | ||
| function gatherReportEnv() { | ||
| return { | ||
| mcpm: "0.27.0", | ||
| node: process.version, | ||
| platform: process.platform, | ||
| arch: process.arch, | ||
| osRelease: os.release(), | ||
| confineBackend: isConfineBackendAvailable(), | ||
| secretStore: isSupportedPlatform() ? "os-keychain" : "machine-key" | ||
| }; | ||
| } | ||
| function registerDoctorCommand(program) { | ||
| program.command("doctor").description("Check MCP setup health and report issues").option("--json", "emit the structured DoctorModel as JSON (shape UNSTABLE; NOT redacted \u2014 includes server names, use --report to share publicly)").option("--report", "emit a redacted, pasteable env snapshot for bug reports (no server names/args)").action(async (options) => { | ||
| const plain = options.json || options.report; | ||
| const deps = { | ||
| getAdapter, | ||
| getConfigPath, | ||
| checkConfigExists: checkConfigExistsDefault, | ||
| execCheck: execCheckDefault, | ||
| output: plain ? (t) => console.log(t) : coloredOutput | ||
| }; | ||
| const exitCode = await doctorHandler(deps, { json: options.json, report: options.report }); | ||
| process.exit(exitCode); | ||
| }); | ||
| } | ||
| export { | ||
| formatMcpEntryCommand, | ||
| collectClientStates, | ||
| buildDriftModel, | ||
| buildDoctorModel, | ||
| makeCheckConfigExists, | ||
| execCheckDefault, | ||
| registerDoctorCommand | ||
| }; | ||
| //# sourceMappingURL=chunk-2MBO4SX3.js.map |
| {"version":3,"sources":["../src/utils/format-entry.ts","../src/commands/doctor.ts","../src/config/drift.ts","../src/scanner/config-secrets.ts"],"sourcesContent":["/**\n * Shared formatting helpers for McpServerEntry display.\n */\n\nimport type { McpServerEntry } from \"../config/adapters/index.js\";\n\n/**\n * Returns the display string for an MCP server entry's command/URL column.\n *\n * @param entry - The server entry to format.\n * @param fallback - String to return when neither url nor command is present.\n */\nexport function formatMcpEntryCommand(\n entry: McpServerEntry,\n fallback = \"\\u2014\"\n): string {\n if (entry.url) return entry.url;\n if (entry.command) {\n const args = entry.args?.join(\" \") ?? \"\";\n return args ? `${entry.command} ${args}` : entry.command;\n }\n return fallback;\n}\n","/**\n * `mcpm doctor` command handler.\n *\n * Checks MCP setup health and reports issues:\n * - Which AI clients have config files\n * - Whether config files are valid JSON\n * - Which runtimes (npx, uvx, docker) are available\n * - Whether installed servers reference available runtimes\n *\n * Returns 0 for no critical issues, 1 for critical issues.\n * All external dependencies are injected for testability.\n *\n * D7: the check logic is split into a pure `buildDoctorModel` (a structured\n * `DoctorModel`) and renderers. `--json` emits the model; `--report` emits a\n * redacted, name-free env snapshot for bug reports; the MCP-server `handleDoctor`\n * reuses the same model (fixing its formerly-hardcoded `issues: []`).\n */\n\nimport { access } from \"fs/promises\";\nimport type { ClientId } from \"../config/paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"../config/adapters/index.js\";\nimport type { getConfigPath } from \"../config/paths.js\";\nimport { buildDriftModel, type ClientState } from \"../config/drift.js\";\nimport { isWrapped } from \"../guard/wrap.js\";\nimport { scanConfigSecrets, type ConfigSecretFinding } from \"../scanner/config-secrets.js\";\nimport { sanitizeForTerminal } from \"../guard/sanitize.js\";\n\n// ---------------------------------------------------------------------------\n// Deps interface\n// ---------------------------------------------------------------------------\n\nexport interface DoctorDeps {\n getAdapter: (clientId: ClientId) => ConfigAdapter;\n getConfigPath: typeof getConfigPath;\n /** Returns true if the config file exists for this client. */\n checkConfigExists: (clientId: ClientId) => Promise<boolean>;\n /** Returns true if the given executable is available on PATH. */\n execCheck: (cmd: string) => Promise<boolean>;\n output: (text: string) => void;\n}\n\n/** The subset of deps the pure model builder needs (no output, no detector). */\nexport type DoctorModelDeps = Pick<\n DoctorDeps,\n \"getAdapter\" | \"getConfigPath\" | \"checkConfigExists\" | \"execCheck\"\n>;\n\n// ---------------------------------------------------------------------------\n// Structured model (D7 — one shape for text/json/report/MCP consumers)\n// ---------------------------------------------------------------------------\n\nexport interface DoctorClientHealth {\n id: ClientId;\n label: string;\n exists: boolean;\n malformed: boolean;\n serverCount: number;\n /** Servers wrapped by the guard relay (subset of serverCount). */\n guardedCount: number;\n}\n\nexport interface DoctorRuntimeHealth {\n name: Runtime;\n available: boolean;\n}\n\nexport interface DoctorDriftEntry {\n name: string;\n kind: \"conflict\" | \"absent\";\n present: string[];\n absent: string[];\n /** Present only for `kind: \"conflict\"`. */\n fields?: string[];\n}\n\nexport interface DoctorCrossClient {\n consistent: boolean;\n clientCount: number;\n serverCount: number;\n drift: DoctorDriftEntry[];\n}\n\nexport interface DoctorIssue {\n kind: \"malformed-config\" | \"missing-runtime\";\n message: string;\n}\n\nexport interface DoctorSecretFinding {\n client: ClientId;\n server: string;\n field: ConfigSecretFinding[\"field\"];\n /** The env var / header NAME — never the value (F9 redaction contract). */\n key: string;\n label: string;\n}\n\nexport interface DoctorModel {\n schemaVersion: 1;\n clients: DoctorClientHealth[];\n runtimes: DoctorRuntimeHealth[];\n /** Advisory cross-client consistency; null when <2 clients have a readable config. */\n crossClient: DoctorCrossClient | null;\n /** Plaintext secrets in client config — advisory (F9); does NOT affect `ok`/exit. */\n secrets: DoctorSecretFinding[];\n /** Critical issues — these drive the exit code. */\n issues: DoctorIssue[];\n /** true iff issues is empty. */\n ok: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst RUNTIMES = [\"npx\", \"uvx\", \"docker\"] as const;\n\ntype Runtime = (typeof RUNTIMES)[number];\n\nconst CLIENT_LABELS: Record<ClientId, string> = {\n \"claude-desktop\": \"Claude Desktop\",\n \"claude-code\": \"Claude Code\",\n cursor: \"Cursor\",\n vscode: \"VS Code\",\n windsurf: \"Windsurf\",\n \"gemini-cli\": \"Gemini CLI\",\n};\n\nconst RUNTIME_INSTALL_HINTS: Record<Runtime, string> = {\n npx: \"install Node.js from https://nodejs.org\",\n uvx: \"install uv from https://docs.astral.sh/uv/\",\n docker: \"install Docker from https://docs.docker.com/get-docker/\",\n};\n\n// ---------------------------------------------------------------------------\n// Model builder (pure — no output)\n// ---------------------------------------------------------------------------\n\ninterface ClientRead {\n exists: boolean;\n malformed: boolean;\n servers: Record<string, McpServerEntry> | null;\n}\n\n/**\n * Runs every health check and returns the structured model. No side effects\n * beyond the injected reads; safe to call from the CLI, `--json`, `--report`,\n * and the MCP `handleDoctor` tool.\n */\nexport async function buildDoctorModel(deps: DoctorModelDeps): Promise<DoctorModel> {\n const { getAdapter, getConfigPath, checkConfigExists, execCheck } = deps;\n\n // 1. Read each known client's config.\n const reads = await Promise.all(\n CLIENT_IDS.map(async (clientId): Promise<{ clientId: ClientId; read: ClientRead }> => {\n const exists = await checkConfigExists(clientId);\n if (!exists) return { clientId, read: { exists: false, malformed: false, servers: null } };\n try {\n const servers = await getAdapter(clientId).read(getConfigPath(clientId));\n return { clientId, read: { exists: true, malformed: false, servers } };\n } catch {\n return { clientId, read: { exists: true, malformed: true, servers: null } };\n }\n })\n );\n\n const issues: DoctorIssue[] = [];\n\n const clients: DoctorClientHealth[] = reads.map(({ clientId, read }) => {\n const label = CLIENT_LABELS[clientId];\n if (read.malformed) {\n issues.push({\n kind: \"malformed-config\",\n message: `Config file for ${label} is malformed — fix the JSON syntax.`,\n });\n }\n const servers = read.servers ?? {};\n const entries = Object.values(servers);\n return {\n id: clientId,\n label,\n exists: read.exists,\n malformed: read.malformed,\n serverCount: entries.length,\n guardedCount: entries.filter(isWrapped).length,\n };\n });\n\n // 2. Runtime availability.\n const runtimes: DoctorRuntimeHealth[] = await Promise.all(\n RUNTIMES.map(async (name) => ({ name, available: await execCheck(name) }))\n );\n const runtimeAvailable = new Map(runtimes.map((r) => [r.name as string, r.available]));\n\n // 3. Cross-check: servers whose command is a tracked-but-unavailable runtime.\n for (const { clientId, read } of reads) {\n if (!read.servers) continue;\n for (const [serverName, entry] of Object.entries(read.servers)) {\n const cmd = entry.command;\n if (!cmd) continue; // HTTP/URL server — no runtime needed.\n if (RUNTIMES.includes(cmd as Runtime) && runtimeAvailable.get(cmd) === false) {\n issues.push({\n kind: \"missing-runtime\",\n message: `Server '${serverName}' in ${CLIENT_LABELS[clientId]} uses '${cmd}' but ${cmd} is not installed.`,\n });\n }\n }\n }\n\n // 4. Cross-client consistency (advisory — never an issue, never fails doctor).\n const driftStates: ClientState[] = reads.flatMap(({ clientId, read }) =>\n read.servers ? [{ clientId, servers: read.servers }] : []\n );\n const crossClient = driftStates.length >= 2 ? toCrossClient(driftStates) : null;\n\n // 5. Plaintext-secret scan (advisory — never an issue, never fails doctor).\n const secrets: DoctorSecretFinding[] = reads.flatMap(({ clientId, read }) =>\n read.servers ? scanConfigSecrets(read.servers).map((f) => ({ client: clientId, ...f })) : []\n );\n\n return {\n schemaVersion: 1,\n clients,\n runtimes,\n crossClient,\n secrets,\n issues,\n ok: issues.length === 0,\n };\n}\n\nfunction toCrossClient(states: ClientState[]): DoctorCrossClient {\n const drift = buildDriftModel(states);\n const entries: DoctorDriftEntry[] = [];\n for (const server of drift.servers) {\n // buildDriftModel returns readonly arrays — copy into the mutable public model.\n if (server.conflict) {\n entries.push({\n name: server.name,\n kind: \"conflict\",\n present: [...server.present],\n absent: [...server.absent],\n fields: server.conflictFields ? [...server.conflictFields] : undefined,\n });\n } else if (server.absent.length > 0) {\n entries.push({\n name: server.name,\n kind: \"absent\",\n present: [...server.present],\n absent: [...server.absent],\n });\n }\n }\n return {\n consistent: drift.drifted === 0,\n clientCount: drift.clients.length,\n serverCount: drift.servers.length,\n drift: entries,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Human-readable renderer (byte-identical to the pre-D7 output)\n// ---------------------------------------------------------------------------\n\nexport function renderDoctorText(model: DoctorModel, output: (text: string) => void): void {\n output(\"\");\n output(\"mcpm doctor\");\n output(\"\");\n\n for (const c of model.clients) {\n if (!c.exists) {\n output(` ✗ ${c.label} — config not found`);\n } else if (c.malformed) {\n output(` ✗ ${c.label} — config malformed (JSON parse error)`);\n } else {\n const word = c.serverCount === 1 ? \"server\" : \"servers\";\n output(` ✓ ${c.label} — config found, ${c.serverCount} ${word}`);\n }\n }\n\n output(\"\");\n output(\"Runtimes:\");\n for (const r of model.runtimes) {\n if (r.available) {\n output(` ✓ ${r.name} available`);\n } else {\n output(` ✗ ${r.name} not found — ${RUNTIME_INSTALL_HINTS[r.name]}`);\n }\n }\n\n if (model.crossClient) {\n const cc = model.crossClient;\n output(\"\");\n output(\"Cross-client (advisory):\");\n if (cc.consistent) {\n const word = cc.serverCount === 1 ? \"server\" : \"servers\";\n output(` ✓ ${cc.serverCount} ${word} consistent across ${cc.clientCount} clients`);\n } else {\n for (const d of cc.drift) {\n if (d.kind === \"conflict\") {\n output(` ⚠ ${d.name} — config differs (${d.fields!.join(\", \")}) across ${d.present.join(\", \")}`);\n } else {\n output(` ⚠ ${d.name} — in ${d.present.join(\", \")}; missing in ${d.absent.join(\", \")}`);\n }\n }\n output(\" Run `mcpm sync --check` for the full matrix (advisory, not a failure).\");\n }\n }\n\n if (model.secrets.length > 0) {\n output(\"\");\n output(\"Plaintext secrets (advisory):\");\n for (const s of model.secrets) {\n // s.server / s.key are attacker-influenceable (registry env-var names, imported\n // configs) — strip ANSI/OSC so a crafted key can't erase or spoof the advisory.\n output(\n ` ⚠ ${s.client} · ${sanitizeForTerminal(s.server)} · ${s.field} '${sanitizeForTerminal(s.key)}' — ${s.label}`\n );\n }\n // Remediation is field-specific: the keychain/placeholder path is env-only\n // (guard resolves placeholders in env, not headers; HTTP servers aren't wrapped).\n if (model.secrets.some((s) => s.field === \"env\")) {\n output(\n \" Move env secrets to the encrypted store: `mcpm secrets set <server> <KEY>` or re-install with `--secrets keychain`.\"\n );\n }\n if (model.secrets.some((s) => s.field === \"header\")) {\n output(\n \" Header secrets have no keychain path yet — rotate the credential and keep it out of committed config.\"\n );\n }\n }\n\n if (model.issues.length > 0) {\n output(\"\");\n output(\"Issues:\");\n for (const issue of model.issues) {\n output(` ⚠ ${issue.message}`);\n }\n output(\"\");\n output(\"Critical issues found. Run the commands above to resolve them.\");\n return;\n }\n\n output(\"\");\n output(\"No critical issues found.\");\n}\n\n// ---------------------------------------------------------------------------\n// Redacted report (D7 — pasteable env snapshot, NO server names/args)\n// ---------------------------------------------------------------------------\n\nexport interface DoctorReportEnv {\n mcpm: string;\n node: string;\n platform: string;\n arch: string;\n osRelease: string;\n confineBackend: boolean;\n secretStore: \"os-keychain\" | \"machine-key\";\n}\n\nexport interface DoctorReport {\n schemaVersion: 1;\n mcpm: string;\n node: string;\n os: string;\n confineBackend: boolean;\n secretStore: \"os-keychain\" | \"machine-key\";\n clients: Array<Omit<DoctorClientHealth, \"label\">>;\n runtimes: DoctorRuntimeHealth[];\n /** Counts only — issue messages + secret keys embed server names, so NOT included. */\n issues: { malformedConfigs: number; missingRuntime: number; plaintextSecrets: number };\n}\n\nexport function buildDoctorReport(model: DoctorModel, env: DoctorReportEnv): DoctorReport {\n return {\n schemaVersion: 1,\n mcpm: env.mcpm,\n node: env.node,\n os: `${env.platform} ${env.arch} ${env.osRelease}`,\n confineBackend: env.confineBackend,\n secretStore: env.secretStore,\n // Redaction: drop the label + every server name; keep only counts.\n clients: model.clients.map(({ id, exists, malformed, serverCount, guardedCount }) => ({\n id,\n exists,\n malformed,\n serverCount,\n guardedCount,\n })),\n runtimes: model.runtimes,\n issues: {\n malformedConfigs: model.issues.filter((i) => i.kind === \"malformed-config\").length,\n missingRuntime: model.issues.filter((i) => i.kind === \"missing-runtime\").length,\n plaintextSecrets: model.secrets.length,\n },\n };\n}\n\nexport function renderReportText(r: DoctorReport): string {\n const lines: string[] = [];\n lines.push(\"mcpm doctor --report (redacted — no server names or args)\");\n lines.push(`mcpm: ${r.mcpm}`);\n lines.push(`node: ${r.node}`);\n lines.push(`os: ${r.os}`);\n lines.push(`confine backend: ${r.confineBackend ? \"available\" : \"unavailable\"}`);\n lines.push(`secret store: ${r.secretStore}`);\n lines.push(\"\");\n lines.push(\"clients:\");\n for (const c of r.clients) {\n if (!c.exists) {\n lines.push(` ${c.id}: not found`);\n } else if (c.malformed) {\n lines.push(` ${c.id}: config malformed`);\n } else {\n const guarded = c.guardedCount > 0 ? `, ${c.guardedCount} guarded` : \"\";\n lines.push(` ${c.id}: ${c.serverCount} servers${guarded}`);\n }\n }\n lines.push(\"runtimes:\");\n for (const rt of r.runtimes) {\n lines.push(` ${rt.name}: ${rt.available ? \"available\" : \"missing\"}`);\n }\n lines.push(\n `issues: ${r.issues.malformedConfigs} malformed config(s), ${r.issues.missingRuntime} missing-runtime, ${r.issues.plaintextSecrets} plaintext secret(s)`\n );\n return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Handler\n// ---------------------------------------------------------------------------\n\nexport interface DoctorOpts {\n json?: boolean;\n report?: boolean;\n /** Injected in --report mode; the Commander action supplies the real env. */\n reportEnv?: DoctorReportEnv;\n}\n\n/**\n * Core logic for `mcpm doctor`.\n * @returns Exit code: 0 = healthy, 1 = critical issues found.\n */\nexport async function doctorHandler(deps: DoctorDeps, opts: DoctorOpts = {}): Promise<number> {\n const model = await buildDoctorModel(deps);\n\n if (opts.report) {\n const env = opts.reportEnv ?? gatherReportEnv();\n deps.output(renderReportText(buildDoctorReport(model, env)));\n } else if (opts.json) {\n deps.output(JSON.stringify(model, null, 2));\n } else {\n renderDoctorText(model, deps.output);\n }\n\n return model.ok ? 0 : 1;\n}\n\n// ---------------------------------------------------------------------------\n// Commander registration\n// ---------------------------------------------------------------------------\n\nimport { Command } from \"commander\";\nimport os from \"os\";\nimport { execFile } from \"child_process\";\nimport { getConfigPath as _getConfigPath, CLIENT_IDS } from \"../config/paths.js\";\nimport { getAdapter as getAdapterDefault } from \"../config/index.js\";\nimport { coloredOutput } from \"../utils/output.js\";\nimport { isConfineBackendAvailable } from \"../guard/confine/apply.js\";\nimport { isSupportedPlatform as isKeychainSupported } from \"../store/os-keychain.js\";\n\n/** Factory so callers that inject a custom getConfigPath (e.g. the MCP server) get honored. */\nexport function makeCheckConfigExists(\n getConfigPathFn: (clientId: ClientId) => string\n): (clientId: ClientId) => Promise<boolean> {\n return async (clientId: ClientId): Promise<boolean> => {\n try {\n await access(getConfigPathFn(clientId));\n return true;\n } catch {\n return false;\n }\n };\n}\n\nconst checkConfigExistsDefault = makeCheckConfigExists(_getConfigPath);\n\nconst ALLOWED_RUNTIME_CMDS = new Set<string>([\"npx\", \"uvx\", \"docker\"]);\n\nexport function execCheckDefault(cmd: string): Promise<boolean> {\n if (!ALLOWED_RUNTIME_CMDS.has(cmd)) return Promise.resolve(false);\n return new Promise((resolve) => {\n const which = process.platform === \"win32\" ? \"where\" : \"which\";\n execFile(which, [cmd], (err) => {\n resolve(err === null);\n });\n });\n}\n\n/** Gathers the impure environment fields for `--report`. */\nfunction gatherReportEnv(): DoctorReportEnv {\n return {\n mcpm: __PKG_VERSION__,\n node: process.version,\n platform: process.platform,\n arch: process.arch,\n osRelease: os.release(),\n confineBackend: isConfineBackendAvailable(),\n secretStore: isKeychainSupported() ? \"os-keychain\" : \"machine-key\",\n };\n}\n\nexport function registerDoctorCommand(program: Command): void {\n program\n .command(\"doctor\")\n .description(\"Check MCP setup health and report issues\")\n .option(\"--json\", \"emit the structured DoctorModel as JSON (shape UNSTABLE; NOT redacted — includes server names, use --report to share publicly)\")\n .option(\"--report\", \"emit a redacted, pasteable env snapshot for bug reports (no server names/args)\")\n .action(async (options: { json?: boolean; report?: boolean }) => {\n // --json / --report are machine/paste output — never colorize.\n const plain = options.json || options.report;\n const deps: DoctorDeps = {\n getAdapter: getAdapterDefault,\n getConfigPath: _getConfigPath,\n checkConfigExists: checkConfigExistsDefault,\n execCheck: execCheckDefault,\n output: plain ? (t) => console.log(t) : coloredOutput,\n };\n\n const exitCode = await doctorHandler(deps, { json: options.json, report: options.report });\n process.exit(exitCode);\n });\n}\n","/**\n * Cross-client config-drift model (pure, injectable).\n *\n * `mcpm diff` answers \"installed vs declared stack\" in ONE direction. This module\n * answers the symmetric N-client question: for every server name, which clients\n * have it, which are missing it, and do the clients that DO have it agree on the\n * server's shape? It is the shared core behind `mcpm sync --check` and the doctor\n * \"Cross-client\" section.\n *\n * Design notes:\n * - Read-only. No writes, no registry/lock/network — it only reads client configs\n * (the collect loop mirrors diff.ts:76-93 / export.ts).\n * - `buildDriftModel` is pure and takes already-collected `ClientState[]` so the\n * doctor command can feed it the reads it already did (no double I/O).\n * - Conflict comparison is over command + ordered args + env KEY set + url +\n * header KEY set. It NEVER compares env / header VALUES — those are secrets, and\n * two clients legitimately hold the same key with a per-machine value.\n *\n * Exports: DriftDeps, ClientState, ServerDrift, DriftModel, collectClientStates,\n * buildDriftModel.\n */\n\nimport type { ClientId } from \"./paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"./adapters/index.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface DriftDeps {\n detectClients: () => Promise<ClientId[]>;\n getAdapter: (clientId: ClientId) => Pick<ConfigAdapter, \"read\">;\n getPath: (clientId: ClientId) => string;\n}\n\n/** A single client's full set of MCP server entries (one successful read). */\nexport interface ClientState {\n readonly clientId: ClientId;\n readonly servers: Record<string, McpServerEntry>;\n}\n\nexport interface ServerDrift {\n readonly name: string;\n /** Clients (with readable configs) that declare this server. */\n readonly present: readonly ClientId[];\n /** Clients (with readable configs) that lack this server. */\n readonly absent: readonly ClientId[];\n /** True when the `present` clients disagree on the server's shape. */\n readonly conflict: boolean;\n /** Which fields diverge among the `present` clients (only when conflict). */\n readonly conflictFields?: readonly string[];\n}\n\nexport interface DriftModel {\n /** Clients considered — those whose config was readable. Sorted. */\n readonly clients: readonly ClientId[];\n /** One entry per distinct server name, sorted by name. */\n readonly servers: readonly ServerDrift[];\n /** Servers present in every considered client with no shape conflict. */\n readonly inSync: number;\n /** Servers with at least one absence or a shape conflict. */\n readonly drifted: number;\n}\n\n// ---------------------------------------------------------------------------\n// Collection (I/O)\n// ---------------------------------------------------------------------------\n\n/**\n * Read each detected client's config into a `ClientState`. Clients whose config\n * is unreadable (missing / malformed) are skipped — never throws — so a single\n * broken config can't blind the whole cross-client view (same posture as\n * `diff` / `export`).\n */\nexport async function collectClientStates(deps: DriftDeps): Promise<ClientState[]> {\n const clients = await deps.detectClients();\n const states: ClientState[] = [];\n for (const clientId of clients) {\n try {\n const servers = await deps.getAdapter(clientId).read(deps.getPath(clientId));\n states.push({ clientId, servers });\n } catch {\n // Skip unreadable clients (missing or malformed config).\n }\n }\n return states;\n}\n\n// ---------------------------------------------------------------------------\n// Drift model (pure)\n// ---------------------------------------------------------------------------\n\n/**\n * Per-field canonical projection used for conflict detection. Each value is a\n * stable string; two entries conflict on a field iff their projected strings\n * differ. Deliberately excludes env / header VALUES (secrets) and the per-client\n * `disabled` flag (an intentional per-client toggle, not a definition drift).\n */\nfunction fieldProjection(entry: McpServerEntry): Record<string, string> {\n return {\n command: entry.command ?? \"\",\n args: JSON.stringify(entry.args ?? []),\n \"env keys\": JSON.stringify(Object.keys(entry.env ?? {}).sort()),\n url: entry.url ?? \"\",\n \"header keys\": JSON.stringify(Object.keys(entry.headers ?? {}).sort()),\n };\n}\n\nconst COMPARED_FIELDS = [\"command\", \"args\", \"env keys\", \"url\", \"header keys\"] as const;\n\n/** Fields on which the given entries (≥1) disagree. Empty ⇒ all identical. */\nfunction divergingFields(entries: readonly McpServerEntry[]): string[] {\n const projections = entries.map(fieldProjection);\n return COMPARED_FIELDS.filter((field) => {\n const distinct = new Set(projections.map((p) => p[field]));\n return distinct.size > 1;\n });\n}\n\nexport function buildDriftModel(states: readonly ClientState[]): DriftModel {\n const clients = states.map((s) => s.clientId).sort();\n\n // Gather, per server name, the clients that declare it and their entries.\n const byName = new Map<string, Array<{ clientId: ClientId; entry: McpServerEntry }>>();\n for (const { clientId, servers } of states) {\n for (const [name, entry] of Object.entries(servers)) {\n const list = byName.get(name) ?? [];\n list.push({ clientId, entry });\n byName.set(name, list);\n }\n }\n\n const servers: ServerDrift[] = [];\n for (const name of [...byName.keys()].sort()) {\n const holders = byName.get(name)!;\n const present = holders.map((h) => h.clientId).sort();\n const presentSet = new Set(present);\n const absent = clients.filter((c) => !presentSet.has(c));\n\n const fields = holders.length > 1 ? divergingFields(holders.map((h) => h.entry)) : [];\n const conflict = fields.length > 0;\n\n servers.push({\n name,\n present,\n absent,\n conflict,\n ...(conflict ? { conflictFields: fields } : {}),\n });\n }\n\n const drifted = servers.filter((s) => s.absent.length > 0 || s.conflict).length;\n return { clients, servers, inSync: servers.length - drifted, drifted };\n}\n","/**\n * Plaintext-secret scan over client MCP config (F9 · PR1).\n *\n * mcpm ships an encrypted secret store + OS keychain, but a server's env/header\n * values are routinely pasted in plaintext (24k+ such leaks documented in the\n * wild). This read-only scan flags them so `doctor` can nudge the user toward\n * `mcpm secrets` / keychain mode.\n *\n * REDACTION CONTRACT: a finding carries the KEY name and a LABEL only — NEVER the\n * matched value. Values already stored as `mcpm:keychain:` placeholders are\n * skipped (they are the safe state, not a leak).\n *\n * Two detectors:\n * 1. value-shape — the sweep-hardened `detectSecretLabels` patterns (AWS /\n * GitHub / OpenAI / … keys). Near-zero false positives.\n * 2. secret-named key — a tight key-name heuristic for generic passwords/tokens\n * no value-regex matches, gated by strong non-secret-qualifier (URL/ID/NAME/…)\n * and non-secret-value (reference/URL/path/flag) exclusions + a benign corpus.\n *\n * Pure: no I/O. The caller (doctor) supplies the already-read config.\n */\n\nimport type { McpServerEntry } from \"../config/adapters/index.js\";\nimport { detectSecretLabels } from \"./patterns.js\";\nimport { parsePlaceholder } from \"../store/keychain.js\";\n\nexport interface ConfigSecretFinding {\n /** Server name as it appears in the client config. */\n server: string;\n /** Which value map the secret sits in. */\n field: \"env\" | \"header\";\n /** The env var / header NAME. Never the value. */\n key: string;\n /** What was matched (e.g. \"AWS access key\"). Never the value. */\n label: string;\n}\n\n/** Label for a key-heuristic hit (detector 2). Value-free by construction. */\nconst GENERIC_LABEL = \"secret-named key holds a plaintext value\";\n\n// Secret-indicating whole words. Matched against the key normalized to\n// upper-case with '-'→'_' (so `X-API-Key` reads as `X_API_KEY`). Bare `KEY` is\n// deliberately NOT a word (PUBLIC_KEY / KEY_ID / SORT_KEY are not secrets) — only\n// the listed `*_KEY` compounds count.\nconst SECRET_KEY_RE =\n /(?:^|_)(?:PASSWORD|PASSWD|PASSPHRASE|SECRET|TOKEN|PAT|APIKEY|AUTHORIZATION|CREDENTIALS?|(?:API|ACCESS|PRIVATE|SECRET|SESSION|SIGNING|ENCRYPTION)_KEY)(?:_|$)/;\n\n// Tokens that mean the field is a descriptor of a secret, not the secret itself\n// (an id, url, name, endpoint, …). Any one vetoes a key-name match, so\n// `TOKEN_URL` / `AWS_ACCESS_KEY_ID` / `SECRET_NAME` / `PUBLIC_KEY` do not fire.\n// KNOWN GAP (advisory tool, accepted): the veto matches a qualifier ANYWHERE in the\n// key, so `ID_TOKEN` (where `ID` is the credential TYPE, not a descriptor) is missed.\n// A suffix-anchored fix would newly false-POSITIVE on `MAPBOX_PUBLIC_TOKEN`; since a\n// false negative in an advisory scan is acceptable but a false positive is not, we\n// keep the anywhere-match.\nconst NON_SECRET_QUALIFIER_RE =\n /(?:^|_)(?:URL|URI|ENDPOINT|HOST|PORT|ID|NAME|PATH|FILE|DIR|ENABLED|DISABLED|TYPE|MODE|REGION|TIMEOUT|VERSION|PUBLIC|FORMAT|HEADER|PREFIX|SUFFIX|COUNT|SIZE|TTL|EXPIRY|EXPIRES|ISSUER|AUDIENCE|ALGORITHM|ALG|SCOPE|METHOD)(?:_|$)/;\n\nfunction normalizeKey(key: string): string {\n return key.toUpperCase().replace(/-/g, \"_\");\n}\n\nfunction keyLooksSecret(key: string): boolean {\n const k = normalizeKey(key);\n return SECRET_KEY_RE.test(k) && !NON_SECRET_QUALIFIER_RE.test(k);\n}\n\n/** True when the value is plausibly a real plaintext secret (not a ref/URL/flag). */\nfunction valueLooksPlaintextSecret(value: string): boolean {\n const v = value.trim();\n if (v.length < 6) return false; // too short to be a credential\n if (parsePlaceholder(value) !== null) return false; // mcpm keychain placeholder\n // Reference, not a literal secret. `${...}` is matched ANYWHERE (not just leading):\n // `Bearer ${input:key}` / `Bearer ${env:VAR}` is VS Code / Cursor / Claude Code's\n // documented header idiom — the recommended SAFE state. Detector 1 already ran on\n // the raw value, so a shaped credential embedded alongside a ref is still caught.\n if (/\\$\\{[^}]*\\}/.test(v)) return false; // ${VAR} template (embedded or leading)\n if (/^\\$[A-Za-z_]/.test(v)) return false; // leading $VAR reference\n if (/^%[A-Za-z_][A-Za-z0-9_]*%([\\\\/].*)?$/.test(v)) return false; // %VAR% ref or %VAR%-rooted path\n // A URI of ANY scheme: real endpoints AND secret-manager references that are the\n // safe state — op:// (1Password), vault:// (Vault). ACCEPTED FALSE-NEGATIVE: a URI\n // that itself CARRIES a credential (connection-string userinfo postgres://u:p@host,\n // or a query-param secret like otpauth://…?secret=SEED) is excluded too. Detector 1\n // still catches any prefix-shaped credential embedded in the value, and the bare\n // (non-URI) secret form is still caught by detector 2. Zero-FP is the hard invariant;\n // re-catching these would need query-param parsing that risks FPs on real endpoints.\n if (/^[a-z][a-z0-9+.-]*:\\/\\//i.test(v)) return false;\n // Filesystem path — POSIX (~ . /) or Windows (drive-letter, UNC).\n if (/^[~./]/.test(v) || /^[A-Za-z]:[\\\\/]/.test(v) || /^\\\\\\\\/.test(v)) return false;\n if (/^(true|false|\\d+)$/i.test(v)) return false; // boolean / plain number\n return true;\n}\n\nfunction scanMap(\n server: string,\n field: \"env\" | \"header\",\n map: Record<string, string> | undefined\n): ConfigSecretFinding[] {\n if (!map) return [];\n const out: ConfigSecretFinding[] = [];\n for (const [key, value] of Object.entries(map)) {\n if (typeof value !== \"string\") continue;\n if (parsePlaceholder(value) !== null) continue; // already stored safely — not a leak\n const labels = detectSecretLabels(value);\n if (labels.length > 0) {\n // Value-shape is the more specific, higher-confidence signal — ONE finding per\n // (field, key) even when several patterns match (e.g. a Bearer-wrapped ghp_\n // token hits both), so the --report count is not inflated. Skip the heuristic.\n out.push({ server, field, key, label: labels.join(\", \") });\n continue;\n }\n if (keyLooksSecret(key) && valueLooksPlaintextSecret(value)) {\n out.push({ server, field, key, label: GENERIC_LABEL });\n }\n }\n return out;\n}\n\n/** Scan one server's env + headers for plaintext secrets. */\nexport function scanServerConfigSecrets(\n server: string,\n entry: McpServerEntry\n): ConfigSecretFinding[] {\n return [...scanMap(server, \"env\", entry.env), ...scanMap(server, \"header\", entry.headers)];\n}\n\n/** Scan every server in a client's config. */\nexport function scanConfigSecrets(\n servers: Record<string, McpServerEntry>\n): ConfigSecretFinding[] {\n return Object.entries(servers).flatMap(([name, entry]) => scanServerConfigSecrets(name, entry));\n}\n"],"mappings":"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","names":["servers","getAdapter","getConfigPath"]} |
| #!/usr/bin/env node | ||
| import { | ||
| OWASP_MCP_TOP_10 | ||
| } from "./chunk-MXHNRCQI.js"; | ||
| import { | ||
| ACTION_RANK, | ||
| defaultActionForFinding, | ||
| inspectMessage, | ||
| normalizeForMatch | ||
| } from "./chunk-62744DB3.js"; | ||
| // src/guard/exfil-names.ts | ||
| var EXFIL_PARAM_DENY = [ | ||
| /^_system_prompt_$/, | ||
| /^_conversation_history_$/, | ||
| /^_chat_history_$/, | ||
| /^_chain_of_thought_$/, | ||
| /^_reasoning_trace_$/, | ||
| /^_(?:full_)?context_window_$/, | ||
| /^_exfil(?:trate)?(?:_[a-z0-9]+)*_$/ | ||
| ]; | ||
| function canonicalize(rawKey) { | ||
| const camelSplit = rawKey.replace(/([a-z0-9])([A-Z])/g, "$1_$2"); | ||
| return normalizeForMatch(camelSplit).toLowerCase().replace(/[\s-]+/g, "_").replace(/_{2,}/g, "_"); | ||
| } | ||
| function classifyParamName(rawKey) { | ||
| const canonical = canonicalize(rawKey); | ||
| return EXFIL_PARAM_DENY.some((re) => re.test(canonical)) ? "deny" : null; | ||
| } | ||
| // src/guard/exfil-params.ts | ||
| var EXFIL_PARAM_SIGNATURE_ID = "exfil-param-in-schema"; | ||
| var MAX_EXCERPT = 200; | ||
| var PASS = { action: "pass", findings: [] }; | ||
| var REMEDIATION = "A tool's input schema declares a parameter named like a context-exfiltration sigil (e.g. `_system_prompt_`) that the model would silently auto-fill from the conversation / system prompt \u2014 a zero-interaction prompt leak. No legitimate tool names a parameter this way. The server's ENTIRE tools/list was blocked before the agent saw it. This is a tripwire for the documented underscore-sigil convention \u2014 a renamed parameter evades it. If you trust this server, mute via `mcpm guard mute exfil-param-in-schema` (re-enables the whole server)."; | ||
| function truncate(s) { | ||
| return s.length > MAX_EXCERPT ? `${s.slice(0, MAX_EXCERPT)}\u2026` : s; | ||
| } | ||
| function* exfilKeys(schema, depth) { | ||
| if (depth > 1 || schema === null || typeof schema !== "object") return; | ||
| const props = schema.properties; | ||
| if (props === null || typeof props !== "object" || Array.isArray(props)) return; | ||
| for (const key of Object.keys(props)) { | ||
| if (!Object.hasOwn(props, key)) continue; | ||
| if (classifyParamName(key) === "deny") yield key; | ||
| yield* exfilKeys(props[key], depth + 1); | ||
| } | ||
| } | ||
| function makeFinding(toolName, rawKey) { | ||
| return { | ||
| signature_id: EXFIL_PARAM_SIGNATURE_ID, | ||
| category: "OWASP-MCP-1", | ||
| severity: "critical", | ||
| target: "tool_description", | ||
| // block-capable carrier (NOT in WARN_ONLY_TARGETS) | ||
| matched_text_excerpt: truncate(`parameter "${rawKey}" in tool "${toolName}"`), | ||
| remediation: REMEDIATION | ||
| }; | ||
| } | ||
| function detectExfilParams(msg) { | ||
| if (!("result" in msg)) return PASS; | ||
| const tools = msg.result?.tools; | ||
| if (!Array.isArray(tools)) return PASS; | ||
| const findings = []; | ||
| for (const tool of tools) { | ||
| if (tool === null || typeof tool !== "object") continue; | ||
| const rawName = tool.name; | ||
| const toolName = typeof rawName === "string" ? rawName : "<unnamed>"; | ||
| for (const key of exfilKeys(tool.inputSchema, 0)) { | ||
| findings.push(makeFinding(toolName, key)); | ||
| } | ||
| } | ||
| if (findings.length === 0) return PASS; | ||
| const action = findings.reduce((acc, f) => { | ||
| const a = defaultActionForFinding(f); | ||
| return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc; | ||
| }, "pass"); | ||
| return { action, findings }; | ||
| } | ||
| // src/guard/inspect-frame.ts | ||
| function withReplyToOrigin(result, replyToOrigin) { | ||
| if (replyToOrigin && result.action === "block") return { ...result, replyToOrigin: true }; | ||
| return result; | ||
| } | ||
| function mergeInspect(a, b) { | ||
| const action = ACTION_RANK[a.action] >= ACTION_RANK[b.action] ? a.action : b.action; | ||
| return withReplyToOrigin( | ||
| { action, findings: [...a.findings, ...b.findings] }, | ||
| a.replyToOrigin === true || b.replyToOrigin === true | ||
| ); | ||
| } | ||
| function hasToolsList(msg) { | ||
| if (!("result" in msg)) return false; | ||
| const result = msg.result; | ||
| return Array.isArray(result?.tools); | ||
| } | ||
| function isServerInitiatedMethod(msg) { | ||
| if (!("method" in msg)) return false; | ||
| const m = msg.method; | ||
| return m === "sampling/createMessage" || m === "elicitation/create"; | ||
| } | ||
| function serverInitiatedContent(msg) { | ||
| const params = msg.params; | ||
| if (params === null || typeof params !== "object") return []; | ||
| const p = params; | ||
| const out = []; | ||
| if (typeof p.systemPrompt === "string") out.push(p.systemPrompt); | ||
| if (Array.isArray(p.messages)) { | ||
| for (const m of p.messages) { | ||
| if (m !== null && typeof m === "object" && "content" in m) out.push(m.content); | ||
| } | ||
| } | ||
| if (typeof p.message === "string") out.push(p.message); | ||
| if (p.requestedSchema !== null && typeof p.requestedSchema === "object") out.push(p.requestedSchema); | ||
| return out; | ||
| } | ||
| function inspectServerInitiated(msg) { | ||
| if (!isServerInitiatedMethod(msg)) return null; | ||
| const contentLeaves = serverInitiatedContent(msg); | ||
| if (contentLeaves.length === 0) return null; | ||
| const synthetic = { | ||
| jsonrpc: "2.0", | ||
| id: 0, | ||
| // dummy — the scan reads only the result subtree, never the id. | ||
| result: { messages: contentLeaves.map((c) => ({ role: "user", content: c })) } | ||
| }; | ||
| const scan = inspectMessage(synthetic, OWASP_MCP_TOP_10); | ||
| if (scan.findings.length === 0) return null; | ||
| const findings = scan.findings.map((f) => ({ ...f, target: "sampling_prompt" })); | ||
| const action = findings.reduce((acc, f) => { | ||
| const a = defaultActionForFinding(f); | ||
| return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc; | ||
| }, "pass"); | ||
| const hasId = "id" in msg && msg.id !== void 0; | ||
| return action === "block" && hasId ? { action, findings, replyToOrigin: true } : { action, findings }; | ||
| } | ||
| function inspectFrame(msg) { | ||
| const serverInitiated = inspectServerInitiated(msg); | ||
| if (serverInitiated !== null) return serverInitiated; | ||
| return mergeInspect(inspectMessage(msg, OWASP_MCP_TOP_10), detectExfilParams(msg)); | ||
| } | ||
| export { | ||
| withReplyToOrigin, | ||
| mergeInspect, | ||
| hasToolsList, | ||
| inspectFrame | ||
| }; | ||
| //# sourceMappingURL=chunk-74BFQMZZ.js.map |
| {"version":3,"sources":["../src/guard/exfil-names.ts","../src/guard/exfil-params.ts","../src/guard/inspect-frame.ts"],"sourcesContent":["/**\n * F5 — exfil-param name classifier.\n *\n * Tool-poisoning attackers add an input-schema parameter the model silently\n * auto-fills from context — named with the documented underscore-sigil convention\n * (`_system_prompt_`, `_conversation_history_`, `_chain_of_thought_`) so the model\n * treats it as a magic slot and leaks the conversation/system prompt with zero user\n * interaction (HiddenLayer / CyberArk PoCs vs Claude 3.7). The guard's content\n * regex walks string VALUES (`stringLeaves` yields `Object.values`), so it\n * structurally cannot see a parameter KEY — this classifier fills that gap.\n *\n * DENY tier = ZERO-FP only. A match blocks the server's whole `tools/list` at\n * advertisement time, so a false positive bricks the entire server. We therefore\n * deny ONLY the underscore-WRAPPED sigil form (the attacker tell), and ONLY for\n * nouns no legitimate tool wraps:\n * - `_system_prompt_`, `_conversation_history_`, `_chat_history_`,\n * `_chain_of_thought_`, `_reasoning_trace_`, `_(full_)context_window_`,\n * `_exfil*` / `_exfiltrate*` verbs.\n * DELIBERATELY EXCLUDED (a legit tool/framework genuinely uses these, so they are\n * the deferred SUSPECT tier, never DENY):\n * - bare unwrapped `system_prompt` / `messages` / `reasoning` (real tool inputs);\n * - `_context_` and `_memory_` (agent frameworks — LangGraph `_context`,\n * mem0/letta `_memory` — inject these as runtime slots);\n * - `_thinking_` (reasoning-trace framework slot; `_chain_of_thought_` already\n * covers the malicious CoT intent).\n *\n * HONEST SCOPE: this is a tripwire for the documented underscore-sigil convention,\n * NOT a general context-exfil defense — a renamed parameter (`systemPrompt`,\n * `sys_prompt`, `context_dump`) evades it.\n */\n\nimport { normalizeForMatch } from \"./patterns.js\";\n\n// Match against the CANONICAL key (see canonicalize): homoglyph/zero-width folded,\n// camelCase split, lowercased, separator runs collapsed to a single `_`. So\n// `_systemPrompt_`, `__system__prompt__`, `_System-Prompt_` all reduce to\n// `_system_prompt_`. The leading/trailing `_` is the load-bearing FP gate — a bare\n// `system_prompt` (no wrap) never matches.\nconst EXFIL_PARAM_DENY: ReadonlyArray<RegExp> = [\n /^_system_prompt_$/,\n /^_conversation_history_$/,\n /^_chat_history_$/,\n /^_chain_of_thought_$/,\n /^_reasoning_trace_$/,\n /^_(?:full_)?context_window_$/,\n /^_exfil(?:trate)?(?:_[a-z0-9]+)*_$/,\n];\n\nfunction canonicalize(rawKey: string): string {\n // Split camelCase BEFORE folding so `_systemPrompt_` → `_system_Prompt_`.\n const camelSplit = rawKey.replace(/([a-z0-9])([A-Z])/g, \"$1_$2\");\n return normalizeForMatch(camelSplit)\n .toLowerCase()\n .replace(/[\\s-]+/g, \"_\") // hyphens / whitespace → underscore\n .replace(/_{2,}/g, \"_\"); // collapse runs (wrap stays a single `_`)\n}\n\n/** Returns \"deny\" if the parameter name matches the zero-FP exfil-sigil denylist. */\nexport function classifyParamName(rawKey: string): \"deny\" | null {\n const canonical = canonicalize(rawKey);\n return EXFIL_PARAM_DENY.some((re) => re.test(canonical)) ? \"deny\" : null;\n}\n","/**\n * F5 — structural exfil-param detector for the guard relay.\n *\n * Walks the KEYS of each tool's `inputSchema.properties` in a `tools/list` response\n * and blocks the frame when a parameter name matches the zero-FP exfil-sigil\n * denylist (see exfil-names.ts). Runs at advertisement time — BEFORE the model ever\n * sees the tool — so it closes the line-jumping window the content-regex pipeline\n * cannot (that pipeline only walks string values, never property keys).\n *\n * IMPORTANT (blast radius): a block on a `tools/list` frame replaces the WHOLE frame\n * with one JSON-RPC error, so the server's entire tool surface is disabled until the\n * finding is muted — not just the one poisoned tool. That is why the denylist is\n * strictly zero-FP. The finding reuses the block-capable `tool_description` target\n * (critical → block) so it needs no new SignatureTarget wiring.\n */\n\nimport type { JSONRPCMessage } from \"@modelcontextprotocol/sdk/types.js\";\nimport type { InspectFinding, InspectResult } from \"./types.js\";\nimport { ACTION_RANK, defaultActionForFinding } from \"./patterns.js\";\nimport { classifyParamName } from \"./exfil-names.js\";\n\nexport const EXFIL_PARAM_SIGNATURE_ID = \"exfil-param-in-schema\";\n\nconst MAX_EXCERPT = 200;\nconst PASS: InspectResult = { action: \"pass\", findings: [] };\n\nconst REMEDIATION =\n \"A tool's input schema declares a parameter named like a context-exfiltration sigil \" +\n \"(e.g. `_system_prompt_`) that the model would silently auto-fill from the conversation / \" +\n \"system prompt — a zero-interaction prompt leak. No legitimate tool names a parameter this \" +\n \"way. The server's ENTIRE tools/list was blocked before the agent saw it. This is a tripwire \" +\n \"for the documented underscore-sigil convention — a renamed parameter evades it. If you trust \" +\n \"this server, mute via `mcpm guard mute exfil-param-in-schema` (re-enables the whole server).\";\n\nfunction truncate(s: string): string {\n return s.length > MAX_EXCERPT ? `${s.slice(0, MAX_EXCERPT)}…` : s;\n}\n\n/**\n * Yield every property KEY (bounded to top-level + one nested `properties` level)\n * whose name matches the exfil denylist. Walks `.properties` keys ONLY — never enum\n * values (those live in `sub.enum`, an array we never key-walk), so a legitimate\n * string value like `enum: [\"_system_prompt_\"]` is not flagged. `Object.hasOwn`\n * guards against inherited keys. `$ref`/`allOf`/`anyOf` are not resolved in v1 (the\n * local key is still classified; the ref is not followed).\n */\nfunction* exfilKeys(schema: unknown, depth: number): Iterable<string> {\n if (depth > 1 || schema === null || typeof schema !== \"object\") return;\n const props = (schema as { properties?: unknown }).properties;\n if (props === null || typeof props !== \"object\" || Array.isArray(props)) return;\n for (const key of Object.keys(props)) {\n if (!Object.hasOwn(props, key)) continue;\n if (classifyParamName(key) === \"deny\") yield key;\n yield* exfilKeys((props as Record<string, unknown>)[key], depth + 1);\n }\n}\n\nfunction makeFinding(toolName: string, rawKey: string): InspectFinding {\n return {\n signature_id: EXFIL_PARAM_SIGNATURE_ID,\n category: \"OWASP-MCP-1\",\n severity: \"critical\",\n target: \"tool_description\", // block-capable carrier (NOT in WARN_ONLY_TARGETS)\n matched_text_excerpt: truncate(`parameter \"${rawKey}\" in tool \"${toolName}\"`),\n remediation: REMEDIATION,\n };\n}\n\n/**\n * Inspect a `tools/list` response for exfil-sigil parameter names. A no-op (pass)\n * on every non-tools/list frame. Returns block when any tool declares one.\n */\nexport function detectExfilParams(msg: JSONRPCMessage): InspectResult {\n if (!(\"result\" in msg)) return PASS;\n const tools = (msg as { result?: { tools?: unknown } }).result?.tools;\n if (!Array.isArray(tools)) return PASS;\n\n const findings: InspectFinding[] = [];\n for (const tool of tools) {\n if (tool === null || typeof tool !== \"object\") continue;\n const rawName = (tool as { name?: unknown }).name;\n const toolName = typeof rawName === \"string\" ? rawName : \"<unnamed>\";\n for (const key of exfilKeys((tool as { inputSchema?: unknown }).inputSchema, 0)) {\n findings.push(makeFinding(toolName, key));\n }\n }\n if (findings.length === 0) return PASS;\n\n const action = findings.reduce<InspectResult[\"action\"]>((acc, f) => {\n const a = defaultActionForFinding(f);\n return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc;\n }, \"pass\");\n return { action, findings };\n}\n","/**\n * The ONE stateless inspection composition — everything the guard can decide\n * about a single frame without relay state (no pins, no session, no policy).\n *\n * Why this module exists: the relay composed three detectors inline\n * (`inspectMessage` + `detectExfilParams` + `inspectServerInitiated`) while\n * `mcpm guard inspect` and the fixture release-gate each called `inspectMessage`\n * alone. So the PUBLIC scoring seam reported `pass` on frames the relay blocks\n * as critical, for 3 of the 12 catalog signatures — and because\n * `mcptox.test.ts` evaluated fixtures through the same incomplete pipeline, a\n * fixture for one of those signatures would have FAILED the release gate. The\n * corpus was shaped by the hole, and mcp-guardbench (which extracts from that\n * corpus) inherited it. One composition, three consumers, no drift.\n *\n * Deliberately excluded — these need relay state and stay in run-inner:\n * schema/handshake drift (pin store + per-session cache) and policy overrides.\n */\n\nimport type { JSONRPCMessage } from \"@modelcontextprotocol/sdk/types.js\";\nimport { inspectMessage, defaultActionForFinding, ACTION_RANK } from \"./patterns.js\";\nimport { detectExfilParams } from \"./exfil-params.js\";\nimport { OWASP_MCP_TOP_10 } from \"./signatures.js\";\nimport type { InspectFinding, InspectResult } from \"./types.js\";\n\n/**\n * H7: replyToOrigin is only meaningful on a block. A policy that downgrades\n * block→warn/pass must not leave a stranded reply-to-origin flag behind.\n */\nexport function withReplyToOrigin(result: InspectResult, replyToOrigin: boolean): InspectResult {\n if (replyToOrigin && result.action === \"block\") return { ...result, replyToOrigin: true };\n return result;\n}\n\nexport function mergeInspect(a: InspectResult, b: InspectResult): InspectResult {\n // Most-severe action wins; concat findings. Uses the shared ACTION_RANK scale\n // (pass < warn < block) instead of a local duplicate map.\n const action = ACTION_RANK[a.action] >= ACTION_RANK[b.action] ? a.action : b.action;\n // H7: carry replyToOrigin if EITHER side requested it (a server-initiated\n // sampling/elicitation block must not be stranded by merging with a benign\n // pattern/drift result). Only kept on a block action (see withReplyToOrigin).\n return withReplyToOrigin(\n { action, findings: [...a.findings, ...b.findings] },\n a.replyToOrigin === true || b.replyToOrigin === true,\n );\n}\n\nexport function hasToolsList(msg: JSONRPCMessage): boolean {\n if (!(\"result\" in msg)) return false;\n const result = (msg as { result?: { tools?: unknown } }).result;\n return Array.isArray(result?.tools);\n}\n\n/** H7: a server-INITIATED sampling/elicitation method frame (id OR no-id — used\n * for content SCANNING; block-to-origin eligibility separately requires an id). */\nfunction isServerInitiatedMethod(msg: JSONRPCMessage): boolean {\n if (!(\"method\" in msg)) return false;\n const m = (msg as { method?: unknown }).method;\n return m === \"sampling/createMessage\" || m === \"elicitation/create\";\n}\n\n/**\n * Extract the server-authored content leaves to scan from a sampling/elicitation\n * request: sampling → params.systemPrompt + params.messages[*].content;\n * elicitation → params.message plus the requestedSchema property descriptions.\n * Non-object/missing shapes yield an empty list (nothing to scan).\n */\nfunction serverInitiatedContent(msg: JSONRPCMessage): unknown[] {\n const params = (msg as { params?: unknown }).params;\n if (params === null || typeof params !== \"object\") return [];\n const p = params as {\n messages?: unknown;\n message?: unknown;\n requestedSchema?: unknown;\n systemPrompt?: unknown;\n };\n const out: unknown[] = [];\n // systemPrompt is server-authored model context (MCP CreateMessageRequestParams)\n // and the highest-leverage sampling injection surface — scan it (review: HIGH).\n if (typeof p.systemPrompt === \"string\") out.push(p.systemPrompt);\n if (Array.isArray(p.messages)) {\n for (const m of p.messages) {\n if (m !== null && typeof m === \"object\" && \"content\" in m) out.push((m as { content: unknown }).content);\n }\n }\n if (typeof p.message === \"string\") out.push(p.message);\n if (p.requestedSchema !== null && typeof p.requestedSchema === \"object\") out.push(p.requestedSchema);\n return out;\n}\n\n/**\n * H7: inspect a server-INITIATED sampling/elicitation request's server-authored\n * content for prompt-injection. Returns block (+ replyToOrigin when the frame can\n * be error-replied) on a detected injection, else null (benign / out of scope) →\n * caller forwards untouched. We gate the injection CONTENT, not the mechanism.\n *\n * The content is wrapped into a synthetic `prompts/get`-shaped frame so the\n * existing `prompt_content` array-content extraction (H1) scans it WITHOUT a new\n * targetSubtree case. But the findings are then RE-TAGGED to `sampling_prompt`:\n * - `prompt_content` is a WARN_ONLY carrier (retrieved prompts/get data), so\n * leaving the finding on it makes applyPolicy's defaultActionForFinding clamp\n * the block back to WARN whenever guard-policy.yaml has ANY signature_override\n * — silently forwarding the injection (CRITICAL, caught in review).\n * - `sampling_prompt` is NOT warn-only, so the action derives from the finding's\n * native severity (critical→block) and survives applyPolicy unclamped.\n * Content scanning covers BOTH id-bearing requests and no-id (notification-shaped)\n * frames; only an id-bearing block carries replyToOrigin (a no-id frame is still\n * dropped — makeBlockResponse returns null for it — but has no reply channel).\n */\nexport function inspectServerInitiated(msg: JSONRPCMessage): InspectResult | null {\n if (!isServerInitiatedMethod(msg)) return null;\n const contentLeaves = serverInitiatedContent(msg);\n if (contentLeaves.length === 0) return null;\n\n const synthetic = {\n jsonrpc: \"2.0\",\n id: 0, // dummy — the scan reads only the result subtree, never the id.\n result: { messages: contentLeaves.map((c) => ({ role: \"user\", content: c })) },\n } as JSONRPCMessage;\n\n const scan = inspectMessage(synthetic, OWASP_MCP_TOP_10);\n if (scan.findings.length === 0) return null;\n\n const findings: InspectFinding[] = scan.findings.map((f) => ({ ...f, target: \"sampling_prompt\" }));\n const action = findings.reduce<InspectResult[\"action\"]>((acc, f) => {\n const a = defaultActionForFinding(f);\n return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc;\n }, \"pass\");\n\n const hasId = \"id\" in msg && (msg as { id?: unknown }).id !== undefined;\n return action === \"block\" && hasId\n ? { action, findings, replyToOrigin: true }\n : { action, findings };\n}\n\n/**\n * Every stateless verdict the guard can reach for one frame.\n *\n * A server-initiated sampling/elicitation frame SHORT-CIRCUITS, matching the\n * relay: such a frame carries `method`, never `result`, so the pattern and\n * exfil passes would have nothing to inspect anyway.\n */\nexport function inspectFrame(msg: JSONRPCMessage): InspectResult {\n const serverInitiated = inspectServerInitiated(msg);\n if (serverInitiated !== null) return serverInitiated;\n // detectExfilParams self-guards on `result.tools`, so it is a no-op pass on\n // every non-tools/list frame — no caller-side gate needed.\n return mergeInspect(inspectMessage(msg, OWASP_MCP_TOP_10), detectExfilParams(msg));\n}\n"],"mappings":"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","names":[]} |
| #!/usr/bin/env node | ||
| import { | ||
| inspectFrame | ||
| } from "./chunk-74BFQMZZ.js"; | ||
| import "./chunk-MXHNRCQI.js"; | ||
| import { | ||
| sanitizeForTerminal | ||
| } from "./chunk-FEXJHHDM.js"; | ||
| import "./chunk-62744DB3.js"; | ||
| // src/guard/inspect-cli.ts | ||
| var ACTION_RANK = { pass: 0, warn: 1, block: 2 }; | ||
| function parseFrames(rawSource) { | ||
| const source = rawSource.replace(/^\uFEFF/, ""); | ||
| if (source.trim() === "") return []; | ||
| try { | ||
| return [asFrame(JSON.parse(source))]; | ||
| } catch { | ||
| } | ||
| const frames = []; | ||
| for (const line of source.split("\n")) { | ||
| const trimmed = line.trim(); | ||
| if (trimmed === "") continue; | ||
| try { | ||
| frames.push(asFrame(JSON.parse(trimmed))); | ||
| } catch (err) { | ||
| frames.push({ error: err instanceof Error ? err.message : String(err) }); | ||
| } | ||
| } | ||
| return frames; | ||
| } | ||
| function asFrame(value) { | ||
| if (typeof value !== "object" || value === null) { | ||
| return { error: `expected a JSON-RPC object, got ${value === null ? "null" : typeof value}` }; | ||
| } | ||
| if (Array.isArray(value)) { | ||
| return { error: "expected a single JSON-RPC object, got an array (send batch members as separate NDJSON lines)" }; | ||
| } | ||
| return { frame: value }; | ||
| } | ||
| function findingToJson(f) { | ||
| return { | ||
| signature_id: f.signature_id, | ||
| category: f.category, | ||
| severity: f.severity, | ||
| target: f.target, | ||
| matched_text_excerpt: f.matched_text_excerpt, | ||
| remediation: f.remediation, | ||
| ...f.decoded === true ? { decoded: true } : {} | ||
| }; | ||
| } | ||
| function plural(n, word) { | ||
| return `${n} ${word}${n === 1 ? "" : "s"}`; | ||
| } | ||
| function jsonLine(value) { | ||
| return JSON.stringify(value).replace( | ||
| /[\u007F-\u009F\u2028\u2029]/g, | ||
| (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}` | ||
| ); | ||
| } | ||
| function runInspectCommand(opts) { | ||
| const parsed = parseFrames(opts.source); | ||
| const json = opts.json === true; | ||
| let worst = "pass"; | ||
| let errors = 0; | ||
| const tally = { pass: 0, warn: 0, block: 0 }; | ||
| const humanLines = []; | ||
| parsed.forEach((entry, i) => { | ||
| if ("error" in entry) { | ||
| errors += 1; | ||
| if (json) { | ||
| opts.write(`${jsonLine({ action: "error", error: entry.error })} | ||
| `); | ||
| } else { | ||
| humanLines.push(`frame ${i + 1} \u2014 error: ${sanitizeForTerminal(entry.error)}`); | ||
| } | ||
| return; | ||
| } | ||
| const result = inspectFrame(entry.frame); | ||
| tally[result.action] += 1; | ||
| if (ACTION_RANK[result.action] > ACTION_RANK[worst]) worst = result.action; | ||
| if (json) { | ||
| opts.write(`${jsonLine({ action: result.action, findings: result.findings.map(findingToJson) })} | ||
| `); | ||
| return; | ||
| } | ||
| humanLines.push(`frame ${i + 1} \u2014 ${result.action}`); | ||
| for (const f of result.findings) { | ||
| humanLines.push(` ${f.signature_id} \xB7 ${f.severity} \xB7 ${f.target}${f.decoded === true ? " \xB7 decoded" : ""}`); | ||
| humanLines.push(` excerpt: ${sanitizeForTerminal(f.matched_text_excerpt)}`); | ||
| humanLines.push(` fix: ${sanitizeForTerminal(f.remediation)}`); | ||
| } | ||
| }); | ||
| if (!json) { | ||
| if (parsed.length === 0) { | ||
| opts.write("no frames on input\n"); | ||
| } else { | ||
| opts.write(`${humanLines.join("\n")} | ||
| `); | ||
| const parts = [plural(parsed.length, "frame")]; | ||
| for (const a of ["block", "warn", "pass"]) { | ||
| if (tally[a] > 0) parts.push(`${tally[a]} ${a}`); | ||
| } | ||
| if (errors > 0) parts.push(plural(errors, "error")); | ||
| opts.write(`${parts.join(" \xB7 ")} | ||
| `); | ||
| } | ||
| } | ||
| return { action: worst, errors, frames: parsed.length }; | ||
| } | ||
| export { | ||
| runInspectCommand | ||
| }; | ||
| //# sourceMappingURL=inspect-cli-ZJF55JOR.js.map |
| {"version":3,"sources":["../src/guard/inspect-cli.ts"],"sourcesContent":["/**\n * `mcpm guard inspect` — run the guard's signature catalog over MCP JSON-RPC\n * frame(s) offline, with no relay, no wrapped server, and no network.\n *\n * Why this exists as a PUBLIC command (not just an internal function): an\n * external harness — mcp-guardbench, a CI job, a researcher reproducing a\n * finding — needs to ask \"what does mcpm's guard say about this frame?\" without\n * importing `src/guard/*`. Before this command the benchmark's reference adapter\n * vendored an esbuild bundle of patterns+signatures, which (a) silently drifts\n * from the shipped engine and (b) gave mcpm a privileged in-process path that no\n * other guard being scored could have. This command is the level playing field:\n * every guard, mcpm included, is measured through its own published CLI.\n *\n * Contract (depended on by external adapters — treat as semi-stable):\n * - input is ONE JSON frame (pretty-printed is fine) or NDJSON, one per line\n * - `--json` writes exactly one verdict object per input frame, in INPUT\n * ORDER — positional correlation is what lets a harness zip verdicts back\n * to its own case ids without mcpm needing to know about them\n * - an unparseable frame yields `{\"action\":\"error\"}`, never a silent skip and\n * never a fabricated \"pass\" (a harness must be able to tell \"my guard said\n * this is safe\" apart from \"my guard fell over\")\n *\n * The verdict comes from `inspectFrame` — the SAME stateless composition the\n * relay enforces (signature patterns + the F5 exfil-param key walker + the H7\n * server-initiated content scan), including the warn-only carrier clamp, so a\n * `resources/read` injection reports `warn` here exactly as it would in-line.\n * v0.25.0 shipped this command calling `inspectMessage` alone, which silently\n * reported `pass` on frames the relay blocks for 3 of the 12 catalog\n * signatures; `inspect-relay-parity.test.ts` now pins the equivalence.\n *\n * Excluded by design, because they are not properties of the frame: schema and\n * handshake drift (needs the pin store and per-session state) and policy\n * overrides (mute/log_only). This command answers \"what do the signatures\n * see\", not \"what would this user's configured policy do\".\n */\n\nimport type { JSONRPCMessage } from \"@modelcontextprotocol/sdk/types.js\";\nimport { inspectFrame } from \"./inspect-frame.js\";\nimport { sanitizeForTerminal } from \"./sanitize.js\";\nimport type { InspectAction, InspectFinding } from \"./types.js\";\n\nexport interface InspectCliOpts {\n /** Raw input text: one JSON frame, or NDJSON with one frame per line. */\n readonly source: string;\n /** Emit NDJSON verdicts (one line per input frame) instead of human text. */\n readonly json?: boolean;\n readonly write: (s: string) => void;\n}\n\nexport interface InspectCliResult {\n /** Worst action across all frames — drives the process exit code. */\n readonly action: InspectAction;\n /** Frames that could not be parsed as a JSON-RPC object. */\n readonly errors: number;\n /** Frames actually inspected, including the unparseable ones. */\n readonly frames: number;\n}\n\nconst ACTION_RANK: Readonly<Record<InspectAction, number>> = { pass: 0, warn: 1, block: 2 };\n\ntype ParsedFrame = { readonly frame: JSONRPCMessage } | { readonly error: string };\n\n/**\n * Split input into frames. A whole-input parse is tried FIRST so a\n * pretty-printed single frame (the common hand-authored / captured case) works;\n * NDJSON falls through to per-line parsing.\n */\nfunction parseFrames(rawSource: string): readonly ParsedFrame[] {\n // A leading BOM is common in editor-saved captures and makes JSON.parse throw\n // on otherwise-valid input; stripping it avoids a baffling parse error.\n const source = rawSource.replace(/^\\uFEFF/, \"\");\n if (source.trim() === \"\") return [];\n\n try {\n return [asFrame(JSON.parse(source) as unknown)];\n } catch {\n // Not a single JSON document — treat as NDJSON.\n }\n\n const frames: ParsedFrame[] = [];\n for (const line of source.split(\"\\n\")) {\n const trimmed = line.trim();\n if (trimmed === \"\") continue; // blank lines are separators, not frames\n try {\n frames.push(asFrame(JSON.parse(trimmed) as unknown));\n } catch (err) {\n frames.push({ error: err instanceof Error ? err.message : String(err) });\n }\n }\n return frames;\n}\n\n/**\n * A JSON-RPC frame must be a plain object. Arrays (JSON-RPC batches) are\n * rejected rather than silently mis-inspected — `inspectMessage` takes a single\n * message, and quietly passing a batch would report a false \"pass\" on whatever\n * it contains. Send batch members as separate NDJSON lines.\n */\nfunction asFrame(value: unknown): ParsedFrame {\n if (typeof value !== \"object\" || value === null) {\n return { error: `expected a JSON-RPC object, got ${value === null ? \"null\" : typeof value}` };\n }\n if (Array.isArray(value)) {\n return { error: \"expected a single JSON-RPC object, got an array (send batch members as separate NDJSON lines)\" };\n }\n return { frame: value as JSONRPCMessage };\n}\n\nfunction findingToJson(f: InspectFinding): Record<string, unknown> {\n return {\n signature_id: f.signature_id,\n category: f.category,\n severity: f.severity,\n target: f.target,\n matched_text_excerpt: f.matched_text_excerpt,\n remediation: f.remediation,\n ...(f.decoded === true ? { decoded: true } : {}),\n };\n}\n\nfunction plural(n: number, word: string): string {\n return `${n} ${word}${n === 1 ? \"\" : \"s\"}`;\n}\n\n/**\n * Serialize one verdict as a single output line.\n *\n * `JSON.stringify` escapes C0 but leaves two families raw, and BOTH matter here\n * because the excerpt is attacker-controlled:\n *\n * - **U+2028 / U+2029** are line terminators to Node's `readline` (and to\n * ECMAScript), which is exactly how the documented consumer splits this\n * stream. One of them inside an excerpt splits a verdict across two \"lines\"\n * and permanently desyncs a consumer doing positional correlation —\n * reproduced forging a `pass` on a real attack and a `block` on a benign\n * case. That makes one-verdict-per-line a security property, not formatting.\n * - **C1 controls (U+0080–U+009F)** drive a terminal with no ESC byte at all\n * (8-bit CSI/OSC), so \"stringify escapes C0, therefore ESC sequences can't\n * survive\" was true but did not imply safety. `--json` gets piped into\n * terminals while triaging hostile captures.\n *\n * Escaping is LOSSLESS — the consumer's `JSON.parse` yields the identical\n * string — so byte-fidelity of the excerpt is preserved. DEL (U+007F) rides\n * along in the same class.\n */\nfunction jsonLine(value: unknown): string {\n return JSON.stringify(value).replace(\n /[\\u007F-\\u009F\\u2028\\u2029]/g,\n (c) => `\\\\u${c.charCodeAt(0).toString(16).padStart(4, \"0\")}`,\n );\n}\n\nexport function runInspectCommand(opts: InspectCliOpts): InspectCliResult {\n const parsed = parseFrames(opts.source);\n const json = opts.json === true;\n\n let worst: InspectAction = \"pass\";\n let errors = 0;\n const tally: Record<InspectAction, number> = { pass: 0, warn: 0, block: 0 };\n const humanLines: string[] = [];\n\n parsed.forEach((entry, i) => {\n if (\"error\" in entry) {\n errors += 1;\n if (json) {\n opts.write(`${jsonLine({ action: \"error\", error: entry.error })}\\n`);\n } else {\n humanLines.push(`frame ${i + 1} — error: ${sanitizeForTerminal(entry.error)}`);\n }\n return;\n }\n\n const result = inspectFrame(entry.frame);\n tally[result.action] += 1;\n if (ACTION_RANK[result.action] > ACTION_RANK[worst]) worst = result.action;\n\n if (json) {\n // Excerpts keep byte-fidelity (a harness needs to see what matched), but\n // are emitted through jsonLine so no character can break the one-line\n // framing or reach a terminal as a control sequence. See jsonLine.\n opts.write(`${jsonLine({ action: result.action, findings: result.findings.map(findingToJson) })}\\n`);\n return;\n }\n\n humanLines.push(`frame ${i + 1} — ${result.action}`);\n for (const f of result.findings) {\n humanLines.push(` ${f.signature_id} · ${f.severity} · ${f.target}${f.decoded === true ? \" · decoded\" : \"\"}`);\n // Excerpts are attacker-controlled. Sanitize before they reach a\n // terminal, or `guard inspect` becomes the ANSI/OSC injection vector the\n // guard itself detects.\n humanLines.push(` excerpt: ${sanitizeForTerminal(f.matched_text_excerpt)}`);\n humanLines.push(` fix: ${sanitizeForTerminal(f.remediation)}`);\n }\n });\n\n if (!json) {\n if (parsed.length === 0) {\n opts.write(\"no frames on input\\n\");\n } else {\n opts.write(`${humanLines.join(\"\\n\")}\\n\\n`);\n const parts = [plural(parsed.length, \"frame\")];\n for (const a of [\"block\", \"warn\", \"pass\"] as const) {\n if (tally[a] > 0) parts.push(`${tally[a]} ${a}`);\n }\n if (errors > 0) parts.push(plural(errors, \"error\"));\n opts.write(`${parts.join(\" · \")}\\n`);\n }\n }\n\n return { action: worst, errors, frames: parsed.length };\n}\n"],"mappings":"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","names":[]} |
| #!/usr/bin/env node | ||
| import { | ||
| buildDriftFinding, | ||
| buildHandshakeDriftFinding, | ||
| classifyDrift, | ||
| classifyHandshakeDrift, | ||
| inspectForDrift, | ||
| inspectHandshakeForDrift | ||
| } from "./chunk-QFYQJDKQ.js"; | ||
| import { | ||
| PolicyIntegrityError, | ||
| expireStale, | ||
| readPolicy | ||
| } from "./chunk-CYYYMOUS.js"; | ||
| import { | ||
| hasToolsList, | ||
| inspectFrame, | ||
| mergeInspect, | ||
| withReplyToOrigin | ||
| } from "./chunk-74BFQMZZ.js"; | ||
| import { | ||
| hashConfineProfile, | ||
| loadProfile | ||
| } from "./chunk-544DEV2D.js"; | ||
| import { | ||
| OWASP_MCP_TOP_10 | ||
| } from "./chunk-MXHNRCQI.js"; | ||
| import { | ||
| fieldHashesOf, | ||
| handshakeCapabilityKeys, | ||
| handshakeFieldHashesOf, | ||
| hashHandshake, | ||
| hashToolDefinition, | ||
| lookupHandshake, | ||
| readPins, | ||
| writePins | ||
| } from "./chunk-DDCTUMSZ.js"; | ||
| import { | ||
| hashOriginalEntry, | ||
| isConfineBackendAvailable, | ||
| wrapForConfinement | ||
| } from "./chunk-WYSMWP2R.js"; | ||
| import "./chunk-OIFKZA4V.js"; | ||
| import { | ||
| sanitizeForTerminal | ||
| } from "./chunk-FEXJHHDM.js"; | ||
| import { | ||
| resolveEnvPlaceholders | ||
| } from "./chunk-GZ3WCRLG.js"; | ||
| import { | ||
| getStorePath | ||
| } from "./chunk-3X76P3FG.js"; | ||
| import { | ||
| ACTION_RANK, | ||
| defaultActionForFinding, | ||
| inspectMessage | ||
| } from "./chunk-62744DB3.js"; | ||
| // src/guard/relay.ts | ||
| import { spawn } from "child_process"; | ||
| import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"; | ||
| var GUARD_BLOCK_ERROR_CODE = -32099; | ||
| function makeBlockResponse(blocked, result) { | ||
| if (!("id" in blocked) || blocked.id === void 0) return null; | ||
| const finding = result.findings[0]; | ||
| return { | ||
| jsonrpc: "2.0", | ||
| id: blocked.id, | ||
| error: { | ||
| code: GUARD_BLOCK_ERROR_CODE, | ||
| message: "BLOCKED by mcpm-guard", | ||
| data: finding ? { | ||
| signature_id: finding.signature_id, | ||
| category: finding.category, | ||
| severity: finding.severity, | ||
| matched_text_excerpt: finding.matched_text_excerpt, | ||
| remediation: finding.remediation | ||
| } : void 0 | ||
| } | ||
| }; | ||
| } | ||
| var SAFE_ENV_PASSTHROUGH = /* @__PURE__ */ new Set([ | ||
| "PATH", | ||
| "HOME", | ||
| "TMPDIR", | ||
| "TEMP", | ||
| "TMP", | ||
| "LANG", | ||
| "LC_ALL", | ||
| "USER", | ||
| "SHELL" | ||
| ]); | ||
| function buildSafeEnv(source = process.env) { | ||
| const out = {}; | ||
| for (const [k, v] of Object.entries(source)) { | ||
| if (SAFE_ENV_PASSTHROUGH.has(k) || k.startsWith("LC_")) out[k] = v; | ||
| } | ||
| return out; | ||
| } | ||
| var MAX_BUFFER_BYTES = 64 * 1024 * 1024; | ||
| function startRelay(opts) { | ||
| const env = opts.env ?? buildSafeEnv(); | ||
| const child = opts.spawnChild ? opts.spawnChild(opts.command, opts.args, env) : spawn(opts.command, [...opts.args], { | ||
| env, | ||
| stdio: ["pipe", "pipe", "inherit"] | ||
| // stderr passthrough — preserves IDE diagnostics | ||
| }); | ||
| const forwardSignal = (sig) => { | ||
| if (!child.killed) child.kill(sig); | ||
| }; | ||
| let settled = false; | ||
| let resolveExit; | ||
| const exit = new Promise((resolve) => { | ||
| resolveExit = resolve; | ||
| }); | ||
| child.on("error", (err) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| process.off("SIGTERM", forwardSignal); | ||
| process.off("SIGINT", forwardSignal); | ||
| const code = err.code ?? "SPAWN-FAILED"; | ||
| opts.onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "child->parent", | ||
| action: "block", | ||
| findings: [ | ||
| { | ||
| signature_id: "spawn-failure", | ||
| category: "RELAY", | ||
| severity: "critical", | ||
| target: "tool_response", | ||
| matched_text_excerpt: `${code}: ${err.message}`, | ||
| remediation: "The wrapped MCP server binary failed to start. Verify the command exists and is executable." | ||
| } | ||
| ] | ||
| }); | ||
| process.stderr.write(`[mcpm-guard] SPAWN-FAILED ${opts.command}: ${code} | ||
| `); | ||
| child.stdout?.destroy(); | ||
| child.stdin?.destroy(); | ||
| resolveExit(1); | ||
| }); | ||
| child.stdin?.on("error", (err) => { | ||
| const code = err.code; | ||
| if (code !== "EPIPE" && code !== "ERR_STREAM_DESTROYED") { | ||
| opts.onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "parent->child", | ||
| action: "warn", | ||
| findings: [] | ||
| }); | ||
| } | ||
| }); | ||
| const writeToChild = (bytes) => { | ||
| if (child.stdin && !child.stdin.destroyed) child.stdin.write(bytes); | ||
| }; | ||
| wireDirection({ | ||
| source: opts.parentIn, | ||
| target: writeToChild, | ||
| targetEnd: () => child.stdin?.end(), | ||
| parentOut: opts.parentOut, | ||
| inspect: opts.inspectParentRequest, | ||
| direction: "parent->child", | ||
| onEvent: opts.onEvent, | ||
| // Symmetry only — a parent-INITIATED block replies to the client (parentOut), | ||
| // so this is unused for this direction (no replyToOrigin on parent requests). | ||
| replyToSource: (bytes) => opts.parentOut.write(bytes) | ||
| }); | ||
| if (child.stdout) { | ||
| wireDirection({ | ||
| source: child.stdout, | ||
| target: (bytes) => opts.parentOut.write(bytes), | ||
| targetEnd: () => void 0, | ||
| // never end parentOut on child exit | ||
| parentOut: opts.parentOut, | ||
| inspect: opts.inspectChildResponse, | ||
| direction: "child->parent", | ||
| onEvent: opts.onEvent, | ||
| // H7: a blocked server-INITIATED request (sampling/elicitation) errors | ||
| // back to the SERVER (child.stdin), not the client. | ||
| replyToSource: writeToChild | ||
| }); | ||
| } | ||
| process.on("SIGTERM", forwardSignal); | ||
| process.on("SIGINT", forwardSignal); | ||
| child.on("exit", (code) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| process.off("SIGTERM", forwardSignal); | ||
| process.off("SIGINT", forwardSignal); | ||
| resolveExit(code ?? 0); | ||
| }); | ||
| return { child, exit }; | ||
| } | ||
| function wireDirection(w) { | ||
| const buffer = new ReadBuffer(); | ||
| let bufferedBytes = 0; | ||
| w.source.on("data", (chunk) => { | ||
| bufferedBytes += chunk.byteLength; | ||
| if (bufferedBytes > MAX_BUFFER_BYTES) { | ||
| w.onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: w.direction, | ||
| action: "block", | ||
| findings: [] | ||
| }); | ||
| w.source.destroy(); | ||
| return; | ||
| } | ||
| buffer.append(chunk); | ||
| let msg; | ||
| try { | ||
| msg = buffer.readMessage(); | ||
| } catch { | ||
| w.onEvent?.(malformedFrameEvent(w.direction)); | ||
| w.source.destroy(); | ||
| return; | ||
| } | ||
| while (msg !== null) { | ||
| bufferedBytes = 0; | ||
| const decision = w.inspect?.(msg); | ||
| if (decision?.action === "block") { | ||
| logEvent(decision, w.direction, w.onEvent); | ||
| const errResp = makeBlockResponse(msg, decision); | ||
| if (errResp !== null) { | ||
| if (decision.replyToOrigin === true) w.replyToSource(serializeMessage(errResp)); | ||
| else w.parentOut.write(serializeMessage(errResp)); | ||
| } | ||
| } else { | ||
| logEvent(decision, w.direction, w.onEvent); | ||
| w.target(serializeMessage(msg)); | ||
| } | ||
| try { | ||
| msg = buffer.readMessage(); | ||
| } catch { | ||
| w.onEvent?.(malformedFrameEvent(w.direction)); | ||
| w.source.destroy(); | ||
| return; | ||
| } | ||
| } | ||
| }); | ||
| w.source.on("end", () => { | ||
| w.targetEnd(); | ||
| }); | ||
| } | ||
| function malformedFrameEvent(direction) { | ||
| return { | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction, | ||
| action: "block", | ||
| findings: [ | ||
| { | ||
| signature_id: "malformed-frame", | ||
| category: "RELAY", | ||
| severity: "critical", | ||
| target: "tool_response", | ||
| matched_text_excerpt: "malformed JSON-RPC frame on stdio", | ||
| remediation: "The wrapped MCP server emitted a non-JSON-RPC line (e.g. a startup banner). It must write only JSON-RPC frames to stdout." | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| function logEvent(result, direction, onEvent) { | ||
| if (!result || result.findings.length === 0) return; | ||
| onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction, | ||
| action: result.action, | ||
| findings: result.findings | ||
| }); | ||
| } | ||
| // src/guard/event-log.ts | ||
| import { appendFile, mkdir } from "fs/promises"; | ||
| import path from "path"; | ||
| var EVENT_LOG_FILENAME = "guard-events.jsonl"; | ||
| var _warnedOnFailure = false; | ||
| async function eventLogPath() { | ||
| return path.join(await getStorePath(), EVENT_LOG_FILENAME); | ||
| } | ||
| function buildEventLogEntry(event, serverName) { | ||
| return { | ||
| ts: event.ts, | ||
| server_name: sanitizeForTerminal(serverName), | ||
| direction: event.direction, | ||
| action: event.action, | ||
| findings: event.findings.map((f) => ({ | ||
| signature_id: f.signature_id, | ||
| category: f.category, | ||
| severity: f.severity, | ||
| target: f.target, | ||
| matched_text_excerpt: f.matched_text_excerpt | ||
| })) | ||
| }; | ||
| } | ||
| async function appendEvent(event, serverName) { | ||
| try { | ||
| const filePath = await eventLogPath(); | ||
| await mkdir(path.dirname(filePath), { recursive: true, mode: 448 }); | ||
| const line = `${JSON.stringify(buildEventLogEntry(event, serverName))} | ||
| `; | ||
| await appendFile(filePath, line, { encoding: "utf-8", mode: 384 }); | ||
| } catch (err) { | ||
| if (!_warnedOnFailure) { | ||
| _warnedOnFailure = true; | ||
| process.stderr.write( | ||
| `[mcpm-guard] event log write failed (logging will continue silently): ${err instanceof Error ? err.message : String(err)} | ||
| ` | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| // src/guard/confine/decide.ts | ||
| function decideConfine(input) { | ||
| const { profile, markerHash, markerRequired, backendAvailable } = input; | ||
| const mustConfine = markerRequired || profile?.require_confine === true; | ||
| if (profile !== null) { | ||
| if (markerHash === null) { | ||
| return mustConfine ? { action: "fail-closed", reason: "confine marker stripped on a required server", event: "confine-marker-stripped" } : { action: "unconfined", reason: "confine marker stripped", event: "confine-marker-stripped" }; | ||
| } | ||
| if (hashConfineProfile(profile) !== markerHash) { | ||
| return { action: "fail-closed", reason: "confine profile hash mismatch (tamper)", event: "confine-hash-mismatch" }; | ||
| } | ||
| if (!backendAvailable) { | ||
| return mustConfine ? { action: "fail-closed", reason: "no confine backend on a required server", event: "confine-backend-missing" } : { action: "unconfined", reason: "no confine backend on this platform", event: "confine-backend-missing" }; | ||
| } | ||
| return { action: "confine", reason: "confined", event: "confine-applied" }; | ||
| } | ||
| if (markerRequired) { | ||
| return { action: "fail-closed", reason: "confine required but no stored profile (store missing?)", event: "confine-profile-missing" }; | ||
| } | ||
| if (markerHash !== null) { | ||
| return { action: "unconfined", reason: "confine marker present but no stored profile", event: "confine-profile-missing" }; | ||
| } | ||
| return { action: "unconfined", reason: "not confined" }; | ||
| } | ||
| // src/guard/run-inner.ts | ||
| var SIGNATURE_LIST_VERSION = "owasp-mcp-top-10@v0.5.0"; | ||
| function applyPolicy(result, policy) { | ||
| const overrides = policy.signature_overrides ?? []; | ||
| if (overrides.length === 0) return result; | ||
| const byId = new Map(overrides.map((o) => [o.id, o])); | ||
| let highest = "pass"; | ||
| const kept = []; | ||
| for (const f of result.findings) { | ||
| const o = byId.get(f.signature_id); | ||
| let perFindingAction; | ||
| if (o === void 0) { | ||
| perFindingAction = defaultActionForFinding(f); | ||
| kept.push(f); | ||
| } else if (o.action === "ignore") { | ||
| continue; | ||
| } else if (o.action === "log_only") { | ||
| perFindingAction = "pass"; | ||
| kept.push(f); | ||
| } else { | ||
| perFindingAction = o.action; | ||
| kept.push(f); | ||
| } | ||
| if (ACTION_RANK[perFindingAction] > ACTION_RANK[highest]) highest = perFindingAction; | ||
| } | ||
| return withReplyToOrigin({ action: highest, findings: kept }, result.replyToOrigin === true); | ||
| } | ||
| function confineGuardEvent(event, reason, action, severity) { | ||
| return { | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "parent->child", | ||
| action, | ||
| findings: [ | ||
| { | ||
| signature_id: event, | ||
| category: "CONFINE", | ||
| severity, | ||
| target: "tool_response", | ||
| matched_text_excerpt: reason, | ||
| remediation: "See docs/GUARD.md \u2014 `mcpm guard confine`." | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| async function runInner(parsed) { | ||
| const safeName = sanitizeForTerminal(parsed.serverName); | ||
| if (typeof parsed.origHash === "string" && parsed.origHash.length > 0) { | ||
| const recomputed = hashOriginalEntry(parsed.command, parsed.args, parsed.declaredEnvKeys); | ||
| if (recomputed !== parsed.origHash) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] ORIG-HASH-MISMATCH ${safeName}: the wrapped command/args/declared-env no longer match the integrity hash embedded at \`mcpm guard enable\` time \u2014 the client config entry may have been edited or tampered with. Starting anyway (advisory); a future mcpm release will refuse to start on mismatch. Review ~/.mcpm/guard-events.jsonl, and if you changed the entry on purpose re-run \`mcpm guard enable\` to re-pin it. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| { | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "parent->child", | ||
| action: "warn", | ||
| findings: [ | ||
| { | ||
| signature_id: "orig-hash-mismatch", | ||
| category: "RELAY", | ||
| severity: "high", | ||
| target: "tool_response", | ||
| matched_text_excerpt: "wrap-marker integrity: recomputed hash != embedded --orig-hash", | ||
| remediation: "Re-run `mcpm guard enable` to re-pin, or restore the original wrapped entry in the client config." | ||
| } | ||
| ] | ||
| }, | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| } | ||
| const logEvent2 = (event) => { | ||
| if (event.action === "block" || event.action === "warn") { | ||
| process.stderr.write( | ||
| `[mcpm-guard] ${event.action.toUpperCase()} ${safeName} ${event.findings.map((f) => f.signature_id).join(",")} | ||
| ` | ||
| ); | ||
| void appendEvent(event, parsed.serverName); | ||
| } | ||
| }; | ||
| let pinsSnapshot; | ||
| try { | ||
| pinsSnapshot = await readPins(); | ||
| } catch (err) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] PINS-READ-ERROR: ${safeName} could not load ~/.mcpm/pins.json: ${err.message} | ||
| Refusing to start the relay \u2014 running with rug-pull (schema-drift) protection silently disabled is more dangerous than not starting. Review ~/.mcpm/guard-events.jsonl for unauthorized activity. If you intentionally changed pins.json, run \`mcpm guard reset-integrity\`. | ||
| ` | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| const policy = expireStale( | ||
| await readPolicy().catch((err) => { | ||
| if (err instanceof PolicyIntegrityError) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] POLICY-INTEGRITY-ERROR: ${safeName} ${err.message} | ||
| Falling back to full enforcement (ignoring guard-policy.yaml) for this session. | ||
| ` | ||
| ); | ||
| } else { | ||
| process.stderr.write( | ||
| `[mcpm-guard] POLICY-READ-ERROR: ${err.message} | ||
| ` | ||
| ); | ||
| } | ||
| return {}; | ||
| }) | ||
| ); | ||
| const pausedUntilFuture = policy.paused_until !== void 0 && new Date(policy.paused_until) > /* @__PURE__ */ new Date(); | ||
| const sessionState = { | ||
| firstHashes: /* @__PURE__ */ new Map(), | ||
| revalidationArmed: false, | ||
| handshakeSeenHash: null | ||
| }; | ||
| const baselineForDrift = pinsSnapshot; | ||
| const inspectChild = (msg) => { | ||
| if (pausedUntilFuture) return { action: "pass", findings: [] }; | ||
| if (isToolsListChangedNotification(msg)) { | ||
| sessionState.revalidationArmed = true; | ||
| return { action: "pass", findings: [] }; | ||
| } | ||
| const statelessResult = inspectFrame(msg); | ||
| let driftResult = { action: "pass", findings: [] }; | ||
| if (hasToolsList(msg)) { | ||
| driftResult = inspectForDriftSync(msg, parsed.serverName, baselineForDrift, sessionState); | ||
| void (async () => { | ||
| await inspectForDrift(msg, parsed.serverName, { | ||
| read: () => readPins().catch(() => pinsSnapshot), | ||
| write: writePins, | ||
| signatureListVersion: SIGNATURE_LIST_VERSION | ||
| }); | ||
| pinsSnapshot = await readPins().catch(() => pinsSnapshot); | ||
| })(); | ||
| } else if (isInitializeResult(msg)) { | ||
| driftResult = inspectHandshakeDriftSync(msg, parsed.serverName, baselineForDrift, sessionState); | ||
| void (async () => { | ||
| await inspectHandshakeForDrift(msg, parsed.serverName, { | ||
| read: () => readPins().catch(() => pinsSnapshot), | ||
| write: writePins, | ||
| signatureListVersion: SIGNATURE_LIST_VERSION | ||
| }); | ||
| pinsSnapshot = await readPins().catch(() => pinsSnapshot); | ||
| })(); | ||
| } | ||
| return applyPolicy(mergeInspect(statelessResult, driftResult), policy); | ||
| }; | ||
| const inspectParent = (msg) => { | ||
| if (pausedUntilFuture) return { action: "pass", findings: [] }; | ||
| return applyPolicy(inspectMessage(msg, OWASP_MCP_TOP_10), policy); | ||
| }; | ||
| const baselineEnv = buildSafeEnv(process.env); | ||
| const childEnvSource = { ...baselineEnv }; | ||
| for (const key of parsed.declaredEnvKeys) { | ||
| const value = process.env[key]; | ||
| if (value !== void 0) childEnvSource[key] = value; | ||
| } | ||
| let childEnv; | ||
| try { | ||
| childEnv = await resolveEnvPlaceholders(childEnvSource); | ||
| } catch (err) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] SECRET-MISSING ${safeName} ${err.message} | ||
| ` | ||
| ); | ||
| return 1; | ||
| } | ||
| if (parsed.confineProfileHash !== void 0 && !/^[0-9a-f]{64}$/.test(parsed.confineProfileHash)) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-BLOCK ${safeName}: malformed --confine-profile-hash in the wrap marker (the client config entry may be tampered or corrupt). Refusing to start. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent( | ||
| "confine-marker-malformed", | ||
| "malformed confine profile hash", | ||
| "block", | ||
| "critical" | ||
| ), | ||
| parsed.serverName | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| let spawnCommand = parsed.command; | ||
| let spawnArgs = parsed.args; | ||
| let confineProfile = null; | ||
| try { | ||
| confineProfile = await loadProfile(parsed.serverName); | ||
| } catch (err) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-STORE-ERROR ${safeName}: ${err.message} | ||
| ` | ||
| ); | ||
| } | ||
| const confineDecision = decideConfine({ | ||
| profile: confineProfile, | ||
| markerHash: parsed.confineProfileHash ?? null, | ||
| markerRequired: parsed.confineRequired === true, | ||
| backendAvailable: isConfineBackendAvailable() | ||
| }); | ||
| if (confineDecision.action === "fail-closed") { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-BLOCK ${safeName}: ${confineDecision.reason}. Refusing to start (this server is marked require-confine). Run \`mcpm guard doctor-confine\` to check the backend, and review ~/.mcpm/guard-events.jsonl. | ||
| ` | ||
| ); | ||
| if (confineDecision.event !== void 0) { | ||
| void appendEvent( | ||
| confineGuardEvent(confineDecision.event, confineDecision.reason, "block", "critical"), | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| process.exit(1); | ||
| } | ||
| if (confineDecision.action === "confine" && confineProfile !== null) { | ||
| const wrapped = wrapForConfinement(confineProfile, parsed.command, parsed.args); | ||
| if (wrapped !== null) { | ||
| spawnCommand = wrapped.command; | ||
| spawnArgs = wrapped.args; | ||
| void appendEvent( | ||
| confineGuardEvent( | ||
| confineDecision.event ?? "confine-applied", | ||
| confineDecision.reason, | ||
| "pass", | ||
| "low" | ||
| ), | ||
| parsed.serverName | ||
| ); | ||
| } else { | ||
| const required = parsed.confineRequired === true || confineProfile.require_confine; | ||
| if (required) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-BLOCK ${safeName}: sandbox backend became unavailable at spawn (require-confine). Refusing to start. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent( | ||
| "confine-backend-missing", | ||
| "backend unavailable at wrap", | ||
| "block", | ||
| "critical" | ||
| ), | ||
| parsed.serverName | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-UNCONFINED ${safeName}: sandbox backend unavailable at wrap \u2014 running unconfined. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent("confine-backend-missing", "backend unavailable at wrap", "warn", "high"), | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| } else if (confineDecision.event !== void 0) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-UNCONFINED ${safeName}: ${confineDecision.reason} \u2014 running unconfined. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent(confineDecision.event, confineDecision.reason, "warn", "high"), | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| const handle = startRelay({ | ||
| command: spawnCommand, | ||
| args: spawnArgs, | ||
| env: childEnv, | ||
| parentIn: process.stdin, | ||
| parentOut: process.stdout, | ||
| inspectChildResponse: inspectChild, | ||
| inspectParentRequest: inspectParent, | ||
| onEvent: logEvent2 | ||
| }); | ||
| return handle.exit; | ||
| } | ||
| function sanitizeLabel(s) { | ||
| return sanitizeForTerminal(s, 128); | ||
| } | ||
| function inspectForDriftSync(msg, serverName, baseline, state) { | ||
| const armed = state.revalidationArmed; | ||
| state.revalidationArmed = false; | ||
| const result = msg.result; | ||
| const tools = Array.isArray(result?.tools) ? result.tools : []; | ||
| const findings = []; | ||
| for (const rawTool of tools) { | ||
| const finding = inspectToolDrift(rawTool, serverName, baseline, state, armed); | ||
| if (finding !== null) findings.push(finding); | ||
| } | ||
| const action = findings.reduce((acc, f) => { | ||
| const a = defaultActionForFinding(f); | ||
| return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc; | ||
| }, "pass"); | ||
| return { action, findings }; | ||
| } | ||
| function inspectToolDrift(rawTool, serverName, baseline, state, armed) { | ||
| if (rawTool === null || typeof rawTool !== "object") return null; | ||
| const tool = rawTool; | ||
| const toolName = typeof tool.name === "string" ? tool.name : null; | ||
| if (toolName === null) return null; | ||
| const fields = { | ||
| description: typeof tool.description === "string" ? tool.description : null, | ||
| schema: tool.inputSchema ?? tool.schema, | ||
| annotations: tool.annotations | ||
| }; | ||
| const liveWhole = hashToolDefinition(fields); | ||
| const liveFields = fieldHashesOf(fields); | ||
| const serverPins = Object.hasOwn(baseline.servers, serverName) ? baseline.servers[serverName] : void 0; | ||
| const pinned = serverPins && Object.hasOwn(serverPins, toolName) ? serverPins[toolName] : void 0; | ||
| const sessionKey = `${serverName}::${toolName}`; | ||
| const firstSeen = state.firstHashes.get(sessionKey); | ||
| if (!armed && firstSeen !== void 0 && firstSeen !== liveWhole) { | ||
| return inSessionDriftFinding(serverName, toolName, firstSeen, liveWhole); | ||
| } | ||
| if (firstSeen === void 0 || armed) state.firstHashes.set(sessionKey, liveWhole); | ||
| if (!pinned || pinned.current_hash === null) return null; | ||
| if (liveWhole === pinned.current_hash) return null; | ||
| const cls = classifyDrift(pinned, liveFields); | ||
| const newDescriptionExcerpt = typeof tool.description === "string" ? sanitizeForTerminal(tool.description, 80) : void 0; | ||
| return buildDriftFinding({ | ||
| cls, | ||
| safeServer: sanitizeLabel(serverName), | ||
| safeTool: sanitizeLabel(toolName), | ||
| expected: pinned.current_hash, | ||
| actual: liveWhole, | ||
| newDescriptionExcerpt | ||
| }); | ||
| } | ||
| function inSessionDriftFinding(serverName, toolName, firstSeen, liveWhole) { | ||
| return { | ||
| signature_id: "schema-drift-in-session", | ||
| category: "OWASP-MCP-1", | ||
| severity: "critical", | ||
| target: "tool_description", | ||
| matched_text_excerpt: `${sanitizeLabel(toolName)}: ${firstSeen.slice(7, 19)}\u2026 \u2192 ${liveWhole.slice(7, 19)}\u2026 (same session)`, | ||
| remediation: `Server "${sanitizeLabel(serverName)}" delivered two different schemas for tool "${sanitizeLabel(toolName)}" in the same session. This is a rug-pull attempt; restart the IDE and reinspect the server's source.` | ||
| }; | ||
| } | ||
| function isToolsListChangedNotification(msg) { | ||
| if (!("method" in msg)) return false; | ||
| if (msg.method !== "notifications/tools/list_changed") return false; | ||
| return !("result" in msg); | ||
| } | ||
| function isInitializeResult(msg) { | ||
| if (!("result" in msg)) return false; | ||
| const result = msg.result; | ||
| return result !== null && typeof result === "object" && typeof result.protocolVersion === "string"; | ||
| } | ||
| function inspectHandshakeDriftSync(msg, serverName, baseline, state) { | ||
| const result = msg.result; | ||
| if (result === null || typeof result !== "object") return { action: "pass", findings: [] }; | ||
| const liveFields = handshakeFieldHashesOf(result); | ||
| const liveCapKeys = handshakeCapabilityKeys(result); | ||
| const liveWhole = hashHandshake(liveFields); | ||
| const seen = state.handshakeSeenHash; | ||
| if (seen !== null && seen !== liveWhole) { | ||
| return warnResult(handshakeInSessionFinding(serverName, seen, liveWhole)); | ||
| } | ||
| if (seen === null) state.handshakeSeenHash = liveWhole; | ||
| const pinned = lookupHandshake(baseline, serverName); | ||
| if (pinned === void 0) return { action: "pass", findings: [] }; | ||
| if (liveWhole === pinned.current_hash || pinned.previous_hashes.includes(liveWhole)) { | ||
| return { action: "pass", findings: [] }; | ||
| } | ||
| const cls = classifyHandshakeDrift(pinned, liveFields, liveCapKeys); | ||
| const findings = buildHandshakeDriftFinding({ | ||
| cls, | ||
| safeServer: sanitizeLabel(serverName) | ||
| }); | ||
| const action = findings.reduce((acc, f) => { | ||
| const a = defaultActionForFinding(f); | ||
| return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc; | ||
| }, "pass"); | ||
| return { action, findings }; | ||
| } | ||
| function warnResult(finding) { | ||
| return { action: defaultActionForFinding(finding), findings: [finding] }; | ||
| } | ||
| function handshakeInSessionFinding(serverName, firstSeen, liveWhole) { | ||
| return { | ||
| signature_id: "handshake-drift-in-session", | ||
| category: "OWASP-MCP-1", | ||
| severity: "high", | ||
| target: "initialize_instructions", | ||
| matched_text_excerpt: `${sanitizeLabel(serverName)}: ${firstSeen.slice(7, 19)}\u2026 \u2192 ${liveWhole.slice(7, 19)}\u2026 (same session)`, | ||
| remediation: `Server "${sanitizeLabel(serverName)}" delivered two different initialize handshakes in the same session \u2014 initialize should occur once. Inspect the wrapped command; this is a warn-only signal and does not block the session.` | ||
| }; | ||
| } | ||
| export { | ||
| applyPolicy, | ||
| inspectForDriftSync, | ||
| inspectHandshakeDriftSync, | ||
| isInitializeResult, | ||
| isToolsListChangedNotification, | ||
| runInner | ||
| }; | ||
| //# sourceMappingURL=run-inner-JQBYVKIO.js.map |
Sorry, the diff of this file is too big to display
| #!/usr/bin/env node | ||
| import { | ||
| buildDoctorModel, | ||
| execCheckDefault, | ||
| formatMcpEntryCommand, | ||
| makeCheckConfigExists | ||
| } from "./chunk-2MBO4SX3.js"; | ||
| import { | ||
| resolveInstallEntry | ||
| } from "./chunk-OVIPM4DT.js"; | ||
| import { | ||
| readPins | ||
| } from "./chunk-DDCTUMSZ.js"; | ||
| import "./chunk-E3T224S3.js"; | ||
| import { | ||
| fetchNpmProvenance | ||
| } from "./chunk-QBEWWR7M.js"; | ||
| import "./chunk-WYSMWP2R.js"; | ||
| import "./chunk-OIFKZA4V.js"; | ||
| import "./chunk-FEXJHHDM.js"; | ||
| import "./chunk-SN3RQIVF.js"; | ||
| import "./chunk-YU6C7OHM.js"; | ||
| import "./chunk-UNGY7RTE.js"; | ||
| import "./chunk-W4IAFBUN.js"; | ||
| import "./chunk-2PWW3Q5Q.js"; | ||
| import { | ||
| fetchNpmIntegrity | ||
| } from "./chunk-7RJXJERN.js"; | ||
| import "./chunk-K4U7EXLG.js"; | ||
| import "./chunk-GZ3WCRLG.js"; | ||
| import "./chunk-6R7TL5O2.js"; | ||
| import { | ||
| CLIENT_IDS | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import "./chunk-2SYM6O5W.js"; | ||
| import "./chunk-3X76P3FG.js"; | ||
| import { | ||
| extractRegistryMeta | ||
| } from "./chunk-MZCNQU2K.js"; | ||
| import "./chunk-62744DB3.js"; | ||
| // src/server/index.ts | ||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| // src/server/tools.ts | ||
| import { z } from "zod"; | ||
| var serverName = z.string().min(1).max(256); | ||
| var clientId = z.enum(CLIENT_IDS); | ||
| var NoArgsInput = z.strictObject({}); | ||
| var SearchInput = z.strictObject({ | ||
| query: z.string().min(1).max(200), | ||
| limit: z.number().int().min(1).max(100).optional().default(20) | ||
| }); | ||
| var InstallInput = z.strictObject({ | ||
| name: serverName, | ||
| client: clientId.optional(), | ||
| minTrustScore: z.number().min(0).max(100).optional().default(50) | ||
| }); | ||
| var InfoInput = z.strictObject({ | ||
| name: serverName | ||
| }); | ||
| var ListInput = z.strictObject({ | ||
| client: clientId.optional() | ||
| }); | ||
| var RemoveInput = z.strictObject({ | ||
| name: serverName, | ||
| client: clientId.optional() | ||
| }); | ||
| var SetupInput = z.strictObject({ | ||
| description: z.string().min(1).max(1e3), | ||
| client: clientId.optional(), | ||
| minTrustScore: z.number().min(0).max(100).optional().default(50) | ||
| }); | ||
| var UpInput = z.strictObject({ | ||
| stackFile: z.string().optional().default("mcpm.yaml"), | ||
| profile: z.string().optional(), | ||
| dryRun: z.boolean().optional().default(false) | ||
| }); | ||
| // src/server/handlers.ts | ||
| import path from "path"; | ||
| var SERVER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}$/; | ||
| function validateMcpServerName(name) { | ||
| if (typeof name !== "string" || name.length === 0 || name.length > 256) { | ||
| throw new Error(`Invalid server name: must be a non-empty string under 256 characters.`); | ||
| } | ||
| if (!SERVER_NAME_RE.test(name)) { | ||
| throw new Error( | ||
| `Invalid server name format: "${name}". Expected format: "namespace/server-name" (alphanumeric, dots, hyphens, underscores only).` | ||
| ); | ||
| } | ||
| } | ||
| function computeTrust(entry, deps) { | ||
| const findings = deps.scanTier1(entry); | ||
| return deps.computeTrustScore({ | ||
| findings, | ||
| healthCheckPassed: null, | ||
| hasExternalScanner: false, | ||
| registryMeta: extractRegistryMeta(entry) | ||
| }); | ||
| } | ||
| async function resolveClients(requestedClient, deps) { | ||
| const detected = await deps.detectClients(); | ||
| if (detected.length === 0) { | ||
| throw new Error("No supported AI clients found."); | ||
| } | ||
| if (requestedClient !== void 0) { | ||
| if (!CLIENT_IDS.includes(requestedClient)) { | ||
| throw new Error( | ||
| `Unknown client "${requestedClient}". Valid values: ${CLIENT_IDS.join(", ")}.` | ||
| ); | ||
| } | ||
| const id = requestedClient; | ||
| if (!detected.includes(id)) { | ||
| throw new Error(`Client "${requestedClient}" is not installed.`); | ||
| } | ||
| return [id]; | ||
| } | ||
| return detected; | ||
| } | ||
| async function handleSearch(args, deps) { | ||
| const entries = await deps.registrySearch(args.query, args.limit); | ||
| const servers = entries.map((entry) => { | ||
| const trust = computeTrust(entry, deps); | ||
| return { | ||
| name: entry.server.name, | ||
| description: entry.server.description ?? "", | ||
| version: entry.server.version, | ||
| trustScore: trust.score | ||
| }; | ||
| }); | ||
| return { servers }; | ||
| } | ||
| var DEFAULT_MIN_TRUST_SCORE = 50; | ||
| var HARD_TRUST_FLOOR = 25; | ||
| function effectiveMinTrustScore(requested) { | ||
| return Math.max(requested ?? DEFAULT_MIN_TRUST_SCORE, HARD_TRUST_FLOOR); | ||
| } | ||
| async function handleInstall(args, deps, preResolved) { | ||
| validateMcpServerName(args.name); | ||
| const entry = preResolved?.entry ?? await deps.registryGetServer(args.name); | ||
| const trust = preResolved?.trust ?? computeTrust(entry, deps); | ||
| const minScore = effectiveMinTrustScore(args.minTrustScore); | ||
| if (trust.score < minScore) { | ||
| throw new Error( | ||
| `Server "${args.name}" has trust score ${trust.score}/${trust.maxPossible} (level: ${trust.level}), which is below the minimum threshold of ${minScore}. Install rejected for safety. Use mcpm CLI with --yes to override after manual review.` | ||
| ); | ||
| } | ||
| const clients = await resolveClients(args.client, deps); | ||
| const installedClients = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| const mcpEntry = resolveInstallEntry(entry, clientId2); | ||
| if (mcpEntry.url !== void 0 && mcpEntry.command === void 0) { | ||
| throw new Error( | ||
| `Server "${args.name}" uses a URL/HTTP transport and runs UNGUARDED (the guard relay only wraps stdio servers). Installing it is not permitted via the MCP surface. Use the mcpm CLI with --allow-unguarded after manual review.` | ||
| ); | ||
| } | ||
| await adapter.addServer(configPath, args.name, mcpEntry); | ||
| installedClients.push(clientId2); | ||
| } | ||
| await deps.addToStore({ | ||
| name: args.name, | ||
| version: entry.server.version, | ||
| clients: [...installedClients], | ||
| installedAt: (/* @__PURE__ */ new Date()).toISOString() | ||
| }); | ||
| return { | ||
| installed: true, | ||
| name: args.name, | ||
| version: entry.server.version, | ||
| clients: installedClients, | ||
| trustScore: trust | ||
| }; | ||
| } | ||
| async function handleInfo(args, deps) { | ||
| validateMcpServerName(args.name); | ||
| const entry = await deps.registryGetServer(args.name); | ||
| const trust = computeTrust(entry, deps); | ||
| return { | ||
| name: entry.server.name, | ||
| description: entry.server.description ?? "", | ||
| version: entry.server.version, | ||
| packages: entry.server.packages.map((p) => ({ | ||
| registryType: p.registryType, | ||
| identifier: p.identifier | ||
| })), | ||
| trustScore: trust | ||
| }; | ||
| } | ||
| async function handleList(args, deps) { | ||
| const clients = await resolveClients(args.client, deps); | ||
| const servers = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| const installed = await adapter.read(configPath); | ||
| for (const [name, entry] of Object.entries(installed)) { | ||
| const command = formatMcpEntryCommand(entry, "unknown"); | ||
| servers.push({ name, client: clientId2, command }); | ||
| } | ||
| } | ||
| return { servers }; | ||
| } | ||
| async function handleRemove(args, deps) { | ||
| validateMcpServerName(args.name); | ||
| const clients = await resolveClients(args.client, deps); | ||
| const removedClients = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| try { | ||
| await adapter.removeServer(configPath, args.name); | ||
| removedClients.push(clientId2); | ||
| } catch { | ||
| } | ||
| } | ||
| if (removedClients.length === 0) { | ||
| throw new Error(`Server "${args.name}" not found in any client config.`); | ||
| } | ||
| try { | ||
| await deps.removeFromStore(args.name); | ||
| } catch { | ||
| } | ||
| return { removed: true, name: args.name, clients: removedClients }; | ||
| } | ||
| async function handleAudit(deps) { | ||
| const clients = await deps.detectClients(); | ||
| const results = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| const installed = await adapter.read(configPath); | ||
| for (const name of Object.keys(installed)) { | ||
| try { | ||
| const entry = await deps.registryGetServer(name); | ||
| const trust = computeTrust(entry, deps); | ||
| results.push({ name, client: clientId2, trustScore: trust }); | ||
| } catch { | ||
| results.push({ | ||
| name, | ||
| client: clientId2, | ||
| trustScore: { score: 0, maxPossible: 80, level: "risky", breakdown: { healthCheck: 0, staticScan: 0, externalScan: 0, registryMeta: 0 } } | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| return { results }; | ||
| } | ||
| async function handleDoctor(deps) { | ||
| return buildDoctorModel({ | ||
| getAdapter: deps.getAdapter, | ||
| getConfigPath: deps.getConfigPath, | ||
| checkConfigExists: makeCheckConfigExists(deps.getConfigPath), | ||
| execCheck: execCheckDefault | ||
| }); | ||
| } | ||
| async function handleSetup(args, deps) { | ||
| if (!args.description.trim()) { | ||
| throw new Error("Could not extract any keywords from empty description."); | ||
| } | ||
| const keywords = extractKeywords(args.description); | ||
| const minScore = effectiveMinTrustScore(args.minTrustScore); | ||
| const installed = []; | ||
| const skipped = []; | ||
| const searchResults = await Promise.all( | ||
| keywords.map( | ||
| (kw) => deps.registrySearch(kw, 5).then((entries) => ({ ok: true, entries })).catch((err) => ({ | ||
| ok: false, | ||
| error: err instanceof Error ? err.message : String(err) | ||
| })) | ||
| ) | ||
| ); | ||
| const seenNames = /* @__PURE__ */ new Set(); | ||
| for (let i = 0; i < keywords.length; i++) { | ||
| const keyword = keywords[i]; | ||
| const outcome = searchResults[i]; | ||
| if (!outcome.ok) { | ||
| skipped.push({ name: keyword, reason: `Registry search failed: ${outcome.error}` }); | ||
| continue; | ||
| } | ||
| const entries = outcome.entries; | ||
| if (entries.length === 0) { | ||
| skipped.push({ name: keyword, reason: `No servers found for "${keyword}"` }); | ||
| continue; | ||
| } | ||
| let bestEntry = null; | ||
| let bestTrust = null; | ||
| for (const entry of entries) { | ||
| if (seenNames.has(entry.server.name)) continue; | ||
| const trust = computeTrust(entry, deps); | ||
| if (bestTrust === null || trust.score > bestTrust.score) { | ||
| bestEntry = entry; | ||
| bestTrust = trust; | ||
| } | ||
| } | ||
| if (bestEntry === null || bestTrust === null) { | ||
| skipped.push({ name: keyword, reason: "All results already installed or duplicated" }); | ||
| continue; | ||
| } | ||
| if (bestTrust.score < minScore) { | ||
| skipped.push({ | ||
| name: bestEntry.server.name, | ||
| reason: `Trust score ${bestTrust.score}/${bestTrust.maxPossible} is below minimum ${minScore}` | ||
| }); | ||
| continue; | ||
| } | ||
| try { | ||
| await handleInstall( | ||
| { name: bestEntry.server.name, client: args.client }, | ||
| deps, | ||
| { entry: bestEntry, trust: bestTrust } | ||
| ); | ||
| seenNames.add(bestEntry.server.name); | ||
| installed.push({ name: bestEntry.server.name, trustScore: bestTrust }); | ||
| } catch (err) { | ||
| skipped.push({ | ||
| name: bestEntry.server.name, | ||
| reason: `Install failed: ${err.message}` | ||
| }); | ||
| } | ||
| } | ||
| const note = installed.length > 0 ? "Restart your AI client to use the newly installed servers." : void 0; | ||
| return { installed, skipped, ...note ? { note } : {} }; | ||
| } | ||
| async function handleMcpUp(args, deps) { | ||
| const stackFile = args.stackFile ?? "mcpm.yaml"; | ||
| const resolved = path.resolve(process.cwd(), stackFile); | ||
| if (resolved !== process.cwd() && !resolved.startsWith(process.cwd() + path.sep)) { | ||
| throw new Error("stackFile must be within the working directory"); | ||
| } | ||
| { | ||
| const { realpath } = await import("fs/promises"); | ||
| try { | ||
| const [realStack, realCwd] = await Promise.all([ | ||
| realpath(resolved), | ||
| realpath(process.cwd()) | ||
| ]); | ||
| if (realStack !== realCwd && !realStack.startsWith(realCwd + path.sep)) { | ||
| throw new Error("stackFile must be within the working directory"); | ||
| } | ||
| } catch (err) { | ||
| const code = err.code ?? ""; | ||
| if (!["ENOENT", "ELOOP", "ENOTDIR"].includes(code)) throw err; | ||
| } | ||
| } | ||
| const { handleUp } = await import("./up-VGICTIUI.js"); | ||
| const { writeFile } = await import("fs/promises"); | ||
| const { handleLock } = await import("./lock-O7O3VM6R.js"); | ||
| const { RegistryClient } = await import("./client-3RPMRFZL.js"); | ||
| const { scanTier1: st1 } = await import("./tier1-VFXYMODG.js"); | ||
| const { checkScannerAvailable: csa, scanTier2: st2 } = await import("./tier2-DE35UF7V.js"); | ||
| const { computeTrustScore: cts } = await import("./trust-score-IP4Y5SAY.js"); | ||
| const client = new RegistryClient(); | ||
| const outputLines = []; | ||
| const records = []; | ||
| let thrownError; | ||
| try { | ||
| await handleUp( | ||
| { | ||
| stackFile, | ||
| profile: args.profile, | ||
| dryRun: args.dryRun, | ||
| ci: true, | ||
| yes: false, | ||
| // MCP surface lockdown (fixes C, D & H1): never auto-read ambient | ||
| // secrets from process.env OR the working-directory .env file, and never | ||
| // install URL servers (they bypass the registry trust gate). All three | ||
| // default to true on the CLI; the MCP (untrusted-caller) surface opts in | ||
| // to the locked-down behavior. | ||
| allowProcessEnv: false, | ||
| allowUrlServers: false, | ||
| allowEnvFile: false, | ||
| // M2: the batch `up` path must honor the same non-overridable trust floor | ||
| // the single-install MCP tool enforces (issue #24), so a low-trust server | ||
| // an agent could not install via mcpm_install can't slip in via mcpm_up. | ||
| minTrustFloor: HARD_TRUST_FLOOR | ||
| }, | ||
| { | ||
| detectClients: deps.detectClients, | ||
| getAdapter: deps.getAdapter, | ||
| getPath: deps.getConfigPath, | ||
| getServer: (name, version) => client.getServer(name, version), | ||
| scanTier1: st1, | ||
| checkScannerAvailable: csa, | ||
| scanTier2: (name) => st2(name), | ||
| computeTrustScore: cts, | ||
| runLock: async (stackFile2) => { | ||
| await handleLock( | ||
| { stackFile: stackFile2 }, | ||
| { | ||
| getServerVersions: (name) => client.getServerVersions(name), | ||
| getServer: (name, v) => client.getServer(name, v), | ||
| scanTier1: st1, | ||
| checkScannerAvailable: csa, | ||
| scanTier2: (name) => st2(name), | ||
| computeTrustScore: cts, | ||
| writeLockFile: (path2, content) => writeFile(path2, content, { encoding: "utf-8", mode: 384 }), | ||
| fetchNpmIntegrity, | ||
| fetchNpmProvenance: (id, ver, sri) => fetchNpmProvenance(id, ver, { integritySri: sri }), | ||
| output: (text) => outputLines.push(text) | ||
| } | ||
| ); | ||
| }, | ||
| // Issue #22: never auto-confirm on the MCP (no-human-in-loop) surface. | ||
| // The previous `async () => true` blanket-approved every confirmation, | ||
| // including strict-mode *removals* of servers not in mcpm.yaml — a | ||
| // prompt-injected agent could silently mutate client configs. Refusing | ||
| // confirmation here means destructive prompts are declined; the trust | ||
| // policy still gates installs via checkTrustPolicy in handleUp. | ||
| confirm: async () => false, | ||
| promptEnvVar: async () => "", | ||
| output: (text) => outputLines.push(text), | ||
| fetchNpmIntegrity, | ||
| // F8/B3: wire the provenance re-check on the MCP surface too, or a | ||
| // policy.frozen: true stack run through mcpm_up would silently skip it. | ||
| fetchNpmProvenance: (id, v, o) => fetchNpmProvenance(id, v, o), | ||
| readPins, | ||
| recordResult: (r) => records.push(r) | ||
| } | ||
| ); | ||
| } catch (err) { | ||
| thrownError = err instanceof Error ? err.message : String(err); | ||
| } | ||
| const installed = []; | ||
| const blocked = []; | ||
| const failed = []; | ||
| const skipped = []; | ||
| if (records.length > 0) { | ||
| for (const r of records) { | ||
| switch (r.status) { | ||
| case "installed": | ||
| installed.push(r.name); | ||
| break; | ||
| case "blocked": | ||
| blocked.push(r.name); | ||
| break; | ||
| case "failed": | ||
| failed.push(r.name); | ||
| break; | ||
| case "skipped": | ||
| case "removed": | ||
| skipped.push(r.name); | ||
| break; | ||
| } | ||
| } | ||
| } else { | ||
| for (const line of outputLines) { | ||
| if (line.includes("\u2713")) installed.push(line.trim()); | ||
| else if (line.includes("\u2717") && line.includes("blocked")) blocked.push(line.trim()); | ||
| else if (line.includes("\u2717")) failed.push(line.trim()); | ||
| else if (line.includes("\u2022")) skipped.push(line.trim()); | ||
| } | ||
| } | ||
| return { | ||
| installed, | ||
| blocked, | ||
| failed, | ||
| skipped, | ||
| ...thrownError !== void 0 ? { error: thrownError } : {}, | ||
| ...installed.length > 0 ? { note: "Restart your AI client to use the newly installed servers." } : {} | ||
| }; | ||
| } | ||
| var STOPWORDS = /\b(i need|set up|access|work with|connect to|a server that|a server for|to|the|a|an|my|for|and|with)\b/gi; | ||
| function extractKeywords(description) { | ||
| const cleaned = description.toLowerCase().replace(STOPWORDS, " ").replace(/[,&]/g, " "); | ||
| const tokens = cleaned.split(/\s+/).map((s) => s.trim()).filter((s) => s.length > 2); | ||
| if (tokens.length > 5) { | ||
| return [cleaned.replace(/\s+/g, " ").trim()]; | ||
| } | ||
| return tokens.length > 0 ? tokens : [description.trim()]; | ||
| } | ||
| // src/server/index.ts | ||
| async function createDeps() { | ||
| const { RegistryClient } = await import("./client-3RPMRFZL.js"); | ||
| const { detectInstalledClients } = await import("./detector-ZI4OWRCJ.js"); | ||
| const { getConfigPath } = await import("./paths-US27HRTP.js"); | ||
| const { getAdapter } = await import("./config-XMU247VO.js"); | ||
| const { scanTier1 } = await import("./tier1-VFXYMODG.js"); | ||
| const { computeTrustScore } = await import("./trust-score-IP4Y5SAY.js"); | ||
| const { addInstalledServer, removeInstalledServer } = await import("./servers-WFV3RC3Z.js"); | ||
| const client = new RegistryClient(); | ||
| return { | ||
| registrySearch: async (query, limit) => { | ||
| const result = await client.searchServers(query, { limit }); | ||
| return result.servers; | ||
| }, | ||
| registryGetServer: (name) => client.getServer(name), | ||
| detectClients: detectInstalledClients, | ||
| getAdapter, | ||
| getConfigPath, | ||
| scanTier1, | ||
| computeTrustScore, | ||
| addToStore: addInstalledServer, | ||
| removeFromStore: removeInstalledServer | ||
| }; | ||
| } | ||
| function registerTools(server, deps) { | ||
| server.registerTool("mcpm_search", { | ||
| description: "Search the MCP registry for servers with trust scores", | ||
| inputSchema: SearchInput, | ||
| annotations: { readOnlyHint: true } | ||
| }, async (args) => { | ||
| const result = await handleSearch(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_install", { | ||
| description: "Install an MCP server with trust assessment", | ||
| inputSchema: InstallInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleInstall(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_info", { | ||
| description: "Show full details and trust score for an MCP server", | ||
| inputSchema: InfoInput, | ||
| annotations: { readOnlyHint: true } | ||
| }, async (args) => { | ||
| const result = await handleInfo(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_list", { | ||
| description: "List installed MCP servers across AI clients", | ||
| inputSchema: ListInput, | ||
| annotations: { readOnlyHint: true } | ||
| }, async (args) => { | ||
| const result = await handleList(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_remove", { | ||
| description: "Remove an MCP server from client configs", | ||
| inputSchema: RemoveInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleRemove(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_audit", { | ||
| inputSchema: NoArgsInput, | ||
| description: "Scan all installed servers and produce trust report", | ||
| annotations: { readOnlyHint: true } | ||
| }, async () => { | ||
| const result = await handleAudit(deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_doctor", { | ||
| inputSchema: NoArgsInput, | ||
| description: "Check MCP setup health", | ||
| annotations: { readOnlyHint: true } | ||
| }, async () => { | ||
| const result = await handleDoctor(deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_setup", { | ||
| description: "Install MCP servers from a natural language description", | ||
| inputSchema: SetupInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleSetup(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_up", { | ||
| description: "Install all servers from an mcpm.yaml stack file with trust verification. Equivalent to docker-compose up for MCP servers. Runs trust re-assessment and blocks servers that violate the trust policy. Pass profile to install only servers matching that profile, or dryRun to preview what would be installed without making changes.", | ||
| inputSchema: UpInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleMcpUp(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| } | ||
| async function startServer() { | ||
| const deps = await createDeps(); | ||
| const server = new McpServer({ | ||
| name: "mcpm", | ||
| // Issue #22: advertise the real package version (injected by tsup at build), | ||
| // not a hardcoded stale "0.1.0". | ||
| version: "0.27.0" | ||
| }); | ||
| registerTools(server, deps); | ||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); | ||
| } | ||
| export { | ||
| registerTools, | ||
| startServer | ||
| }; | ||
| //# sourceMappingURL=server-T4II2WP6.js.map |
| {"version":3,"sources":["../src/server/index.ts","../src/server/tools.ts","../src/server/handlers.ts"],"sourcesContent":["/**\n * MCP server for mcpm — exposes search, install, audit, and setup as tools.\n *\n * Uses @modelcontextprotocol/sdk with stdio transport.\n * All logic delegates to handlers.ts which wraps existing mcpm functions.\n */\n\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport {\n NoArgsInput,\n SearchInput,\n InstallInput,\n InfoInput,\n ListInput,\n RemoveInput,\n SetupInput,\n UpInput,\n} from \"./tools.js\";\nimport {\n handleSearch,\n handleInstall,\n handleInfo,\n handleList,\n handleRemove,\n handleAudit,\n handleDoctor,\n handleSetup,\n handleMcpUp,\n} from \"./handlers.js\";\nimport type { ServerDeps } from \"./handlers.js\";\n\n// ---------------------------------------------------------------------------\n// Wire up real dependencies\n// ---------------------------------------------------------------------------\n\nasync function createDeps(): Promise<ServerDeps> {\n const { RegistryClient } = await import(\"../registry/client.js\");\n const { detectInstalledClients } = await import(\"../config/detector.js\");\n const { getConfigPath } = await import(\"../config/paths.js\");\n const { getAdapter } = await import(\"../config/index.js\");\n const { scanTier1 } = await import(\"../scanner/tier1.js\");\n const { computeTrustScore } = await import(\"../scanner/trust-score.js\");\n const { addInstalledServer, removeInstalledServer } = await import(\"../store/servers.js\");\n\n const client = new RegistryClient();\n\n return {\n registrySearch: async (query, limit) => {\n const result = await client.searchServers(query, { limit });\n return result.servers;\n },\n registryGetServer: (name) => client.getServer(name),\n detectClients: detectInstalledClients,\n getAdapter,\n getConfigPath,\n scanTier1,\n computeTrustScore,\n addToStore: addInstalledServer,\n removeFromStore: removeInstalledServer,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Server setup\n// ---------------------------------------------------------------------------\n\n/**\n * Register every mcpm tool on the server. Extracted from startServer so the\n * registration can be unit-tested (fix F.1): a test spies registerTool and\n * asserts every TOOL_DEFINITIONS name is registered exactly once, guarding\n * against future tool/registration divergence.\n *\n * `server` is typed loosely as `Pick<McpServer, \"registerTool\">` so tests can\n * pass a lightweight spy without constructing a full McpServer.\n */\nexport function registerTools(\n server: Pick<McpServer, \"registerTool\">,\n deps: ServerDeps\n): void {\n // Register tools using registerTool API\n server.registerTool(\"mcpm_search\", {\n description: \"Search the MCP registry for servers with trust scores\",\n inputSchema: SearchInput,\n annotations: { readOnlyHint: true },\n }, async (args) => {\n const result = await handleSearch(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_install\", {\n description: \"Install an MCP server with trust assessment\",\n inputSchema: InstallInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleInstall(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_info\", {\n description: \"Show full details and trust score for an MCP server\",\n inputSchema: InfoInput,\n annotations: { readOnlyHint: true },\n }, async (args) => {\n const result = await handleInfo(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_list\", {\n description: \"List installed MCP servers across AI clients\",\n inputSchema: ListInput,\n annotations: { readOnlyHint: true },\n }, async (args) => {\n const result = await handleList(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_remove\", {\n description: \"Remove an MCP server from client configs\",\n inputSchema: RemoveInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleRemove(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_audit\", {\n inputSchema: NoArgsInput,\n description: \"Scan all installed servers and produce trust report\",\n annotations: { readOnlyHint: true },\n }, async () => {\n const result = await handleAudit(deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_doctor\", {\n inputSchema: NoArgsInput,\n description: \"Check MCP setup health\",\n annotations: { readOnlyHint: true },\n }, async () => {\n const result = await handleDoctor(deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_setup\", {\n description: \"Install MCP servers from a natural language description\",\n inputSchema: SetupInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleSetup(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_up\", {\n description: \"Install all servers from an mcpm.yaml stack file with trust verification. Equivalent to docker-compose up for MCP servers. Runs trust re-assessment and blocks servers that violate the trust policy. Pass profile to install only servers matching that profile, or dryRun to preview what would be installed without making changes.\",\n inputSchema: UpInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleMcpUp(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n}\n\nexport async function startServer(): Promise<void> {\n const deps = await createDeps();\n\n const server = new McpServer({\n name: \"mcpm\",\n // Issue #22: advertise the real package version (injected by tsup at build),\n // not a hardcoded stale \"0.1.0\".\n version: __PKG_VERSION__,\n });\n\n registerTools(server, deps);\n\n // Start stdio transport\n const transport = new StdioServerTransport();\n await server.connect(transport);\n}\n","/**\n * MCP tool definitions for mcpm serve.\n *\n * Each tool has a name, description, and Zod input schema.\n * Handlers are in handlers.ts.\n */\n\nimport { z } from \"zod\";\nimport { CLIENT_IDS } from \"../config/paths.js\";\n\nexport const TOOL_DEFINITIONS = [\n {\n name: \"mcpm_search\",\n description: \"Search the MCP registry for servers. Returns results with trust scores.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n query: { type: \"string\", description: \"Search query (substring match on server name)\" },\n limit: { type: \"number\", description: \"Max results to return (default 20)\" },\n },\n required: [\"query\"],\n },\n },\n {\n name: \"mcpm_install\",\n description: \"Install an MCP server from the registry into detected AI client configs. Runs trust assessment automatically. Rejects servers below the minimum trust score (default 50).\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n name: { type: \"string\", description: \"Server name (e.g. io.github.domdomegg/filesystem-mcp)\" },\n client: { type: \"string\", description: \"Install to specific client only (claude-desktop, cursor, vscode, windsurf)\" },\n minTrustScore: { type: \"number\", description: \"Minimum trust score to allow install (default 50, range 0-100)\" },\n },\n required: [\"name\"],\n },\n },\n {\n name: \"mcpm_info\",\n description: \"Show full details for an MCP server including trust score breakdown.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n name: { type: \"string\", description: \"Server name\" },\n },\n required: [\"name\"],\n },\n },\n {\n name: \"mcpm_list\",\n description: \"List all installed MCP servers across detected AI clients.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n client: { type: \"string\", description: \"Filter to specific client\" },\n },\n required: [],\n },\n },\n {\n name: \"mcpm_remove\",\n description: \"Remove an MCP server from AI client configs.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n name: { type: \"string\", description: \"Server name to remove\" },\n client: { type: \"string\", description: \"Remove from specific client only\" },\n },\n required: [\"name\"],\n },\n },\n {\n name: \"mcpm_audit\",\n description: \"Scan all installed MCP servers and produce a trust report with scores.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {},\n required: [],\n },\n },\n {\n name: \"mcpm_doctor\",\n description: \"Check MCP setup health: detected clients, available runtimes, configuration issues.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {},\n required: [],\n },\n },\n {\n name: \"mcpm_setup\",\n description: \"Install MCP servers from a natural language description. Searches, evaluates trust, installs the best match for each keyword. Example: 'filesystem and GitHub' installs filesystem + GitHub servers.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n description: { type: \"string\", description: \"What you need (e.g. 'filesystem access and GitHub integration')\" },\n client: { type: \"string\", description: \"Install to specific client only\" },\n minTrustScore: { type: \"number\", description: \"Minimum trust score to auto-install (default 50, range 0-100)\" },\n },\n required: [\"description\"],\n },\n },\n {\n name: \"mcpm_up\",\n description: \"Install all servers from an mcpm.yaml stack file with trust verification. Equivalent to docker-compose up for MCP servers. Runs trust re-assessment and blocks servers that violate the trust policy. Pass profile to install only servers matching that profile, or dryRun to preview what would be installed without making changes.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n stackFile: { type: \"string\", description: \"Path to mcpm.yaml (default: mcpm.yaml in CWD)\" },\n profile: { type: \"string\", description: \"Install only servers matching this profile\" },\n dryRun: { type: \"boolean\", description: \"Show what would be installed without making changes\" },\n },\n required: [],\n },\n },\n] as const;\n\n// Shared field schemas (security #31): a bounded server-name string and a closed\n// client enum, so the Zod layer — not just the runtime `validateMcpServerName` /\n// `CLIENT_IDS.includes` checks in handlers.ts — is the declarative enforcement\n// point. The objects below are `strictObject` so unknown keys are rejected\n// instead of silently dropped.\n//\n// These are passed to `registerTool` WHOLE (not via `.shape`) — see\n// server/index.ts. That distinction is load-bearing: the SDK accepts either a\n// raw shape or a full schema, but a raw shape is rebuilt as a plain\n// `z.object(shape)`, which silently DROPS the object-level `strict` setting.\n// Per-field constraints (the length bound, the client enum) survive either way;\n// strictness does not.\n//\n// Passing the whole schema means the SDK rejects unknown keys with a JSON-RPC\n// -32602 `unrecognized_keys` error, AND advertises `additionalProperties: false`\n// in `tools/list` so a caller can see the contract before calling. Verified over\n// a real in-memory MCP transport in server-strict-schema.test.ts.\n//\n// The runtime guards in handlers.ts (`validateMcpServerName`, `CLIENT_IDS`)\n// remain as defence in depth.\nconst serverName = z.string().min(1).max(256);\nconst clientId = z.enum(CLIENT_IDS);\n\n/**\n * Zero-argument tools (`mcpm_audit`, `mcpm_doctor`) still declare a CLOSED\n * schema rather than omitting `inputSchema` entirely. Omitting it advertises no\n * `additionalProperties: false`, so any argument a caller passes is silently\n * ignored — for a tool that takes nothing, that means EVERY argument is\n * silently ignored. An empty strict object makes the contract explicit and\n * turns a mistaken call into a clear error.\n */\nexport const NoArgsInput = z.strictObject({});\n\nexport const SearchInput = z.strictObject({\n query: z.string().min(1).max(200),\n limit: z.number().int().min(1).max(100).optional().default(20),\n});\n\nexport const InstallInput = z.strictObject({\n name: serverName,\n client: clientId.optional(),\n minTrustScore: z.number().min(0).max(100).optional().default(50),\n});\n\nexport const InfoInput = z.strictObject({\n name: serverName,\n});\n\nexport const ListInput = z.strictObject({\n client: clientId.optional(),\n});\n\nexport const RemoveInput = z.strictObject({\n name: serverName,\n client: clientId.optional(),\n});\n\nexport const SetupInput = z.strictObject({\n description: z.string().min(1).max(1000),\n client: clientId.optional(),\n minTrustScore: z.number().min(0).max(100).optional().default(50),\n});\n\nexport const UpInput = z.strictObject({\n stackFile: z.string().optional().default(\"mcpm.yaml\"),\n profile: z.string().optional(),\n dryRun: z.boolean().optional().default(false),\n});\n","/**\n * MCP tool handlers for mcpm serve.\n *\n * Each handler wraps existing mcpm logic and returns structured JSON.\n * All dependencies are injectable for testability.\n */\n\nimport path from \"node:path\";\nimport type { ClientId } from \"../config/paths.js\";\nimport { CLIENT_IDS } from \"../config/paths.js\";\nimport type { ConfigAdapter } from \"../config/adapters/index.js\";\nimport type { ServerEntry } from \"../registry/types.js\";\nimport type { Finding } from \"../scanner/tier1.js\";\nimport type { TrustScore, TrustScoreInput } from \"../scanner/trust-score.js\";\nimport { extractRegistryMeta } from \"../utils/format-trust.js\";\nimport { formatMcpEntryCommand } from \"../utils/format-entry.js\";\nimport { resolveInstallEntry } from \"../commands/install.js\";\nimport { buildDoctorModel, makeCheckConfigExists, execCheckDefault } from \"../commands/doctor.js\";\nimport { fetchNpmIntegrity as _fetchNpmIntegrity } from \"../registry/npm-integrity.js\";\nimport { fetchNpmProvenance as _fetchNpmProvenance } from \"../registry/npm-provenance.js\";\nimport { readPins as _readPins } from \"../guard/pins.js\";\n\n// ---------------------------------------------------------------------------\n// Input validation for MCP server tool arguments\n// ---------------------------------------------------------------------------\n\n/**\n * Server name pattern for MCP registry names.\n * Format: \"namespace/server-name\" — alphanumeric with dots, hyphens, underscores.\n * Max length 256 to prevent abuse. Must not contain shell metacharacters,\n * path traversal sequences, or control characters.\n */\nconst SERVER_NAME_RE =\n /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}\\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}$/;\n\n/**\n * Validate a server name received from an MCP tool call.\n * This is the trust boundary — AI agents provide these strings, and they\n * could be influenced by prompt injection or adversarial inputs.\n */\nfunction validateMcpServerName(name: string): void {\n if (typeof name !== \"string\" || name.length === 0 || name.length > 256) {\n throw new Error(`Invalid server name: must be a non-empty string under 256 characters.`);\n }\n if (!SERVER_NAME_RE.test(name)) {\n throw new Error(\n `Invalid server name format: \"${name}\". Expected format: \"namespace/server-name\" ` +\n `(alphanumeric, dots, hyphens, underscores only).`\n );\n }\n}\n\n// ---------------------------------------------------------------------------\n// Dependency injection types\n// ---------------------------------------------------------------------------\n\nexport interface ServerDeps {\n registrySearch: (query: string, limit: number) => Promise<ServerEntry[]>;\n registryGetServer: (name: string) => Promise<ServerEntry>;\n detectClients: () => Promise<ClientId[]>;\n getAdapter: (clientId: ClientId) => ConfigAdapter;\n getConfigPath: (clientId: ClientId) => string;\n scanTier1: (server: ServerEntry) => Finding[];\n computeTrustScore: (input: TrustScoreInput) => TrustScore;\n addToStore: (server: { name: string; version: string; clients: ClientId[]; installedAt: string }) => Promise<void>;\n removeFromStore: (name: string) => Promise<void>;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * F4 scope note: this helper deliberately does NOT include the\n * release-cooldown finding (ServerDeps has no injectable clock; the F4 spec\n * file list excludes server/). Consequence: mcpm_install / mcpm_search score\n * a fresh (<24h) package up to 5 points higher than CLI install/why AND than\n * the sibling mcpm_up tool (which inherits the finding via up.ts\n * processServer), and HARD_TRUST_FLOOR evaluates that inflated score — do NOT\n * compensate by raising the floor. Fast-follow is mechanical:\n * ServerDeps += now?: () => number, then append\n * assessReleaseAge({...}).finding here; no schema changes.\n */\nfunction computeTrust(entry: ServerEntry, deps: ServerDeps): TrustScore {\n const findings = deps.scanTier1(entry);\n return deps.computeTrustScore({\n findings,\n healthCheckPassed: null,\n hasExternalScanner: false,\n registryMeta: extractRegistryMeta(entry),\n });\n}\n\nasync function resolveClients(\n requestedClient: string | undefined,\n deps: ServerDeps\n): Promise<ClientId[]> {\n const detected = await deps.detectClients();\n if (detected.length === 0) {\n throw new Error(\"No supported AI clients found.\");\n }\n if (requestedClient !== undefined) {\n if (!CLIENT_IDS.includes(requestedClient as ClientId)) {\n throw new Error(\n `Unknown client \"${requestedClient}\". Valid values: ${CLIENT_IDS.join(\", \")}.`\n );\n }\n const id = requestedClient as ClientId;\n if (!detected.includes(id)) {\n throw new Error(`Client \"${requestedClient}\" is not installed.`);\n }\n return [id];\n }\n return detected;\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nexport async function handleSearch(\n args: { query: string; limit: number },\n deps: ServerDeps\n): Promise<object> {\n const entries = await deps.registrySearch(args.query, args.limit);\n const servers = entries.map((entry) => {\n const trust = computeTrust(entry, deps);\n return {\n name: entry.server.name,\n description: entry.server.description ?? \"\",\n version: entry.server.version,\n trustScore: trust.score,\n };\n });\n return { servers };\n}\n\n/** Default minimum trust score for MCP server tool installs (no human in the loop). */\nconst DEFAULT_MIN_TRUST_SCORE = 50;\n\n/**\n * Hard, non-overridable trust floor for the MCP server surface (issue #24).\n *\n * The MCP `minTrustScore` input accepts `0`, which a prompt-injected agent could\n * pass to disable the install gate entirely. We clamp the effective threshold to\n * `Math.max(userValue, HARD_TRUST_FLOOR)` so no caller-supplied value can lower\n * the gate below this floor. This protects the no-human-in-loop path; the CLI\n * (with a human confirmation prompt) is the only place to install below it.\n */\nconst HARD_TRUST_FLOOR = 25;\n\n/** Clamp a requested minimum trust score so it can never sink below the floor. */\nfunction effectiveMinTrustScore(requested: number | undefined): number {\n return Math.max(requested ?? DEFAULT_MIN_TRUST_SCORE, HARD_TRUST_FLOOR);\n}\n\nexport async function handleInstall(\n args: { name: string; client?: string; minTrustScore?: number },\n deps: ServerDeps,\n preResolved?: { entry: ServerEntry; trust: TrustScore }\n): Promise<object> {\n validateMcpServerName(args.name);\n const entry = preResolved?.entry ?? await deps.registryGetServer(args.name);\n const trust = preResolved?.trust ?? computeTrust(entry, deps);\n\n // Security gate: reject servers below the minimum trust score.\n // Unlike the CLI path which has a human confirmation prompt, the MCP server\n // path is driven by AI agents with no human in the loop. A malicious prompt\n // could trick an agent into installing a dangerous server, so we enforce a\n // hard trust floor here. Issue #24: minTrustScore:0 must NOT disable the gate —\n // the effective threshold is clamped to HARD_TRUST_FLOOR.\n const minScore = effectiveMinTrustScore(args.minTrustScore);\n if (trust.score < minScore) {\n throw new Error(\n `Server \"${args.name}\" has trust score ${trust.score}/${trust.maxPossible} ` +\n `(level: ${trust.level}), which is below the minimum threshold of ${minScore}. ` +\n `Install rejected for safety. Use mcpm CLI with --yes to override after manual review.`\n );\n }\n\n const clients = await resolveClients(args.client, deps);\n\n const installedClients: ClientId[] = [];\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n const mcpEntry = resolveInstallEntry(entry, clientId);\n // H9 (fail-closed): a URL/HTTP-transport entry (url, no command) runs\n // UNGUARDED — the guard relay only wraps a stdio process. The MCP surface is\n // driven by an untrusted agent with no human in the loop and no\n // `--allow-unguarded` opt-in, so url-transport installs are HARD-DENIED here\n // (mirrors the batch `up` MCP wiring's allowUrlServers:false kill-switch).\n if (mcpEntry.url !== undefined && mcpEntry.command === undefined) {\n throw new Error(\n `Server \"${args.name}\" uses a URL/HTTP transport and runs UNGUARDED ` +\n `(the guard relay only wraps stdio servers). Installing it is not permitted ` +\n `via the MCP surface. Use the mcpm CLI with --allow-unguarded after manual review.`\n );\n }\n await adapter.addServer(configPath, args.name, mcpEntry);\n installedClients.push(clientId);\n }\n\n await deps.addToStore({\n name: args.name,\n version: entry.server.version,\n clients: [...installedClients],\n installedAt: new Date().toISOString(),\n });\n\n return {\n installed: true,\n name: args.name,\n version: entry.server.version,\n clients: installedClients,\n trustScore: trust,\n };\n}\n\nexport async function handleInfo(\n args: { name: string },\n deps: ServerDeps\n): Promise<object> {\n validateMcpServerName(args.name);\n const entry = await deps.registryGetServer(args.name);\n const trust = computeTrust(entry, deps);\n return {\n name: entry.server.name,\n description: entry.server.description ?? \"\",\n version: entry.server.version,\n packages: entry.server.packages.map((p) => ({\n registryType: p.registryType,\n identifier: p.identifier,\n })),\n trustScore: trust,\n };\n}\n\nexport async function handleList(\n args: { client?: string },\n deps: ServerDeps\n): Promise<object> {\n const clients = await resolveClients(args.client, deps);\n const servers: Array<{ name: string; client: string; command: string }> = [];\n\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n const installed = await adapter.read(configPath);\n\n for (const [name, entry] of Object.entries(installed)) {\n const command = formatMcpEntryCommand(entry, \"unknown\");\n servers.push({ name, client: clientId, command });\n }\n }\n\n return { servers };\n}\n\nexport async function handleRemove(\n args: { name: string; client?: string },\n deps: ServerDeps\n): Promise<object> {\n validateMcpServerName(args.name);\n const clients = await resolveClients(args.client, deps);\n const removedClients: ClientId[] = [];\n\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n try {\n await adapter.removeServer(configPath, args.name);\n removedClients.push(clientId);\n } catch {\n // Server not in this client, skip\n }\n }\n\n if (removedClients.length === 0) {\n throw new Error(`Server \"${args.name}\" not found in any client config.`);\n }\n\n try {\n await deps.removeFromStore(args.name);\n } catch {\n // Not in store, fine\n }\n\n return { removed: true, name: args.name, clients: removedClients };\n}\n\nexport async function handleAudit(deps: ServerDeps): Promise<object> {\n const clients = await deps.detectClients();\n const results: Array<{ name: string; client: string; trustScore: TrustScore }> = [];\n\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n const installed = await adapter.read(configPath);\n\n for (const name of Object.keys(installed)) {\n try {\n const entry = await deps.registryGetServer(name);\n const trust = computeTrust(entry, deps);\n results.push({ name, client: clientId, trustScore: trust });\n } catch {\n results.push({\n name,\n client: clientId,\n trustScore: { score: 0, maxPossible: 80, level: \"risky\", breakdown: { healthCheck: 0, staticScan: 0, externalScan: 0, registryMeta: 0 } },\n });\n }\n }\n }\n\n return { results };\n}\n\nexport async function handleDoctor(deps: ServerDeps): Promise<object> {\n // Reuse the CLI's structured model so this tool reports real issues instead of\n // the formerly-hardcoded `issues: []` (D7). Honors the injected getConfigPath.\n return buildDoctorModel({\n getAdapter: deps.getAdapter,\n getConfigPath: deps.getConfigPath,\n checkConfigExists: makeCheckConfigExists(deps.getConfigPath),\n execCheck: execCheckDefault,\n });\n}\n\nexport async function handleSetup(\n args: { description: string; client?: string; minTrustScore: number },\n deps: ServerDeps\n): Promise<object> {\n if (!args.description.trim()) {\n throw new Error(\"Could not extract any keywords from empty description.\");\n }\n const keywords = extractKeywords(args.description);\n\n // Issue #24: clamp to the hard floor so minTrustScore:0 can't disable the gate\n // on the no-human-in-loop setup path either.\n const minScore = effectiveMinTrustScore(args.minTrustScore);\n\n const installed: Array<{ name: string; trustScore: TrustScore }> = [];\n const skipped: Array<{ name: string; reason: string }> = [];\n\n // Parallel search pass — all keywords searched concurrently. Capture the\n // thrown error per keyword so a registry outage is distinguishable from a\n // genuine empty result (both otherwise look like \"no servers\").\n type SearchOutcome =\n | { ok: true; entries: ServerEntry[] }\n | { ok: false; error: string };\n const searchResults: SearchOutcome[] = await Promise.all(\n keywords.map((kw) =>\n deps\n .registrySearch(kw, 5)\n .then((entries): SearchOutcome => ({ ok: true, entries }))\n .catch((err): SearchOutcome => ({\n ok: false,\n error: err instanceof Error ? err.message : String(err),\n }))\n )\n );\n\n const seenNames = new Set<string>();\n\n // Sequential evaluate/install pass (installs depend on previous state)\n for (let i = 0; i < keywords.length; i++) {\n const keyword = keywords[i];\n const outcome = searchResults[i];\n\n if (!outcome.ok) {\n skipped.push({ name: keyword, reason: `Registry search failed: ${outcome.error}` });\n continue;\n }\n\n const entries = outcome.entries;\n\n if (entries.length === 0) {\n skipped.push({ name: keyword, reason: `No servers found for \"${keyword}\"` });\n continue;\n }\n\n let bestEntry: ServerEntry | null = null;\n let bestTrust: TrustScore | null = null;\n\n for (const entry of entries) {\n if (seenNames.has(entry.server.name)) continue;\n const trust = computeTrust(entry, deps);\n if (bestTrust === null || trust.score > bestTrust.score) {\n bestEntry = entry;\n bestTrust = trust;\n }\n }\n\n if (bestEntry === null || bestTrust === null) {\n skipped.push({ name: keyword, reason: \"All results already installed or duplicated\" });\n continue;\n }\n\n if (bestTrust.score < minScore) {\n skipped.push({\n name: bestEntry.server.name,\n reason: `Trust score ${bestTrust.score}/${bestTrust.maxPossible} is below minimum ${minScore}`,\n });\n continue;\n }\n\n try {\n await handleInstall(\n { name: bestEntry.server.name, client: args.client },\n deps,\n { entry: bestEntry, trust: bestTrust }\n );\n seenNames.add(bestEntry.server.name);\n installed.push({ name: bestEntry.server.name, trustScore: bestTrust });\n } catch (err) {\n skipped.push({\n name: bestEntry.server.name,\n reason: `Install failed: ${(err as Error).message}`,\n });\n }\n }\n\n const note = installed.length > 0\n ? \"Restart your AI client to use the newly installed servers.\"\n : undefined;\n\n return { installed, skipped, ...(note ? { note } : {}) };\n}\n\n// ---------------------------------------------------------------------------\n// mcpm_up — batch install from stack file\n// ---------------------------------------------------------------------------\n\nexport async function handleMcpUp(\n args: { stackFile?: string; profile?: string; dryRun?: boolean },\n deps: ServerDeps\n): Promise<{\n installed: string[];\n blocked: string[];\n failed: string[];\n skipped: string[];\n error?: string;\n note?: string;\n}> {\n // Validate stackFile path (AI agent trust boundary). Zod defaults stackFile to\n // \"mcpm.yaml\", so the old `if (args.stackFile !== undefined)` guard was dead.\n // Enforce real containment unconditionally via resolved paths: path.resolve\n // normalizes Windows backslashes and \"..\", so this catches traversal and\n // absolute escapes that string-only checks miss.\n const stackFile = args.stackFile ?? \"mcpm.yaml\";\n const resolved = path.resolve(process.cwd(), stackFile);\n if (\n resolved !== process.cwd() &&\n !resolved.startsWith(process.cwd() + path.sep)\n ) {\n throw new Error(\"stackFile must be within the working directory\");\n }\n // M3: the lexical check above catches \"../\" and absolute escapes, but NOT a\n // symlink that lives inside cwd yet points outside it — the file reader would\n // follow it (arbitrary out-of-tree read). Resolve the REAL path and re-check.\n // realpath throws ENOENT when the file does not exist yet; that's fine — handleUp\n // reports the missing file. A containment failure thrown inside the try is not\n // an ErrnoException, so the catch re-throws it.\n {\n const { realpath } = await import(\"node:fs/promises\");\n try {\n const [realStack, realCwd] = await Promise.all([\n realpath(resolved),\n realpath(process.cwd()),\n ]);\n if (realStack !== realCwd && !realStack.startsWith(realCwd + path.sep)) {\n throw new Error(\"stackFile must be within the working directory\");\n }\n } catch (err) {\n // ENOENT (no such file), ELOOP (circular symlink), and ENOTDIR (a path\n // component is a file) all mean \"no real path to contain\" — fall through and\n // let handleUp report the missing/invalid file. Re-throwing them would leak a\n // raw internal ErrnoException (with stack) to the untrusted caller. The\n // containment Error thrown just above has no `.code`, so it still propagates.\n const code = (err as NodeJS.ErrnoException).code ?? \"\";\n if (![\"ENOENT\", \"ELOOP\", \"ENOTDIR\"].includes(code)) throw err;\n }\n }\n\n const { handleUp } = await import(\"../commands/up.js\");\n const { writeFile } = await import(\"fs/promises\");\n const { handleLock } = await import(\"../commands/lock.js\");\n const { RegistryClient } = await import(\"../registry/client.js\");\n const { scanTier1: st1 } = await import(\"../scanner/tier1.js\");\n const { checkScannerAvailable: csa, scanTier2: st2 } = await import(\"../scanner/tier2.js\");\n const { computeTrustScore: cts } = await import(\"../scanner/trust-score.js\");\n\n const client = new RegistryClient();\n const outputLines: string[] = [];\n // Fix A/D: structured per-server results from handleUp. Authoritative source\n // for categorization — emoji-scraping cannot distinguish blocked from failed.\n const records: Array<{ name: string; status: string }> = [];\n let thrownError: string | undefined;\n\n try {\n await handleUp(\n {\n stackFile,\n profile: args.profile,\n dryRun: args.dryRun,\n ci: true,\n yes: false,\n // MCP surface lockdown (fixes C, D & H1): never auto-read ambient\n // secrets from process.env OR the working-directory .env file, and never\n // install URL servers (they bypass the registry trust gate). All three\n // default to true on the CLI; the MCP (untrusted-caller) surface opts in\n // to the locked-down behavior.\n allowProcessEnv: false,\n allowUrlServers: false,\n allowEnvFile: false,\n // M2: the batch `up` path must honor the same non-overridable trust floor\n // the single-install MCP tool enforces (issue #24), so a low-trust server\n // an agent could not install via mcpm_install can't slip in via mcpm_up.\n minTrustFloor: HARD_TRUST_FLOOR,\n },\n {\n detectClients: deps.detectClients,\n getAdapter: deps.getAdapter,\n getPath: deps.getConfigPath,\n getServer: (name, version?) => client.getServer(name, version),\n scanTier1: st1,\n checkScannerAvailable: csa,\n scanTier2: (name) => st2(name),\n computeTrustScore: cts,\n runLock: async (stackFile) => {\n await handleLock(\n { stackFile },\n {\n getServerVersions: (name) => client.getServerVersions(name),\n getServer: (name, v?) => client.getServer(name, v),\n scanTier1: st1,\n checkScannerAvailable: csa,\n scanTier2: (name) => st2(name),\n computeTrustScore: cts,\n writeLockFile: (path, content) =>\n writeFile(path, content, { encoding: \"utf-8\", mode: 0o600 }),\n fetchNpmIntegrity: _fetchNpmIntegrity,\n fetchNpmProvenance: (id, ver, sri) => _fetchNpmProvenance(id, ver, { integritySri: sri }),\n output: (text) => outputLines.push(text),\n }\n );\n },\n // Issue #22: never auto-confirm on the MCP (no-human-in-loop) surface.\n // The previous `async () => true` blanket-approved every confirmation,\n // including strict-mode *removals* of servers not in mcpm.yaml — a\n // prompt-injected agent could silently mutate client configs. Refusing\n // confirmation here means destructive prompts are declined; the trust\n // policy still gates installs via checkTrustPolicy in handleUp.\n confirm: async () => false,\n promptEnvVar: async () => \"\",\n output: (text) => outputLines.push(text),\n fetchNpmIntegrity: _fetchNpmIntegrity,\n // F8/B3: wire the provenance re-check on the MCP surface too, or a\n // policy.frozen: true stack run through mcpm_up would silently skip it.\n fetchNpmProvenance: (id, v, o) => _fetchNpmProvenance(id, v, o),\n readPins: _readPins,\n recordResult: (r) => records.push(r),\n }\n );\n } catch (err) {\n // Fix A: handleUp throws on early/whole-batch failures (no clients, lock-file\n // creation failure, missing required env in CI, the summary \"N could not be\n // installed\" throw, etc.). The previous bare catch swallowed these into a\n // clean-looking empty result. Capture the message so the caller can never\n // mistake a thrown failure for success.\n thrownError = err instanceof Error ? err.message : String(err);\n }\n\n const installed: string[] = [];\n const blocked: string[] = [];\n const failed: string[] = [];\n const skipped: string[] = [];\n\n if (records.length > 0) {\n // Authoritative path (fix D, F.3/F.5): categorize from handleUp's typed\n // per-server statuses. Unlike emoji-scraping, this reliably separates\n // \"blocked\" (policy/URL-lockdown) from \"failed\".\n for (const r of records) {\n switch (r.status) {\n case \"installed\": installed.push(r.name); break;\n case \"blocked\": blocked.push(r.name); break;\n case \"failed\": failed.push(r.name); break;\n case \"skipped\":\n case \"removed\": skipped.push(r.name); break;\n }\n }\n } else {\n // Fallback for the no-record path (e.g. a throw before any server is\n // processed): preserve the original output-line parsing.\n for (const line of outputLines) {\n if (line.includes(\"\\u2713\")) installed.push(line.trim());\n else if (line.includes(\"\\u2717\") && line.includes(\"blocked\")) blocked.push(line.trim());\n else if (line.includes(\"\\u2717\")) failed.push(line.trim());\n else if (line.includes(\"\\u2022\")) skipped.push(line.trim());\n }\n }\n\n // Fix A, refined for M1: a thrown handleUp failure MUST be signaled \\u2014 but only\n // via the top-level `error` field (set in the return below). The previous\n // version pushed the error *message* into `failed`, which is contracted to hold\n // server NAMES; a consumer iterating it as names got a stray sentence. `error`\n // is the authoritative batch-failure signal; `failed` stays names-only (genuine\n // per-server failures are already recorded into it above via `records`).\n\n return {\n installed,\n blocked,\n failed,\n skipped,\n ...(thrownError !== undefined ? { error: thrownError } : {}),\n ...(installed.length > 0\n ? { note: \"Restart your AI client to use the newly installed servers.\" }\n : {}),\n };\n}\n\n// ---------------------------------------------------------------------------\n// Keyword extraction\n// ---------------------------------------------------------------------------\n\nconst STOPWORDS = /\\b(i need|set up|access|work with|connect to|a server that|a server for|to|the|a|an|my|for|and|with)\\b/gi;\n\nexport function extractKeywords(description: string): string[] {\n const cleaned = description\n .toLowerCase()\n .replace(STOPWORDS, \" \")\n .replace(/[,&]/g, \" \");\n\n const tokens = cleaned\n .split(/\\s+/)\n .map((s) => s.trim())\n .filter((s) => s.length > 2);\n\n // If splitting produced too many tokens, use the full cleaned string\n if (tokens.length > 5) {\n return [cleaned.replace(/\\s+/g, \" \").trim()];\n }\n\n return tokens.length > 0 ? tokens : [description.trim()];\n}\n\n"],"mappings":"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","names":["clientId","stackFile","path"]} |
+1
-1
| { | ||
| "name": "@getmcpm/cli", | ||
| "version": "0.26.3", | ||
| "version": "0.27.0", | ||
| "mcpName": "io.github.getmcpm/cli", | ||
@@ -5,0 +5,0 @@ "description": "MCP package manager — search, install, and audit MCP servers across Claude Desktop, Cursor, VS Code, and Windsurf", |
| #!/usr/bin/env node | ||
| import { | ||
| coloredOutput | ||
| } from "./chunk-E3T224S3.js"; | ||
| import { | ||
| isConfineBackendAvailable, | ||
| isWrapped | ||
| } from "./chunk-WYSMWP2R.js"; | ||
| import { | ||
| sanitizeForTerminal | ||
| } from "./chunk-FEXJHHDM.js"; | ||
| import { | ||
| getAdapter | ||
| } from "./chunk-W4IAFBUN.js"; | ||
| import { | ||
| isSupportedPlatform, | ||
| parsePlaceholder | ||
| } from "./chunk-GZ3WCRLG.js"; | ||
| import { | ||
| CLIENT_IDS, | ||
| getConfigPath | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import { | ||
| detectSecretLabels | ||
| } from "./chunk-MZCNQU2K.js"; | ||
| // src/utils/format-entry.ts | ||
| function formatMcpEntryCommand(entry, fallback = "\u2014") { | ||
| if (entry.url) return entry.url; | ||
| if (entry.command) { | ||
| const args = entry.args?.join(" ") ?? ""; | ||
| return args ? `${entry.command} ${args}` : entry.command; | ||
| } | ||
| return fallback; | ||
| } | ||
| // src/commands/doctor.ts | ||
| import { access } from "fs/promises"; | ||
| // src/config/drift.ts | ||
| async function collectClientStates(deps) { | ||
| const clients = await deps.detectClients(); | ||
| const states = []; | ||
| for (const clientId of clients) { | ||
| try { | ||
| const servers = await deps.getAdapter(clientId).read(deps.getPath(clientId)); | ||
| states.push({ clientId, servers }); | ||
| } catch { | ||
| } | ||
| } | ||
| return states; | ||
| } | ||
| function fieldProjection(entry) { | ||
| return { | ||
| command: entry.command ?? "", | ||
| args: JSON.stringify(entry.args ?? []), | ||
| "env keys": JSON.stringify(Object.keys(entry.env ?? {}).sort()), | ||
| url: entry.url ?? "", | ||
| "header keys": JSON.stringify(Object.keys(entry.headers ?? {}).sort()) | ||
| }; | ||
| } | ||
| var COMPARED_FIELDS = ["command", "args", "env keys", "url", "header keys"]; | ||
| function divergingFields(entries) { | ||
| const projections = entries.map(fieldProjection); | ||
| return COMPARED_FIELDS.filter((field) => { | ||
| const distinct = new Set(projections.map((p) => p[field])); | ||
| return distinct.size > 1; | ||
| }); | ||
| } | ||
| function buildDriftModel(states) { | ||
| const clients = states.map((s) => s.clientId).sort(); | ||
| const byName = /* @__PURE__ */ new Map(); | ||
| for (const { clientId, servers: servers2 } of states) { | ||
| for (const [name, entry] of Object.entries(servers2)) { | ||
| const list = byName.get(name) ?? []; | ||
| list.push({ clientId, entry }); | ||
| byName.set(name, list); | ||
| } | ||
| } | ||
| const servers = []; | ||
| for (const name of [...byName.keys()].sort()) { | ||
| const holders = byName.get(name); | ||
| const present = holders.map((h) => h.clientId).sort(); | ||
| const presentSet = new Set(present); | ||
| const absent = clients.filter((c) => !presentSet.has(c)); | ||
| const fields = holders.length > 1 ? divergingFields(holders.map((h) => h.entry)) : []; | ||
| const conflict = fields.length > 0; | ||
| servers.push({ | ||
| name, | ||
| present, | ||
| absent, | ||
| conflict, | ||
| ...conflict ? { conflictFields: fields } : {} | ||
| }); | ||
| } | ||
| const drifted = servers.filter((s) => s.absent.length > 0 || s.conflict).length; | ||
| return { clients, servers, inSync: servers.length - drifted, drifted }; | ||
| } | ||
| // src/scanner/config-secrets.ts | ||
| var GENERIC_LABEL = "secret-named key holds a plaintext value"; | ||
| var SECRET_KEY_RE = /(?:^|_)(?:PASSWORD|PASSWD|PASSPHRASE|SECRET|TOKEN|PAT|APIKEY|AUTHORIZATION|CREDENTIALS?|(?:API|ACCESS|PRIVATE|SECRET|SESSION|SIGNING|ENCRYPTION)_KEY)(?:_|$)/; | ||
| var NON_SECRET_QUALIFIER_RE = /(?:^|_)(?:URL|URI|ENDPOINT|HOST|PORT|ID|NAME|PATH|FILE|DIR|ENABLED|DISABLED|TYPE|MODE|REGION|TIMEOUT|VERSION|PUBLIC|FORMAT|HEADER|PREFIX|SUFFIX|COUNT|SIZE|TTL|EXPIRY|EXPIRES|ISSUER|AUDIENCE|ALGORITHM|ALG|SCOPE|METHOD)(?:_|$)/; | ||
| function normalizeKey(key) { | ||
| return key.toUpperCase().replace(/-/g, "_"); | ||
| } | ||
| function keyLooksSecret(key) { | ||
| const k = normalizeKey(key); | ||
| return SECRET_KEY_RE.test(k) && !NON_SECRET_QUALIFIER_RE.test(k); | ||
| } | ||
| function valueLooksPlaintextSecret(value) { | ||
| const v = value.trim(); | ||
| if (v.length < 6) return false; | ||
| if (parsePlaceholder(value) !== null) return false; | ||
| if (/\$\{[^}]*\}/.test(v)) return false; | ||
| if (/^\$[A-Za-z_]/.test(v)) return false; | ||
| if (/^%[A-Za-z_][A-Za-z0-9_]*%([\\/].*)?$/.test(v)) return false; | ||
| if (/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return false; | ||
| if (/^[~./]/.test(v) || /^[A-Za-z]:[\\/]/.test(v) || /^\\\\/.test(v)) return false; | ||
| if (/^(true|false|\d+)$/i.test(v)) return false; | ||
| return true; | ||
| } | ||
| function scanMap(server, field, map) { | ||
| if (!map) return []; | ||
| const out = []; | ||
| for (const [key, value] of Object.entries(map)) { | ||
| if (typeof value !== "string") continue; | ||
| if (parsePlaceholder(value) !== null) continue; | ||
| const labels = detectSecretLabels(value); | ||
| if (labels.length > 0) { | ||
| out.push({ server, field, key, label: labels.join(", ") }); | ||
| continue; | ||
| } | ||
| if (keyLooksSecret(key) && valueLooksPlaintextSecret(value)) { | ||
| out.push({ server, field, key, label: GENERIC_LABEL }); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| function scanServerConfigSecrets(server, entry) { | ||
| return [...scanMap(server, "env", entry.env), ...scanMap(server, "header", entry.headers)]; | ||
| } | ||
| function scanConfigSecrets(servers) { | ||
| return Object.entries(servers).flatMap(([name, entry]) => scanServerConfigSecrets(name, entry)); | ||
| } | ||
| // src/commands/doctor.ts | ||
| import "commander"; | ||
| import os from "os"; | ||
| import { execFile } from "child_process"; | ||
| var RUNTIMES = ["npx", "uvx", "docker"]; | ||
| var CLIENT_LABELS = { | ||
| "claude-desktop": "Claude Desktop", | ||
| "claude-code": "Claude Code", | ||
| cursor: "Cursor", | ||
| vscode: "VS Code", | ||
| windsurf: "Windsurf", | ||
| "gemini-cli": "Gemini CLI" | ||
| }; | ||
| var RUNTIME_INSTALL_HINTS = { | ||
| npx: "install Node.js from https://nodejs.org", | ||
| uvx: "install uv from https://docs.astral.sh/uv/", | ||
| docker: "install Docker from https://docs.docker.com/get-docker/" | ||
| }; | ||
| async function buildDoctorModel(deps) { | ||
| const { getAdapter: getAdapter2, getConfigPath: getConfigPath2, checkConfigExists, execCheck } = deps; | ||
| const reads = await Promise.all( | ||
| CLIENT_IDS.map(async (clientId) => { | ||
| const exists = await checkConfigExists(clientId); | ||
| if (!exists) return { clientId, read: { exists: false, malformed: false, servers: null } }; | ||
| try { | ||
| const servers = await getAdapter2(clientId).read(getConfigPath2(clientId)); | ||
| return { clientId, read: { exists: true, malformed: false, servers } }; | ||
| } catch { | ||
| return { clientId, read: { exists: true, malformed: true, servers: null } }; | ||
| } | ||
| }) | ||
| ); | ||
| const issues = []; | ||
| const clients = reads.map(({ clientId, read }) => { | ||
| const label = CLIENT_LABELS[clientId]; | ||
| if (read.malformed) { | ||
| issues.push({ | ||
| kind: "malformed-config", | ||
| message: `Config file for ${label} is malformed \u2014 fix the JSON syntax.` | ||
| }); | ||
| } | ||
| const servers = read.servers ?? {}; | ||
| const entries = Object.values(servers); | ||
| return { | ||
| id: clientId, | ||
| label, | ||
| exists: read.exists, | ||
| malformed: read.malformed, | ||
| serverCount: entries.length, | ||
| guardedCount: entries.filter(isWrapped).length | ||
| }; | ||
| }); | ||
| const runtimes = await Promise.all( | ||
| RUNTIMES.map(async (name) => ({ name, available: await execCheck(name) })) | ||
| ); | ||
| const runtimeAvailable = new Map(runtimes.map((r) => [r.name, r.available])); | ||
| for (const { clientId, read } of reads) { | ||
| if (!read.servers) continue; | ||
| for (const [serverName, entry] of Object.entries(read.servers)) { | ||
| const cmd = entry.command; | ||
| if (!cmd) continue; | ||
| if (RUNTIMES.includes(cmd) && runtimeAvailable.get(cmd) === false) { | ||
| issues.push({ | ||
| kind: "missing-runtime", | ||
| message: `Server '${serverName}' in ${CLIENT_LABELS[clientId]} uses '${cmd}' but ${cmd} is not installed.` | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| const driftStates = reads.flatMap( | ||
| ({ clientId, read }) => read.servers ? [{ clientId, servers: read.servers }] : [] | ||
| ); | ||
| const crossClient = driftStates.length >= 2 ? toCrossClient(driftStates) : null; | ||
| const secrets = reads.flatMap( | ||
| ({ clientId, read }) => read.servers ? scanConfigSecrets(read.servers).map((f) => ({ client: clientId, ...f })) : [] | ||
| ); | ||
| return { | ||
| schemaVersion: 1, | ||
| clients, | ||
| runtimes, | ||
| crossClient, | ||
| secrets, | ||
| issues, | ||
| ok: issues.length === 0 | ||
| }; | ||
| } | ||
| function toCrossClient(states) { | ||
| const drift = buildDriftModel(states); | ||
| const entries = []; | ||
| for (const server of drift.servers) { | ||
| if (server.conflict) { | ||
| entries.push({ | ||
| name: server.name, | ||
| kind: "conflict", | ||
| present: [...server.present], | ||
| absent: [...server.absent], | ||
| fields: server.conflictFields ? [...server.conflictFields] : void 0 | ||
| }); | ||
| } else if (server.absent.length > 0) { | ||
| entries.push({ | ||
| name: server.name, | ||
| kind: "absent", | ||
| present: [...server.present], | ||
| absent: [...server.absent] | ||
| }); | ||
| } | ||
| } | ||
| return { | ||
| consistent: drift.drifted === 0, | ||
| clientCount: drift.clients.length, | ||
| serverCount: drift.servers.length, | ||
| drift: entries | ||
| }; | ||
| } | ||
| function renderDoctorText(model, output) { | ||
| output(""); | ||
| output("mcpm doctor"); | ||
| output(""); | ||
| for (const c of model.clients) { | ||
| if (!c.exists) { | ||
| output(` \u2717 ${c.label} \u2014 config not found`); | ||
| } else if (c.malformed) { | ||
| output(` \u2717 ${c.label} \u2014 config malformed (JSON parse error)`); | ||
| } else { | ||
| const word = c.serverCount === 1 ? "server" : "servers"; | ||
| output(` \u2713 ${c.label} \u2014 config found, ${c.serverCount} ${word}`); | ||
| } | ||
| } | ||
| output(""); | ||
| output("Runtimes:"); | ||
| for (const r of model.runtimes) { | ||
| if (r.available) { | ||
| output(` \u2713 ${r.name} available`); | ||
| } else { | ||
| output(` \u2717 ${r.name} not found \u2014 ${RUNTIME_INSTALL_HINTS[r.name]}`); | ||
| } | ||
| } | ||
| if (model.crossClient) { | ||
| const cc = model.crossClient; | ||
| output(""); | ||
| output("Cross-client (advisory):"); | ||
| if (cc.consistent) { | ||
| const word = cc.serverCount === 1 ? "server" : "servers"; | ||
| output(` \u2713 ${cc.serverCount} ${word} consistent across ${cc.clientCount} clients`); | ||
| } else { | ||
| for (const d of cc.drift) { | ||
| if (d.kind === "conflict") { | ||
| output(` \u26A0 ${d.name} \u2014 config differs (${d.fields.join(", ")}) across ${d.present.join(", ")}`); | ||
| } else { | ||
| output(` \u26A0 ${d.name} \u2014 in ${d.present.join(", ")}; missing in ${d.absent.join(", ")}`); | ||
| } | ||
| } | ||
| output(" Run `mcpm sync --check` for the full matrix (advisory, not a failure)."); | ||
| } | ||
| } | ||
| if (model.secrets.length > 0) { | ||
| output(""); | ||
| output("Plaintext secrets (advisory):"); | ||
| for (const s of model.secrets) { | ||
| output( | ||
| ` \u26A0 ${s.client} \xB7 ${sanitizeForTerminal(s.server)} \xB7 ${s.field} '${sanitizeForTerminal(s.key)}' \u2014 ${s.label}` | ||
| ); | ||
| } | ||
| if (model.secrets.some((s) => s.field === "env")) { | ||
| output( | ||
| " Move env secrets to the encrypted store: `mcpm secrets set <server> <KEY>` or re-install with `--secrets keychain`." | ||
| ); | ||
| } | ||
| if (model.secrets.some((s) => s.field === "header")) { | ||
| output( | ||
| " Header secrets have no keychain path yet \u2014 rotate the credential and keep it out of committed config." | ||
| ); | ||
| } | ||
| } | ||
| if (model.issues.length > 0) { | ||
| output(""); | ||
| output("Issues:"); | ||
| for (const issue of model.issues) { | ||
| output(` \u26A0 ${issue.message}`); | ||
| } | ||
| output(""); | ||
| output("Critical issues found. Run the commands above to resolve them."); | ||
| return; | ||
| } | ||
| output(""); | ||
| output("No critical issues found."); | ||
| } | ||
| function buildDoctorReport(model, env) { | ||
| return { | ||
| schemaVersion: 1, | ||
| mcpm: env.mcpm, | ||
| node: env.node, | ||
| os: `${env.platform} ${env.arch} ${env.osRelease}`, | ||
| confineBackend: env.confineBackend, | ||
| secretStore: env.secretStore, | ||
| // Redaction: drop the label + every server name; keep only counts. | ||
| clients: model.clients.map(({ id, exists, malformed, serverCount, guardedCount }) => ({ | ||
| id, | ||
| exists, | ||
| malformed, | ||
| serverCount, | ||
| guardedCount | ||
| })), | ||
| runtimes: model.runtimes, | ||
| issues: { | ||
| malformedConfigs: model.issues.filter((i) => i.kind === "malformed-config").length, | ||
| missingRuntime: model.issues.filter((i) => i.kind === "missing-runtime").length, | ||
| plaintextSecrets: model.secrets.length | ||
| } | ||
| }; | ||
| } | ||
| function renderReportText(r) { | ||
| const lines = []; | ||
| lines.push("mcpm doctor --report (redacted \u2014 no server names or args)"); | ||
| lines.push(`mcpm: ${r.mcpm}`); | ||
| lines.push(`node: ${r.node}`); | ||
| lines.push(`os: ${r.os}`); | ||
| lines.push(`confine backend: ${r.confineBackend ? "available" : "unavailable"}`); | ||
| lines.push(`secret store: ${r.secretStore}`); | ||
| lines.push(""); | ||
| lines.push("clients:"); | ||
| for (const c of r.clients) { | ||
| if (!c.exists) { | ||
| lines.push(` ${c.id}: not found`); | ||
| } else if (c.malformed) { | ||
| lines.push(` ${c.id}: config malformed`); | ||
| } else { | ||
| const guarded = c.guardedCount > 0 ? `, ${c.guardedCount} guarded` : ""; | ||
| lines.push(` ${c.id}: ${c.serverCount} servers${guarded}`); | ||
| } | ||
| } | ||
| lines.push("runtimes:"); | ||
| for (const rt of r.runtimes) { | ||
| lines.push(` ${rt.name}: ${rt.available ? "available" : "missing"}`); | ||
| } | ||
| lines.push( | ||
| `issues: ${r.issues.malformedConfigs} malformed config(s), ${r.issues.missingRuntime} missing-runtime, ${r.issues.plaintextSecrets} plaintext secret(s)` | ||
| ); | ||
| return lines.join("\n"); | ||
| } | ||
| async function doctorHandler(deps, opts = {}) { | ||
| const model = await buildDoctorModel(deps); | ||
| if (opts.report) { | ||
| const env = opts.reportEnv ?? gatherReportEnv(); | ||
| deps.output(renderReportText(buildDoctorReport(model, env))); | ||
| } else if (opts.json) { | ||
| deps.output(JSON.stringify(model, null, 2)); | ||
| } else { | ||
| renderDoctorText(model, deps.output); | ||
| } | ||
| return model.ok ? 0 : 1; | ||
| } | ||
| function makeCheckConfigExists(getConfigPathFn) { | ||
| return async (clientId) => { | ||
| try { | ||
| await access(getConfigPathFn(clientId)); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| }; | ||
| } | ||
| var checkConfigExistsDefault = makeCheckConfigExists(getConfigPath); | ||
| var ALLOWED_RUNTIME_CMDS = /* @__PURE__ */ new Set(["npx", "uvx", "docker"]); | ||
| function execCheckDefault(cmd) { | ||
| if (!ALLOWED_RUNTIME_CMDS.has(cmd)) return Promise.resolve(false); | ||
| return new Promise((resolve) => { | ||
| const which = process.platform === "win32" ? "where" : "which"; | ||
| execFile(which, [cmd], (err) => { | ||
| resolve(err === null); | ||
| }); | ||
| }); | ||
| } | ||
| function gatherReportEnv() { | ||
| return { | ||
| mcpm: "0.26.3", | ||
| node: process.version, | ||
| platform: process.platform, | ||
| arch: process.arch, | ||
| osRelease: os.release(), | ||
| confineBackend: isConfineBackendAvailable(), | ||
| secretStore: isSupportedPlatform() ? "os-keychain" : "machine-key" | ||
| }; | ||
| } | ||
| function registerDoctorCommand(program) { | ||
| program.command("doctor").description("Check MCP setup health and report issues").option("--json", "emit the structured DoctorModel as JSON (shape UNSTABLE; NOT redacted \u2014 includes server names, use --report to share publicly)").option("--report", "emit a redacted, pasteable env snapshot for bug reports (no server names/args)").action(async (options) => { | ||
| const plain = options.json || options.report; | ||
| const deps = { | ||
| getAdapter, | ||
| getConfigPath, | ||
| checkConfigExists: checkConfigExistsDefault, | ||
| execCheck: execCheckDefault, | ||
| output: plain ? (t) => console.log(t) : coloredOutput | ||
| }; | ||
| const exitCode = await doctorHandler(deps, { json: options.json, report: options.report }); | ||
| process.exit(exitCode); | ||
| }); | ||
| } | ||
| export { | ||
| formatMcpEntryCommand, | ||
| collectClientStates, | ||
| buildDriftModel, | ||
| buildDoctorModel, | ||
| makeCheckConfigExists, | ||
| execCheckDefault, | ||
| registerDoctorCommand | ||
| }; | ||
| //# sourceMappingURL=chunk-AIAAC2ZX.js.map |
| {"version":3,"sources":["../src/utils/format-entry.ts","../src/commands/doctor.ts","../src/config/drift.ts","../src/scanner/config-secrets.ts"],"sourcesContent":["/**\n * Shared formatting helpers for McpServerEntry display.\n */\n\nimport type { McpServerEntry } from \"../config/adapters/index.js\";\n\n/**\n * Returns the display string for an MCP server entry's command/URL column.\n *\n * @param entry - The server entry to format.\n * @param fallback - String to return when neither url nor command is present.\n */\nexport function formatMcpEntryCommand(\n entry: McpServerEntry,\n fallback = \"\\u2014\"\n): string {\n if (entry.url) return entry.url;\n if (entry.command) {\n const args = entry.args?.join(\" \") ?? \"\";\n return args ? `${entry.command} ${args}` : entry.command;\n }\n return fallback;\n}\n","/**\n * `mcpm doctor` command handler.\n *\n * Checks MCP setup health and reports issues:\n * - Which AI clients have config files\n * - Whether config files are valid JSON\n * - Which runtimes (npx, uvx, docker) are available\n * - Whether installed servers reference available runtimes\n *\n * Returns 0 for no critical issues, 1 for critical issues.\n * All external dependencies are injected for testability.\n *\n * D7: the check logic is split into a pure `buildDoctorModel` (a structured\n * `DoctorModel`) and renderers. `--json` emits the model; `--report` emits a\n * redacted, name-free env snapshot for bug reports; the MCP-server `handleDoctor`\n * reuses the same model (fixing its formerly-hardcoded `issues: []`).\n */\n\nimport { access } from \"fs/promises\";\nimport type { ClientId } from \"../config/paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"../config/adapters/index.js\";\nimport type { getConfigPath } from \"../config/paths.js\";\nimport { buildDriftModel, type ClientState } from \"../config/drift.js\";\nimport { isWrapped } from \"../guard/wrap.js\";\nimport { scanConfigSecrets, type ConfigSecretFinding } from \"../scanner/config-secrets.js\";\nimport { sanitizeForTerminal } from \"../guard/sanitize.js\";\n\n// ---------------------------------------------------------------------------\n// Deps interface\n// ---------------------------------------------------------------------------\n\nexport interface DoctorDeps {\n getAdapter: (clientId: ClientId) => ConfigAdapter;\n getConfigPath: typeof getConfigPath;\n /** Returns true if the config file exists for this client. */\n checkConfigExists: (clientId: ClientId) => Promise<boolean>;\n /** Returns true if the given executable is available on PATH. */\n execCheck: (cmd: string) => Promise<boolean>;\n output: (text: string) => void;\n}\n\n/** The subset of deps the pure model builder needs (no output, no detector). */\nexport type DoctorModelDeps = Pick<\n DoctorDeps,\n \"getAdapter\" | \"getConfigPath\" | \"checkConfigExists\" | \"execCheck\"\n>;\n\n// ---------------------------------------------------------------------------\n// Structured model (D7 — one shape for text/json/report/MCP consumers)\n// ---------------------------------------------------------------------------\n\nexport interface DoctorClientHealth {\n id: ClientId;\n label: string;\n exists: boolean;\n malformed: boolean;\n serverCount: number;\n /** Servers wrapped by the guard relay (subset of serverCount). */\n guardedCount: number;\n}\n\nexport interface DoctorRuntimeHealth {\n name: Runtime;\n available: boolean;\n}\n\nexport interface DoctorDriftEntry {\n name: string;\n kind: \"conflict\" | \"absent\";\n present: string[];\n absent: string[];\n /** Present only for `kind: \"conflict\"`. */\n fields?: string[];\n}\n\nexport interface DoctorCrossClient {\n consistent: boolean;\n clientCount: number;\n serverCount: number;\n drift: DoctorDriftEntry[];\n}\n\nexport interface DoctorIssue {\n kind: \"malformed-config\" | \"missing-runtime\";\n message: string;\n}\n\nexport interface DoctorSecretFinding {\n client: ClientId;\n server: string;\n field: ConfigSecretFinding[\"field\"];\n /** The env var / header NAME — never the value (F9 redaction contract). */\n key: string;\n label: string;\n}\n\nexport interface DoctorModel {\n schemaVersion: 1;\n clients: DoctorClientHealth[];\n runtimes: DoctorRuntimeHealth[];\n /** Advisory cross-client consistency; null when <2 clients have a readable config. */\n crossClient: DoctorCrossClient | null;\n /** Plaintext secrets in client config — advisory (F9); does NOT affect `ok`/exit. */\n secrets: DoctorSecretFinding[];\n /** Critical issues — these drive the exit code. */\n issues: DoctorIssue[];\n /** true iff issues is empty. */\n ok: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst RUNTIMES = [\"npx\", \"uvx\", \"docker\"] as const;\n\ntype Runtime = (typeof RUNTIMES)[number];\n\nconst CLIENT_LABELS: Record<ClientId, string> = {\n \"claude-desktop\": \"Claude Desktop\",\n \"claude-code\": \"Claude Code\",\n cursor: \"Cursor\",\n vscode: \"VS Code\",\n windsurf: \"Windsurf\",\n \"gemini-cli\": \"Gemini CLI\",\n};\n\nconst RUNTIME_INSTALL_HINTS: Record<Runtime, string> = {\n npx: \"install Node.js from https://nodejs.org\",\n uvx: \"install uv from https://docs.astral.sh/uv/\",\n docker: \"install Docker from https://docs.docker.com/get-docker/\",\n};\n\n// ---------------------------------------------------------------------------\n// Model builder (pure — no output)\n// ---------------------------------------------------------------------------\n\ninterface ClientRead {\n exists: boolean;\n malformed: boolean;\n servers: Record<string, McpServerEntry> | null;\n}\n\n/**\n * Runs every health check and returns the structured model. No side effects\n * beyond the injected reads; safe to call from the CLI, `--json`, `--report`,\n * and the MCP `handleDoctor` tool.\n */\nexport async function buildDoctorModel(deps: DoctorModelDeps): Promise<DoctorModel> {\n const { getAdapter, getConfigPath, checkConfigExists, execCheck } = deps;\n\n // 1. Read each known client's config.\n const reads = await Promise.all(\n CLIENT_IDS.map(async (clientId): Promise<{ clientId: ClientId; read: ClientRead }> => {\n const exists = await checkConfigExists(clientId);\n if (!exists) return { clientId, read: { exists: false, malformed: false, servers: null } };\n try {\n const servers = await getAdapter(clientId).read(getConfigPath(clientId));\n return { clientId, read: { exists: true, malformed: false, servers } };\n } catch {\n return { clientId, read: { exists: true, malformed: true, servers: null } };\n }\n })\n );\n\n const issues: DoctorIssue[] = [];\n\n const clients: DoctorClientHealth[] = reads.map(({ clientId, read }) => {\n const label = CLIENT_LABELS[clientId];\n if (read.malformed) {\n issues.push({\n kind: \"malformed-config\",\n message: `Config file for ${label} is malformed — fix the JSON syntax.`,\n });\n }\n const servers = read.servers ?? {};\n const entries = Object.values(servers);\n return {\n id: clientId,\n label,\n exists: read.exists,\n malformed: read.malformed,\n serverCount: entries.length,\n guardedCount: entries.filter(isWrapped).length,\n };\n });\n\n // 2. Runtime availability.\n const runtimes: DoctorRuntimeHealth[] = await Promise.all(\n RUNTIMES.map(async (name) => ({ name, available: await execCheck(name) }))\n );\n const runtimeAvailable = new Map(runtimes.map((r) => [r.name as string, r.available]));\n\n // 3. Cross-check: servers whose command is a tracked-but-unavailable runtime.\n for (const { clientId, read } of reads) {\n if (!read.servers) continue;\n for (const [serverName, entry] of Object.entries(read.servers)) {\n const cmd = entry.command;\n if (!cmd) continue; // HTTP/URL server — no runtime needed.\n if (RUNTIMES.includes(cmd as Runtime) && runtimeAvailable.get(cmd) === false) {\n issues.push({\n kind: \"missing-runtime\",\n message: `Server '${serverName}' in ${CLIENT_LABELS[clientId]} uses '${cmd}' but ${cmd} is not installed.`,\n });\n }\n }\n }\n\n // 4. Cross-client consistency (advisory — never an issue, never fails doctor).\n const driftStates: ClientState[] = reads.flatMap(({ clientId, read }) =>\n read.servers ? [{ clientId, servers: read.servers }] : []\n );\n const crossClient = driftStates.length >= 2 ? toCrossClient(driftStates) : null;\n\n // 5. Plaintext-secret scan (advisory — never an issue, never fails doctor).\n const secrets: DoctorSecretFinding[] = reads.flatMap(({ clientId, read }) =>\n read.servers ? scanConfigSecrets(read.servers).map((f) => ({ client: clientId, ...f })) : []\n );\n\n return {\n schemaVersion: 1,\n clients,\n runtimes,\n crossClient,\n secrets,\n issues,\n ok: issues.length === 0,\n };\n}\n\nfunction toCrossClient(states: ClientState[]): DoctorCrossClient {\n const drift = buildDriftModel(states);\n const entries: DoctorDriftEntry[] = [];\n for (const server of drift.servers) {\n // buildDriftModel returns readonly arrays — copy into the mutable public model.\n if (server.conflict) {\n entries.push({\n name: server.name,\n kind: \"conflict\",\n present: [...server.present],\n absent: [...server.absent],\n fields: server.conflictFields ? [...server.conflictFields] : undefined,\n });\n } else if (server.absent.length > 0) {\n entries.push({\n name: server.name,\n kind: \"absent\",\n present: [...server.present],\n absent: [...server.absent],\n });\n }\n }\n return {\n consistent: drift.drifted === 0,\n clientCount: drift.clients.length,\n serverCount: drift.servers.length,\n drift: entries,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Human-readable renderer (byte-identical to the pre-D7 output)\n// ---------------------------------------------------------------------------\n\nexport function renderDoctorText(model: DoctorModel, output: (text: string) => void): void {\n output(\"\");\n output(\"mcpm doctor\");\n output(\"\");\n\n for (const c of model.clients) {\n if (!c.exists) {\n output(` ✗ ${c.label} — config not found`);\n } else if (c.malformed) {\n output(` ✗ ${c.label} — config malformed (JSON parse error)`);\n } else {\n const word = c.serverCount === 1 ? \"server\" : \"servers\";\n output(` ✓ ${c.label} — config found, ${c.serverCount} ${word}`);\n }\n }\n\n output(\"\");\n output(\"Runtimes:\");\n for (const r of model.runtimes) {\n if (r.available) {\n output(` ✓ ${r.name} available`);\n } else {\n output(` ✗ ${r.name} not found — ${RUNTIME_INSTALL_HINTS[r.name]}`);\n }\n }\n\n if (model.crossClient) {\n const cc = model.crossClient;\n output(\"\");\n output(\"Cross-client (advisory):\");\n if (cc.consistent) {\n const word = cc.serverCount === 1 ? \"server\" : \"servers\";\n output(` ✓ ${cc.serverCount} ${word} consistent across ${cc.clientCount} clients`);\n } else {\n for (const d of cc.drift) {\n if (d.kind === \"conflict\") {\n output(` ⚠ ${d.name} — config differs (${d.fields!.join(\", \")}) across ${d.present.join(\", \")}`);\n } else {\n output(` ⚠ ${d.name} — in ${d.present.join(\", \")}; missing in ${d.absent.join(\", \")}`);\n }\n }\n output(\" Run `mcpm sync --check` for the full matrix (advisory, not a failure).\");\n }\n }\n\n if (model.secrets.length > 0) {\n output(\"\");\n output(\"Plaintext secrets (advisory):\");\n for (const s of model.secrets) {\n // s.server / s.key are attacker-influenceable (registry env-var names, imported\n // configs) — strip ANSI/OSC so a crafted key can't erase or spoof the advisory.\n output(\n ` ⚠ ${s.client} · ${sanitizeForTerminal(s.server)} · ${s.field} '${sanitizeForTerminal(s.key)}' — ${s.label}`\n );\n }\n // Remediation is field-specific: the keychain/placeholder path is env-only\n // (guard resolves placeholders in env, not headers; HTTP servers aren't wrapped).\n if (model.secrets.some((s) => s.field === \"env\")) {\n output(\n \" Move env secrets to the encrypted store: `mcpm secrets set <server> <KEY>` or re-install with `--secrets keychain`.\"\n );\n }\n if (model.secrets.some((s) => s.field === \"header\")) {\n output(\n \" Header secrets have no keychain path yet — rotate the credential and keep it out of committed config.\"\n );\n }\n }\n\n if (model.issues.length > 0) {\n output(\"\");\n output(\"Issues:\");\n for (const issue of model.issues) {\n output(` ⚠ ${issue.message}`);\n }\n output(\"\");\n output(\"Critical issues found. Run the commands above to resolve them.\");\n return;\n }\n\n output(\"\");\n output(\"No critical issues found.\");\n}\n\n// ---------------------------------------------------------------------------\n// Redacted report (D7 — pasteable env snapshot, NO server names/args)\n// ---------------------------------------------------------------------------\n\nexport interface DoctorReportEnv {\n mcpm: string;\n node: string;\n platform: string;\n arch: string;\n osRelease: string;\n confineBackend: boolean;\n secretStore: \"os-keychain\" | \"machine-key\";\n}\n\nexport interface DoctorReport {\n schemaVersion: 1;\n mcpm: string;\n node: string;\n os: string;\n confineBackend: boolean;\n secretStore: \"os-keychain\" | \"machine-key\";\n clients: Array<Omit<DoctorClientHealth, \"label\">>;\n runtimes: DoctorRuntimeHealth[];\n /** Counts only — issue messages + secret keys embed server names, so NOT included. */\n issues: { malformedConfigs: number; missingRuntime: number; plaintextSecrets: number };\n}\n\nexport function buildDoctorReport(model: DoctorModel, env: DoctorReportEnv): DoctorReport {\n return {\n schemaVersion: 1,\n mcpm: env.mcpm,\n node: env.node,\n os: `${env.platform} ${env.arch} ${env.osRelease}`,\n confineBackend: env.confineBackend,\n secretStore: env.secretStore,\n // Redaction: drop the label + every server name; keep only counts.\n clients: model.clients.map(({ id, exists, malformed, serverCount, guardedCount }) => ({\n id,\n exists,\n malformed,\n serverCount,\n guardedCount,\n })),\n runtimes: model.runtimes,\n issues: {\n malformedConfigs: model.issues.filter((i) => i.kind === \"malformed-config\").length,\n missingRuntime: model.issues.filter((i) => i.kind === \"missing-runtime\").length,\n plaintextSecrets: model.secrets.length,\n },\n };\n}\n\nexport function renderReportText(r: DoctorReport): string {\n const lines: string[] = [];\n lines.push(\"mcpm doctor --report (redacted — no server names or args)\");\n lines.push(`mcpm: ${r.mcpm}`);\n lines.push(`node: ${r.node}`);\n lines.push(`os: ${r.os}`);\n lines.push(`confine backend: ${r.confineBackend ? \"available\" : \"unavailable\"}`);\n lines.push(`secret store: ${r.secretStore}`);\n lines.push(\"\");\n lines.push(\"clients:\");\n for (const c of r.clients) {\n if (!c.exists) {\n lines.push(` ${c.id}: not found`);\n } else if (c.malformed) {\n lines.push(` ${c.id}: config malformed`);\n } else {\n const guarded = c.guardedCount > 0 ? `, ${c.guardedCount} guarded` : \"\";\n lines.push(` ${c.id}: ${c.serverCount} servers${guarded}`);\n }\n }\n lines.push(\"runtimes:\");\n for (const rt of r.runtimes) {\n lines.push(` ${rt.name}: ${rt.available ? \"available\" : \"missing\"}`);\n }\n lines.push(\n `issues: ${r.issues.malformedConfigs} malformed config(s), ${r.issues.missingRuntime} missing-runtime, ${r.issues.plaintextSecrets} plaintext secret(s)`\n );\n return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Handler\n// ---------------------------------------------------------------------------\n\nexport interface DoctorOpts {\n json?: boolean;\n report?: boolean;\n /** Injected in --report mode; the Commander action supplies the real env. */\n reportEnv?: DoctorReportEnv;\n}\n\n/**\n * Core logic for `mcpm doctor`.\n * @returns Exit code: 0 = healthy, 1 = critical issues found.\n */\nexport async function doctorHandler(deps: DoctorDeps, opts: DoctorOpts = {}): Promise<number> {\n const model = await buildDoctorModel(deps);\n\n if (opts.report) {\n const env = opts.reportEnv ?? gatherReportEnv();\n deps.output(renderReportText(buildDoctorReport(model, env)));\n } else if (opts.json) {\n deps.output(JSON.stringify(model, null, 2));\n } else {\n renderDoctorText(model, deps.output);\n }\n\n return model.ok ? 0 : 1;\n}\n\n// ---------------------------------------------------------------------------\n// Commander registration\n// ---------------------------------------------------------------------------\n\nimport { Command } from \"commander\";\nimport os from \"os\";\nimport { execFile } from \"child_process\";\nimport { getConfigPath as _getConfigPath, CLIENT_IDS } from \"../config/paths.js\";\nimport { getAdapter as getAdapterDefault } from \"../config/index.js\";\nimport { coloredOutput } from \"../utils/output.js\";\nimport { isConfineBackendAvailable } from \"../guard/confine/apply.js\";\nimport { isSupportedPlatform as isKeychainSupported } from \"../store/os-keychain.js\";\n\n/** Factory so callers that inject a custom getConfigPath (e.g. the MCP server) get honored. */\nexport function makeCheckConfigExists(\n getConfigPathFn: (clientId: ClientId) => string\n): (clientId: ClientId) => Promise<boolean> {\n return async (clientId: ClientId): Promise<boolean> => {\n try {\n await access(getConfigPathFn(clientId));\n return true;\n } catch {\n return false;\n }\n };\n}\n\nconst checkConfigExistsDefault = makeCheckConfigExists(_getConfigPath);\n\nconst ALLOWED_RUNTIME_CMDS = new Set<string>([\"npx\", \"uvx\", \"docker\"]);\n\nexport function execCheckDefault(cmd: string): Promise<boolean> {\n if (!ALLOWED_RUNTIME_CMDS.has(cmd)) return Promise.resolve(false);\n return new Promise((resolve) => {\n const which = process.platform === \"win32\" ? \"where\" : \"which\";\n execFile(which, [cmd], (err) => {\n resolve(err === null);\n });\n });\n}\n\n/** Gathers the impure environment fields for `--report`. */\nfunction gatherReportEnv(): DoctorReportEnv {\n return {\n mcpm: __PKG_VERSION__,\n node: process.version,\n platform: process.platform,\n arch: process.arch,\n osRelease: os.release(),\n confineBackend: isConfineBackendAvailable(),\n secretStore: isKeychainSupported() ? \"os-keychain\" : \"machine-key\",\n };\n}\n\nexport function registerDoctorCommand(program: Command): void {\n program\n .command(\"doctor\")\n .description(\"Check MCP setup health and report issues\")\n .option(\"--json\", \"emit the structured DoctorModel as JSON (shape UNSTABLE; NOT redacted — includes server names, use --report to share publicly)\")\n .option(\"--report\", \"emit a redacted, pasteable env snapshot for bug reports (no server names/args)\")\n .action(async (options: { json?: boolean; report?: boolean }) => {\n // --json / --report are machine/paste output — never colorize.\n const plain = options.json || options.report;\n const deps: DoctorDeps = {\n getAdapter: getAdapterDefault,\n getConfigPath: _getConfigPath,\n checkConfigExists: checkConfigExistsDefault,\n execCheck: execCheckDefault,\n output: plain ? (t) => console.log(t) : coloredOutput,\n };\n\n const exitCode = await doctorHandler(deps, { json: options.json, report: options.report });\n process.exit(exitCode);\n });\n}\n","/**\n * Cross-client config-drift model (pure, injectable).\n *\n * `mcpm diff` answers \"installed vs declared stack\" in ONE direction. This module\n * answers the symmetric N-client question: for every server name, which clients\n * have it, which are missing it, and do the clients that DO have it agree on the\n * server's shape? It is the shared core behind `mcpm sync --check` and the doctor\n * \"Cross-client\" section.\n *\n * Design notes:\n * - Read-only. No writes, no registry/lock/network — it only reads client configs\n * (the collect loop mirrors diff.ts:76-93 / export.ts).\n * - `buildDriftModel` is pure and takes already-collected `ClientState[]` so the\n * doctor command can feed it the reads it already did (no double I/O).\n * - Conflict comparison is over command + ordered args + env KEY set + url +\n * header KEY set. It NEVER compares env / header VALUES — those are secrets, and\n * two clients legitimately hold the same key with a per-machine value.\n *\n * Exports: DriftDeps, ClientState, ServerDrift, DriftModel, collectClientStates,\n * buildDriftModel.\n */\n\nimport type { ClientId } from \"./paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"./adapters/index.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface DriftDeps {\n detectClients: () => Promise<ClientId[]>;\n getAdapter: (clientId: ClientId) => Pick<ConfigAdapter, \"read\">;\n getPath: (clientId: ClientId) => string;\n}\n\n/** A single client's full set of MCP server entries (one successful read). */\nexport interface ClientState {\n readonly clientId: ClientId;\n readonly servers: Record<string, McpServerEntry>;\n}\n\nexport interface ServerDrift {\n readonly name: string;\n /** Clients (with readable configs) that declare this server. */\n readonly present: readonly ClientId[];\n /** Clients (with readable configs) that lack this server. */\n readonly absent: readonly ClientId[];\n /** True when the `present` clients disagree on the server's shape. */\n readonly conflict: boolean;\n /** Which fields diverge among the `present` clients (only when conflict). */\n readonly conflictFields?: readonly string[];\n}\n\nexport interface DriftModel {\n /** Clients considered — those whose config was readable. Sorted. */\n readonly clients: readonly ClientId[];\n /** One entry per distinct server name, sorted by name. */\n readonly servers: readonly ServerDrift[];\n /** Servers present in every considered client with no shape conflict. */\n readonly inSync: number;\n /** Servers with at least one absence or a shape conflict. */\n readonly drifted: number;\n}\n\n// ---------------------------------------------------------------------------\n// Collection (I/O)\n// ---------------------------------------------------------------------------\n\n/**\n * Read each detected client's config into a `ClientState`. Clients whose config\n * is unreadable (missing / malformed) are skipped — never throws — so a single\n * broken config can't blind the whole cross-client view (same posture as\n * `diff` / `export`).\n */\nexport async function collectClientStates(deps: DriftDeps): Promise<ClientState[]> {\n const clients = await deps.detectClients();\n const states: ClientState[] = [];\n for (const clientId of clients) {\n try {\n const servers = await deps.getAdapter(clientId).read(deps.getPath(clientId));\n states.push({ clientId, servers });\n } catch {\n // Skip unreadable clients (missing or malformed config).\n }\n }\n return states;\n}\n\n// ---------------------------------------------------------------------------\n// Drift model (pure)\n// ---------------------------------------------------------------------------\n\n/**\n * Per-field canonical projection used for conflict detection. Each value is a\n * stable string; two entries conflict on a field iff their projected strings\n * differ. Deliberately excludes env / header VALUES (secrets) and the per-client\n * `disabled` flag (an intentional per-client toggle, not a definition drift).\n */\nfunction fieldProjection(entry: McpServerEntry): Record<string, string> {\n return {\n command: entry.command ?? \"\",\n args: JSON.stringify(entry.args ?? []),\n \"env keys\": JSON.stringify(Object.keys(entry.env ?? {}).sort()),\n url: entry.url ?? \"\",\n \"header keys\": JSON.stringify(Object.keys(entry.headers ?? {}).sort()),\n };\n}\n\nconst COMPARED_FIELDS = [\"command\", \"args\", \"env keys\", \"url\", \"header keys\"] as const;\n\n/** Fields on which the given entries (≥1) disagree. Empty ⇒ all identical. */\nfunction divergingFields(entries: readonly McpServerEntry[]): string[] {\n const projections = entries.map(fieldProjection);\n return COMPARED_FIELDS.filter((field) => {\n const distinct = new Set(projections.map((p) => p[field]));\n return distinct.size > 1;\n });\n}\n\nexport function buildDriftModel(states: readonly ClientState[]): DriftModel {\n const clients = states.map((s) => s.clientId).sort();\n\n // Gather, per server name, the clients that declare it and their entries.\n const byName = new Map<string, Array<{ clientId: ClientId; entry: McpServerEntry }>>();\n for (const { clientId, servers } of states) {\n for (const [name, entry] of Object.entries(servers)) {\n const list = byName.get(name) ?? [];\n list.push({ clientId, entry });\n byName.set(name, list);\n }\n }\n\n const servers: ServerDrift[] = [];\n for (const name of [...byName.keys()].sort()) {\n const holders = byName.get(name)!;\n const present = holders.map((h) => h.clientId).sort();\n const presentSet = new Set(present);\n const absent = clients.filter((c) => !presentSet.has(c));\n\n const fields = holders.length > 1 ? divergingFields(holders.map((h) => h.entry)) : [];\n const conflict = fields.length > 0;\n\n servers.push({\n name,\n present,\n absent,\n conflict,\n ...(conflict ? { conflictFields: fields } : {}),\n });\n }\n\n const drifted = servers.filter((s) => s.absent.length > 0 || s.conflict).length;\n return { clients, servers, inSync: servers.length - drifted, drifted };\n}\n","/**\n * Plaintext-secret scan over client MCP config (F9 · PR1).\n *\n * mcpm ships an encrypted secret store + OS keychain, but a server's env/header\n * values are routinely pasted in plaintext (24k+ such leaks documented in the\n * wild). This read-only scan flags them so `doctor` can nudge the user toward\n * `mcpm secrets` / keychain mode.\n *\n * REDACTION CONTRACT: a finding carries the KEY name and a LABEL only — NEVER the\n * matched value. Values already stored as `mcpm:keychain:` placeholders are\n * skipped (they are the safe state, not a leak).\n *\n * Two detectors:\n * 1. value-shape — the sweep-hardened `detectSecretLabels` patterns (AWS /\n * GitHub / OpenAI / … keys). Near-zero false positives.\n * 2. secret-named key — a tight key-name heuristic for generic passwords/tokens\n * no value-regex matches, gated by strong non-secret-qualifier (URL/ID/NAME/…)\n * and non-secret-value (reference/URL/path/flag) exclusions + a benign corpus.\n *\n * Pure: no I/O. The caller (doctor) supplies the already-read config.\n */\n\nimport type { McpServerEntry } from \"../config/adapters/index.js\";\nimport { detectSecretLabels } from \"./patterns.js\";\nimport { parsePlaceholder } from \"../store/keychain.js\";\n\nexport interface ConfigSecretFinding {\n /** Server name as it appears in the client config. */\n server: string;\n /** Which value map the secret sits in. */\n field: \"env\" | \"header\";\n /** The env var / header NAME. Never the value. */\n key: string;\n /** What was matched (e.g. \"AWS access key\"). Never the value. */\n label: string;\n}\n\n/** Label for a key-heuristic hit (detector 2). Value-free by construction. */\nconst GENERIC_LABEL = \"secret-named key holds a plaintext value\";\n\n// Secret-indicating whole words. Matched against the key normalized to\n// upper-case with '-'→'_' (so `X-API-Key` reads as `X_API_KEY`). Bare `KEY` is\n// deliberately NOT a word (PUBLIC_KEY / KEY_ID / SORT_KEY are not secrets) — only\n// the listed `*_KEY` compounds count.\nconst SECRET_KEY_RE =\n /(?:^|_)(?:PASSWORD|PASSWD|PASSPHRASE|SECRET|TOKEN|PAT|APIKEY|AUTHORIZATION|CREDENTIALS?|(?:API|ACCESS|PRIVATE|SECRET|SESSION|SIGNING|ENCRYPTION)_KEY)(?:_|$)/;\n\n// Tokens that mean the field is a descriptor of a secret, not the secret itself\n// (an id, url, name, endpoint, …). Any one vetoes a key-name match, so\n// `TOKEN_URL` / `AWS_ACCESS_KEY_ID` / `SECRET_NAME` / `PUBLIC_KEY` do not fire.\n// KNOWN GAP (advisory tool, accepted): the veto matches a qualifier ANYWHERE in the\n// key, so `ID_TOKEN` (where `ID` is the credential TYPE, not a descriptor) is missed.\n// A suffix-anchored fix would newly false-POSITIVE on `MAPBOX_PUBLIC_TOKEN`; since a\n// false negative in an advisory scan is acceptable but a false positive is not, we\n// keep the anywhere-match.\nconst NON_SECRET_QUALIFIER_RE =\n /(?:^|_)(?:URL|URI|ENDPOINT|HOST|PORT|ID|NAME|PATH|FILE|DIR|ENABLED|DISABLED|TYPE|MODE|REGION|TIMEOUT|VERSION|PUBLIC|FORMAT|HEADER|PREFIX|SUFFIX|COUNT|SIZE|TTL|EXPIRY|EXPIRES|ISSUER|AUDIENCE|ALGORITHM|ALG|SCOPE|METHOD)(?:_|$)/;\n\nfunction normalizeKey(key: string): string {\n return key.toUpperCase().replace(/-/g, \"_\");\n}\n\nfunction keyLooksSecret(key: string): boolean {\n const k = normalizeKey(key);\n return SECRET_KEY_RE.test(k) && !NON_SECRET_QUALIFIER_RE.test(k);\n}\n\n/** True when the value is plausibly a real plaintext secret (not a ref/URL/flag). */\nfunction valueLooksPlaintextSecret(value: string): boolean {\n const v = value.trim();\n if (v.length < 6) return false; // too short to be a credential\n if (parsePlaceholder(value) !== null) return false; // mcpm keychain placeholder\n // Reference, not a literal secret. `${...}` is matched ANYWHERE (not just leading):\n // `Bearer ${input:key}` / `Bearer ${env:VAR}` is VS Code / Cursor / Claude Code's\n // documented header idiom — the recommended SAFE state. Detector 1 already ran on\n // the raw value, so a shaped credential embedded alongside a ref is still caught.\n if (/\\$\\{[^}]*\\}/.test(v)) return false; // ${VAR} template (embedded or leading)\n if (/^\\$[A-Za-z_]/.test(v)) return false; // leading $VAR reference\n if (/^%[A-Za-z_][A-Za-z0-9_]*%([\\\\/].*)?$/.test(v)) return false; // %VAR% ref or %VAR%-rooted path\n // A URI of ANY scheme: real endpoints AND secret-manager references that are the\n // safe state — op:// (1Password), vault:// (Vault). ACCEPTED FALSE-NEGATIVE: a URI\n // that itself CARRIES a credential (connection-string userinfo postgres://u:p@host,\n // or a query-param secret like otpauth://…?secret=SEED) is excluded too. Detector 1\n // still catches any prefix-shaped credential embedded in the value, and the bare\n // (non-URI) secret form is still caught by detector 2. Zero-FP is the hard invariant;\n // re-catching these would need query-param parsing that risks FPs on real endpoints.\n if (/^[a-z][a-z0-9+.-]*:\\/\\//i.test(v)) return false;\n // Filesystem path — POSIX (~ . /) or Windows (drive-letter, UNC).\n if (/^[~./]/.test(v) || /^[A-Za-z]:[\\\\/]/.test(v) || /^\\\\\\\\/.test(v)) return false;\n if (/^(true|false|\\d+)$/i.test(v)) return false; // boolean / plain number\n return true;\n}\n\nfunction scanMap(\n server: string,\n field: \"env\" | \"header\",\n map: Record<string, string> | undefined\n): ConfigSecretFinding[] {\n if (!map) return [];\n const out: ConfigSecretFinding[] = [];\n for (const [key, value] of Object.entries(map)) {\n if (typeof value !== \"string\") continue;\n if (parsePlaceholder(value) !== null) continue; // already stored safely — not a leak\n const labels = detectSecretLabels(value);\n if (labels.length > 0) {\n // Value-shape is the more specific, higher-confidence signal — ONE finding per\n // (field, key) even when several patterns match (e.g. a Bearer-wrapped ghp_\n // token hits both), so the --report count is not inflated. Skip the heuristic.\n out.push({ server, field, key, label: labels.join(\", \") });\n continue;\n }\n if (keyLooksSecret(key) && valueLooksPlaintextSecret(value)) {\n out.push({ server, field, key, label: GENERIC_LABEL });\n }\n }\n return out;\n}\n\n/** Scan one server's env + headers for plaintext secrets. */\nexport function scanServerConfigSecrets(\n server: string,\n entry: McpServerEntry\n): ConfigSecretFinding[] {\n return [...scanMap(server, \"env\", entry.env), ...scanMap(server, \"header\", entry.headers)];\n}\n\n/** Scan every server in a client's config. */\nexport function scanConfigSecrets(\n servers: Record<string, McpServerEntry>\n): ConfigSecretFinding[] {\n return Object.entries(servers).flatMap(([name, entry]) => scanServerConfigSecrets(name, entry));\n}\n"],"mappings":"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","names":["servers","getAdapter","getConfigPath"]} |
| #!/usr/bin/env node | ||
| import { | ||
| OWASP_MCP_TOP_10 | ||
| } from "./chunk-MXHNRCQI.js"; | ||
| import { | ||
| sanitizeForTerminal | ||
| } from "./chunk-FEXJHHDM.js"; | ||
| import { | ||
| inspectMessage | ||
| } from "./chunk-62744DB3.js"; | ||
| // src/guard/inspect-cli.ts | ||
| var ACTION_RANK = { pass: 0, warn: 1, block: 2 }; | ||
| function parseFrames(rawSource) { | ||
| const source = rawSource.replace(/^\uFEFF/, ""); | ||
| if (source.trim() === "") return []; | ||
| try { | ||
| return [asFrame(JSON.parse(source))]; | ||
| } catch { | ||
| } | ||
| const frames = []; | ||
| for (const line of source.split("\n")) { | ||
| const trimmed = line.trim(); | ||
| if (trimmed === "") continue; | ||
| try { | ||
| frames.push(asFrame(JSON.parse(trimmed))); | ||
| } catch (err) { | ||
| frames.push({ error: err instanceof Error ? err.message : String(err) }); | ||
| } | ||
| } | ||
| return frames; | ||
| } | ||
| function asFrame(value) { | ||
| if (typeof value !== "object" || value === null) { | ||
| return { error: `expected a JSON-RPC object, got ${value === null ? "null" : typeof value}` }; | ||
| } | ||
| if (Array.isArray(value)) { | ||
| return { error: "expected a single JSON-RPC object, got an array (send batch members as separate NDJSON lines)" }; | ||
| } | ||
| return { frame: value }; | ||
| } | ||
| function findingToJson(f) { | ||
| return { | ||
| signature_id: f.signature_id, | ||
| category: f.category, | ||
| severity: f.severity, | ||
| target: f.target, | ||
| matched_text_excerpt: f.matched_text_excerpt, | ||
| remediation: f.remediation, | ||
| ...f.decoded === true ? { decoded: true } : {} | ||
| }; | ||
| } | ||
| function plural(n, word) { | ||
| return `${n} ${word}${n === 1 ? "" : "s"}`; | ||
| } | ||
| function jsonLine(value) { | ||
| return JSON.stringify(value).replace( | ||
| /[\u007F-\u009F\u2028\u2029]/g, | ||
| (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}` | ||
| ); | ||
| } | ||
| function runInspectCommand(opts) { | ||
| const parsed = parseFrames(opts.source); | ||
| const json = opts.json === true; | ||
| let worst = "pass"; | ||
| let errors = 0; | ||
| const tally = { pass: 0, warn: 0, block: 0 }; | ||
| const humanLines = []; | ||
| parsed.forEach((entry, i) => { | ||
| if ("error" in entry) { | ||
| errors += 1; | ||
| if (json) { | ||
| opts.write(`${jsonLine({ action: "error", error: entry.error })} | ||
| `); | ||
| } else { | ||
| humanLines.push(`frame ${i + 1} \u2014 error: ${sanitizeForTerminal(entry.error)}`); | ||
| } | ||
| return; | ||
| } | ||
| const result = inspectMessage(entry.frame, OWASP_MCP_TOP_10); | ||
| tally[result.action] += 1; | ||
| if (ACTION_RANK[result.action] > ACTION_RANK[worst]) worst = result.action; | ||
| if (json) { | ||
| opts.write(`${jsonLine({ action: result.action, findings: result.findings.map(findingToJson) })} | ||
| `); | ||
| return; | ||
| } | ||
| humanLines.push(`frame ${i + 1} \u2014 ${result.action}`); | ||
| for (const f of result.findings) { | ||
| humanLines.push(` ${f.signature_id} \xB7 ${f.severity} \xB7 ${f.target}${f.decoded === true ? " \xB7 decoded" : ""}`); | ||
| humanLines.push(` excerpt: ${sanitizeForTerminal(f.matched_text_excerpt)}`); | ||
| humanLines.push(` fix: ${sanitizeForTerminal(f.remediation)}`); | ||
| } | ||
| }); | ||
| if (!json) { | ||
| if (parsed.length === 0) { | ||
| opts.write("no frames on input\n"); | ||
| } else { | ||
| opts.write(`${humanLines.join("\n")} | ||
| `); | ||
| const parts = [plural(parsed.length, "frame")]; | ||
| for (const a of ["block", "warn", "pass"]) { | ||
| if (tally[a] > 0) parts.push(`${tally[a]} ${a}`); | ||
| } | ||
| if (errors > 0) parts.push(plural(errors, "error")); | ||
| opts.write(`${parts.join(" \xB7 ")} | ||
| `); | ||
| } | ||
| } | ||
| return { action: worst, errors, frames: parsed.length }; | ||
| } | ||
| export { | ||
| runInspectCommand | ||
| }; | ||
| //# sourceMappingURL=inspect-cli-GGGLUPES.js.map |
| {"version":3,"sources":["../src/guard/inspect-cli.ts"],"sourcesContent":["/**\n * `mcpm guard inspect` — run the guard's signature catalog over MCP JSON-RPC\n * frame(s) offline, with no relay, no wrapped server, and no network.\n *\n * Why this exists as a PUBLIC command (not just an internal function): an\n * external harness — mcp-guardbench, a CI job, a researcher reproducing a\n * finding — needs to ask \"what does mcpm's guard say about this frame?\" without\n * importing `src/guard/*`. Before this command the benchmark's reference adapter\n * vendored an esbuild bundle of patterns+signatures, which (a) silently drifts\n * from the shipped engine and (b) gave mcpm a privileged in-process path that no\n * other guard being scored could have. This command is the level playing field:\n * every guard, mcpm included, is measured through its own published CLI.\n *\n * Contract (depended on by external adapters — treat as semi-stable):\n * - input is ONE JSON frame (pretty-printed is fine) or NDJSON, one per line\n * - `--json` writes exactly one verdict object per input frame, in INPUT\n * ORDER — positional correlation is what lets a harness zip verdicts back\n * to its own case ids without mcpm needing to know about them\n * - an unparseable frame yields `{\"action\":\"error\"}`, never a silent skip and\n * never a fabricated \"pass\" (a harness must be able to tell \"my guard said\n * this is safe\" apart from \"my guard fell over\")\n *\n * The verdict is the same `inspectMessage` default action the relay uses,\n * including the warn-only carrier clamp — so a `resources/read` injection\n * reports `warn` here exactly as it would in-line. Policy overrides\n * (mute/log_only, `guard.policy.json`) are deliberately NOT applied: this\n * command answers \"what do the signatures see\", not \"what would this user's\n * configured policy do\".\n */\n\nimport type { JSONRPCMessage } from \"@modelcontextprotocol/sdk/types.js\";\nimport { inspectMessage } from \"./patterns.js\";\nimport { sanitizeForTerminal } from \"./sanitize.js\";\nimport { OWASP_MCP_TOP_10 } from \"./signatures.js\";\nimport type { InspectAction, InspectFinding } from \"./types.js\";\n\nexport interface InspectCliOpts {\n /** Raw input text: one JSON frame, or NDJSON with one frame per line. */\n readonly source: string;\n /** Emit NDJSON verdicts (one line per input frame) instead of human text. */\n readonly json?: boolean;\n readonly write: (s: string) => void;\n}\n\nexport interface InspectCliResult {\n /** Worst action across all frames — drives the process exit code. */\n readonly action: InspectAction;\n /** Frames that could not be parsed as a JSON-RPC object. */\n readonly errors: number;\n /** Frames actually inspected, including the unparseable ones. */\n readonly frames: number;\n}\n\nconst ACTION_RANK: Readonly<Record<InspectAction, number>> = { pass: 0, warn: 1, block: 2 };\n\ntype ParsedFrame = { readonly frame: JSONRPCMessage } | { readonly error: string };\n\n/**\n * Split input into frames. A whole-input parse is tried FIRST so a\n * pretty-printed single frame (the common hand-authored / captured case) works;\n * NDJSON falls through to per-line parsing.\n */\nfunction parseFrames(rawSource: string): readonly ParsedFrame[] {\n // A leading BOM is common in editor-saved captures and makes JSON.parse throw\n // on otherwise-valid input; stripping it avoids a baffling parse error.\n const source = rawSource.replace(/^\\uFEFF/, \"\");\n if (source.trim() === \"\") return [];\n\n try {\n return [asFrame(JSON.parse(source) as unknown)];\n } catch {\n // Not a single JSON document — treat as NDJSON.\n }\n\n const frames: ParsedFrame[] = [];\n for (const line of source.split(\"\\n\")) {\n const trimmed = line.trim();\n if (trimmed === \"\") continue; // blank lines are separators, not frames\n try {\n frames.push(asFrame(JSON.parse(trimmed) as unknown));\n } catch (err) {\n frames.push({ error: err instanceof Error ? err.message : String(err) });\n }\n }\n return frames;\n}\n\n/**\n * A JSON-RPC frame must be a plain object. Arrays (JSON-RPC batches) are\n * rejected rather than silently mis-inspected — `inspectMessage` takes a single\n * message, and quietly passing a batch would report a false \"pass\" on whatever\n * it contains. Send batch members as separate NDJSON lines.\n */\nfunction asFrame(value: unknown): ParsedFrame {\n if (typeof value !== \"object\" || value === null) {\n return { error: `expected a JSON-RPC object, got ${value === null ? \"null\" : typeof value}` };\n }\n if (Array.isArray(value)) {\n return { error: \"expected a single JSON-RPC object, got an array (send batch members as separate NDJSON lines)\" };\n }\n return { frame: value as JSONRPCMessage };\n}\n\nfunction findingToJson(f: InspectFinding): Record<string, unknown> {\n return {\n signature_id: f.signature_id,\n category: f.category,\n severity: f.severity,\n target: f.target,\n matched_text_excerpt: f.matched_text_excerpt,\n remediation: f.remediation,\n ...(f.decoded === true ? { decoded: true } : {}),\n };\n}\n\nfunction plural(n: number, word: string): string {\n return `${n} ${word}${n === 1 ? \"\" : \"s\"}`;\n}\n\n/**\n * Serialize one verdict as a single output line.\n *\n * `JSON.stringify` escapes C0 but leaves two families raw, and BOTH matter here\n * because the excerpt is attacker-controlled:\n *\n * - **U+2028 / U+2029** are line terminators to Node's `readline` (and to\n * ECMAScript), which is exactly how the documented consumer splits this\n * stream. One of them inside an excerpt splits a verdict across two \"lines\"\n * and permanently desyncs a consumer doing positional correlation —\n * reproduced forging a `pass` on a real attack and a `block` on a benign\n * case. That makes one-verdict-per-line a security property, not formatting.\n * - **C1 controls (U+0080–U+009F)** drive a terminal with no ESC byte at all\n * (8-bit CSI/OSC), so \"stringify escapes C0, therefore ESC sequences can't\n * survive\" was true but did not imply safety. `--json` gets piped into\n * terminals while triaging hostile captures.\n *\n * Escaping is LOSSLESS — the consumer's `JSON.parse` yields the identical\n * string — so byte-fidelity of the excerpt is preserved. DEL (U+007F) rides\n * along in the same class.\n */\nfunction jsonLine(value: unknown): string {\n return JSON.stringify(value).replace(\n /[\\u007F-\\u009F\\u2028\\u2029]/g,\n (c) => `\\\\u${c.charCodeAt(0).toString(16).padStart(4, \"0\")}`,\n );\n}\n\nexport function runInspectCommand(opts: InspectCliOpts): InspectCliResult {\n const parsed = parseFrames(opts.source);\n const json = opts.json === true;\n\n let worst: InspectAction = \"pass\";\n let errors = 0;\n const tally: Record<InspectAction, number> = { pass: 0, warn: 0, block: 0 };\n const humanLines: string[] = [];\n\n parsed.forEach((entry, i) => {\n if (\"error\" in entry) {\n errors += 1;\n if (json) {\n opts.write(`${jsonLine({ action: \"error\", error: entry.error })}\\n`);\n } else {\n humanLines.push(`frame ${i + 1} — error: ${sanitizeForTerminal(entry.error)}`);\n }\n return;\n }\n\n const result = inspectMessage(entry.frame, OWASP_MCP_TOP_10);\n tally[result.action] += 1;\n if (ACTION_RANK[result.action] > ACTION_RANK[worst]) worst = result.action;\n\n if (json) {\n // Excerpts keep byte-fidelity (a harness needs to see what matched), but\n // are emitted through jsonLine so no character can break the one-line\n // framing or reach a terminal as a control sequence. See jsonLine.\n opts.write(`${jsonLine({ action: result.action, findings: result.findings.map(findingToJson) })}\\n`);\n return;\n }\n\n humanLines.push(`frame ${i + 1} — ${result.action}`);\n for (const f of result.findings) {\n humanLines.push(` ${f.signature_id} · ${f.severity} · ${f.target}${f.decoded === true ? \" · decoded\" : \"\"}`);\n // Excerpts are attacker-controlled. Sanitize before they reach a\n // terminal, or `guard inspect` becomes the ANSI/OSC injection vector the\n // guard itself detects.\n humanLines.push(` excerpt: ${sanitizeForTerminal(f.matched_text_excerpt)}`);\n humanLines.push(` fix: ${sanitizeForTerminal(f.remediation)}`);\n }\n });\n\n if (!json) {\n if (parsed.length === 0) {\n opts.write(\"no frames on input\\n\");\n } else {\n opts.write(`${humanLines.join(\"\\n\")}\\n\\n`);\n const parts = [plural(parsed.length, \"frame\")];\n for (const a of [\"block\", \"warn\", \"pass\"] as const) {\n if (tally[a] > 0) parts.push(`${tally[a]} ${a}`);\n }\n if (errors > 0) parts.push(plural(errors, \"error\"));\n opts.write(`${parts.join(\" · \")}\\n`);\n }\n }\n\n return { action: worst, errors, frames: parsed.length };\n}\n"],"mappings":"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","names":[]} |
| #!/usr/bin/env node | ||
| import { | ||
| buildDriftFinding, | ||
| buildHandshakeDriftFinding, | ||
| classifyDrift, | ||
| classifyHandshakeDrift, | ||
| inspectForDrift, | ||
| inspectHandshakeForDrift | ||
| } from "./chunk-QFYQJDKQ.js"; | ||
| import { | ||
| PolicyIntegrityError, | ||
| expireStale, | ||
| readPolicy | ||
| } from "./chunk-CYYYMOUS.js"; | ||
| import { | ||
| hashConfineProfile, | ||
| loadProfile | ||
| } from "./chunk-544DEV2D.js"; | ||
| import { | ||
| OWASP_MCP_TOP_10 | ||
| } from "./chunk-MXHNRCQI.js"; | ||
| import { | ||
| fieldHashesOf, | ||
| handshakeCapabilityKeys, | ||
| handshakeFieldHashesOf, | ||
| hashHandshake, | ||
| hashToolDefinition, | ||
| lookupHandshake, | ||
| readPins, | ||
| writePins | ||
| } from "./chunk-DDCTUMSZ.js"; | ||
| import { | ||
| hashOriginalEntry, | ||
| isConfineBackendAvailable, | ||
| wrapForConfinement | ||
| } from "./chunk-WYSMWP2R.js"; | ||
| import "./chunk-OIFKZA4V.js"; | ||
| import { | ||
| sanitizeForTerminal | ||
| } from "./chunk-FEXJHHDM.js"; | ||
| import { | ||
| resolveEnvPlaceholders | ||
| } from "./chunk-GZ3WCRLG.js"; | ||
| import { | ||
| getStorePath | ||
| } from "./chunk-3X76P3FG.js"; | ||
| import { | ||
| ACTION_RANK, | ||
| defaultActionForFinding, | ||
| inspectMessage, | ||
| normalizeForMatch | ||
| } from "./chunk-62744DB3.js"; | ||
| // src/guard/exfil-names.ts | ||
| var EXFIL_PARAM_DENY = [ | ||
| /^_system_prompt_$/, | ||
| /^_conversation_history_$/, | ||
| /^_chat_history_$/, | ||
| /^_chain_of_thought_$/, | ||
| /^_reasoning_trace_$/, | ||
| /^_(?:full_)?context_window_$/, | ||
| /^_exfil(?:trate)?(?:_[a-z0-9]+)*_$/ | ||
| ]; | ||
| function canonicalize(rawKey) { | ||
| const camelSplit = rawKey.replace(/([a-z0-9])([A-Z])/g, "$1_$2"); | ||
| return normalizeForMatch(camelSplit).toLowerCase().replace(/[\s-]+/g, "_").replace(/_{2,}/g, "_"); | ||
| } | ||
| function classifyParamName(rawKey) { | ||
| const canonical = canonicalize(rawKey); | ||
| return EXFIL_PARAM_DENY.some((re) => re.test(canonical)) ? "deny" : null; | ||
| } | ||
| // src/guard/exfil-params.ts | ||
| var EXFIL_PARAM_SIGNATURE_ID = "exfil-param-in-schema"; | ||
| var MAX_EXCERPT = 200; | ||
| var PASS = { action: "pass", findings: [] }; | ||
| var REMEDIATION = "A tool's input schema declares a parameter named like a context-exfiltration sigil (e.g. `_system_prompt_`) that the model would silently auto-fill from the conversation / system prompt \u2014 a zero-interaction prompt leak. No legitimate tool names a parameter this way. The server's ENTIRE tools/list was blocked before the agent saw it. This is a tripwire for the documented underscore-sigil convention \u2014 a renamed parameter evades it. If you trust this server, mute via `mcpm guard mute exfil-param-in-schema` (re-enables the whole server)."; | ||
| function truncate(s) { | ||
| return s.length > MAX_EXCERPT ? `${s.slice(0, MAX_EXCERPT)}\u2026` : s; | ||
| } | ||
| function* exfilKeys(schema, depth) { | ||
| if (depth > 1 || schema === null || typeof schema !== "object") return; | ||
| const props = schema.properties; | ||
| if (props === null || typeof props !== "object" || Array.isArray(props)) return; | ||
| for (const key of Object.keys(props)) { | ||
| if (!Object.hasOwn(props, key)) continue; | ||
| if (classifyParamName(key) === "deny") yield key; | ||
| yield* exfilKeys(props[key], depth + 1); | ||
| } | ||
| } | ||
| function makeFinding(toolName, rawKey) { | ||
| return { | ||
| signature_id: EXFIL_PARAM_SIGNATURE_ID, | ||
| category: "OWASP-MCP-1", | ||
| severity: "critical", | ||
| target: "tool_description", | ||
| // block-capable carrier (NOT in WARN_ONLY_TARGETS) | ||
| matched_text_excerpt: truncate(`parameter "${rawKey}" in tool "${toolName}"`), | ||
| remediation: REMEDIATION | ||
| }; | ||
| } | ||
| function detectExfilParams(msg) { | ||
| if (!("result" in msg)) return PASS; | ||
| const tools = msg.result?.tools; | ||
| if (!Array.isArray(tools)) return PASS; | ||
| const findings = []; | ||
| for (const tool of tools) { | ||
| if (tool === null || typeof tool !== "object") continue; | ||
| const rawName = tool.name; | ||
| const toolName = typeof rawName === "string" ? rawName : "<unnamed>"; | ||
| for (const key of exfilKeys(tool.inputSchema, 0)) { | ||
| findings.push(makeFinding(toolName, key)); | ||
| } | ||
| } | ||
| if (findings.length === 0) return PASS; | ||
| const action = findings.reduce((acc, f) => { | ||
| const a = defaultActionForFinding(f); | ||
| return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc; | ||
| }, "pass"); | ||
| return { action, findings }; | ||
| } | ||
| // src/guard/relay.ts | ||
| import { spawn } from "child_process"; | ||
| import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"; | ||
| var GUARD_BLOCK_ERROR_CODE = -32099; | ||
| function makeBlockResponse(blocked, result) { | ||
| if (!("id" in blocked) || blocked.id === void 0) return null; | ||
| const finding = result.findings[0]; | ||
| return { | ||
| jsonrpc: "2.0", | ||
| id: blocked.id, | ||
| error: { | ||
| code: GUARD_BLOCK_ERROR_CODE, | ||
| message: "BLOCKED by mcpm-guard", | ||
| data: finding ? { | ||
| signature_id: finding.signature_id, | ||
| category: finding.category, | ||
| severity: finding.severity, | ||
| matched_text_excerpt: finding.matched_text_excerpt, | ||
| remediation: finding.remediation | ||
| } : void 0 | ||
| } | ||
| }; | ||
| } | ||
| var SAFE_ENV_PASSTHROUGH = /* @__PURE__ */ new Set([ | ||
| "PATH", | ||
| "HOME", | ||
| "TMPDIR", | ||
| "TEMP", | ||
| "TMP", | ||
| "LANG", | ||
| "LC_ALL", | ||
| "USER", | ||
| "SHELL" | ||
| ]); | ||
| function buildSafeEnv(source = process.env) { | ||
| const out = {}; | ||
| for (const [k, v] of Object.entries(source)) { | ||
| if (SAFE_ENV_PASSTHROUGH.has(k) || k.startsWith("LC_")) out[k] = v; | ||
| } | ||
| return out; | ||
| } | ||
| var MAX_BUFFER_BYTES = 64 * 1024 * 1024; | ||
| function startRelay(opts) { | ||
| const env = opts.env ?? buildSafeEnv(); | ||
| const child = opts.spawnChild ? opts.spawnChild(opts.command, opts.args, env) : spawn(opts.command, [...opts.args], { | ||
| env, | ||
| stdio: ["pipe", "pipe", "inherit"] | ||
| // stderr passthrough — preserves IDE diagnostics | ||
| }); | ||
| const forwardSignal = (sig) => { | ||
| if (!child.killed) child.kill(sig); | ||
| }; | ||
| let settled = false; | ||
| let resolveExit; | ||
| const exit = new Promise((resolve) => { | ||
| resolveExit = resolve; | ||
| }); | ||
| child.on("error", (err) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| process.off("SIGTERM", forwardSignal); | ||
| process.off("SIGINT", forwardSignal); | ||
| const code = err.code ?? "SPAWN-FAILED"; | ||
| opts.onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "child->parent", | ||
| action: "block", | ||
| findings: [ | ||
| { | ||
| signature_id: "spawn-failure", | ||
| category: "RELAY", | ||
| severity: "critical", | ||
| target: "tool_response", | ||
| matched_text_excerpt: `${code}: ${err.message}`, | ||
| remediation: "The wrapped MCP server binary failed to start. Verify the command exists and is executable." | ||
| } | ||
| ] | ||
| }); | ||
| process.stderr.write(`[mcpm-guard] SPAWN-FAILED ${opts.command}: ${code} | ||
| `); | ||
| child.stdout?.destroy(); | ||
| child.stdin?.destroy(); | ||
| resolveExit(1); | ||
| }); | ||
| child.stdin?.on("error", (err) => { | ||
| const code = err.code; | ||
| if (code !== "EPIPE" && code !== "ERR_STREAM_DESTROYED") { | ||
| opts.onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "parent->child", | ||
| action: "warn", | ||
| findings: [] | ||
| }); | ||
| } | ||
| }); | ||
| const writeToChild = (bytes) => { | ||
| if (child.stdin && !child.stdin.destroyed) child.stdin.write(bytes); | ||
| }; | ||
| wireDirection({ | ||
| source: opts.parentIn, | ||
| target: writeToChild, | ||
| targetEnd: () => child.stdin?.end(), | ||
| parentOut: opts.parentOut, | ||
| inspect: opts.inspectParentRequest, | ||
| direction: "parent->child", | ||
| onEvent: opts.onEvent, | ||
| // Symmetry only — a parent-INITIATED block replies to the client (parentOut), | ||
| // so this is unused for this direction (no replyToOrigin on parent requests). | ||
| replyToSource: (bytes) => opts.parentOut.write(bytes) | ||
| }); | ||
| if (child.stdout) { | ||
| wireDirection({ | ||
| source: child.stdout, | ||
| target: (bytes) => opts.parentOut.write(bytes), | ||
| targetEnd: () => void 0, | ||
| // never end parentOut on child exit | ||
| parentOut: opts.parentOut, | ||
| inspect: opts.inspectChildResponse, | ||
| direction: "child->parent", | ||
| onEvent: opts.onEvent, | ||
| // H7: a blocked server-INITIATED request (sampling/elicitation) errors | ||
| // back to the SERVER (child.stdin), not the client. | ||
| replyToSource: writeToChild | ||
| }); | ||
| } | ||
| process.on("SIGTERM", forwardSignal); | ||
| process.on("SIGINT", forwardSignal); | ||
| child.on("exit", (code) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| process.off("SIGTERM", forwardSignal); | ||
| process.off("SIGINT", forwardSignal); | ||
| resolveExit(code ?? 0); | ||
| }); | ||
| return { child, exit }; | ||
| } | ||
| function wireDirection(w) { | ||
| const buffer = new ReadBuffer(); | ||
| let bufferedBytes = 0; | ||
| w.source.on("data", (chunk) => { | ||
| bufferedBytes += chunk.byteLength; | ||
| if (bufferedBytes > MAX_BUFFER_BYTES) { | ||
| w.onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: w.direction, | ||
| action: "block", | ||
| findings: [] | ||
| }); | ||
| w.source.destroy(); | ||
| return; | ||
| } | ||
| buffer.append(chunk); | ||
| let msg; | ||
| try { | ||
| msg = buffer.readMessage(); | ||
| } catch { | ||
| w.onEvent?.(malformedFrameEvent(w.direction)); | ||
| w.source.destroy(); | ||
| return; | ||
| } | ||
| while (msg !== null) { | ||
| bufferedBytes = 0; | ||
| const decision = w.inspect?.(msg); | ||
| if (decision?.action === "block") { | ||
| logEvent(decision, w.direction, w.onEvent); | ||
| const errResp = makeBlockResponse(msg, decision); | ||
| if (errResp !== null) { | ||
| if (decision.replyToOrigin === true) w.replyToSource(serializeMessage(errResp)); | ||
| else w.parentOut.write(serializeMessage(errResp)); | ||
| } | ||
| } else { | ||
| logEvent(decision, w.direction, w.onEvent); | ||
| w.target(serializeMessage(msg)); | ||
| } | ||
| try { | ||
| msg = buffer.readMessage(); | ||
| } catch { | ||
| w.onEvent?.(malformedFrameEvent(w.direction)); | ||
| w.source.destroy(); | ||
| return; | ||
| } | ||
| } | ||
| }); | ||
| w.source.on("end", () => { | ||
| w.targetEnd(); | ||
| }); | ||
| } | ||
| function malformedFrameEvent(direction) { | ||
| return { | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction, | ||
| action: "block", | ||
| findings: [ | ||
| { | ||
| signature_id: "malformed-frame", | ||
| category: "RELAY", | ||
| severity: "critical", | ||
| target: "tool_response", | ||
| matched_text_excerpt: "malformed JSON-RPC frame on stdio", | ||
| remediation: "The wrapped MCP server emitted a non-JSON-RPC line (e.g. a startup banner). It must write only JSON-RPC frames to stdout." | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| function logEvent(result, direction, onEvent) { | ||
| if (!result || result.findings.length === 0) return; | ||
| onEvent?.({ | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction, | ||
| action: result.action, | ||
| findings: result.findings | ||
| }); | ||
| } | ||
| // src/guard/event-log.ts | ||
| import { appendFile, mkdir } from "fs/promises"; | ||
| import path from "path"; | ||
| var EVENT_LOG_FILENAME = "guard-events.jsonl"; | ||
| var _warnedOnFailure = false; | ||
| async function eventLogPath() { | ||
| return path.join(await getStorePath(), EVENT_LOG_FILENAME); | ||
| } | ||
| function buildEventLogEntry(event, serverName) { | ||
| return { | ||
| ts: event.ts, | ||
| server_name: sanitizeForTerminal(serverName), | ||
| direction: event.direction, | ||
| action: event.action, | ||
| findings: event.findings.map((f) => ({ | ||
| signature_id: f.signature_id, | ||
| category: f.category, | ||
| severity: f.severity, | ||
| target: f.target, | ||
| matched_text_excerpt: f.matched_text_excerpt | ||
| })) | ||
| }; | ||
| } | ||
| async function appendEvent(event, serverName) { | ||
| try { | ||
| const filePath = await eventLogPath(); | ||
| await mkdir(path.dirname(filePath), { recursive: true, mode: 448 }); | ||
| const line = `${JSON.stringify(buildEventLogEntry(event, serverName))} | ||
| `; | ||
| await appendFile(filePath, line, { encoding: "utf-8", mode: 384 }); | ||
| } catch (err) { | ||
| if (!_warnedOnFailure) { | ||
| _warnedOnFailure = true; | ||
| process.stderr.write( | ||
| `[mcpm-guard] event log write failed (logging will continue silently): ${err instanceof Error ? err.message : String(err)} | ||
| ` | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| // src/guard/confine/decide.ts | ||
| function decideConfine(input) { | ||
| const { profile, markerHash, markerRequired, backendAvailable } = input; | ||
| const mustConfine = markerRequired || profile?.require_confine === true; | ||
| if (profile !== null) { | ||
| if (markerHash === null) { | ||
| return mustConfine ? { action: "fail-closed", reason: "confine marker stripped on a required server", event: "confine-marker-stripped" } : { action: "unconfined", reason: "confine marker stripped", event: "confine-marker-stripped" }; | ||
| } | ||
| if (hashConfineProfile(profile) !== markerHash) { | ||
| return { action: "fail-closed", reason: "confine profile hash mismatch (tamper)", event: "confine-hash-mismatch" }; | ||
| } | ||
| if (!backendAvailable) { | ||
| return mustConfine ? { action: "fail-closed", reason: "no confine backend on a required server", event: "confine-backend-missing" } : { action: "unconfined", reason: "no confine backend on this platform", event: "confine-backend-missing" }; | ||
| } | ||
| return { action: "confine", reason: "confined", event: "confine-applied" }; | ||
| } | ||
| if (markerRequired) { | ||
| return { action: "fail-closed", reason: "confine required but no stored profile (store missing?)", event: "confine-profile-missing" }; | ||
| } | ||
| if (markerHash !== null) { | ||
| return { action: "unconfined", reason: "confine marker present but no stored profile", event: "confine-profile-missing" }; | ||
| } | ||
| return { action: "unconfined", reason: "not confined" }; | ||
| } | ||
| // src/guard/run-inner.ts | ||
| var SIGNATURE_LIST_VERSION = "owasp-mcp-top-10@v0.5.0"; | ||
| function mergeInspect(a, b) { | ||
| const action = ACTION_RANK[a.action] >= ACTION_RANK[b.action] ? a.action : b.action; | ||
| return withReplyToOrigin( | ||
| { action, findings: [...a.findings, ...b.findings] }, | ||
| a.replyToOrigin === true || b.replyToOrigin === true | ||
| ); | ||
| } | ||
| function withReplyToOrigin(result, replyToOrigin) { | ||
| if (replyToOrigin && result.action === "block") return { ...result, replyToOrigin: true }; | ||
| return result; | ||
| } | ||
| function applyPolicy(result, policy) { | ||
| const overrides = policy.signature_overrides ?? []; | ||
| if (overrides.length === 0) return result; | ||
| const byId = new Map(overrides.map((o) => [o.id, o])); | ||
| let highest = "pass"; | ||
| const kept = []; | ||
| for (const f of result.findings) { | ||
| const o = byId.get(f.signature_id); | ||
| let perFindingAction; | ||
| if (o === void 0) { | ||
| perFindingAction = defaultActionForFinding(f); | ||
| kept.push(f); | ||
| } else if (o.action === "ignore") { | ||
| continue; | ||
| } else if (o.action === "log_only") { | ||
| perFindingAction = "pass"; | ||
| kept.push(f); | ||
| } else { | ||
| perFindingAction = o.action; | ||
| kept.push(f); | ||
| } | ||
| if (ACTION_RANK[perFindingAction] > ACTION_RANK[highest]) highest = perFindingAction; | ||
| } | ||
| return withReplyToOrigin({ action: highest, findings: kept }, result.replyToOrigin === true); | ||
| } | ||
| function hasToolsList(msg) { | ||
| if (!("result" in msg)) return false; | ||
| const result = msg.result; | ||
| return Array.isArray(result?.tools); | ||
| } | ||
| function isServerInitiatedMethod(msg) { | ||
| if (!("method" in msg)) return false; | ||
| const m = msg.method; | ||
| return m === "sampling/createMessage" || m === "elicitation/create"; | ||
| } | ||
| function inspectServerInitiated(msg) { | ||
| if (!isServerInitiatedMethod(msg)) return null; | ||
| const contentLeaves = serverInitiatedContent(msg); | ||
| if (contentLeaves.length === 0) return null; | ||
| const synthetic = { | ||
| jsonrpc: "2.0", | ||
| id: 0, | ||
| // dummy — the scan reads only the result subtree, never the id. | ||
| result: { messages: contentLeaves.map((c) => ({ role: "user", content: c })) } | ||
| }; | ||
| const scan = inspectMessage(synthetic, OWASP_MCP_TOP_10); | ||
| if (scan.findings.length === 0) return null; | ||
| const findings = scan.findings.map((f) => ({ ...f, target: "sampling_prompt" })); | ||
| const action = findings.reduce((acc, f) => { | ||
| const a = defaultActionForFinding(f); | ||
| return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc; | ||
| }, "pass"); | ||
| const hasId = "id" in msg && msg.id !== void 0; | ||
| return action === "block" && hasId ? { action, findings, replyToOrigin: true } : { action, findings }; | ||
| } | ||
| function serverInitiatedContent(msg) { | ||
| const params = msg.params; | ||
| if (params === null || typeof params !== "object") return []; | ||
| const p = params; | ||
| const out = []; | ||
| if (typeof p.systemPrompt === "string") out.push(p.systemPrompt); | ||
| if (Array.isArray(p.messages)) { | ||
| for (const m of p.messages) { | ||
| if (m !== null && typeof m === "object" && "content" in m) out.push(m.content); | ||
| } | ||
| } | ||
| if (typeof p.message === "string") out.push(p.message); | ||
| if (p.requestedSchema !== null && typeof p.requestedSchema === "object") out.push(p.requestedSchema); | ||
| return out; | ||
| } | ||
| function confineGuardEvent(event, reason, action, severity) { | ||
| return { | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "parent->child", | ||
| action, | ||
| findings: [ | ||
| { | ||
| signature_id: event, | ||
| category: "CONFINE", | ||
| severity, | ||
| target: "tool_response", | ||
| matched_text_excerpt: reason, | ||
| remediation: "See docs/GUARD.md \u2014 `mcpm guard confine`." | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| async function runInner(parsed) { | ||
| const safeName = sanitizeForTerminal(parsed.serverName); | ||
| if (typeof parsed.origHash === "string" && parsed.origHash.length > 0) { | ||
| const recomputed = hashOriginalEntry(parsed.command, parsed.args, parsed.declaredEnvKeys); | ||
| if (recomputed !== parsed.origHash) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] ORIG-HASH-MISMATCH ${safeName}: the wrapped command/args/declared-env no longer match the integrity hash embedded at \`mcpm guard enable\` time \u2014 the client config entry may have been edited or tampered with. Starting anyway (advisory); a future mcpm release will refuse to start on mismatch. Review ~/.mcpm/guard-events.jsonl, and if you changed the entry on purpose re-run \`mcpm guard enable\` to re-pin it. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| { | ||
| ts: (/* @__PURE__ */ new Date()).toISOString(), | ||
| direction: "parent->child", | ||
| action: "warn", | ||
| findings: [ | ||
| { | ||
| signature_id: "orig-hash-mismatch", | ||
| category: "RELAY", | ||
| severity: "high", | ||
| target: "tool_response", | ||
| matched_text_excerpt: "wrap-marker integrity: recomputed hash != embedded --orig-hash", | ||
| remediation: "Re-run `mcpm guard enable` to re-pin, or restore the original wrapped entry in the client config." | ||
| } | ||
| ] | ||
| }, | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| } | ||
| const logEvent2 = (event) => { | ||
| if (event.action === "block" || event.action === "warn") { | ||
| process.stderr.write( | ||
| `[mcpm-guard] ${event.action.toUpperCase()} ${safeName} ${event.findings.map((f) => f.signature_id).join(",")} | ||
| ` | ||
| ); | ||
| void appendEvent(event, parsed.serverName); | ||
| } | ||
| }; | ||
| let pinsSnapshot; | ||
| try { | ||
| pinsSnapshot = await readPins(); | ||
| } catch (err) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] PINS-READ-ERROR: ${safeName} could not load ~/.mcpm/pins.json: ${err.message} | ||
| Refusing to start the relay \u2014 running with rug-pull (schema-drift) protection silently disabled is more dangerous than not starting. Review ~/.mcpm/guard-events.jsonl for unauthorized activity. If you intentionally changed pins.json, run \`mcpm guard reset-integrity\`. | ||
| ` | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| const policy = expireStale( | ||
| await readPolicy().catch((err) => { | ||
| if (err instanceof PolicyIntegrityError) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] POLICY-INTEGRITY-ERROR: ${safeName} ${err.message} | ||
| Falling back to full enforcement (ignoring guard-policy.yaml) for this session. | ||
| ` | ||
| ); | ||
| } else { | ||
| process.stderr.write( | ||
| `[mcpm-guard] POLICY-READ-ERROR: ${err.message} | ||
| ` | ||
| ); | ||
| } | ||
| return {}; | ||
| }) | ||
| ); | ||
| const pausedUntilFuture = policy.paused_until !== void 0 && new Date(policy.paused_until) > /* @__PURE__ */ new Date(); | ||
| const sessionState = { | ||
| firstHashes: /* @__PURE__ */ new Map(), | ||
| revalidationArmed: false, | ||
| handshakeSeenHash: null | ||
| }; | ||
| const baselineForDrift = pinsSnapshot; | ||
| const inspectChild = (msg) => { | ||
| if (pausedUntilFuture) return { action: "pass", findings: [] }; | ||
| if (isToolsListChangedNotification(msg)) { | ||
| sessionState.revalidationArmed = true; | ||
| return { action: "pass", findings: [] }; | ||
| } | ||
| const serverInitiated = inspectServerInitiated(msg); | ||
| if (serverInitiated !== null) return applyPolicy(serverInitiated, policy); | ||
| const patternResult = inspectMessage(msg, OWASP_MCP_TOP_10); | ||
| let driftResult = { action: "pass", findings: [] }; | ||
| let exfilResult = { action: "pass", findings: [] }; | ||
| if (hasToolsList(msg)) { | ||
| exfilResult = detectExfilParams(msg); | ||
| driftResult = inspectForDriftSync(msg, parsed.serverName, baselineForDrift, sessionState); | ||
| void (async () => { | ||
| await inspectForDrift(msg, parsed.serverName, { | ||
| read: () => readPins().catch(() => pinsSnapshot), | ||
| write: writePins, | ||
| signatureListVersion: SIGNATURE_LIST_VERSION | ||
| }); | ||
| pinsSnapshot = await readPins().catch(() => pinsSnapshot); | ||
| })(); | ||
| } else if (isInitializeResult(msg)) { | ||
| driftResult = inspectHandshakeDriftSync(msg, parsed.serverName, baselineForDrift, sessionState); | ||
| void (async () => { | ||
| await inspectHandshakeForDrift(msg, parsed.serverName, { | ||
| read: () => readPins().catch(() => pinsSnapshot), | ||
| write: writePins, | ||
| signatureListVersion: SIGNATURE_LIST_VERSION | ||
| }); | ||
| pinsSnapshot = await readPins().catch(() => pinsSnapshot); | ||
| })(); | ||
| } | ||
| return applyPolicy(mergeInspect(mergeInspect(patternResult, driftResult), exfilResult), policy); | ||
| }; | ||
| const inspectParent = (msg) => { | ||
| if (pausedUntilFuture) return { action: "pass", findings: [] }; | ||
| return applyPolicy(inspectMessage(msg, OWASP_MCP_TOP_10), policy); | ||
| }; | ||
| const baselineEnv = buildSafeEnv(process.env); | ||
| const childEnvSource = { ...baselineEnv }; | ||
| for (const key of parsed.declaredEnvKeys) { | ||
| const value = process.env[key]; | ||
| if (value !== void 0) childEnvSource[key] = value; | ||
| } | ||
| let childEnv; | ||
| try { | ||
| childEnv = await resolveEnvPlaceholders(childEnvSource); | ||
| } catch (err) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] SECRET-MISSING ${safeName} ${err.message} | ||
| ` | ||
| ); | ||
| return 1; | ||
| } | ||
| if (parsed.confineProfileHash !== void 0 && !/^[0-9a-f]{64}$/.test(parsed.confineProfileHash)) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-BLOCK ${safeName}: malformed --confine-profile-hash in the wrap marker (the client config entry may be tampered or corrupt). Refusing to start. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent( | ||
| "confine-marker-malformed", | ||
| "malformed confine profile hash", | ||
| "block", | ||
| "critical" | ||
| ), | ||
| parsed.serverName | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| let spawnCommand = parsed.command; | ||
| let spawnArgs = parsed.args; | ||
| let confineProfile = null; | ||
| try { | ||
| confineProfile = await loadProfile(parsed.serverName); | ||
| } catch (err) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-STORE-ERROR ${safeName}: ${err.message} | ||
| ` | ||
| ); | ||
| } | ||
| const confineDecision = decideConfine({ | ||
| profile: confineProfile, | ||
| markerHash: parsed.confineProfileHash ?? null, | ||
| markerRequired: parsed.confineRequired === true, | ||
| backendAvailable: isConfineBackendAvailable() | ||
| }); | ||
| if (confineDecision.action === "fail-closed") { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-BLOCK ${safeName}: ${confineDecision.reason}. Refusing to start (this server is marked require-confine). Run \`mcpm guard doctor-confine\` to check the backend, and review ~/.mcpm/guard-events.jsonl. | ||
| ` | ||
| ); | ||
| if (confineDecision.event !== void 0) { | ||
| void appendEvent( | ||
| confineGuardEvent(confineDecision.event, confineDecision.reason, "block", "critical"), | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| process.exit(1); | ||
| } | ||
| if (confineDecision.action === "confine" && confineProfile !== null) { | ||
| const wrapped = wrapForConfinement(confineProfile, parsed.command, parsed.args); | ||
| if (wrapped !== null) { | ||
| spawnCommand = wrapped.command; | ||
| spawnArgs = wrapped.args; | ||
| void appendEvent( | ||
| confineGuardEvent( | ||
| confineDecision.event ?? "confine-applied", | ||
| confineDecision.reason, | ||
| "pass", | ||
| "low" | ||
| ), | ||
| parsed.serverName | ||
| ); | ||
| } else { | ||
| const required = parsed.confineRequired === true || confineProfile.require_confine; | ||
| if (required) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-BLOCK ${safeName}: sandbox backend became unavailable at spawn (require-confine). Refusing to start. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent( | ||
| "confine-backend-missing", | ||
| "backend unavailable at wrap", | ||
| "block", | ||
| "critical" | ||
| ), | ||
| parsed.serverName | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-UNCONFINED ${safeName}: sandbox backend unavailable at wrap \u2014 running unconfined. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent("confine-backend-missing", "backend unavailable at wrap", "warn", "high"), | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| } else if (confineDecision.event !== void 0) { | ||
| process.stderr.write( | ||
| `[mcpm-guard] CONFINE-UNCONFINED ${safeName}: ${confineDecision.reason} \u2014 running unconfined. | ||
| ` | ||
| ); | ||
| void appendEvent( | ||
| confineGuardEvent(confineDecision.event, confineDecision.reason, "warn", "high"), | ||
| parsed.serverName | ||
| ); | ||
| } | ||
| const handle = startRelay({ | ||
| command: spawnCommand, | ||
| args: spawnArgs, | ||
| env: childEnv, | ||
| parentIn: process.stdin, | ||
| parentOut: process.stdout, | ||
| inspectChildResponse: inspectChild, | ||
| inspectParentRequest: inspectParent, | ||
| onEvent: logEvent2 | ||
| }); | ||
| return handle.exit; | ||
| } | ||
| function sanitizeLabel(s) { | ||
| return sanitizeForTerminal(s, 128); | ||
| } | ||
| function inspectForDriftSync(msg, serverName, baseline, state) { | ||
| const armed = state.revalidationArmed; | ||
| state.revalidationArmed = false; | ||
| const result = msg.result; | ||
| const tools = Array.isArray(result?.tools) ? result.tools : []; | ||
| const findings = []; | ||
| for (const rawTool of tools) { | ||
| const finding = inspectToolDrift(rawTool, serverName, baseline, state, armed); | ||
| if (finding !== null) findings.push(finding); | ||
| } | ||
| const action = findings.reduce((acc, f) => { | ||
| const a = defaultActionForFinding(f); | ||
| return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc; | ||
| }, "pass"); | ||
| return { action, findings }; | ||
| } | ||
| function inspectToolDrift(rawTool, serverName, baseline, state, armed) { | ||
| if (rawTool === null || typeof rawTool !== "object") return null; | ||
| const tool = rawTool; | ||
| const toolName = typeof tool.name === "string" ? tool.name : null; | ||
| if (toolName === null) return null; | ||
| const fields = { | ||
| description: typeof tool.description === "string" ? tool.description : null, | ||
| schema: tool.inputSchema ?? tool.schema, | ||
| annotations: tool.annotations | ||
| }; | ||
| const liveWhole = hashToolDefinition(fields); | ||
| const liveFields = fieldHashesOf(fields); | ||
| const serverPins = Object.hasOwn(baseline.servers, serverName) ? baseline.servers[serverName] : void 0; | ||
| const pinned = serverPins && Object.hasOwn(serverPins, toolName) ? serverPins[toolName] : void 0; | ||
| const sessionKey = `${serverName}::${toolName}`; | ||
| const firstSeen = state.firstHashes.get(sessionKey); | ||
| if (!armed && firstSeen !== void 0 && firstSeen !== liveWhole) { | ||
| return inSessionDriftFinding(serverName, toolName, firstSeen, liveWhole); | ||
| } | ||
| if (firstSeen === void 0 || armed) state.firstHashes.set(sessionKey, liveWhole); | ||
| if (!pinned || pinned.current_hash === null) return null; | ||
| if (liveWhole === pinned.current_hash) return null; | ||
| const cls = classifyDrift(pinned, liveFields); | ||
| const newDescriptionExcerpt = typeof tool.description === "string" ? sanitizeForTerminal(tool.description, 80) : void 0; | ||
| return buildDriftFinding({ | ||
| cls, | ||
| safeServer: sanitizeLabel(serverName), | ||
| safeTool: sanitizeLabel(toolName), | ||
| expected: pinned.current_hash, | ||
| actual: liveWhole, | ||
| newDescriptionExcerpt | ||
| }); | ||
| } | ||
| function inSessionDriftFinding(serverName, toolName, firstSeen, liveWhole) { | ||
| return { | ||
| signature_id: "schema-drift-in-session", | ||
| category: "OWASP-MCP-1", | ||
| severity: "critical", | ||
| target: "tool_description", | ||
| matched_text_excerpt: `${sanitizeLabel(toolName)}: ${firstSeen.slice(7, 19)}\u2026 \u2192 ${liveWhole.slice(7, 19)}\u2026 (same session)`, | ||
| remediation: `Server "${sanitizeLabel(serverName)}" delivered two different schemas for tool "${sanitizeLabel(toolName)}" in the same session. This is a rug-pull attempt; restart the IDE and reinspect the server's source.` | ||
| }; | ||
| } | ||
| function isToolsListChangedNotification(msg) { | ||
| if (!("method" in msg)) return false; | ||
| if (msg.method !== "notifications/tools/list_changed") return false; | ||
| return !("result" in msg); | ||
| } | ||
| function isInitializeResult(msg) { | ||
| if (!("result" in msg)) return false; | ||
| const result = msg.result; | ||
| return result !== null && typeof result === "object" && typeof result.protocolVersion === "string"; | ||
| } | ||
| function inspectHandshakeDriftSync(msg, serverName, baseline, state) { | ||
| const result = msg.result; | ||
| if (result === null || typeof result !== "object") return { action: "pass", findings: [] }; | ||
| const liveFields = handshakeFieldHashesOf(result); | ||
| const liveCapKeys = handshakeCapabilityKeys(result); | ||
| const liveWhole = hashHandshake(liveFields); | ||
| const seen = state.handshakeSeenHash; | ||
| if (seen !== null && seen !== liveWhole) { | ||
| return warnResult(handshakeInSessionFinding(serverName, seen, liveWhole)); | ||
| } | ||
| if (seen === null) state.handshakeSeenHash = liveWhole; | ||
| const pinned = lookupHandshake(baseline, serverName); | ||
| if (pinned === void 0) return { action: "pass", findings: [] }; | ||
| if (liveWhole === pinned.current_hash || pinned.previous_hashes.includes(liveWhole)) { | ||
| return { action: "pass", findings: [] }; | ||
| } | ||
| const cls = classifyHandshakeDrift(pinned, liveFields, liveCapKeys); | ||
| const findings = buildHandshakeDriftFinding({ | ||
| cls, | ||
| safeServer: sanitizeLabel(serverName) | ||
| }); | ||
| const action = findings.reduce((acc, f) => { | ||
| const a = defaultActionForFinding(f); | ||
| return ACTION_RANK[a] > ACTION_RANK[acc] ? a : acc; | ||
| }, "pass"); | ||
| return { action, findings }; | ||
| } | ||
| function warnResult(finding) { | ||
| return { action: defaultActionForFinding(finding), findings: [finding] }; | ||
| } | ||
| function handshakeInSessionFinding(serverName, firstSeen, liveWhole) { | ||
| return { | ||
| signature_id: "handshake-drift-in-session", | ||
| category: "OWASP-MCP-1", | ||
| severity: "high", | ||
| target: "initialize_instructions", | ||
| matched_text_excerpt: `${sanitizeLabel(serverName)}: ${firstSeen.slice(7, 19)}\u2026 \u2192 ${liveWhole.slice(7, 19)}\u2026 (same session)`, | ||
| remediation: `Server "${sanitizeLabel(serverName)}" delivered two different initialize handshakes in the same session \u2014 initialize should occur once. Inspect the wrapped command; this is a warn-only signal and does not block the session.` | ||
| }; | ||
| } | ||
| export { | ||
| applyPolicy, | ||
| inspectForDriftSync, | ||
| inspectHandshakeDriftSync, | ||
| inspectServerInitiated, | ||
| isInitializeResult, | ||
| isToolsListChangedNotification, | ||
| mergeInspect, | ||
| runInner | ||
| }; | ||
| //# sourceMappingURL=run-inner-AEJPKZM5.js.map |
Sorry, the diff of this file is too big to display
| #!/usr/bin/env node | ||
| import { | ||
| buildDoctorModel, | ||
| execCheckDefault, | ||
| formatMcpEntryCommand, | ||
| makeCheckConfigExists | ||
| } from "./chunk-AIAAC2ZX.js"; | ||
| import { | ||
| resolveInstallEntry | ||
| } from "./chunk-OVIPM4DT.js"; | ||
| import { | ||
| readPins | ||
| } from "./chunk-DDCTUMSZ.js"; | ||
| import "./chunk-E3T224S3.js"; | ||
| import { | ||
| fetchNpmProvenance | ||
| } from "./chunk-QBEWWR7M.js"; | ||
| import "./chunk-WYSMWP2R.js"; | ||
| import "./chunk-OIFKZA4V.js"; | ||
| import "./chunk-FEXJHHDM.js"; | ||
| import "./chunk-SN3RQIVF.js"; | ||
| import "./chunk-YU6C7OHM.js"; | ||
| import "./chunk-UNGY7RTE.js"; | ||
| import "./chunk-W4IAFBUN.js"; | ||
| import "./chunk-2PWW3Q5Q.js"; | ||
| import { | ||
| fetchNpmIntegrity | ||
| } from "./chunk-7RJXJERN.js"; | ||
| import "./chunk-K4U7EXLG.js"; | ||
| import "./chunk-GZ3WCRLG.js"; | ||
| import "./chunk-6R7TL5O2.js"; | ||
| import { | ||
| CLIENT_IDS | ||
| } from "./chunk-R4R2VPDA.js"; | ||
| import "./chunk-2SYM6O5W.js"; | ||
| import "./chunk-3X76P3FG.js"; | ||
| import { | ||
| extractRegistryMeta | ||
| } from "./chunk-MZCNQU2K.js"; | ||
| import "./chunk-62744DB3.js"; | ||
| // src/server/index.ts | ||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| // src/server/tools.ts | ||
| import { z } from "zod"; | ||
| var serverName = z.string().min(1).max(256); | ||
| var clientId = z.enum(CLIENT_IDS); | ||
| var NoArgsInput = z.strictObject({}); | ||
| var SearchInput = z.strictObject({ | ||
| query: z.string().min(1).max(200), | ||
| limit: z.number().int().min(1).max(100).optional().default(20) | ||
| }); | ||
| var InstallInput = z.strictObject({ | ||
| name: serverName, | ||
| client: clientId.optional(), | ||
| minTrustScore: z.number().min(0).max(100).optional().default(50) | ||
| }); | ||
| var InfoInput = z.strictObject({ | ||
| name: serverName | ||
| }); | ||
| var ListInput = z.strictObject({ | ||
| client: clientId.optional() | ||
| }); | ||
| var RemoveInput = z.strictObject({ | ||
| name: serverName, | ||
| client: clientId.optional() | ||
| }); | ||
| var SetupInput = z.strictObject({ | ||
| description: z.string().min(1).max(1e3), | ||
| client: clientId.optional(), | ||
| minTrustScore: z.number().min(0).max(100).optional().default(50) | ||
| }); | ||
| var UpInput = z.strictObject({ | ||
| stackFile: z.string().optional().default("mcpm.yaml"), | ||
| profile: z.string().optional(), | ||
| dryRun: z.boolean().optional().default(false) | ||
| }); | ||
| // src/server/handlers.ts | ||
| import path from "path"; | ||
| var SERVER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}$/; | ||
| function validateMcpServerName(name) { | ||
| if (typeof name !== "string" || name.length === 0 || name.length > 256) { | ||
| throw new Error(`Invalid server name: must be a non-empty string under 256 characters.`); | ||
| } | ||
| if (!SERVER_NAME_RE.test(name)) { | ||
| throw new Error( | ||
| `Invalid server name format: "${name}". Expected format: "namespace/server-name" (alphanumeric, dots, hyphens, underscores only).` | ||
| ); | ||
| } | ||
| } | ||
| function computeTrust(entry, deps) { | ||
| const findings = deps.scanTier1(entry); | ||
| return deps.computeTrustScore({ | ||
| findings, | ||
| healthCheckPassed: null, | ||
| hasExternalScanner: false, | ||
| registryMeta: extractRegistryMeta(entry) | ||
| }); | ||
| } | ||
| async function resolveClients(requestedClient, deps) { | ||
| const detected = await deps.detectClients(); | ||
| if (detected.length === 0) { | ||
| throw new Error("No supported AI clients found."); | ||
| } | ||
| if (requestedClient !== void 0) { | ||
| if (!CLIENT_IDS.includes(requestedClient)) { | ||
| throw new Error( | ||
| `Unknown client "${requestedClient}". Valid values: ${CLIENT_IDS.join(", ")}.` | ||
| ); | ||
| } | ||
| const id = requestedClient; | ||
| if (!detected.includes(id)) { | ||
| throw new Error(`Client "${requestedClient}" is not installed.`); | ||
| } | ||
| return [id]; | ||
| } | ||
| return detected; | ||
| } | ||
| async function handleSearch(args, deps) { | ||
| const entries = await deps.registrySearch(args.query, args.limit); | ||
| const servers = entries.map((entry) => { | ||
| const trust = computeTrust(entry, deps); | ||
| return { | ||
| name: entry.server.name, | ||
| description: entry.server.description ?? "", | ||
| version: entry.server.version, | ||
| trustScore: trust.score | ||
| }; | ||
| }); | ||
| return { servers }; | ||
| } | ||
| var DEFAULT_MIN_TRUST_SCORE = 50; | ||
| var HARD_TRUST_FLOOR = 25; | ||
| function effectiveMinTrustScore(requested) { | ||
| return Math.max(requested ?? DEFAULT_MIN_TRUST_SCORE, HARD_TRUST_FLOOR); | ||
| } | ||
| async function handleInstall(args, deps, preResolved) { | ||
| validateMcpServerName(args.name); | ||
| const entry = preResolved?.entry ?? await deps.registryGetServer(args.name); | ||
| const trust = preResolved?.trust ?? computeTrust(entry, deps); | ||
| const minScore = effectiveMinTrustScore(args.minTrustScore); | ||
| if (trust.score < minScore) { | ||
| throw new Error( | ||
| `Server "${args.name}" has trust score ${trust.score}/${trust.maxPossible} (level: ${trust.level}), which is below the minimum threshold of ${minScore}. Install rejected for safety. Use mcpm CLI with --yes to override after manual review.` | ||
| ); | ||
| } | ||
| const clients = await resolveClients(args.client, deps); | ||
| const installedClients = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| const mcpEntry = resolveInstallEntry(entry, clientId2); | ||
| if (mcpEntry.url !== void 0 && mcpEntry.command === void 0) { | ||
| throw new Error( | ||
| `Server "${args.name}" uses a URL/HTTP transport and runs UNGUARDED (the guard relay only wraps stdio servers). Installing it is not permitted via the MCP surface. Use the mcpm CLI with --allow-unguarded after manual review.` | ||
| ); | ||
| } | ||
| await adapter.addServer(configPath, args.name, mcpEntry); | ||
| installedClients.push(clientId2); | ||
| } | ||
| await deps.addToStore({ | ||
| name: args.name, | ||
| version: entry.server.version, | ||
| clients: [...installedClients], | ||
| installedAt: (/* @__PURE__ */ new Date()).toISOString() | ||
| }); | ||
| return { | ||
| installed: true, | ||
| name: args.name, | ||
| version: entry.server.version, | ||
| clients: installedClients, | ||
| trustScore: trust | ||
| }; | ||
| } | ||
| async function handleInfo(args, deps) { | ||
| validateMcpServerName(args.name); | ||
| const entry = await deps.registryGetServer(args.name); | ||
| const trust = computeTrust(entry, deps); | ||
| return { | ||
| name: entry.server.name, | ||
| description: entry.server.description ?? "", | ||
| version: entry.server.version, | ||
| packages: entry.server.packages.map((p) => ({ | ||
| registryType: p.registryType, | ||
| identifier: p.identifier | ||
| })), | ||
| trustScore: trust | ||
| }; | ||
| } | ||
| async function handleList(args, deps) { | ||
| const clients = await resolveClients(args.client, deps); | ||
| const servers = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| const installed = await adapter.read(configPath); | ||
| for (const [name, entry] of Object.entries(installed)) { | ||
| const command = formatMcpEntryCommand(entry, "unknown"); | ||
| servers.push({ name, client: clientId2, command }); | ||
| } | ||
| } | ||
| return { servers }; | ||
| } | ||
| async function handleRemove(args, deps) { | ||
| validateMcpServerName(args.name); | ||
| const clients = await resolveClients(args.client, deps); | ||
| const removedClients = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| try { | ||
| await adapter.removeServer(configPath, args.name); | ||
| removedClients.push(clientId2); | ||
| } catch { | ||
| } | ||
| } | ||
| if (removedClients.length === 0) { | ||
| throw new Error(`Server "${args.name}" not found in any client config.`); | ||
| } | ||
| try { | ||
| await deps.removeFromStore(args.name); | ||
| } catch { | ||
| } | ||
| return { removed: true, name: args.name, clients: removedClients }; | ||
| } | ||
| async function handleAudit(deps) { | ||
| const clients = await deps.detectClients(); | ||
| const results = []; | ||
| for (const clientId2 of clients) { | ||
| const adapter = deps.getAdapter(clientId2); | ||
| const configPath = deps.getConfigPath(clientId2); | ||
| const installed = await adapter.read(configPath); | ||
| for (const name of Object.keys(installed)) { | ||
| try { | ||
| const entry = await deps.registryGetServer(name); | ||
| const trust = computeTrust(entry, deps); | ||
| results.push({ name, client: clientId2, trustScore: trust }); | ||
| } catch { | ||
| results.push({ | ||
| name, | ||
| client: clientId2, | ||
| trustScore: { score: 0, maxPossible: 80, level: "risky", breakdown: { healthCheck: 0, staticScan: 0, externalScan: 0, registryMeta: 0 } } | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| return { results }; | ||
| } | ||
| async function handleDoctor(deps) { | ||
| return buildDoctorModel({ | ||
| getAdapter: deps.getAdapter, | ||
| getConfigPath: deps.getConfigPath, | ||
| checkConfigExists: makeCheckConfigExists(deps.getConfigPath), | ||
| execCheck: execCheckDefault | ||
| }); | ||
| } | ||
| async function handleSetup(args, deps) { | ||
| if (!args.description.trim()) { | ||
| throw new Error("Could not extract any keywords from empty description."); | ||
| } | ||
| const keywords = extractKeywords(args.description); | ||
| const minScore = effectiveMinTrustScore(args.minTrustScore); | ||
| const installed = []; | ||
| const skipped = []; | ||
| const searchResults = await Promise.all( | ||
| keywords.map( | ||
| (kw) => deps.registrySearch(kw, 5).then((entries) => ({ ok: true, entries })).catch((err) => ({ | ||
| ok: false, | ||
| error: err instanceof Error ? err.message : String(err) | ||
| })) | ||
| ) | ||
| ); | ||
| const seenNames = /* @__PURE__ */ new Set(); | ||
| for (let i = 0; i < keywords.length; i++) { | ||
| const keyword = keywords[i]; | ||
| const outcome = searchResults[i]; | ||
| if (!outcome.ok) { | ||
| skipped.push({ name: keyword, reason: `Registry search failed: ${outcome.error}` }); | ||
| continue; | ||
| } | ||
| const entries = outcome.entries; | ||
| if (entries.length === 0) { | ||
| skipped.push({ name: keyword, reason: `No servers found for "${keyword}"` }); | ||
| continue; | ||
| } | ||
| let bestEntry = null; | ||
| let bestTrust = null; | ||
| for (const entry of entries) { | ||
| if (seenNames.has(entry.server.name)) continue; | ||
| const trust = computeTrust(entry, deps); | ||
| if (bestTrust === null || trust.score > bestTrust.score) { | ||
| bestEntry = entry; | ||
| bestTrust = trust; | ||
| } | ||
| } | ||
| if (bestEntry === null || bestTrust === null) { | ||
| skipped.push({ name: keyword, reason: "All results already installed or duplicated" }); | ||
| continue; | ||
| } | ||
| if (bestTrust.score < minScore) { | ||
| skipped.push({ | ||
| name: bestEntry.server.name, | ||
| reason: `Trust score ${bestTrust.score}/${bestTrust.maxPossible} is below minimum ${minScore}` | ||
| }); | ||
| continue; | ||
| } | ||
| try { | ||
| await handleInstall( | ||
| { name: bestEntry.server.name, client: args.client }, | ||
| deps, | ||
| { entry: bestEntry, trust: bestTrust } | ||
| ); | ||
| seenNames.add(bestEntry.server.name); | ||
| installed.push({ name: bestEntry.server.name, trustScore: bestTrust }); | ||
| } catch (err) { | ||
| skipped.push({ | ||
| name: bestEntry.server.name, | ||
| reason: `Install failed: ${err.message}` | ||
| }); | ||
| } | ||
| } | ||
| const note = installed.length > 0 ? "Restart your AI client to use the newly installed servers." : void 0; | ||
| return { installed, skipped, ...note ? { note } : {} }; | ||
| } | ||
| async function handleMcpUp(args, deps) { | ||
| const stackFile = args.stackFile ?? "mcpm.yaml"; | ||
| const resolved = path.resolve(process.cwd(), stackFile); | ||
| if (resolved !== process.cwd() && !resolved.startsWith(process.cwd() + path.sep)) { | ||
| throw new Error("stackFile must be within the working directory"); | ||
| } | ||
| { | ||
| const { realpath } = await import("fs/promises"); | ||
| try { | ||
| const [realStack, realCwd] = await Promise.all([ | ||
| realpath(resolved), | ||
| realpath(process.cwd()) | ||
| ]); | ||
| if (realStack !== realCwd && !realStack.startsWith(realCwd + path.sep)) { | ||
| throw new Error("stackFile must be within the working directory"); | ||
| } | ||
| } catch (err) { | ||
| const code = err.code ?? ""; | ||
| if (!["ENOENT", "ELOOP", "ENOTDIR"].includes(code)) throw err; | ||
| } | ||
| } | ||
| const { handleUp } = await import("./up-VGICTIUI.js"); | ||
| const { writeFile } = await import("fs/promises"); | ||
| const { handleLock } = await import("./lock-O7O3VM6R.js"); | ||
| const { RegistryClient } = await import("./client-3RPMRFZL.js"); | ||
| const { scanTier1: st1 } = await import("./tier1-VFXYMODG.js"); | ||
| const { checkScannerAvailable: csa, scanTier2: st2 } = await import("./tier2-DE35UF7V.js"); | ||
| const { computeTrustScore: cts } = await import("./trust-score-IP4Y5SAY.js"); | ||
| const client = new RegistryClient(); | ||
| const outputLines = []; | ||
| const records = []; | ||
| let thrownError; | ||
| try { | ||
| await handleUp( | ||
| { | ||
| stackFile, | ||
| profile: args.profile, | ||
| dryRun: args.dryRun, | ||
| ci: true, | ||
| yes: false, | ||
| // MCP surface lockdown (fixes C, D & H1): never auto-read ambient | ||
| // secrets from process.env OR the working-directory .env file, and never | ||
| // install URL servers (they bypass the registry trust gate). All three | ||
| // default to true on the CLI; the MCP (untrusted-caller) surface opts in | ||
| // to the locked-down behavior. | ||
| allowProcessEnv: false, | ||
| allowUrlServers: false, | ||
| allowEnvFile: false, | ||
| // M2: the batch `up` path must honor the same non-overridable trust floor | ||
| // the single-install MCP tool enforces (issue #24), so a low-trust server | ||
| // an agent could not install via mcpm_install can't slip in via mcpm_up. | ||
| minTrustFloor: HARD_TRUST_FLOOR | ||
| }, | ||
| { | ||
| detectClients: deps.detectClients, | ||
| getAdapter: deps.getAdapter, | ||
| getPath: deps.getConfigPath, | ||
| getServer: (name, version) => client.getServer(name, version), | ||
| scanTier1: st1, | ||
| checkScannerAvailable: csa, | ||
| scanTier2: (name) => st2(name), | ||
| computeTrustScore: cts, | ||
| runLock: async (stackFile2) => { | ||
| await handleLock( | ||
| { stackFile: stackFile2 }, | ||
| { | ||
| getServerVersions: (name) => client.getServerVersions(name), | ||
| getServer: (name, v) => client.getServer(name, v), | ||
| scanTier1: st1, | ||
| checkScannerAvailable: csa, | ||
| scanTier2: (name) => st2(name), | ||
| computeTrustScore: cts, | ||
| writeLockFile: (path2, content) => writeFile(path2, content, { encoding: "utf-8", mode: 384 }), | ||
| fetchNpmIntegrity, | ||
| fetchNpmProvenance: (id, ver, sri) => fetchNpmProvenance(id, ver, { integritySri: sri }), | ||
| output: (text) => outputLines.push(text) | ||
| } | ||
| ); | ||
| }, | ||
| // Issue #22: never auto-confirm on the MCP (no-human-in-loop) surface. | ||
| // The previous `async () => true` blanket-approved every confirmation, | ||
| // including strict-mode *removals* of servers not in mcpm.yaml — a | ||
| // prompt-injected agent could silently mutate client configs. Refusing | ||
| // confirmation here means destructive prompts are declined; the trust | ||
| // policy still gates installs via checkTrustPolicy in handleUp. | ||
| confirm: async () => false, | ||
| promptEnvVar: async () => "", | ||
| output: (text) => outputLines.push(text), | ||
| fetchNpmIntegrity, | ||
| // F8/B3: wire the provenance re-check on the MCP surface too, or a | ||
| // policy.frozen: true stack run through mcpm_up would silently skip it. | ||
| fetchNpmProvenance: (id, v, o) => fetchNpmProvenance(id, v, o), | ||
| readPins, | ||
| recordResult: (r) => records.push(r) | ||
| } | ||
| ); | ||
| } catch (err) { | ||
| thrownError = err instanceof Error ? err.message : String(err); | ||
| } | ||
| const installed = []; | ||
| const blocked = []; | ||
| const failed = []; | ||
| const skipped = []; | ||
| if (records.length > 0) { | ||
| for (const r of records) { | ||
| switch (r.status) { | ||
| case "installed": | ||
| installed.push(r.name); | ||
| break; | ||
| case "blocked": | ||
| blocked.push(r.name); | ||
| break; | ||
| case "failed": | ||
| failed.push(r.name); | ||
| break; | ||
| case "skipped": | ||
| case "removed": | ||
| skipped.push(r.name); | ||
| break; | ||
| } | ||
| } | ||
| } else { | ||
| for (const line of outputLines) { | ||
| if (line.includes("\u2713")) installed.push(line.trim()); | ||
| else if (line.includes("\u2717") && line.includes("blocked")) blocked.push(line.trim()); | ||
| else if (line.includes("\u2717")) failed.push(line.trim()); | ||
| else if (line.includes("\u2022")) skipped.push(line.trim()); | ||
| } | ||
| } | ||
| return { | ||
| installed, | ||
| blocked, | ||
| failed, | ||
| skipped, | ||
| ...thrownError !== void 0 ? { error: thrownError } : {}, | ||
| ...installed.length > 0 ? { note: "Restart your AI client to use the newly installed servers." } : {} | ||
| }; | ||
| } | ||
| var STOPWORDS = /\b(i need|set up|access|work with|connect to|a server that|a server for|to|the|a|an|my|for|and|with)\b/gi; | ||
| function extractKeywords(description) { | ||
| const cleaned = description.toLowerCase().replace(STOPWORDS, " ").replace(/[,&]/g, " "); | ||
| const tokens = cleaned.split(/\s+/).map((s) => s.trim()).filter((s) => s.length > 2); | ||
| if (tokens.length > 5) { | ||
| return [cleaned.replace(/\s+/g, " ").trim()]; | ||
| } | ||
| return tokens.length > 0 ? tokens : [description.trim()]; | ||
| } | ||
| // src/server/index.ts | ||
| async function createDeps() { | ||
| const { RegistryClient } = await import("./client-3RPMRFZL.js"); | ||
| const { detectInstalledClients } = await import("./detector-ZI4OWRCJ.js"); | ||
| const { getConfigPath } = await import("./paths-US27HRTP.js"); | ||
| const { getAdapter } = await import("./config-XMU247VO.js"); | ||
| const { scanTier1 } = await import("./tier1-VFXYMODG.js"); | ||
| const { computeTrustScore } = await import("./trust-score-IP4Y5SAY.js"); | ||
| const { addInstalledServer, removeInstalledServer } = await import("./servers-WFV3RC3Z.js"); | ||
| const client = new RegistryClient(); | ||
| return { | ||
| registrySearch: async (query, limit) => { | ||
| const result = await client.searchServers(query, { limit }); | ||
| return result.servers; | ||
| }, | ||
| registryGetServer: (name) => client.getServer(name), | ||
| detectClients: detectInstalledClients, | ||
| getAdapter, | ||
| getConfigPath, | ||
| scanTier1, | ||
| computeTrustScore, | ||
| addToStore: addInstalledServer, | ||
| removeFromStore: removeInstalledServer | ||
| }; | ||
| } | ||
| function registerTools(server, deps) { | ||
| server.registerTool("mcpm_search", { | ||
| description: "Search the MCP registry for servers with trust scores", | ||
| inputSchema: SearchInput, | ||
| annotations: { readOnlyHint: true } | ||
| }, async (args) => { | ||
| const result = await handleSearch(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_install", { | ||
| description: "Install an MCP server with trust assessment", | ||
| inputSchema: InstallInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleInstall(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_info", { | ||
| description: "Show full details and trust score for an MCP server", | ||
| inputSchema: InfoInput, | ||
| annotations: { readOnlyHint: true } | ||
| }, async (args) => { | ||
| const result = await handleInfo(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_list", { | ||
| description: "List installed MCP servers across AI clients", | ||
| inputSchema: ListInput, | ||
| annotations: { readOnlyHint: true } | ||
| }, async (args) => { | ||
| const result = await handleList(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_remove", { | ||
| description: "Remove an MCP server from client configs", | ||
| inputSchema: RemoveInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleRemove(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_audit", { | ||
| inputSchema: NoArgsInput, | ||
| description: "Scan all installed servers and produce trust report", | ||
| annotations: { readOnlyHint: true } | ||
| }, async () => { | ||
| const result = await handleAudit(deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_doctor", { | ||
| inputSchema: NoArgsInput, | ||
| description: "Check MCP setup health", | ||
| annotations: { readOnlyHint: true } | ||
| }, async () => { | ||
| const result = await handleDoctor(deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_setup", { | ||
| description: "Install MCP servers from a natural language description", | ||
| inputSchema: SetupInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleSetup(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| server.registerTool("mcpm_up", { | ||
| description: "Install all servers from an mcpm.yaml stack file with trust verification. Equivalent to docker-compose up for MCP servers. Runs trust re-assessment and blocks servers that violate the trust policy. Pass profile to install only servers matching that profile, or dryRun to preview what would be installed without making changes.", | ||
| inputSchema: UpInput, | ||
| annotations: { destructiveHint: true } | ||
| }, async (args) => { | ||
| const result = await handleMcpUp(args, deps); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| }); | ||
| } | ||
| async function startServer() { | ||
| const deps = await createDeps(); | ||
| const server = new McpServer({ | ||
| name: "mcpm", | ||
| // Issue #22: advertise the real package version (injected by tsup at build), | ||
| // not a hardcoded stale "0.1.0". | ||
| version: "0.26.3" | ||
| }); | ||
| registerTools(server, deps); | ||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); | ||
| } | ||
| export { | ||
| registerTools, | ||
| startServer | ||
| }; | ||
| //# sourceMappingURL=server-UCXWUSQF.js.map |
| {"version":3,"sources":["../src/server/index.ts","../src/server/tools.ts","../src/server/handlers.ts"],"sourcesContent":["/**\n * MCP server for mcpm — exposes search, install, audit, and setup as tools.\n *\n * Uses @modelcontextprotocol/sdk with stdio transport.\n * All logic delegates to handlers.ts which wraps existing mcpm functions.\n */\n\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport {\n NoArgsInput,\n SearchInput,\n InstallInput,\n InfoInput,\n ListInput,\n RemoveInput,\n SetupInput,\n UpInput,\n} from \"./tools.js\";\nimport {\n handleSearch,\n handleInstall,\n handleInfo,\n handleList,\n handleRemove,\n handleAudit,\n handleDoctor,\n handleSetup,\n handleMcpUp,\n} from \"./handlers.js\";\nimport type { ServerDeps } from \"./handlers.js\";\n\n// ---------------------------------------------------------------------------\n// Wire up real dependencies\n// ---------------------------------------------------------------------------\n\nasync function createDeps(): Promise<ServerDeps> {\n const { RegistryClient } = await import(\"../registry/client.js\");\n const { detectInstalledClients } = await import(\"../config/detector.js\");\n const { getConfigPath } = await import(\"../config/paths.js\");\n const { getAdapter } = await import(\"../config/index.js\");\n const { scanTier1 } = await import(\"../scanner/tier1.js\");\n const { computeTrustScore } = await import(\"../scanner/trust-score.js\");\n const { addInstalledServer, removeInstalledServer } = await import(\"../store/servers.js\");\n\n const client = new RegistryClient();\n\n return {\n registrySearch: async (query, limit) => {\n const result = await client.searchServers(query, { limit });\n return result.servers;\n },\n registryGetServer: (name) => client.getServer(name),\n detectClients: detectInstalledClients,\n getAdapter,\n getConfigPath,\n scanTier1,\n computeTrustScore,\n addToStore: addInstalledServer,\n removeFromStore: removeInstalledServer,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Server setup\n// ---------------------------------------------------------------------------\n\n/**\n * Register every mcpm tool on the server. Extracted from startServer so the\n * registration can be unit-tested (fix F.1): a test spies registerTool and\n * asserts every TOOL_DEFINITIONS name is registered exactly once, guarding\n * against future tool/registration divergence.\n *\n * `server` is typed loosely as `Pick<McpServer, \"registerTool\">` so tests can\n * pass a lightweight spy without constructing a full McpServer.\n */\nexport function registerTools(\n server: Pick<McpServer, \"registerTool\">,\n deps: ServerDeps\n): void {\n // Register tools using registerTool API\n server.registerTool(\"mcpm_search\", {\n description: \"Search the MCP registry for servers with trust scores\",\n inputSchema: SearchInput,\n annotations: { readOnlyHint: true },\n }, async (args) => {\n const result = await handleSearch(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_install\", {\n description: \"Install an MCP server with trust assessment\",\n inputSchema: InstallInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleInstall(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_info\", {\n description: \"Show full details and trust score for an MCP server\",\n inputSchema: InfoInput,\n annotations: { readOnlyHint: true },\n }, async (args) => {\n const result = await handleInfo(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_list\", {\n description: \"List installed MCP servers across AI clients\",\n inputSchema: ListInput,\n annotations: { readOnlyHint: true },\n }, async (args) => {\n const result = await handleList(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_remove\", {\n description: \"Remove an MCP server from client configs\",\n inputSchema: RemoveInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleRemove(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_audit\", {\n inputSchema: NoArgsInput,\n description: \"Scan all installed servers and produce trust report\",\n annotations: { readOnlyHint: true },\n }, async () => {\n const result = await handleAudit(deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_doctor\", {\n inputSchema: NoArgsInput,\n description: \"Check MCP setup health\",\n annotations: { readOnlyHint: true },\n }, async () => {\n const result = await handleDoctor(deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_setup\", {\n description: \"Install MCP servers from a natural language description\",\n inputSchema: SetupInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleSetup(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n\n server.registerTool(\"mcpm_up\", {\n description: \"Install all servers from an mcpm.yaml stack file with trust verification. Equivalent to docker-compose up for MCP servers. Runs trust re-assessment and blocks servers that violate the trust policy. Pass profile to install only servers matching that profile, or dryRun to preview what would be installed without making changes.\",\n inputSchema: UpInput,\n annotations: { destructiveHint: true },\n }, async (args) => {\n const result = await handleMcpUp(args, deps);\n return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n });\n}\n\nexport async function startServer(): Promise<void> {\n const deps = await createDeps();\n\n const server = new McpServer({\n name: \"mcpm\",\n // Issue #22: advertise the real package version (injected by tsup at build),\n // not a hardcoded stale \"0.1.0\".\n version: __PKG_VERSION__,\n });\n\n registerTools(server, deps);\n\n // Start stdio transport\n const transport = new StdioServerTransport();\n await server.connect(transport);\n}\n","/**\n * MCP tool definitions for mcpm serve.\n *\n * Each tool has a name, description, and Zod input schema.\n * Handlers are in handlers.ts.\n */\n\nimport { z } from \"zod\";\nimport { CLIENT_IDS } from \"../config/paths.js\";\n\nexport const TOOL_DEFINITIONS = [\n {\n name: \"mcpm_search\",\n description: \"Search the MCP registry for servers. Returns results with trust scores.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n query: { type: \"string\", description: \"Search query (substring match on server name)\" },\n limit: { type: \"number\", description: \"Max results to return (default 20)\" },\n },\n required: [\"query\"],\n },\n },\n {\n name: \"mcpm_install\",\n description: \"Install an MCP server from the registry into detected AI client configs. Runs trust assessment automatically. Rejects servers below the minimum trust score (default 50).\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n name: { type: \"string\", description: \"Server name (e.g. io.github.domdomegg/filesystem-mcp)\" },\n client: { type: \"string\", description: \"Install to specific client only (claude-desktop, cursor, vscode, windsurf)\" },\n minTrustScore: { type: \"number\", description: \"Minimum trust score to allow install (default 50, range 0-100)\" },\n },\n required: [\"name\"],\n },\n },\n {\n name: \"mcpm_info\",\n description: \"Show full details for an MCP server including trust score breakdown.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n name: { type: \"string\", description: \"Server name\" },\n },\n required: [\"name\"],\n },\n },\n {\n name: \"mcpm_list\",\n description: \"List all installed MCP servers across detected AI clients.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n client: { type: \"string\", description: \"Filter to specific client\" },\n },\n required: [],\n },\n },\n {\n name: \"mcpm_remove\",\n description: \"Remove an MCP server from AI client configs.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n name: { type: \"string\", description: \"Server name to remove\" },\n client: { type: \"string\", description: \"Remove from specific client only\" },\n },\n required: [\"name\"],\n },\n },\n {\n name: \"mcpm_audit\",\n description: \"Scan all installed MCP servers and produce a trust report with scores.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {},\n required: [],\n },\n },\n {\n name: \"mcpm_doctor\",\n description: \"Check MCP setup health: detected clients, available runtimes, configuration issues.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {},\n required: [],\n },\n },\n {\n name: \"mcpm_setup\",\n description: \"Install MCP servers from a natural language description. Searches, evaluates trust, installs the best match for each keyword. Example: 'filesystem and GitHub' installs filesystem + GitHub servers.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n description: { type: \"string\", description: \"What you need (e.g. 'filesystem access and GitHub integration')\" },\n client: { type: \"string\", description: \"Install to specific client only\" },\n minTrustScore: { type: \"number\", description: \"Minimum trust score to auto-install (default 50, range 0-100)\" },\n },\n required: [\"description\"],\n },\n },\n {\n name: \"mcpm_up\",\n description: \"Install all servers from an mcpm.yaml stack file with trust verification. Equivalent to docker-compose up for MCP servers. Runs trust re-assessment and blocks servers that violate the trust policy. Pass profile to install only servers matching that profile, or dryRun to preview what would be installed without making changes.\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n stackFile: { type: \"string\", description: \"Path to mcpm.yaml (default: mcpm.yaml in CWD)\" },\n profile: { type: \"string\", description: \"Install only servers matching this profile\" },\n dryRun: { type: \"boolean\", description: \"Show what would be installed without making changes\" },\n },\n required: [],\n },\n },\n] as const;\n\n// Shared field schemas (security #31): a bounded server-name string and a closed\n// client enum, so the Zod layer — not just the runtime `validateMcpServerName` /\n// `CLIENT_IDS.includes` checks in handlers.ts — is the declarative enforcement\n// point. The objects below are `strictObject` so unknown keys are rejected\n// instead of silently dropped.\n//\n// These are passed to `registerTool` WHOLE (not via `.shape`) — see\n// server/index.ts. That distinction is load-bearing: the SDK accepts either a\n// raw shape or a full schema, but a raw shape is rebuilt as a plain\n// `z.object(shape)`, which silently DROPS the object-level `strict` setting.\n// Per-field constraints (the length bound, the client enum) survive either way;\n// strictness does not.\n//\n// Passing the whole schema means the SDK rejects unknown keys with a JSON-RPC\n// -32602 `unrecognized_keys` error, AND advertises `additionalProperties: false`\n// in `tools/list` so a caller can see the contract before calling. Verified over\n// a real in-memory MCP transport in server-strict-schema.test.ts.\n//\n// The runtime guards in handlers.ts (`validateMcpServerName`, `CLIENT_IDS`)\n// remain as defence in depth.\nconst serverName = z.string().min(1).max(256);\nconst clientId = z.enum(CLIENT_IDS);\n\n/**\n * Zero-argument tools (`mcpm_audit`, `mcpm_doctor`) still declare a CLOSED\n * schema rather than omitting `inputSchema` entirely. Omitting it advertises no\n * `additionalProperties: false`, so any argument a caller passes is silently\n * ignored — for a tool that takes nothing, that means EVERY argument is\n * silently ignored. An empty strict object makes the contract explicit and\n * turns a mistaken call into a clear error.\n */\nexport const NoArgsInput = z.strictObject({});\n\nexport const SearchInput = z.strictObject({\n query: z.string().min(1).max(200),\n limit: z.number().int().min(1).max(100).optional().default(20),\n});\n\nexport const InstallInput = z.strictObject({\n name: serverName,\n client: clientId.optional(),\n minTrustScore: z.number().min(0).max(100).optional().default(50),\n});\n\nexport const InfoInput = z.strictObject({\n name: serverName,\n});\n\nexport const ListInput = z.strictObject({\n client: clientId.optional(),\n});\n\nexport const RemoveInput = z.strictObject({\n name: serverName,\n client: clientId.optional(),\n});\n\nexport const SetupInput = z.strictObject({\n description: z.string().min(1).max(1000),\n client: clientId.optional(),\n minTrustScore: z.number().min(0).max(100).optional().default(50),\n});\n\nexport const UpInput = z.strictObject({\n stackFile: z.string().optional().default(\"mcpm.yaml\"),\n profile: z.string().optional(),\n dryRun: z.boolean().optional().default(false),\n});\n","/**\n * MCP tool handlers for mcpm serve.\n *\n * Each handler wraps existing mcpm logic and returns structured JSON.\n * All dependencies are injectable for testability.\n */\n\nimport path from \"node:path\";\nimport type { ClientId } from \"../config/paths.js\";\nimport { CLIENT_IDS } from \"../config/paths.js\";\nimport type { ConfigAdapter } from \"../config/adapters/index.js\";\nimport type { ServerEntry } from \"../registry/types.js\";\nimport type { Finding } from \"../scanner/tier1.js\";\nimport type { TrustScore, TrustScoreInput } from \"../scanner/trust-score.js\";\nimport { extractRegistryMeta } from \"../utils/format-trust.js\";\nimport { formatMcpEntryCommand } from \"../utils/format-entry.js\";\nimport { resolveInstallEntry } from \"../commands/install.js\";\nimport { buildDoctorModel, makeCheckConfigExists, execCheckDefault } from \"../commands/doctor.js\";\nimport { fetchNpmIntegrity as _fetchNpmIntegrity } from \"../registry/npm-integrity.js\";\nimport { fetchNpmProvenance as _fetchNpmProvenance } from \"../registry/npm-provenance.js\";\nimport { readPins as _readPins } from \"../guard/pins.js\";\n\n// ---------------------------------------------------------------------------\n// Input validation for MCP server tool arguments\n// ---------------------------------------------------------------------------\n\n/**\n * Server name pattern for MCP registry names.\n * Format: \"namespace/server-name\" — alphanumeric with dots, hyphens, underscores.\n * Max length 256 to prevent abuse. Must not contain shell metacharacters,\n * path traversal sequences, or control characters.\n */\nconst SERVER_NAME_RE =\n /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}\\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,126}$/;\n\n/**\n * Validate a server name received from an MCP tool call.\n * This is the trust boundary — AI agents provide these strings, and they\n * could be influenced by prompt injection or adversarial inputs.\n */\nfunction validateMcpServerName(name: string): void {\n if (typeof name !== \"string\" || name.length === 0 || name.length > 256) {\n throw new Error(`Invalid server name: must be a non-empty string under 256 characters.`);\n }\n if (!SERVER_NAME_RE.test(name)) {\n throw new Error(\n `Invalid server name format: \"${name}\". Expected format: \"namespace/server-name\" ` +\n `(alphanumeric, dots, hyphens, underscores only).`\n );\n }\n}\n\n// ---------------------------------------------------------------------------\n// Dependency injection types\n// ---------------------------------------------------------------------------\n\nexport interface ServerDeps {\n registrySearch: (query: string, limit: number) => Promise<ServerEntry[]>;\n registryGetServer: (name: string) => Promise<ServerEntry>;\n detectClients: () => Promise<ClientId[]>;\n getAdapter: (clientId: ClientId) => ConfigAdapter;\n getConfigPath: (clientId: ClientId) => string;\n scanTier1: (server: ServerEntry) => Finding[];\n computeTrustScore: (input: TrustScoreInput) => TrustScore;\n addToStore: (server: { name: string; version: string; clients: ClientId[]; installedAt: string }) => Promise<void>;\n removeFromStore: (name: string) => Promise<void>;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * F4 scope note: this helper deliberately does NOT include the\n * release-cooldown finding (ServerDeps has no injectable clock; the F4 spec\n * file list excludes server/). Consequence: mcpm_install / mcpm_search score\n * a fresh (<24h) package up to 5 points higher than CLI install/why AND than\n * the sibling mcpm_up tool (which inherits the finding via up.ts\n * processServer), and HARD_TRUST_FLOOR evaluates that inflated score — do NOT\n * compensate by raising the floor. Fast-follow is mechanical:\n * ServerDeps += now?: () => number, then append\n * assessReleaseAge({...}).finding here; no schema changes.\n */\nfunction computeTrust(entry: ServerEntry, deps: ServerDeps): TrustScore {\n const findings = deps.scanTier1(entry);\n return deps.computeTrustScore({\n findings,\n healthCheckPassed: null,\n hasExternalScanner: false,\n registryMeta: extractRegistryMeta(entry),\n });\n}\n\nasync function resolveClients(\n requestedClient: string | undefined,\n deps: ServerDeps\n): Promise<ClientId[]> {\n const detected = await deps.detectClients();\n if (detected.length === 0) {\n throw new Error(\"No supported AI clients found.\");\n }\n if (requestedClient !== undefined) {\n if (!CLIENT_IDS.includes(requestedClient as ClientId)) {\n throw new Error(\n `Unknown client \"${requestedClient}\". Valid values: ${CLIENT_IDS.join(\", \")}.`\n );\n }\n const id = requestedClient as ClientId;\n if (!detected.includes(id)) {\n throw new Error(`Client \"${requestedClient}\" is not installed.`);\n }\n return [id];\n }\n return detected;\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nexport async function handleSearch(\n args: { query: string; limit: number },\n deps: ServerDeps\n): Promise<object> {\n const entries = await deps.registrySearch(args.query, args.limit);\n const servers = entries.map((entry) => {\n const trust = computeTrust(entry, deps);\n return {\n name: entry.server.name,\n description: entry.server.description ?? \"\",\n version: entry.server.version,\n trustScore: trust.score,\n };\n });\n return { servers };\n}\n\n/** Default minimum trust score for MCP server tool installs (no human in the loop). */\nconst DEFAULT_MIN_TRUST_SCORE = 50;\n\n/**\n * Hard, non-overridable trust floor for the MCP server surface (issue #24).\n *\n * The MCP `minTrustScore` input accepts `0`, which a prompt-injected agent could\n * pass to disable the install gate entirely. We clamp the effective threshold to\n * `Math.max(userValue, HARD_TRUST_FLOOR)` so no caller-supplied value can lower\n * the gate below this floor. This protects the no-human-in-loop path; the CLI\n * (with a human confirmation prompt) is the only place to install below it.\n */\nconst HARD_TRUST_FLOOR = 25;\n\n/** Clamp a requested minimum trust score so it can never sink below the floor. */\nfunction effectiveMinTrustScore(requested: number | undefined): number {\n return Math.max(requested ?? DEFAULT_MIN_TRUST_SCORE, HARD_TRUST_FLOOR);\n}\n\nexport async function handleInstall(\n args: { name: string; client?: string; minTrustScore?: number },\n deps: ServerDeps,\n preResolved?: { entry: ServerEntry; trust: TrustScore }\n): Promise<object> {\n validateMcpServerName(args.name);\n const entry = preResolved?.entry ?? await deps.registryGetServer(args.name);\n const trust = preResolved?.trust ?? computeTrust(entry, deps);\n\n // Security gate: reject servers below the minimum trust score.\n // Unlike the CLI path which has a human confirmation prompt, the MCP server\n // path is driven by AI agents with no human in the loop. A malicious prompt\n // could trick an agent into installing a dangerous server, so we enforce a\n // hard trust floor here. Issue #24: minTrustScore:0 must NOT disable the gate —\n // the effective threshold is clamped to HARD_TRUST_FLOOR.\n const minScore = effectiveMinTrustScore(args.minTrustScore);\n if (trust.score < minScore) {\n throw new Error(\n `Server \"${args.name}\" has trust score ${trust.score}/${trust.maxPossible} ` +\n `(level: ${trust.level}), which is below the minimum threshold of ${minScore}. ` +\n `Install rejected for safety. Use mcpm CLI with --yes to override after manual review.`\n );\n }\n\n const clients = await resolveClients(args.client, deps);\n\n const installedClients: ClientId[] = [];\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n const mcpEntry = resolveInstallEntry(entry, clientId);\n // H9 (fail-closed): a URL/HTTP-transport entry (url, no command) runs\n // UNGUARDED — the guard relay only wraps a stdio process. The MCP surface is\n // driven by an untrusted agent with no human in the loop and no\n // `--allow-unguarded` opt-in, so url-transport installs are HARD-DENIED here\n // (mirrors the batch `up` MCP wiring's allowUrlServers:false kill-switch).\n if (mcpEntry.url !== undefined && mcpEntry.command === undefined) {\n throw new Error(\n `Server \"${args.name}\" uses a URL/HTTP transport and runs UNGUARDED ` +\n `(the guard relay only wraps stdio servers). Installing it is not permitted ` +\n `via the MCP surface. Use the mcpm CLI with --allow-unguarded after manual review.`\n );\n }\n await adapter.addServer(configPath, args.name, mcpEntry);\n installedClients.push(clientId);\n }\n\n await deps.addToStore({\n name: args.name,\n version: entry.server.version,\n clients: [...installedClients],\n installedAt: new Date().toISOString(),\n });\n\n return {\n installed: true,\n name: args.name,\n version: entry.server.version,\n clients: installedClients,\n trustScore: trust,\n };\n}\n\nexport async function handleInfo(\n args: { name: string },\n deps: ServerDeps\n): Promise<object> {\n validateMcpServerName(args.name);\n const entry = await deps.registryGetServer(args.name);\n const trust = computeTrust(entry, deps);\n return {\n name: entry.server.name,\n description: entry.server.description ?? \"\",\n version: entry.server.version,\n packages: entry.server.packages.map((p) => ({\n registryType: p.registryType,\n identifier: p.identifier,\n })),\n trustScore: trust,\n };\n}\n\nexport async function handleList(\n args: { client?: string },\n deps: ServerDeps\n): Promise<object> {\n const clients = await resolveClients(args.client, deps);\n const servers: Array<{ name: string; client: string; command: string }> = [];\n\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n const installed = await adapter.read(configPath);\n\n for (const [name, entry] of Object.entries(installed)) {\n const command = formatMcpEntryCommand(entry, \"unknown\");\n servers.push({ name, client: clientId, command });\n }\n }\n\n return { servers };\n}\n\nexport async function handleRemove(\n args: { name: string; client?: string },\n deps: ServerDeps\n): Promise<object> {\n validateMcpServerName(args.name);\n const clients = await resolveClients(args.client, deps);\n const removedClients: ClientId[] = [];\n\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n try {\n await adapter.removeServer(configPath, args.name);\n removedClients.push(clientId);\n } catch {\n // Server not in this client, skip\n }\n }\n\n if (removedClients.length === 0) {\n throw new Error(`Server \"${args.name}\" not found in any client config.`);\n }\n\n try {\n await deps.removeFromStore(args.name);\n } catch {\n // Not in store, fine\n }\n\n return { removed: true, name: args.name, clients: removedClients };\n}\n\nexport async function handleAudit(deps: ServerDeps): Promise<object> {\n const clients = await deps.detectClients();\n const results: Array<{ name: string; client: string; trustScore: TrustScore }> = [];\n\n for (const clientId of clients) {\n const adapter = deps.getAdapter(clientId);\n const configPath = deps.getConfigPath(clientId);\n const installed = await adapter.read(configPath);\n\n for (const name of Object.keys(installed)) {\n try {\n const entry = await deps.registryGetServer(name);\n const trust = computeTrust(entry, deps);\n results.push({ name, client: clientId, trustScore: trust });\n } catch {\n results.push({\n name,\n client: clientId,\n trustScore: { score: 0, maxPossible: 80, level: \"risky\", breakdown: { healthCheck: 0, staticScan: 0, externalScan: 0, registryMeta: 0 } },\n });\n }\n }\n }\n\n return { results };\n}\n\nexport async function handleDoctor(deps: ServerDeps): Promise<object> {\n // Reuse the CLI's structured model so this tool reports real issues instead of\n // the formerly-hardcoded `issues: []` (D7). Honors the injected getConfigPath.\n return buildDoctorModel({\n getAdapter: deps.getAdapter,\n getConfigPath: deps.getConfigPath,\n checkConfigExists: makeCheckConfigExists(deps.getConfigPath),\n execCheck: execCheckDefault,\n });\n}\n\nexport async function handleSetup(\n args: { description: string; client?: string; minTrustScore: number },\n deps: ServerDeps\n): Promise<object> {\n if (!args.description.trim()) {\n throw new Error(\"Could not extract any keywords from empty description.\");\n }\n const keywords = extractKeywords(args.description);\n\n // Issue #24: clamp to the hard floor so minTrustScore:0 can't disable the gate\n // on the no-human-in-loop setup path either.\n const minScore = effectiveMinTrustScore(args.minTrustScore);\n\n const installed: Array<{ name: string; trustScore: TrustScore }> = [];\n const skipped: Array<{ name: string; reason: string }> = [];\n\n // Parallel search pass — all keywords searched concurrently. Capture the\n // thrown error per keyword so a registry outage is distinguishable from a\n // genuine empty result (both otherwise look like \"no servers\").\n type SearchOutcome =\n | { ok: true; entries: ServerEntry[] }\n | { ok: false; error: string };\n const searchResults: SearchOutcome[] = await Promise.all(\n keywords.map((kw) =>\n deps\n .registrySearch(kw, 5)\n .then((entries): SearchOutcome => ({ ok: true, entries }))\n .catch((err): SearchOutcome => ({\n ok: false,\n error: err instanceof Error ? err.message : String(err),\n }))\n )\n );\n\n const seenNames = new Set<string>();\n\n // Sequential evaluate/install pass (installs depend on previous state)\n for (let i = 0; i < keywords.length; i++) {\n const keyword = keywords[i];\n const outcome = searchResults[i];\n\n if (!outcome.ok) {\n skipped.push({ name: keyword, reason: `Registry search failed: ${outcome.error}` });\n continue;\n }\n\n const entries = outcome.entries;\n\n if (entries.length === 0) {\n skipped.push({ name: keyword, reason: `No servers found for \"${keyword}\"` });\n continue;\n }\n\n let bestEntry: ServerEntry | null = null;\n let bestTrust: TrustScore | null = null;\n\n for (const entry of entries) {\n if (seenNames.has(entry.server.name)) continue;\n const trust = computeTrust(entry, deps);\n if (bestTrust === null || trust.score > bestTrust.score) {\n bestEntry = entry;\n bestTrust = trust;\n }\n }\n\n if (bestEntry === null || bestTrust === null) {\n skipped.push({ name: keyword, reason: \"All results already installed or duplicated\" });\n continue;\n }\n\n if (bestTrust.score < minScore) {\n skipped.push({\n name: bestEntry.server.name,\n reason: `Trust score ${bestTrust.score}/${bestTrust.maxPossible} is below minimum ${minScore}`,\n });\n continue;\n }\n\n try {\n await handleInstall(\n { name: bestEntry.server.name, client: args.client },\n deps,\n { entry: bestEntry, trust: bestTrust }\n );\n seenNames.add(bestEntry.server.name);\n installed.push({ name: bestEntry.server.name, trustScore: bestTrust });\n } catch (err) {\n skipped.push({\n name: bestEntry.server.name,\n reason: `Install failed: ${(err as Error).message}`,\n });\n }\n }\n\n const note = installed.length > 0\n ? \"Restart your AI client to use the newly installed servers.\"\n : undefined;\n\n return { installed, skipped, ...(note ? { note } : {}) };\n}\n\n// ---------------------------------------------------------------------------\n// mcpm_up — batch install from stack file\n// ---------------------------------------------------------------------------\n\nexport async function handleMcpUp(\n args: { stackFile?: string; profile?: string; dryRun?: boolean },\n deps: ServerDeps\n): Promise<{\n installed: string[];\n blocked: string[];\n failed: string[];\n skipped: string[];\n error?: string;\n note?: string;\n}> {\n // Validate stackFile path (AI agent trust boundary). Zod defaults stackFile to\n // \"mcpm.yaml\", so the old `if (args.stackFile !== undefined)` guard was dead.\n // Enforce real containment unconditionally via resolved paths: path.resolve\n // normalizes Windows backslashes and \"..\", so this catches traversal and\n // absolute escapes that string-only checks miss.\n const stackFile = args.stackFile ?? \"mcpm.yaml\";\n const resolved = path.resolve(process.cwd(), stackFile);\n if (\n resolved !== process.cwd() &&\n !resolved.startsWith(process.cwd() + path.sep)\n ) {\n throw new Error(\"stackFile must be within the working directory\");\n }\n // M3: the lexical check above catches \"../\" and absolute escapes, but NOT a\n // symlink that lives inside cwd yet points outside it — the file reader would\n // follow it (arbitrary out-of-tree read). Resolve the REAL path and re-check.\n // realpath throws ENOENT when the file does not exist yet; that's fine — handleUp\n // reports the missing file. A containment failure thrown inside the try is not\n // an ErrnoException, so the catch re-throws it.\n {\n const { realpath } = await import(\"node:fs/promises\");\n try {\n const [realStack, realCwd] = await Promise.all([\n realpath(resolved),\n realpath(process.cwd()),\n ]);\n if (realStack !== realCwd && !realStack.startsWith(realCwd + path.sep)) {\n throw new Error(\"stackFile must be within the working directory\");\n }\n } catch (err) {\n // ENOENT (no such file), ELOOP (circular symlink), and ENOTDIR (a path\n // component is a file) all mean \"no real path to contain\" — fall through and\n // let handleUp report the missing/invalid file. Re-throwing them would leak a\n // raw internal ErrnoException (with stack) to the untrusted caller. The\n // containment Error thrown just above has no `.code`, so it still propagates.\n const code = (err as NodeJS.ErrnoException).code ?? \"\";\n if (![\"ENOENT\", \"ELOOP\", \"ENOTDIR\"].includes(code)) throw err;\n }\n }\n\n const { handleUp } = await import(\"../commands/up.js\");\n const { writeFile } = await import(\"fs/promises\");\n const { handleLock } = await import(\"../commands/lock.js\");\n const { RegistryClient } = await import(\"../registry/client.js\");\n const { scanTier1: st1 } = await import(\"../scanner/tier1.js\");\n const { checkScannerAvailable: csa, scanTier2: st2 } = await import(\"../scanner/tier2.js\");\n const { computeTrustScore: cts } = await import(\"../scanner/trust-score.js\");\n\n const client = new RegistryClient();\n const outputLines: string[] = [];\n // Fix A/D: structured per-server results from handleUp. Authoritative source\n // for categorization — emoji-scraping cannot distinguish blocked from failed.\n const records: Array<{ name: string; status: string }> = [];\n let thrownError: string | undefined;\n\n try {\n await handleUp(\n {\n stackFile,\n profile: args.profile,\n dryRun: args.dryRun,\n ci: true,\n yes: false,\n // MCP surface lockdown (fixes C, D & H1): never auto-read ambient\n // secrets from process.env OR the working-directory .env file, and never\n // install URL servers (they bypass the registry trust gate). All three\n // default to true on the CLI; the MCP (untrusted-caller) surface opts in\n // to the locked-down behavior.\n allowProcessEnv: false,\n allowUrlServers: false,\n allowEnvFile: false,\n // M2: the batch `up` path must honor the same non-overridable trust floor\n // the single-install MCP tool enforces (issue #24), so a low-trust server\n // an agent could not install via mcpm_install can't slip in via mcpm_up.\n minTrustFloor: HARD_TRUST_FLOOR,\n },\n {\n detectClients: deps.detectClients,\n getAdapter: deps.getAdapter,\n getPath: deps.getConfigPath,\n getServer: (name, version?) => client.getServer(name, version),\n scanTier1: st1,\n checkScannerAvailable: csa,\n scanTier2: (name) => st2(name),\n computeTrustScore: cts,\n runLock: async (stackFile) => {\n await handleLock(\n { stackFile },\n {\n getServerVersions: (name) => client.getServerVersions(name),\n getServer: (name, v?) => client.getServer(name, v),\n scanTier1: st1,\n checkScannerAvailable: csa,\n scanTier2: (name) => st2(name),\n computeTrustScore: cts,\n writeLockFile: (path, content) =>\n writeFile(path, content, { encoding: \"utf-8\", mode: 0o600 }),\n fetchNpmIntegrity: _fetchNpmIntegrity,\n fetchNpmProvenance: (id, ver, sri) => _fetchNpmProvenance(id, ver, { integritySri: sri }),\n output: (text) => outputLines.push(text),\n }\n );\n },\n // Issue #22: never auto-confirm on the MCP (no-human-in-loop) surface.\n // The previous `async () => true` blanket-approved every confirmation,\n // including strict-mode *removals* of servers not in mcpm.yaml — a\n // prompt-injected agent could silently mutate client configs. Refusing\n // confirmation here means destructive prompts are declined; the trust\n // policy still gates installs via checkTrustPolicy in handleUp.\n confirm: async () => false,\n promptEnvVar: async () => \"\",\n output: (text) => outputLines.push(text),\n fetchNpmIntegrity: _fetchNpmIntegrity,\n // F8/B3: wire the provenance re-check on the MCP surface too, or a\n // policy.frozen: true stack run through mcpm_up would silently skip it.\n fetchNpmProvenance: (id, v, o) => _fetchNpmProvenance(id, v, o),\n readPins: _readPins,\n recordResult: (r) => records.push(r),\n }\n );\n } catch (err) {\n // Fix A: handleUp throws on early/whole-batch failures (no clients, lock-file\n // creation failure, missing required env in CI, the summary \"N could not be\n // installed\" throw, etc.). The previous bare catch swallowed these into a\n // clean-looking empty result. Capture the message so the caller can never\n // mistake a thrown failure for success.\n thrownError = err instanceof Error ? err.message : String(err);\n }\n\n const installed: string[] = [];\n const blocked: string[] = [];\n const failed: string[] = [];\n const skipped: string[] = [];\n\n if (records.length > 0) {\n // Authoritative path (fix D, F.3/F.5): categorize from handleUp's typed\n // per-server statuses. Unlike emoji-scraping, this reliably separates\n // \"blocked\" (policy/URL-lockdown) from \"failed\".\n for (const r of records) {\n switch (r.status) {\n case \"installed\": installed.push(r.name); break;\n case \"blocked\": blocked.push(r.name); break;\n case \"failed\": failed.push(r.name); break;\n case \"skipped\":\n case \"removed\": skipped.push(r.name); break;\n }\n }\n } else {\n // Fallback for the no-record path (e.g. a throw before any server is\n // processed): preserve the original output-line parsing.\n for (const line of outputLines) {\n if (line.includes(\"\\u2713\")) installed.push(line.trim());\n else if (line.includes(\"\\u2717\") && line.includes(\"blocked\")) blocked.push(line.trim());\n else if (line.includes(\"\\u2717\")) failed.push(line.trim());\n else if (line.includes(\"\\u2022\")) skipped.push(line.trim());\n }\n }\n\n // Fix A, refined for M1: a thrown handleUp failure MUST be signaled \\u2014 but only\n // via the top-level `error` field (set in the return below). The previous\n // version pushed the error *message* into `failed`, which is contracted to hold\n // server NAMES; a consumer iterating it as names got a stray sentence. `error`\n // is the authoritative batch-failure signal; `failed` stays names-only (genuine\n // per-server failures are already recorded into it above via `records`).\n\n return {\n installed,\n blocked,\n failed,\n skipped,\n ...(thrownError !== undefined ? { error: thrownError } : {}),\n ...(installed.length > 0\n ? { note: \"Restart your AI client to use the newly installed servers.\" }\n : {}),\n };\n}\n\n// ---------------------------------------------------------------------------\n// Keyword extraction\n// ---------------------------------------------------------------------------\n\nconst STOPWORDS = /\\b(i need|set up|access|work with|connect to|a server that|a server for|to|the|a|an|my|for|and|with)\\b/gi;\n\nexport function extractKeywords(description: string): string[] {\n const cleaned = description\n .toLowerCase()\n .replace(STOPWORDS, \" \")\n .replace(/[,&]/g, \" \");\n\n const tokens = cleaned\n .split(/\\s+/)\n .map((s) => s.trim())\n .filter((s) => s.length > 2);\n\n // If splitting produced too many tokens, use the full cleaned string\n if (tokens.length > 5) {\n return [cleaned.replace(/\\s+/g, \" \").trim()];\n }\n\n return tokens.length > 0 ? tokens : [description.trim()];\n}\n\n"],"mappings":"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","names":["clientId","stackFile","path"]} |
Sorry, the diff of this file is too big to display
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1722606
0.16%126
1.61%13062
0.15%