Sign In

@getmcpm/cli

Package Overview
Dependencies
Maintainers
1
Versions
47
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@getmcpm/cli - npm Package Compare versions

Comparing version
0.29.1
to
0.30.0
+326
dist/chunk-APDTSW26.js
#!/usr/bin/env node
import {
DEFAULT_MIN_RELEASE_AGE_HOURS,
assessReleaseAge,
stdoutOutput
} from "./chunk-E3T224S3.js";
import {
compareProvenance,
fetchNpmProvenance,
isLockedRegistryServer,
isRegistryServer,
isUrlServer,
parseLockFile,
parseStackFile,
serializeYaml
} from "./chunk-RTI2GLYX.js";
import {
sanitizeForTerminal
} from "./chunk-FEXJHHDM.js";
import {
extractRegistryMeta,
scanTier1
} from "./chunk-U7N6FRYF.js";
import {
checkScannerAvailable,
scanTier2
} from "./chunk-F6CHEUGO.js";
import {
fetchNpmIntegrity
} from "./chunk-7RJXJERN.js";
import {
RegistryClient
} from "./chunk-V4AA4ZL5.js";
import {
computeTrustScore
} from "./chunk-Y36MYEDT.js";
// src/stack/resolve.ts
import semver from "semver";
function resolveVersion(serverName, range, available) {
const validVersions = available.filter((v) => semver.valid(v) !== null);
if (range === "latest") {
if (validVersions.length === 0) {
throw new Error(`No versions available for "${serverName}".`);
}
const sorted = [...validVersions].sort(semver.rcompare);
return { resolved: sorted[0], range, available: validVersions };
}
if (semver.valid(range) !== null) {
const exact = validVersions.find((v) => semver.eq(v, range));
if (exact) {
return { resolved: exact, range, available: validVersions };
}
throw new Error(
`Version "${range}" not found for "${serverName}". Available: ${formatVersionList(validVersions)}`
);
}
const match = semver.maxSatisfying(validVersions, range);
if (match !== null) {
return { resolved: match, range, available: validVersions };
}
throw new Error(
`No version satisfies "${range}" for "${serverName}". Available: ${formatVersionList(validVersions)}`
);
}
function resolveWithSingleVersion(serverName, range, singleVersion) {
if (range === "latest") {
return { resolved: singleVersion, range, available: [singleVersion] };
}
if (semver.valid(range) !== null) {
if (semver.eq(singleVersion, range)) {
return { resolved: singleVersion, range, available: [singleVersion] };
}
throw new Error(
`Version "${range}" not found for "${serverName}". Only version available: ${singleVersion}`
);
}
if (semver.satisfies(singleVersion, range)) {
return { resolved: singleVersion, range, available: [singleVersion] };
}
throw new Error(
`Version "${singleVersion}" does not satisfy "${range}" for "${serverName}". This is the only version available from the registry.`
);
}
function formatVersionList(versions) {
if (versions.length === 0) return "(none)";
const sorted = [...versions].sort(semver.rcompare);
if (sorted.length <= 5) return sorted.join(", ");
return `${sorted.slice(0, 5).join(", ")} (+${sorted.length - 5} more)`;
}
// src/stack/paths.ts
function lockPathFor(stackPath) {
return stackPath.replace(/(\.ya?ml)?$/i, "-lock$1");
}
// src/commands/lock.ts
import { valid as semverValid } from "semver";
import "commander";
import { writeFile } from "fs/promises";
async function handleLock(options, deps) {
const stackPath = options.stackFile ?? "mcpm.yaml";
const lockPath = lockPathFor(stackPath);
const stackFile = await parseStackFile(stackPath);
const scannerAvailable = await deps.checkScannerAvailable();
const entries = Object.entries(stackFile.servers);
const minAgeHours = stackFile.policy?.minReleaseAgeHours ?? DEFAULT_MIN_RELEASE_AGE_HOURS;
const prevLock = deps.readExistingLock ? await deps.readExistingLock(lockPath).catch(() => null) : null;
const prevProvenance = buildPrevProvenanceMap(prevLock);
const settlements = await Promise.all(
entries.map(
([name, server]) => resolveServer(name, server, scannerAvailable, minAgeHours, deps, prevProvenance.get(name)).then((locked) => ({ name, locked })).catch((err) => ({
name,
error: err instanceof Error ? err.message : String(err)
}))
)
);
const results = [];
const errors = [];
for (const s of settlements) {
if ("locked" in s) {
results.push(s);
} else {
errors.push(s);
}
}
const lockedServers = {};
for (const { name, locked } of results) {
lockedServers[name] = locked;
}
if (errors.length > 0) {
deps.output("");
for (const { name, error } of errors) {
deps.output(` Failed: ${name} \u2014 ${error}`);
}
throw new Error(
`${errors.length} server(s) failed to resolve \u2014 lock not written (${lockPath} left unchanged).
Fix the entries above and re-run \`mcpm lock\`; a partial lock would verify green while enforcing less than you declared.`
);
}
const lockFile = {
lockfileVersion: 1,
lockedAt: (/* @__PURE__ */ new Date()).toISOString(),
servers: lockedServers
};
await deps.writeLockFile(lockPath, serializeYaml(lockFile));
deps.output(`Locked ${results.length} servers to ${lockPath}`);
reportProvenanceDrift(prevLock, results, deps.output);
}
function provenanceOf(server) {
return server && isLockedRegistryServer(server) ? server.provenance : void 0;
}
function repoLabel(snap) {
const raw = snap?.identity?.sourceRepo ?? snap?.identity?.repositoryId ?? "unknown source";
return sanitizeForTerminal(raw);
}
function buildPrevProvenanceMap(prevLock) {
const map = /* @__PURE__ */ new Map();
if (!prevLock) return map;
for (const [name, server] of Object.entries(prevLock.servers)) {
if (isLockedRegistryServer(server) && server.provenance) {
map.set(name, { identifier: server.identifier, snapshot: server.provenance });
}
}
return map;
}
function reportProvenanceDrift(prevLock, results, output) {
if (!prevLock) return;
for (const { name, locked } of results) {
const prevServer = prevLock.servers[name];
const prev = provenanceOf(prevServer);
const next = provenanceOf(locked);
const prevId = isLockedRegistryServer(prevServer) ? prevServer.identifier : void 0;
const nextId = isLockedRegistryServer(locked) ? locked.identifier : void 0;
const sameCoordinate = prevId !== void 0 && prevId === nextId && prev?.npmVersion === next?.npmVersion;
switch (compareProvenance(prev, next)) {
case "identity-drift":
output(
sameCoordinate ? ` \u26A0 provenance identity changed for ${name} on the SAME version ${next?.npmVersion} (${repoLabel(prev)} \u2192 ${repoLabel(next)}) \u2014 an immutable coordinate's attestation should never change publisher; treat as a possible attestation swap and verify before shipping.` : ` \u26A0 provenance identity changed for ${name}: ${repoLabel(prev)} \u2192 ${repoLabel(next)} \u2014 expected if the project moved repos/CI; investigate if not.`
);
break;
case "signed-to-unsigned":
output(
` \u26A0 provenance dropped for ${name}: was attested (${repoLabel(prev)}), now unsigned \u2014 a poisoned republish can look like this; verify before shipping.`
);
break;
}
if (prev?.status === "attested" && prev.verification?.outcome === "verified" && next !== void 0 && next.status !== "unsigned" && !(next.status === "attested" && next.verification?.outcome === "verified")) {
output(
` \u26A0 provenance verification downgraded for ${name}: was cryptographically verified, now unverified \u2014 \`mcpm verify\`/\`up --frozen\` no longer crypto-check it. Investigate a swap; if the new version legitimately dropped or changed provenance, re-baseline knowingly.`
);
}
}
}
async function resolveServer(name, server, scannerAvailable, minAgeHours, deps, prevProvenance) {
if (isUrlServer(server)) {
return { url: server.url };
}
if (!isRegistryServer(server)) {
throw new Error(`Invalid server entry for "${name}"`);
}
let resolvedVersion;
try {
const versions = await deps.getServerVersions(name);
const versionStrings = versions.map((v) => v.version);
const result = resolveVersion(name, server.version, versionStrings);
resolvedVersion = result.resolved;
} catch {
const entry = await deps.getServer(name);
const result = resolveWithSingleVersion(
name,
server.version,
entry.server.version
);
resolvedVersion = result.resolved;
}
const serverEntry = await deps.getServer(name, resolvedVersion);
const tier1Findings = deps.scanTier1(serverEntry);
let allFindings = [...tier1Findings];
if (scannerAvailable) {
const tier2Findings = await deps.scanTier2(name);
allFindings = [...allFindings, ...tier2Findings];
}
const registryMeta = extractRegistryMeta(serverEntry);
const releaseAge = assessReleaseAge({
publishedAt: registryMeta.publishedAt,
now: (deps.now ?? Date.now)(),
minAgeHours
});
if (releaseAge.finding) {
allFindings = [...allFindings, releaseAge.finding];
}
const trustInput = {
findings: allFindings,
healthCheckPassed: null,
hasExternalScanner: scannerAvailable,
registryMeta
};
const trustScore = deps.computeTrustScore(trustInput);
const pkg = serverEntry.server.packages.find((p) => p.registryType === "npm") ?? serverEntry.server.packages.find((p) => p.registryType === "pypi") ?? serverEntry.server.packages.find((p) => p.registryType === "oci") ?? serverEntry.server.packages[0];
const snapshot = {
score: trustScore.score,
maxPossible: trustScore.maxPossible,
level: trustScore.level,
assessedAt: (/* @__PURE__ */ new Date()).toISOString(),
// TODOS #35: record the external-scanner credit so the drop tripwire can
// recover this baseline's native figure later. Always written (0 when no
// scanner was credited) so every lock this mcpm writes is native-recoverable.
externalScanCredit: trustScore.breakdown.externalScan
};
const isConcreteNpm = pkg?.registryType === "npm" && semverValid(pkg.version ?? null) !== null;
let npmIntegritySnap;
if (isConcreteNpm) {
npmIntegritySnap = await deps.fetchNpmIntegrity(
pkg.identifier,
pkg.version
);
}
let provenanceSnap;
if (isConcreteNpm && deps.fetchNpmProvenance) {
provenanceSnap = await deps.fetchNpmProvenance(
pkg.identifier,
pkg.version,
npmIntegritySnap?.integrity
);
}
const prevSnap = prevProvenance?.snapshot;
const sameCoordinate = isConcreteNpm && prevProvenance?.identifier === pkg?.identifier && prevSnap?.status === "attested" && prevSnap.npmVersion === pkg?.version;
const prevWasVerified = prevSnap?.verification?.outcome === "verified";
const freshVerified = provenanceSnap?.status === "attested" && provenanceSnap.verification?.outcome === "verified";
const freshUnreadable = provenanceSnap === void 0 || provenanceSnap.status === "unsupported";
if (sameCoordinate && (prevWasVerified && !freshVerified || freshUnreadable)) {
const activelyContradicts = provenanceSnap?.status === "unsigned" || provenanceSnap?.status === "unsupported" || provenanceSnap?.verification?.outcome === "could-not-verify";
if (prevWasVerified && activelyContradicts) {
deps.output(
` \u26A0 provenance verification regressed for ${name}: was cryptographically verified, now fails to verify \u2014 a poisoned attestation swap can look like this. If npm's record is unchanged, your mcpm/@sigstore version may have changed since you locked; run \`mcpm verify\`. If the change is expected, remove this server's \`provenance:\` block from the lock and re-lock to re-baseline.`
);
}
provenanceSnap = prevSnap;
}
return {
version: resolvedVersion,
registryType: pkg?.registryType ?? "unknown",
identifier: pkg?.identifier ?? name,
trust: snapshot,
...npmIntegritySnap ? { npmIntegrity: npmIntegritySnap } : {},
...provenanceSnap ? { provenance: provenanceSnap } : {}
};
}
function registerLockCommand(program) {
program.command("lock").description(
"Resolve versions and create mcpm-lock.yaml with trust snapshots"
).option("-f, --file <path>", "path to mcpm.yaml", "mcpm.yaml").action(async (opts) => {
const chalk = (await import("chalk")).default;
const client = new RegistryClient();
try {
await handleLock(
{ stackFile: opts.file },
{
getServerVersions: (name) => client.getServerVersions(name),
getServer: (name, version) => client.getServer(name, version),
scanTier1,
checkScannerAvailable,
scanTier2: (name) => scanTier2(name),
computeTrustScore,
now: () => Date.now(),
writeLockFile: (path, content) => writeFile(path, content, { encoding: "utf-8", mode: 384 }),
fetchNpmIntegrity,
fetchNpmProvenance: (id, ver, sri) => fetchNpmProvenance(id, ver, { integritySri: sri }),
readExistingLock: (p) => parseLockFile(p),
output: stdoutOutput
}
);
} catch (err) {
console.error(chalk.red(err.message));
process.exit(1);
}
});
}
export {
lockPathFor,
handleLock,
registerLockCommand
};
//# sourceMappingURL=chunk-APDTSW26.js.map
{"version":3,"sources":["../src/stack/resolve.ts","../src/stack/paths.ts","../src/commands/lock.ts"],"sourcesContent":["/**\n * Version resolution — resolves semver ranges against available versions.\n *\n * Uses the `semver` package for range matching.\n * Pure functions, no I/O.\n */\n\nimport semver from \"semver\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ResolveResult {\n readonly resolved: string;\n readonly range: string;\n readonly available: readonly string[];\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve a version range against a list of available versions.\n *\n * Supports exact versions (\"1.2.3\"), caret ranges (\"^1.0.0\"),\n * tilde ranges (\"~1.2.0\"), and the \"latest\" alias (highest available).\n *\n * @param serverName — used only for error messages\n * @param range — the version range from mcpm.yaml\n * @param available — version strings from the registry\n * @returns the highest satisfying version\n * @throws if no version satisfies the range\n */\nexport function resolveVersion(\n serverName: string,\n range: string,\n available: readonly string[]\n): ResolveResult {\n // Filter to valid semver strings only (registry may return non-semver)\n const validVersions = available.filter((v) => semver.valid(v) !== null);\n\n // \"latest\" alias — highest available version\n if (range === \"latest\") {\n if (validVersions.length === 0) {\n throw new Error(`No versions available for \"${serverName}\".`);\n }\n const sorted = [...validVersions].sort(semver.rcompare);\n return { resolved: sorted[0], range, available: validVersions };\n }\n\n // Exact version match — skip range resolution\n if (semver.valid(range) !== null) {\n const exact = validVersions.find((v) => semver.eq(v, range));\n if (exact) {\n return { resolved: exact, range, available: validVersions };\n }\n throw new Error(\n `Version \"${range}\" not found for \"${serverName}\". ` +\n `Available: ${formatVersionList(validVersions)}`\n );\n }\n\n // Range resolution (caret, tilde)\n const match = semver.maxSatisfying(validVersions, range);\n if (match !== null) {\n return { resolved: match, range, available: validVersions };\n }\n\n throw new Error(\n `No version satisfies \"${range}\" for \"${serverName}\". ` +\n `Available: ${formatVersionList(validVersions)}`\n );\n}\n\n/**\n * Resolve a version range using a single version (fallback path).\n *\n * When the registry only returns the latest version (no version listing\n * endpoint), check if the single version satisfies the range.\n */\nexport function resolveWithSingleVersion(\n serverName: string,\n range: string,\n singleVersion: string\n): ResolveResult {\n // \"latest\" alias always accepts the single available version\n if (range === \"latest\") {\n return { resolved: singleVersion, range, available: [singleVersion] };\n }\n\n if (semver.valid(range) !== null) {\n // Exact match required\n if (semver.eq(singleVersion, range)) {\n return { resolved: singleVersion, range, available: [singleVersion] };\n }\n throw new Error(\n `Version \"${range}\" not found for \"${serverName}\". ` +\n `Only version available: ${singleVersion}`\n );\n }\n\n if (semver.satisfies(singleVersion, range)) {\n return { resolved: singleVersion, range, available: [singleVersion] };\n }\n\n throw new Error(\n `Version \"${singleVersion}\" does not satisfy \"${range}\" for \"${serverName}\". ` +\n `This is the only version available from the registry.`\n );\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction formatVersionList(versions: readonly string[]): string {\n if (versions.length === 0) return \"(none)\";\n const sorted = [...versions].sort(semver.rcompare);\n if (sorted.length <= 5) return sorted.join(\", \");\n return `${sorted.slice(0, 5).join(\", \")} (+${sorted.length - 5} more)`;\n}\n","/**\n * Stack/lock path derivation — one definition, four consumers.\n *\n * `lock`, `up`, `verify` and `diff` each derived the lock path inline with\n * `stackPath.replace(/\\.yaml$/, \"-lock.yaml\")`. That regex is anchored AND\n * case-sensitive, so any stack path not ending in exactly `.yaml` was returned\n * UNCHANGED — making `lockPath === stackPath`. For `mcpm lock` that meant a plain\n * `writeFile` of the lock over the user's own stack file: their server\n * declarations replaced by generated lock content, reported as success, exit 0.\n * `mcpm.yml` is the obvious case (docker-compose / GitHub Actions habit), but\n * `mcpm.YAML`, `mcpm.yaml.bak` and an extensionless `stack` all self-destructed.\n *\n * The first fix STRIPPED any yaml extension and appended a fixed `-lock.yaml`. That\n * cured the self-overwrite but was not INJECTIVE: `mcpm.yaml`, `mcpm.yml` and\n * `mcpm.YAML` all normalised onto the single path `mcpm-lock.yaml`. With two such\n * files in one directory — a production stack and a scratch copy — `mcpm lock -f\n * mcpm.yml` overwrote the OTHER stack's lock, discarding its trust snapshots and\n * sticky Sigstore provenance baselines, reported as success, exit 0. Proving that the\n * output differs from its own input says nothing about two inputs sharing an output.\n *\n * So the extension is PRESERVED and `-lock` inserted before it. `mcpm.yaml` still maps\n * to `mcpm-lock.yaml`, byte-identical, so no existing lock file moves.\n *\n * Injective, by invertibility: the output is the input with `-lock` inserted at a\n * position recoverable from the output itself (immediately before a trailing yaml\n * extension, or at the end when there is none — `-lock` never ends in a yaml\n * extension, so the two cases cannot be confused). A map you can invert cannot merge\n * two inputs. Parameterising the `-lock` infix would void that argument.\n *\n * A path with no yaml extension gets `-lock` appended and nothing invented:\n * defaulting to `.yaml` would put `stack` and `stack.yaml` back on one output.\n */\n\n/** Derive the lock-file path that belongs to a stack file. */\nexport function lockPathFor(stackPath: string): string {\n return stackPath.replace(/(\\.ya?ml)?$/i, \"-lock$1\");\n}\n","/**\n * `mcpm lock` command handler.\n *\n * Reads mcpm.yaml, resolves version ranges against the registry,\n * runs trust assessment per server, and writes mcpm-lock.yaml.\n *\n * URL-based servers are pinned directly (no version resolution).\n * Per-server errors are collected and reported; one failure does not\n * block resolution of other servers.\n *\n * Exports:\n * - handleLock() — injectable handler for testing\n * - registerLockCommand() — Commander registration\n */\n\nimport type { ClientId } from \"../config/paths.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 type {\n StackFile,\n StackServer,\n LockFile,\n LockedServer,\n TrustSnapshot,\n NpmIntegritySnapshot,\n NpmProvenanceSnapshot,\n} from \"../stack/schema.js\";\nimport {\n parseStackFile,\n serializeYaml,\n isRegistryServer,\n isUrlServer,\n isLockedRegistryServer,\n} from \"../stack/schema.js\";\nimport { compareProvenance } from \"../registry/npm-provenance.js\";\nimport { sanitizeForTerminal } from \"../guard/sanitize.js\";\nimport { resolveVersion, resolveWithSingleVersion } from \"../stack/resolve.js\";\nimport { lockPathFor } from \"../stack/paths.js\";\nimport { valid as semverValid } from \"semver\";\nimport { assessReleaseAge, DEFAULT_MIN_RELEASE_AGE_HOURS } from \"../scanner/cooldown.js\";\nimport { extractRegistryMeta } from \"../utils/format-trust.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface LockOptions {\n stackFile?: string;\n}\n\nexport interface LockDeps {\n getServerVersions: (name: string) => Promise<{ version: string }[]>;\n getServer: (name: string, version?: string) => Promise<ServerEntry>;\n scanTier1: (server: ServerEntry) => Finding[];\n checkScannerAvailable: () => Promise<boolean>;\n scanTier2: (name: string) => Promise<Finding[]>;\n computeTrustScore: (input: TrustScoreInput) => TrustScore;\n /** Epoch-ms clock for release-age assessment; defaults to Date.now at the CLI boundary. */\n now?: () => number;\n writeLockFile: (path: string, content: string) => Promise<void>;\n output: (text: string) => void;\n /**\n * H11 slice 1: fetch npm's published dist.integrity for an exact package\n * coordinate. FAIL-OPEN: returns undefined on any error. When undefined the\n * snapshot is omitted and lock never blocks.\n */\n fetchNpmIntegrity: (\n identifier: string,\n npmVersion: string\n ) => Promise<NpmIntegritySnapshot | undefined>;\n /**\n * F8 slice 1: fetch npm's parse-only provenance record for an exact npm\n * coordinate. Optional — capture is skipped when absent. FAIL-OPEN (undefined).\n */\n fetchNpmProvenance?: (\n identifier: string,\n npmVersion: string,\n /** F8 crypto slice: dist.integrity SRI for subject-binding the attestation. */\n integritySri?: string\n ) => Promise<NpmProvenanceSnapshot | undefined>;\n /**\n * F8 slice 1: read the PREVIOUS lock (before overwrite) so provenance-identity\n * drift can be reported. Optional — drift check is skipped when absent.\n */\n readExistingLock?: (lockPath: string) => Promise<LockFile | null>;\n}\n\n// ---------------------------------------------------------------------------\n// Handler\n// ---------------------------------------------------------------------------\n\ninterface LockResult {\n readonly name: string;\n readonly locked: LockedServer;\n}\n\ninterface LockError {\n readonly name: string;\n readonly error: string;\n}\n\n/**\n * Core handler for `mcpm lock`.\n *\n * Resolves all servers in mcpm.yaml, runs trust assessment, writes lock file.\n *\n * Per-server errors are collected rather than short-circuiting, so ONE bad entry\n * still reports every other failure in the same run. But the lock is all-or-\n * nothing: if any server failed, nothing is written and this THROWS. A partial\n * lock is worse than no lock — `verify` / `up --frozen` enforce only what the\n * lock contains, so a silently truncated one is a green gate over less coverage.\n */\nexport async function handleLock(\n options: LockOptions,\n deps: LockDeps\n): Promise<void> {\n const stackPath = options.stackFile ?? \"mcpm.yaml\";\n const lockPath = lockPathFor(stackPath);\n const stackFile = await parseStackFile(stackPath);\n\n const scannerAvailable = await deps.checkScannerAvailable();\n const entries = Object.entries(stackFile.servers);\n\n // F4 lock/up symmetry: snapshots must be scored with the SAME cooldown\n // threshold `up` re-scores with, or blockOnScoreDrop trips spuriously.\n const minAgeHours =\n stackFile.policy?.minReleaseAgeHours ?? DEFAULT_MIN_RELEASE_AGE_HOURS;\n\n // F8: read the PREVIOUS lock up front — it feeds BOTH the drift baseline and\n // the carry-forward that keeps a known-good provenance snapshot sticky across a\n // transient re-read failure of the same immutable coordinate.\n const prevLock = deps.readExistingLock\n ? await deps.readExistingLock(lockPath).catch(() => null)\n : null;\n const prevProvenance = buildPrevProvenanceMap(prevLock);\n\n // Resolve all servers in parallel\n const settlements = await Promise.all(\n entries.map(([name, server]) =>\n resolveServer(name, server, scannerAvailable, minAgeHours, deps, prevProvenance.get(name))\n .then((locked): LockResult => ({ name, locked }))\n .catch((err): LockError => ({\n name,\n error: err instanceof Error ? err.message : String(err),\n }))\n )\n );\n\n const results: LockResult[] = [];\n const errors: LockError[] = [];\n\n for (const s of settlements) {\n if (\"locked\" in s) {\n results.push(s);\n } else {\n errors.push(s);\n }\n }\n\n // Build lock file from successful resolutions\n const lockedServers: Record<string, LockedServer> = {};\n for (const { name, locked } of results) {\n lockedServers[name] = locked;\n }\n\n // FAIL-CLOSED: a lock missing a declared server must never reach disk. `mcpm\n // verify` and `up --frozen` check only what the lock CONTAINS, so a truncated\n // lock passes every gate — silently narrowing what is enforced. Report every\n // failure (resolution still ran to completion for all of them), then abort\n // WITHOUT writing, leaving any previous good lock intact.\n if (errors.length > 0) {\n deps.output(\"\");\n for (const { name, error } of errors) {\n deps.output(` Failed: ${name} — ${error}`);\n }\n throw new Error(\n `${errors.length} server(s) failed to resolve — lock not written (${lockPath} left unchanged).\\n` +\n `Fix the entries above and re-run \\`mcpm lock\\`; a partial lock would verify green while enforcing less than you declared.`\n );\n }\n\n const lockFile: LockFile = {\n lockfileVersion: 1,\n lockedAt: new Date().toISOString(),\n servers: lockedServers,\n };\n\n await deps.writeLockFile(lockPath, serializeYaml(lockFile));\n deps.output(`Locked ${results.length} servers to ${lockPath}`);\n\n reportProvenanceDrift(prevLock, results, deps.output);\n}\n\n// ---------------------------------------------------------------------------\n// F8 slice 1 — provenance-identity drift reporting (report-only)\n// ---------------------------------------------------------------------------\n\nfunction provenanceOf(server: LockedServer | undefined): NpmProvenanceSnapshot | undefined {\n return server && isLockedRegistryServer(server) ? server.provenance : undefined;\n}\n\n/** Human label for a provenance source — SANITIZED: the value is unverified\n * registry / committed-lockfile free text, so strip ANSI/OSC (and bound length)\n * before it reaches a terminal inside a security warning. */\nfunction repoLabel(snap: NpmProvenanceSnapshot | undefined): string {\n const raw = snap?.identity?.sourceRepo ?? snap?.identity?.repositoryId ?? \"unknown source\";\n return sanitizeForTerminal(raw);\n}\n\n/** The previous lock's provenance baseline + the identifier it was recorded for. */\ntype PrevProvenance = { identifier: string; snapshot: NpmProvenanceSnapshot };\n\n/** Index the previous lock's provenance snapshots (with identifier) by server name. */\nfunction buildPrevProvenanceMap(prevLock: LockFile | null): Map<string, PrevProvenance> {\n const map = new Map<string, PrevProvenance>();\n if (!prevLock) return map;\n for (const [name, server] of Object.entries(prevLock.servers)) {\n // Carry the identifier alongside the snapshot: the sticky carry-forward must NOT\n // apply a previous baseline to a DIFFERENT package the user swapped in under the\n // same server name (that would false-positive the F8 verify-time signer gate).\n if (isLockedRegistryServer(server) && server.provenance) {\n map.set(name, { identifier: server.identifier, snapshot: server.provenance });\n }\n }\n return map;\n}\n\n/**\n * Compare each freshly-locked server's provenance to the previous lock's and\n * WARN on identity drift / a signed→unsigned drop. Report-only: never blocks,\n * never re-pins (consistent with the H4/H5/H11 tripwire posture). Copy is\n * careful — legitimate repo renames / org transfers happen, so it advises, and\n * never claims \"verified\".\n */\nfunction reportProvenanceDrift(\n prevLock: LockFile | null,\n results: LockResult[],\n output: (text: string) => void\n): void {\n if (!prevLock) return;\n for (const { name, locked } of results) {\n const prevServer = prevLock.servers[name];\n const prev = provenanceOf(prevServer);\n const next = provenanceOf(locked);\n // The \"same immutable coordinate\" hard-copy applies only when the package IDENTIFIER\n // is unchanged too — a user re-pointing the entry at a DIFFERENT npm package that\n // happens to share a version string is a legit swap, not an attestation swap.\n const prevId = isLockedRegistryServer(prevServer) ? prevServer.identifier : undefined;\n const nextId = isLockedRegistryServer(locked) ? locked.identifier : undefined;\n const sameCoordinate = prevId !== undefined && prevId === nextId && prev?.npmVersion === next?.npmVersion;\n switch (compareProvenance(prev, next)) {\n case \"identity-drift\":\n // On the SAME immutable coordinate the org-transfer hedge does NOT apply — a\n // pinned coordinate's attestation can't legitimately change publisher, so this\n // is a swap to investigate, not a rename to wave through.\n output(\n sameCoordinate\n ? ` ⚠ provenance identity changed for ${name} on the SAME version ${next?.npmVersion} ` +\n `(${repoLabel(prev)} → ${repoLabel(next)}) — an immutable coordinate's attestation should ` +\n `never change publisher; treat as a possible attestation swap and verify before shipping.`\n : ` ⚠ provenance identity changed for ${name}: ${repoLabel(prev)} → ${repoLabel(next)} — ` +\n `expected if the project moved repos/CI; investigate if not.`\n );\n break;\n case \"signed-to-unsigned\":\n output(\n ` ⚠ provenance dropped for ${name}: was attested (${repoLabel(prev)}), now unsigned — ` +\n `a poisoned republish can look like this; verify before shipping.`\n );\n break;\n }\n\n // Verification DOWNGRADE (F8): a coordinate that was crypto-`verified` is now anything\n // that no longer verifies — attested-but-unverified OR an unrecognized/anchorless\n // (\"unsupported\") attestation shape. On the SAME coordinate the sticky carry keeps\n // `next` verified, so this fires only across a VERSION BUMP (or a genuine re-baseline),\n // where compareProvenance's cross-derivation guard returns \"none\" and would otherwise\n // stay silent while the F8 gate quietly stops covering this server. Mirrors the carry's\n // exhaustive \"unless the fresh read verifies\" doctrine rather than enumerating states.\n // `unsigned` is excluded (already surfaced by signed-to-unsigned above); `undefined`\n // is excluded (a transient fetch-fail, fail-open by design).\n if (\n prev?.status === \"attested\" &&\n prev.verification?.outcome === \"verified\" &&\n next !== undefined &&\n next.status !== \"unsigned\" &&\n !(next.status === \"attested\" && next.verification?.outcome === \"verified\")\n ) {\n output(\n ` ⚠ provenance verification downgraded for ${name}: was cryptographically verified, now ` +\n `unverified — \\`mcpm verify\\`/\\`up --frozen\\` no longer crypto-check it. Investigate a swap; if ` +\n `the new version legitimately dropped or changed provenance, re-baseline knowingly.`\n );\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Per-server resolution\n// ---------------------------------------------------------------------------\n\nasync function resolveServer(\n name: string,\n server: StackServer,\n scannerAvailable: boolean,\n minAgeHours: number,\n deps: LockDeps,\n prevProvenance?: PrevProvenance\n): Promise<LockedServer> {\n // URL-based servers: pin directly, no version resolution or trust\n if (isUrlServer(server)) {\n return { url: server.url };\n }\n\n if (!isRegistryServer(server)) {\n throw new Error(`Invalid server entry for \"${name}\"`);\n }\n\n // Step 1: Resolve version\n let resolvedVersion: string;\n try {\n const versions = await deps.getServerVersions(name);\n const versionStrings = versions.map((v) => v.version);\n const result = resolveVersion(name, server.version, versionStrings);\n resolvedVersion = result.resolved;\n } catch {\n // Fallback: try with just the latest version\n const entry = await deps.getServer(name);\n const result = resolveWithSingleVersion(\n name,\n server.version,\n entry.server.version\n );\n resolvedVersion = result.resolved;\n }\n\n // Step 2: Fetch the resolved version's full entry\n const serverEntry = await deps.getServer(name, resolvedVersion);\n\n // Step 3: Trust assessment\n const tier1Findings = deps.scanTier1(serverEntry);\n let allFindings: Finding[] = [...tier1Findings];\n if (scannerAvailable) {\n const tier2Findings = await deps.scanTier2(name);\n allFindings = [...allFindings, ...tier2Findings];\n }\n\n // Release-age assessment (F4): the snapshot carries the same cooldown\n // penalty `up` will re-score with (see handleLock's minAgeHours threading).\n const registryMeta = extractRegistryMeta(serverEntry);\n const releaseAge = assessReleaseAge({\n publishedAt: registryMeta.publishedAt,\n now: (deps.now ?? Date.now)(),\n minAgeHours,\n });\n if (releaseAge.finding) {\n allFindings = [...allFindings, releaseAge.finding];\n }\n\n const trustInput: TrustScoreInput = {\n findings: allFindings,\n healthCheckPassed: null,\n hasExternalScanner: scannerAvailable,\n registryMeta,\n };\n const trustScore = deps.computeTrustScore(trustInput);\n\n // Step 4: Determine registry type and identifier\n const pkg =\n serverEntry.server.packages.find((p) => p.registryType === \"npm\") ??\n serverEntry.server.packages.find((p) => p.registryType === \"pypi\") ??\n serverEntry.server.packages.find((p) => p.registryType === \"oci\") ??\n serverEntry.server.packages[0];\n\n const snapshot: TrustSnapshot = {\n score: trustScore.score,\n maxPossible: trustScore.maxPossible,\n level: trustScore.level,\n assessedAt: new Date().toISOString(),\n // TODOS #35: record the external-scanner credit so the drop tripwire can\n // recover this baseline's native figure later. Always written (0 when no\n // scanner was credited) so every lock this mcpm writes is native-recoverable.\n externalScanCredit: trustScore.breakdown.externalScan,\n };\n\n // Step 5: H11 slice 1 — capture npm artifact integrity snapshot.\n // Only for npm packages whose pkg.version is a concrete exact semver\n // (not \"latest\", a dist-tag, or a range). Using pkg.version (the npm\n // coordinate) — NOT the resolved MCP server version — because the npm\n // per-version endpoint uses the npm package version, not the registry's\n // MCP server version field. Fail-open: if fetchNpmIntegrity returns\n // undefined, omit the snapshot and proceed; lock never blocks on this.\n const isConcreteNpm =\n pkg?.registryType === \"npm\" && semverValid(pkg.version ?? null) !== null;\n\n let npmIntegritySnap: NpmIntegritySnapshot | undefined;\n if (isConcreteNpm) {\n npmIntegritySnap = await deps.fetchNpmIntegrity(\n pkg.identifier,\n pkg.version as string\n );\n }\n\n // F8 slice 1: capture the parse-only provenance snapshot behind the SAME gate.\n // Fail-open: undefined omits the block; lock never blocks on this.\n let provenanceSnap: NpmProvenanceSnapshot | undefined;\n if (isConcreteNpm && deps.fetchNpmProvenance) {\n // Pass the H11 dist.integrity SRI (may be undefined if that fetch failed) so\n // the provenance layer can subject-bind a crypto \"verified\" verdict to THIS\n // tarball. Without it, only the parse-only \"attested\" record is produced.\n provenanceSnap = await deps.fetchNpmProvenance(\n pkg.identifier,\n pkg.version as string,\n npmIntegritySnap?.integrity\n );\n }\n\n // F8 sticky baseline — the COMPLETE invariant (inverts a fragile enumeration). For the\n // SAME immutable coordinate + identifier, a crypto-`verified` baseline may be REPLACED\n // only by a fresh read that is ALSO crypto-`verified` (a legitimate re-sign). EVERY\n // other fresh outcome — fetch-fail (undefined), 404 (unsigned), unparseable body\n // (unsupported), attested-but-could-not-verify, attested-without-verification — is\n // transient-or-attack, so we CARRY the verified baseline forward to keep the F8\n // verify-time gate ARMED (it re-fetches and hard-blocks a real regression). Enumerating\n // the \"bad\" states missed one every review round (could-not-verify, then unsigned);\n // \"carry unless the fresh read verifies\" is exhaustive by construction. Attested-ONLY\n // baselines keep only the original transient carry (undefined/unsupported), preserving\n // drift-report stability without touching the F8 gate. Guarded on identifier equality\n // (no cross-package carry).\n const prevSnap = prevProvenance?.snapshot;\n const sameCoordinate =\n isConcreteNpm &&\n prevProvenance?.identifier === pkg?.identifier &&\n prevSnap?.status === \"attested\" &&\n prevSnap.npmVersion === pkg?.version;\n const prevWasVerified = prevSnap?.verification?.outcome === \"verified\";\n const freshVerified =\n provenanceSnap?.status === \"attested\" && provenanceSnap.verification?.outcome === \"verified\";\n const freshUnreadable = provenanceSnap === undefined || provenanceSnap.status === \"unsupported\";\n\n if (sameCoordinate && ((prevWasVerified && !freshVerified) || freshUnreadable)) {\n // Warn when the fresh read ACTIVELY contradicts a verified baseline (a 404 dropping\n // the attestation, an unparseable body, or a present-but-failed crypto) — hedged for\n // the benign mcpm/@sigstore-upgrade case, and naming the re-baseline escape. A bare\n // fetch-fail / crypto-didn't-run (undefined verification) is benign → stays silent.\n const activelyContradicts =\n provenanceSnap?.status === \"unsigned\" ||\n provenanceSnap?.status === \"unsupported\" ||\n provenanceSnap?.verification?.outcome === \"could-not-verify\";\n if (prevWasVerified && activelyContradicts) {\n deps.output(\n ` ⚠ provenance verification regressed for ${name}: was cryptographically verified, now fails to ` +\n `verify — a poisoned attestation swap can look like this. If npm's record is unchanged, your ` +\n `mcpm/@sigstore version may have changed since you locked; run \\`mcpm verify\\`. If the change is ` +\n `expected, remove this server's \\`provenance:\\` block from the lock and re-lock to re-baseline.`\n );\n }\n provenanceSnap = prevSnap;\n }\n\n return {\n version: resolvedVersion,\n registryType: pkg?.registryType ?? \"unknown\",\n identifier: pkg?.identifier ?? name,\n trust: snapshot,\n ...(npmIntegritySnap ? { npmIntegrity: npmIntegritySnap } : {}),\n ...(provenanceSnap ? { provenance: provenanceSnap } : {}),\n };\n}\n\n// ---------------------------------------------------------------------------\n// Commander registration\n// ---------------------------------------------------------------------------\n\nimport { Command } from \"commander\";\nimport { writeFile } from \"fs/promises\";\nimport { RegistryClient } from \"../registry/client.js\";\nimport { scanTier1 as _scanTier1 } from \"../scanner/tier1.js\";\nimport {\n checkScannerAvailable as _checkScannerAvailable,\n scanTier2 as _scanTier2,\n} from \"../scanner/tier2.js\";\nimport { computeTrustScore as _computeTrustScore } from \"../scanner/trust-score.js\";\nimport { fetchNpmIntegrity as _fetchNpmIntegrity } from \"../registry/npm-integrity.js\";\nimport { fetchNpmProvenance as _fetchNpmProvenance } from \"../registry/npm-provenance.js\";\nimport { parseLockFile } from \"../stack/schema.js\";\nimport { stdoutOutput } from \"../utils/output.js\";\n\nexport function registerLockCommand(program: Command): void {\n program\n .command(\"lock\")\n .description(\n \"Resolve versions and create mcpm-lock.yaml with trust snapshots\"\n )\n .option(\"-f, --file <path>\", \"path to mcpm.yaml\", \"mcpm.yaml\")\n .action(async (opts: { file?: string }) => {\n const chalk = (await import(\"chalk\")).default;\n const client = new RegistryClient();\n\n try {\n await handleLock(\n { stackFile: opts.file },\n {\n getServerVersions: (name) =>\n client.getServerVersions(name),\n getServer: (name, version?) => client.getServer(name, version),\n scanTier1: _scanTier1,\n checkScannerAvailable: _checkScannerAvailable,\n scanTier2: (name) => _scanTier2(name),\n computeTrustScore: _computeTrustScore,\n now: () => Date.now(),\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 readExistingLock: (p) => parseLockFile(p),\n output: stdoutOutput,\n }\n );\n } catch (err) {\n console.error(chalk.red((err as Error).message));\n process.exit(1);\n }\n });\n}\n"],"mappings":"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","names":[]}
#!/usr/bin/env node
import {
handleLock,
lockPathFor
} from "./chunk-APDTSW26.js";
import {
parseSecretsMode,
resolveInstallEntry,
validateRemoteUrl
} from "./chunk-HFNKEMBT.js";
import {
readPins
} from "./chunk-DDCTUMSZ.js";
import {
DEFAULT_MIN_RELEASE_AGE_HOURS,
assessReleaseAge,
stdoutOutput
} from "./chunk-E3T224S3.js";
import {
fetchNpmProvenance,
isEnoent,
isLockedRegistryServer,
isRegistryServer,
isUrlServer,
parseLockFile,
parseStackFile
} from "./chunk-RTI2GLYX.js";
import {
assessServerStatus,
extractRegistryMeta,
scanTier1
} from "./chunk-U7N6FRYF.js";
import {
checkScannerAvailable,
scanTier2
} from "./chunk-F6CHEUGO.js";
import {
getAdapter
} from "./chunk-W4IAFBUN.js";
import {
confirm
} from "./chunk-2PWW3Q5Q.js";
import {
isNewUnguarded
} from "./chunk-MLVDFLDQ.js";
import {
compareIntegrity,
fetchNpmIntegrity
} from "./chunk-7RJXJERN.js";
import {
RegistryClient
} from "./chunk-V4AA4ZL5.js";
import {
EXTERNAL_SCAN_MAX,
computeTrustScore,
maxAchievableBeforeHealthCheck,
nativeTrustScore
} from "./chunk-Y36MYEDT.js";
import {
applyKeychainSecrets,
setSecrets
} from "./chunk-NPJ3SGGS.js";
import {
detectInstalledClients
} from "./chunk-6R7TL5O2.js";
import {
getConfigPath
} from "./chunk-R4R2VPDA.js";
// src/stack/policy.ts
function checkTrustPolicy(input2) {
const { serverName, currentScore, currentMaxPossible, lockedSnapshot, policy } = input2;
if (policy === void 0) {
return { pass: true };
}
const currentPct = toPct(currentScore, currentMaxPossible);
if (policy.minTrustScore !== void 0 && currentPct < policy.minTrustScore) {
const creditedCeiling = maxAchievableBeforeHealthCheck(true);
const ceiling = currentMaxPossible === creditedCeiling.maxPossible ? creditedCeiling : maxAchievableBeforeHealthCheck(false);
const ceilingPct = toPct(ceiling.score, ceiling.maxPossible);
if (policy.minTrustScore > ceilingPct) {
return {
pass: false,
reason: `policy.minTrustScore ${policy.minTrustScore}% is above ${ceilingPct}%, the highest percentage \`mcpm up\` can award a server scored the way "${serverName}" was (${currentPct}%). Trust is scored BEFORE any health check and mcpm reads no download count, so no server on this evidence path can reach the threshold \u2014 it refuses for what \`up\` cannot measure, not for the server's evidence.`
};
}
return {
pass: false,
reason: `"${serverName}" trust score ${currentPct}% is below the minimum policy threshold of ${policy.minTrustScore}%.`
};
}
if (policy.blockOnScoreDrop === true && lockedSnapshot !== void 0) {
const { currentNativeScore, currentNativeMaxPossible } = input2;
if (currentNativeScore === void 0 || currentNativeMaxPossible === void 0) {
throw new Error(
"blockOnScoreDrop requires the current native trust figures (currentNativeScore / currentNativeMaxPossible). This is a bug \u2014 report it rather than working around it."
);
}
const lockedNative = recoverLockedNative(
lockedSnapshot,
currentNativeMaxPossible
);
const curPct = toPct(currentNativeScore, currentNativeMaxPossible);
const lockPct = toPct(lockedNative.score, lockedNative.maxPossible);
if (curPct < lockPct) {
return {
pass: false,
reason: `"${serverName}" trust score dropped from ${lockPct}% to ${curPct}% (mcpm-native evidence, excluding unverifiable external-scanner credit) since the lock file was created.` + (lockedNative.basis === "legacy-bound" ? ` This lock predates native-evidence drop checks and was written with an external scanner credited, so the baseline is an upper bound \u2014 re-run \`mcpm lock\` to record an exact one.` : lockedNative.basis === "out-of-range" ? (
// Deliberately NOT phrased as tampering: an upward re-weighting of the
// external bucket would make that accusation false for an older mcpm
// reading a newer lock. Re-locking is the remedy either way.
` This lock records trust figures outside the range this version can interpret, so the baseline is an upper bound \u2014 re-run \`mcpm lock\` to record an exact one.`
) : ` If you recently upgraded mcpm, new scanner findings can lower scores \u2014 re-run \`mcpm lock\` to refresh snapshots if the drop is expected.`)
};
}
}
if (policy.minReleaseAgeHours !== void 0 && input2.releaseAge?.blocksArmedGate === true) {
const { ageHours, status } = input2.releaseAge;
if (status === "future") {
return {
pass: false,
reason: `"${serverName}" has a publish timestamp in the future; treated as within the minimum release age of ${policy.minReleaseAgeHours} hour(s) required by policy.`
};
}
if (ageHours === null) {
return {
pass: false,
reason: `"${serverName}" release is of unverifiable age (publish timestamp ${status === "absent" ? "missing from registry metadata" : "could not be parsed"}), and the policy requires a minimum release age of ${policy.minReleaseAgeHours} hour(s).`
};
}
return {
pass: false,
reason: `"${serverName}" release is ${ageHours} hour(s) old, below the minimum release age of ${policy.minReleaseAgeHours} hour(s) required by policy.`
};
}
if (policy.blockInstallScripts === true && input2.hasInstallScriptFindings === true) {
return {
pass: false,
reason: `"${serverName}" resolves to a launcher that runs install scripts, and the policy blocks install scripts.`
};
}
return { pass: true };
}
function toPct(score, maxPossible) {
if (maxPossible <= 0) return 0;
return Math.round(score / maxPossible * 100);
}
function isUsableCredit(credit, locked, nativeMax) {
if (locked.maxPossible === nativeMax && credit > 0) return false;
return credit >= 0 && credit <= EXTERNAL_SCAN_MAX && credit <= locked.score;
}
function recoverLockedNative(locked, nativeMax) {
const bounded = (score, basis) => {
const clamped = Math.max(0, Math.min(nativeMax, score));
return {
score: clamped,
maxPossible: nativeMax,
// A computation that had to be clamped was not exact, whatever produced it — an
// out-of-range `score` reaches here the same way an out-of-range credit does,
// since both are unbounded `z.number()`. Reporting it as exact would hand the
// user the "you upgraded mcpm" remedy for a lock that actually needs re-locking.
basis: basis === "exact" && clamped !== score ? "out-of-range" : basis
};
};
if (locked.externalScanCredit !== void 0) {
return isUsableCredit(locked.externalScanCredit, locked, nativeMax) ? bounded(locked.score - locked.externalScanCredit, "exact") : bounded(locked.score, "out-of-range");
}
if (locked.maxPossible === nativeMax) {
return bounded(locked.score, "exact");
}
return bounded(locked.score, "legacy-bound");
}
// src/stack/env.ts
import { readFile } from "fs/promises";
var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "__defineGetter__", "__defineSetter__"]);
async function parseEnvFile(filePath) {
let raw;
try {
raw = await readFile(filePath, "utf-8");
} catch (err) {
if (isEnoent(err)) {
return { vars: {}, warnings: [] };
}
throw err;
}
return parseEnvString(raw);
}
function parseEnvString(content) {
const vars = {};
const warnings = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const lineNum = i + 1;
const raw = lines[i];
const trimmed = raw.trim();
if (trimmed === "" || trimmed.startsWith("#")) {
continue;
}
const eqIndex = trimmed.indexOf("=");
if (eqIndex === -1) {
warnings.push(`Line ${lineNum}: skipped malformed line (no = sign)`);
continue;
}
const key = trimmed.slice(0, eqIndex).trim();
if (key === "") {
warnings.push(`Line ${lineNum}: skipped line with empty key`);
continue;
}
if (!ENV_KEY_RE.test(key) || UNSAFE_KEYS.has(key)) {
warnings.push(
`Line ${lineNum}: skipped invalid key "${key}"`
);
continue;
}
let value = trimmed.slice(eqIndex + 1).trim();
if (!value.startsWith('"') && !value.startsWith("'")) {
const commentIndex = value.indexOf(" #");
if (commentIndex !== -1) {
value = value.slice(0, commentIndex).trim();
}
}
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
value = value.slice(1, -1);
}
vars[key] = value;
}
return { vars, warnings };
}
// src/stack/frozen-verify.ts
function memoizeIntegrity(fetch) {
const cache = /* @__PURE__ */ new Map();
return (identifier, npmVersion) => {
const key = `${identifier}\0${npmVersion}`;
let p = cache.get(key);
if (p === void 0) {
p = fetch(identifier, npmVersion);
cache.set(key, p);
}
return p;
};
}
async function classifyIntegrity(lockFile, fetchNpmIntegrity2) {
const registryEntries = Object.entries(lockFile.servers).filter(
([, locked]) => isLockedRegistryServer(locked)
);
const npmEntries = registryEntries.filter(([, l]) => l.registryType === "npm");
const npmNames = new Set(npmEntries.map(([name]) => name));
const unenforceable = Object.keys(lockFile.servers).filter((name) => !npmNames.has(name));
const checkable = npmEntries.filter(([, l]) => l.npmIntegrity !== void 0);
const absentBaseline = npmEntries.filter(([, l]) => l.npmIntegrity === void 0).map(([name]) => name);
const fresh = await Promise.all(
checkable.map(([, l]) => fetchNpmIntegrity2(l.identifier, l.npmIntegrity.npmVersion))
);
const drift = [];
const formatOnly = [];
const couldNotVerify = [];
for (let i = 0; i < checkable.length; i++) {
const [name, locked] = checkable[i];
const baseline = locked.npmIntegrity;
const snap = fresh[i];
const coord = { name, identifier: locked.identifier, npmVersion: baseline.npmVersion };
if (snap === void 0) {
couldNotVerify.push(coord);
continue;
}
const cmp = compareIntegrity(baseline.integrity, snap.integrity);
if (cmp === "equal") continue;
if (cmp === "differ") {
drift.push({ ...coord, oldIntegrity: baseline.integrity, newIntegrity: snap.integrity });
} else {
formatOnly.push(coord);
}
}
return { drift, formatOnly, couldNotVerify, absentBaseline, unenforceable, checkedNpmCount: checkable.length };
}
function frozenVerdict(c) {
const noBaselines = c.absentBaseline.length > 0 && c.checkedNpmCount === 0;
const blocks = [];
for (const d of c.drift) {
blocks.push({
name: d.name,
reason: "drift",
identifier: d.identifier,
npmVersion: d.npmVersion,
oldIntegrity: d.oldIntegrity,
newIntegrity: d.newIntegrity
});
}
for (const f of c.formatOnly) {
blocks.push({ name: f.name, reason: "format", identifier: f.identifier, npmVersion: f.npmVersion });
}
for (const v of c.couldNotVerify) {
blocks.push({ name: v.name, reason: "could-not-verify", identifier: v.identifier, npmVersion: v.npmVersion });
}
if (!noBaselines) {
for (const name of c.absentBaseline) {
blocks.push({ name, reason: "missing-baseline" });
}
}
return {
ok: !noBaselines && blocks.length === 0,
noBaselines,
blocks,
unenforceable: c.unenforceable,
checkedNpmCount: c.checkedNpmCount
};
}
// src/stack/frozen-provenance.ts
function verifiedBaseline(locked) {
const prov = locked.provenance;
if (prov?.status !== "attested" || prov.verification?.outcome !== "verified") {
return void 0;
}
return {
npmVersion: prov.npmVersion,
signerSan: prov.verification.signerSan,
signerIssuer: prov.verification.signerIssuer
};
}
async function classifyProvenance(lockFile, fetchNpmIntegrity2, fetchNpmProvenance2) {
const checked = Object.entries(lockFile.servers).filter(
([, l]) => isLockedRegistryServer(l)
).filter(([, l]) => l.registryType === "npm").map(([name, l]) => ({ name, locked: l, baseline: verifiedBaseline(l) })).filter(
(e) => e.baseline !== void 0
);
const blocks = (await Promise.all(
checked.map(async ({ name, locked, baseline }) => {
const coord = { name, identifier: locked.identifier, npmVersion: baseline.npmVersion };
try {
const integ = await fetchNpmIntegrity2(locked.identifier, baseline.npmVersion);
if (integ === void 0) {
return {
...coord,
reason: "unverifiable",
detail: "could not fetch npm's published integrity to bind the attestation"
};
}
const fresh = await fetchNpmProvenance2(locked.identifier, baseline.npmVersion, {
integritySri: integ.integrity
});
return classifyOne(coord, baseline, fresh);
} catch {
return {
...coord,
reason: "unverifiable",
detail: "re-verification errored this run (fetcher threw)"
};
}
})
)).filter((b) => b !== void 0);
return { ok: blocks.length === 0, blocks, checkedVerifiedCount: checked.length };
}
function classifyOne(coord, baseline, fresh) {
if (fresh === void 0) {
return { ...coord, reason: "unverifiable", detail: "no fresh attestation record this run (offline or endpoint error)" };
}
if (fresh.status === "unsigned") {
return { ...coord, reason: "regression", detail: "the attestation that verified at lock time is no longer published (now unsigned)" };
}
if (fresh.status !== "attested") {
return { ...coord, reason: "unverifiable", detail: "attestation shape is no longer a recognizable SLSA record" };
}
const v = fresh.verification;
if (v === void 0) {
return { ...coord, reason: "unverifiable", detail: "attestation present but cryptographic verification did not run this fetch" };
}
if (v.outcome === "could-not-verify") {
return { ...coord, reason: "regression", detail: `attestation no longer cryptographically verifies (${v.reason ?? "crypto failure"})` };
}
if (baseline.signerSan === void 0) {
return { ...coord, reason: "unverifiable", detail: "verified baseline lacks a recorded signer SAN \u2014 cannot assert signer equality; re-lock to record it" };
}
if (v.signerSan !== baseline.signerSan || v.signerIssuer !== baseline.signerIssuer) {
const deltas = [];
if (v.signerSan !== baseline.signerSan) {
deltas.push(`SAN ${baseline.signerSan ?? "(none)"} \u2192 ${v.signerSan ?? "(none)"}`);
}
if (v.signerIssuer !== baseline.signerIssuer) {
deltas.push(`issuer ${baseline.signerIssuer ?? "(none)"} \u2192 ${v.signerIssuer ?? "(none)"}`);
}
return { ...coord, reason: "signer-changed", detail: `signer identity changed: ${deltas.join("; ")}` };
}
return void 0;
}
// src/guard/shadow.ts
function detectNameCollisions(inventory) {
const ownersByTool = /* @__PURE__ */ new Map();
for (const [server, tools] of inventory) {
for (const tool of tools) {
let owners = ownersByTool.get(tool);
if (owners === void 0) {
owners = /* @__PURE__ */ new Set();
ownersByTool.set(tool, owners);
}
owners.add(server);
}
}
const findings = [];
for (const [toolName, owners] of ownersByTool) {
if (owners.size >= 2) {
findings.push({ toolName, servers: [...owners].sort() });
}
}
return findings.sort((a, b) => a.toolName.localeCompare(b.toolName));
}
function buildInventoryFromPins(pins, serverNames) {
const inventory = /* @__PURE__ */ new Map();
for (const name of serverNames) {
inventory.set(name, toolNamesFor(pins, name));
}
return inventory;
}
function toolNamesFor(pins, name) {
return Object.hasOwn(pins.servers, name) ? Object.keys(pins.servers[name]) : [];
}
function serversWithoutBaseline(pins, serverNames) {
return serverNames.filter((name) => toolNamesFor(pins, name).length === 0);
}
// src/commands/up.ts
import "commander";
import chalk from "chalk";
import { input, password } from "@inquirer/prompts";
function trustFigure(trust, options) {
if (options.minTrustFloor === void 0) {
return `${trust.score}/${trust.maxPossible}`;
}
const native = nativeTrustScore(trust);
if (native.excludedExternalCredit === 0) {
return `${trust.score}/${trust.maxPossible}`;
}
return `${native.score}/${native.maxPossible} against the floor, ${trust.score}/${trust.maxPossible} with the external scanner`;
}
async function handleUp(options, deps) {
if (options.secrets === "keychain" && options.ci) {
throw new Error(
"--secrets keychain cannot be combined with --ci (it would persist secrets to the CI runner's keychain). Use --secrets plaintext in CI."
);
}
const stackPath = options.stackFile ?? "mcpm.yaml";
const lockPath = lockPathFor(stackPath);
const stackFile = await parseStackFile(stackPath);
let lockFile = await parseLockFile(lockPath);
if (lockFile === null) {
deps.output("No lock file found. Running mcpm lock first...");
await deps.runLock(stackPath);
lockFile = await parseLockFile(lockPath);
if (lockFile === null) {
throw new Error("Failed to create lock file.");
}
}
const clients = await deps.detectClients();
if (clients.length === 0) {
throw new Error("No supported AI clients found.");
}
const serverEntries = filterByProfile(stackFile, options.profile);
if (serverEntries.length === 0) {
deps.output("No servers match the selected profile.");
return;
}
if (options.frozen === true || stackFile.policy?.frozen === true) {
await runFrozenPass(lockFile, deps);
}
const envFileVars = options.allowEnvFile === false ? { vars: {}, warnings: [] } : await parseEnvFile(".env");
const scannerAvailable = await deps.checkScannerAvailable();
if (options.dryRun) {
deps.output("Dry run \u2014 no changes will be made.\n");
}
if (!options.dryRun) {
await backupConfigs(clients, deps);
}
const results = [];
const previousConsented = deps.readUnguardedConsent ? await deps.readUnguardedConsent() : [];
const consentedUnguarded = new Set(previousConsented);
for (const [name, server] of serverEntries) {
const locked = lockFile.servers[name];
try {
const result = await processServer({
name,
server,
locked,
policy: stackFile.policy,
clients,
scannerAvailable,
envFileVars: envFileVars.vars,
consentedUnguarded,
options,
deps
});
results.push(result);
deps.recordResult?.({ name, status: result.status });
deps.output(` ${statusIcon(result.status)} ${name}: ${result.message}`);
} catch (err) {
const failure = {
name,
status: "failed",
message: err instanceof Error ? err.message : String(err)
};
results.push(failure);
deps.recordResult?.({ name, status: "failed" });
deps.output(` ${statusIcon("failed")} ${name}: ${failure.message}`);
}
}
if (options.strict && !options.dryRun) {
await handleStrictRemoval(stackFile, clients, options, deps, results);
}
const urlServerNames = new Set(
serverEntries.filter(([, s]) => isUrlServer(s)).map(([n]) => n)
);
const installedUnguarded = results.filter((r) => r.status === "installed" && urlServerNames.has(r.name)).map((r) => r.name).sort();
if (installedUnguarded.length > 0 && !options.dryRun) {
const newlyConsented = installedUnguarded.filter((n) => !consentedUnguarded.has(n));
if (isNewUnguarded(installedUnguarded, previousConsented)) {
const alreadyCount = installedUnguarded.length - newlyConsented.length;
const alreadyNote = alreadyCount > 0 ? ` (+${alreadyCount} previously consented)` : "";
deps.output(
`
\u26A0 UNGUARDED: the following URL/HTTP-transport server(s) now run WITHOUT runtime inspection (no relay wraps a non-stdio transport): ${newlyConsented.join(", ")}${alreadyNote}. This grants consent \u2014 it does NOT add protection. The only true fix is a streamable-HTTP relay (not yet implemented). Future \`up\` runs stay quiet unless a NEW unguarded server appears.`
);
if (deps.recordUnguardedConsent) {
await deps.recordUnguardedConsent(newlyConsented).catch(() => void 0);
}
} else {
deps.output(
`
${installedUnguarded.length} server(s) running unguarded (previously consented): ${installedUnguarded.join(", ")}`
);
}
}
if (options.frozen !== true && stackFile.policy?.frozen !== true) {
await runIntegrityPass(lockFile, deps);
}
let shadowCollisions = 0;
if (options.checkShadowing === true || stackFile.policy?.checkShadowing === true) {
shadowCollisions = await runShadowPass(
serverEntries.map(([name]) => name),
deps
);
}
const installed = results.filter((r) => r.status === "installed").length;
const blocked = results.filter((r) => r.status === "blocked").length;
const failed = results.filter((r) => r.status === "failed").length;
const skipped = results.filter((r) => r.status === "skipped").length;
const removed = results.filter((r) => r.status === "removed").length;
const unguarded = installedUnguarded.length;
deps.output(
`
${installed} installed, ${skipped} skipped, ${blocked} blocked, ${failed} failed` + (removed > 0 ? `, ${removed} removed` : "") + (unguarded > 0 ? `, ${unguarded} unguarded` : "")
);
const totalSecretsStored = results.reduce(
(sum, r) => sum + (r.storedSecrets ?? 0),
0
);
if (options.secrets === "keychain" && totalSecretsStored > 0 && !options.dryRun) {
deps.output(
"Secrets stored encrypted at rest in ~/.mcpm. With an OS keychain this protects against other-user/offline access (not same-user processes); without one a machine-derived key is used that guards casual local inspection only, NOT file exfiltration \u2014 run `mcpm secrets migrate` once a keychain is available. Run `mcpm guard enable` (then restart your IDE) so they resolve at launch."
);
}
if (blocked > 0 || failed > 0) {
throw new Error(`${blocked + failed} server(s) could not be installed.`);
}
if (shadowCollisions > 0 && options.ci) {
throw new Error(
`${shadowCollisions} cross-server tool-name collision(s) detected (--ci). Resolve the shadowing (rename/remove a duplicate tool) or drop --check-shadowing.`
);
}
}
function filterByProfile(stackFile, profile) {
return Object.entries(stackFile.servers).filter(([, server]) => {
const profiles = isRegistryServer(server) || isUrlServer(server) ? server.profiles : void 0;
if (!profiles) return true;
if (!profile) return true;
return profiles.includes(profile);
});
}
async function backupConfigs(clients, deps) {
const { readFile: readFile2, writeFile } = await import("fs/promises");
for (const clientId of clients) {
try {
const adapter = deps.getAdapter(clientId);
const configPath = deps.getPath(clientId);
const content = await readFile2(configPath, "utf-8");
await writeFile(`${configPath}.bak`, content, {
encoding: "utf-8",
mode: 384
});
} catch {
}
}
}
async function processServer(input2) {
const { name, server, locked, policy, clients, scannerAvailable, envFileVars, options, deps } = input2;
if (isUrlServer(server)) {
return processUrlServer(name, server.url, clients, policy, input2.consentedUnguarded, options, deps);
}
if (!locked || !isLockedRegistryServer(locked)) {
return { name, status: "failed", message: "Not found in lock file. Run mcpm lock." };
}
const serverEntry = await deps.getServer(name, locked.version);
const statusGate = assessServerStatus(serverEntry);
if (statusGate.blocks) {
return {
name,
status: "blocked",
message: `deleted from the MCP registry${statusGate.statusMessage ? ` (${statusGate.statusMessage})` : ""}`
};
}
const tier1 = deps.scanTier1(serverEntry);
let findings = [...tier1];
if (scannerAvailable) {
const tier2 = await deps.scanTier2(name);
findings = [...findings, ...tier2];
}
const registryMeta = extractRegistryMeta(serverEntry);
const releaseAge = assessReleaseAge({
publishedAt: registryMeta.publishedAt,
now: (deps.now ?? Date.now)(),
minAgeHours: options.minTrustFloor !== void 0 ? DEFAULT_MIN_RELEASE_AGE_HOURS : policy?.minReleaseAgeHours ?? DEFAULT_MIN_RELEASE_AGE_HOURS
});
if (releaseAge.finding) {
findings = [...findings, releaseAge.finding];
}
const trustInput = {
findings,
healthCheckPassed: null,
hasExternalScanner: scannerAvailable,
registryMeta
};
const trustScore = deps.computeTrustScore(trustInput);
const nativeTrust = nativeTrustScore(trustScore);
if (options.minTrustFloor !== void 0 && nativeTrust.score < options.minTrustFloor) {
return {
name,
status: "blocked",
message: `trust score ${nativeTrust.score}/${nativeTrust.maxPossible} is below the required floor of ${options.minTrustFloor}` + (nativeTrust.excludedExternalCredit > 0 ? ` (the external scanner's ${nativeTrust.excludedExternalCredit} points do not count toward the floor)` : "")
};
}
const policyResult = checkTrustPolicy({
serverName: name,
currentScore: trustScore.score,
currentMaxPossible: trustScore.maxPossible,
// TODOS #35: the blockOnScoreDrop tripwire compares native evidence, so a fake
// MCPM_EXTERNAL_SCANNER cannot mask a drop. `nativeTrust` is computed above.
currentNativeScore: nativeTrust.score,
currentNativeMaxPossible: nativeTrust.maxPossible,
lockedSnapshot: locked.trust,
policy,
releaseAge: {
ageHours: releaseAge.ageHours,
status: releaseAge.status,
blocksArmedGate: releaseAge.blocksArmedGate
},
hasInstallScriptFindings: findings.some((f) => f.type === "install-script")
});
if (!policyResult.pass) {
return { name, status: "blocked", message: policyResult.reason };
}
if (options.dryRun) {
return {
name,
status: "skipped",
message: `would install v${locked.version} (trust: ${trustFigure(trustScore, options)})`
};
}
const { env: envVars, storedCount } = await resolveEnvVars(name, server, envFileVars, options, deps);
const installedClients = [];
const clientErrors = [];
for (const clientId of clients) {
try {
const entry = resolveInstallEntry(serverEntry, clientId);
const entryWithEnv = {
...entry,
...Object.keys(envVars).length > 0 ? { env: { ...entry.env, ...envVars } } : {}
};
const adapter = deps.getAdapter(clientId);
const configPath = deps.getPath(clientId);
await adapter.addServer(configPath, name, entryWithEnv, { force: true });
installedClients.push(clientId);
} catch (err) {
clientErrors.push(`${clientId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (installedClients.length === 0) {
return {
name,
status: "failed",
message: `could not write to any client (${clientErrors.join("; ")})`
};
}
const partialNote = clientErrors.length > 0 ? ` (warning: failed on ${clientErrors.join("; ")})` : "";
return {
name,
status: "installed",
message: `v${locked.version} (trust: ${trustFigure(trustScore, options)})${partialNote}`,
storedSecrets: storedCount
};
}
async function runIntegrityPass(lockFile, deps) {
const c = await classifyIntegrity(lockFile, deps.fetchNpmIntegrity);
for (const d of c.drift) {
const oldShort = d.oldIntegrity.slice(0, 16);
const newShort = d.newIntegrity.slice(0, 16);
deps.output(
`
\u26A0 INTEGRITY DRIFT: npm's published record for ${d.identifier}@${d.npmVersion} changed since you locked it (dist.integrity ${oldShort}\u2026 \u2192 ${newShort}\u2026). A published version's integrity is meant to be immutable, so this can mean a supply-chain republish \u2014 but it can also be a legitimate republish or a different registry. mcpm checks the registry's published record, not the code your agent runs. This is a warning only \u2014 it does not block \`mcpm up\`; npx/uvx fetch and run the actual package independently when the server starts (possibly from a different mirror). Re-run \`mcpm lock\` if this change is expected.`
);
}
for (const f of c.formatOnly) {
deps.output(
`
\u26A0 ${f.name}: npm changed the integrity format for ${f.identifier}@${f.npmVersion}, so mcpm cannot compare its published record against your locked baseline (mcpm checks the registry's published record, not the code your agent runs). Re-run \`mcpm lock\` to refresh the baseline.`
);
}
if (c.couldNotVerify.length > 0) {
deps.output(
`
could not verify npm integrity for ${c.couldNotVerify.length} server(s) this run (no drift result is not proof of integrity).`
);
}
if (c.absentBaseline.length > 0) {
deps.output(
`
integrity baseline missing for ${c.absentBaseline.length} npm server(s) \u2014 re-run \`mcpm lock\` with network access to enable drift detection.`
);
}
}
async function runFrozenPass(lockFile, deps) {
const fetchIntegrity = memoizeIntegrity(deps.fetchNpmIntegrity);
const [v, pv] = await Promise.all([
classifyIntegrity(lockFile, fetchIntegrity).then(frozenVerdict),
classifyProvenance(lockFile, fetchIntegrity, deps.fetchNpmProvenance)
]);
const provBlocks = pv.blocks;
if (v.unenforceable.length > 0) {
deps.output(
`
${v.unenforceable.length} server(s) (pypi/oci/url) have no integrity baseline mechanism \u2014 \`--frozen\` cannot enforce them (multi-registry pinning is deferred).`
);
}
if (v.noBaselines && provBlocks.length === 0) {
throw new Error(
"--frozen: this lock has no integrity baselines (it predates them, or was last locked offline). Run `mcpm lock` online once to record them, then `mcpm up --frozen`."
);
}
if (v.ok && provBlocks.length === 0) return;
const integrityMessages = v.blocks.map((b) => {
switch (b.reason) {
case "drift":
return `\u2717 FROZEN: npm's published record for ${b.identifier}@${b.npmVersion} changed since you locked it (dist.integrity ${b.oldIntegrity.slice(0, 16)}\u2026 \u2192 ${b.newIntegrity.slice(0, 16)}\u2026). --frozen refuses to install on integrity drift. Re-pin with \`mcpm lock\` only if this change is expected.`;
case "format":
return `\u2717 FROZEN: cannot compare npm's published record for ${b.identifier}@${b.npmVersion} against your locked baseline (integrity format changed). Re-run \`mcpm lock\` to refresh it.`;
case "could-not-verify":
return `\u2717 FROZEN: could not verify npm's published record for ${b.identifier}@${b.npmVersion} this run (offline, a yanked version, or no comparable dist.integrity). --frozen requires proof the record matches your lock \u2014 this may be a transient registry error, so re-run; if it persists, drop --frozen.`;
case "missing-baseline":
return `\u2717 FROZEN: no integrity baseline recorded for ${b.name}, though other servers in this lock have one. Re-run \`mcpm lock\` online to record it, then \`mcpm up --frozen\`.`;
default: {
const _never = b;
throw new Error(`unhandled frozen block reason: ${JSON.stringify(_never)}`);
}
}
});
const provenanceMessages = provBlocks.map(frozenProvenanceMessage);
const noticeMessages = v.noBaselines ? [
"\u26A0 FROZEN: this lock has no integrity baselines (predates them / locked offline) \u2014 run `mcpm lock` online to record them."
] : [];
const allMessages = [...noticeMessages, ...integrityMessages, ...provenanceMessages];
deps.output(`
${allMessages.join("\n")}`);
deps.output("\nmcpm verifies the registry's published record, not the code your agent runs at launch.");
const failed = /* @__PURE__ */ new Set([...v.blocks.map((b) => b.name), ...provBlocks.map((b) => b.name)]);
throw new Error(
`frozen: ${failed.size} server(s) failed verification; nothing was installed.`
);
}
function frozenProvenanceMessage(b) {
switch (b.reason) {
case "signer-changed":
return `\u2717 FROZEN: the cryptographic signer for ${b.identifier}@${b.npmVersion} changed since you locked it (${b.detail}). --frozen refuses to install on a provenance signer swap. Re-pin with \`mcpm lock\` only if this re-sign is expected.`;
case "regression":
return `\u2717 FROZEN: provenance for ${b.identifier}@${b.npmVersion} regressed \u2014 it cryptographically verified when you locked it and no longer does (${b.detail}). --frozen refuses to install. If npm's record is unchanged, your mcpm/@sigstore version may have changed since you locked (e.g. after an mcpm upgrade); if that regression is expected, remove this server's stale lock entry and re-lock to re-baseline (a plain \`mcpm lock\` keeps the prior verified baseline).`;
case "unverifiable":
return `\u2717 FROZEN: could not cryptographically re-verify provenance for ${b.identifier}@${b.npmVersion} this run (${b.detail}). --frozen requires proof the attestation still verifies \u2014 this may be a transient error, so re-run; if it persists, investigate before dropping --frozen.`;
default: {
const _never = b.reason;
throw new Error(`unhandled provenance block reason: ${JSON.stringify(_never)}`);
}
}
}
async function runShadowPass(serverNames, deps) {
if (deps.readPins === void 0) {
deps.output("\n\u26A0 shadow check skipped: no pins reader available in this context.");
return 0;
}
let pins;
try {
pins = await deps.readPins();
} catch {
deps.output(
"\n\u26A0 shadow check skipped: ~/.mcpm/pins.json is unreadable (integrity check or corruption)."
);
return 0;
}
const findings = detectNameCollisions(buildInventoryFromPins(pins, serverNames));
const noBaseline = serversWithoutBaseline(pins, serverNames);
const checked = serverNames.length - noBaseline.length;
deps.output(
`
Shadow check: compared guarded tool inventories for ${checked} of ${serverNames.length} server(s).`
);
if (noBaseline.length > 0) {
deps.output(
` ${noBaseline.length} server(s) have NO guard baseline yet (${noBaseline.join(", ")}) \u2014 this check cannot see their tools, so a clean result does NOT mean no shadowing. Run them under \`mcpm guard\` (then re-run \`mcpm up\`) to include them.`
);
}
for (const f of findings) {
deps.output(
`
\u26A0 SHADOW: tool "${f.toolName}" is exposed by ${f.servers.length} servers (${f.servers.join(", ")}). A lower-trust server can shadow a tool meant for another, so agent calls to "${f.toolName}" are ambiguous. This can also be benign (two servers of the same kind legitimately export the same tool). Review which server should own it. (Exact-name match only \u2014 a homoglyph/case variant evades this check.)`
);
}
return findings.length;
}
async function processUrlServer(name, url, clients, policy, consentedUnguarded, options, deps) {
if (options.allowUrlServers === false) {
return {
name,
status: "blocked",
message: "URL servers are not permitted via the MCP surface"
};
}
const consented = options.allowUnguarded === true || policy?.allowUrlServers === true || consentedUnguarded.has(name);
if (!consented) {
return {
name,
status: "blocked",
message: "URL/HTTP-transport server runs UNGUARDED \u2014 no runtime inspection is possible (mcpm's guard relay only wraps stdio servers). Re-run with --allow-unguarded or set policy.allowUrlServers: true to install it WITHOUT protection."
};
}
let urlError;
try {
validateRemoteUrl(url);
} catch (err) {
urlError = err instanceof Error ? err.message : String(err);
}
const cursorClients = clients.filter((c) => c === "cursor");
if (cursorClients.length === 0) {
return {
name,
status: "skipped",
message: "URL server \u2014 no Cursor client detected (only Cursor supports URL transport)"
};
}
if (options.dryRun) {
return urlError ? { name, status: "skipped", message: `would reject URL ${url}: ${urlError}` } : { name, status: "skipped", message: `would install URL ${url} to Cursor` };
}
if (urlError) {
return { name, status: "blocked", message: urlError };
}
for (const clientId of cursorClients) {
const adapter = deps.getAdapter(clientId);
const configPath = deps.getPath(clientId);
await adapter.addServer(configPath, name, { url }, { force: true });
}
return { name, status: "installed", message: `URL ${url} \u2192 Cursor` };
}
async function resolveEnvVars(serverName, server, envFileVars, options, deps) {
const envDecl = isRegistryServer(server) || isUrlServer(server) ? server.env : void 0;
if (!envDecl) return { env: {}, storedCount: 0 };
const resolved = {};
const secretKeys = /* @__PURE__ */ new Set();
for (const [key, decl] of Object.entries(envDecl)) {
const fromEnv = options.allowProcessEnv === false ? void 0 : process.env[key];
const fromFile = envFileVars[key];
const fromDefault = decl.default;
let value;
if (fromEnv !== void 0) {
value = fromEnv;
} else if (fromFile !== void 0) {
value = fromFile;
} else if (fromDefault !== void 0) {
value = fromDefault;
} else if (decl.required) {
if (options.ci) {
throw new Error(
`Required env var "${key}" for "${serverName}" is not set. Set it in process.env or .env file (--ci mode, no interactive prompt).`
);
}
value = await deps.promptEnvVar(key, decl.secret);
}
if (value === void 0) continue;
resolved[key] = value;
if (decl.secret) secretKeys.add(key);
}
return applyKeychainSecrets({
serverName,
resolvedEnv: resolved,
isSecret: (key) => secretKeys.has(key),
mode: options.secrets ?? "plaintext",
setSecrets: deps.setSecrets
});
}
async function handleStrictRemoval(stackFile, clients, options, deps, results) {
const declaredNames = new Set(Object.keys(stackFile.servers));
for (const clientId of clients) {
const adapter = deps.getAdapter(clientId);
const configPath = deps.getPath(clientId);
const installed = await adapter.read(configPath);
for (const name of Object.keys(installed)) {
if (declaredNames.has(name)) continue;
if (options.ci && !options.yes) {
throw new Error(
`--strict --ci requires --yes to remove servers not in mcpm.yaml. Server "${name}" in ${clientId} would be removed.`
);
}
if (!options.ci && options.yes !== true) {
const confirmed = await deps.confirm(
`Remove "${name}" from ${clientId}? (not in mcpm.yaml)`
);
if (!confirmed) continue;
}
await adapter.removeServer(configPath, name);
results.push({
name,
status: "removed",
message: `removed from ${clientId} (not in mcpm.yaml)`
});
deps.recordResult?.({ name, status: "removed" });
deps.output(` - ${name}: removed from ${clientId}`);
}
}
}
function statusIcon(status) {
switch (status) {
case "installed":
return "\u2713";
case "removed":
return "\u2212";
case "skipped":
return "\u2022";
case "blocked":
return "\u2717";
case "failed":
return "\u2717";
default:
return "?";
}
}
function registerUpCommand(program) {
program.command("up").description("Install all servers from mcpm.yaml with trust verification").option("-f, --file <path>", "path to mcpm.yaml", "mcpm.yaml").option("-p, --profile <name>", "install only servers matching this profile").option("--dry-run", "show what would be installed without making changes").option("--ci", "CI mode: no interactive prompts, exit nonzero on failure").option("--strict", "remove servers not declared in mcpm.yaml").option("-y, --yes", "skip confirmation prompts (required with --strict --ci)").option("--secrets <mode>", "where to store secret env vars: 'keychain' (encrypted in ~/.mcpm, resolved by mcpm guard at launch) or 'plaintext' (default); 'keychain' is rejected with --ci", parseSecretsMode).option("--allow-unguarded", "permit URL/HTTP-transport servers to run WITHOUT runtime guard inspection (no relay wraps a non-stdio transport); records consent so future runs stay quiet").option("--check-shadowing", "report tool-name collisions across guarded servers (a shadowing signal); advisory interactively, exits nonzero under --ci").option("--frozen", "fail closed: BEFORE installing, verify every locked npm server's published integrity AND re-verify Sigstore provenance for crypto-verified servers, then BLOCK (install nothing, exit nonzero) on integrity drift / provenance regression / unverifiable / missing baseline \u2014 the CI supply-chain freeze gate").action(
async (opts) => {
const client = new RegistryClient();
const { readUnguardedConsent, writeUnguardedConsent, mergeUnguarded } = await import("./unguarded-GJO5WRM7.js");
try {
await handleUp(
{
stackFile: opts.file,
profile: opts.profile,
dryRun: opts.dryRun,
ci: opts.ci,
strict: opts.strict,
yes: opts.yes,
secrets: opts.secrets,
allowUnguarded: opts.allowUnguarded,
checkShadowing: opts.checkShadowing,
frozen: opts.frozen
},
{
detectClients: detectInstalledClients,
getAdapter,
getPath: getConfigPath,
getServer: (name, version) => client.getServer(name, version),
scanTier1,
checkScannerAvailable,
scanTier2: (name) => scanTier2(name),
computeTrustScore,
now: () => Date.now(),
runLock: async (stackFile) => {
const { writeFile } = await import("fs/promises");
await handleLock(
{ stackFile },
{
getServerVersions: (name) => client.getServerVersions(name),
getServer: (name, v) => client.getServer(name, v),
scanTier1,
checkScannerAvailable,
scanTier2: (name) => scanTier2(name),
computeTrustScore,
now: () => Date.now(),
writeLockFile: (path, content) => writeFile(path, content, { encoding: "utf-8", mode: 384 }),
fetchNpmIntegrity,
// F8/B3: auto-lock must record the crypto-`verified` provenance
// baseline too, or the verify-time gate is vacuous for up-locked repos.
fetchNpmProvenance: (id, ver, sri) => fetchNpmProvenance(id, ver, { integritySri: sri }),
output: stdoutOutput
}
);
},
confirm,
promptEnvVar: async (name, isSecret) => {
if (isSecret) {
return password({ message: `${name}:` });
}
return input({ message: `${name}:` });
},
output: stdoutOutput,
setSecrets,
fetchNpmIntegrity,
fetchNpmProvenance: (id, v, o) => fetchNpmProvenance(id, v, o),
readPins,
readUnguardedConsent,
recordUnguardedConsent: async (names) => {
const previous = await readUnguardedConsent();
await writeUnguardedConsent(mergeUnguarded(previous, names));
}
}
);
} catch (err) {
console.error(chalk.red(err.message));
process.exit(1);
}
}
);
}
export {
memoizeIntegrity,
classifyIntegrity,
frozenVerdict,
classifyProvenance,
handleUp,
registerUpCommand
};
//# sourceMappingURL=chunk-EF3HQG5R.js.map

Sorry, the diff of this file is too big to display

#!/usr/bin/env node
import {
DEFAULT_MIN_RELEASE_AGE_HOURS,
assessReleaseAge,
stdoutOutput
} from "./chunk-E3T224S3.js";
import {
DANGEROUS_FLAG_PREFIXES,
argvTokens,
assessServerStatus,
extractRegistryMeta,
levelColor,
scanTier1,
scoreBar
} from "./chunk-U7N6FRYF.js";
import {
checkScannerAvailable,
scanTier2
} from "./chunk-F6CHEUGO.js";
import {
getAdapter
} from "./chunk-W4IAFBUN.js";
import {
confirm
} from "./chunk-2PWW3Q5Q.js";
import {
computeTrustScore,
externalCredited,
maxAchievableBeforeHealthCheck
} from "./chunk-Y36MYEDT.js";
import {
applyKeychainSecrets,
setSecrets
} from "./chunk-NPJ3SGGS.js";
import {
detectInstalledClients
} from "./chunk-6R7TL5O2.js";
import {
CLIENT_IDS,
getConfigPath
} from "./chunk-R4R2VPDA.js";
import {
addInstalledServer
} from "./chunk-4QDJ3I7X.js";
// src/commands/install.ts
import { InvalidArgumentError } from "commander";
import chalk from "chalk";
import { input, password } from "@inquirer/prompts";
function validateRemoteUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid remote URL: "${url}"`);
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new Error(
`Remote URL must use http or https protocol, got: "${parsed.protocol}"`
);
}
if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
throw new Error(
`Remote URL must use https for non-loopback hosts (plaintext http is vulnerable to interception), got: "${url}"`
);
}
}
function isLoopbackHost(hostname) {
const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return h === "localhost" || h.endsWith(".localhost") || h === "127.0.0.1" || h === "::1";
}
var NPM_IDENTIFIER_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
var PYPI_IDENTIFIER_RE = /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/;
var OCI_IDENTIFIER_RE = /^[a-z0-9]+([._-][a-z0-9]+)*(\/[a-z0-9]+([._-][a-z0-9]+)*)*:[a-zA-Z0-9._-]+$/;
function validateIdentifier(identifier, registryType) {
const patterns = {
npm: NPM_IDENTIFIER_RE,
pypi: PYPI_IDENTIFIER_RE,
oci: OCI_IDENTIFIER_RE
};
const re = patterns[registryType];
if (re && !re.test(identifier)) {
throw new Error(
`Rejected potentially malicious ${registryType} identifier: "${identifier}"`
);
}
}
function normalizeRuntimeArgs(args) {
return args.flatMap(argvTokens);
}
var SAFE_ARG_PATTERNS = [
// Generic boolean flags (--allow-write, --read-only, --no-sandbox, etc.)
/^--[a-zA-Z][\w-]*$/,
// Single-dash short flags the live registry legitimately declares (-i, -y, -p).
// EXACTLY one alpha char — no bundled tail. Allowing a tail (-rmodule, -eCODE)
// would let a dangerous flag bundle its payload and slip past the Layer-1
// DANGEROUS_FLAG_PREFIXES check, which only rejects the exact token (-e/-r) or
// its '=' form. The live registry's short flags are all single-letter, so the
// narrow form loses no real coverage while closing the bundling bypass.
/^-[a-zA-Z]$/,
// Generic --key=value flags with safe value characters
// Blocks shell metacharacters: ; | $ ` & ( ) { } < > ! ' "
/^--[a-zA-Z][\w-]+=[\w./@:, -]+$/,
// Bare absolute paths (Unix: /path/to/dir)
/^\/[\w.@/ -]+$/,
// Home-relative paths (~/Documents)
/^~[\w.@/ -]*$/,
// Bare positional arguments (no dashes, no path traversal)
/^[a-zA-Z0-9][\w.@/-]*$/
];
function validateRuntimeArgs(args) {
for (const arg of args) {
if (/(?:^|[=\\/])\.\.(?:[\\/]|$)/.test(arg)) {
throw new Error(`Rejected path traversal in runtime argument: "${arg}"`);
}
const isDangerous = DANGEROUS_FLAG_PREFIXES.some(
(prefix) => arg === prefix || arg.startsWith(`${prefix}=`)
);
if (isDangerous) {
throw new Error(`Rejected dangerous runtime argument: "${arg}"`);
}
const isSafe = SAFE_ARG_PATTERNS.some((pattern) => pattern.test(arg));
if (!isSafe) {
throw new Error(`Rejected unrecognized runtime argument: "${arg}"`);
}
}
}
function resolveInstallEntry(serverEntry, clientId) {
const { server } = serverEntry;
if (clientId === "cursor" && server.remotes && server.remotes.length > 0) {
const httpRemote = server.remotes.find(
(r) => r.type === "streamable-http" || r.type === "sse"
);
if (httpRemote) {
validateRemoteUrl(httpRemote.url);
const headers = {};
for (const h of httpRemote.headers) {
headers[h.name] = "";
}
return {
url: httpRemote.url,
...Object.keys(headers).length > 0 ? { headers } : {}
};
}
}
const npmPkg = server.packages.find((p) => p.registryType === "npm");
const pypiPkg = server.packages.find((p) => p.registryType === "pypi");
const ociPkg = server.packages.find((p) => p.registryType === "oci");
if (npmPkg) {
validateIdentifier(npmPkg.identifier, "npm");
const rtArgs = normalizeRuntimeArgs(npmPkg.runtimeArguments ?? []);
validateRuntimeArgs(rtArgs);
return {
command: "npx",
args: ["-y", npmPkg.identifier, ...rtArgs]
};
}
if (pypiPkg) {
validateIdentifier(pypiPkg.identifier, "pypi");
const rtArgs = normalizeRuntimeArgs(pypiPkg.runtimeArguments ?? []);
validateRuntimeArgs(rtArgs);
return {
command: "uvx",
args: [pypiPkg.identifier, ...rtArgs]
};
}
if (ociPkg) {
validateIdentifier(ociPkg.identifier, "oci");
const rtArgs = normalizeRuntimeArgs(ociPkg.runtimeArguments ?? []);
validateRuntimeArgs(rtArgs);
return {
command: "docker",
args: ["run", "--rm", "-i", ociPkg.identifier, ...rtArgs]
};
}
if (clientId === "cursor" && server.remotes && server.remotes.length > 0) {
const remote = server.remotes[0];
validateRemoteUrl(remote.url);
return { url: remote.url };
}
throw new Error(
`No install path found for server "${server.name}": no packages and no compatible remotes.`
);
}
function formatTrustScore(trustScore) {
const { score, maxPossible, level, breakdown } = trustScore;
const levelLabel = levelColor(level.toUpperCase());
const bar = scoreBar(score, maxPossible);
const lines = [
`${bar} ${score}/${maxPossible} ${levelLabel}`,
` \u251C\u2500 Health check: ${breakdown.healthCheck > 0 ? "not yet run" : "failed or skipped"}`,
` \u251C\u2500 Tool descriptions: ${breakdown.staticScan === 40 ? "CLEAN (no injection patterns)" : `score ${breakdown.staticScan}/40`}`,
` \u251C\u2500 Package: publisher verification ${breakdown.registryMeta > 0 ? "passed" : "unverified"}`,
` \u2514\u2500 External scan: ${breakdown.externalScan > 0 ? `passed (${breakdown.externalScan}/20)` : "not available (set MCPM_EXTERNAL_SCANNER for deeper analysis)"}`
];
return lines.join("\n");
}
async function handleInstall(name, options, deps) {
const {
registryClient,
detectClients,
getAdapter: getAdapter2,
getConfigPath: getConfigPath2,
scanTier1: scanTier12,
checkScannerAvailable: checkScannerAvailable2,
scanTier2: scanTier22,
computeTrustScore: computeTrustScore2,
addToStore,
confirm: confirm2,
promptEnvVars,
output
} = deps;
const serverEntry = await registryClient.getServer(name);
const statusGate = assessServerStatus(serverEntry);
if (statusGate.blocks) {
if (options.json === true) {
output(
JSON.stringify(
{
name,
error: "server_delisted",
status: statusGate.status,
message: statusGate.statusMessage ?? null
},
null,
2
)
);
}
throw new Error(
`"${name}" has been deleted from the MCP registry${statusGate.statusMessage ? ` (${statusGate.statusMessage})` : ""}. Installation aborted.`
);
}
const tier1Findings = scanTier12(serverEntry);
const scannerAvailable = await checkScannerAvailable2();
let allFindings = [...tier1Findings];
if (scannerAvailable) {
const tier2Findings = await scanTier22(name);
allFindings = [...allFindings, ...tier2Findings];
}
const registryMeta = extractRegistryMeta(serverEntry);
const releaseAge = assessReleaseAge({
publishedAt: registryMeta.publishedAt,
now: (deps.now ?? Date.now)(),
minAgeHours: options.minReleaseAge ?? DEFAULT_MIN_RELEASE_AGE_HOURS
});
if (releaseAge.finding) {
allFindings = [...allFindings, releaseAge.finding];
}
const trustScoreInput = {
findings: allFindings,
healthCheckPassed: null,
// health check not yet run at this point
hasExternalScanner: scannerAvailable,
registryMeta
};
const trustScore = computeTrustScore2(trustScoreInput);
if (options.minTrust !== void 0) {
const ceiling = maxAchievableBeforeHealthCheck(externalCredited(trustScore));
if (options.minTrust > ceiling.score) {
if (options.json === true) {
output(
JSON.stringify(
{ name, error: "min_trust_unsatisfiable", required: options.minTrust, ceiling: ceiling.score, maxPossible: ceiling.maxPossible },
null,
2
)
);
}
throw new Error(
`--min-trust ${options.minTrust} is above ${ceiling.score}, the highest score \`mcpm install\` can award here. Trust is scored BEFORE the health check runs and mcpm reads no download count, so ${ceiling.maxPossible - ceiling.score} of the ${ceiling.maxPossible} points are unreachable at install time. "${name}" scored ${trustScore.score}/${trustScore.maxPossible}. Use --min-trust ${trustScore.score} or lower to gate on evidence rather than on what install cannot measure, or run \`mcpm audit\` after installing to score it with more of the picture.`
);
}
}
if (options.minTrust !== void 0 && trustScore.score < options.minTrust) {
if (options.json === true) {
output(
JSON.stringify(
{
name,
error: "min_trust_not_met",
score: trustScore.score,
maxPossible: trustScore.maxPossible,
required: options.minTrust,
level: trustScore.level
},
null,
2
)
);
}
throw new Error(
`Trust score ${trustScore.score}/${trustScore.maxPossible} is below the required minimum of ${options.minTrust}. Installation aborted.`
);
}
if (options.minReleaseAge !== void 0 && options.allowFresh !== true && releaseAge.blocksArmedGate) {
if (options.json === true) {
output(
JSON.stringify(
{
name,
error: "release_age_not_met",
ageHours: releaseAge.ageHours,
required: options.minReleaseAge,
reason: releaseAge.status
},
null,
2
)
);
}
const tail = "Installation aborted. Use --allow-fresh to bypass.";
throw new Error(
releaseAge.status === "future" ? `Release publish timestamp is in the future (clock skew or forged metadata); treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}` : releaseAge.status === "unparseable" ? `Release publish timestamp could not be parsed; treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}` : releaseAge.status === "absent" ? `Release publish timestamp is missing from the registry metadata, so release age cannot be verified against the ${options.minReleaseAge}-hour minimum. ${tail}` : `Release age ${releaseAge.ageHours}h is below the required minimum of ${options.minReleaseAge}h. ${tail}`
);
}
const jsonMode = options.json === true;
if (!jsonMode) {
output(formatTrustScore(trustScore));
output("");
}
if (options.yes !== true) {
let shouldProceed;
if (trustScore.level === "risky") {
if (!jsonMode) {
output("\x1B[31mWARNING: This server has a low trust score and may be risky to install.\x1B[0m");
output("\x1B[31mSecurity findings indicate potential dangers. Proceed with extreme caution.\x1B[0m");
}
shouldProceed = await confirm2(
"I understand the risks and want to install this server anyway. Continue?"
);
} else if (trustScore.level === "caution") {
if (!jsonMode) {
output("\x1B[33mCAUTION: This server has a moderate trust score. Review the details above.\x1B[0m");
}
shouldProceed = await confirm2(`Install '${name}'? (caution recommended)`);
} else {
shouldProceed = await confirm2(`Install '${name}'?`);
}
if (!shouldProceed) {
if (!jsonMode) output("Installation cancelled.");
return;
}
}
let targetClients = await detectClients();
if (targetClients.length === 0) {
throw new Error(
"No supported AI clients found. Install Claude Desktop, Cursor, VS Code, or Windsurf first."
);
}
if (options.client !== void 0) {
if (!CLIENT_IDS.includes(options.client)) {
throw new Error(
`Unknown client "${options.client}". Valid values: ${CLIENT_IDS.join(", ")}.`
);
}
const requestedId = options.client;
if (!targetClients.includes(requestedId)) {
throw new Error(
`Client "${requestedId}" is not installed on this machine.`
);
}
targetClients = [requestedId];
}
if (options.force !== true) {
for (const clientId of targetClients) {
const adapter = getAdapter2(clientId);
const configPath = getConfigPath2(clientId);
const existing = await adapter.read(configPath);
if (Object.prototype.hasOwnProperty.call(existing, name)) {
throw new Error(
`Server '${name}' is already installed in ${clientId}. Use --force to overwrite.`
);
}
}
}
const { server } = serverEntry;
const bestPkg = server.packages.find((p) => p.registryType === "npm") ?? server.packages.find((p) => p.registryType === "pypi") ?? server.packages.find((p) => p.registryType === "oci") ?? server.packages[0];
const envVarDefs = bestPkg?.environmentVariables ?? [];
const resolvedEnvVars = await promptEnvVars(envVarDefs);
const secretsMode = options.secrets ?? "plaintext";
const { env: envForConfig, storedCount: storedSecretCount } = await applyKeychainSecrets({
serverName: name,
resolvedEnv: resolvedEnvVars,
isSecret: (key) => envVarDefs.find((d) => d.name === key)?.isSecret === true,
mode: secretsMode,
setSecrets: deps.setSecrets
});
const resolvedEntries = /* @__PURE__ */ new Map();
for (const clientId of targetClients) {
resolvedEntries.set(clientId, resolveInstallEntry(serverEntry, clientId));
}
const isUnguardedEntry = [...resolvedEntries.values()].some(
(e) => e.url !== void 0 && e.command === void 0
);
if (isUnguardedEntry) {
if (options.allowUrlServers === false) {
throw new Error(
`Server '${name}' uses a URL/HTTP transport and is not permitted via the MCP surface.`
);
}
const previousConsented = deps.readUnguardedConsent ? await deps.readUnguardedConsent() : [];
const alreadyConsented = previousConsented.includes(name);
const consented = options.allowUnguarded === true || alreadyConsented;
if (!consented) {
throw new Error(
`Server '${name}' uses a URL/HTTP transport and runs UNGUARDED \u2014 no runtime inspection is possible (mcpm's guard relay only wraps stdio servers). Re-run with --allow-unguarded to install it WITHOUT protection.`
);
}
if (!alreadyConsented) {
if (!jsonMode) {
output(
"\x1B[33m\u26A0 UNGUARDED: this URL/HTTP-transport server runs WITHOUT runtime inspection (the guard relay only wraps stdio servers). This grants consent \u2014 it does NOT add protection. The only true fix is a streamable-HTTP relay (not yet implemented).\x1B[0m"
);
}
if (deps.recordUnguardedConsent) {
await deps.recordUnguardedConsent([name]).catch(() => void 0);
}
}
}
const installedClients = [];
for (const clientId of targetClients) {
const adapter = getAdapter2(clientId);
const configPath = getConfigPath2(clientId);
const rawEntry = resolvedEntries.get(clientId);
const entry = {
...rawEntry,
...Object.keys(envForConfig).length > 0 ? { env: { ...rawEntry.env ?? {}, ...envForConfig } } : {}
};
await adapter.addServer(configPath, name, entry, { force: options.force });
installedClients.push(clientId);
}
if (!options.json) {
if (secretsMode === "keychain" && storedSecretCount > 0) {
output(
`\x1B[32mStored ${storedSecretCount} secret(s) encrypted at rest in ~/.mcpm. With an OS keychain this protects against other-user/offline access (not same-user processes); without one a machine-derived key is used that guards casual local inspection only, NOT file exfiltration \u2014 run \`mcpm secrets migrate\` once a keychain is available. Run \`mcpm guard enable\` (then restart your IDE) so they resolve at launch \u2014 until guard wraps this server it receives the literal placeholder.\x1B[0m`
);
} else {
const hasSecrets = envVarDefs.some((ev) => ev.isSecret && resolvedEnvVars[ev.name]);
if (hasSecrets) {
output(
"\x1B[33mNote: API keys are stored as plaintext in client config files. Ensure config files have appropriate permissions (chmod 600).\x1B[0m"
);
}
}
}
const storeEntry = {
name,
version: serverEntry.server.version,
clients: [...installedClients],
installedAt: (/* @__PURE__ */ new Date()).toISOString()
};
await addToStore(storeEntry);
if (options.json === true) {
const result = {
name,
version: serverEntry.server.version,
clients: installedClients,
trustScore: {
score: trustScore.score,
maxPossible: trustScore.maxPossible,
level: trustScore.level
}
};
output(JSON.stringify(result, null, 2));
return;
}
const clientList = installedClients.join(", ");
output(`\x1B[32mInstalled '${name}' successfully into: ${clientList}\x1B[0m`);
}
async function promptEnvVarsDefault(vars) {
if (vars.length === 0) return {};
const result = {};
for (const envVar of vars) {
if (!envVar.isRequired && !envVar.isSecret) continue;
const defaultVal = envVar.default ?? "";
const promptMessage = envVar.description ? `${envVar.name} (${envVar.description}):` : `${envVar.name}:`;
let prompted;
if (envVar.isSecret) {
prompted = await password({ message: promptMessage });
if (!prompted && defaultVal) {
prompted = defaultVal;
}
} else {
prompted = await input({ message: promptMessage, default: defaultVal });
}
if (prompted) {
result[envVar.name] = prompted;
}
}
return result;
}
function parseSecretsMode(raw) {
if (raw !== "keychain" && raw !== "plaintext") {
throw new InvalidArgumentError(
`--secrets must be "keychain" or "plaintext", got: "${raw}"`
);
}
return raw;
}
function parseMinTrust(raw) {
if (!/^\d+$/.test(raw)) {
throw new InvalidArgumentError(
`--min-trust must be an integer between 0 and 100, got: "${raw}"`
);
}
const n = Number(raw);
if (n < 0 || n > 100) {
throw new InvalidArgumentError(
`--min-trust must be an integer between 0 and 100, got: "${raw}"`
);
}
return n;
}
function parseMinReleaseAge(raw) {
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) {
throw new InvalidArgumentError(
`--min-release-age must be a non-negative integer number of hours, got: "${raw}"`
);
}
return Number(raw);
}
function registerInstallCommand(program) {
program.command("install <name>").description("Install an MCP server from the registry").option("-c, --client <id>", "install to a specific client only").option("-y, --yes", "skip all confirmation prompts").option("-f, --force", "overwrite if server already installed").option("--skip-health-check", "skip post-install health check").option("--json", "output result as JSON").option("--min-trust <n>", "abort install if pre-install trust score is below this threshold; scored before the health check runs, so a threshold above what install can award is refused as unsatisfiable rather than applied", parseMinTrust).option("--min-release-age <hours>", "abort install if the release is younger than this many hours OR its publish timestamp is missing/unparseable (fail-closed when set; also sets the scoring cooldown threshold; bypass with --allow-fresh)", parseMinReleaseAge).option("--allow-fresh", "bypass the --min-release-age gate (including the missing-timestamp block)").option("--secrets <mode>", "where to store secret env vars: 'keychain' (encrypted in ~/.mcpm, resolved by mcpm guard at launch) or 'plaintext' (default)", parseSecretsMode).option("--allow-unguarded", "permit a URL/HTTP-transport server to run WITHOUT runtime guard inspection (no relay wraps a non-stdio transport); records consent so future installs stay quiet").action(async (name, opts) => {
const { RegistryClient } = await import("./client-3RPMRFZL.js");
const client = new RegistryClient();
const { readUnguardedConsent, writeUnguardedConsent, mergeUnguarded } = await import("./unguarded-GJO5WRM7.js");
const installOptions = {
client: opts.client,
yes: opts.yes,
force: opts.force,
skipHealthCheck: opts.skipHealthCheck,
json: opts.json,
minTrust: opts.minTrust,
minReleaseAge: opts.minReleaseAge,
allowFresh: opts.allowFresh,
secrets: opts.secrets,
allowUnguarded: opts.allowUnguarded
};
const installDeps = {
registryClient: client,
detectClients: detectInstalledClients,
getAdapter,
getConfigPath,
scanTier1,
checkScannerAvailable,
scanTier2: (serverName) => scanTier2(serverName),
computeTrustScore,
addToStore: addInstalledServer,
confirm,
promptEnvVars: promptEnvVarsDefault,
output: stdoutOutput,
setSecrets,
now: () => Date.now(),
readUnguardedConsent,
recordUnguardedConsent: async (names) => {
const previous = await readUnguardedConsent();
await writeUnguardedConsent(mergeUnguarded(previous, names));
}
};
try {
await handleInstall(name, installOptions, installDeps);
} catch (err) {
if (installOptions.json !== true) {
console.error(chalk.red(err.message));
}
process.exit(1);
}
});
}
export {
validateRemoteUrl,
resolveInstallEntry,
parseSecretsMode,
parseMinTrust,
registerInstallCommand
};
//# sourceMappingURL=chunk-HFNKEMBT.js.map
{"version":3,"sources":["../src/commands/install.ts"],"sourcesContent":["/**\n * `mcpm install <name>` command handler.\n *\n * Wires together: registry fetch → trust assessment → user confirmation →\n * client detection → env var prompting → config write → store record.\n *\n * All external dependencies are injected for testability.\n *\n * Exports:\n * - handleInstall() — injectable handler for testing\n * - resolveInstallEntry() — pure function: ServerEntry + ClientId → McpServerEntry\n * - formatTrustScore() — pure function: TrustScore → formatted string\n * - registerInstallCommand() — Commander registration\n */\n\nimport { CLIENT_IDS } from \"../config/paths.js\";\nimport type { ClientId } from \"../config/paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"../config/adapters/index.js\";\nimport type { ServerEntry, EnvVar } from \"../registry/types.js\";\nimport { argvTokens, type RuntimeArgument } from \"../registry/argument-tokens.js\";\nimport type { Finding } from \"../scanner/tier1.js\";\nimport type { TrustScore, TrustScoreInput } from \"../scanner/trust-score.js\";\nimport { externalCredited, maxAchievableBeforeHealthCheck } from \"../scanner/trust-score.js\";\nimport type { InstalledServer } from \"../store/servers.js\";\nimport { scoreBar, levelColor, extractRegistryMeta } from \"../utils/format-trust.js\";\nimport { assessReleaseAge, DEFAULT_MIN_RELEASE_AGE_HOURS } from \"../scanner/cooldown.js\";\nimport { assessServerStatus } from \"../scanner/registry-status.js\";\nimport { DANGEROUS_FLAG_PREFIXES } from \"../scanner/patterns.js\";\nimport { applyKeychainSecrets, type SecretsMode, setSecrets as _setSecrets } from \"../store/keychain.js\";\n\n// ---------------------------------------------------------------------------\n// URL validation — guard against malicious remote URLs\n// ---------------------------------------------------------------------------\n\n/**\n * Validate a remote URL before it is written to any IDE config file.\n * Only http: and https: protocols are permitted.\n */\nexport function validateRemoteUrl(url: string): void {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(`Invalid remote URL: \"${url}\"`);\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") {\n throw new Error(\n `Remote URL must use http or https protocol, got: \"${parsed.protocol}\"`\n );\n }\n // M4a: plaintext http to a non-loopback host is interceptable once written to an\n // IDE config. Allow http only for loopback (local dev servers); require https for\n // every other host. https is always allowed.\n if (parsed.protocol === \"http:\" && !isLoopbackHost(parsed.hostname)) {\n throw new Error(\n `Remote URL must use https for non-loopback hosts (plaintext http is ` +\n `vulnerable to interception), got: \"${url}\"`\n );\n }\n}\n\n/**\n * True for localhost / loopback literals, where plaintext http is acceptable.\n * Recognizes localhost / *.localhost / 127.0.0.1 / ::1. Exotic loopback spellings\n * (IPv4-mapped `::ffff:127.0.0.1`, `127.x.x.x`, decimal/octal/hex IPs) are NOT\n * recognized and fall through to the https requirement — over-rejection only, never\n * a bypass (a non-loopback host can never be mistaken for loopback).\n */\nfunction isLoopbackHost(hostname: string): boolean {\n const h = hostname.toLowerCase().replace(/^\\[|\\]$/g, \"\"); // strip IPv6 brackets\n return (\n h === \"localhost\" ||\n h.endsWith(\".localhost\") ||\n h === \"127.0.0.1\" ||\n h === \"::1\"\n );\n}\n\nconst NPM_IDENTIFIER_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*$/;\nconst PYPI_IDENTIFIER_RE = /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/;\nconst OCI_IDENTIFIER_RE =\n /^[a-z0-9]+([._-][a-z0-9]+)*(\\/[a-z0-9]+([._-][a-z0-9]+)*)*:[a-zA-Z0-9._-]+$/;\n\n/**\n * Validate a package identifier against the expected pattern for its registry\n * type. Throws if the identifier looks potentially malicious.\n */\nexport function validateIdentifier(identifier: string, registryType: string): void {\n const patterns: Record<string, RegExp> = {\n npm: NPM_IDENTIFIER_RE,\n pypi: PYPI_IDENTIFIER_RE,\n oci: OCI_IDENTIFIER_RE,\n };\n const re = patterns[registryType];\n if (re && !re.test(identifier)) {\n throw new Error(\n `Rejected potentially malicious ${registryType} identifier: \"${identifier}\"`\n );\n }\n}\n\n/**\n * Render runtimeArguments from the registry into a launch argv slice.\n *\n * Delegates to argvTokens (name + value, never valueHint) so the SAME function\n * defines both what gets executed here and what the F4 dangerous-flag scan\n * matches in scanner/patterns.ts — they cannot diverge. valueHint (a\n * documentation placeholder like \"directory\") is deliberately not rendered:\n * emitting it would inject a bogus literal argument. The injection scanner\n * (scanner/tier1.ts) uses argumentTokens instead, which DOES read valueHint as\n * user-facing text; that divergence is intentional and documented there.\n */\nfunction normalizeRuntimeArgs(\n args: ReadonlyArray<RuntimeArgument>\n): string[] {\n return args.flatMap(argvTokens);\n}\n\n/**\n * Allowlist of safe runtime argument shapes.\n * After dangerous flags are rejected, arguments must match one of these\n * patterns. This blocks shell metacharacters and path traversal while\n * allowing the wide range of flags real MCP servers use.\n */\nconst SAFE_ARG_PATTERNS: readonly RegExp[] = [\n // Generic boolean flags (--allow-write, --read-only, --no-sandbox, etc.)\n /^--[a-zA-Z][\\w-]*$/,\n // Single-dash short flags the live registry legitimately declares (-i, -y, -p).\n // EXACTLY one alpha char — no bundled tail. Allowing a tail (-rmodule, -eCODE)\n // would let a dangerous flag bundle its payload and slip past the Layer-1\n // DANGEROUS_FLAG_PREFIXES check, which only rejects the exact token (-e/-r) or\n // its '=' form. The live registry's short flags are all single-letter, so the\n // narrow form loses no real coverage while closing the bundling bypass.\n /^-[a-zA-Z]$/,\n // Generic --key=value flags with safe value characters\n // Blocks shell metacharacters: ; | $ ` & ( ) { } < > ! ' \"\n /^--[a-zA-Z][\\w-]+=[\\w./@:, -]+$/,\n // Bare absolute paths (Unix: /path/to/dir)\n /^\\/[\\w.@/ -]+$/,\n // Home-relative paths (~/Documents)\n /^~[\\w.@/ -]*$/,\n // Bare positional arguments (no dashes, no path traversal)\n /^[a-zA-Z0-9][\\w.@/-]*$/,\n];\n\n/**\n * Validate runtime arguments from the registry.\n * Two-layer defense: reject known-dangerous Node.js flags first,\n * then require remaining args to match safe structural patterns.\n */\nexport function validateRuntimeArgs(args: string[]): void {\n for (const arg of args) {\n // Layer 0 (M4b): reject a \"..\" path-traversal segment anywhere in the argument\n // — \"../x\", \"a/../../etc/passwd\", \"--config=../secret\". A \"..\" segment is one\n // bounded by start-of-arg, \"=\" (flag value), or a path separator on the left,\n // and a separator or end-of-arg on the right. The Layer-2 allowlist permits \".\"\n // and \"/\" inside values, so without this a traversal would slip through; a\n // non-traversal double dot like \"--range=1..10\" is left untouched.\n if (/(?:^|[=\\\\/])\\.\\.(?:[\\\\/]|$)/.test(arg)) {\n throw new Error(`Rejected path traversal in runtime argument: \"${arg}\"`);\n }\n\n // Layer 1: reject dangerous Node.js flags\n const isDangerous = DANGEROUS_FLAG_PREFIXES.some(\n (prefix) => arg === prefix || arg.startsWith(`${prefix}=`)\n );\n if (isDangerous) {\n throw new Error(`Rejected dangerous runtime argument: \"${arg}\"`);\n }\n\n // Layer 2: require safe structural pattern\n const isSafe = SAFE_ARG_PATTERNS.some((pattern) => pattern.test(arg));\n if (!isSafe) {\n throw new Error(`Rejected unrecognized runtime argument: \"${arg}\"`);\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface InstallOptions {\n client?: string;\n yes?: boolean;\n force?: boolean;\n skipHealthCheck?: boolean;\n json?: boolean;\n minTrust?: number;\n minReleaseAge?: number;\n allowFresh?: boolean;\n secrets?: SecretsMode;\n /**\n * H9 (fail-closed): per-invocation consent (`--allow-unguarded`) to install a\n * URL/HTTP-transport server that runs UNGUARDED (the guard relay only wraps a\n * stdio transport — a non-stdio remote gets ZERO runtime inspection). When\n * neither this nor a name already in the persistent consent store grants it,\n * such a server is DENIED. DISTINCT from `allowUrlServers`, the MCP-surface\n * kill-switch: `allowUrlServers === false` ALWAYS wins.\n */\n allowUnguarded?: boolean;\n /**\n * Whether URL/HTTP-transport servers may be installed at all. DEFAULT\n * (undefined/true) preserves CLI behavior. The MCP surface passes `false` so a\n * url-transport server is recorded as blocked instead of written to a config —\n * an untrusted caller can never reach the unguarded run path.\n */\n allowUrlServers?: boolean;\n}\n\nexport interface InstallDeps {\n registryClient: { getServer: (name: string) => Promise<ServerEntry> };\n detectClients: () => Promise<ClientId[]>;\n getAdapter: (clientId: ClientId) => ConfigAdapter;\n getConfigPath: (clientId: ClientId) => string;\n scanTier1: (server: ServerEntry) => Finding[];\n checkScannerAvailable: () => Promise<boolean>;\n scanTier2: (name: string) => Promise<Finding[]>;\n computeTrustScore: (input: TrustScoreInput) => TrustScore;\n addToStore: (server: InstalledServer) => Promise<void>;\n confirm: (message: string) => Promise<boolean>;\n promptEnvVars: (vars: EnvVar[]) => Promise<Record<string, string>>;\n output: (text: string) => void;\n /** Optional; required only when options.secrets === \"keychain\". */\n setSecrets?: (server: string, values: Record<string, string>) => Promise<void>;\n /** Epoch-ms clock for release-age assessment; defaults to Date.now at the CLI boundary. */\n now?: () => number;\n /**\n * H9: read the persistent set of server names previously consented to run\n * unguarded. Injectable for tests; defaults to the real store at the CLI\n * boundary. When omitted, no server is treated as previously-consented.\n */\n readUnguardedConsent?: () => Promise<string[]>;\n /**\n * H9: persist (union into the store) the name newly consented to run\n * unguarded. Injectable for tests; defaults to the real store. Called once\n * after a url server is installed under fresh consent.\n */\n recordUnguardedConsent?: (names: readonly string[]) => Promise<void>;\n}\n\n// ---------------------------------------------------------------------------\n// resolveInstallEntry — pure function, no I/O\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve the McpServerEntry for a given server + clientId.\n *\n * Decision tree:\n * 1. Cursor + server has HTTP remote → produce { url, headers } entry\n * 2. Otherwise pick from packages[]: npm → pypi → oci (first available)\n * 3. npm: { command: 'npx', args: ['-y', identifier, ...runtimeArgs], env }\n * 4. pypi: { command: 'uvx', args: [identifier, ...runtimeArgs], env }\n * 5. docker: { command: 'docker', args: ['run', '--rm', '-i', image], env }\n * 6. If no packages and no usable remote: throw\n */\nexport function resolveInstallEntry(\n serverEntry: ServerEntry,\n clientId: ClientId\n): McpServerEntry {\n const { server } = serverEntry;\n\n // Rule 1: Cursor + HTTP remote → streamable-http entry\n if (clientId === \"cursor\" && server.remotes && server.remotes.length > 0) {\n const httpRemote = server.remotes.find(\n (r) => r.type === \"streamable-http\" || r.type === \"sse\"\n );\n if (httpRemote) {\n validateRemoteUrl(httpRemote.url);\n // Build headers record if any\n const headers: Record<string, string> = {};\n for (const h of httpRemote.headers) {\n headers[h.name] = \"\";\n }\n return {\n url: httpRemote.url,\n ...(Object.keys(headers).length > 0 ? { headers } : {}),\n };\n }\n }\n\n // Rule 2: Pick best package by priority: npm → pypi → oci\n const npmPkg = server.packages.find((p) => p.registryType === \"npm\");\n const pypiPkg = server.packages.find((p) => p.registryType === \"pypi\");\n const ociPkg = server.packages.find((p) => p.registryType === \"oci\");\n\n if (npmPkg) {\n validateIdentifier(npmPkg.identifier, \"npm\");\n const rtArgs = normalizeRuntimeArgs(npmPkg.runtimeArguments ?? []);\n validateRuntimeArgs(rtArgs);\n return {\n command: \"npx\",\n args: [\"-y\", npmPkg.identifier, ...rtArgs],\n };\n }\n\n if (pypiPkg) {\n validateIdentifier(pypiPkg.identifier, \"pypi\");\n const rtArgs = normalizeRuntimeArgs(pypiPkg.runtimeArguments ?? []);\n validateRuntimeArgs(rtArgs);\n return {\n command: \"uvx\",\n args: [pypiPkg.identifier, ...rtArgs],\n };\n }\n\n if (ociPkg) {\n validateIdentifier(ociPkg.identifier, \"oci\");\n const rtArgs = normalizeRuntimeArgs(ociPkg.runtimeArguments ?? []);\n validateRuntimeArgs(rtArgs);\n return {\n command: \"docker\",\n args: [\"run\", \"--rm\", \"-i\", ociPkg.identifier, ...rtArgs],\n };\n }\n\n // Rule 3: Cursor-only path — HTTP remote with no packages\n if (clientId === \"cursor\" && server.remotes && server.remotes.length > 0) {\n const remote = server.remotes[0];\n validateRemoteUrl(remote.url);\n return { url: remote.url };\n }\n\n throw new Error(\n `No install path found for server \"${server.name}\": no packages and no compatible remotes.`\n );\n}\n\n// ---------------------------------------------------------------------------\n// formatTrustScore — pure function, rich display\n// ---------------------------------------------------------------------------\n\n/**\n * Format a trust score as a visual progress bar with breakdown details.\n */\nexport function formatTrustScore(trustScore: TrustScore): string {\n const { score, maxPossible, level, breakdown } = trustScore;\n\n const levelLabel = levelColor(level.toUpperCase());\n const bar = scoreBar(score, maxPossible);\n\n const lines: string[] = [\n `${bar} ${score}/${maxPossible} ${levelLabel}`,\n ` \\u251C\\u2500 Health check: ${breakdown.healthCheck > 0 ? \"not yet run\" : \"failed or skipped\"}`,\n ` \\u251C\\u2500 Tool descriptions: ${breakdown.staticScan === 40 ? \"CLEAN (no injection patterns)\" : `score ${breakdown.staticScan}/40`}`,\n ` \\u251C\\u2500 Package: publisher verification ${breakdown.registryMeta > 0 ? \"passed\" : \"unverified\"}`,\n ` \\u2514\\u2500 External scan: ${breakdown.externalScan > 0 ? `passed (${breakdown.externalScan}/20)` : \"not available (set MCPM_EXTERNAL_SCANNER for deeper analysis)\"}`,\n ];\n\n return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// handleInstall — main handler\n// ---------------------------------------------------------------------------\n\n/**\n * Core handler for `mcpm install <name>`.\n * All dependencies are injected for hermetic testability.\n */\nexport async function handleInstall(\n name: string,\n options: InstallOptions,\n deps: InstallDeps\n): Promise<void> {\n const {\n registryClient,\n detectClients,\n getAdapter,\n getConfigPath,\n scanTier1,\n checkScannerAvailable,\n scanTier2,\n computeTrustScore,\n addToStore,\n confirm,\n promptEnvVars,\n output,\n } = deps;\n\n // -------------------------------------------------------------------------\n // Step 1: Fetch server metadata\n // -------------------------------------------------------------------------\n const serverEntry = await registryClient.getServer(name);\n\n // -------------------------------------------------------------------------\n // Step 1b: registry-delisting gate (fail closed, before any scan/output)\n // -------------------------------------------------------------------------\n // If the registry itself marks this server \"deleted\" (removed/withdrawn),\n // refuse to install. Fail-SAFE: ONLY an explicit \"deleted\" blocks; a\n // \"deprecated\" or absent/unknown status does not (surfaced as an advisory\n // finding by scanTier1 instead). See scanner/registry-status.ts.\n const statusGate = assessServerStatus(serverEntry);\n if (statusGate.blocks) {\n if (options.json === true) {\n output(\n JSON.stringify(\n {\n name,\n error: \"server_delisted\",\n status: statusGate.status,\n message: statusGate.statusMessage ?? null,\n },\n null,\n 2\n )\n );\n }\n throw new Error(\n `\"${name}\" has been deleted from the MCP registry${statusGate.statusMessage ? ` (${statusGate.statusMessage})` : \"\"}. Installation aborted.`\n );\n }\n\n // -------------------------------------------------------------------------\n // Step 2: Trust assessment\n // -------------------------------------------------------------------------\n const tier1Findings = scanTier1(serverEntry);\n const scannerAvailable = await checkScannerAvailable();\n\n let allFindings: Finding[] = [...tier1Findings];\n if (scannerAvailable) {\n const tier2Findings = await scanTier2(name);\n allFindings = [...allFindings, ...tier2Findings];\n }\n\n // Release-age cooldown: assessed ONCE so the score finding and the Step 2c\n // gate can never disagree — passing --min-release-age below 24 therefore also\n // lowers the scoring cooldown threshold (documented in the flag help text).\n // The medium finding lands unconditionally for fresh releases, with or\n // without the gate — that is the inversion fix, independent of the gate.\n const registryMeta = extractRegistryMeta(serverEntry);\n const releaseAge = assessReleaseAge({\n publishedAt: registryMeta.publishedAt,\n now: (deps.now ?? Date.now)(),\n minAgeHours: options.minReleaseAge ?? DEFAULT_MIN_RELEASE_AGE_HOURS,\n });\n if (releaseAge.finding) {\n allFindings = [...allFindings, releaseAge.finding];\n }\n\n const trustScoreInput: TrustScoreInput = {\n findings: allFindings,\n healthCheckPassed: null, // health check not yet run at this point\n hasExternalScanner: scannerAvailable,\n registryMeta,\n };\n\n const trustScore = computeTrustScore(trustScoreInput);\n\n // -------------------------------------------------------------------------\n // Step 2b: --min-trust gate (checked before any output or confirmation)\n // -------------------------------------------------------------------------\n // A threshold above what this gate can AWARD refuses every server in the registry,\n // forever, while the message below blames the server (\"62/80 is below 63\") and sends\n // the user looking for a better one. The gate scores with `healthCheckPassed: null`\n // and no download count, so 18 native points are unreachable here — see\n // `maxAchievableBeforeHealthCheck`. Keyed on what this score was CREDITED, never on\n // scanner availability; those differ, and the gap is the whole 63..82 band.\n //\n // Unlike `audit --fix`, both branches REFUSE — nothing is installed either way — so\n // this changes the diagnosis, not the safety. That is the entire point: TODOS #45.\n if (options.minTrust !== undefined) {\n const ceiling = maxAchievableBeforeHealthCheck(externalCredited(trustScore));\n if (options.minTrust > ceiling.score) {\n if (options.json === true) {\n output(\n JSON.stringify(\n { name, error: \"min_trust_unsatisfiable\", required: options.minTrust, ceiling: ceiling.score, maxPossible: ceiling.maxPossible },\n null,\n 2\n )\n );\n }\n // Recommend the score this server ACTUALLY reached, never the model ceiling.\n // `audit`'s sibling guard recommends `bestObserved` for the same reason and states\n // it outright: a threshold above what was observed refuses servers \"for what audit\n // cannot measure rather than for their evidence\". Recommending 62 would also be\n // unreachable for any npm server, which caps at 60 on the `npx -y` launcher class —\n // sending the user straight into a second refusal.\n //\n // Scope the claim to this invocation. If an external scanner answered `--version`\n // but errored on THIS server, the scorer treats it as absent and the ceiling is the\n // native one — a statement about the run, not a law about every server.\n throw new Error(\n `--min-trust ${options.minTrust} is above ${ceiling.score}, the highest score \\`mcpm install\\` ` +\n `can award here. Trust is scored BEFORE the health check runs and mcpm reads no ` +\n `download count, so ${ceiling.maxPossible - ceiling.score} of the ${ceiling.maxPossible} points are ` +\n `unreachable at install time. \"${name}\" scored ${trustScore.score}/${trustScore.maxPossible}. ` +\n `Use --min-trust ${trustScore.score} or lower to gate on evidence rather than on what install ` +\n `cannot measure, or run \\`mcpm audit\\` after installing to score it with more of the picture.`\n );\n }\n }\n\n if (options.minTrust !== undefined && trustScore.score < options.minTrust) {\n if (options.json === true) {\n output(\n JSON.stringify(\n {\n name,\n error: \"min_trust_not_met\",\n score: trustScore.score,\n maxPossible: trustScore.maxPossible,\n required: options.minTrust,\n level: trustScore.level,\n },\n null,\n 2\n )\n );\n }\n // The denominator is 80 unless the external-scanner bucket was credited, which is\n // the default — so a hardcoded /100 understated every score by 20 points of scale\n // and made a flawless 62/80 (77.5%) read as 62%.\n throw new Error(\n `Trust score ${trustScore.score}/${trustScore.maxPossible} is below the required minimum of ${options.minTrust}. Installation aborted.`\n );\n }\n\n // -------------------------------------------------------------------------\n // Step 2c: --min-release-age gate (checked before any output or confirmation)\n // -------------------------------------------------------------------------\n // Fail-closed when armed: a MISSING publish timestamp blocks too (blocksArmedGate)\n // — otherwise a registry/compromised mirror could defeat the gate by omitting\n // _meta (publishedAt is .optional() in OfficialMetaSchema). The score finding\n // stays fail-open for absent; only the explicitly armed gate hardens.\n if (\n options.minReleaseAge !== undefined &&\n options.allowFresh !== true &&\n releaseAge.blocksArmedGate\n ) {\n if (options.json === true) {\n output(\n JSON.stringify(\n {\n name,\n error: \"release_age_not_met\",\n ageHours: releaseAge.ageHours,\n required: options.minReleaseAge,\n reason: releaseAge.status,\n },\n null,\n 2\n )\n );\n }\n const tail = \"Installation aborted. Use --allow-fresh to bypass.\";\n throw new Error(\n releaseAge.status === \"future\"\n ? `Release publish timestamp is in the future (clock skew or forged metadata); treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}`\n : releaseAge.status === \"unparseable\"\n ? `Release publish timestamp could not be parsed; treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}`\n : releaseAge.status === \"absent\"\n ? `Release publish timestamp is missing from the registry metadata, so release age cannot be verified against the ${options.minReleaseAge}-hour minimum. ${tail}`\n : `Release age ${releaseAge.ageHours}h is below the required minimum of ${options.minReleaseAge}h. ${tail}`\n );\n }\n\n // -------------------------------------------------------------------------\n // Step 3: Display trust score and confirm\n // -------------------------------------------------------------------------\n // In --json mode suppress all human-readable output; only the final JSON\n // is written to stdout.\n const jsonMode = options.json === true;\n\n if (!jsonMode) {\n output(formatTrustScore(trustScore));\n output(\"\");\n }\n\n if (options.yes !== true) {\n let shouldProceed: boolean;\n\n if (trustScore.level === \"risky\") {\n if (!jsonMode) {\n output(\"\\u001b[31mWARNING: This server has a low trust score and may be risky to install.\\u001b[0m\");\n output(\"\\u001b[31mSecurity findings indicate potential dangers. Proceed with extreme caution.\\u001b[0m\");\n }\n shouldProceed = await confirm(\n \"I understand the risks and want to install this server anyway. Continue?\"\n );\n } else if (trustScore.level === \"caution\") {\n if (!jsonMode) {\n output(\"\\u001b[33mCAUTION: This server has a moderate trust score. Review the details above.\\u001b[0m\");\n }\n shouldProceed = await confirm(`Install '${name}'? (caution recommended)`);\n } else {\n // GREEN — brief display, proceed\n shouldProceed = await confirm(`Install '${name}'?`);\n }\n\n if (!shouldProceed) {\n if (!jsonMode) output(\"Installation cancelled.\");\n return;\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 4: Detect and filter clients\n // -------------------------------------------------------------------------\n let targetClients = await detectClients();\n\n if (targetClients.length === 0) {\n throw new Error(\n \"No supported AI clients found. Install Claude Desktop, Cursor, VS Code, or Windsurf first.\"\n );\n }\n\n if (options.client !== undefined) {\n if (!CLIENT_IDS.includes(options.client as ClientId)) {\n throw new Error(\n `Unknown client \"${options.client}\". Valid values: ${CLIENT_IDS.join(\", \")}.`\n );\n }\n const requestedId = options.client as ClientId;\n if (!targetClients.includes(requestedId)) {\n throw new Error(\n `Client \"${requestedId}\" is not installed on this machine.`\n );\n }\n targetClients = [requestedId];\n }\n\n // -------------------------------------------------------------------------\n // Step 5: Check for already-installed (unless --force)\n // -------------------------------------------------------------------------\n if (options.force !== true) {\n for (const clientId of targetClients) {\n const adapter = getAdapter(clientId);\n const configPath = getConfigPath(clientId);\n const existing = await adapter.read(configPath);\n if (Object.prototype.hasOwnProperty.call(existing, name)) {\n throw new Error(\n `Server '${name}' is already installed in ${clientId}. Use --force to overwrite.`\n );\n }\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 6: Resolve env vars to prompt for\n // -------------------------------------------------------------------------\n // Collect env vars from the best-match package\n const { server } = serverEntry;\n const bestPkg =\n server.packages.find((p) => p.registryType === \"npm\") ??\n server.packages.find((p) => p.registryType === \"pypi\") ??\n server.packages.find((p) => p.registryType === \"oci\") ??\n server.packages[0];\n\n const envVarDefs: EnvVar[] = bestPkg?.environmentVariables ?? [];\n const resolvedEnvVars = await promptEnvVars(envVarDefs);\n\n // Step 6b: In keychain mode, persist secret-flagged values encrypted and swap\n // them for `mcpm:keychain:…` placeholders, so no plaintext is written to any\n // client config. Non-secret vars stay inline; each secret is stored once and\n // reused for every client. The placeholder resolves at launch only while mcpm\n // guard wraps the server (run-inner.ts → resolveEnvPlaceholders). The swap\n // (and the \"no plaintext in config\" invariant) lives in applyKeychainSecrets.\n const secretsMode: SecretsMode = options.secrets ?? \"plaintext\";\n const { env: envForConfig, storedCount: storedSecretCount } = await applyKeychainSecrets({\n serverName: name,\n resolvedEnv: resolvedEnvVars,\n isSecret: (key) => envVarDefs.find((d) => d.name === key)?.isSecret === true,\n mode: secretsMode,\n setSecrets: deps.setSecrets,\n });\n\n // -------------------------------------------------------------------------\n // Step 7: Resolve (and thereby validate) each client's entry up front\n // -------------------------------------------------------------------------\n // resolveInstallEntry throws on an invalid identifier, so resolving here\n // before any config is written preserves fail-fast validation. The resolved\n // entries are reused in Step 8 to avoid recomputing them.\n const resolvedEntries = new Map<ClientId, McpServerEntry>();\n for (const clientId of targetClients) {\n resolvedEntries.set(clientId, resolveInstallEntry(serverEntry, clientId));\n }\n\n // -------------------------------------------------------------------------\n // Step 7b: H9 fail-closed gate for URL/HTTP-transport servers\n // -------------------------------------------------------------------------\n // A resolved entry with a `url` and no `command` runs UNGUARDED — the guard\n // relay only wraps a stdio process, so a non-stdio remote gets ZERO runtime\n // inspection. Mirror processUrlServer (up.ts): the MCP-surface kill-switch\n // (allowUrlServers === false) ALWAYS wins; otherwise DENY unless explicit\n // informed consent (`--allow-unguarded` this run, or a name already in the\n // persistent consent store). This is informed consent, NOT protection.\n const isUnguardedEntry = [...resolvedEntries.values()].some(\n (e) => e.url !== undefined && e.command === undefined\n );\n if (isUnguardedEntry) {\n if (options.allowUrlServers === false) {\n throw new Error(\n `Server '${name}' uses a URL/HTTP transport and is not permitted via the MCP surface.`\n );\n }\n const previousConsented = deps.readUnguardedConsent\n ? await deps.readUnguardedConsent()\n : [];\n const alreadyConsented = previousConsented.includes(name);\n const consented = options.allowUnguarded === true || alreadyConsented;\n if (!consented) {\n throw new Error(\n `Server '${name}' uses a URL/HTTP transport and runs UNGUARDED — no runtime ` +\n `inspection is possible (mcpm's guard relay only wraps stdio servers). ` +\n `Re-run with --allow-unguarded to install it WITHOUT protection.`\n );\n }\n // First-time consent: warn once and persist so a future install stays quiet.\n if (!alreadyConsented) {\n if (!jsonMode) {\n output(\n \"\\x1b[33m⚠ UNGUARDED: this URL/HTTP-transport server runs WITHOUT runtime \" +\n \"inspection (the guard relay only wraps stdio servers). This grants consent — \" +\n \"it does NOT add protection. The only true fix is a streamable-HTTP relay \" +\n \"(not yet implemented).\\x1b[0m\"\n );\n }\n if (deps.recordUnguardedConsent) {\n await deps.recordUnguardedConsent([name]).catch(() => undefined);\n }\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 8: Write config to each client and record in store\n // -------------------------------------------------------------------------\n const installedClients: ClientId[] = [];\n\n for (const clientId of targetClients) {\n const adapter = getAdapter(clientId);\n const configPath = getConfigPath(clientId);\n const rawEntry = resolvedEntries.get(clientId)!;\n\n // Merge env vars into the entry (immutable). In keychain mode envForConfig\n // carries placeholders in place of secret values; otherwise it === resolvedEnvVars.\n const entry: McpServerEntry = {\n ...rawEntry,\n ...(Object.keys(envForConfig).length > 0\n ? { env: { ...(rawEntry.env ?? {}), ...envForConfig } }\n : {}),\n };\n\n await adapter.addServer(configPath, name, entry, { force: options.force });\n installedClients.push(clientId);\n }\n\n // -------------------------------------------------------------------------\n // Step 8b: Secret-storage notice\n // -------------------------------------------------------------------------\n if (!options.json) {\n if (secretsMode === \"keychain\" && storedSecretCount > 0) {\n output(\n `\\x1b[32mStored ${storedSecretCount} secret(s) encrypted at rest in ~/.mcpm. ` +\n \"With an OS keychain this protects against other-user/offline access (not \" +\n \"same-user processes); without one a machine-derived key is used that guards \" +\n \"casual local inspection only, NOT file exfiltration — run `mcpm secrets migrate` \" +\n \"once a keychain is available. \" +\n \"Run `mcpm guard enable` (then restart your IDE) so they resolve at launch — \" +\n \"until guard wraps this server it receives the literal placeholder.\\x1b[0m\"\n );\n } else {\n const hasSecrets = envVarDefs.some((ev) => ev.isSecret && resolvedEnvVars[ev.name]);\n if (hasSecrets) {\n output(\n \"\\x1b[33mNote: API keys are stored as plaintext in client config files. \" +\n \"Ensure config files have appropriate permissions (chmod 600).\\x1b[0m\"\n );\n }\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 9: Record in store\n // -------------------------------------------------------------------------\n const storeEntry: InstalledServer = {\n name,\n version: serverEntry.server.version,\n clients: [...installedClients],\n installedAt: new Date().toISOString(),\n };\n await addToStore(storeEntry);\n\n // -------------------------------------------------------------------------\n // Step 10: Output result\n // -------------------------------------------------------------------------\n if (options.json === true) {\n const result = {\n name,\n version: serverEntry.server.version,\n clients: installedClients,\n trustScore: {\n score: trustScore.score,\n maxPossible: trustScore.maxPossible,\n level: trustScore.level,\n },\n };\n output(JSON.stringify(result, null, 2));\n return;\n }\n\n const clientList = installedClients.join(\", \");\n output(`\\u001b[32mInstalled '${name}' successfully into: ${clientList}\\u001b[0m`);\n}\n\n// ---------------------------------------------------------------------------\n// Commander registration\n// ---------------------------------------------------------------------------\n\nimport { Command, InvalidArgumentError } from \"commander\";\nimport chalk from \"chalk\";\nimport { input, password } from \"@inquirer/prompts\";\nimport { detectInstalledClients as _detectClients } from \"../config/detector.js\";\nimport { getConfigPath as _getConfigPath } from \"../config/paths.js\";\nimport { addInstalledServer as _addToStore } from \"../store/servers.js\";\nimport { scanTier1 as _scanTier1 } from \"../scanner/tier1.js\";\nimport { checkScannerAvailable as _checkScannerAvailable, scanTier2 as _scanTier2 } from \"../scanner/tier2.js\";\nimport { computeTrustScore as _computeTrustScore } from \"../scanner/trust-score.js\";\nimport { getAdapter as getAdapterDefault } from \"../config/index.js\";\nimport { confirm } from \"../utils/confirm.js\";\nimport { stdoutOutput } from \"../utils/output.js\";\n\nasync function promptEnvVarsDefault(\n vars: EnvVar[]\n): Promise<Record<string, string>> {\n if (vars.length === 0) return {};\n\n const result: Record<string, string> = {};\n for (const envVar of vars) {\n if (!envVar.isRequired && !envVar.isSecret) continue;\n\n const defaultVal = envVar.default ?? \"\";\n const promptMessage = envVar.description\n ? `${envVar.name} (${envVar.description}):`\n : `${envVar.name}:`;\n\n let prompted: string;\n if (envVar.isSecret) {\n // Use password prompt to mask secret input — value is never echoed to the terminal\n prompted = await password({ message: promptMessage });\n if (!prompted && defaultVal) {\n prompted = defaultVal;\n }\n } else {\n prompted = await input({ message: promptMessage, default: defaultVal });\n }\n\n if (prompted) {\n result[envVar.name] = prompted;\n }\n }\n return result;\n}\n\nexport function parseSecretsMode(raw: string): SecretsMode {\n if (raw !== \"keychain\" && raw !== \"plaintext\") {\n throw new InvalidArgumentError(\n `--secrets must be \"keychain\" or \"plaintext\", got: \"${raw}\"`\n );\n }\n return raw;\n}\n\nexport function parseMinTrust(raw: string): number {\n // Reject anything that isn't plain decimal digits (blocks hex \"0x50\", scientific\n // notation \"1e2\", spaces, empty string, and negative sign before range check).\n if (!/^\\d+$/.test(raw)) {\n throw new InvalidArgumentError(\n `--min-trust must be an integer between 0 and 100, got: \"${raw}\"`\n );\n }\n const n = Number(raw);\n if (n < 0 || n > 100) {\n throw new InvalidArgumentError(\n `--min-trust must be an integer between 0 and 100, got: \"${raw}\"`\n );\n }\n return n;\n}\n\nexport function parseMinReleaseAge(raw: string): number {\n // Same regex-first discipline as parseMinTrust: blocks hex \"0x18\", \"1e2\",\n // spaces, empty string, negatives. Safe-integer check guards absurd lengths.\n if (!/^\\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) {\n throw new InvalidArgumentError(\n `--min-release-age must be a non-negative integer number of hours, got: \"${raw}\"`\n );\n }\n return Number(raw);\n}\n\nexport function registerInstallCommand(program: Command): void {\n program\n .command(\"install <name>\")\n .description(\"Install an MCP server from the registry\")\n .option(\"-c, --client <id>\", \"install to a specific client only\")\n .option(\"-y, --yes\", \"skip all confirmation prompts\")\n .option(\"-f, --force\", \"overwrite if server already installed\")\n .option(\"--skip-health-check\", \"skip post-install health check\")\n .option(\"--json\", \"output result as JSON\")\n .option(\"--min-trust <n>\", \"abort install if pre-install trust score is below this threshold; scored before the health check runs, so a threshold above what install can award is refused as unsatisfiable rather than applied\", parseMinTrust)\n .option(\"--min-release-age <hours>\", \"abort install if the release is younger than this many hours OR its publish timestamp is missing/unparseable (fail-closed when set; also sets the scoring cooldown threshold; bypass with --allow-fresh)\", parseMinReleaseAge)\n .option(\"--allow-fresh\", \"bypass the --min-release-age gate (including the missing-timestamp block)\")\n .option(\"--secrets <mode>\", \"where to store secret env vars: 'keychain' (encrypted in ~/.mcpm, resolved by mcpm guard at launch) or 'plaintext' (default)\", parseSecretsMode)\n .option(\"--allow-unguarded\", \"permit a URL/HTTP-transport server to run WITHOUT runtime guard inspection (no relay wraps a non-stdio transport); records consent so future installs stay quiet\")\n .action(async (name: string, opts: { client?: string; yes?: boolean; force?: boolean; skipHealthCheck?: boolean; json?: boolean; minTrust?: number; minReleaseAge?: number; allowFresh?: boolean; secrets?: SecretsMode; allowUnguarded?: boolean }) => {\n const { RegistryClient } = await import(\"../registry/client.js\");\n const client = new RegistryClient();\n const { readUnguardedConsent, writeUnguardedConsent, mergeUnguarded } = await import(\n \"../guard/unguarded.js\"\n );\n\n const installOptions: InstallOptions = {\n client: opts.client,\n yes: opts.yes,\n force: opts.force,\n skipHealthCheck: opts.skipHealthCheck,\n json: opts.json,\n minTrust: opts.minTrust,\n minReleaseAge: opts.minReleaseAge,\n allowFresh: opts.allowFresh,\n secrets: opts.secrets,\n allowUnguarded: opts.allowUnguarded,\n };\n\n const installDeps: InstallDeps = {\n registryClient: client,\n detectClients: _detectClients,\n getAdapter: getAdapterDefault,\n getConfigPath: _getConfigPath,\n scanTier1: _scanTier1,\n checkScannerAvailable: _checkScannerAvailable,\n scanTier2: (serverName: string) => _scanTier2(serverName),\n computeTrustScore: _computeTrustScore,\n addToStore: _addToStore,\n confirm,\n promptEnvVars: promptEnvVarsDefault,\n output: stdoutOutput,\n setSecrets: _setSecrets,\n now: () => Date.now(),\n readUnguardedConsent,\n recordUnguardedConsent: async (names) => {\n const previous = await readUnguardedConsent();\n await writeUnguardedConsent(mergeUnguarded(previous, names));\n },\n };\n\n try {\n await handleInstall(name, installOptions, installDeps);\n } catch (err) {\n if (installOptions.json !== true) {\n console.error(chalk.red((err as Error).message));\n }\n process.exit(1);\n }\n });\n}\n"],"mappings":"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","names":["getAdapter","getConfigPath","scanTier1","checkScannerAvailable","scanTier2","computeTrustScore","confirm"]}
#!/usr/bin/env node
import {
coloredOutput
} from "./chunk-E3T224S3.js";
import {
isConfineBackendAvailable,
isWrapped
} from "./chunk-WYSMWP2R.js";
import {
sanitizeForTerminal
} from "./chunk-FEXJHHDM.js";
import {
detectSecretLabels
} from "./chunk-U7N6FRYF.js";
import {
getAdapter
} from "./chunk-W4IAFBUN.js";
import {
isSupportedPlatform,
parsePlaceholder
} from "./chunk-NPJ3SGGS.js";
import {
CLIENT_IDS,
getConfigPath
} from "./chunk-R4R2VPDA.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.30.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-LJK7OC4C.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
// src/scanner/trust-score.ts
var HEALTH_CHECK_PASS = 30;
var HEALTH_CHECK_FAIL = 0;
var HEALTH_CHECK_NULL = 15;
var STATIC_SCAN_MAX = 40;
var EXTERNAL_SCAN_MAX = 20;
var REGISTRY_META_MAX = 10;
var SEVERITY_DEDUCTIONS = {
critical: 20,
high: 10,
medium: 5,
low: 2
};
var MS_PER_DAY = 24 * 60 * 60 * 1e3;
var PUBLISHED_AGE_DAYS = 30;
var DOWNLOAD_THRESHOLD = 100;
var NATIVE_MAX_POSSIBLE = HEALTH_CHECK_PASS + STATIC_SCAN_MAX + REGISTRY_META_MAX;
var FULL_MAX_POSSIBLE = NATIVE_MAX_POSSIBLE + EXTERNAL_SCAN_MAX;
function scoreHealthCheck(passed) {
if (passed === true) return HEALTH_CHECK_PASS;
if (passed === false) return HEALTH_CHECK_FAIL;
return HEALTH_CHECK_NULL;
}
function totalDeductions(findings) {
return findings.reduce((sum, f) => sum + SEVERITY_DEDUCTIONS[f.severity], 0);
}
function scoreStaticScan(findings) {
return Math.max(0, STATIC_SCAN_MAX - totalDeductions(findings));
}
function scoreExternalScan(hasExternalScanner, findings) {
if (!hasExternalScanner) return 0;
return Math.max(0, EXTERNAL_SCAN_MAX - totalDeductions(findings));
}
function scoreRegistryMeta(meta) {
let points = 0;
if (meta.isVerifiedPublisher === true) {
points += 4;
}
if (meta.publishedAt) {
const publishedAge = Date.now() - new Date(meta.publishedAt).getTime();
if (publishedAge > PUBLISHED_AGE_DAYS * MS_PER_DAY) {
points += 3;
}
}
if (typeof meta.downloadCount === "number" && meta.downloadCount > DOWNLOAD_THRESHOLD) {
points += 3;
}
return Math.min(points, REGISTRY_META_MAX);
}
function computeLevel(score, maxPossible) {
const ratio = score / maxPossible;
if (ratio >= 0.8) return "safe";
if (ratio >= 0.5) return "caution";
return "risky";
}
function hasCriticalOrHighFindings(findings) {
return findings.some((f) => f.severity === "critical" || f.severity === "high");
}
function computeTrustScore(input) {
const externalCredited2 = input.hasExternalScanner && !input.findings.some((f) => f.source === "external" && f.type === "scanner-error");
const maxPossible = externalCredited2 ? FULL_MAX_POSSIBLE : NATIVE_MAX_POSSIBLE;
const registryMetaScore = hasCriticalOrHighFindings(input.findings) ? 0 : scoreRegistryMeta(input.registryMeta);
const externalFindings = externalCredited2 ? input.findings.filter((f) => f.source === "external") : [];
const staticFindings = externalCredited2 ? input.findings.filter((f) => f.source !== "external") : input.findings.filter((f) => !(f.type === "scanner-error" && f.source === "external"));
const breakdown = {
healthCheck: scoreHealthCheck(input.healthCheckPassed),
staticScan: scoreStaticScan(staticFindings),
externalScan: scoreExternalScan(externalCredited2, externalFindings),
registryMeta: registryMetaScore
};
const score = breakdown.healthCheck + breakdown.staticScan + breakdown.externalScan + breakdown.registryMeta;
const level = computeLevel(score, maxPossible);
return { score, maxPossible, level, breakdown: { ...breakdown } };
}
function externalCredited(trust) {
return trust.maxPossible === FULL_MAX_POSSIBLE;
}
function nativeTrustScore(trust) {
const score = trust.score - trust.breakdown?.externalScan;
if (!Number.isFinite(score)) {
throw new Error(
"Cannot evaluate a trust floor: the trust score has no usable breakdown. This is a bug \u2014 report it rather than working around it."
);
}
return {
score,
maxPossible: NATIVE_MAX_POSSIBLE,
excludedExternalCredit: trust.breakdown.externalScan
};
}
function maxAchievableBeforeHealthCheck(hasExternalScanner) {
return computeTrustScore({
findings: [],
healthCheckPassed: null,
hasExternalScanner,
registryMeta: {
isVerifiedPublisher: true,
publishedAt: "1970-01-01T00:00:00.000Z",
downloadCount: void 0
}
});
}
export {
EXTERNAL_SCAN_MAX,
computeTrustScore,
externalCredited,
nativeTrustScore,
maxAchievableBeforeHealthCheck
};
//# sourceMappingURL=chunk-Y36MYEDT.js.map
{"version":3,"sources":["../src/scanner/trust-score.ts"],"sourcesContent":["/**\n * Trust score computation — pure function, no I/O.\n *\n * Takes findings and metadata, returns a structured TrustScore.\n * All objects returned are new (immutable pattern).\n */\n\nimport type { Finding } from \"./tier1.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TrustScoreInput {\n findings: Finding[];\n healthCheckPassed: boolean | null; // null = not yet run\n hasExternalScanner: boolean;\n registryMeta: {\n isVerifiedPublisher?: boolean;\n publishedAt?: string;\n downloadCount?: number;\n };\n}\n\nexport interface TrustScoreBreakdown {\n healthCheck: number; // 0-30\n staticScan: number; // 0-40\n externalScan: number; // 0-20\n registryMeta: number; // 0-10\n}\n\nexport interface TrustScore {\n score: number; // 0-100\n maxPossible: number; // 80 if no external scanner, 100 otherwise\n level: \"safe\" | \"caution\" | \"risky\";\n breakdown: TrustScoreBreakdown;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst HEALTH_CHECK_PASS = 30;\nconst HEALTH_CHECK_FAIL = 0;\nconst HEALTH_CHECK_NULL = 15;\n\nconst STATIC_SCAN_MAX = 40;\n/**\n * Exported for `stack/policy.ts`, which validates a lockfile's recorded\n * `externalScanCredit` against it. That field is this bucket's value at lock time,\n * so the ceiling has to be the one this scorer actually awards — a second copy of\n * `20` in the validator would silently stop matching if the bucket is re-weighted.\n */\nexport const EXTERNAL_SCAN_MAX = 20;\nconst REGISTRY_META_MAX = 10;\n\n/** Deductions per finding severity (applied to both static and external scan). */\nconst SEVERITY_DEDUCTIONS: Record<Finding[\"severity\"], number> = {\n critical: 20,\n high: 10,\n medium: 5,\n low: 2,\n};\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\nconst PUBLISHED_AGE_DAYS = 30;\nconst DOWNLOAD_THRESHOLD = 100;\n\n/**\n * The score reachable from mcpm's OWN evidence — health check, tier-1 static\n * scan, registry metadata. Also the `maxPossible` reported when no external\n * scanner was credited, and the denominator `nativeTrustScore` reports.\n *\n * Derived rather than written as `80` so the three buckets stay the single\n * source of truth if any of them is ever re-weighted.\n */\nconst NATIVE_MAX_POSSIBLE = HEALTH_CHECK_PASS + STATIC_SCAN_MAX + REGISTRY_META_MAX;\nconst FULL_MAX_POSSIBLE = NATIVE_MAX_POSSIBLE + EXTERNAL_SCAN_MAX;\n\n// ---------------------------------------------------------------------------\n// Component scorers\n// ---------------------------------------------------------------------------\n\nfunction scoreHealthCheck(passed: boolean | null): number {\n if (passed === true) return HEALTH_CHECK_PASS;\n if (passed === false) return HEALTH_CHECK_FAIL;\n return HEALTH_CHECK_NULL;\n}\n\nfunction totalDeductions(findings: Finding[]): number {\n return findings.reduce((sum, f) => sum + SEVERITY_DEDUCTIONS[f.severity], 0);\n}\n\nfunction scoreStaticScan(findings: Finding[]): number {\n return Math.max(0, STATIC_SCAN_MAX - totalDeductions(findings));\n}\n\nfunction scoreExternalScan(hasExternalScanner: boolean, findings: Finding[]): number {\n if (!hasExternalScanner) return 0;\n return Math.max(0, EXTERNAL_SCAN_MAX - totalDeductions(findings));\n}\n\nfunction scoreRegistryMeta(meta: TrustScoreInput[\"registryMeta\"]): number {\n let points = 0;\n\n if (meta.isVerifiedPublisher === true) {\n points += 4;\n }\n\n if (meta.publishedAt) {\n const publishedAge = Date.now() - new Date(meta.publishedAt).getTime();\n if (publishedAge > PUBLISHED_AGE_DAYS * MS_PER_DAY) {\n points += 3;\n }\n }\n\n if (typeof meta.downloadCount === \"number\" && meta.downloadCount > DOWNLOAD_THRESHOLD) {\n points += 3;\n }\n\n return Math.min(points, REGISTRY_META_MAX);\n}\n\n// ---------------------------------------------------------------------------\n// Level threshold\n// ---------------------------------------------------------------------------\n\nfunction computeLevel(score: number, maxPossible: number): TrustScore[\"level\"] {\n const ratio = score / maxPossible;\n if (ratio >= 0.8) return \"safe\";\n if (ratio >= 0.5) return \"caution\";\n return \"risky\";\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Returns true if any finding has critical or high severity.\n */\nfunction hasCriticalOrHighFindings(findings: Finding[]): boolean {\n return findings.some((f) => f.severity === \"critical\" || f.severity === \"high\");\n}\n\n/**\n * Compute a trust score from findings and server metadata.\n * Returns a new TrustScore object — never mutates input.\n */\nexport function computeTrustScore(input: TrustScoreInput): TrustScore {\n // The external bucket is credited only when the scanner returned a result we\n // could READ — not merely because one was configured and exited 0.\n //\n // Be precise about what this does and does not buy, because the raw score\n // feeds `mcpm install --min-trust` and the MCP `HARD_TRUST_FLOOR`. It stops\n // the ACCIDENTAL inflation: a binary that is not a scanner (`/bin/true` exits\n // 0 and says nothing), a typo'd path, a scanner that broke after an upgrade.\n // Those would otherwise bank a silent 20/20 for doing nothing.\n //\n // It does NOT stop deliberate self-deception, and cannot. Anyone who can set\n // MCPM_EXTERNAL_SCANNER can also write a two-line script that prints\n // `{\"findings\": []}`, which is indistinguishable from a real clean scan —\n // mcpm has no way to verify an arbitrary executable did any work. So the\n // external bucket is, and should be read as, \"a scanner the USER chose to\n // trust reported this\", never as independent corroboration mcpm validated.\n //\n // Because it cannot be verified, that bucket is excluded from the safety\n // floors the no-human-in-loop MCP surface enforces — see `nativeTrustScore`\n // below (TODOS #33). It still informs the score every human-facing surface\n // displays and compares; it just cannot CLEAR a floor on its own.\n //\n // A scanner whose result we could not read is treated as ABSENT rather than as\n // a failing scan: the bucket leaves `maxPossible` (80, not 100) instead of\n // scoring 0 out of 100. Note the honest comparison — that is \"no worse than\n // having no scanner\", NOT \"no change\": a stack that was scoring against a\n // working scanner and whose scanner then breaks does lose those points, and a\n // raw `--min-trust` gate will see the drop.\n const externalCredited =\n input.hasExternalScanner &&\n !input.findings.some((f) => f.source === \"external\" && f.type === \"scanner-error\");\n\n const maxPossible = externalCredited ? FULL_MAX_POSSIBLE : NATIVE_MAX_POSSIBLE;\n\n // Cap registryMeta to 0 when critical/high findings are present.\n // Attacker-controlled metadata (publishedAt, downloads) must not inflate\n // the score when the scan found serious issues.\n const registryMetaScore = hasCriticalOrHighFindings(input.findings)\n ? 0\n : scoreRegistryMeta(input.registryMeta);\n\n // Partition findings by source so each finding is deducted from exactly one\n // bucket. Tier-1 findings (source \"static\" or undefined) hit the static\n // sub-score; tier-2 external-scanner findings (source \"external\") hit the\n // external sub-score. Without this split, every finding was deducted from\n // BOTH buckets whenever an external scanner was present — making scores\n // artificially low precisely when the extra scanner was enabled.\n //\n // When no external scanner ran, the external sub-score is hard-zeroed and the\n // bucket is removed from maxPossible, so an \"external\"-tagged finding present\n // without a scanner would otherwise be silently dropped from ALL scoring and\n // deduct nothing. That should not happen in normal flow, but we route such\n // orphans into the static bucket as a safe fallback so they still deduct\n // rather than vanish.\n //\n // The one thing that must NOT fall through to the static bucket is a\n // scanner-error diagnostic from an uncredited scanner. It says the user's\n // scanner failed, not that the server is worse — deducting for it would make\n // a broken scanner quietly depress every server's score, which is the exact\n // opposite of treating a failed scanner as absent.\n const externalFindings = externalCredited\n ? input.findings.filter((f) => f.source === \"external\")\n : [];\n const staticFindings = externalCredited\n ? input.findings.filter((f) => f.source !== \"external\")\n : input.findings.filter((f) => !(f.type === \"scanner-error\" && f.source === \"external\"));\n\n const breakdown: TrustScoreBreakdown = {\n healthCheck: scoreHealthCheck(input.healthCheckPassed),\n staticScan: scoreStaticScan(staticFindings),\n externalScan: scoreExternalScan(externalCredited, externalFindings),\n registryMeta: registryMetaScore,\n };\n\n const score =\n breakdown.healthCheck +\n breakdown.staticScan +\n breakdown.externalScan +\n breakdown.registryMeta;\n\n const level = computeLevel(score, maxPossible);\n\n return { score, maxPossible, level, breakdown: { ...breakdown } };\n}\n\n/**\n * Did this score bank the external-scanner bucket?\n *\n * The scorer widens the denominator to `FULL_MAX_POSSIBLE` exactly when it credited\n * the bucket, so `maxPossible` IS the record of that decision — which makes this the\n * one honest way to ask after the fact.\n *\n * Note what it is NOT: `checkScannerAvailable()`. That is a bare `<cmd> --version`\n * exit-0 probe, while crediting additionally requires that the scanner returned output\n * the scorer could READ. A scanner that answers `--version` but cannot scan a registry\n * server name emits a `scanner-error` finding and is credited nowhere, so the two\n * predicates disagree precisely when a scanner is half-working — the case that made\n * `audit --fix`'s ceiling delete flawless servers.\n *\n * Crediting is decided PER SERVER (a `scanner-error` is per invocation), so a single\n * run can legitimately contain both, and callers reducing over a run must decide which\n * direction is safe rather than assume uniformity.\n */\nexport function externalCredited(trust: TrustScore): boolean {\n return trust.maxPossible === FULL_MAX_POSSIBLE;\n}\n\n/** A trust score with third-party credit mcpm cannot verify removed. */\nexport interface NativeTrustScore {\n /** The score, less the external-scanner bucket's credit. */\n score: number;\n /** Denominator for `score` — always the three mcpm-native buckets. */\n maxPossible: number;\n /** Points excluded — 0 when no external scanner was credited. */\n excludedExternalCredit: number;\n}\n\n/**\n * The part of a trust score mcpm produced itself, for evaluating SAFETY FLOORS\n * (TODOS #33).\n *\n * `HARD_TRUST_FLOOR` is documented as a gate no caller-supplied value can\n * lower. `MCPM_EXTERNAL_SCANNER` is caller-supplied — it names an arbitrary\n * executable — and a two-line script that prints `{\"findings\": []}` is\n * indistinguishable from a real clean scan, so before this the bucket's 20\n * points could lift a server over the floor. Reproduced: two critical tier-1\n * findings and no health check score 15 (blocked); with such a script\n * configured they score 35, and `mcpm_up` installed the server.\n *\n * The rule: third-party corroboration mcpm cannot verify may INFORM a score,\n * but must not be able to CLEAR a safety floor. So the floor is compared\n * against `score - breakdown.externalScan`.\n *\n * Note the asymmetry is deliberate and one-directional WITH RESPECT TO THE\n * EXTERNAL SCANNER. Removing only the bucket's CREDIT leaves every penalty an\n * external finding carries outside that bucket — most importantly the\n * critical/high cap on `registryMeta` — intact. An external scanner can\n * therefore still push a server DOWN through the floor, which is the\n * fail-closed direction, but can never push one UP through it.\n *\n * That is a claim about THIS bucket, not about the native figure in general.\n * The native buckets are not attacker-proof by construction: on the MCP surface\n * the caller also supplies the stack file, and a stack `policy` knob that\n * suppresses a native-bucket finding raises the figure this returns. The\n * release-age cooldown was exactly that (`minReleaseAgeHours: 0`, native\n * 20 → 25 against a floor of 25) and is now ignored whenever a floor is in\n * effect — see `processServer` in `src/commands/up.ts`. Any FUTURE policy knob\n * that can suppress a healthCheck / staticScan / registryMeta finding needs the\n * same treatment; this function cannot enforce that on its own.\n *\n * This is deliberately NOT applied to `mcpm install --min-trust`, to a stack\n * file's `policy.minTrustScore`, or to `mcpm audit --fix`. Those are thresholds a\n * human chose on their own machine, where the same person configures both the\n * threshold and the scanner; subtracting their scanner's points there would\n * surprise legitimate users for no security gain.\n *\n * `audit --fix` is excluded for a SECOND, independently sufficient reason, and it\n * is the one that decided the case (TODOS #35 sibling, resolved 2026-08-12): it is\n * the only score-gated DESTRUCTIVE gate in the CLI. Every other site on this list\n * REFUSES; that one DELETES servers from the user's IDE configs, so subtracting the\n * bucket there removes more rather than refusing more. Measured over 1,199 live\n * registry entries: a native filter changes 0 servers at audit's default threshold\n * and deletes every server once the threshold passes audit's native ceiling of 62.\n *\n * So the test is agent-reachability AND refuse-not-delete, not agent-reachability\n * alone — note that `policy.minTrustScore` is on this list yet IS reachable by an\n * agent through `mcpm_up` (`handlers.ts`), so the older one-line framing (\"the\n * floors this guards are the ones an AI agent, not a human, is on the other side\n * of\") was already too neat.\n */\nexport function nativeTrustScore(trust: TrustScore): NativeTrustScore {\n const score = trust.score - trust.breakdown?.externalScan;\n\n // Both gate expressions are `native.score < floor`, and `NaN < 25` is FALSE —\n // so a TrustScore carrying a partial `breakdown` would fail OPEN and install.\n // No production caller can produce one today (every wiring binds the real\n // `computeTrustScore`), but the lock file's `TrustSnapshot` is a\n // breakdown-less, TrustScore-shaped object that already exists and is already\n // read by a sibling gate, so the trajectory is short. Refuse rather than\n // coerce: a `?? 0` fallback would CREDIT the missing bucket as zero and pass.\n if (!Number.isFinite(score)) {\n throw new Error(\n \"Cannot evaluate a trust floor: the trust score has no usable breakdown. \" +\n \"This is a bug — report it rather than working around it.\",\n );\n }\n\n return {\n score,\n maxPossible: NATIVE_MAX_POSSIBLE,\n excludedExternalCredit: trust.breakdown.externalScan,\n };\n}\n\n/**\n * The best score any server could reach at a gate that runs BEFORE the health check.\n *\n * Every score-threshold gate in the product — `install --min-trust`,\n * `policy.minTrustScore`, `audit --fix --min-trust`, the MCP install floor, and\n * `mcpm_setup`'s pre-filter (a FIFTH gate that does not forward its threshold to the\n * install gate, so it needs its own check) — scores\n * with `healthCheckPassed: null` (the check runs after install, if at all), and\n * `extractRegistryMeta` never returns a download count anywhere in the product. So 18 of\n * the 80 mcpm-native points are unreachable AT THOSE CALL SITES: the best possible server\n * tops out at 62/80, or 82/100 once the external bucket is credited. A threshold above\n * that is unsatisfiable by construction — every server is refused, forever, and the\n * message blames the server rather than the threshold.\n *\n * The metadata here is deliberately the most FAVOURABLE a server could present, which\n * makes this a property of the CALL SITE (what a gate can measure) rather than of any one\n * server. Folding a server's own `publishedAt` / registry status in instead would move\n * the ceiling per server — on `audit --fix` that let a publisher republish to become\n * unremovable, inverting F4's release-age cooldown.\n *\n * ABSOLUTE vs PERCENTAGE is not interchangeable and has bitten this code before. Callers\n * comparing a raw score use `.score` (62); `policy.minTrustScore` is a PERCENTAGE and\n * must use `.score / .maxPossible * 100` (77.5). Passing 62 to the percentage gate would\n * silently under-guard by 15 points.\n *\n * Known residual, unchanged from TODOS #42: 62 is reachable only by a pypi/oci server.\n * Every npm package draws one `low` `install-script` finding for the `npx -y` launcher\n * class, so a clean npm server tops out at 60. `--min-trust 61..62` therefore stays\n * unsatisfiable for an all-npm stack — narrower than the band this closes, and stated\n * rather than papered over. In PERCENTAGE units, which is what `policy.minTrustScore`\n * reads, the same residual is 60/80 = 75% against a ceiling of 78: a policy of 76..78\n * is unsatisfiable for an all-npm stack while this guard reports it as satisfiable.\n *\n * Callers must key this on whether the score was CREDITED (`externalCredited`), never on\n * scanner AVAILABILITY. Those are different predicates: availability is a bare\n * `<cmd> --version` probe, while the scorer credits the bucket only when the scanner also\n * returned output it could read. An availability-keyed ceiling reads 82 for a run capped\n * at 62.\n */\nexport function maxAchievableBeforeHealthCheck(hasExternalScanner: boolean): TrustScore {\n return computeTrustScore({\n findings: [],\n healthCheckPassed: null,\n hasExternalScanner,\n registryMeta: {\n isVerifiedPublisher: true,\n publishedAt: \"1970-01-01T00:00:00.000Z\",\n downloadCount: undefined,\n },\n });\n}\n"],"mappings":"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","names":["externalCredited"]}
#!/usr/bin/env node
import {
handleLock,
registerLockCommand
} from "./chunk-APDTSW26.js";
import "./chunk-E3T224S3.js";
import "./chunk-RTI2GLYX.js";
import "./chunk-FEXJHHDM.js";
import "./chunk-U7N6FRYF.js";
import "./chunk-WT6V33F2.js";
import "./chunk-F6CHEUGO.js";
import "./chunk-7RJXJERN.js";
import "./chunk-V4AA4ZL5.js";
import "./chunk-32VRWVOF.js";
import "./chunk-K4U7EXLG.js";
import "./chunk-Y36MYEDT.js";
export {
handleLock,
registerLockCommand
};
//# sourceMappingURL=lock-FFJKG32M.js.map
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
#!/usr/bin/env node
import {
buildDriftFinding,
buildHandshakeDriftFinding,
classifyDrift,
classifyHandshakeDrift,
inspectForDrift,
inspectHandshakeForDrift
} from "./chunk-5W5Z3VZG.js";
import {
PolicyIntegrityError,
expireStale,
readPolicy
} from "./chunk-CYYYMOUS.js";
import {
hasToolsList,
inspectFrame,
mergeInspect,
withReplyToOrigin
} from "./chunk-XLPT6EJQ.js";
import {
hashConfineProfile,
loadProfile
} from "./chunk-544DEV2D.js";
import {
OWASP_MCP_TOP_10
} from "./chunk-4ANBMGU5.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 {
ACTION_RANK,
defaultActionForFinding,
inspectMessage
} from "./chunk-WT6V33F2.js";
import {
resolveEnvPlaceholders
} from "./chunk-NPJ3SGGS.js";
import {
getStorePath
} from "./chunk-3X76P3FG.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-QBWG3IJ2.js.map

Sorry, the diff of this file is too big to display

#!/usr/bin/env node
import {
buildDoctorModel,
execCheckDefault,
formatMcpEntryCommand,
makeCheckConfigExists
} from "./chunk-LJK7OC4C.js";
import {
resolveInstallEntry
} from "./chunk-HFNKEMBT.js";
import {
readPins
} from "./chunk-DDCTUMSZ.js";
import "./chunk-E3T224S3.js";
import {
fetchNpmProvenance
} from "./chunk-RTI2GLYX.js";
import "./chunk-WYSMWP2R.js";
import "./chunk-OIFKZA4V.js";
import "./chunk-FEXJHHDM.js";
import {
extractRegistryMeta
} from "./chunk-U7N6FRYF.js";
import "./chunk-WT6V33F2.js";
import "./chunk-F6CHEUGO.js";
import "./chunk-UNGY7RTE.js";
import "./chunk-W4IAFBUN.js";
import "./chunk-2PWW3Q5Q.js";
import {
fetchNpmIntegrity
} from "./chunk-7RJXJERN.js";
import "./chunk-K4U7EXLG.js";
import {
maxAchievableBeforeHealthCheck,
nativeTrustScore
} from "./chunk-Y36MYEDT.js";
import "./chunk-NPJ3SGGS.js";
import "./chunk-6R7TL5O2.js";
import {
CLIENT_IDS
} from "./chunk-R4R2VPDA.js";
import "./chunk-4QDJ3I7X.js";
import "./chunk-3X76P3FG.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);
const nativeTrust = nativeTrustScore(trust);
const gateCeiling = maxAchievableBeforeHealthCheck(false);
if (minScore > gateCeiling.score) {
throw new Error(
`minTrustScore ${minScore} is above ${gateCeiling.score}, the highest score this gate can award. Trust is scored before the health check runs and without a download count, so ${gateCeiling.maxPossible - gateCeiling.score} of ${gateCeiling.maxPossible} points are unreachable here \u2014 this is a property of the gate, not of "${args.name}", which scored ${nativeTrust.score}/${nativeTrust.maxPossible}. Use ${nativeTrust.score} or lower.`
);
}
if (nativeTrust.score < minScore) {
throw new Error(
`Server "${args.name}" has trust score ${nativeTrust.score}/${nativeTrust.maxPossible} (level: ${trust.level}), which is below the minimum threshold of ${minScore}. ` + (nativeTrust.excludedExternalCredit > 0 ? `An external scanner's ${nativeTrust.excludedExternalCredit} points are excluded from this floor because mcpm cannot verify them. ` : "") + `Install rejected for safety. Use mcpm CLI with --yes to override after manual review.`
);
}
const clients = await resolveClients(args.client, deps);
const planned = clients.map((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.`
);
}
return {
clientId: clientId2,
adapter: deps.getAdapter(clientId2),
configPath: deps.getConfigPath(clientId2),
mcpEntry
};
});
const done = [];
try {
for (const p of planned) {
await p.adapter.addServer(p.configPath, args.name, p.mcpEntry);
done.push(p);
}
await deps.addToStore({
name: args.name,
version: entry.server.version,
clients: done.map((p) => p.clientId),
installedAt: (/* @__PURE__ */ new Date()).toISOString()
});
} catch (err) {
const stranded = await rollbackInstall(done, args.name);
if (stranded.length > 0) {
throw new Error(
`${err instanceof Error ? err.message : String(err)}
Rollback incomplete: "${args.name}" is STILL INSTALLED in ${stranded.join(", ")}. Remove it with \`mcpm remove ${args.name}\` before retrying.`
);
}
throw err;
}
return {
installed: true,
name: args.name,
version: entry.server.version,
clients: done.map((p) => p.clientId),
trustScore: trust
};
}
async function rollbackInstall(done, name) {
const stranded = [];
for (const p of done) {
try {
await p.adapter.removeServer(p.configPath, name);
} catch {
stranded.push(p.clientId);
}
}
return stranded;
}
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 = Math.max(
effectiveMinTrustScore(args.minTrustScore),
DEFAULT_MIN_TRUST_SCORE
);
const setupCeiling = maxAchievableBeforeHealthCheck(false);
if (minScore > setupCeiling.score) {
throw new Error(
`minTrustScore ${minScore} is above ${setupCeiling.score}, the highest score this gate can award. Trust is scored before the health check runs and without a download count, so ${setupCeiling.maxPossible - setupCeiling.score} of ${setupCeiling.maxPossible} points are unreachable here \u2014 no server can satisfy it, so every match would be reported as untrusted regardless of its evidence. Use ${setupCeiling.score} or lower.`
);
}
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;
}
const bestNative = nativeTrustScore(bestTrust);
if (bestNative.score < minScore) {
skipped.push({
name: bestEntry.server.name,
reason: `Trust score ${bestNative.score}/${bestNative.maxPossible} is below minimum ${minScore}` + (bestNative.excludedExternalCredit > 0 ? ` (an external scanner's ${bestNative.excludedExternalCredit} points are excluded \u2014 mcpm cannot verify them)` : "")
});
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-ZSLAYFCC.js");
const { writeFile } = await import("fs/promises");
const { handleLock } = await import("./lock-FFJKG32M.js");
const { RegistryClient } = await import("./client-3RPMRFZL.js");
const { scanTier1: st1 } = await import("./tier1-OPUMS3NX.js");
const { checkScannerAvailable: csa, scanTier2: st2 } = await import("./tier2-PI43NCHZ.js");
const { computeTrustScore: cts } = await import("./trust-score-YBKORPZO.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-OPUMS3NX.js");
const { computeTrustScore } = await import("./trust-score-YBKORPZO.js");
const { addInstalledServer, removeInstalledServer } = await import("./servers-IS6ZWSYC.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.30.0"
});
registerTools(server, deps);
const transport = new StdioServerTransport();
await server.connect(transport);
}
export {
registerTools,
startServer
};
//# sourceMappingURL=server-J76245G7.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). Scored before any health check, so 62 is the highest attainable; above that is refused as unsatisfiable rather than applied.\" },\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). Scored before any health check, so 62 is the highest attainable; above that is refused as unsatisfiable rather than applied.\" },\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, McpServerEntry } 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 { maxAchievableBeforeHealthCheck, nativeTrustScore } 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 *\n * Lowering the gate is only half of it. A score can also be pushed UP to meet\n * the gate, and `MCPM_EXTERNAL_SCANNER` is caller-supplied too — so the floor is\n * evaluated against `nativeTrustScore`, which excludes the external bucket's\n * unverifiable credit (TODOS #33).\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 //\n // TODOS #33: compared against mcpm's OWN evidence. `computeTrust` above already\n // passes `hasExternalScanner: false`, so today this subtracts nothing — it is\n // here so that wiring a scanner into this path later cannot silently reopen the\n // floor, which is the failure mode #33 found on the sibling `mcpm_up` path.\n const minScore = effectiveMinTrustScore(args.minTrustScore);\n //\n // TODOS #45: an agent asking for a natural-sounding `minTrustScore: 70` gets EVERY\n // server rejected, because `computeTrust` above scores with `healthCheckPassed: null`\n // and `hasExternalScanner: false` — a ceiling of 62. With no human in the loop the\n // agent has no way to tell \"this server is untrustworthy\" from \"no server can ever\n // pass\", and the honest reading of a blanket rejection is that the ecosystem is\n // unsafe. Say which one it is. `false` is not a guess: `computeTrust` hardcodes it,\n // so this path's ceiling is the native one unconditionally.\n const nativeTrust = nativeTrustScore(trust);\n const gateCeiling = maxAchievableBeforeHealthCheck(false);\n if (minScore > gateCeiling.score) {\n // Recommend the OBSERVED score, not the ceiling — `audit`'s sibling guard does the\n // same, and recommending 62 would itself be unsatisfiable for any npm server (they\n // cap at 60 on the `npx -y` launcher class), producing a second rejection.\n throw new Error(\n `minTrustScore ${minScore} is above ${gateCeiling.score}, the highest score this gate can ` +\n `award. Trust is scored before the health check runs and without a download count, so ` +\n `${gateCeiling.maxPossible - gateCeiling.score} of ${gateCeiling.maxPossible} points are unreachable here — ` +\n `this is a property of the gate, not of \"${args.name}\", which scored ` +\n `${nativeTrust.score}/${nativeTrust.maxPossible}. Use ${nativeTrust.score} or lower.`\n );\n }\n if (nativeTrust.score < minScore) {\n throw new Error(\n `Server \"${args.name}\" has trust score ${nativeTrust.score}/${nativeTrust.maxPossible} ` +\n `(level: ${trust.level}), which is below the minimum threshold of ${minScore}. ` +\n (nativeTrust.excludedExternalCredit > 0\n ? `An external scanner's ${nativeTrust.excludedExternalCredit} points are excluded from this floor because mcpm cannot verify them. `\n : \"\") +\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 // PRE-FLIGHT: resolve and validate EVERY client's entry before touching a single\n // config. resolveInstallEntry's URL rule is cursor-ONLY, so a server carrying both\n // an npm package and an http remote used to install cleanly on claude-desktop and\n // only THEN hit the H9 deny on cursor — the agent was told the install failed while\n // a live execution surface sat in Claude Desktop, with no store record of it.\n const planned = clients.map((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 return {\n clientId,\n adapter: deps.getAdapter(clientId),\n configPath: deps.getConfigPath(clientId),\n mcpEntry,\n };\n });\n\n // APPLY as a unit. Any failure — a client write or the store write — unwinds every\n // write already made, so this tool never reports failure over a live install.\n // The store write is inside the transaction on purpose: configs written without a\n // store record are invisible to `mcpm list` / `audit` and survive `mcpm remove`.\n const done: PlannedInstall[] = [];\n try {\n for (const p of planned) {\n await p.adapter.addServer(p.configPath, args.name, p.mcpEntry);\n done.push(p);\n }\n await deps.addToStore({\n name: args.name,\n version: entry.server.version,\n clients: done.map((p) => p.clientId),\n installedAt: new Date().toISOString(),\n });\n } catch (err) {\n const stranded = await rollbackInstall(done, args.name);\n if (stranded.length > 0) {\n // Rollback is best-effort and can fail too. Swallowing that would report a\n // clean failure over a server that is still installed — name it instead.\n throw new Error(\n `${err instanceof Error ? err.message : String(err)}\\n\\n` +\n `Rollback incomplete: \"${args.name}\" is STILL INSTALLED in ${stranded.join(\", \")}. ` +\n `Remove it with \\`mcpm remove ${args.name}\\` before retrying.`\n );\n }\n throw err;\n }\n\n return {\n installed: true,\n name: args.name,\n version: entry.server.version,\n clients: done.map((p) => p.clientId),\n trustScore: trust,\n };\n}\n\n/** One client's fully-resolved install, validated and ready to write. */\ntype PlannedInstall = {\n clientId: ClientId;\n adapter: ConfigAdapter;\n configPath: string;\n mcpEntry: McpServerEntry;\n};\n\n/**\n * Undo the client-config writes already made by a failed install.\n * @returns the clients that could NOT be rolled back (still installed).\n */\nasync function rollbackInstall(done: PlannedInstall[], name: string): Promise<ClientId[]> {\n const stranded: ClientId[] = [];\n for (const p of done) {\n try {\n await p.adapter.removeServer(p.configPath, name);\n } catch {\n stranded.push(p.clientId);\n }\n }\n return stranded;\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 //\n // Also clamp UP to handleInstall's own default. This pre-filter delegates to\n // handleInstall without forwarding minTrustScore, so the enforcing gate always\n // applies DEFAULT_MIN_TRUST_SCORE. With a requested 25-49 the two disagreed:\n // the pre-filter waved the server through and handleInstall then refused it,\n // reporting a trust rejection as \"Install failed\" and quoting a threshold the\n // caller never asked for. Taking the stricter of the two makes the pre-filter\n // report exactly what the enforcing gate will do. Deliberately NOT fixed by\n // forwarding minTrustScore instead -- that would let a caller-supplied 30\n // LOWER the gate this path enforces today.\n const minScore = Math.max(\n effectiveMinTrustScore(args.minTrustScore),\n DEFAULT_MIN_TRUST_SCORE,\n );\n\n // TODOS #45, fifth gate. This pre-filter deliberately does NOT forward minTrustScore to\n // handleInstall (forwarding would let a caller-supplied 30 LOWER the enforcing gate), so\n // handleInstall's ceiling guard can never fire from here — this path needs its own. It\n // is the worst place to omit it: every keyword reports its best match as \"below\n // minimum\", and an agent reading a blanket rejection concludes the ecosystem is unsafe.\n //\n // Placed AFTER the Math.max clamp, or a requested 0 would be compared against the\n // ceiling before the clamp raised it. `false` is exact: `computeTrust` hardcodes\n // `hasExternalScanner: false`. Throws rather than pushing a `skipped` row — it is a\n // caller error about the threshold, and a `skipped` row would reintroduce the\n // blame-the-server framing this removes.\n const setupCeiling = maxAchievableBeforeHealthCheck(false);\n if (minScore > setupCeiling.score) {\n throw new Error(\n `minTrustScore ${minScore} is above ${setupCeiling.score}, the highest score this gate can ` +\n `award. Trust is scored before the health check runs and without a download count, so ` +\n `${setupCeiling.maxPossible - setupCeiling.score} of ${setupCeiling.maxPossible} points are unreachable ` +\n `here — no server can satisfy it, so every match would be reported as untrusted ` +\n `regardless of its evidence. Use ${setupCeiling.score} or lower.`\n );\n }\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 // TODOS #33: the same native-evidence rule as the sibling gates. handleInstall\n // below re-checks and is the enforcing gate, so this pre-filter exists to\n // produce an accurate \"skipped\" reason rather than an \"Install failed\" one —\n // but it must agree with it, or a server rejected downstream gets reported\n // under the wrong heading with a score that was never compared.\n const bestNative = nativeTrustScore(bestTrust);\n if (bestNative.score < minScore) {\n skipped.push({\n name: bestEntry.server.name,\n reason:\n `Trust score ${bestNative.score}/${bestNative.maxPossible} is below minimum ${minScore}` +\n (bestNative.excludedExternalCredit > 0\n ? ` (an external scanner's ${bestNative.excludedExternalCredit} points are excluded — mcpm cannot verify them)`\n : \"\"),\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"]}
#!/usr/bin/env node
import {
EXTERNAL_SCAN_MAX,
computeTrustScore,
externalCredited,
maxAchievableBeforeHealthCheck,
nativeTrustScore
} from "./chunk-Y36MYEDT.js";
export {
EXTERNAL_SCAN_MAX,
computeTrustScore,
externalCredited,
maxAchievableBeforeHealthCheck,
nativeTrustScore
};
//# sourceMappingURL=trust-score-YBKORPZO.js.map
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
#!/usr/bin/env node
import {
handleUp,
registerUpCommand
} from "./chunk-EF3HQG5R.js";
import "./chunk-APDTSW26.js";
import "./chunk-HFNKEMBT.js";
import "./chunk-DDCTUMSZ.js";
import "./chunk-E3T224S3.js";
import "./chunk-RTI2GLYX.js";
import "./chunk-OIFKZA4V.js";
import "./chunk-FEXJHHDM.js";
import "./chunk-U7N6FRYF.js";
import "./chunk-WT6V33F2.js";
import "./chunk-F6CHEUGO.js";
import "./chunk-UNGY7RTE.js";
import "./chunk-W4IAFBUN.js";
import "./chunk-2PWW3Q5Q.js";
import "./chunk-MLVDFLDQ.js";
import "./chunk-7RJXJERN.js";
import "./chunk-V4AA4ZL5.js";
import "./chunk-32VRWVOF.js";
import "./chunk-K4U7EXLG.js";
import "./chunk-Y36MYEDT.js";
import "./chunk-NPJ3SGGS.js";
import "./chunk-6R7TL5O2.js";
import "./chunk-R4R2VPDA.js";
import "./chunk-4QDJ3I7X.js";
import "./chunk-3X76P3FG.js";
export {
handleUp,
registerUpCommand
};
//# sourceMappingURL=up-ZSLAYFCC.js.map
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
+1
-1

@@ -16,4 +16,4 @@ #!/usr/bin/env node

import "./chunk-FEXJHHDM.js";
import "./chunk-WT6V33F2.js";
import "./chunk-3X76P3FG.js";
import "./chunk-WT6V33F2.js";
export {

@@ -20,0 +20,0 @@ acceptDriftCommand,

{
"name": "@getmcpm/cli",
"version": "0.29.1",
"version": "0.30.0",
"mcpName": "io.github.getmcpm/cli",

@@ -39,8 +39,8 @@ "description": "MCP package manager — search, install, and audit MCP servers across Claude Desktop, Cursor, VS Code, and Windsurf",

"@inquirer/prompts": "^8.5.2",
"@modelcontextprotocol/sdk": "^1.29.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"@sigstore/bundle": "^5.0.0",
"@sigstore/protobuf-specs": "^0.5.1",
"@sigstore/verify": "^4.1.0",
"@sigstore/verify": "^4.1.2",
"@types/proper-lockfile": "^4.1.4",
"chalk": "^5.6.2",
"chalk": "^6.0.0",
"cli-table3": "^0.6.5",

@@ -56,3 +56,3 @@ "commander": "^15.0.0",

"@types/node": "^22.19.19",
"@types/semver": "^7.7.1",
"@types/semver": "^7.8.0",
"@vitest/coverage-v8": "^4.1.0",

@@ -59,0 +59,0 @@ "tsup": "^8.5.1",

#!/usr/bin/env node
import {
DEFAULT_MIN_RELEASE_AGE_HOURS,
assessReleaseAge,
stdoutOutput
} from "./chunk-E3T224S3.js";
import {
compareProvenance,
fetchNpmProvenance,
isLockedRegistryServer,
isRegistryServer,
isUrlServer,
parseLockFile,
parseStackFile,
serializeYaml
} from "./chunk-RTI2GLYX.js";
import {
sanitizeForTerminal
} from "./chunk-FEXJHHDM.js";
import {
checkScannerAvailable,
scanTier2
} from "./chunk-F6CHEUGO.js";
import {
computeTrustScore
} from "./chunk-LSNEZAFR.js";
import {
fetchNpmIntegrity
} from "./chunk-7RJXJERN.js";
import {
RegistryClient
} from "./chunk-V4AA4ZL5.js";
import {
extractRegistryMeta,
scanTier1
} from "./chunk-U7N6FRYF.js";
// src/stack/resolve.ts
import semver from "semver";
function resolveVersion(serverName, range, available) {
const validVersions = available.filter((v) => semver.valid(v) !== null);
if (range === "latest") {
if (validVersions.length === 0) {
throw new Error(`No versions available for "${serverName}".`);
}
const sorted = [...validVersions].sort(semver.rcompare);
return { resolved: sorted[0], range, available: validVersions };
}
if (semver.valid(range) !== null) {
const exact = validVersions.find((v) => semver.eq(v, range));
if (exact) {
return { resolved: exact, range, available: validVersions };
}
throw new Error(
`Version "${range}" not found for "${serverName}". Available: ${formatVersionList(validVersions)}`
);
}
const match = semver.maxSatisfying(validVersions, range);
if (match !== null) {
return { resolved: match, range, available: validVersions };
}
throw new Error(
`No version satisfies "${range}" for "${serverName}". Available: ${formatVersionList(validVersions)}`
);
}
function resolveWithSingleVersion(serverName, range, singleVersion) {
if (range === "latest") {
return { resolved: singleVersion, range, available: [singleVersion] };
}
if (semver.valid(range) !== null) {
if (semver.eq(singleVersion, range)) {
return { resolved: singleVersion, range, available: [singleVersion] };
}
throw new Error(
`Version "${range}" not found for "${serverName}". Only version available: ${singleVersion}`
);
}
if (semver.satisfies(singleVersion, range)) {
return { resolved: singleVersion, range, available: [singleVersion] };
}
throw new Error(
`Version "${singleVersion}" does not satisfy "${range}" for "${serverName}". This is the only version available from the registry.`
);
}
function formatVersionList(versions) {
if (versions.length === 0) return "(none)";
const sorted = [...versions].sort(semver.rcompare);
if (sorted.length <= 5) return sorted.join(", ");
return `${sorted.slice(0, 5).join(", ")} (+${sorted.length - 5} more)`;
}
// src/stack/paths.ts
function lockPathFor(stackPath) {
return stackPath.replace(/(\.ya?ml)?$/i, "-lock$1");
}
// src/commands/lock.ts
import { valid as semverValid } from "semver";
import "commander";
import { writeFile } from "fs/promises";
async function handleLock(options, deps) {
const stackPath = options.stackFile ?? "mcpm.yaml";
const lockPath = lockPathFor(stackPath);
const stackFile = await parseStackFile(stackPath);
const scannerAvailable = await deps.checkScannerAvailable();
const entries = Object.entries(stackFile.servers);
const minAgeHours = stackFile.policy?.minReleaseAgeHours ?? DEFAULT_MIN_RELEASE_AGE_HOURS;
const prevLock = deps.readExistingLock ? await deps.readExistingLock(lockPath).catch(() => null) : null;
const prevProvenance = buildPrevProvenanceMap(prevLock);
const settlements = await Promise.all(
entries.map(
([name, server]) => resolveServer(name, server, scannerAvailable, minAgeHours, deps, prevProvenance.get(name)).then((locked) => ({ name, locked })).catch((err) => ({
name,
error: err instanceof Error ? err.message : String(err)
}))
)
);
const results = [];
const errors = [];
for (const s of settlements) {
if ("locked" in s) {
results.push(s);
} else {
errors.push(s);
}
}
const lockedServers = {};
for (const { name, locked } of results) {
lockedServers[name] = locked;
}
if (errors.length > 0) {
deps.output("");
for (const { name, error } of errors) {
deps.output(` Failed: ${name} \u2014 ${error}`);
}
throw new Error(
`${errors.length} server(s) failed to resolve \u2014 lock not written (${lockPath} left unchanged).
Fix the entries above and re-run \`mcpm lock\`; a partial lock would verify green while enforcing less than you declared.`
);
}
const lockFile = {
lockfileVersion: 1,
lockedAt: (/* @__PURE__ */ new Date()).toISOString(),
servers: lockedServers
};
await deps.writeLockFile(lockPath, serializeYaml(lockFile));
deps.output(`Locked ${results.length} servers to ${lockPath}`);
reportProvenanceDrift(prevLock, results, deps.output);
}
function provenanceOf(server) {
return server && isLockedRegistryServer(server) ? server.provenance : void 0;
}
function repoLabel(snap) {
const raw = snap?.identity?.sourceRepo ?? snap?.identity?.repositoryId ?? "unknown source";
return sanitizeForTerminal(raw);
}
function buildPrevProvenanceMap(prevLock) {
const map = /* @__PURE__ */ new Map();
if (!prevLock) return map;
for (const [name, server] of Object.entries(prevLock.servers)) {
if (isLockedRegistryServer(server) && server.provenance) {
map.set(name, { identifier: server.identifier, snapshot: server.provenance });
}
}
return map;
}
function reportProvenanceDrift(prevLock, results, output) {
if (!prevLock) return;
for (const { name, locked } of results) {
const prevServer = prevLock.servers[name];
const prev = provenanceOf(prevServer);
const next = provenanceOf(locked);
const prevId = isLockedRegistryServer(prevServer) ? prevServer.identifier : void 0;
const nextId = isLockedRegistryServer(locked) ? locked.identifier : void 0;
const sameCoordinate = prevId !== void 0 && prevId === nextId && prev?.npmVersion === next?.npmVersion;
switch (compareProvenance(prev, next)) {
case "identity-drift":
output(
sameCoordinate ? ` \u26A0 provenance identity changed for ${name} on the SAME version ${next?.npmVersion} (${repoLabel(prev)} \u2192 ${repoLabel(next)}) \u2014 an immutable coordinate's attestation should never change publisher; treat as a possible attestation swap and verify before shipping.` : ` \u26A0 provenance identity changed for ${name}: ${repoLabel(prev)} \u2192 ${repoLabel(next)} \u2014 expected if the project moved repos/CI; investigate if not.`
);
break;
case "signed-to-unsigned":
output(
` \u26A0 provenance dropped for ${name}: was attested (${repoLabel(prev)}), now unsigned \u2014 a poisoned republish can look like this; verify before shipping.`
);
break;
}
if (prev?.status === "attested" && prev.verification?.outcome === "verified" && next !== void 0 && next.status !== "unsigned" && !(next.status === "attested" && next.verification?.outcome === "verified")) {
output(
` \u26A0 provenance verification downgraded for ${name}: was cryptographically verified, now unverified \u2014 \`mcpm verify\`/\`up --frozen\` no longer crypto-check it. Investigate a swap; if the new version legitimately dropped or changed provenance, re-baseline knowingly.`
);
}
}
}
async function resolveServer(name, server, scannerAvailable, minAgeHours, deps, prevProvenance) {
if (isUrlServer(server)) {
return { url: server.url };
}
if (!isRegistryServer(server)) {
throw new Error(`Invalid server entry for "${name}"`);
}
let resolvedVersion;
try {
const versions = await deps.getServerVersions(name);
const versionStrings = versions.map((v) => v.version);
const result = resolveVersion(name, server.version, versionStrings);
resolvedVersion = result.resolved;
} catch {
const entry = await deps.getServer(name);
const result = resolveWithSingleVersion(
name,
server.version,
entry.server.version
);
resolvedVersion = result.resolved;
}
const serverEntry = await deps.getServer(name, resolvedVersion);
const tier1Findings = deps.scanTier1(serverEntry);
let allFindings = [...tier1Findings];
if (scannerAvailable) {
const tier2Findings = await deps.scanTier2(name);
allFindings = [...allFindings, ...tier2Findings];
}
const registryMeta = extractRegistryMeta(serverEntry);
const releaseAge = assessReleaseAge({
publishedAt: registryMeta.publishedAt,
now: (deps.now ?? Date.now)(),
minAgeHours
});
if (releaseAge.finding) {
allFindings = [...allFindings, releaseAge.finding];
}
const trustInput = {
findings: allFindings,
healthCheckPassed: null,
hasExternalScanner: scannerAvailable,
registryMeta
};
const trustScore = deps.computeTrustScore(trustInput);
const pkg = serverEntry.server.packages.find((p) => p.registryType === "npm") ?? serverEntry.server.packages.find((p) => p.registryType === "pypi") ?? serverEntry.server.packages.find((p) => p.registryType === "oci") ?? serverEntry.server.packages[0];
const snapshot = {
score: trustScore.score,
maxPossible: trustScore.maxPossible,
level: trustScore.level,
assessedAt: (/* @__PURE__ */ new Date()).toISOString(),
// TODOS #35: record the external-scanner credit so the drop tripwire can
// recover this baseline's native figure later. Always written (0 when no
// scanner was credited) so every lock this mcpm writes is native-recoverable.
externalScanCredit: trustScore.breakdown.externalScan
};
const isConcreteNpm = pkg?.registryType === "npm" && semverValid(pkg.version ?? null) !== null;
let npmIntegritySnap;
if (isConcreteNpm) {
npmIntegritySnap = await deps.fetchNpmIntegrity(
pkg.identifier,
pkg.version
);
}
let provenanceSnap;
if (isConcreteNpm && deps.fetchNpmProvenance) {
provenanceSnap = await deps.fetchNpmProvenance(
pkg.identifier,
pkg.version,
npmIntegritySnap?.integrity
);
}
const prevSnap = prevProvenance?.snapshot;
const sameCoordinate = isConcreteNpm && prevProvenance?.identifier === pkg?.identifier && prevSnap?.status === "attested" && prevSnap.npmVersion === pkg?.version;
const prevWasVerified = prevSnap?.verification?.outcome === "verified";
const freshVerified = provenanceSnap?.status === "attested" && provenanceSnap.verification?.outcome === "verified";
const freshUnreadable = provenanceSnap === void 0 || provenanceSnap.status === "unsupported";
if (sameCoordinate && (prevWasVerified && !freshVerified || freshUnreadable)) {
const activelyContradicts = provenanceSnap?.status === "unsigned" || provenanceSnap?.status === "unsupported" || provenanceSnap?.verification?.outcome === "could-not-verify";
if (prevWasVerified && activelyContradicts) {
deps.output(
` \u26A0 provenance verification regressed for ${name}: was cryptographically verified, now fails to verify \u2014 a poisoned attestation swap can look like this. If npm's record is unchanged, your mcpm/@sigstore version may have changed since you locked; run \`mcpm verify\`. If the change is expected, remove this server's \`provenance:\` block from the lock and re-lock to re-baseline.`
);
}
provenanceSnap = prevSnap;
}
return {
version: resolvedVersion,
registryType: pkg?.registryType ?? "unknown",
identifier: pkg?.identifier ?? name,
trust: snapshot,
...npmIntegritySnap ? { npmIntegrity: npmIntegritySnap } : {},
...provenanceSnap ? { provenance: provenanceSnap } : {}
};
}
function registerLockCommand(program) {
program.command("lock").description(
"Resolve versions and create mcpm-lock.yaml with trust snapshots"
).option("-f, --file <path>", "path to mcpm.yaml", "mcpm.yaml").action(async (opts) => {
const chalk = (await import("chalk")).default;
const client = new RegistryClient();
try {
await handleLock(
{ stackFile: opts.file },
{
getServerVersions: (name) => client.getServerVersions(name),
getServer: (name, version) => client.getServer(name, version),
scanTier1,
checkScannerAvailable,
scanTier2: (name) => scanTier2(name),
computeTrustScore,
now: () => Date.now(),
writeLockFile: (path, content) => writeFile(path, content, { encoding: "utf-8", mode: 384 }),
fetchNpmIntegrity,
fetchNpmProvenance: (id, ver, sri) => fetchNpmProvenance(id, ver, { integritySri: sri }),
readExistingLock: (p) => parseLockFile(p),
output: stdoutOutput
}
);
} catch (err) {
console.error(chalk.red(err.message));
process.exit(1);
}
});
}
export {
lockPathFor,
handleLock,
registerLockCommand
};
//# sourceMappingURL=chunk-C6CAHFQX.js.map
{"version":3,"sources":["../src/stack/resolve.ts","../src/stack/paths.ts","../src/commands/lock.ts"],"sourcesContent":["/**\n * Version resolution — resolves semver ranges against available versions.\n *\n * Uses the `semver` package for range matching.\n * Pure functions, no I/O.\n */\n\nimport semver from \"semver\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ResolveResult {\n readonly resolved: string;\n readonly range: string;\n readonly available: readonly string[];\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve a version range against a list of available versions.\n *\n * Supports exact versions (\"1.2.3\"), caret ranges (\"^1.0.0\"),\n * tilde ranges (\"~1.2.0\"), and the \"latest\" alias (highest available).\n *\n * @param serverName — used only for error messages\n * @param range — the version range from mcpm.yaml\n * @param available — version strings from the registry\n * @returns the highest satisfying version\n * @throws if no version satisfies the range\n */\nexport function resolveVersion(\n serverName: string,\n range: string,\n available: readonly string[]\n): ResolveResult {\n // Filter to valid semver strings only (registry may return non-semver)\n const validVersions = available.filter((v) => semver.valid(v) !== null);\n\n // \"latest\" alias — highest available version\n if (range === \"latest\") {\n if (validVersions.length === 0) {\n throw new Error(`No versions available for \"${serverName}\".`);\n }\n const sorted = [...validVersions].sort(semver.rcompare);\n return { resolved: sorted[0], range, available: validVersions };\n }\n\n // Exact version match — skip range resolution\n if (semver.valid(range) !== null) {\n const exact = validVersions.find((v) => semver.eq(v, range));\n if (exact) {\n return { resolved: exact, range, available: validVersions };\n }\n throw new Error(\n `Version \"${range}\" not found for \"${serverName}\". ` +\n `Available: ${formatVersionList(validVersions)}`\n );\n }\n\n // Range resolution (caret, tilde)\n const match = semver.maxSatisfying(validVersions, range);\n if (match !== null) {\n return { resolved: match, range, available: validVersions };\n }\n\n throw new Error(\n `No version satisfies \"${range}\" for \"${serverName}\". ` +\n `Available: ${formatVersionList(validVersions)}`\n );\n}\n\n/**\n * Resolve a version range using a single version (fallback path).\n *\n * When the registry only returns the latest version (no version listing\n * endpoint), check if the single version satisfies the range.\n */\nexport function resolveWithSingleVersion(\n serverName: string,\n range: string,\n singleVersion: string\n): ResolveResult {\n // \"latest\" alias always accepts the single available version\n if (range === \"latest\") {\n return { resolved: singleVersion, range, available: [singleVersion] };\n }\n\n if (semver.valid(range) !== null) {\n // Exact match required\n if (semver.eq(singleVersion, range)) {\n return { resolved: singleVersion, range, available: [singleVersion] };\n }\n throw new Error(\n `Version \"${range}\" not found for \"${serverName}\". ` +\n `Only version available: ${singleVersion}`\n );\n }\n\n if (semver.satisfies(singleVersion, range)) {\n return { resolved: singleVersion, range, available: [singleVersion] };\n }\n\n throw new Error(\n `Version \"${singleVersion}\" does not satisfy \"${range}\" for \"${serverName}\". ` +\n `This is the only version available from the registry.`\n );\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction formatVersionList(versions: readonly string[]): string {\n if (versions.length === 0) return \"(none)\";\n const sorted = [...versions].sort(semver.rcompare);\n if (sorted.length <= 5) return sorted.join(\", \");\n return `${sorted.slice(0, 5).join(\", \")} (+${sorted.length - 5} more)`;\n}\n","/**\n * Stack/lock path derivation — one definition, four consumers.\n *\n * `lock`, `up`, `verify` and `diff` each derived the lock path inline with\n * `stackPath.replace(/\\.yaml$/, \"-lock.yaml\")`. That regex is anchored AND\n * case-sensitive, so any stack path not ending in exactly `.yaml` was returned\n * UNCHANGED — making `lockPath === stackPath`. For `mcpm lock` that meant a plain\n * `writeFile` of the lock over the user's own stack file: their server\n * declarations replaced by generated lock content, reported as success, exit 0.\n * `mcpm.yml` is the obvious case (docker-compose / GitHub Actions habit), but\n * `mcpm.YAML`, `mcpm.yaml.bak` and an extensionless `stack` all self-destructed.\n *\n * The first fix STRIPPED any yaml extension and appended a fixed `-lock.yaml`. That\n * cured the self-overwrite but was not INJECTIVE: `mcpm.yaml`, `mcpm.yml` and\n * `mcpm.YAML` all normalised onto the single path `mcpm-lock.yaml`. With two such\n * files in one directory — a production stack and a scratch copy — `mcpm lock -f\n * mcpm.yml` overwrote the OTHER stack's lock, discarding its trust snapshots and\n * sticky Sigstore provenance baselines, reported as success, exit 0. Proving that the\n * output differs from its own input says nothing about two inputs sharing an output.\n *\n * So the extension is PRESERVED and `-lock` inserted before it. `mcpm.yaml` still maps\n * to `mcpm-lock.yaml`, byte-identical, so no existing lock file moves.\n *\n * Injective, by invertibility: the output is the input with `-lock` inserted at a\n * position recoverable from the output itself (immediately before a trailing yaml\n * extension, or at the end when there is none — `-lock` never ends in a yaml\n * extension, so the two cases cannot be confused). A map you can invert cannot merge\n * two inputs. Parameterising the `-lock` infix would void that argument.\n *\n * A path with no yaml extension gets `-lock` appended and nothing invented:\n * defaulting to `.yaml` would put `stack` and `stack.yaml` back on one output.\n */\n\n/** Derive the lock-file path that belongs to a stack file. */\nexport function lockPathFor(stackPath: string): string {\n return stackPath.replace(/(\\.ya?ml)?$/i, \"-lock$1\");\n}\n","/**\n * `mcpm lock` command handler.\n *\n * Reads mcpm.yaml, resolves version ranges against the registry,\n * runs trust assessment per server, and writes mcpm-lock.yaml.\n *\n * URL-based servers are pinned directly (no version resolution).\n * Per-server errors are collected and reported; one failure does not\n * block resolution of other servers.\n *\n * Exports:\n * - handleLock() — injectable handler for testing\n * - registerLockCommand() — Commander registration\n */\n\nimport type { ClientId } from \"../config/paths.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 type {\n StackFile,\n StackServer,\n LockFile,\n LockedServer,\n TrustSnapshot,\n NpmIntegritySnapshot,\n NpmProvenanceSnapshot,\n} from \"../stack/schema.js\";\nimport {\n parseStackFile,\n serializeYaml,\n isRegistryServer,\n isUrlServer,\n isLockedRegistryServer,\n} from \"../stack/schema.js\";\nimport { compareProvenance } from \"../registry/npm-provenance.js\";\nimport { sanitizeForTerminal } from \"../guard/sanitize.js\";\nimport { resolveVersion, resolveWithSingleVersion } from \"../stack/resolve.js\";\nimport { lockPathFor } from \"../stack/paths.js\";\nimport { valid as semverValid } from \"semver\";\nimport { assessReleaseAge, DEFAULT_MIN_RELEASE_AGE_HOURS } from \"../scanner/cooldown.js\";\nimport { extractRegistryMeta } from \"../utils/format-trust.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface LockOptions {\n stackFile?: string;\n}\n\nexport interface LockDeps {\n getServerVersions: (name: string) => Promise<{ version: string }[]>;\n getServer: (name: string, version?: string) => Promise<ServerEntry>;\n scanTier1: (server: ServerEntry) => Finding[];\n checkScannerAvailable: () => Promise<boolean>;\n scanTier2: (name: string) => Promise<Finding[]>;\n computeTrustScore: (input: TrustScoreInput) => TrustScore;\n /** Epoch-ms clock for release-age assessment; defaults to Date.now at the CLI boundary. */\n now?: () => number;\n writeLockFile: (path: string, content: string) => Promise<void>;\n output: (text: string) => void;\n /**\n * H11 slice 1: fetch npm's published dist.integrity for an exact package\n * coordinate. FAIL-OPEN: returns undefined on any error. When undefined the\n * snapshot is omitted and lock never blocks.\n */\n fetchNpmIntegrity: (\n identifier: string,\n npmVersion: string\n ) => Promise<NpmIntegritySnapshot | undefined>;\n /**\n * F8 slice 1: fetch npm's parse-only provenance record for an exact npm\n * coordinate. Optional — capture is skipped when absent. FAIL-OPEN (undefined).\n */\n fetchNpmProvenance?: (\n identifier: string,\n npmVersion: string,\n /** F8 crypto slice: dist.integrity SRI for subject-binding the attestation. */\n integritySri?: string\n ) => Promise<NpmProvenanceSnapshot | undefined>;\n /**\n * F8 slice 1: read the PREVIOUS lock (before overwrite) so provenance-identity\n * drift can be reported. Optional — drift check is skipped when absent.\n */\n readExistingLock?: (lockPath: string) => Promise<LockFile | null>;\n}\n\n// ---------------------------------------------------------------------------\n// Handler\n// ---------------------------------------------------------------------------\n\ninterface LockResult {\n readonly name: string;\n readonly locked: LockedServer;\n}\n\ninterface LockError {\n readonly name: string;\n readonly error: string;\n}\n\n/**\n * Core handler for `mcpm lock`.\n *\n * Resolves all servers in mcpm.yaml, runs trust assessment, writes lock file.\n *\n * Per-server errors are collected rather than short-circuiting, so ONE bad entry\n * still reports every other failure in the same run. But the lock is all-or-\n * nothing: if any server failed, nothing is written and this THROWS. A partial\n * lock is worse than no lock — `verify` / `up --frozen` enforce only what the\n * lock contains, so a silently truncated one is a green gate over less coverage.\n */\nexport async function handleLock(\n options: LockOptions,\n deps: LockDeps\n): Promise<void> {\n const stackPath = options.stackFile ?? \"mcpm.yaml\";\n const lockPath = lockPathFor(stackPath);\n const stackFile = await parseStackFile(stackPath);\n\n const scannerAvailable = await deps.checkScannerAvailable();\n const entries = Object.entries(stackFile.servers);\n\n // F4 lock/up symmetry: snapshots must be scored with the SAME cooldown\n // threshold `up` re-scores with, or blockOnScoreDrop trips spuriously.\n const minAgeHours =\n stackFile.policy?.minReleaseAgeHours ?? DEFAULT_MIN_RELEASE_AGE_HOURS;\n\n // F8: read the PREVIOUS lock up front — it feeds BOTH the drift baseline and\n // the carry-forward that keeps a known-good provenance snapshot sticky across a\n // transient re-read failure of the same immutable coordinate.\n const prevLock = deps.readExistingLock\n ? await deps.readExistingLock(lockPath).catch(() => null)\n : null;\n const prevProvenance = buildPrevProvenanceMap(prevLock);\n\n // Resolve all servers in parallel\n const settlements = await Promise.all(\n entries.map(([name, server]) =>\n resolveServer(name, server, scannerAvailable, minAgeHours, deps, prevProvenance.get(name))\n .then((locked): LockResult => ({ name, locked }))\n .catch((err): LockError => ({\n name,\n error: err instanceof Error ? err.message : String(err),\n }))\n )\n );\n\n const results: LockResult[] = [];\n const errors: LockError[] = [];\n\n for (const s of settlements) {\n if (\"locked\" in s) {\n results.push(s);\n } else {\n errors.push(s);\n }\n }\n\n // Build lock file from successful resolutions\n const lockedServers: Record<string, LockedServer> = {};\n for (const { name, locked } of results) {\n lockedServers[name] = locked;\n }\n\n // FAIL-CLOSED: a lock missing a declared server must never reach disk. `mcpm\n // verify` and `up --frozen` check only what the lock CONTAINS, so a truncated\n // lock passes every gate — silently narrowing what is enforced. Report every\n // failure (resolution still ran to completion for all of them), then abort\n // WITHOUT writing, leaving any previous good lock intact.\n if (errors.length > 0) {\n deps.output(\"\");\n for (const { name, error } of errors) {\n deps.output(` Failed: ${name} — ${error}`);\n }\n throw new Error(\n `${errors.length} server(s) failed to resolve — lock not written (${lockPath} left unchanged).\\n` +\n `Fix the entries above and re-run \\`mcpm lock\\`; a partial lock would verify green while enforcing less than you declared.`\n );\n }\n\n const lockFile: LockFile = {\n lockfileVersion: 1,\n lockedAt: new Date().toISOString(),\n servers: lockedServers,\n };\n\n await deps.writeLockFile(lockPath, serializeYaml(lockFile));\n deps.output(`Locked ${results.length} servers to ${lockPath}`);\n\n reportProvenanceDrift(prevLock, results, deps.output);\n}\n\n// ---------------------------------------------------------------------------\n// F8 slice 1 — provenance-identity drift reporting (report-only)\n// ---------------------------------------------------------------------------\n\nfunction provenanceOf(server: LockedServer | undefined): NpmProvenanceSnapshot | undefined {\n return server && isLockedRegistryServer(server) ? server.provenance : undefined;\n}\n\n/** Human label for a provenance source — SANITIZED: the value is unverified\n * registry / committed-lockfile free text, so strip ANSI/OSC (and bound length)\n * before it reaches a terminal inside a security warning. */\nfunction repoLabel(snap: NpmProvenanceSnapshot | undefined): string {\n const raw = snap?.identity?.sourceRepo ?? snap?.identity?.repositoryId ?? \"unknown source\";\n return sanitizeForTerminal(raw);\n}\n\n/** The previous lock's provenance baseline + the identifier it was recorded for. */\ntype PrevProvenance = { identifier: string; snapshot: NpmProvenanceSnapshot };\n\n/** Index the previous lock's provenance snapshots (with identifier) by server name. */\nfunction buildPrevProvenanceMap(prevLock: LockFile | null): Map<string, PrevProvenance> {\n const map = new Map<string, PrevProvenance>();\n if (!prevLock) return map;\n for (const [name, server] of Object.entries(prevLock.servers)) {\n // Carry the identifier alongside the snapshot: the sticky carry-forward must NOT\n // apply a previous baseline to a DIFFERENT package the user swapped in under the\n // same server name (that would false-positive the F8 verify-time signer gate).\n if (isLockedRegistryServer(server) && server.provenance) {\n map.set(name, { identifier: server.identifier, snapshot: server.provenance });\n }\n }\n return map;\n}\n\n/**\n * Compare each freshly-locked server's provenance to the previous lock's and\n * WARN on identity drift / a signed→unsigned drop. Report-only: never blocks,\n * never re-pins (consistent with the H4/H5/H11 tripwire posture). Copy is\n * careful — legitimate repo renames / org transfers happen, so it advises, and\n * never claims \"verified\".\n */\nfunction reportProvenanceDrift(\n prevLock: LockFile | null,\n results: LockResult[],\n output: (text: string) => void\n): void {\n if (!prevLock) return;\n for (const { name, locked } of results) {\n const prevServer = prevLock.servers[name];\n const prev = provenanceOf(prevServer);\n const next = provenanceOf(locked);\n // The \"same immutable coordinate\" hard-copy applies only when the package IDENTIFIER\n // is unchanged too — a user re-pointing the entry at a DIFFERENT npm package that\n // happens to share a version string is a legit swap, not an attestation swap.\n const prevId = isLockedRegistryServer(prevServer) ? prevServer.identifier : undefined;\n const nextId = isLockedRegistryServer(locked) ? locked.identifier : undefined;\n const sameCoordinate = prevId !== undefined && prevId === nextId && prev?.npmVersion === next?.npmVersion;\n switch (compareProvenance(prev, next)) {\n case \"identity-drift\":\n // On the SAME immutable coordinate the org-transfer hedge does NOT apply — a\n // pinned coordinate's attestation can't legitimately change publisher, so this\n // is a swap to investigate, not a rename to wave through.\n output(\n sameCoordinate\n ? ` ⚠ provenance identity changed for ${name} on the SAME version ${next?.npmVersion} ` +\n `(${repoLabel(prev)} → ${repoLabel(next)}) — an immutable coordinate's attestation should ` +\n `never change publisher; treat as a possible attestation swap and verify before shipping.`\n : ` ⚠ provenance identity changed for ${name}: ${repoLabel(prev)} → ${repoLabel(next)} — ` +\n `expected if the project moved repos/CI; investigate if not.`\n );\n break;\n case \"signed-to-unsigned\":\n output(\n ` ⚠ provenance dropped for ${name}: was attested (${repoLabel(prev)}), now unsigned — ` +\n `a poisoned republish can look like this; verify before shipping.`\n );\n break;\n }\n\n // Verification DOWNGRADE (F8): a coordinate that was crypto-`verified` is now anything\n // that no longer verifies — attested-but-unverified OR an unrecognized/anchorless\n // (\"unsupported\") attestation shape. On the SAME coordinate the sticky carry keeps\n // `next` verified, so this fires only across a VERSION BUMP (or a genuine re-baseline),\n // where compareProvenance's cross-derivation guard returns \"none\" and would otherwise\n // stay silent while the F8 gate quietly stops covering this server. Mirrors the carry's\n // exhaustive \"unless the fresh read verifies\" doctrine rather than enumerating states.\n // `unsigned` is excluded (already surfaced by signed-to-unsigned above); `undefined`\n // is excluded (a transient fetch-fail, fail-open by design).\n if (\n prev?.status === \"attested\" &&\n prev.verification?.outcome === \"verified\" &&\n next !== undefined &&\n next.status !== \"unsigned\" &&\n !(next.status === \"attested\" && next.verification?.outcome === \"verified\")\n ) {\n output(\n ` ⚠ provenance verification downgraded for ${name}: was cryptographically verified, now ` +\n `unverified — \\`mcpm verify\\`/\\`up --frozen\\` no longer crypto-check it. Investigate a swap; if ` +\n `the new version legitimately dropped or changed provenance, re-baseline knowingly.`\n );\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Per-server resolution\n// ---------------------------------------------------------------------------\n\nasync function resolveServer(\n name: string,\n server: StackServer,\n scannerAvailable: boolean,\n minAgeHours: number,\n deps: LockDeps,\n prevProvenance?: PrevProvenance\n): Promise<LockedServer> {\n // URL-based servers: pin directly, no version resolution or trust\n if (isUrlServer(server)) {\n return { url: server.url };\n }\n\n if (!isRegistryServer(server)) {\n throw new Error(`Invalid server entry for \"${name}\"`);\n }\n\n // Step 1: Resolve version\n let resolvedVersion: string;\n try {\n const versions = await deps.getServerVersions(name);\n const versionStrings = versions.map((v) => v.version);\n const result = resolveVersion(name, server.version, versionStrings);\n resolvedVersion = result.resolved;\n } catch {\n // Fallback: try with just the latest version\n const entry = await deps.getServer(name);\n const result = resolveWithSingleVersion(\n name,\n server.version,\n entry.server.version\n );\n resolvedVersion = result.resolved;\n }\n\n // Step 2: Fetch the resolved version's full entry\n const serverEntry = await deps.getServer(name, resolvedVersion);\n\n // Step 3: Trust assessment\n const tier1Findings = deps.scanTier1(serverEntry);\n let allFindings: Finding[] = [...tier1Findings];\n if (scannerAvailable) {\n const tier2Findings = await deps.scanTier2(name);\n allFindings = [...allFindings, ...tier2Findings];\n }\n\n // Release-age assessment (F4): the snapshot carries the same cooldown\n // penalty `up` will re-score with (see handleLock's minAgeHours threading).\n const registryMeta = extractRegistryMeta(serverEntry);\n const releaseAge = assessReleaseAge({\n publishedAt: registryMeta.publishedAt,\n now: (deps.now ?? Date.now)(),\n minAgeHours,\n });\n if (releaseAge.finding) {\n allFindings = [...allFindings, releaseAge.finding];\n }\n\n const trustInput: TrustScoreInput = {\n findings: allFindings,\n healthCheckPassed: null,\n hasExternalScanner: scannerAvailable,\n registryMeta,\n };\n const trustScore = deps.computeTrustScore(trustInput);\n\n // Step 4: Determine registry type and identifier\n const pkg =\n serverEntry.server.packages.find((p) => p.registryType === \"npm\") ??\n serverEntry.server.packages.find((p) => p.registryType === \"pypi\") ??\n serverEntry.server.packages.find((p) => p.registryType === \"oci\") ??\n serverEntry.server.packages[0];\n\n const snapshot: TrustSnapshot = {\n score: trustScore.score,\n maxPossible: trustScore.maxPossible,\n level: trustScore.level,\n assessedAt: new Date().toISOString(),\n // TODOS #35: record the external-scanner credit so the drop tripwire can\n // recover this baseline's native figure later. Always written (0 when no\n // scanner was credited) so every lock this mcpm writes is native-recoverable.\n externalScanCredit: trustScore.breakdown.externalScan,\n };\n\n // Step 5: H11 slice 1 — capture npm artifact integrity snapshot.\n // Only for npm packages whose pkg.version is a concrete exact semver\n // (not \"latest\", a dist-tag, or a range). Using pkg.version (the npm\n // coordinate) — NOT the resolved MCP server version — because the npm\n // per-version endpoint uses the npm package version, not the registry's\n // MCP server version field. Fail-open: if fetchNpmIntegrity returns\n // undefined, omit the snapshot and proceed; lock never blocks on this.\n const isConcreteNpm =\n pkg?.registryType === \"npm\" && semverValid(pkg.version ?? null) !== null;\n\n let npmIntegritySnap: NpmIntegritySnapshot | undefined;\n if (isConcreteNpm) {\n npmIntegritySnap = await deps.fetchNpmIntegrity(\n pkg.identifier,\n pkg.version as string\n );\n }\n\n // F8 slice 1: capture the parse-only provenance snapshot behind the SAME gate.\n // Fail-open: undefined omits the block; lock never blocks on this.\n let provenanceSnap: NpmProvenanceSnapshot | undefined;\n if (isConcreteNpm && deps.fetchNpmProvenance) {\n // Pass the H11 dist.integrity SRI (may be undefined if that fetch failed) so\n // the provenance layer can subject-bind a crypto \"verified\" verdict to THIS\n // tarball. Without it, only the parse-only \"attested\" record is produced.\n provenanceSnap = await deps.fetchNpmProvenance(\n pkg.identifier,\n pkg.version as string,\n npmIntegritySnap?.integrity\n );\n }\n\n // F8 sticky baseline — the COMPLETE invariant (inverts a fragile enumeration). For the\n // SAME immutable coordinate + identifier, a crypto-`verified` baseline may be REPLACED\n // only by a fresh read that is ALSO crypto-`verified` (a legitimate re-sign). EVERY\n // other fresh outcome — fetch-fail (undefined), 404 (unsigned), unparseable body\n // (unsupported), attested-but-could-not-verify, attested-without-verification — is\n // transient-or-attack, so we CARRY the verified baseline forward to keep the F8\n // verify-time gate ARMED (it re-fetches and hard-blocks a real regression). Enumerating\n // the \"bad\" states missed one every review round (could-not-verify, then unsigned);\n // \"carry unless the fresh read verifies\" is exhaustive by construction. Attested-ONLY\n // baselines keep only the original transient carry (undefined/unsupported), preserving\n // drift-report stability without touching the F8 gate. Guarded on identifier equality\n // (no cross-package carry).\n const prevSnap = prevProvenance?.snapshot;\n const sameCoordinate =\n isConcreteNpm &&\n prevProvenance?.identifier === pkg?.identifier &&\n prevSnap?.status === \"attested\" &&\n prevSnap.npmVersion === pkg?.version;\n const prevWasVerified = prevSnap?.verification?.outcome === \"verified\";\n const freshVerified =\n provenanceSnap?.status === \"attested\" && provenanceSnap.verification?.outcome === \"verified\";\n const freshUnreadable = provenanceSnap === undefined || provenanceSnap.status === \"unsupported\";\n\n if (sameCoordinate && ((prevWasVerified && !freshVerified) || freshUnreadable)) {\n // Warn when the fresh read ACTIVELY contradicts a verified baseline (a 404 dropping\n // the attestation, an unparseable body, or a present-but-failed crypto) — hedged for\n // the benign mcpm/@sigstore-upgrade case, and naming the re-baseline escape. A bare\n // fetch-fail / crypto-didn't-run (undefined verification) is benign → stays silent.\n const activelyContradicts =\n provenanceSnap?.status === \"unsigned\" ||\n provenanceSnap?.status === \"unsupported\" ||\n provenanceSnap?.verification?.outcome === \"could-not-verify\";\n if (prevWasVerified && activelyContradicts) {\n deps.output(\n ` ⚠ provenance verification regressed for ${name}: was cryptographically verified, now fails to ` +\n `verify — a poisoned attestation swap can look like this. If npm's record is unchanged, your ` +\n `mcpm/@sigstore version may have changed since you locked; run \\`mcpm verify\\`. If the change is ` +\n `expected, remove this server's \\`provenance:\\` block from the lock and re-lock to re-baseline.`\n );\n }\n provenanceSnap = prevSnap;\n }\n\n return {\n version: resolvedVersion,\n registryType: pkg?.registryType ?? \"unknown\",\n identifier: pkg?.identifier ?? name,\n trust: snapshot,\n ...(npmIntegritySnap ? { npmIntegrity: npmIntegritySnap } : {}),\n ...(provenanceSnap ? { provenance: provenanceSnap } : {}),\n };\n}\n\n// ---------------------------------------------------------------------------\n// Commander registration\n// ---------------------------------------------------------------------------\n\nimport { Command } from \"commander\";\nimport { writeFile } from \"fs/promises\";\nimport { RegistryClient } from \"../registry/client.js\";\nimport { scanTier1 as _scanTier1 } from \"../scanner/tier1.js\";\nimport {\n checkScannerAvailable as _checkScannerAvailable,\n scanTier2 as _scanTier2,\n} from \"../scanner/tier2.js\";\nimport { computeTrustScore as _computeTrustScore } from \"../scanner/trust-score.js\";\nimport { fetchNpmIntegrity as _fetchNpmIntegrity } from \"../registry/npm-integrity.js\";\nimport { fetchNpmProvenance as _fetchNpmProvenance } from \"../registry/npm-provenance.js\";\nimport { parseLockFile } from \"../stack/schema.js\";\nimport { stdoutOutput } from \"../utils/output.js\";\n\nexport function registerLockCommand(program: Command): void {\n program\n .command(\"lock\")\n .description(\n \"Resolve versions and create mcpm-lock.yaml with trust snapshots\"\n )\n .option(\"-f, --file <path>\", \"path to mcpm.yaml\", \"mcpm.yaml\")\n .action(async (opts: { file?: string }) => {\n const chalk = (await import(\"chalk\")).default;\n const client = new RegistryClient();\n\n try {\n await handleLock(\n { stackFile: opts.file },\n {\n getServerVersions: (name) =>\n client.getServerVersions(name),\n getServer: (name, version?) => client.getServer(name, version),\n scanTier1: _scanTier1,\n checkScannerAvailable: _checkScannerAvailable,\n scanTier2: (name) => _scanTier2(name),\n computeTrustScore: _computeTrustScore,\n now: () => Date.now(),\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 readExistingLock: (p) => parseLockFile(p),\n output: stdoutOutput,\n }\n );\n } catch (err) {\n console.error(chalk.red((err as Error).message));\n process.exit(1);\n }\n });\n}\n"],"mappings":"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","names":[]}
#!/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-NPJ3SGGS.js";
import {
CLIENT_IDS,
getConfigPath
} from "./chunk-R4R2VPDA.js";
import {
detectSecretLabels
} from "./chunk-U7N6FRYF.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.29.1",
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-DR7HERUD.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 {
handleLock,
lockPathFor
} from "./chunk-C6CAHFQX.js";
import {
parseSecretsMode,
resolveInstallEntry,
validateRemoteUrl
} from "./chunk-LNGTDYGN.js";
import {
readPins
} from "./chunk-DDCTUMSZ.js";
import {
DEFAULT_MIN_RELEASE_AGE_HOURS,
assessReleaseAge,
stdoutOutput
} from "./chunk-E3T224S3.js";
import {
fetchNpmProvenance,
isEnoent,
isLockedRegistryServer,
isRegistryServer,
isUrlServer,
parseLockFile,
parseStackFile
} from "./chunk-RTI2GLYX.js";
import {
checkScannerAvailable,
scanTier2
} from "./chunk-F6CHEUGO.js";
import {
EXTERNAL_SCAN_MAX,
computeTrustScore,
nativeTrustScore
} from "./chunk-LSNEZAFR.js";
import {
getAdapter
} from "./chunk-W4IAFBUN.js";
import {
confirm
} from "./chunk-2PWW3Q5Q.js";
import {
isNewUnguarded
} from "./chunk-MLVDFLDQ.js";
import {
compareIntegrity,
fetchNpmIntegrity
} from "./chunk-7RJXJERN.js";
import {
RegistryClient
} from "./chunk-V4AA4ZL5.js";
import {
applyKeychainSecrets,
setSecrets
} from "./chunk-NPJ3SGGS.js";
import {
detectInstalledClients
} from "./chunk-6R7TL5O2.js";
import {
getConfigPath
} from "./chunk-R4R2VPDA.js";
import {
assessServerStatus,
extractRegistryMeta,
scanTier1
} from "./chunk-U7N6FRYF.js";
// src/stack/policy.ts
function checkTrustPolicy(input2) {
const { serverName, currentScore, currentMaxPossible, lockedSnapshot, policy } = input2;
if (policy === void 0) {
return { pass: true };
}
const currentPct = toPct(currentScore, currentMaxPossible);
if (policy.minTrustScore !== void 0 && currentPct < policy.minTrustScore) {
return {
pass: false,
reason: `"${serverName}" trust score ${currentPct}% is below the minimum policy threshold of ${policy.minTrustScore}%.`
};
}
if (policy.blockOnScoreDrop === true && lockedSnapshot !== void 0) {
const { currentNativeScore, currentNativeMaxPossible } = input2;
if (currentNativeScore === void 0 || currentNativeMaxPossible === void 0) {
throw new Error(
"blockOnScoreDrop requires the current native trust figures (currentNativeScore / currentNativeMaxPossible). This is a bug \u2014 report it rather than working around it."
);
}
const lockedNative = recoverLockedNative(
lockedSnapshot,
currentNativeMaxPossible
);
const curPct = toPct(currentNativeScore, currentNativeMaxPossible);
const lockPct = toPct(lockedNative.score, lockedNative.maxPossible);
if (curPct < lockPct) {
return {
pass: false,
reason: `"${serverName}" trust score dropped from ${lockPct}% to ${curPct}% (mcpm-native evidence, excluding unverifiable external-scanner credit) since the lock file was created.` + (lockedNative.basis === "legacy-bound" ? ` This lock predates native-evidence drop checks and was written with an external scanner credited, so the baseline is an upper bound \u2014 re-run \`mcpm lock\` to record an exact one.` : lockedNative.basis === "out-of-range" ? (
// Deliberately NOT phrased as tampering: an upward re-weighting of the
// external bucket would make that accusation false for an older mcpm
// reading a newer lock. Re-locking is the remedy either way.
` This lock records trust figures outside the range this version can interpret, so the baseline is an upper bound \u2014 re-run \`mcpm lock\` to record an exact one.`
) : ` If you recently upgraded mcpm, new scanner findings can lower scores \u2014 re-run \`mcpm lock\` to refresh snapshots if the drop is expected.`)
};
}
}
if (policy.minReleaseAgeHours !== void 0 && input2.releaseAge?.blocksArmedGate === true) {
const { ageHours, status } = input2.releaseAge;
if (status === "future") {
return {
pass: false,
reason: `"${serverName}" has a publish timestamp in the future; treated as within the minimum release age of ${policy.minReleaseAgeHours} hour(s) required by policy.`
};
}
if (ageHours === null) {
return {
pass: false,
reason: `"${serverName}" release is of unverifiable age (publish timestamp ${status === "absent" ? "missing from registry metadata" : "could not be parsed"}), and the policy requires a minimum release age of ${policy.minReleaseAgeHours} hour(s).`
};
}
return {
pass: false,
reason: `"${serverName}" release is ${ageHours} hour(s) old, below the minimum release age of ${policy.minReleaseAgeHours} hour(s) required by policy.`
};
}
if (policy.blockInstallScripts === true && input2.hasInstallScriptFindings === true) {
return {
pass: false,
reason: `"${serverName}" resolves to a launcher that runs install scripts, and the policy blocks install scripts.`
};
}
return { pass: true };
}
function toPct(score, maxPossible) {
if (maxPossible <= 0) return 0;
return Math.round(score / maxPossible * 100);
}
function isUsableCredit(credit, locked, nativeMax) {
if (locked.maxPossible === nativeMax && credit > 0) return false;
return credit >= 0 && credit <= EXTERNAL_SCAN_MAX && credit <= locked.score;
}
function recoverLockedNative(locked, nativeMax) {
const bounded = (score, basis) => {
const clamped = Math.max(0, Math.min(nativeMax, score));
return {
score: clamped,
maxPossible: nativeMax,
// A computation that had to be clamped was not exact, whatever produced it — an
// out-of-range `score` reaches here the same way an out-of-range credit does,
// since both are unbounded `z.number()`. Reporting it as exact would hand the
// user the "you upgraded mcpm" remedy for a lock that actually needs re-locking.
basis: basis === "exact" && clamped !== score ? "out-of-range" : basis
};
};
if (locked.externalScanCredit !== void 0) {
return isUsableCredit(locked.externalScanCredit, locked, nativeMax) ? bounded(locked.score - locked.externalScanCredit, "exact") : bounded(locked.score, "out-of-range");
}
if (locked.maxPossible === nativeMax) {
return bounded(locked.score, "exact");
}
return bounded(locked.score, "legacy-bound");
}
// src/stack/env.ts
import { readFile } from "fs/promises";
var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "__defineGetter__", "__defineSetter__"]);
async function parseEnvFile(filePath) {
let raw;
try {
raw = await readFile(filePath, "utf-8");
} catch (err) {
if (isEnoent(err)) {
return { vars: {}, warnings: [] };
}
throw err;
}
return parseEnvString(raw);
}
function parseEnvString(content) {
const vars = {};
const warnings = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const lineNum = i + 1;
const raw = lines[i];
const trimmed = raw.trim();
if (trimmed === "" || trimmed.startsWith("#")) {
continue;
}
const eqIndex = trimmed.indexOf("=");
if (eqIndex === -1) {
warnings.push(`Line ${lineNum}: skipped malformed line (no = sign)`);
continue;
}
const key = trimmed.slice(0, eqIndex).trim();
if (key === "") {
warnings.push(`Line ${lineNum}: skipped line with empty key`);
continue;
}
if (!ENV_KEY_RE.test(key) || UNSAFE_KEYS.has(key)) {
warnings.push(
`Line ${lineNum}: skipped invalid key "${key}"`
);
continue;
}
let value = trimmed.slice(eqIndex + 1).trim();
if (!value.startsWith('"') && !value.startsWith("'")) {
const commentIndex = value.indexOf(" #");
if (commentIndex !== -1) {
value = value.slice(0, commentIndex).trim();
}
}
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
value = value.slice(1, -1);
}
vars[key] = value;
}
return { vars, warnings };
}
// src/stack/frozen-verify.ts
function memoizeIntegrity(fetch) {
const cache = /* @__PURE__ */ new Map();
return (identifier, npmVersion) => {
const key = `${identifier}\0${npmVersion}`;
let p = cache.get(key);
if (p === void 0) {
p = fetch(identifier, npmVersion);
cache.set(key, p);
}
return p;
};
}
async function classifyIntegrity(lockFile, fetchNpmIntegrity2) {
const registryEntries = Object.entries(lockFile.servers).filter(
([, locked]) => isLockedRegistryServer(locked)
);
const npmEntries = registryEntries.filter(([, l]) => l.registryType === "npm");
const npmNames = new Set(npmEntries.map(([name]) => name));
const unenforceable = Object.keys(lockFile.servers).filter((name) => !npmNames.has(name));
const checkable = npmEntries.filter(([, l]) => l.npmIntegrity !== void 0);
const absentBaseline = npmEntries.filter(([, l]) => l.npmIntegrity === void 0).map(([name]) => name);
const fresh = await Promise.all(
checkable.map(([, l]) => fetchNpmIntegrity2(l.identifier, l.npmIntegrity.npmVersion))
);
const drift = [];
const formatOnly = [];
const couldNotVerify = [];
for (let i = 0; i < checkable.length; i++) {
const [name, locked] = checkable[i];
const baseline = locked.npmIntegrity;
const snap = fresh[i];
const coord = { name, identifier: locked.identifier, npmVersion: baseline.npmVersion };
if (snap === void 0) {
couldNotVerify.push(coord);
continue;
}
const cmp = compareIntegrity(baseline.integrity, snap.integrity);
if (cmp === "equal") continue;
if (cmp === "differ") {
drift.push({ ...coord, oldIntegrity: baseline.integrity, newIntegrity: snap.integrity });
} else {
formatOnly.push(coord);
}
}
return { drift, formatOnly, couldNotVerify, absentBaseline, unenforceable, checkedNpmCount: checkable.length };
}
function frozenVerdict(c) {
const noBaselines = c.absentBaseline.length > 0 && c.checkedNpmCount === 0;
const blocks = [];
for (const d of c.drift) {
blocks.push({
name: d.name,
reason: "drift",
identifier: d.identifier,
npmVersion: d.npmVersion,
oldIntegrity: d.oldIntegrity,
newIntegrity: d.newIntegrity
});
}
for (const f of c.formatOnly) {
blocks.push({ name: f.name, reason: "format", identifier: f.identifier, npmVersion: f.npmVersion });
}
for (const v of c.couldNotVerify) {
blocks.push({ name: v.name, reason: "could-not-verify", identifier: v.identifier, npmVersion: v.npmVersion });
}
if (!noBaselines) {
for (const name of c.absentBaseline) {
blocks.push({ name, reason: "missing-baseline" });
}
}
return {
ok: !noBaselines && blocks.length === 0,
noBaselines,
blocks,
unenforceable: c.unenforceable,
checkedNpmCount: c.checkedNpmCount
};
}
// src/stack/frozen-provenance.ts
function verifiedBaseline(locked) {
const prov = locked.provenance;
if (prov?.status !== "attested" || prov.verification?.outcome !== "verified") {
return void 0;
}
return {
npmVersion: prov.npmVersion,
signerSan: prov.verification.signerSan,
signerIssuer: prov.verification.signerIssuer
};
}
async function classifyProvenance(lockFile, fetchNpmIntegrity2, fetchNpmProvenance2) {
const checked = Object.entries(lockFile.servers).filter(
([, l]) => isLockedRegistryServer(l)
).filter(([, l]) => l.registryType === "npm").map(([name, l]) => ({ name, locked: l, baseline: verifiedBaseline(l) })).filter(
(e) => e.baseline !== void 0
);
const blocks = (await Promise.all(
checked.map(async ({ name, locked, baseline }) => {
const coord = { name, identifier: locked.identifier, npmVersion: baseline.npmVersion };
try {
const integ = await fetchNpmIntegrity2(locked.identifier, baseline.npmVersion);
if (integ === void 0) {
return {
...coord,
reason: "unverifiable",
detail: "could not fetch npm's published integrity to bind the attestation"
};
}
const fresh = await fetchNpmProvenance2(locked.identifier, baseline.npmVersion, {
integritySri: integ.integrity
});
return classifyOne(coord, baseline, fresh);
} catch {
return {
...coord,
reason: "unverifiable",
detail: "re-verification errored this run (fetcher threw)"
};
}
})
)).filter((b) => b !== void 0);
return { ok: blocks.length === 0, blocks, checkedVerifiedCount: checked.length };
}
function classifyOne(coord, baseline, fresh) {
if (fresh === void 0) {
return { ...coord, reason: "unverifiable", detail: "no fresh attestation record this run (offline or endpoint error)" };
}
if (fresh.status === "unsigned") {
return { ...coord, reason: "regression", detail: "the attestation that verified at lock time is no longer published (now unsigned)" };
}
if (fresh.status !== "attested") {
return { ...coord, reason: "unverifiable", detail: "attestation shape is no longer a recognizable SLSA record" };
}
const v = fresh.verification;
if (v === void 0) {
return { ...coord, reason: "unverifiable", detail: "attestation present but cryptographic verification did not run this fetch" };
}
if (v.outcome === "could-not-verify") {
return { ...coord, reason: "regression", detail: `attestation no longer cryptographically verifies (${v.reason ?? "crypto failure"})` };
}
if (baseline.signerSan === void 0) {
return { ...coord, reason: "unverifiable", detail: "verified baseline lacks a recorded signer SAN \u2014 cannot assert signer equality; re-lock to record it" };
}
if (v.signerSan !== baseline.signerSan || v.signerIssuer !== baseline.signerIssuer) {
const deltas = [];
if (v.signerSan !== baseline.signerSan) {
deltas.push(`SAN ${baseline.signerSan ?? "(none)"} \u2192 ${v.signerSan ?? "(none)"}`);
}
if (v.signerIssuer !== baseline.signerIssuer) {
deltas.push(`issuer ${baseline.signerIssuer ?? "(none)"} \u2192 ${v.signerIssuer ?? "(none)"}`);
}
return { ...coord, reason: "signer-changed", detail: `signer identity changed: ${deltas.join("; ")}` };
}
return void 0;
}
// src/guard/shadow.ts
function detectNameCollisions(inventory) {
const ownersByTool = /* @__PURE__ */ new Map();
for (const [server, tools] of inventory) {
for (const tool of tools) {
let owners = ownersByTool.get(tool);
if (owners === void 0) {
owners = /* @__PURE__ */ new Set();
ownersByTool.set(tool, owners);
}
owners.add(server);
}
}
const findings = [];
for (const [toolName, owners] of ownersByTool) {
if (owners.size >= 2) {
findings.push({ toolName, servers: [...owners].sort() });
}
}
return findings.sort((a, b) => a.toolName.localeCompare(b.toolName));
}
function buildInventoryFromPins(pins, serverNames) {
const inventory = /* @__PURE__ */ new Map();
for (const name of serverNames) {
inventory.set(name, toolNamesFor(pins, name));
}
return inventory;
}
function toolNamesFor(pins, name) {
return Object.hasOwn(pins.servers, name) ? Object.keys(pins.servers[name]) : [];
}
function serversWithoutBaseline(pins, serverNames) {
return serverNames.filter((name) => toolNamesFor(pins, name).length === 0);
}
// src/commands/up.ts
import "commander";
import chalk from "chalk";
import { input, password } from "@inquirer/prompts";
function trustFigure(trust, options) {
if (options.minTrustFloor === void 0) {
return `${trust.score}/${trust.maxPossible}`;
}
const native = nativeTrustScore(trust);
if (native.excludedExternalCredit === 0) {
return `${trust.score}/${trust.maxPossible}`;
}
return `${native.score}/${native.maxPossible} against the floor, ${trust.score}/${trust.maxPossible} with the external scanner`;
}
async function handleUp(options, deps) {
if (options.secrets === "keychain" && options.ci) {
throw new Error(
"--secrets keychain cannot be combined with --ci (it would persist secrets to the CI runner's keychain). Use --secrets plaintext in CI."
);
}
const stackPath = options.stackFile ?? "mcpm.yaml";
const lockPath = lockPathFor(stackPath);
const stackFile = await parseStackFile(stackPath);
let lockFile = await parseLockFile(lockPath);
if (lockFile === null) {
deps.output("No lock file found. Running mcpm lock first...");
await deps.runLock(stackPath);
lockFile = await parseLockFile(lockPath);
if (lockFile === null) {
throw new Error("Failed to create lock file.");
}
}
const clients = await deps.detectClients();
if (clients.length === 0) {
throw new Error("No supported AI clients found.");
}
const serverEntries = filterByProfile(stackFile, options.profile);
if (serverEntries.length === 0) {
deps.output("No servers match the selected profile.");
return;
}
if (options.frozen === true || stackFile.policy?.frozen === true) {
await runFrozenPass(lockFile, deps);
}
const envFileVars = options.allowEnvFile === false ? { vars: {}, warnings: [] } : await parseEnvFile(".env");
const scannerAvailable = await deps.checkScannerAvailable();
if (options.dryRun) {
deps.output("Dry run \u2014 no changes will be made.\n");
}
if (!options.dryRun) {
await backupConfigs(clients, deps);
}
const results = [];
const previousConsented = deps.readUnguardedConsent ? await deps.readUnguardedConsent() : [];
const consentedUnguarded = new Set(previousConsented);
for (const [name, server] of serverEntries) {
const locked = lockFile.servers[name];
try {
const result = await processServer({
name,
server,
locked,
policy: stackFile.policy,
clients,
scannerAvailable,
envFileVars: envFileVars.vars,
consentedUnguarded,
options,
deps
});
results.push(result);
deps.recordResult?.({ name, status: result.status });
deps.output(` ${statusIcon(result.status)} ${name}: ${result.message}`);
} catch (err) {
const failure = {
name,
status: "failed",
message: err instanceof Error ? err.message : String(err)
};
results.push(failure);
deps.recordResult?.({ name, status: "failed" });
deps.output(` ${statusIcon("failed")} ${name}: ${failure.message}`);
}
}
if (options.strict && !options.dryRun) {
await handleStrictRemoval(stackFile, clients, options, deps, results);
}
const urlServerNames = new Set(
serverEntries.filter(([, s]) => isUrlServer(s)).map(([n]) => n)
);
const installedUnguarded = results.filter((r) => r.status === "installed" && urlServerNames.has(r.name)).map((r) => r.name).sort();
if (installedUnguarded.length > 0 && !options.dryRun) {
const newlyConsented = installedUnguarded.filter((n) => !consentedUnguarded.has(n));
if (isNewUnguarded(installedUnguarded, previousConsented)) {
const alreadyCount = installedUnguarded.length - newlyConsented.length;
const alreadyNote = alreadyCount > 0 ? ` (+${alreadyCount} previously consented)` : "";
deps.output(
`
\u26A0 UNGUARDED: the following URL/HTTP-transport server(s) now run WITHOUT runtime inspection (no relay wraps a non-stdio transport): ${newlyConsented.join(", ")}${alreadyNote}. This grants consent \u2014 it does NOT add protection. The only true fix is a streamable-HTTP relay (not yet implemented). Future \`up\` runs stay quiet unless a NEW unguarded server appears.`
);
if (deps.recordUnguardedConsent) {
await deps.recordUnguardedConsent(newlyConsented).catch(() => void 0);
}
} else {
deps.output(
`
${installedUnguarded.length} server(s) running unguarded (previously consented): ${installedUnguarded.join(", ")}`
);
}
}
if (options.frozen !== true && stackFile.policy?.frozen !== true) {
await runIntegrityPass(lockFile, deps);
}
let shadowCollisions = 0;
if (options.checkShadowing === true || stackFile.policy?.checkShadowing === true) {
shadowCollisions = await runShadowPass(
serverEntries.map(([name]) => name),
deps
);
}
const installed = results.filter((r) => r.status === "installed").length;
const blocked = results.filter((r) => r.status === "blocked").length;
const failed = results.filter((r) => r.status === "failed").length;
const skipped = results.filter((r) => r.status === "skipped").length;
const removed = results.filter((r) => r.status === "removed").length;
const unguarded = installedUnguarded.length;
deps.output(
`
${installed} installed, ${skipped} skipped, ${blocked} blocked, ${failed} failed` + (removed > 0 ? `, ${removed} removed` : "") + (unguarded > 0 ? `, ${unguarded} unguarded` : "")
);
const totalSecretsStored = results.reduce(
(sum, r) => sum + (r.storedSecrets ?? 0),
0
);
if (options.secrets === "keychain" && totalSecretsStored > 0 && !options.dryRun) {
deps.output(
"Secrets stored encrypted at rest in ~/.mcpm. With an OS keychain this protects against other-user/offline access (not same-user processes); without one a machine-derived key is used that guards casual local inspection only, NOT file exfiltration \u2014 run `mcpm secrets migrate` once a keychain is available. Run `mcpm guard enable` (then restart your IDE) so they resolve at launch."
);
}
if (blocked > 0 || failed > 0) {
throw new Error(`${blocked + failed} server(s) could not be installed.`);
}
if (shadowCollisions > 0 && options.ci) {
throw new Error(
`${shadowCollisions} cross-server tool-name collision(s) detected (--ci). Resolve the shadowing (rename/remove a duplicate tool) or drop --check-shadowing.`
);
}
}
function filterByProfile(stackFile, profile) {
return Object.entries(stackFile.servers).filter(([, server]) => {
const profiles = isRegistryServer(server) || isUrlServer(server) ? server.profiles : void 0;
if (!profiles) return true;
if (!profile) return true;
return profiles.includes(profile);
});
}
async function backupConfigs(clients, deps) {
const { readFile: readFile2, writeFile } = await import("fs/promises");
for (const clientId of clients) {
try {
const adapter = deps.getAdapter(clientId);
const configPath = deps.getPath(clientId);
const content = await readFile2(configPath, "utf-8");
await writeFile(`${configPath}.bak`, content, {
encoding: "utf-8",
mode: 384
});
} catch {
}
}
}
async function processServer(input2) {
const { name, server, locked, policy, clients, scannerAvailable, envFileVars, options, deps } = input2;
if (isUrlServer(server)) {
return processUrlServer(name, server.url, clients, policy, input2.consentedUnguarded, options, deps);
}
if (!locked || !isLockedRegistryServer(locked)) {
return { name, status: "failed", message: "Not found in lock file. Run mcpm lock." };
}
const serverEntry = await deps.getServer(name, locked.version);
const statusGate = assessServerStatus(serverEntry);
if (statusGate.blocks) {
return {
name,
status: "blocked",
message: `deleted from the MCP registry${statusGate.statusMessage ? ` (${statusGate.statusMessage})` : ""}`
};
}
const tier1 = deps.scanTier1(serverEntry);
let findings = [...tier1];
if (scannerAvailable) {
const tier2 = await deps.scanTier2(name);
findings = [...findings, ...tier2];
}
const registryMeta = extractRegistryMeta(serverEntry);
const releaseAge = assessReleaseAge({
publishedAt: registryMeta.publishedAt,
now: (deps.now ?? Date.now)(),
minAgeHours: options.minTrustFloor !== void 0 ? DEFAULT_MIN_RELEASE_AGE_HOURS : policy?.minReleaseAgeHours ?? DEFAULT_MIN_RELEASE_AGE_HOURS
});
if (releaseAge.finding) {
findings = [...findings, releaseAge.finding];
}
const trustInput = {
findings,
healthCheckPassed: null,
hasExternalScanner: scannerAvailable,
registryMeta
};
const trustScore = deps.computeTrustScore(trustInput);
const nativeTrust = nativeTrustScore(trustScore);
if (options.minTrustFloor !== void 0 && nativeTrust.score < options.minTrustFloor) {
return {
name,
status: "blocked",
message: `trust score ${nativeTrust.score}/${nativeTrust.maxPossible} is below the required floor of ${options.minTrustFloor}` + (nativeTrust.excludedExternalCredit > 0 ? ` (the external scanner's ${nativeTrust.excludedExternalCredit} points do not count toward the floor)` : "")
};
}
const policyResult = checkTrustPolicy({
serverName: name,
currentScore: trustScore.score,
currentMaxPossible: trustScore.maxPossible,
// TODOS #35: the blockOnScoreDrop tripwire compares native evidence, so a fake
// MCPM_EXTERNAL_SCANNER cannot mask a drop. `nativeTrust` is computed above.
currentNativeScore: nativeTrust.score,
currentNativeMaxPossible: nativeTrust.maxPossible,
lockedSnapshot: locked.trust,
policy,
releaseAge: {
ageHours: releaseAge.ageHours,
status: releaseAge.status,
blocksArmedGate: releaseAge.blocksArmedGate
},
hasInstallScriptFindings: findings.some((f) => f.type === "install-script")
});
if (!policyResult.pass) {
return { name, status: "blocked", message: policyResult.reason };
}
if (options.dryRun) {
return {
name,
status: "skipped",
message: `would install v${locked.version} (trust: ${trustFigure(trustScore, options)})`
};
}
const { env: envVars, storedCount } = await resolveEnvVars(name, server, envFileVars, options, deps);
const installedClients = [];
const clientErrors = [];
for (const clientId of clients) {
try {
const entry = resolveInstallEntry(serverEntry, clientId);
const entryWithEnv = {
...entry,
...Object.keys(envVars).length > 0 ? { env: { ...entry.env, ...envVars } } : {}
};
const adapter = deps.getAdapter(clientId);
const configPath = deps.getPath(clientId);
await adapter.addServer(configPath, name, entryWithEnv, { force: true });
installedClients.push(clientId);
} catch (err) {
clientErrors.push(`${clientId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (installedClients.length === 0) {
return {
name,
status: "failed",
message: `could not write to any client (${clientErrors.join("; ")})`
};
}
const partialNote = clientErrors.length > 0 ? ` (warning: failed on ${clientErrors.join("; ")})` : "";
return {
name,
status: "installed",
message: `v${locked.version} (trust: ${trustFigure(trustScore, options)})${partialNote}`,
storedSecrets: storedCount
};
}
async function runIntegrityPass(lockFile, deps) {
const c = await classifyIntegrity(lockFile, deps.fetchNpmIntegrity);
for (const d of c.drift) {
const oldShort = d.oldIntegrity.slice(0, 16);
const newShort = d.newIntegrity.slice(0, 16);
deps.output(
`
\u26A0 INTEGRITY DRIFT: npm's published record for ${d.identifier}@${d.npmVersion} changed since you locked it (dist.integrity ${oldShort}\u2026 \u2192 ${newShort}\u2026). A published version's integrity is meant to be immutable, so this can mean a supply-chain republish \u2014 but it can also be a legitimate republish or a different registry. mcpm checks the registry's published record, not the code your agent runs. This is a warning only \u2014 it does not block \`mcpm up\`; npx/uvx fetch and run the actual package independently when the server starts (possibly from a different mirror). Re-run \`mcpm lock\` if this change is expected.`
);
}
for (const f of c.formatOnly) {
deps.output(
`
\u26A0 ${f.name}: npm changed the integrity format for ${f.identifier}@${f.npmVersion}, so mcpm cannot compare its published record against your locked baseline (mcpm checks the registry's published record, not the code your agent runs). Re-run \`mcpm lock\` to refresh the baseline.`
);
}
if (c.couldNotVerify.length > 0) {
deps.output(
`
could not verify npm integrity for ${c.couldNotVerify.length} server(s) this run (no drift result is not proof of integrity).`
);
}
if (c.absentBaseline.length > 0) {
deps.output(
`
integrity baseline missing for ${c.absentBaseline.length} npm server(s) \u2014 re-run \`mcpm lock\` with network access to enable drift detection.`
);
}
}
async function runFrozenPass(lockFile, deps) {
const fetchIntegrity = memoizeIntegrity(deps.fetchNpmIntegrity);
const [v, pv] = await Promise.all([
classifyIntegrity(lockFile, fetchIntegrity).then(frozenVerdict),
classifyProvenance(lockFile, fetchIntegrity, deps.fetchNpmProvenance)
]);
const provBlocks = pv.blocks;
if (v.unenforceable.length > 0) {
deps.output(
`
${v.unenforceable.length} server(s) (pypi/oci/url) have no integrity baseline mechanism \u2014 \`--frozen\` cannot enforce them (multi-registry pinning is deferred).`
);
}
if (v.noBaselines && provBlocks.length === 0) {
throw new Error(
"--frozen: this lock has no integrity baselines (it predates them, or was last locked offline). Run `mcpm lock` online once to record them, then `mcpm up --frozen`."
);
}
if (v.ok && provBlocks.length === 0) return;
const integrityMessages = v.blocks.map((b) => {
switch (b.reason) {
case "drift":
return `\u2717 FROZEN: npm's published record for ${b.identifier}@${b.npmVersion} changed since you locked it (dist.integrity ${b.oldIntegrity.slice(0, 16)}\u2026 \u2192 ${b.newIntegrity.slice(0, 16)}\u2026). --frozen refuses to install on integrity drift. Re-pin with \`mcpm lock\` only if this change is expected.`;
case "format":
return `\u2717 FROZEN: cannot compare npm's published record for ${b.identifier}@${b.npmVersion} against your locked baseline (integrity format changed). Re-run \`mcpm lock\` to refresh it.`;
case "could-not-verify":
return `\u2717 FROZEN: could not verify npm's published record for ${b.identifier}@${b.npmVersion} this run (offline, a yanked version, or no comparable dist.integrity). --frozen requires proof the record matches your lock \u2014 this may be a transient registry error, so re-run; if it persists, drop --frozen.`;
case "missing-baseline":
return `\u2717 FROZEN: no integrity baseline recorded for ${b.name}, though other servers in this lock have one. Re-run \`mcpm lock\` online to record it, then \`mcpm up --frozen\`.`;
default: {
const _never = b;
throw new Error(`unhandled frozen block reason: ${JSON.stringify(_never)}`);
}
}
});
const provenanceMessages = provBlocks.map(frozenProvenanceMessage);
const noticeMessages = v.noBaselines ? [
"\u26A0 FROZEN: this lock has no integrity baselines (predates them / locked offline) \u2014 run `mcpm lock` online to record them."
] : [];
const allMessages = [...noticeMessages, ...integrityMessages, ...provenanceMessages];
deps.output(`
${allMessages.join("\n")}`);
deps.output("\nmcpm verifies the registry's published record, not the code your agent runs at launch.");
const failed = /* @__PURE__ */ new Set([...v.blocks.map((b) => b.name), ...provBlocks.map((b) => b.name)]);
throw new Error(
`frozen: ${failed.size} server(s) failed verification; nothing was installed.`
);
}
function frozenProvenanceMessage(b) {
switch (b.reason) {
case "signer-changed":
return `\u2717 FROZEN: the cryptographic signer for ${b.identifier}@${b.npmVersion} changed since you locked it (${b.detail}). --frozen refuses to install on a provenance signer swap. Re-pin with \`mcpm lock\` only if this re-sign is expected.`;
case "regression":
return `\u2717 FROZEN: provenance for ${b.identifier}@${b.npmVersion} regressed \u2014 it cryptographically verified when you locked it and no longer does (${b.detail}). --frozen refuses to install. If npm's record is unchanged, your mcpm/@sigstore version may have changed since you locked (e.g. after an mcpm upgrade); if that regression is expected, remove this server's stale lock entry and re-lock to re-baseline (a plain \`mcpm lock\` keeps the prior verified baseline).`;
case "unverifiable":
return `\u2717 FROZEN: could not cryptographically re-verify provenance for ${b.identifier}@${b.npmVersion} this run (${b.detail}). --frozen requires proof the attestation still verifies \u2014 this may be a transient error, so re-run; if it persists, investigate before dropping --frozen.`;
default: {
const _never = b.reason;
throw new Error(`unhandled provenance block reason: ${JSON.stringify(_never)}`);
}
}
}
async function runShadowPass(serverNames, deps) {
if (deps.readPins === void 0) {
deps.output("\n\u26A0 shadow check skipped: no pins reader available in this context.");
return 0;
}
let pins;
try {
pins = await deps.readPins();
} catch {
deps.output(
"\n\u26A0 shadow check skipped: ~/.mcpm/pins.json is unreadable (integrity check or corruption)."
);
return 0;
}
const findings = detectNameCollisions(buildInventoryFromPins(pins, serverNames));
const noBaseline = serversWithoutBaseline(pins, serverNames);
const checked = serverNames.length - noBaseline.length;
deps.output(
`
Shadow check: compared guarded tool inventories for ${checked} of ${serverNames.length} server(s).`
);
if (noBaseline.length > 0) {
deps.output(
` ${noBaseline.length} server(s) have NO guard baseline yet (${noBaseline.join(", ")}) \u2014 this check cannot see their tools, so a clean result does NOT mean no shadowing. Run them under \`mcpm guard\` (then re-run \`mcpm up\`) to include them.`
);
}
for (const f of findings) {
deps.output(
`
\u26A0 SHADOW: tool "${f.toolName}" is exposed by ${f.servers.length} servers (${f.servers.join(", ")}). A lower-trust server can shadow a tool meant for another, so agent calls to "${f.toolName}" are ambiguous. This can also be benign (two servers of the same kind legitimately export the same tool). Review which server should own it. (Exact-name match only \u2014 a homoglyph/case variant evades this check.)`
);
}
return findings.length;
}
async function processUrlServer(name, url, clients, policy, consentedUnguarded, options, deps) {
if (options.allowUrlServers === false) {
return {
name,
status: "blocked",
message: "URL servers are not permitted via the MCP surface"
};
}
const consented = options.allowUnguarded === true || policy?.allowUrlServers === true || consentedUnguarded.has(name);
if (!consented) {
return {
name,
status: "blocked",
message: "URL/HTTP-transport server runs UNGUARDED \u2014 no runtime inspection is possible (mcpm's guard relay only wraps stdio servers). Re-run with --allow-unguarded or set policy.allowUrlServers: true to install it WITHOUT protection."
};
}
let urlError;
try {
validateRemoteUrl(url);
} catch (err) {
urlError = err instanceof Error ? err.message : String(err);
}
const cursorClients = clients.filter((c) => c === "cursor");
if (cursorClients.length === 0) {
return {
name,
status: "skipped",
message: "URL server \u2014 no Cursor client detected (only Cursor supports URL transport)"
};
}
if (options.dryRun) {
return urlError ? { name, status: "skipped", message: `would reject URL ${url}: ${urlError}` } : { name, status: "skipped", message: `would install URL ${url} to Cursor` };
}
if (urlError) {
return { name, status: "blocked", message: urlError };
}
for (const clientId of cursorClients) {
const adapter = deps.getAdapter(clientId);
const configPath = deps.getPath(clientId);
await adapter.addServer(configPath, name, { url }, { force: true });
}
return { name, status: "installed", message: `URL ${url} \u2192 Cursor` };
}
async function resolveEnvVars(serverName, server, envFileVars, options, deps) {
const envDecl = isRegistryServer(server) || isUrlServer(server) ? server.env : void 0;
if (!envDecl) return { env: {}, storedCount: 0 };
const resolved = {};
const secretKeys = /* @__PURE__ */ new Set();
for (const [key, decl] of Object.entries(envDecl)) {
const fromEnv = options.allowProcessEnv === false ? void 0 : process.env[key];
const fromFile = envFileVars[key];
const fromDefault = decl.default;
let value;
if (fromEnv !== void 0) {
value = fromEnv;
} else if (fromFile !== void 0) {
value = fromFile;
} else if (fromDefault !== void 0) {
value = fromDefault;
} else if (decl.required) {
if (options.ci) {
throw new Error(
`Required env var "${key}" for "${serverName}" is not set. Set it in process.env or .env file (--ci mode, no interactive prompt).`
);
}
value = await deps.promptEnvVar(key, decl.secret);
}
if (value === void 0) continue;
resolved[key] = value;
if (decl.secret) secretKeys.add(key);
}
return applyKeychainSecrets({
serverName,
resolvedEnv: resolved,
isSecret: (key) => secretKeys.has(key),
mode: options.secrets ?? "plaintext",
setSecrets: deps.setSecrets
});
}
async function handleStrictRemoval(stackFile, clients, options, deps, results) {
const declaredNames = new Set(Object.keys(stackFile.servers));
for (const clientId of clients) {
const adapter = deps.getAdapter(clientId);
const configPath = deps.getPath(clientId);
const installed = await adapter.read(configPath);
for (const name of Object.keys(installed)) {
if (declaredNames.has(name)) continue;
if (options.ci && !options.yes) {
throw new Error(
`--strict --ci requires --yes to remove servers not in mcpm.yaml. Server "${name}" in ${clientId} would be removed.`
);
}
if (!options.ci && options.yes !== true) {
const confirmed = await deps.confirm(
`Remove "${name}" from ${clientId}? (not in mcpm.yaml)`
);
if (!confirmed) continue;
}
await adapter.removeServer(configPath, name);
results.push({
name,
status: "removed",
message: `removed from ${clientId} (not in mcpm.yaml)`
});
deps.recordResult?.({ name, status: "removed" });
deps.output(` - ${name}: removed from ${clientId}`);
}
}
}
function statusIcon(status) {
switch (status) {
case "installed":
return "\u2713";
case "removed":
return "\u2212";
case "skipped":
return "\u2022";
case "blocked":
return "\u2717";
case "failed":
return "\u2717";
default:
return "?";
}
}
function registerUpCommand(program) {
program.command("up").description("Install all servers from mcpm.yaml with trust verification").option("-f, --file <path>", "path to mcpm.yaml", "mcpm.yaml").option("-p, --profile <name>", "install only servers matching this profile").option("--dry-run", "show what would be installed without making changes").option("--ci", "CI mode: no interactive prompts, exit nonzero on failure").option("--strict", "remove servers not declared in mcpm.yaml").option("-y, --yes", "skip confirmation prompts (required with --strict --ci)").option("--secrets <mode>", "where to store secret env vars: 'keychain' (encrypted in ~/.mcpm, resolved by mcpm guard at launch) or 'plaintext' (default); 'keychain' is rejected with --ci", parseSecretsMode).option("--allow-unguarded", "permit URL/HTTP-transport servers to run WITHOUT runtime guard inspection (no relay wraps a non-stdio transport); records consent so future runs stay quiet").option("--check-shadowing", "report tool-name collisions across guarded servers (a shadowing signal); advisory interactively, exits nonzero under --ci").option("--frozen", "fail closed: BEFORE installing, verify every locked npm server's published integrity AND re-verify Sigstore provenance for crypto-verified servers, then BLOCK (install nothing, exit nonzero) on integrity drift / provenance regression / unverifiable / missing baseline \u2014 the CI supply-chain freeze gate").action(
async (opts) => {
const client = new RegistryClient();
const { readUnguardedConsent, writeUnguardedConsent, mergeUnguarded } = await import("./unguarded-GJO5WRM7.js");
try {
await handleUp(
{
stackFile: opts.file,
profile: opts.profile,
dryRun: opts.dryRun,
ci: opts.ci,
strict: opts.strict,
yes: opts.yes,
secrets: opts.secrets,
allowUnguarded: opts.allowUnguarded,
checkShadowing: opts.checkShadowing,
frozen: opts.frozen
},
{
detectClients: detectInstalledClients,
getAdapter,
getPath: getConfigPath,
getServer: (name, version) => client.getServer(name, version),
scanTier1,
checkScannerAvailable,
scanTier2: (name) => scanTier2(name),
computeTrustScore,
now: () => Date.now(),
runLock: async (stackFile) => {
const { writeFile } = await import("fs/promises");
await handleLock(
{ stackFile },
{
getServerVersions: (name) => client.getServerVersions(name),
getServer: (name, v) => client.getServer(name, v),
scanTier1,
checkScannerAvailable,
scanTier2: (name) => scanTier2(name),
computeTrustScore,
now: () => Date.now(),
writeLockFile: (path, content) => writeFile(path, content, { encoding: "utf-8", mode: 384 }),
fetchNpmIntegrity,
// F8/B3: auto-lock must record the crypto-`verified` provenance
// baseline too, or the verify-time gate is vacuous for up-locked repos.
fetchNpmProvenance: (id, ver, sri) => fetchNpmProvenance(id, ver, { integritySri: sri }),
output: stdoutOutput
}
);
},
confirm,
promptEnvVar: async (name, isSecret) => {
if (isSecret) {
return password({ message: `${name}:` });
}
return input({ message: `${name}:` });
},
output: stdoutOutput,
setSecrets,
fetchNpmIntegrity,
fetchNpmProvenance: (id, v, o) => fetchNpmProvenance(id, v, o),
readPins,
readUnguardedConsent,
recordUnguardedConsent: async (names) => {
const previous = await readUnguardedConsent();
await writeUnguardedConsent(mergeUnguarded(previous, names));
}
}
);
} catch (err) {
console.error(chalk.red(err.message));
process.exit(1);
}
}
);
}
export {
memoizeIntegrity,
classifyIntegrity,
frozenVerdict,
classifyProvenance,
handleUp,
registerUpCommand
};
//# sourceMappingURL=chunk-KGCQLI22.js.map

Sorry, the diff of this file is too big to display

#!/usr/bin/env node
import {
DEFAULT_MIN_RELEASE_AGE_HOURS,
assessReleaseAge,
stdoutOutput
} from "./chunk-E3T224S3.js";
import {
checkScannerAvailable,
scanTier2
} from "./chunk-F6CHEUGO.js";
import {
computeTrustScore
} from "./chunk-LSNEZAFR.js";
import {
getAdapter
} from "./chunk-W4IAFBUN.js";
import {
confirm
} from "./chunk-2PWW3Q5Q.js";
import {
applyKeychainSecrets,
setSecrets
} from "./chunk-NPJ3SGGS.js";
import {
detectInstalledClients
} from "./chunk-6R7TL5O2.js";
import {
CLIENT_IDS,
getConfigPath
} from "./chunk-R4R2VPDA.js";
import {
addInstalledServer
} from "./chunk-4QDJ3I7X.js";
import {
DANGEROUS_FLAG_PREFIXES,
argvTokens,
assessServerStatus,
extractRegistryMeta,
levelColor,
scanTier1,
scoreBar
} from "./chunk-U7N6FRYF.js";
// src/commands/install.ts
import { InvalidArgumentError } from "commander";
import chalk from "chalk";
import { input, password } from "@inquirer/prompts";
function validateRemoteUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid remote URL: "${url}"`);
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new Error(
`Remote URL must use http or https protocol, got: "${parsed.protocol}"`
);
}
if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
throw new Error(
`Remote URL must use https for non-loopback hosts (plaintext http is vulnerable to interception), got: "${url}"`
);
}
}
function isLoopbackHost(hostname) {
const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return h === "localhost" || h.endsWith(".localhost") || h === "127.0.0.1" || h === "::1";
}
var NPM_IDENTIFIER_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
var PYPI_IDENTIFIER_RE = /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/;
var OCI_IDENTIFIER_RE = /^[a-z0-9]+([._-][a-z0-9]+)*(\/[a-z0-9]+([._-][a-z0-9]+)*)*:[a-zA-Z0-9._-]+$/;
function validateIdentifier(identifier, registryType) {
const patterns = {
npm: NPM_IDENTIFIER_RE,
pypi: PYPI_IDENTIFIER_RE,
oci: OCI_IDENTIFIER_RE
};
const re = patterns[registryType];
if (re && !re.test(identifier)) {
throw new Error(
`Rejected potentially malicious ${registryType} identifier: "${identifier}"`
);
}
}
function normalizeRuntimeArgs(args) {
return args.flatMap(argvTokens);
}
var SAFE_ARG_PATTERNS = [
// Generic boolean flags (--allow-write, --read-only, --no-sandbox, etc.)
/^--[a-zA-Z][\w-]*$/,
// Single-dash short flags the live registry legitimately declares (-i, -y, -p).
// EXACTLY one alpha char — no bundled tail. Allowing a tail (-rmodule, -eCODE)
// would let a dangerous flag bundle its payload and slip past the Layer-1
// DANGEROUS_FLAG_PREFIXES check, which only rejects the exact token (-e/-r) or
// its '=' form. The live registry's short flags are all single-letter, so the
// narrow form loses no real coverage while closing the bundling bypass.
/^-[a-zA-Z]$/,
// Generic --key=value flags with safe value characters
// Blocks shell metacharacters: ; | $ ` & ( ) { } < > ! ' "
/^--[a-zA-Z][\w-]+=[\w./@:, -]+$/,
// Bare absolute paths (Unix: /path/to/dir)
/^\/[\w.@/ -]+$/,
// Home-relative paths (~/Documents)
/^~[\w.@/ -]*$/,
// Bare positional arguments (no dashes, no path traversal)
/^[a-zA-Z0-9][\w.@/-]*$/
];
function validateRuntimeArgs(args) {
for (const arg of args) {
if (/(?:^|[=\\/])\.\.(?:[\\/]|$)/.test(arg)) {
throw new Error(`Rejected path traversal in runtime argument: "${arg}"`);
}
const isDangerous = DANGEROUS_FLAG_PREFIXES.some(
(prefix) => arg === prefix || arg.startsWith(`${prefix}=`)
);
if (isDangerous) {
throw new Error(`Rejected dangerous runtime argument: "${arg}"`);
}
const isSafe = SAFE_ARG_PATTERNS.some((pattern) => pattern.test(arg));
if (!isSafe) {
throw new Error(`Rejected unrecognized runtime argument: "${arg}"`);
}
}
}
function resolveInstallEntry(serverEntry, clientId) {
const { server } = serverEntry;
if (clientId === "cursor" && server.remotes && server.remotes.length > 0) {
const httpRemote = server.remotes.find(
(r) => r.type === "streamable-http" || r.type === "sse"
);
if (httpRemote) {
validateRemoteUrl(httpRemote.url);
const headers = {};
for (const h of httpRemote.headers) {
headers[h.name] = "";
}
return {
url: httpRemote.url,
...Object.keys(headers).length > 0 ? { headers } : {}
};
}
}
const npmPkg = server.packages.find((p) => p.registryType === "npm");
const pypiPkg = server.packages.find((p) => p.registryType === "pypi");
const ociPkg = server.packages.find((p) => p.registryType === "oci");
if (npmPkg) {
validateIdentifier(npmPkg.identifier, "npm");
const rtArgs = normalizeRuntimeArgs(npmPkg.runtimeArguments ?? []);
validateRuntimeArgs(rtArgs);
return {
command: "npx",
args: ["-y", npmPkg.identifier, ...rtArgs]
};
}
if (pypiPkg) {
validateIdentifier(pypiPkg.identifier, "pypi");
const rtArgs = normalizeRuntimeArgs(pypiPkg.runtimeArguments ?? []);
validateRuntimeArgs(rtArgs);
return {
command: "uvx",
args: [pypiPkg.identifier, ...rtArgs]
};
}
if (ociPkg) {
validateIdentifier(ociPkg.identifier, "oci");
const rtArgs = normalizeRuntimeArgs(ociPkg.runtimeArguments ?? []);
validateRuntimeArgs(rtArgs);
return {
command: "docker",
args: ["run", "--rm", "-i", ociPkg.identifier, ...rtArgs]
};
}
if (clientId === "cursor" && server.remotes && server.remotes.length > 0) {
const remote = server.remotes[0];
validateRemoteUrl(remote.url);
return { url: remote.url };
}
throw new Error(
`No install path found for server "${server.name}": no packages and no compatible remotes.`
);
}
function formatTrustScore(trustScore) {
const { score, maxPossible, level, breakdown } = trustScore;
const levelLabel = levelColor(level.toUpperCase());
const bar = scoreBar(score, maxPossible);
const lines = [
`${bar} ${score}/${maxPossible} ${levelLabel}`,
` \u251C\u2500 Health check: ${breakdown.healthCheck > 0 ? "not yet run" : "failed or skipped"}`,
` \u251C\u2500 Tool descriptions: ${breakdown.staticScan === 40 ? "CLEAN (no injection patterns)" : `score ${breakdown.staticScan}/40`}`,
` \u251C\u2500 Package: publisher verification ${breakdown.registryMeta > 0 ? "passed" : "unverified"}`,
` \u2514\u2500 External scan: ${breakdown.externalScan > 0 ? `passed (${breakdown.externalScan}/20)` : "not available (set MCPM_EXTERNAL_SCANNER for deeper analysis)"}`
];
return lines.join("\n");
}
async function handleInstall(name, options, deps) {
const {
registryClient,
detectClients,
getAdapter: getAdapter2,
getConfigPath: getConfigPath2,
scanTier1: scanTier12,
checkScannerAvailable: checkScannerAvailable2,
scanTier2: scanTier22,
computeTrustScore: computeTrustScore2,
addToStore,
confirm: confirm2,
promptEnvVars,
output
} = deps;
const serverEntry = await registryClient.getServer(name);
const statusGate = assessServerStatus(serverEntry);
if (statusGate.blocks) {
if (options.json === true) {
output(
JSON.stringify(
{
name,
error: "server_delisted",
status: statusGate.status,
message: statusGate.statusMessage ?? null
},
null,
2
)
);
}
throw new Error(
`"${name}" has been deleted from the MCP registry${statusGate.statusMessage ? ` (${statusGate.statusMessage})` : ""}. Installation aborted.`
);
}
const tier1Findings = scanTier12(serverEntry);
const scannerAvailable = await checkScannerAvailable2();
let allFindings = [...tier1Findings];
if (scannerAvailable) {
const tier2Findings = await scanTier22(name);
allFindings = [...allFindings, ...tier2Findings];
}
const registryMeta = extractRegistryMeta(serverEntry);
const releaseAge = assessReleaseAge({
publishedAt: registryMeta.publishedAt,
now: (deps.now ?? Date.now)(),
minAgeHours: options.minReleaseAge ?? DEFAULT_MIN_RELEASE_AGE_HOURS
});
if (releaseAge.finding) {
allFindings = [...allFindings, releaseAge.finding];
}
const trustScoreInput = {
findings: allFindings,
healthCheckPassed: null,
// health check not yet run at this point
hasExternalScanner: scannerAvailable,
registryMeta
};
const trustScore = computeTrustScore2(trustScoreInput);
if (options.minTrust !== void 0 && trustScore.score < options.minTrust) {
if (options.json === true) {
output(
JSON.stringify(
{
name,
error: "min_trust_not_met",
score: trustScore.score,
maxPossible: trustScore.maxPossible,
required: options.minTrust,
level: trustScore.level
},
null,
2
)
);
}
throw new Error(
`Trust score ${trustScore.score}/${trustScore.maxPossible} is below the required minimum of ${options.minTrust}. Installation aborted.`
);
}
if (options.minReleaseAge !== void 0 && options.allowFresh !== true && releaseAge.blocksArmedGate) {
if (options.json === true) {
output(
JSON.stringify(
{
name,
error: "release_age_not_met",
ageHours: releaseAge.ageHours,
required: options.minReleaseAge,
reason: releaseAge.status
},
null,
2
)
);
}
const tail = "Installation aborted. Use --allow-fresh to bypass.";
throw new Error(
releaseAge.status === "future" ? `Release publish timestamp is in the future (clock skew or forged metadata); treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}` : releaseAge.status === "unparseable" ? `Release publish timestamp could not be parsed; treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}` : releaseAge.status === "absent" ? `Release publish timestamp is missing from the registry metadata, so release age cannot be verified against the ${options.minReleaseAge}-hour minimum. ${tail}` : `Release age ${releaseAge.ageHours}h is below the required minimum of ${options.minReleaseAge}h. ${tail}`
);
}
const jsonMode = options.json === true;
if (!jsonMode) {
output(formatTrustScore(trustScore));
output("");
}
if (options.yes !== true) {
let shouldProceed;
if (trustScore.level === "risky") {
if (!jsonMode) {
output("\x1B[31mWARNING: This server has a low trust score and may be risky to install.\x1B[0m");
output("\x1B[31mSecurity findings indicate potential dangers. Proceed with extreme caution.\x1B[0m");
}
shouldProceed = await confirm2(
"I understand the risks and want to install this server anyway. Continue?"
);
} else if (trustScore.level === "caution") {
if (!jsonMode) {
output("\x1B[33mCAUTION: This server has a moderate trust score. Review the details above.\x1B[0m");
}
shouldProceed = await confirm2(`Install '${name}'? (caution recommended)`);
} else {
shouldProceed = await confirm2(`Install '${name}'?`);
}
if (!shouldProceed) {
if (!jsonMode) output("Installation cancelled.");
return;
}
}
let targetClients = await detectClients();
if (targetClients.length === 0) {
throw new Error(
"No supported AI clients found. Install Claude Desktop, Cursor, VS Code, or Windsurf first."
);
}
if (options.client !== void 0) {
if (!CLIENT_IDS.includes(options.client)) {
throw new Error(
`Unknown client "${options.client}". Valid values: ${CLIENT_IDS.join(", ")}.`
);
}
const requestedId = options.client;
if (!targetClients.includes(requestedId)) {
throw new Error(
`Client "${requestedId}" is not installed on this machine.`
);
}
targetClients = [requestedId];
}
if (options.force !== true) {
for (const clientId of targetClients) {
const adapter = getAdapter2(clientId);
const configPath = getConfigPath2(clientId);
const existing = await adapter.read(configPath);
if (Object.prototype.hasOwnProperty.call(existing, name)) {
throw new Error(
`Server '${name}' is already installed in ${clientId}. Use --force to overwrite.`
);
}
}
}
const { server } = serverEntry;
const bestPkg = server.packages.find((p) => p.registryType === "npm") ?? server.packages.find((p) => p.registryType === "pypi") ?? server.packages.find((p) => p.registryType === "oci") ?? server.packages[0];
const envVarDefs = bestPkg?.environmentVariables ?? [];
const resolvedEnvVars = await promptEnvVars(envVarDefs);
const secretsMode = options.secrets ?? "plaintext";
const { env: envForConfig, storedCount: storedSecretCount } = await applyKeychainSecrets({
serverName: name,
resolvedEnv: resolvedEnvVars,
isSecret: (key) => envVarDefs.find((d) => d.name === key)?.isSecret === true,
mode: secretsMode,
setSecrets: deps.setSecrets
});
const resolvedEntries = /* @__PURE__ */ new Map();
for (const clientId of targetClients) {
resolvedEntries.set(clientId, resolveInstallEntry(serverEntry, clientId));
}
const isUnguardedEntry = [...resolvedEntries.values()].some(
(e) => e.url !== void 0 && e.command === void 0
);
if (isUnguardedEntry) {
if (options.allowUrlServers === false) {
throw new Error(
`Server '${name}' uses a URL/HTTP transport and is not permitted via the MCP surface.`
);
}
const previousConsented = deps.readUnguardedConsent ? await deps.readUnguardedConsent() : [];
const alreadyConsented = previousConsented.includes(name);
const consented = options.allowUnguarded === true || alreadyConsented;
if (!consented) {
throw new Error(
`Server '${name}' uses a URL/HTTP transport and runs UNGUARDED \u2014 no runtime inspection is possible (mcpm's guard relay only wraps stdio servers). Re-run with --allow-unguarded to install it WITHOUT protection.`
);
}
if (!alreadyConsented) {
if (!jsonMode) {
output(
"\x1B[33m\u26A0 UNGUARDED: this URL/HTTP-transport server runs WITHOUT runtime inspection (the guard relay only wraps stdio servers). This grants consent \u2014 it does NOT add protection. The only true fix is a streamable-HTTP relay (not yet implemented).\x1B[0m"
);
}
if (deps.recordUnguardedConsent) {
await deps.recordUnguardedConsent([name]).catch(() => void 0);
}
}
}
const installedClients = [];
for (const clientId of targetClients) {
const adapter = getAdapter2(clientId);
const configPath = getConfigPath2(clientId);
const rawEntry = resolvedEntries.get(clientId);
const entry = {
...rawEntry,
...Object.keys(envForConfig).length > 0 ? { env: { ...rawEntry.env ?? {}, ...envForConfig } } : {}
};
await adapter.addServer(configPath, name, entry, { force: options.force });
installedClients.push(clientId);
}
if (!options.json) {
if (secretsMode === "keychain" && storedSecretCount > 0) {
output(
`\x1B[32mStored ${storedSecretCount} secret(s) encrypted at rest in ~/.mcpm. With an OS keychain this protects against other-user/offline access (not same-user processes); without one a machine-derived key is used that guards casual local inspection only, NOT file exfiltration \u2014 run \`mcpm secrets migrate\` once a keychain is available. Run \`mcpm guard enable\` (then restart your IDE) so they resolve at launch \u2014 until guard wraps this server it receives the literal placeholder.\x1B[0m`
);
} else {
const hasSecrets = envVarDefs.some((ev) => ev.isSecret && resolvedEnvVars[ev.name]);
if (hasSecrets) {
output(
"\x1B[33mNote: API keys are stored as plaintext in client config files. Ensure config files have appropriate permissions (chmod 600).\x1B[0m"
);
}
}
}
const storeEntry = {
name,
version: serverEntry.server.version,
clients: [...installedClients],
installedAt: (/* @__PURE__ */ new Date()).toISOString()
};
await addToStore(storeEntry);
if (options.json === true) {
const result = {
name,
version: serverEntry.server.version,
clients: installedClients,
trustScore: {
score: trustScore.score,
maxPossible: trustScore.maxPossible,
level: trustScore.level
}
};
output(JSON.stringify(result, null, 2));
return;
}
const clientList = installedClients.join(", ");
output(`\x1B[32mInstalled '${name}' successfully into: ${clientList}\x1B[0m`);
}
async function promptEnvVarsDefault(vars) {
if (vars.length === 0) return {};
const result = {};
for (const envVar of vars) {
if (!envVar.isRequired && !envVar.isSecret) continue;
const defaultVal = envVar.default ?? "";
const promptMessage = envVar.description ? `${envVar.name} (${envVar.description}):` : `${envVar.name}:`;
let prompted;
if (envVar.isSecret) {
prompted = await password({ message: promptMessage });
if (!prompted && defaultVal) {
prompted = defaultVal;
}
} else {
prompted = await input({ message: promptMessage, default: defaultVal });
}
if (prompted) {
result[envVar.name] = prompted;
}
}
return result;
}
function parseSecretsMode(raw) {
if (raw !== "keychain" && raw !== "plaintext") {
throw new InvalidArgumentError(
`--secrets must be "keychain" or "plaintext", got: "${raw}"`
);
}
return raw;
}
function parseMinTrust(raw) {
if (!/^\d+$/.test(raw)) {
throw new InvalidArgumentError(
`--min-trust must be an integer between 0 and 100, got: "${raw}"`
);
}
const n = Number(raw);
if (n < 0 || n > 100) {
throw new InvalidArgumentError(
`--min-trust must be an integer between 0 and 100, got: "${raw}"`
);
}
return n;
}
function parseMinReleaseAge(raw) {
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) {
throw new InvalidArgumentError(
`--min-release-age must be a non-negative integer number of hours, got: "${raw}"`
);
}
return Number(raw);
}
function registerInstallCommand(program) {
program.command("install <name>").description("Install an MCP server from the registry").option("-c, --client <id>", "install to a specific client only").option("-y, --yes", "skip all confirmation prompts").option("-f, --force", "overwrite if server already installed").option("--skip-health-check", "skip post-install health check").option("--json", "output result as JSON").option("--min-trust <n>", "abort install if pre-install trust score is below this threshold (0-100; health check runs after install)", parseMinTrust).option("--min-release-age <hours>", "abort install if the release is younger than this many hours OR its publish timestamp is missing/unparseable (fail-closed when set; also sets the scoring cooldown threshold; bypass with --allow-fresh)", parseMinReleaseAge).option("--allow-fresh", "bypass the --min-release-age gate (including the missing-timestamp block)").option("--secrets <mode>", "where to store secret env vars: 'keychain' (encrypted in ~/.mcpm, resolved by mcpm guard at launch) or 'plaintext' (default)", parseSecretsMode).option("--allow-unguarded", "permit a URL/HTTP-transport server to run WITHOUT runtime guard inspection (no relay wraps a non-stdio transport); records consent so future installs stay quiet").action(async (name, opts) => {
const { RegistryClient } = await import("./client-3RPMRFZL.js");
const client = new RegistryClient();
const { readUnguardedConsent, writeUnguardedConsent, mergeUnguarded } = await import("./unguarded-GJO5WRM7.js");
const installOptions = {
client: opts.client,
yes: opts.yes,
force: opts.force,
skipHealthCheck: opts.skipHealthCheck,
json: opts.json,
minTrust: opts.minTrust,
minReleaseAge: opts.minReleaseAge,
allowFresh: opts.allowFresh,
secrets: opts.secrets,
allowUnguarded: opts.allowUnguarded
};
const installDeps = {
registryClient: client,
detectClients: detectInstalledClients,
getAdapter,
getConfigPath,
scanTier1,
checkScannerAvailable,
scanTier2: (serverName) => scanTier2(serverName),
computeTrustScore,
addToStore: addInstalledServer,
confirm,
promptEnvVars: promptEnvVarsDefault,
output: stdoutOutput,
setSecrets,
now: () => Date.now(),
readUnguardedConsent,
recordUnguardedConsent: async (names) => {
const previous = await readUnguardedConsent();
await writeUnguardedConsent(mergeUnguarded(previous, names));
}
};
try {
await handleInstall(name, installOptions, installDeps);
} catch (err) {
if (installOptions.json !== true) {
console.error(chalk.red(err.message));
}
process.exit(1);
}
});
}
export {
validateRemoteUrl,
resolveInstallEntry,
parseSecretsMode,
parseMinTrust,
registerInstallCommand
};
//# sourceMappingURL=chunk-LNGTDYGN.js.map
{"version":3,"sources":["../src/commands/install.ts"],"sourcesContent":["/**\n * `mcpm install <name>` command handler.\n *\n * Wires together: registry fetch → trust assessment → user confirmation →\n * client detection → env var prompting → config write → store record.\n *\n * All external dependencies are injected for testability.\n *\n * Exports:\n * - handleInstall() — injectable handler for testing\n * - resolveInstallEntry() — pure function: ServerEntry + ClientId → McpServerEntry\n * - formatTrustScore() — pure function: TrustScore → formatted string\n * - registerInstallCommand() — Commander registration\n */\n\nimport { CLIENT_IDS } from \"../config/paths.js\";\nimport type { ClientId } from \"../config/paths.js\";\nimport type { ConfigAdapter, McpServerEntry } from \"../config/adapters/index.js\";\nimport type { ServerEntry, EnvVar } from \"../registry/types.js\";\nimport { argvTokens, type RuntimeArgument } from \"../registry/argument-tokens.js\";\nimport type { Finding } from \"../scanner/tier1.js\";\nimport type { TrustScore, TrustScoreInput } from \"../scanner/trust-score.js\";\nimport type { InstalledServer } from \"../store/servers.js\";\nimport { scoreBar, levelColor, extractRegistryMeta } from \"../utils/format-trust.js\";\nimport { assessReleaseAge, DEFAULT_MIN_RELEASE_AGE_HOURS } from \"../scanner/cooldown.js\";\nimport { assessServerStatus } from \"../scanner/registry-status.js\";\nimport { DANGEROUS_FLAG_PREFIXES } from \"../scanner/patterns.js\";\nimport { applyKeychainSecrets, type SecretsMode, setSecrets as _setSecrets } from \"../store/keychain.js\";\n\n// ---------------------------------------------------------------------------\n// URL validation — guard against malicious remote URLs\n// ---------------------------------------------------------------------------\n\n/**\n * Validate a remote URL before it is written to any IDE config file.\n * Only http: and https: protocols are permitted.\n */\nexport function validateRemoteUrl(url: string): void {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(`Invalid remote URL: \"${url}\"`);\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") {\n throw new Error(\n `Remote URL must use http or https protocol, got: \"${parsed.protocol}\"`\n );\n }\n // M4a: plaintext http to a non-loopback host is interceptable once written to an\n // IDE config. Allow http only for loopback (local dev servers); require https for\n // every other host. https is always allowed.\n if (parsed.protocol === \"http:\" && !isLoopbackHost(parsed.hostname)) {\n throw new Error(\n `Remote URL must use https for non-loopback hosts (plaintext http is ` +\n `vulnerable to interception), got: \"${url}\"`\n );\n }\n}\n\n/**\n * True for localhost / loopback literals, where plaintext http is acceptable.\n * Recognizes localhost / *.localhost / 127.0.0.1 / ::1. Exotic loopback spellings\n * (IPv4-mapped `::ffff:127.0.0.1`, `127.x.x.x`, decimal/octal/hex IPs) are NOT\n * recognized and fall through to the https requirement — over-rejection only, never\n * a bypass (a non-loopback host can never be mistaken for loopback).\n */\nfunction isLoopbackHost(hostname: string): boolean {\n const h = hostname.toLowerCase().replace(/^\\[|\\]$/g, \"\"); // strip IPv6 brackets\n return (\n h === \"localhost\" ||\n h.endsWith(\".localhost\") ||\n h === \"127.0.0.1\" ||\n h === \"::1\"\n );\n}\n\nconst NPM_IDENTIFIER_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*$/;\nconst PYPI_IDENTIFIER_RE = /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/;\nconst OCI_IDENTIFIER_RE =\n /^[a-z0-9]+([._-][a-z0-9]+)*(\\/[a-z0-9]+([._-][a-z0-9]+)*)*:[a-zA-Z0-9._-]+$/;\n\n/**\n * Validate a package identifier against the expected pattern for its registry\n * type. Throws if the identifier looks potentially malicious.\n */\nexport function validateIdentifier(identifier: string, registryType: string): void {\n const patterns: Record<string, RegExp> = {\n npm: NPM_IDENTIFIER_RE,\n pypi: PYPI_IDENTIFIER_RE,\n oci: OCI_IDENTIFIER_RE,\n };\n const re = patterns[registryType];\n if (re && !re.test(identifier)) {\n throw new Error(\n `Rejected potentially malicious ${registryType} identifier: \"${identifier}\"`\n );\n }\n}\n\n/**\n * Render runtimeArguments from the registry into a launch argv slice.\n *\n * Delegates to argvTokens (name + value, never valueHint) so the SAME function\n * defines both what gets executed here and what the F4 dangerous-flag scan\n * matches in scanner/patterns.ts — they cannot diverge. valueHint (a\n * documentation placeholder like \"directory\") is deliberately not rendered:\n * emitting it would inject a bogus literal argument. The injection scanner\n * (scanner/tier1.ts) uses argumentTokens instead, which DOES read valueHint as\n * user-facing text; that divergence is intentional and documented there.\n */\nfunction normalizeRuntimeArgs(\n args: ReadonlyArray<RuntimeArgument>\n): string[] {\n return args.flatMap(argvTokens);\n}\n\n/**\n * Allowlist of safe runtime argument shapes.\n * After dangerous flags are rejected, arguments must match one of these\n * patterns. This blocks shell metacharacters and path traversal while\n * allowing the wide range of flags real MCP servers use.\n */\nconst SAFE_ARG_PATTERNS: readonly RegExp[] = [\n // Generic boolean flags (--allow-write, --read-only, --no-sandbox, etc.)\n /^--[a-zA-Z][\\w-]*$/,\n // Single-dash short flags the live registry legitimately declares (-i, -y, -p).\n // EXACTLY one alpha char — no bundled tail. Allowing a tail (-rmodule, -eCODE)\n // would let a dangerous flag bundle its payload and slip past the Layer-1\n // DANGEROUS_FLAG_PREFIXES check, which only rejects the exact token (-e/-r) or\n // its '=' form. The live registry's short flags are all single-letter, so the\n // narrow form loses no real coverage while closing the bundling bypass.\n /^-[a-zA-Z]$/,\n // Generic --key=value flags with safe value characters\n // Blocks shell metacharacters: ; | $ ` & ( ) { } < > ! ' \"\n /^--[a-zA-Z][\\w-]+=[\\w./@:, -]+$/,\n // Bare absolute paths (Unix: /path/to/dir)\n /^\\/[\\w.@/ -]+$/,\n // Home-relative paths (~/Documents)\n /^~[\\w.@/ -]*$/,\n // Bare positional arguments (no dashes, no path traversal)\n /^[a-zA-Z0-9][\\w.@/-]*$/,\n];\n\n/**\n * Validate runtime arguments from the registry.\n * Two-layer defense: reject known-dangerous Node.js flags first,\n * then require remaining args to match safe structural patterns.\n */\nexport function validateRuntimeArgs(args: string[]): void {\n for (const arg of args) {\n // Layer 0 (M4b): reject a \"..\" path-traversal segment anywhere in the argument\n // — \"../x\", \"a/../../etc/passwd\", \"--config=../secret\". A \"..\" segment is one\n // bounded by start-of-arg, \"=\" (flag value), or a path separator on the left,\n // and a separator or end-of-arg on the right. The Layer-2 allowlist permits \".\"\n // and \"/\" inside values, so without this a traversal would slip through; a\n // non-traversal double dot like \"--range=1..10\" is left untouched.\n if (/(?:^|[=\\\\/])\\.\\.(?:[\\\\/]|$)/.test(arg)) {\n throw new Error(`Rejected path traversal in runtime argument: \"${arg}\"`);\n }\n\n // Layer 1: reject dangerous Node.js flags\n const isDangerous = DANGEROUS_FLAG_PREFIXES.some(\n (prefix) => arg === prefix || arg.startsWith(`${prefix}=`)\n );\n if (isDangerous) {\n throw new Error(`Rejected dangerous runtime argument: \"${arg}\"`);\n }\n\n // Layer 2: require safe structural pattern\n const isSafe = SAFE_ARG_PATTERNS.some((pattern) => pattern.test(arg));\n if (!isSafe) {\n throw new Error(`Rejected unrecognized runtime argument: \"${arg}\"`);\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface InstallOptions {\n client?: string;\n yes?: boolean;\n force?: boolean;\n skipHealthCheck?: boolean;\n json?: boolean;\n minTrust?: number;\n minReleaseAge?: number;\n allowFresh?: boolean;\n secrets?: SecretsMode;\n /**\n * H9 (fail-closed): per-invocation consent (`--allow-unguarded`) to install a\n * URL/HTTP-transport server that runs UNGUARDED (the guard relay only wraps a\n * stdio transport — a non-stdio remote gets ZERO runtime inspection). When\n * neither this nor a name already in the persistent consent store grants it,\n * such a server is DENIED. DISTINCT from `allowUrlServers`, the MCP-surface\n * kill-switch: `allowUrlServers === false` ALWAYS wins.\n */\n allowUnguarded?: boolean;\n /**\n * Whether URL/HTTP-transport servers may be installed at all. DEFAULT\n * (undefined/true) preserves CLI behavior. The MCP surface passes `false` so a\n * url-transport server is recorded as blocked instead of written to a config —\n * an untrusted caller can never reach the unguarded run path.\n */\n allowUrlServers?: boolean;\n}\n\nexport interface InstallDeps {\n registryClient: { getServer: (name: string) => Promise<ServerEntry> };\n detectClients: () => Promise<ClientId[]>;\n getAdapter: (clientId: ClientId) => ConfigAdapter;\n getConfigPath: (clientId: ClientId) => string;\n scanTier1: (server: ServerEntry) => Finding[];\n checkScannerAvailable: () => Promise<boolean>;\n scanTier2: (name: string) => Promise<Finding[]>;\n computeTrustScore: (input: TrustScoreInput) => TrustScore;\n addToStore: (server: InstalledServer) => Promise<void>;\n confirm: (message: string) => Promise<boolean>;\n promptEnvVars: (vars: EnvVar[]) => Promise<Record<string, string>>;\n output: (text: string) => void;\n /** Optional; required only when options.secrets === \"keychain\". */\n setSecrets?: (server: string, values: Record<string, string>) => Promise<void>;\n /** Epoch-ms clock for release-age assessment; defaults to Date.now at the CLI boundary. */\n now?: () => number;\n /**\n * H9: read the persistent set of server names previously consented to run\n * unguarded. Injectable for tests; defaults to the real store at the CLI\n * boundary. When omitted, no server is treated as previously-consented.\n */\n readUnguardedConsent?: () => Promise<string[]>;\n /**\n * H9: persist (union into the store) the name newly consented to run\n * unguarded. Injectable for tests; defaults to the real store. Called once\n * after a url server is installed under fresh consent.\n */\n recordUnguardedConsent?: (names: readonly string[]) => Promise<void>;\n}\n\n// ---------------------------------------------------------------------------\n// resolveInstallEntry — pure function, no I/O\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve the McpServerEntry for a given server + clientId.\n *\n * Decision tree:\n * 1. Cursor + server has HTTP remote → produce { url, headers } entry\n * 2. Otherwise pick from packages[]: npm → pypi → oci (first available)\n * 3. npm: { command: 'npx', args: ['-y', identifier, ...runtimeArgs], env }\n * 4. pypi: { command: 'uvx', args: [identifier, ...runtimeArgs], env }\n * 5. docker: { command: 'docker', args: ['run', '--rm', '-i', image], env }\n * 6. If no packages and no usable remote: throw\n */\nexport function resolveInstallEntry(\n serverEntry: ServerEntry,\n clientId: ClientId\n): McpServerEntry {\n const { server } = serverEntry;\n\n // Rule 1: Cursor + HTTP remote → streamable-http entry\n if (clientId === \"cursor\" && server.remotes && server.remotes.length > 0) {\n const httpRemote = server.remotes.find(\n (r) => r.type === \"streamable-http\" || r.type === \"sse\"\n );\n if (httpRemote) {\n validateRemoteUrl(httpRemote.url);\n // Build headers record if any\n const headers: Record<string, string> = {};\n for (const h of httpRemote.headers) {\n headers[h.name] = \"\";\n }\n return {\n url: httpRemote.url,\n ...(Object.keys(headers).length > 0 ? { headers } : {}),\n };\n }\n }\n\n // Rule 2: Pick best package by priority: npm → pypi → oci\n const npmPkg = server.packages.find((p) => p.registryType === \"npm\");\n const pypiPkg = server.packages.find((p) => p.registryType === \"pypi\");\n const ociPkg = server.packages.find((p) => p.registryType === \"oci\");\n\n if (npmPkg) {\n validateIdentifier(npmPkg.identifier, \"npm\");\n const rtArgs = normalizeRuntimeArgs(npmPkg.runtimeArguments ?? []);\n validateRuntimeArgs(rtArgs);\n return {\n command: \"npx\",\n args: [\"-y\", npmPkg.identifier, ...rtArgs],\n };\n }\n\n if (pypiPkg) {\n validateIdentifier(pypiPkg.identifier, \"pypi\");\n const rtArgs = normalizeRuntimeArgs(pypiPkg.runtimeArguments ?? []);\n validateRuntimeArgs(rtArgs);\n return {\n command: \"uvx\",\n args: [pypiPkg.identifier, ...rtArgs],\n };\n }\n\n if (ociPkg) {\n validateIdentifier(ociPkg.identifier, \"oci\");\n const rtArgs = normalizeRuntimeArgs(ociPkg.runtimeArguments ?? []);\n validateRuntimeArgs(rtArgs);\n return {\n command: \"docker\",\n args: [\"run\", \"--rm\", \"-i\", ociPkg.identifier, ...rtArgs],\n };\n }\n\n // Rule 3: Cursor-only path — HTTP remote with no packages\n if (clientId === \"cursor\" && server.remotes && server.remotes.length > 0) {\n const remote = server.remotes[0];\n validateRemoteUrl(remote.url);\n return { url: remote.url };\n }\n\n throw new Error(\n `No install path found for server \"${server.name}\": no packages and no compatible remotes.`\n );\n}\n\n// ---------------------------------------------------------------------------\n// formatTrustScore — pure function, rich display\n// ---------------------------------------------------------------------------\n\n/**\n * Format a trust score as a visual progress bar with breakdown details.\n */\nexport function formatTrustScore(trustScore: TrustScore): string {\n const { score, maxPossible, level, breakdown } = trustScore;\n\n const levelLabel = levelColor(level.toUpperCase());\n const bar = scoreBar(score, maxPossible);\n\n const lines: string[] = [\n `${bar} ${score}/${maxPossible} ${levelLabel}`,\n ` \\u251C\\u2500 Health check: ${breakdown.healthCheck > 0 ? \"not yet run\" : \"failed or skipped\"}`,\n ` \\u251C\\u2500 Tool descriptions: ${breakdown.staticScan === 40 ? \"CLEAN (no injection patterns)\" : `score ${breakdown.staticScan}/40`}`,\n ` \\u251C\\u2500 Package: publisher verification ${breakdown.registryMeta > 0 ? \"passed\" : \"unverified\"}`,\n ` \\u2514\\u2500 External scan: ${breakdown.externalScan > 0 ? `passed (${breakdown.externalScan}/20)` : \"not available (set MCPM_EXTERNAL_SCANNER for deeper analysis)\"}`,\n ];\n\n return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// handleInstall — main handler\n// ---------------------------------------------------------------------------\n\n/**\n * Core handler for `mcpm install <name>`.\n * All dependencies are injected for hermetic testability.\n */\nexport async function handleInstall(\n name: string,\n options: InstallOptions,\n deps: InstallDeps\n): Promise<void> {\n const {\n registryClient,\n detectClients,\n getAdapter,\n getConfigPath,\n scanTier1,\n checkScannerAvailable,\n scanTier2,\n computeTrustScore,\n addToStore,\n confirm,\n promptEnvVars,\n output,\n } = deps;\n\n // -------------------------------------------------------------------------\n // Step 1: Fetch server metadata\n // -------------------------------------------------------------------------\n const serverEntry = await registryClient.getServer(name);\n\n // -------------------------------------------------------------------------\n // Step 1b: registry-delisting gate (fail closed, before any scan/output)\n // -------------------------------------------------------------------------\n // If the registry itself marks this server \"deleted\" (removed/withdrawn),\n // refuse to install. Fail-SAFE: ONLY an explicit \"deleted\" blocks; a\n // \"deprecated\" or absent/unknown status does not (surfaced as an advisory\n // finding by scanTier1 instead). See scanner/registry-status.ts.\n const statusGate = assessServerStatus(serverEntry);\n if (statusGate.blocks) {\n if (options.json === true) {\n output(\n JSON.stringify(\n {\n name,\n error: \"server_delisted\",\n status: statusGate.status,\n message: statusGate.statusMessage ?? null,\n },\n null,\n 2\n )\n );\n }\n throw new Error(\n `\"${name}\" has been deleted from the MCP registry${statusGate.statusMessage ? ` (${statusGate.statusMessage})` : \"\"}. Installation aborted.`\n );\n }\n\n // -------------------------------------------------------------------------\n // Step 2: Trust assessment\n // -------------------------------------------------------------------------\n const tier1Findings = scanTier1(serverEntry);\n const scannerAvailable = await checkScannerAvailable();\n\n let allFindings: Finding[] = [...tier1Findings];\n if (scannerAvailable) {\n const tier2Findings = await scanTier2(name);\n allFindings = [...allFindings, ...tier2Findings];\n }\n\n // Release-age cooldown: assessed ONCE so the score finding and the Step 2c\n // gate can never disagree — passing --min-release-age below 24 therefore also\n // lowers the scoring cooldown threshold (documented in the flag help text).\n // The medium finding lands unconditionally for fresh releases, with or\n // without the gate — that is the inversion fix, independent of the gate.\n const registryMeta = extractRegistryMeta(serverEntry);\n const releaseAge = assessReleaseAge({\n publishedAt: registryMeta.publishedAt,\n now: (deps.now ?? Date.now)(),\n minAgeHours: options.minReleaseAge ?? DEFAULT_MIN_RELEASE_AGE_HOURS,\n });\n if (releaseAge.finding) {\n allFindings = [...allFindings, releaseAge.finding];\n }\n\n const trustScoreInput: TrustScoreInput = {\n findings: allFindings,\n healthCheckPassed: null, // health check not yet run at this point\n hasExternalScanner: scannerAvailable,\n registryMeta,\n };\n\n const trustScore = computeTrustScore(trustScoreInput);\n\n // -------------------------------------------------------------------------\n // Step 2b: --min-trust gate (checked before any output or confirmation)\n // -------------------------------------------------------------------------\n if (options.minTrust !== undefined && trustScore.score < options.minTrust) {\n if (options.json === true) {\n output(\n JSON.stringify(\n {\n name,\n error: \"min_trust_not_met\",\n score: trustScore.score,\n maxPossible: trustScore.maxPossible,\n required: options.minTrust,\n level: trustScore.level,\n },\n null,\n 2\n )\n );\n }\n // The denominator is 80 unless the external-scanner bucket was credited, which is\n // the default — so a hardcoded /100 understated every score by 20 points of scale\n // and made a flawless 62/80 (77.5%) read as 62%.\n throw new Error(\n `Trust score ${trustScore.score}/${trustScore.maxPossible} is below the required minimum of ${options.minTrust}. Installation aborted.`\n );\n }\n\n // -------------------------------------------------------------------------\n // Step 2c: --min-release-age gate (checked before any output or confirmation)\n // -------------------------------------------------------------------------\n // Fail-closed when armed: a MISSING publish timestamp blocks too (blocksArmedGate)\n // — otherwise a registry/compromised mirror could defeat the gate by omitting\n // _meta (publishedAt is .optional() in OfficialMetaSchema). The score finding\n // stays fail-open for absent; only the explicitly armed gate hardens.\n if (\n options.minReleaseAge !== undefined &&\n options.allowFresh !== true &&\n releaseAge.blocksArmedGate\n ) {\n if (options.json === true) {\n output(\n JSON.stringify(\n {\n name,\n error: \"release_age_not_met\",\n ageHours: releaseAge.ageHours,\n required: options.minReleaseAge,\n reason: releaseAge.status,\n },\n null,\n 2\n )\n );\n }\n const tail = \"Installation aborted. Use --allow-fresh to bypass.\";\n throw new Error(\n releaseAge.status === \"future\"\n ? `Release publish timestamp is in the future (clock skew or forged metadata); treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}`\n : releaseAge.status === \"unparseable\"\n ? `Release publish timestamp could not be parsed; treated as within the ${options.minReleaseAge}-hour minimum release age. ${tail}`\n : releaseAge.status === \"absent\"\n ? `Release publish timestamp is missing from the registry metadata, so release age cannot be verified against the ${options.minReleaseAge}-hour minimum. ${tail}`\n : `Release age ${releaseAge.ageHours}h is below the required minimum of ${options.minReleaseAge}h. ${tail}`\n );\n }\n\n // -------------------------------------------------------------------------\n // Step 3: Display trust score and confirm\n // -------------------------------------------------------------------------\n // In --json mode suppress all human-readable output; only the final JSON\n // is written to stdout.\n const jsonMode = options.json === true;\n\n if (!jsonMode) {\n output(formatTrustScore(trustScore));\n output(\"\");\n }\n\n if (options.yes !== true) {\n let shouldProceed: boolean;\n\n if (trustScore.level === \"risky\") {\n if (!jsonMode) {\n output(\"\\u001b[31mWARNING: This server has a low trust score and may be risky to install.\\u001b[0m\");\n output(\"\\u001b[31mSecurity findings indicate potential dangers. Proceed with extreme caution.\\u001b[0m\");\n }\n shouldProceed = await confirm(\n \"I understand the risks and want to install this server anyway. Continue?\"\n );\n } else if (trustScore.level === \"caution\") {\n if (!jsonMode) {\n output(\"\\u001b[33mCAUTION: This server has a moderate trust score. Review the details above.\\u001b[0m\");\n }\n shouldProceed = await confirm(`Install '${name}'? (caution recommended)`);\n } else {\n // GREEN — brief display, proceed\n shouldProceed = await confirm(`Install '${name}'?`);\n }\n\n if (!shouldProceed) {\n if (!jsonMode) output(\"Installation cancelled.\");\n return;\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 4: Detect and filter clients\n // -------------------------------------------------------------------------\n let targetClients = await detectClients();\n\n if (targetClients.length === 0) {\n throw new Error(\n \"No supported AI clients found. Install Claude Desktop, Cursor, VS Code, or Windsurf first.\"\n );\n }\n\n if (options.client !== undefined) {\n if (!CLIENT_IDS.includes(options.client as ClientId)) {\n throw new Error(\n `Unknown client \"${options.client}\". Valid values: ${CLIENT_IDS.join(\", \")}.`\n );\n }\n const requestedId = options.client as ClientId;\n if (!targetClients.includes(requestedId)) {\n throw new Error(\n `Client \"${requestedId}\" is not installed on this machine.`\n );\n }\n targetClients = [requestedId];\n }\n\n // -------------------------------------------------------------------------\n // Step 5: Check for already-installed (unless --force)\n // -------------------------------------------------------------------------\n if (options.force !== true) {\n for (const clientId of targetClients) {\n const adapter = getAdapter(clientId);\n const configPath = getConfigPath(clientId);\n const existing = await adapter.read(configPath);\n if (Object.prototype.hasOwnProperty.call(existing, name)) {\n throw new Error(\n `Server '${name}' is already installed in ${clientId}. Use --force to overwrite.`\n );\n }\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 6: Resolve env vars to prompt for\n // -------------------------------------------------------------------------\n // Collect env vars from the best-match package\n const { server } = serverEntry;\n const bestPkg =\n server.packages.find((p) => p.registryType === \"npm\") ??\n server.packages.find((p) => p.registryType === \"pypi\") ??\n server.packages.find((p) => p.registryType === \"oci\") ??\n server.packages[0];\n\n const envVarDefs: EnvVar[] = bestPkg?.environmentVariables ?? [];\n const resolvedEnvVars = await promptEnvVars(envVarDefs);\n\n // Step 6b: In keychain mode, persist secret-flagged values encrypted and swap\n // them for `mcpm:keychain:…` placeholders, so no plaintext is written to any\n // client config. Non-secret vars stay inline; each secret is stored once and\n // reused for every client. The placeholder resolves at launch only while mcpm\n // guard wraps the server (run-inner.ts → resolveEnvPlaceholders). The swap\n // (and the \"no plaintext in config\" invariant) lives in applyKeychainSecrets.\n const secretsMode: SecretsMode = options.secrets ?? \"plaintext\";\n const { env: envForConfig, storedCount: storedSecretCount } = await applyKeychainSecrets({\n serverName: name,\n resolvedEnv: resolvedEnvVars,\n isSecret: (key) => envVarDefs.find((d) => d.name === key)?.isSecret === true,\n mode: secretsMode,\n setSecrets: deps.setSecrets,\n });\n\n // -------------------------------------------------------------------------\n // Step 7: Resolve (and thereby validate) each client's entry up front\n // -------------------------------------------------------------------------\n // resolveInstallEntry throws on an invalid identifier, so resolving here\n // before any config is written preserves fail-fast validation. The resolved\n // entries are reused in Step 8 to avoid recomputing them.\n const resolvedEntries = new Map<ClientId, McpServerEntry>();\n for (const clientId of targetClients) {\n resolvedEntries.set(clientId, resolveInstallEntry(serverEntry, clientId));\n }\n\n // -------------------------------------------------------------------------\n // Step 7b: H9 fail-closed gate for URL/HTTP-transport servers\n // -------------------------------------------------------------------------\n // A resolved entry with a `url` and no `command` runs UNGUARDED — the guard\n // relay only wraps a stdio process, so a non-stdio remote gets ZERO runtime\n // inspection. Mirror processUrlServer (up.ts): the MCP-surface kill-switch\n // (allowUrlServers === false) ALWAYS wins; otherwise DENY unless explicit\n // informed consent (`--allow-unguarded` this run, or a name already in the\n // persistent consent store). This is informed consent, NOT protection.\n const isUnguardedEntry = [...resolvedEntries.values()].some(\n (e) => e.url !== undefined && e.command === undefined\n );\n if (isUnguardedEntry) {\n if (options.allowUrlServers === false) {\n throw new Error(\n `Server '${name}' uses a URL/HTTP transport and is not permitted via the MCP surface.`\n );\n }\n const previousConsented = deps.readUnguardedConsent\n ? await deps.readUnguardedConsent()\n : [];\n const alreadyConsented = previousConsented.includes(name);\n const consented = options.allowUnguarded === true || alreadyConsented;\n if (!consented) {\n throw new Error(\n `Server '${name}' uses a URL/HTTP transport and runs UNGUARDED — no runtime ` +\n `inspection is possible (mcpm's guard relay only wraps stdio servers). ` +\n `Re-run with --allow-unguarded to install it WITHOUT protection.`\n );\n }\n // First-time consent: warn once and persist so a future install stays quiet.\n if (!alreadyConsented) {\n if (!jsonMode) {\n output(\n \"\\x1b[33m⚠ UNGUARDED: this URL/HTTP-transport server runs WITHOUT runtime \" +\n \"inspection (the guard relay only wraps stdio servers). This grants consent — \" +\n \"it does NOT add protection. The only true fix is a streamable-HTTP relay \" +\n \"(not yet implemented).\\x1b[0m\"\n );\n }\n if (deps.recordUnguardedConsent) {\n await deps.recordUnguardedConsent([name]).catch(() => undefined);\n }\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 8: Write config to each client and record in store\n // -------------------------------------------------------------------------\n const installedClients: ClientId[] = [];\n\n for (const clientId of targetClients) {\n const adapter = getAdapter(clientId);\n const configPath = getConfigPath(clientId);\n const rawEntry = resolvedEntries.get(clientId)!;\n\n // Merge env vars into the entry (immutable). In keychain mode envForConfig\n // carries placeholders in place of secret values; otherwise it === resolvedEnvVars.\n const entry: McpServerEntry = {\n ...rawEntry,\n ...(Object.keys(envForConfig).length > 0\n ? { env: { ...(rawEntry.env ?? {}), ...envForConfig } }\n : {}),\n };\n\n await adapter.addServer(configPath, name, entry, { force: options.force });\n installedClients.push(clientId);\n }\n\n // -------------------------------------------------------------------------\n // Step 8b: Secret-storage notice\n // -------------------------------------------------------------------------\n if (!options.json) {\n if (secretsMode === \"keychain\" && storedSecretCount > 0) {\n output(\n `\\x1b[32mStored ${storedSecretCount} secret(s) encrypted at rest in ~/.mcpm. ` +\n \"With an OS keychain this protects against other-user/offline access (not \" +\n \"same-user processes); without one a machine-derived key is used that guards \" +\n \"casual local inspection only, NOT file exfiltration — run `mcpm secrets migrate` \" +\n \"once a keychain is available. \" +\n \"Run `mcpm guard enable` (then restart your IDE) so they resolve at launch — \" +\n \"until guard wraps this server it receives the literal placeholder.\\x1b[0m\"\n );\n } else {\n const hasSecrets = envVarDefs.some((ev) => ev.isSecret && resolvedEnvVars[ev.name]);\n if (hasSecrets) {\n output(\n \"\\x1b[33mNote: API keys are stored as plaintext in client config files. \" +\n \"Ensure config files have appropriate permissions (chmod 600).\\x1b[0m\"\n );\n }\n }\n }\n\n // -------------------------------------------------------------------------\n // Step 9: Record in store\n // -------------------------------------------------------------------------\n const storeEntry: InstalledServer = {\n name,\n version: serverEntry.server.version,\n clients: [...installedClients],\n installedAt: new Date().toISOString(),\n };\n await addToStore(storeEntry);\n\n // -------------------------------------------------------------------------\n // Step 10: Output result\n // -------------------------------------------------------------------------\n if (options.json === true) {\n const result = {\n name,\n version: serverEntry.server.version,\n clients: installedClients,\n trustScore: {\n score: trustScore.score,\n maxPossible: trustScore.maxPossible,\n level: trustScore.level,\n },\n };\n output(JSON.stringify(result, null, 2));\n return;\n }\n\n const clientList = installedClients.join(\", \");\n output(`\\u001b[32mInstalled '${name}' successfully into: ${clientList}\\u001b[0m`);\n}\n\n// ---------------------------------------------------------------------------\n// Commander registration\n// ---------------------------------------------------------------------------\n\nimport { Command, InvalidArgumentError } from \"commander\";\nimport chalk from \"chalk\";\nimport { input, password } from \"@inquirer/prompts\";\nimport { detectInstalledClients as _detectClients } from \"../config/detector.js\";\nimport { getConfigPath as _getConfigPath } from \"../config/paths.js\";\nimport { addInstalledServer as _addToStore } from \"../store/servers.js\";\nimport { scanTier1 as _scanTier1 } from \"../scanner/tier1.js\";\nimport { checkScannerAvailable as _checkScannerAvailable, scanTier2 as _scanTier2 } from \"../scanner/tier2.js\";\nimport { computeTrustScore as _computeTrustScore } from \"../scanner/trust-score.js\";\nimport { getAdapter as getAdapterDefault } from \"../config/index.js\";\nimport { confirm } from \"../utils/confirm.js\";\nimport { stdoutOutput } from \"../utils/output.js\";\n\nasync function promptEnvVarsDefault(\n vars: EnvVar[]\n): Promise<Record<string, string>> {\n if (vars.length === 0) return {};\n\n const result: Record<string, string> = {};\n for (const envVar of vars) {\n if (!envVar.isRequired && !envVar.isSecret) continue;\n\n const defaultVal = envVar.default ?? \"\";\n const promptMessage = envVar.description\n ? `${envVar.name} (${envVar.description}):`\n : `${envVar.name}:`;\n\n let prompted: string;\n if (envVar.isSecret) {\n // Use password prompt to mask secret input — value is never echoed to the terminal\n prompted = await password({ message: promptMessage });\n if (!prompted && defaultVal) {\n prompted = defaultVal;\n }\n } else {\n prompted = await input({ message: promptMessage, default: defaultVal });\n }\n\n if (prompted) {\n result[envVar.name] = prompted;\n }\n }\n return result;\n}\n\nexport function parseSecretsMode(raw: string): SecretsMode {\n if (raw !== \"keychain\" && raw !== \"plaintext\") {\n throw new InvalidArgumentError(\n `--secrets must be \"keychain\" or \"plaintext\", got: \"${raw}\"`\n );\n }\n return raw;\n}\n\nexport function parseMinTrust(raw: string): number {\n // Reject anything that isn't plain decimal digits (blocks hex \"0x50\", scientific\n // notation \"1e2\", spaces, empty string, and negative sign before range check).\n if (!/^\\d+$/.test(raw)) {\n throw new InvalidArgumentError(\n `--min-trust must be an integer between 0 and 100, got: \"${raw}\"`\n );\n }\n const n = Number(raw);\n if (n < 0 || n > 100) {\n throw new InvalidArgumentError(\n `--min-trust must be an integer between 0 and 100, got: \"${raw}\"`\n );\n }\n return n;\n}\n\nexport function parseMinReleaseAge(raw: string): number {\n // Same regex-first discipline as parseMinTrust: blocks hex \"0x18\", \"1e2\",\n // spaces, empty string, negatives. Safe-integer check guards absurd lengths.\n if (!/^\\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) {\n throw new InvalidArgumentError(\n `--min-release-age must be a non-negative integer number of hours, got: \"${raw}\"`\n );\n }\n return Number(raw);\n}\n\nexport function registerInstallCommand(program: Command): void {\n program\n .command(\"install <name>\")\n .description(\"Install an MCP server from the registry\")\n .option(\"-c, --client <id>\", \"install to a specific client only\")\n .option(\"-y, --yes\", \"skip all confirmation prompts\")\n .option(\"-f, --force\", \"overwrite if server already installed\")\n .option(\"--skip-health-check\", \"skip post-install health check\")\n .option(\"--json\", \"output result as JSON\")\n .option(\"--min-trust <n>\", \"abort install if pre-install trust score is below this threshold (0-100; health check runs after install)\", parseMinTrust)\n .option(\"--min-release-age <hours>\", \"abort install if the release is younger than this many hours OR its publish timestamp is missing/unparseable (fail-closed when set; also sets the scoring cooldown threshold; bypass with --allow-fresh)\", parseMinReleaseAge)\n .option(\"--allow-fresh\", \"bypass the --min-release-age gate (including the missing-timestamp block)\")\n .option(\"--secrets <mode>\", \"where to store secret env vars: 'keychain' (encrypted in ~/.mcpm, resolved by mcpm guard at launch) or 'plaintext' (default)\", parseSecretsMode)\n .option(\"--allow-unguarded\", \"permit a URL/HTTP-transport server to run WITHOUT runtime guard inspection (no relay wraps a non-stdio transport); records consent so future installs stay quiet\")\n .action(async (name: string, opts: { client?: string; yes?: boolean; force?: boolean; skipHealthCheck?: boolean; json?: boolean; minTrust?: number; minReleaseAge?: number; allowFresh?: boolean; secrets?: SecretsMode; allowUnguarded?: boolean }) => {\n const { RegistryClient } = await import(\"../registry/client.js\");\n const client = new RegistryClient();\n const { readUnguardedConsent, writeUnguardedConsent, mergeUnguarded } = await import(\n \"../guard/unguarded.js\"\n );\n\n const installOptions: InstallOptions = {\n client: opts.client,\n yes: opts.yes,\n force: opts.force,\n skipHealthCheck: opts.skipHealthCheck,\n json: opts.json,\n minTrust: opts.minTrust,\n minReleaseAge: opts.minReleaseAge,\n allowFresh: opts.allowFresh,\n secrets: opts.secrets,\n allowUnguarded: opts.allowUnguarded,\n };\n\n const installDeps: InstallDeps = {\n registryClient: client,\n detectClients: _detectClients,\n getAdapter: getAdapterDefault,\n getConfigPath: _getConfigPath,\n scanTier1: _scanTier1,\n checkScannerAvailable: _checkScannerAvailable,\n scanTier2: (serverName: string) => _scanTier2(serverName),\n computeTrustScore: _computeTrustScore,\n addToStore: _addToStore,\n confirm,\n promptEnvVars: promptEnvVarsDefault,\n output: stdoutOutput,\n setSecrets: _setSecrets,\n now: () => Date.now(),\n readUnguardedConsent,\n recordUnguardedConsent: async (names) => {\n const previous = await readUnguardedConsent();\n await writeUnguardedConsent(mergeUnguarded(previous, names));\n },\n };\n\n try {\n await handleInstall(name, installOptions, installDeps);\n } catch (err) {\n if (installOptions.json !== true) {\n console.error(chalk.red((err as Error).message));\n }\n process.exit(1);\n }\n });\n}\n"],"mappings":"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","names":["getAdapter","getConfigPath","scanTier1","checkScannerAvailable","scanTier2","computeTrustScore","confirm"]}
#!/usr/bin/env node
// src/scanner/trust-score.ts
var HEALTH_CHECK_PASS = 30;
var HEALTH_CHECK_FAIL = 0;
var HEALTH_CHECK_NULL = 15;
var STATIC_SCAN_MAX = 40;
var EXTERNAL_SCAN_MAX = 20;
var REGISTRY_META_MAX = 10;
var SEVERITY_DEDUCTIONS = {
critical: 20,
high: 10,
medium: 5,
low: 2
};
var MS_PER_DAY = 24 * 60 * 60 * 1e3;
var PUBLISHED_AGE_DAYS = 30;
var DOWNLOAD_THRESHOLD = 100;
var NATIVE_MAX_POSSIBLE = HEALTH_CHECK_PASS + STATIC_SCAN_MAX + REGISTRY_META_MAX;
var FULL_MAX_POSSIBLE = NATIVE_MAX_POSSIBLE + EXTERNAL_SCAN_MAX;
function scoreHealthCheck(passed) {
if (passed === true) return HEALTH_CHECK_PASS;
if (passed === false) return HEALTH_CHECK_FAIL;
return HEALTH_CHECK_NULL;
}
function totalDeductions(findings) {
return findings.reduce((sum, f) => sum + SEVERITY_DEDUCTIONS[f.severity], 0);
}
function scoreStaticScan(findings) {
return Math.max(0, STATIC_SCAN_MAX - totalDeductions(findings));
}
function scoreExternalScan(hasExternalScanner, findings) {
if (!hasExternalScanner) return 0;
return Math.max(0, EXTERNAL_SCAN_MAX - totalDeductions(findings));
}
function scoreRegistryMeta(meta) {
let points = 0;
if (meta.isVerifiedPublisher === true) {
points += 4;
}
if (meta.publishedAt) {
const publishedAge = Date.now() - new Date(meta.publishedAt).getTime();
if (publishedAge > PUBLISHED_AGE_DAYS * MS_PER_DAY) {
points += 3;
}
}
if (typeof meta.downloadCount === "number" && meta.downloadCount > DOWNLOAD_THRESHOLD) {
points += 3;
}
return Math.min(points, REGISTRY_META_MAX);
}
function computeLevel(score, maxPossible) {
const ratio = score / maxPossible;
if (ratio >= 0.8) return "safe";
if (ratio >= 0.5) return "caution";
return "risky";
}
function hasCriticalOrHighFindings(findings) {
return findings.some((f) => f.severity === "critical" || f.severity === "high");
}
function computeTrustScore(input) {
const externalCredited2 = input.hasExternalScanner && !input.findings.some((f) => f.source === "external" && f.type === "scanner-error");
const maxPossible = externalCredited2 ? FULL_MAX_POSSIBLE : NATIVE_MAX_POSSIBLE;
const registryMetaScore = hasCriticalOrHighFindings(input.findings) ? 0 : scoreRegistryMeta(input.registryMeta);
const externalFindings = externalCredited2 ? input.findings.filter((f) => f.source === "external") : [];
const staticFindings = externalCredited2 ? input.findings.filter((f) => f.source !== "external") : input.findings.filter((f) => !(f.type === "scanner-error" && f.source === "external"));
const breakdown = {
healthCheck: scoreHealthCheck(input.healthCheckPassed),
staticScan: scoreStaticScan(staticFindings),
externalScan: scoreExternalScan(externalCredited2, externalFindings),
registryMeta: registryMetaScore
};
const score = breakdown.healthCheck + breakdown.staticScan + breakdown.externalScan + breakdown.registryMeta;
const level = computeLevel(score, maxPossible);
return { score, maxPossible, level, breakdown: { ...breakdown } };
}
function externalCredited(trust) {
return trust.maxPossible === FULL_MAX_POSSIBLE;
}
function nativeTrustScore(trust) {
const score = trust.score - trust.breakdown?.externalScan;
if (!Number.isFinite(score)) {
throw new Error(
"Cannot evaluate a trust floor: the trust score has no usable breakdown. This is a bug \u2014 report it rather than working around it."
);
}
return {
score,
maxPossible: NATIVE_MAX_POSSIBLE,
excludedExternalCredit: trust.breakdown.externalScan
};
}
export {
EXTERNAL_SCAN_MAX,
computeTrustScore,
externalCredited,
nativeTrustScore
};
//# sourceMappingURL=chunk-LSNEZAFR.js.map
{"version":3,"sources":["../src/scanner/trust-score.ts"],"sourcesContent":["/**\n * Trust score computation — pure function, no I/O.\n *\n * Takes findings and metadata, returns a structured TrustScore.\n * All objects returned are new (immutable pattern).\n */\n\nimport type { Finding } from \"./tier1.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TrustScoreInput {\n findings: Finding[];\n healthCheckPassed: boolean | null; // null = not yet run\n hasExternalScanner: boolean;\n registryMeta: {\n isVerifiedPublisher?: boolean;\n publishedAt?: string;\n downloadCount?: number;\n };\n}\n\nexport interface TrustScoreBreakdown {\n healthCheck: number; // 0-30\n staticScan: number; // 0-40\n externalScan: number; // 0-20\n registryMeta: number; // 0-10\n}\n\nexport interface TrustScore {\n score: number; // 0-100\n maxPossible: number; // 80 if no external scanner, 100 otherwise\n level: \"safe\" | \"caution\" | \"risky\";\n breakdown: TrustScoreBreakdown;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst HEALTH_CHECK_PASS = 30;\nconst HEALTH_CHECK_FAIL = 0;\nconst HEALTH_CHECK_NULL = 15;\n\nconst STATIC_SCAN_MAX = 40;\n/**\n * Exported for `stack/policy.ts`, which validates a lockfile's recorded\n * `externalScanCredit` against it. That field is this bucket's value at lock time,\n * so the ceiling has to be the one this scorer actually awards — a second copy of\n * `20` in the validator would silently stop matching if the bucket is re-weighted.\n */\nexport const EXTERNAL_SCAN_MAX = 20;\nconst REGISTRY_META_MAX = 10;\n\n/** Deductions per finding severity (applied to both static and external scan). */\nconst SEVERITY_DEDUCTIONS: Record<Finding[\"severity\"], number> = {\n critical: 20,\n high: 10,\n medium: 5,\n low: 2,\n};\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\nconst PUBLISHED_AGE_DAYS = 30;\nconst DOWNLOAD_THRESHOLD = 100;\n\n/**\n * The score reachable from mcpm's OWN evidence — health check, tier-1 static\n * scan, registry metadata. Also the `maxPossible` reported when no external\n * scanner was credited, and the denominator `nativeTrustScore` reports.\n *\n * Derived rather than written as `80` so the three buckets stay the single\n * source of truth if any of them is ever re-weighted.\n */\nconst NATIVE_MAX_POSSIBLE = HEALTH_CHECK_PASS + STATIC_SCAN_MAX + REGISTRY_META_MAX;\nconst FULL_MAX_POSSIBLE = NATIVE_MAX_POSSIBLE + EXTERNAL_SCAN_MAX;\n\n// ---------------------------------------------------------------------------\n// Component scorers\n// ---------------------------------------------------------------------------\n\nfunction scoreHealthCheck(passed: boolean | null): number {\n if (passed === true) return HEALTH_CHECK_PASS;\n if (passed === false) return HEALTH_CHECK_FAIL;\n return HEALTH_CHECK_NULL;\n}\n\nfunction totalDeductions(findings: Finding[]): number {\n return findings.reduce((sum, f) => sum + SEVERITY_DEDUCTIONS[f.severity], 0);\n}\n\nfunction scoreStaticScan(findings: Finding[]): number {\n return Math.max(0, STATIC_SCAN_MAX - totalDeductions(findings));\n}\n\nfunction scoreExternalScan(hasExternalScanner: boolean, findings: Finding[]): number {\n if (!hasExternalScanner) return 0;\n return Math.max(0, EXTERNAL_SCAN_MAX - totalDeductions(findings));\n}\n\nfunction scoreRegistryMeta(meta: TrustScoreInput[\"registryMeta\"]): number {\n let points = 0;\n\n if (meta.isVerifiedPublisher === true) {\n points += 4;\n }\n\n if (meta.publishedAt) {\n const publishedAge = Date.now() - new Date(meta.publishedAt).getTime();\n if (publishedAge > PUBLISHED_AGE_DAYS * MS_PER_DAY) {\n points += 3;\n }\n }\n\n if (typeof meta.downloadCount === \"number\" && meta.downloadCount > DOWNLOAD_THRESHOLD) {\n points += 3;\n }\n\n return Math.min(points, REGISTRY_META_MAX);\n}\n\n// ---------------------------------------------------------------------------\n// Level threshold\n// ---------------------------------------------------------------------------\n\nfunction computeLevel(score: number, maxPossible: number): TrustScore[\"level\"] {\n const ratio = score / maxPossible;\n if (ratio >= 0.8) return \"safe\";\n if (ratio >= 0.5) return \"caution\";\n return \"risky\";\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Returns true if any finding has critical or high severity.\n */\nfunction hasCriticalOrHighFindings(findings: Finding[]): boolean {\n return findings.some((f) => f.severity === \"critical\" || f.severity === \"high\");\n}\n\n/**\n * Compute a trust score from findings and server metadata.\n * Returns a new TrustScore object — never mutates input.\n */\nexport function computeTrustScore(input: TrustScoreInput): TrustScore {\n // The external bucket is credited only when the scanner returned a result we\n // could READ — not merely because one was configured and exited 0.\n //\n // Be precise about what this does and does not buy, because the raw score\n // feeds `mcpm install --min-trust` and the MCP `HARD_TRUST_FLOOR`. It stops\n // the ACCIDENTAL inflation: a binary that is not a scanner (`/bin/true` exits\n // 0 and says nothing), a typo'd path, a scanner that broke after an upgrade.\n // Those would otherwise bank a silent 20/20 for doing nothing.\n //\n // It does NOT stop deliberate self-deception, and cannot. Anyone who can set\n // MCPM_EXTERNAL_SCANNER can also write a two-line script that prints\n // `{\"findings\": []}`, which is indistinguishable from a real clean scan —\n // mcpm has no way to verify an arbitrary executable did any work. So the\n // external bucket is, and should be read as, \"a scanner the USER chose to\n // trust reported this\", never as independent corroboration mcpm validated.\n //\n // Because it cannot be verified, that bucket is excluded from the safety\n // floors the no-human-in-loop MCP surface enforces — see `nativeTrustScore`\n // below (TODOS #33). It still informs the score every human-facing surface\n // displays and compares; it just cannot CLEAR a floor on its own.\n //\n // A scanner whose result we could not read is treated as ABSENT rather than as\n // a failing scan: the bucket leaves `maxPossible` (80, not 100) instead of\n // scoring 0 out of 100. Note the honest comparison — that is \"no worse than\n // having no scanner\", NOT \"no change\": a stack that was scoring against a\n // working scanner and whose scanner then breaks does lose those points, and a\n // raw `--min-trust` gate will see the drop.\n const externalCredited =\n input.hasExternalScanner &&\n !input.findings.some((f) => f.source === \"external\" && f.type === \"scanner-error\");\n\n const maxPossible = externalCredited ? FULL_MAX_POSSIBLE : NATIVE_MAX_POSSIBLE;\n\n // Cap registryMeta to 0 when critical/high findings are present.\n // Attacker-controlled metadata (publishedAt, downloads) must not inflate\n // the score when the scan found serious issues.\n const registryMetaScore = hasCriticalOrHighFindings(input.findings)\n ? 0\n : scoreRegistryMeta(input.registryMeta);\n\n // Partition findings by source so each finding is deducted from exactly one\n // bucket. Tier-1 findings (source \"static\" or undefined) hit the static\n // sub-score; tier-2 external-scanner findings (source \"external\") hit the\n // external sub-score. Without this split, every finding was deducted from\n // BOTH buckets whenever an external scanner was present — making scores\n // artificially low precisely when the extra scanner was enabled.\n //\n // When no external scanner ran, the external sub-score is hard-zeroed and the\n // bucket is removed from maxPossible, so an \"external\"-tagged finding present\n // without a scanner would otherwise be silently dropped from ALL scoring and\n // deduct nothing. That should not happen in normal flow, but we route such\n // orphans into the static bucket as a safe fallback so they still deduct\n // rather than vanish.\n //\n // The one thing that must NOT fall through to the static bucket is a\n // scanner-error diagnostic from an uncredited scanner. It says the user's\n // scanner failed, not that the server is worse — deducting for it would make\n // a broken scanner quietly depress every server's score, which is the exact\n // opposite of treating a failed scanner as absent.\n const externalFindings = externalCredited\n ? input.findings.filter((f) => f.source === \"external\")\n : [];\n const staticFindings = externalCredited\n ? input.findings.filter((f) => f.source !== \"external\")\n : input.findings.filter((f) => !(f.type === \"scanner-error\" && f.source === \"external\"));\n\n const breakdown: TrustScoreBreakdown = {\n healthCheck: scoreHealthCheck(input.healthCheckPassed),\n staticScan: scoreStaticScan(staticFindings),\n externalScan: scoreExternalScan(externalCredited, externalFindings),\n registryMeta: registryMetaScore,\n };\n\n const score =\n breakdown.healthCheck +\n breakdown.staticScan +\n breakdown.externalScan +\n breakdown.registryMeta;\n\n const level = computeLevel(score, maxPossible);\n\n return { score, maxPossible, level, breakdown: { ...breakdown } };\n}\n\n/**\n * Did this score bank the external-scanner bucket?\n *\n * The scorer widens the denominator to `FULL_MAX_POSSIBLE` exactly when it credited\n * the bucket, so `maxPossible` IS the record of that decision — which makes this the\n * one honest way to ask after the fact.\n *\n * Note what it is NOT: `checkScannerAvailable()`. That is a bare `<cmd> --version`\n * exit-0 probe, while crediting additionally requires that the scanner returned output\n * the scorer could READ. A scanner that answers `--version` but cannot scan a registry\n * server name emits a `scanner-error` finding and is credited nowhere, so the two\n * predicates disagree precisely when a scanner is half-working — the case that made\n * `audit --fix`'s ceiling delete flawless servers.\n *\n * Crediting is decided PER SERVER (a `scanner-error` is per invocation), so a single\n * run can legitimately contain both, and callers reducing over a run must decide which\n * direction is safe rather than assume uniformity.\n */\nexport function externalCredited(trust: TrustScore): boolean {\n return trust.maxPossible === FULL_MAX_POSSIBLE;\n}\n\n/** A trust score with third-party credit mcpm cannot verify removed. */\nexport interface NativeTrustScore {\n /** The score, less the external-scanner bucket's credit. */\n score: number;\n /** Denominator for `score` — always the three mcpm-native buckets. */\n maxPossible: number;\n /** Points excluded — 0 when no external scanner was credited. */\n excludedExternalCredit: number;\n}\n\n/**\n * The part of a trust score mcpm produced itself, for evaluating SAFETY FLOORS\n * (TODOS #33).\n *\n * `HARD_TRUST_FLOOR` is documented as a gate no caller-supplied value can\n * lower. `MCPM_EXTERNAL_SCANNER` is caller-supplied — it names an arbitrary\n * executable — and a two-line script that prints `{\"findings\": []}` is\n * indistinguishable from a real clean scan, so before this the bucket's 20\n * points could lift a server over the floor. Reproduced: two critical tier-1\n * findings and no health check score 15 (blocked); with such a script\n * configured they score 35, and `mcpm_up` installed the server.\n *\n * The rule: third-party corroboration mcpm cannot verify may INFORM a score,\n * but must not be able to CLEAR a safety floor. So the floor is compared\n * against `score - breakdown.externalScan`.\n *\n * Note the asymmetry is deliberate and one-directional WITH RESPECT TO THE\n * EXTERNAL SCANNER. Removing only the bucket's CREDIT leaves every penalty an\n * external finding carries outside that bucket — most importantly the\n * critical/high cap on `registryMeta` — intact. An external scanner can\n * therefore still push a server DOWN through the floor, which is the\n * fail-closed direction, but can never push one UP through it.\n *\n * That is a claim about THIS bucket, not about the native figure in general.\n * The native buckets are not attacker-proof by construction: on the MCP surface\n * the caller also supplies the stack file, and a stack `policy` knob that\n * suppresses a native-bucket finding raises the figure this returns. The\n * release-age cooldown was exactly that (`minReleaseAgeHours: 0`, native\n * 20 → 25 against a floor of 25) and is now ignored whenever a floor is in\n * effect — see `processServer` in `src/commands/up.ts`. Any FUTURE policy knob\n * that can suppress a healthCheck / staticScan / registryMeta finding needs the\n * same treatment; this function cannot enforce that on its own.\n *\n * This is deliberately NOT applied to `mcpm install --min-trust`, to a stack\n * file's `policy.minTrustScore`, or to `mcpm audit --fix`. Those are thresholds a\n * human chose on their own machine, where the same person configures both the\n * threshold and the scanner; subtracting their scanner's points there would\n * surprise legitimate users for no security gain.\n *\n * `audit --fix` is excluded for a SECOND, independently sufficient reason, and it\n * is the one that decided the case (TODOS #35 sibling, resolved 2026-08-12): it is\n * the only score-gated DESTRUCTIVE gate in the CLI. Every other site on this list\n * REFUSES; that one DELETES servers from the user's IDE configs, so subtracting the\n * bucket there removes more rather than refusing more. Measured over 1,199 live\n * registry entries: a native filter changes 0 servers at audit's default threshold\n * and deletes every server once the threshold passes audit's native ceiling of 62.\n *\n * So the test is agent-reachability AND refuse-not-delete, not agent-reachability\n * alone — note that `policy.minTrustScore` is on this list yet IS reachable by an\n * agent through `mcpm_up` (`handlers.ts`), so the older one-line framing (\"the\n * floors this guards are the ones an AI agent, not a human, is on the other side\n * of\") was already too neat.\n */\nexport function nativeTrustScore(trust: TrustScore): NativeTrustScore {\n const score = trust.score - trust.breakdown?.externalScan;\n\n // Both gate expressions are `native.score < floor`, and `NaN < 25` is FALSE —\n // so a TrustScore carrying a partial `breakdown` would fail OPEN and install.\n // No production caller can produce one today (every wiring binds the real\n // `computeTrustScore`), but the lock file's `TrustSnapshot` is a\n // breakdown-less, TrustScore-shaped object that already exists and is already\n // read by a sibling gate, so the trajectory is short. Refuse rather than\n // coerce: a `?? 0` fallback would CREDIT the missing bucket as zero and pass.\n if (!Number.isFinite(score)) {\n throw new Error(\n \"Cannot evaluate a trust floor: the trust score has no usable breakdown. \" +\n \"This is a bug — report it rather than working around it.\",\n );\n }\n\n return {\n score,\n maxPossible: NATIVE_MAX_POSSIBLE,\n excludedExternalCredit: trust.breakdown.externalScan,\n };\n}\n"],"mappings":";;;AA0CA,IAAM,oBAAoB;AAC1B,IAAM,oBAAoB;AAC1B,IAAM,oBAAoB;AAE1B,IAAM,kBAAkB;AAOjB,IAAM,oBAAoB;AACjC,IAAM,oBAAoB;AAG1B,IAAM,sBAA2D;AAAA,EAC/D,UAAU;AAAA,EACV,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,KAAK;AACP;AAEA,IAAM,aAAa,KAAK,KAAK,KAAK;AAClC,IAAM,qBAAqB;AAC3B,IAAM,qBAAqB;AAU3B,IAAM,sBAAsB,oBAAoB,kBAAkB;AAClE,IAAM,oBAAoB,sBAAsB;AAMhD,SAAS,iBAAiB,QAAgC;AACxD,MAAI,WAAW,KAAM,QAAO;AAC5B,MAAI,WAAW,MAAO,QAAO;AAC7B,SAAO;AACT;AAEA,SAAS,gBAAgB,UAA6B;AACpD,SAAO,SAAS,OAAO,CAAC,KAAK,MAAM,MAAM,oBAAoB,EAAE,QAAQ,GAAG,CAAC;AAC7E;AAEA,SAAS,gBAAgB,UAA6B;AACpD,SAAO,KAAK,IAAI,GAAG,kBAAkB,gBAAgB,QAAQ,CAAC;AAChE;AAEA,SAAS,kBAAkB,oBAA6B,UAA6B;AACnF,MAAI,CAAC,mBAAoB,QAAO;AAChC,SAAO,KAAK,IAAI,GAAG,oBAAoB,gBAAgB,QAAQ,CAAC;AAClE;AAEA,SAAS,kBAAkB,MAA+C;AACxE,MAAI,SAAS;AAEb,MAAI,KAAK,wBAAwB,MAAM;AACrC,cAAU;AAAA,EACZ;AAEA,MAAI,KAAK,aAAa;AACpB,UAAM,eAAe,KAAK,IAAI,IAAI,IAAI,KAAK,KAAK,WAAW,EAAE,QAAQ;AACrE,QAAI,eAAe,qBAAqB,YAAY;AAClD,gBAAU;AAAA,IACZ;AAAA,EACF;AAEA,MAAI,OAAO,KAAK,kBAAkB,YAAY,KAAK,gBAAgB,oBAAoB;AACrF,cAAU;AAAA,EACZ;AAEA,SAAO,KAAK,IAAI,QAAQ,iBAAiB;AAC3C;AAMA,SAAS,aAAa,OAAe,aAA0C;AAC7E,QAAM,QAAQ,QAAQ;AACtB,MAAI,SAAS,IAAK,QAAO;AACzB,MAAI,SAAS,IAAK,QAAO;AACzB,SAAO;AACT;AASA,SAAS,0BAA0B,UAA8B;AAC/D,SAAO,SAAS,KAAK,CAAC,MAAM,EAAE,aAAa,cAAc,EAAE,aAAa,MAAM;AAChF;AAMO,SAAS,kBAAkB,OAAoC;AA4BpE,QAAMA,oBACJ,MAAM,sBACN,CAAC,MAAM,SAAS,KAAK,CAAC,MAAM,EAAE,WAAW,cAAc,EAAE,SAAS,eAAe;AAEnF,QAAM,cAAcA,oBAAmB,oBAAoB;AAK3D,QAAM,oBAAoB,0BAA0B,MAAM,QAAQ,IAC9D,IACA,kBAAkB,MAAM,YAAY;AAqBxC,QAAM,mBAAmBA,oBACrB,MAAM,SAAS,OAAO,CAAC,MAAM,EAAE,WAAW,UAAU,IACpD,CAAC;AACL,QAAM,iBAAiBA,oBACnB,MAAM,SAAS,OAAO,CAAC,MAAM,EAAE,WAAW,UAAU,IACpD,MAAM,SAAS,OAAO,CAAC,MAAM,EAAE,EAAE,SAAS,mBAAmB,EAAE,WAAW,WAAW;AAEzF,QAAM,YAAiC;AAAA,IACrC,aAAa,iBAAiB,MAAM,iBAAiB;AAAA,IACrD,YAAY,gBAAgB,cAAc;AAAA,IAC1C,cAAc,kBAAkBA,mBAAkB,gBAAgB;AAAA,IAClE,cAAc;AAAA,EAChB;AAEA,QAAM,QACJ,UAAU,cACV,UAAU,aACV,UAAU,eACV,UAAU;AAEZ,QAAM,QAAQ,aAAa,OAAO,WAAW;AAE7C,SAAO,EAAE,OAAO,aAAa,OAAO,WAAW,EAAE,GAAG,UAAU,EAAE;AAClE;AAoBO,SAAS,iBAAiB,OAA4B;AAC3D,SAAO,MAAM,gBAAgB;AAC/B;AAiEO,SAAS,iBAAiB,OAAqC;AACpE,QAAM,QAAQ,MAAM,QAAQ,MAAM,WAAW;AAS7C,MAAI,CAAC,OAAO,SAAS,KAAK,GAAG;AAC3B,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AAEA,SAAO;AAAA,IACL;AAAA,IACA,aAAa;AAAA,IACb,wBAAwB,MAAM,UAAU;AAAA,EAC1C;AACF;","names":["externalCredited"]}
#!/usr/bin/env node
import {
handleLock,
registerLockCommand
} from "./chunk-C6CAHFQX.js";
import "./chunk-E3T224S3.js";
import "./chunk-RTI2GLYX.js";
import "./chunk-FEXJHHDM.js";
import "./chunk-F6CHEUGO.js";
import "./chunk-LSNEZAFR.js";
import "./chunk-7RJXJERN.js";
import "./chunk-V4AA4ZL5.js";
import "./chunk-32VRWVOF.js";
import "./chunk-K4U7EXLG.js";
import "./chunk-U7N6FRYF.js";
import "./chunk-WT6V33F2.js";
export {
handleLock,
registerLockCommand
};
//# sourceMappingURL=lock-3JKW72C5.js.map
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
#!/usr/bin/env node
import {
buildDriftFinding,
buildHandshakeDriftFinding,
classifyDrift,
classifyHandshakeDrift,
inspectForDrift,
inspectHandshakeForDrift
} from "./chunk-5W5Z3VZG.js";
import {
PolicyIntegrityError,
expireStale,
readPolicy
} from "./chunk-CYYYMOUS.js";
import {
hasToolsList,
inspectFrame,
mergeInspect,
withReplyToOrigin
} from "./chunk-XLPT6EJQ.js";
import {
hashConfineProfile,
loadProfile
} from "./chunk-544DEV2D.js";
import {
OWASP_MCP_TOP_10
} from "./chunk-4ANBMGU5.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-NPJ3SGGS.js";
import {
getStorePath
} from "./chunk-3X76P3FG.js";
import {
ACTION_RANK,
defaultActionForFinding,
inspectMessage
} from "./chunk-WT6V33F2.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-E2H7X7PM.js.map

Sorry, the diff of this file is too big to display

#!/usr/bin/env node
import {
buildDoctorModel,
execCheckDefault,
formatMcpEntryCommand,
makeCheckConfigExists
} from "./chunk-DR7HERUD.js";
import {
resolveInstallEntry
} from "./chunk-LNGTDYGN.js";
import {
readPins
} from "./chunk-DDCTUMSZ.js";
import "./chunk-E3T224S3.js";
import {
fetchNpmProvenance
} from "./chunk-RTI2GLYX.js";
import "./chunk-WYSMWP2R.js";
import "./chunk-OIFKZA4V.js";
import "./chunk-FEXJHHDM.js";
import "./chunk-F6CHEUGO.js";
import {
nativeTrustScore
} from "./chunk-LSNEZAFR.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-NPJ3SGGS.js";
import "./chunk-6R7TL5O2.js";
import {
CLIENT_IDS
} from "./chunk-R4R2VPDA.js";
import "./chunk-4QDJ3I7X.js";
import "./chunk-3X76P3FG.js";
import {
extractRegistryMeta
} from "./chunk-U7N6FRYF.js";
import "./chunk-WT6V33F2.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);
const nativeTrust = nativeTrustScore(trust);
if (nativeTrust.score < minScore) {
throw new Error(
`Server "${args.name}" has trust score ${nativeTrust.score}/${nativeTrust.maxPossible} (level: ${trust.level}), which is below the minimum threshold of ${minScore}. ` + (nativeTrust.excludedExternalCredit > 0 ? `An external scanner's ${nativeTrust.excludedExternalCredit} points are excluded from this floor because mcpm cannot verify them. ` : "") + `Install rejected for safety. Use mcpm CLI with --yes to override after manual review.`
);
}
const clients = await resolveClients(args.client, deps);
const planned = clients.map((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.`
);
}
return {
clientId: clientId2,
adapter: deps.getAdapter(clientId2),
configPath: deps.getConfigPath(clientId2),
mcpEntry
};
});
const done = [];
try {
for (const p of planned) {
await p.adapter.addServer(p.configPath, args.name, p.mcpEntry);
done.push(p);
}
await deps.addToStore({
name: args.name,
version: entry.server.version,
clients: done.map((p) => p.clientId),
installedAt: (/* @__PURE__ */ new Date()).toISOString()
});
} catch (err) {
const stranded = await rollbackInstall(done, args.name);
if (stranded.length > 0) {
throw new Error(
`${err instanceof Error ? err.message : String(err)}
Rollback incomplete: "${args.name}" is STILL INSTALLED in ${stranded.join(", ")}. Remove it with \`mcpm remove ${args.name}\` before retrying.`
);
}
throw err;
}
return {
installed: true,
name: args.name,
version: entry.server.version,
clients: done.map((p) => p.clientId),
trustScore: trust
};
}
async function rollbackInstall(done, name) {
const stranded = [];
for (const p of done) {
try {
await p.adapter.removeServer(p.configPath, name);
} catch {
stranded.push(p.clientId);
}
}
return stranded;
}
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 = Math.max(
effectiveMinTrustScore(args.minTrustScore),
DEFAULT_MIN_TRUST_SCORE
);
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;
}
const bestNative = nativeTrustScore(bestTrust);
if (bestNative.score < minScore) {
skipped.push({
name: bestEntry.server.name,
reason: `Trust score ${bestNative.score}/${bestNative.maxPossible} is below minimum ${minScore}` + (bestNative.excludedExternalCredit > 0 ? ` (an external scanner's ${bestNative.excludedExternalCredit} points are excluded \u2014 mcpm cannot verify them)` : "")
});
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-IF5B5AKH.js");
const { writeFile } = await import("fs/promises");
const { handleLock } = await import("./lock-3JKW72C5.js");
const { RegistryClient } = await import("./client-3RPMRFZL.js");
const { scanTier1: st1 } = await import("./tier1-OPUMS3NX.js");
const { checkScannerAvailable: csa, scanTier2: st2 } = await import("./tier2-PI43NCHZ.js");
const { computeTrustScore: cts } = await import("./trust-score-BAGF67DE.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-OPUMS3NX.js");
const { computeTrustScore } = await import("./trust-score-BAGF67DE.js");
const { addInstalledServer, removeInstalledServer } = await import("./servers-IS6ZWSYC.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.29.1"
});
registerTools(server, deps);
const transport = new StdioServerTransport();
await server.connect(transport);
}
export {
registerTools,
startServer
};
//# sourceMappingURL=server-B4TJ4OGL.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, McpServerEntry } 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 { nativeTrustScore } 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 *\n * Lowering the gate is only half of it. A score can also be pushed UP to meet\n * the gate, and `MCPM_EXTERNAL_SCANNER` is caller-supplied too — so the floor is\n * evaluated against `nativeTrustScore`, which excludes the external bucket's\n * unverifiable credit (TODOS #33).\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 //\n // TODOS #33: compared against mcpm's OWN evidence. `computeTrust` above already\n // passes `hasExternalScanner: false`, so today this subtracts nothing — it is\n // here so that wiring a scanner into this path later cannot silently reopen the\n // floor, which is the failure mode #33 found on the sibling `mcpm_up` path.\n const minScore = effectiveMinTrustScore(args.minTrustScore);\n const nativeTrust = nativeTrustScore(trust);\n if (nativeTrust.score < minScore) {\n throw new Error(\n `Server \"${args.name}\" has trust score ${nativeTrust.score}/${nativeTrust.maxPossible} ` +\n `(level: ${trust.level}), which is below the minimum threshold of ${minScore}. ` +\n (nativeTrust.excludedExternalCredit > 0\n ? `An external scanner's ${nativeTrust.excludedExternalCredit} points are excluded from this floor because mcpm cannot verify them. `\n : \"\") +\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 // PRE-FLIGHT: resolve and validate EVERY client's entry before touching a single\n // config. resolveInstallEntry's URL rule is cursor-ONLY, so a server carrying both\n // an npm package and an http remote used to install cleanly on claude-desktop and\n // only THEN hit the H9 deny on cursor — the agent was told the install failed while\n // a live execution surface sat in Claude Desktop, with no store record of it.\n const planned = clients.map((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 return {\n clientId,\n adapter: deps.getAdapter(clientId),\n configPath: deps.getConfigPath(clientId),\n mcpEntry,\n };\n });\n\n // APPLY as a unit. Any failure — a client write or the store write — unwinds every\n // write already made, so this tool never reports failure over a live install.\n // The store write is inside the transaction on purpose: configs written without a\n // store record are invisible to `mcpm list` / `audit` and survive `mcpm remove`.\n const done: PlannedInstall[] = [];\n try {\n for (const p of planned) {\n await p.adapter.addServer(p.configPath, args.name, p.mcpEntry);\n done.push(p);\n }\n await deps.addToStore({\n name: args.name,\n version: entry.server.version,\n clients: done.map((p) => p.clientId),\n installedAt: new Date().toISOString(),\n });\n } catch (err) {\n const stranded = await rollbackInstall(done, args.name);\n if (stranded.length > 0) {\n // Rollback is best-effort and can fail too. Swallowing that would report a\n // clean failure over a server that is still installed — name it instead.\n throw new Error(\n `${err instanceof Error ? err.message : String(err)}\\n\\n` +\n `Rollback incomplete: \"${args.name}\" is STILL INSTALLED in ${stranded.join(\", \")}. ` +\n `Remove it with \\`mcpm remove ${args.name}\\` before retrying.`\n );\n }\n throw err;\n }\n\n return {\n installed: true,\n name: args.name,\n version: entry.server.version,\n clients: done.map((p) => p.clientId),\n trustScore: trust,\n };\n}\n\n/** One client's fully-resolved install, validated and ready to write. */\ntype PlannedInstall = {\n clientId: ClientId;\n adapter: ConfigAdapter;\n configPath: string;\n mcpEntry: McpServerEntry;\n};\n\n/**\n * Undo the client-config writes already made by a failed install.\n * @returns the clients that could NOT be rolled back (still installed).\n */\nasync function rollbackInstall(done: PlannedInstall[], name: string): Promise<ClientId[]> {\n const stranded: ClientId[] = [];\n for (const p of done) {\n try {\n await p.adapter.removeServer(p.configPath, name);\n } catch {\n stranded.push(p.clientId);\n }\n }\n return stranded;\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 //\n // Also clamp UP to handleInstall's own default. This pre-filter delegates to\n // handleInstall without forwarding minTrustScore, so the enforcing gate always\n // applies DEFAULT_MIN_TRUST_SCORE. With a requested 25-49 the two disagreed:\n // the pre-filter waved the server through and handleInstall then refused it,\n // reporting a trust rejection as \"Install failed\" and quoting a threshold the\n // caller never asked for. Taking the stricter of the two makes the pre-filter\n // report exactly what the enforcing gate will do. Deliberately NOT fixed by\n // forwarding minTrustScore instead -- that would let a caller-supplied 30\n // LOWER the gate this path enforces today.\n const minScore = Math.max(\n effectiveMinTrustScore(args.minTrustScore),\n DEFAULT_MIN_TRUST_SCORE,\n );\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 // TODOS #33: the same native-evidence rule as the sibling gates. handleInstall\n // below re-checks and is the enforcing gate, so this pre-filter exists to\n // produce an accurate \"skipped\" reason rather than an \"Install failed\" one —\n // but it must agree with it, or a server rejected downstream gets reported\n // under the wrong heading with a score that was never compared.\n const bestNative = nativeTrustScore(bestTrust);\n if (bestNative.score < minScore) {\n skipped.push({\n name: bestEntry.server.name,\n reason:\n `Trust score ${bestNative.score}/${bestNative.maxPossible} is below minimum ${minScore}` +\n (bestNative.excludedExternalCredit > 0\n ? ` (an external scanner's ${bestNative.excludedExternalCredit} points are excluded — mcpm cannot verify them)`\n : \"\"),\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"]}
#!/usr/bin/env node
import {
EXTERNAL_SCAN_MAX,
computeTrustScore,
externalCredited,
nativeTrustScore
} from "./chunk-LSNEZAFR.js";
export {
EXTERNAL_SCAN_MAX,
computeTrustScore,
externalCredited,
nativeTrustScore
};
//# sourceMappingURL=trust-score-BAGF67DE.js.map
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
#!/usr/bin/env node
import {
handleUp,
registerUpCommand
} from "./chunk-KGCQLI22.js";
import "./chunk-C6CAHFQX.js";
import "./chunk-LNGTDYGN.js";
import "./chunk-DDCTUMSZ.js";
import "./chunk-E3T224S3.js";
import "./chunk-RTI2GLYX.js";
import "./chunk-OIFKZA4V.js";
import "./chunk-FEXJHHDM.js";
import "./chunk-F6CHEUGO.js";
import "./chunk-LSNEZAFR.js";
import "./chunk-UNGY7RTE.js";
import "./chunk-W4IAFBUN.js";
import "./chunk-2PWW3Q5Q.js";
import "./chunk-MLVDFLDQ.js";
import "./chunk-7RJXJERN.js";
import "./chunk-V4AA4ZL5.js";
import "./chunk-32VRWVOF.js";
import "./chunk-K4U7EXLG.js";
import "./chunk-NPJ3SGGS.js";
import "./chunk-6R7TL5O2.js";
import "./chunk-R4R2VPDA.js";
import "./chunk-4QDJ3I7X.js";
import "./chunk-3X76P3FG.js";
import "./chunk-U7N6FRYF.js";
import "./chunk-WT6V33F2.js";
export {
handleUp,
registerUpCommand
};
//# sourceMappingURL=up-IF5B5AKH.js.map
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display