diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b56101..f7e4adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`adlc-cli mcp install [DIR] --agent NAME`** — materialise the project's + `.mcp.json` into each named agent's own MCP config (`claude-code`, + `opencode`, `codex`, `cursor`; `claude-code` is a no-op — the standard is + its file). A name in `.mcp.json` is tool-owned and regenerated on each run; + the off switch is `disabled: true` in `.mcp.json`, which the tool never + rewrites. Exit 0 only when every named agent is configured; refusals and + target-level errors fail the run with exit 1, and an entry that cannot be + fully expressed is never half-written. A copy-once gap report prints drift + against the team's `.mcp.json` as warnings on stderr, never changing the + exit code. JSON targets are normalised to 2-space indent on first write. + `team setup` and `team update` now chain the install for the configured + agent (before the agent session and the repair pass respectively); the + chained run behaves exactly like the standalone command — gap report + included — and an MCP failure aborts the team command with exit 1. + Note: an `init-options.json` that records no agent now fails `team update` + loudly at the chained install (previously such a hand-maintained file + completed); run `adlc-cli team setup` or pass `-a` to record the agent. + This also fixes `team update` exiting 1 after a successful repair: the + repair script's exit status is now honoured (`0` was coerced to `1`). + ## [2.5.0] - 2026-10-28 ### Added diff --git a/README.md b/README.md index 6c9c67f..4daf4a0 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,20 @@ npx adlc-cli version | Command | Description | |----------|-------------| -| `team setup -a ` | Install skills + write `.adlc/init-options.json` + run `/team-setup` (interactive UI on a TTY, headless in CI/pipes; `--skip-skills` to skip installation) | -| `team update` | `git pull` the team-ai-directives repo + re-install skills from source + `team repair --update-confidence` | +| `team setup -a ` | Install skills + write `.adlc/init-options.json` + run `mcp install` + run `/team-setup` (interactive UI on a TTY, headless in CI/pipes; `--skip-skills` to skip installation) | +| `team update` | `git pull` the team-ai-directives repo + re-install skills from source + run `mcp install` + `team repair --update-confidence` | | `team repair [--update-confidence\|--validate-drafts\|--build-to-delete]` | Run `/team-repair` headlessly (agent-driven), or the deterministic `setup-team.sh` path for `--update-confidence` / `--validate-drafts` | The `team` tree reads its targets from `.adlc/init-options.json` (see below); `-a` defaults to the configured agent. +Both `team setup` and `team update` chain `mcp install` for the configured +agent before the agent session (`team setup`) or the repair pass +(`team update`) — a named agent never starts without its MCP servers. If the +chained install fails (a refused entry, a target-level error, a corrupt +`.mcp.json`), the command aborts with exit 1; its copy-once gap report runs +chained too (see [MCP](#mcp)). + ### Workspace | Command | Description | @@ -205,6 +212,35 @@ later step or gate can read them, for example a rule, the file is ignored with a log line. Stdout is never parsed for results. +### MCP + +| Command | Description | +|---------|-------------| +| `mcp install [DIR] --agent NAME` | Write each named agent's own MCP config from the project's `.mcp.json` | + +`mcp install` reads `.mcp.json` (the single standard declaration of MCP +servers) and materialises its `mcpServers` into each named agent's own config +shape: `opencode.json` (`mcp`), `.cursor/mcp.json` (`mcpServers`) and +`.codex/config.toml` (`[mcp_servers.*]`); `claude-code` is a no-op — the +standard is already its file. A name present in `.mcp.json` is tool-owned and +regenerated on each run; the off switch is `disabled: true` in `.mcp.json` +itself, which the tool never rewrites. + +Precondition: `team setup` has recorded an agent in `.adlc/init-options.json`, +or pass `--agent`/`-a` (repeatable) explicitly. Supported agents: +`claude-code`, `opencode`, `codex`, `cursor`. + +Exit contract: `0` only when every named agent is configured; `1` otherwise — +target-level errors and per-server refusals both fail the run (an entry that +cannot be fully expressed is never half-written). After the install, a +copy-once gap report compares the team's `.mcp.json` against the project's and +prints drift as warnings on stderr; it never changes the exit code. JSON +targets are normalised to 2-space indent on first write. + +`team setup` and `team update` chain this command for the configured agent; +the chained run has the same behavior — gap report included — and an MCP +failure aborts the team command with exit 1. + ### Top-level | Command | Description | diff --git a/src/commands/mcp.mjs b/src/commands/mcp.mjs new file mode 100644 index 0000000..57bbd8e --- /dev/null +++ b/src/commands/mcp.mjs @@ -0,0 +1,235 @@ +// `adlc-cli mcp install` — materialise the project's `.mcp.json` into each +// NAMED agent's own MCP config. The command is the promise surface +// (PDR-138/139): exit 0 only when every named agent is configured; on any +// failure it exits 1 naming the agent, the target file and the reason. +// +// ── Precondition (PDR-139) ──────────────────────────────────────────── +// team-setup has run, observable as `.adlc/init-options.json` naming the +// agent. `--agent`/`-a`/`--agents` overrides it. Nothing is ever silently +// defaulted to claude — an unknown agent target is a loud failure, not a +// quiet no-op. +// +// ── Naming an agent is a commitment ─────────────────────────────────── +// Only `MCP_AGENTS` keys get a file written. A named agent outside that set +// gets nothing written and the servers listed for hand-install on stderr +// ("add these by hand", PDR-138 scope-out) with the run failing. An agent +// never named is simply out of scope. +// +// ── Two failure channels, one exit code ─────────────────────────────── +// installForAgent reports target-level `errors[]` (file left untouched) and +// per-server `refused` entries. Either fails the run: a named agent that +// will start without its servers is never reported as success. The exit +// contract is 0 | 1 only — never 3 (workflow.mjs reserves it for PAUSED). +// +// `.mcp.json` is the source of truth and is never rewritten here; +// `claude-code` is therefore a no-op that reports every server kept. +// +// ── The copy-once gap report (PDR-140) ──────────────────────────────── +// After the install, the team's .mcp.json (the upstream declaration) is +// compared against the project's (the copy). What the copy lacks is +// reported as a warning — never written, never blocking — so it can never +// change the exit code. A missing team source is itself reported: a +// report that quietly reports nothing is worse than no report. + +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { droppedDefaults, reportGap } from "../mcp/gap.mjs"; +import { installForAgent } from "../mcp/install.mjs"; +import { getMcpAgent, MCP_AGENTS } from "../registry.mjs"; +import { readAgent } from "../utils/init-options.mjs"; +import { + out, + err, + error, + failure, + success, + section, + item, + sym, + warn, +} from "../ui/index.mjs"; + +const MCP_SOURCE = ".mcp.json"; +const INIT_OPTIONS = ".adlc/init-options.json"; +const SUPPORTED = Object.keys(MCP_AGENTS).join(", "); + +/** + * `adlc-cli mcp install [DIR] --agent NAME` — write each named agent's MCP + * config from the project's `.mcp.json`. + * + * @param {string[]} args positional args; `args[0]` is the project dir (default: cwd). + * @param {object} flags parsed flags; `flags.agents` names the committed agents. + * @returns {Promise} 0 when every named agent is configured, 1 otherwise. + */ +export async function cmdMcpInstall(args = [], flags = {}) { + const projectRoot = resolve(args[0] ?? process.cwd()); + + // Precondition: an agent must be named before anything can be delivered. + // --agent/-a/--agents wins; else the agent team-setup recorded; else fail + // loudly — never silently default to claude (PDR-139). + const named = Array.isArray(flags.agents) ? [...flags.agents] : []; + if (named.some((agent) => !agent)) { + error(`--agent requires a NAME (${SUPPORTED})`); + return 1; + } + if (named.length === 0) { + const recorded = readAgent(projectRoot); + if (recorded) { + named.push(recorded); + } else { + if (existsSync(join(projectRoot, INIT_OPTIONS))) { + error(`no agent available: ${INIT_OPTIONS} records none and no --agent was given`); + } else { + error(`team-setup has not run: ${INIT_OPTIONS} is missing and no --agent was given`); + } + err(`Run 'adlc-cli team setup' first, or pass --agent NAME (${SUPPORTED}).`); + return 1; + } + } + + // Read the source of truth. Its absence is a legitimate no-op: there is + // nothing to install and nobody was promised anything. + const sourcePath = join(projectRoot, MCP_SOURCE); + if (!existsSync(sourcePath)) { + out(`nothing to install: no ${MCP_SOURCE} in ${projectRoot}`); + // The copy-once contract is drift detection, not an install + // postscript: a project that has not copied yet lacks every + // team-declared server, and never-blocking means exit stays 0. + printGapReport(projectRoot, {}, named); + return 0; + } + let doc; + try { + doc = JSON.parse(readFileSync(sourcePath, "utf-8")); + } catch { + error(`${MCP_SOURCE} is not plain JSON; refusing to install from it`); + return 1; + } + if (!isPlainObject(doc)) { + error(`${MCP_SOURCE} has the wrong shape (root is not an object); refusing to install from it`); + return 1; + } + // Lenient source validation: only `mcpServers` is translated; unknown + // top-level keys are never an error (the team file ships a "tools" block). + const servers = doc.mcpServers ?? {}; + if (!isPlainObject(servers)) { + error(`${MCP_SOURCE}: "mcpServers" is not an object; refusing to install from it`); + return 1; + } + const names = Object.keys(servers); + if (names.length === 0) { + out(`nothing to install: ${MCP_SOURCE} declares no servers`); + printGapReport(projectRoot, servers, named); + return 0; + } + + // Deliver per named agent; what can be delivered still is, and the run + // verdict below aggregates — one failure fails the whole run. + const failedAgents = []; + for (const agent of named) { + const row = getMcpAgent(agent); + if (!row) { + failedAgents.push(agent); + reportNoAdapter(agent, names); + continue; + } + const report = installForAgent({ projectRoot, agent, servers }); + printReport(agent, report); + + const targetFailed = report.errors.length > 0; + const refused = report.written.filter((w) => w.action === "refused").map((w) => w.name); + if (targetFailed || refused.length > 0) { + failedAgents.push(agent); + // A named agent without one of its servers: say exactly what must be + // added by hand, and where. + reportByHand(report.target, targetFailed ? names : refused); + } + } + + // Copy-once gap report: additive warning lines after the install report, + // before the verdict — it must never change the exit code (PDR-140). + printGapReport(projectRoot, servers, named); + + if (failedAgents.length > 0) { + failure(`MCP install failed for: ${failedAgents.join(", ")} — add the listed servers by hand`); + return 1; + } + success("MCP install complete"); + return 0; +} + +// One agent's result: the target it landed in and every server's outcome. +// Failures travel to stderr; the record itself is the human-facing result. +function printReport(agent, report) { + section(`${agent} → ${report.target}`); + for (const w of report.written) { + const line = w.detail ? `${w.name} ${w.action} — ${w.detail}` : `${w.name} ${w.action}`; + if (w.action === "refused") { + failure(line); + } else { + item(line, { glyph: sym.success, color: "green" }); + } + } + for (const e of report.errors) { + error(e); + } +} + +// A named agent with no adapter: nothing written, everything by hand. +// The target file is unknown for it — that is the point of saying so. +function reportNoAdapter(agent, names) { + error(`no MCP adapter for "${agent}" (supported: ${SUPPORTED}); nothing was written`); + err(`add these servers by hand to ${agent}'s MCP config (target file unknown — definitions are in ${MCP_SOURCE}):`); + for (const name of names) err(` ${name}`); +} + +// A known target that could not take its servers: name the file and what +// belongs in it (PDR-139: agent, target file, reason). +function reportByHand(target, names) { + err(`add these servers by hand to ${target}:`); + for (const name of names) err(` ${name}`); +} + +// ── The copy-once gap report (PDR-140) ───────────────────────────────── +// Additive warning lines on stderr (the ui layer's warn() convention — +// stdout stays the result channel scripts and CI read): they never change +// the exit code and never write anything. Silence when there is nothing +// to say; a missing team source is itself reported — a report that +// quietly reports nothing is worse than no report. +function printGapReport(projectRoot, servers, named) { + const gap = reportGap({ projectRoot, servers }); + const lines = []; + if (gap.teamSourceUnavailable) { + lines.push(`team MCP source unavailable — gap not checked (team-ai-directives not configured or unreadable)`); + } + if (gap.missing.length > 0) { + lines.push(`team declares servers missing from ${MCP_SOURCE}: ${gap.missing.join(", ")} (add them by hand if you want them)`); + } + if (gap.notCarried.length > 0) { + lines.push(`team ${MCP_SOURCE} keys not carried to any target: ${gap.notCarried.join(", ")}`); + } + appendDroppedDefaults(lines, servers, named); + if (lines.length === 0) return; + warn("MCP gap (warning only — nothing written, nothing blocked)"); + for (const line of lines) warn(line); +} + +// ${VAR:-default} fallbacks are dropped by every variable syntax except +// "passthrough". The install report already carries the note for "env-vars" +// targets (droppedDefaultsNote in install.mjs prints it in the entry +// detail), so the gap report surfaces the drop only for the targets where +// it would otherwise stay silent. +function appendDroppedDefaults(lines, servers, named) { + const targets = [...new Set(named.filter((agent) => { + const row = getMcpAgent(agent); + return row && (row.var_syntax === "env-braces" || row.var_syntax === "env-prefix"); + }))]; + if (targets.length === 0) return; + for (const { server, vars } of droppedDefaults(servers)) { + lines.push(`${server}: defaults dropped in translation to ${targets.join(", ")} — set explicitly: ${vars.join(", ")}`); + } +} + +function isPlainObject(v) { + return v !== null && typeof v === "object" && !Array.isArray(v); +} diff --git a/src/commands/team.mjs b/src/commands/team.mjs index 30d9048..c5e81bf 100644 --- a/src/commands/team.mjs +++ b/src/commands/team.mjs @@ -7,8 +7,35 @@ import { readInitOptions, writeInitOptions, readAgent, readTeamAiDirectives } fr import { findInstalledSkills } from "../source.mjs"; import { getAgent } from "../registry.mjs"; import { cmdAdd, cmdUpdate } from "./skills.mjs"; +import { cmdMcpInstall } from "./mcp.mjs"; import { error, failure, note } from "../ui/index.mjs"; +// ── mcp install chaining ──────────────────────────────────────────────── +// `team setup`/`team update` configure the agent's MCP servers before its +// session starts — a named agent that would start without its servers is +// never reported as success (PDR-138/139). CLI-to-CLI chaining only: the +// skill prose never orchestrates this. + +/** + * Run `mcp install` for one agent as part of a team command, aborting the + * command when it fails (ruling 1a). + * + * Thin by design: every decision — the missing-source no-op, the copy-once + * gap report, refusals — already lives in (and is tested on) cmdMcpInstall. + * Do not pre-check for `.mcp.json` here; the chained run must behave exactly + * like the standalone command (ruling 3: the gap report runs chained too). + * + * @param {string} projectRoot project dir holding `.mcp.json`. + * @param {string} agentKey registry key of the committed agent. + * @param {string} label the aborting message names the team command. + * @returns {Promise} 0 the agent is configured, 1 otherwise. + */ +export async function chainMcpInstall(projectRoot, agentKey, label) { + const code = await cmdMcpInstall([projectRoot], { agents: [agentKey] }); + if (code !== 0) failure(`MCP install failed — aborting ${label}`); + return code; +} + // ── setup ────────────────────────────────────────────────────────────── export async function cmdTeamSetup(args, flags) { const source = args[0]; @@ -43,6 +70,13 @@ export async function cmdTeamSetup(args, flags) { } } + // Configure the agent's MCP servers before its session starts — an agent + // that would start without its servers is never reported as success + // (PDR-138/139). The chained run is the full mcp install behavior, gap + // report included (ruling 3); a failure aborts the command (ruling 1a). + const mcpCode = await chainMcpInstall(resolve(flags.cwd ?? process.cwd()), agentKey, "team setup"); + if (mcpCode !== 0) return mcpCode; + const { cmdAgentRun, runAgentInteractive } = await import("./agent.mjs"); const prompt = "Run /team-setup to configure team-ai-directives for this project"; @@ -88,6 +122,13 @@ export async function cmdTeamUpdate(args, flags) { return updateCode || 1; } + // The agent's MCP servers are (re)configured from .mcp.json before the + // repair pass; an MCP failure aborts the update before repair runs + // (ruling 1a). The agent is the one team-setup recorded (`-a` wins). + const agentKey = flags.agents?.[0] || opts.agent; + const mcpCode = await chainMcpInstall(process.cwd(), agentKey, "team update"); + if (mcpCode !== 0) return mcpCode; + return cmdTeamRepair([], { updateConfidence: true }); } @@ -114,8 +155,15 @@ export async function cmdTeamRepair(args, flags) { } const scriptArgs = flags.validateDrafts ? ["--validate-drafts"] : ["--update-confidence"]; - const result = spawnSync("bash", [scriptPath, ...scriptArgs], { stdio: "inherit" }); - return result.status || 1; + // Git Bash treats backslashes as escape characters, so hand it a + // forward-slash path on Windows (same convention as dispatcher.mjs's + // sh branch); POSIX paths pass through untouched. + const bashScript = process.platform === "win32" ? scriptPath.replace(/\\/g, "/") : scriptPath; + const result = spawnSync("bash", [bashScript, ...scriptArgs], { stdio: "inherit" }); + // ?? not ||: a successful script (status 0) is success — `0 || 1` made + // every successful repair (and therefore every successful `team update`) + // exit 1. + return result.status ?? 1; } const agentKey = flags.agents[0] || readAgent(); diff --git a/src/dispatch.mjs b/src/dispatch.mjs index eb28afa..d935779 100644 --- a/src/dispatch.mjs +++ b/src/dispatch.mjs @@ -8,6 +8,7 @@ import { cmdFactorySetup } from "./commands/factory.mjs"; import { cmdAgentRun, cmdAgentList } from "./commands/agent.mjs"; import { cmdWorkspaceSetup, cmdWorkspaceInit, cmdWorkspaceStatus } from "./commands/workspace.mjs"; import { cmdWorkflow } from "./commands/workflow.mjs"; +import { cmdMcpInstall } from "./commands/mcp.mjs"; import { printCliHelp, printSkillsHelp, @@ -16,6 +17,7 @@ import { printAgentHelp, printWorkspaceHelp, printWorkflowHelp, + printMcpHelp, printHelp, } from "./help.mjs"; import { setColorMode, out, err, style, combine } from "./ui/index.mjs"; @@ -241,6 +243,21 @@ async function runNewTree({ command, args, flags }, argv) { return 1; } } + case "mcp": { + const sub = args[0] ?? "help"; + const rest = args.slice(1); + switch (sub) { + case "install": + return cmdMcpInstall(rest, flags); + case "help": + printMcpHelp(); + return 0; + default: + err(combine(style("Unknown", "red"), ' ', style(`mcp command: "${sub}"`, "gray"))); + printMcpHelp(); + return 1; + } + } case "version": out(`adlc-cli ${VERSION}`); return 0; @@ -264,7 +281,7 @@ function parseArgs(argv) { for (let i = 0; i < rest.length; i++) { const arg = rest[i]; - if (arg === "-a" || arg === "--agent") { + if (arg === "-a" || arg === "--agent" || arg === "--agents") { flags.agents.push(rest[++i]); } else if (arg === "-g" || arg === "--global") { flags.global = true; diff --git a/src/help.mjs b/src/help.mjs index d5be9d8..5be70d5 100644 --- a/src/help.mjs +++ b/src/help.mjs @@ -80,6 +80,7 @@ export function printCliHelp() { ["workflow status [run_id]", "List runs / show one run's step states"], ["workflow validate ", "Validate a workflow definition (--headless checks gate verdicts)"], ["workflow state …", "LLM-executor helpers: start/advance/pause/fail/show"], + ["mcp install [DIR] --agent NAME", "Write each named agent's MCP config from .mcp.json"], ["version", "Print installed version"], ["help", "Show this help"], ]), @@ -372,6 +373,34 @@ export function printHelp() { out(""); } +export function printMcpHelp() { + out(""); + out(section("Usage", [tint("adlc-cli mcp install [DIR] --agent NAME", "bold")])); + out( + section("Commands", [ + ...pairs([ + ["install [DIR]", "Write each named agent's MCP config from the project's .mcp.json (DIR defaults to the current directory)"], + ["help", "Show this help"], + ]), + ]), + ); + out( + section("Flags", [ + ...pairs([ + ["-a, --agent, --agents ", "Agent to configure (repeatable): claude-code | opencode | codex | cursor; default: the agent in .adlc/init-options.json"], + ]), + ]), + ); + out( + section("Notes", [ + tint(".mcp.json is the source of truth and is never rewritten; claude-code needs no file (the standard is its file).", "gray"), + tint("Exit 0 only when every named agent is configured; 1 otherwise. A named agent with no adapter gets its servers listed for hand-install.", "gray"), + ]), + ); + out(section("More", [tint("Run 'adlc-cli help' for the full list.", "gray")])); + out(""); +} + export function printWorkflowHelp() { out(""); out(section("Usage", [tint("adlc-cli workflow [flags]", "bold")])); diff --git a/src/mcp/gap.mjs b/src/mcp/gap.mjs new file mode 100644 index 0000000..58d6f5e --- /dev/null +++ b/src/mcp/gap.mjs @@ -0,0 +1,92 @@ +// The copy-once gap report (PDR-140). +// +// The team's .mcp.json is the upstream declaration; the project's .mcp.json +// is the copy made once. When the upstream declares servers the copy lacks, +// that drift is REPORTED — never written, never blocking, never a merge. +// +// ── A missing team source is itself reported ────────────────────────── +// teamSourceUnavailable is true whenever the team's .mcp.json cannot be +// located or read (not configured, not cloned, unparseable, wrong shape). +// A report that quietly reports nothing is worse than no report — the gap +// never degrades to "no gap" just because the upstream went missing. +// +// ── Purity split ────────────────────────────────────────────────────── +// reportGap locates and parses the team's .mcp.json (I/O, via +// readTeamAiDirectives); the comparison itself is pure (diffGap), so it +// stays testable without fixtures. Nothing here writes anything — the +// no-write property of the whole command is pinned by the install tests. + +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { expandTilde } from "../source.mjs"; +import { readTeamAiDirectives } from "../utils/init-options.mjs"; +import { expressibleVars } from "./translate.mjs"; + +const MCP_SOURCE = ".mcp.json"; + +/** + * Compare the project's server map against the team's declared one. + * + * @param {{projectRoot: string, servers: object}} args + * `servers` is the project's parsed .mcp.json mcpServers map. + * @returns {{missing: string[], notCarried: string[], teamSourceUnavailable: boolean}} + * missing — team-declared server names the project lacks; + * notCarried — top-level keys in the team's .mcp.json that no target + * carries (its "tools" block; settled decision 4: never an error, never + * silently dropped); + * teamSourceUnavailable — the team's .mcp.json could not be read at all. + */ +export function reportGap({ projectRoot, servers }) { + const teamDir = readTeamAiDirectives(projectRoot); + if (!teamDir) return unavailable(); + const teamPath = join(resolve(projectRoot, expandTilde(teamDir)), MCP_SOURCE); + if (!existsSync(teamPath)) return unavailable(); + let doc; + try { + doc = JSON.parse(readFileSync(teamPath, "utf-8")); + } catch { + return unavailable(); + } + // A root or mcpServers of the wrong shape cannot be compared honestly — + // report the source as unavailable rather than guess at its meaning. + if (!isPlainObject(doc)) return unavailable(); + if (doc.mcpServers !== undefined && !isPlainObject(doc.mcpServers)) return unavailable(); + return diffGap(servers, doc); +} + +/** + * The pure comparison: team-declared names the project lacks, and team + * top-level keys no target carries. `mcpServers` is the only key any + * target reads; every other top-level key in the team's file is reported + * as not carried. + */ +export function diffGap(projectServers, teamDoc) { + const teamServers = isPlainObject(teamDoc?.mcpServers) ? teamDoc.mcpServers : {}; + const declared = new Set(Object.keys(projectServers ?? {})); + const missing = Object.keys(teamServers).filter((name) => !declared.has(name)); + const notCarried = Object.keys(teamDoc).filter((key) => key !== "mcpServers"); + return { missing, notCarried, teamSourceUnavailable: false }; +} + +/** + * Per-server ${VAR:-default} fallbacks that do not survive translation + * into targets without default support — the "set these explicitly" + * carry. Which targets drop them is the caller's knowledge (the registry + * rows); this stays a pure function of the server specs. + */ +export function droppedDefaults(servers) { + const out = []; + for (const [name, spec] of Object.entries(servers ?? {})) { + const { droppedDefaults: vars } = expressibleVars(spec); + if (vars.length > 0) out.push({ server: name, vars }); + } + return out; +} + +function unavailable() { + return { missing: [], notCarried: [], teamSourceUnavailable: true }; +} + +function isPlainObject(v) { + return v !== null && typeof v === "object" && !Array.isArray(v); +} diff --git a/src/mcp/install.mjs b/src/mcp/install.mjs new file mode 100644 index 0000000..b31e034 --- /dev/null +++ b/src/mcp/install.mjs @@ -0,0 +1,593 @@ +// The merge engine for `adlc-cli mcp install`: materialise the .mcp.json +// server map into each agent's own config file, keyed on the server NAME. +// +// ── Ownership is the name ───────────────────────────────────────────── +// A name present in the .mcp.json `servers` map is tool-owned and +// regenerated every run, even if hand-edited — including a bare +// {"enabled": } / `enabled = ` override: overrides belong in +// .mcp.json (`disabled`), not in the agent's file (ADR-455). Keeping a +// bare off-switch here would silently trap a disabled→re-enabled server +// in the off state while .mcp.json says on. A name absent from it is +// never read, compared or written — everything else in the target is +// foreign and preserved (byte-for-byte where the format allows). +// +// ── Two failure channels (never mixed) ──────────────────────────────── +// errors[] — target-level: unparseable / JSONC / wrong shape / +// unreadable / unwritable / a write-guard refusal. The file +// is left untouched and no per-server write is attempted. +// written[] — per-server: added | updated | kept | refused | +// disabled-skipped (each with a detail). The rest of the +// servers still install. +// +// ── Never half-write ───────────────────────────────────────────────── +// translateEntry returning null means the entry cannot be fully expressed +// here: it is refused and explained (describeRefusal), never partially +// written. A disabled server either becomes an off switch where one exists +// (has_off_switch) or is omitted/reported where none does. +// +// ── .mcp.json is the source, never a target ────────────────────────── +// .mcp.json is the source of truth and is never rewritten by tooling. +// claude-code ("the standard is its file") is therefore a no-op: every +// server reports kept and nothing is written — those servers came FROM +// .mcp.json. disabled:true does not delete there either. A write-layer +// guard additionally refuses to write the resolved source path for any +// row: it judges every write by the target's REAL path (symlinks +// followed), refusing the source itself and anything that resolves +// outside the project tree. A malformed TOML marker pair is a +// target-level error: the file is left untouched rather than risk losing +// a user's content. + +import { readFileSync, writeFileSync, mkdirSync, existsSync, lstatSync, realpathSync } from "node:fs"; +import { join, dirname, resolve, relative, isAbsolute, basename } from "node:path"; +import { getMcpAgent } from "../registry.mjs"; +import { translateEntry, describeRefusal, offSwitchEntry, expressibleVars } from "./translate.mjs"; + +// The protected source of truth. Never written by tooling. +const MCP_SOURCE = ".mcp.json"; + +/** + * Merge the .mcp.json `servers` map into one agent's config file. + * + * @param {{projectRoot: string, agent: string, servers: object}} args + * `agent` is a registry key ("claude"/"opencode"/"codex"/"cursor"); + * `servers` is the parsed .mcp.json mcpServers map. + * @returns {{target: string, written: Array<{name: string, action: string, detail?: string}>, errors: string[]}} + */ +export function installForAgent({ projectRoot, agent, servers }) { + const report = { target: "", written: [], errors: [] }; + const row = getMcpAgent(agent); + if (!row) { + report.errors.push(`unknown agent "${agent}"`); + return report; + } + + const target = resolveTarget(projectRoot, row); + report.target = target; + const absPath = join(projectRoot, target); + + // claude-code ("the standard is its file"): a no-op. Its target IS the + // source .mcp.json, which is never rewritten. Every server already lives + // there (that is where `servers` came from), so report kept and write + // nothing. disabled:true has no effect here and must not delete the + // declaration — the user deletes it by hand to turn a server off. + if (row.format === "claude" && isSourcePath(absPath, projectRoot)) { + for (const [name, spec] of Object.entries(servers ?? {})) { + report.written.push({ + name, + action: "kept", + detail: spec?.disabled + ? "no effect in claude — delete the entry from .mcp.json by hand" + : "already in .mcp.json (the standard is its file)", + }); + } + return report; + } + + // The file's own format decides the writer: never JSON.parse a .toml. + if (target.endsWith(".toml")) mergeToml(absPath, row, servers, report, projectRoot); + else mergeJson(absPath, row, servers, report, projectRoot); + + return report; +} + +// First existing candidate wins; else the first candidate (which we create). +function resolveTarget(projectRoot, row) { + for (const candidate of row.target_files) { + if (existsSync(join(projectRoot, candidate))) return candidate; + } + return row.target_files[0]; +} + +// ── Per-server classification ───────────────────────────────────────── +// Decide the desired state for one owned name before any I/O. +// entry — write this entry (a translated entry, or the off switch). +// absent — disabled where the target has no off switch: absence is the +// only real off switch (remove any stale entry). +// refuse — cannot be fully expressed here: do not write; leave alone. +function classifySpec(spec, row) { + const disabled = spec?.disabled; + if (disabled && !row.has_off_switch) { + return { kind: "absent", reason: describeRefusal(spec, row) }; + } + if (disabled && row.has_off_switch) { + return { kind: "entry", entry: offSwitchEntry(row), note: "disabled: off switch written" }; + } + const entry = translateEntry(spec, row); + if (entry === null) { + return { kind: "refuse", reason: describeRefusal(spec, row) }; + } + return { kind: "entry", entry, note: droppedDefaultsNote(spec, row) }; +} + +// Under the "env-vars" syntax a ${VAR:-default} loses its fallback and the +// variable becomes required — worth a note on the written entry. +function droppedDefaultsNote(spec, row) { + if (row.var_syntax !== "env-vars") return undefined; + const { droppedDefaults } = expressibleVars(spec); + if (droppedDefaults.length === 0) return undefined; + return `dropped defaults (set explicitly): ${droppedDefaults.join(", ")}`; +} + +// ── JSON merge (claude-code, opencode, cursor) ──────────────────────── +function mergeJson(absPath, row, servers, report, projectRoot) { + // Write-layer guard: never rewrite the source of truth, whatever row is + // driving us, and never write outside the project tree. Refuse and + // report rather than write. + if (!writeTargetGuard(absPath, projectRoot, report)) return; + const exists = existsSync(absPath); + let raw = ""; + if (exists) { + try { + raw = readFileSync(absPath, "utf-8"); + } catch { + report.errors.push(`${report.target}: unreadable; refusing to edit`); + return; + } + } + + // `.jsonc` or anything that is not plain JSON is never edited (ruling 4). + if (report.target.endsWith(".jsonc")) { + report.errors.push(`${report.target} is not plain JSON (JSONC); refusing to edit`); + return; + } + let doc; + if (raw.trim() === "") { + doc = {}; + } else { + try { + doc = JSON.parse(raw); + } catch { + report.errors.push(`${report.target} is not plain JSON (JSONC); refusing to edit`); + return; + } + if (!isPlainObject(doc)) { + report.errors.push(`${report.target} has the wrong shape (root is not an object); refusing to edit`); + return; + } + } + + const sectionKey = row.section; + let section; + if (hasOwn(doc, sectionKey)) { + section = doc[sectionKey]; + if (!isPlainObject(section)) { + report.errors.push(`${report.target}: "${sectionKey}" is not an object; refusing to edit`); + return; + } + } else { + section = {}; + } + + let changed = false; + for (const [name, spec] of Object.entries(servers ?? {})) { + const existing = hasOwn(section, name) ? section[name] : undefined; + + const c = classifySpec(spec, row); + if (c.kind === "absent") { + let detail = c.reason; + if (existing !== undefined) { + delete section[name]; + changed = true; + detail += " (removed existing entry to disable)"; + } + report.written.push({ name, action: "disabled-skipped", detail }); + continue; + } + if (c.kind === "refuse") { + report.written.push({ name, action: "refused", detail: c.reason }); + continue; + } + + const desired = c.entry; + let action; + if (existing === undefined) { + setOwn(section, name, desired); + changed = true; + action = "added"; + } else if (deepEqual(existing, desired)) { + action = "kept"; + } else { + setOwn(section, name, desired); + changed = true; + action = "updated"; + } + report.written.push({ name, action, detail: c.note }); + } + + if (changed) { + setOwn(doc, sectionKey, section); + writeTargetFile(absPath, JSON.stringify(doc, null, 2) + "\n", report); + } +} + +// ── TOML merge (codex) ──────────────────────────────────────────────── +// Rebuild only our `[mcp_servers.]` fragments (a marker pair each), +// leaving the rest of the file byte-for-byte. We never re-serialise the +// whole file and never JSON.parse a .toml. +function mergeToml(absPath, row, servers, report, projectRoot) { + // Write-layer guard: never rewrite the source of truth, whatever row is + // driving us, and never write outside the project tree. Refuse and + // report rather than write. + if (!writeTargetGuard(absPath, projectRoot, report)) return; + const exists = existsSync(absPath); + let raw; + try { + raw = exists ? readFileSync(absPath, "utf-8") : ""; + } catch { + // Mirror the JSON path: an unreadable target is a target-level + // failure, reported — never a thrown stack trace that would abort + // every remaining named agent (PDR-139's report contract). + report.errors.push(`${report.target}: unreadable; refusing to edit`); + return; + } + const decisions = new Map(); + for (const [name, spec] of Object.entries(servers ?? {})) { + // A name the marker machinery cannot key byte-exactly is refused here + // (never written, never removed) — see markerSafeName below. + decisions.set(name, markerSafeName(name) ? classifySpec(spec, row) : unsafeNameRefusal(name)); + } + // Refused names keep whatever is there (never destroy user config we cannot + // faithfully regenerate). Everything else is managed: written or removed. + const managed = new Set(); + for (const [name, c] of decisions) if (c.kind !== "refuse") managed.add(name); + + const { foreign, blocks, malformed } = splitToml(raw.split("\n"), managed); + + // A malformed marker pair (unterminated begin / orphan end) means the file + // is not safely ours to rewrite. Report and leave it untouched — data loss + // in a user's config is never acceptable. + if (malformed.length > 0) { + for (const m of malformed) report.errors.push(`${report.target}: ${m}; refusing to edit`); + return; + } + + let changed = false; + const fresh = []; + for (const [name, c] of decisions) { + const prior = blocks.get(name); + if (c.kind === "refuse") { + report.written.push({ name, action: "refused", detail: c.reason }); + continue; + } + if (c.kind === "absent") { + let detail = c.reason; + if (prior !== undefined) { + changed = true; + detail += " (removed existing entry to disable)"; + } + report.written.push({ name, action: "disabled-skipped", detail }); + continue; + } + const block = buildTomlBlock(name, c.entry); + let action; + if (prior === undefined) { + action = "added"; + changed = true; + } else if (sameBlock(prior, block)) { + action = "kept"; + } else { + action = "updated"; + changed = true; + } + fresh.push(block); + report.written.push({ name, action, detail: c.note }); + } + + if (!changed) return; // nothing to write — leave the file byte-for-byte + + const cleaned = normaliseToml(foreign); + const body = fresh.join("\n\n"); + const newContent = cleaned + ? (body ? `${cleaned}\n\n${body}\n` : `${cleaned}\n`) + : (body ? `${body}\n` : ""); + writeTargetFile(absPath, newContent, report); +} + +// Split TOML lines into foreign lines (kept verbatim) and the owned blocks to +// regenerate/remove, keyed by name. The marker scan is bounded — it stops at +// the next begin-mark or a table header — so an unterminated pair can never +// swallow a user's remaining config; that is reported as malformed instead. +function splitToml(lines, managed) { + const foreign = []; + const blocks = new Map(); + const malformed = []; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const bn = beginMarkName(line); + if (bn !== null) { + const block = [line]; + let j = i + 1; + let terminated = false; + while (j < lines.length) { + const l = lines[j]; + if (endMarkName(l) === bn) { block.push(l); j++; terminated = true; break; } + if (beginMarkName(l) !== null) break; // next begin-mark bounds it + // A table header bounds it too — but not the block's own header. + if (TABLE_HEAD_RE.test(l) && tableName(l) !== bn) break; + block.push(l); j++; + } + if (!terminated) { + malformed.push(`unterminated marker block for "${bn}"`); + foreign.push(...block); // preserve what we scanned; nothing is lost + i = j; + continue; + } + // A bare `enabled = ` block is still a tool-owned name (ADR-455): + // it is regenerated like any other owned body, never kept as an + // override — the off switch is `disabled` in .mcp.json. + if (managed.has(bn)) { + blocks.set(bn, block.join("\n")); // ours to regenerate or remove + } else { + foreign.push(...block); // not managed — leave alone + } + i = j; + continue; + } + const tn = tableName(line); + if (tn !== null && managed.has(tn)) { + const block = [line]; + let j = i + 1; + while (j < lines.length + && !TABLE_HEAD_RE.test(lines[j]) + && beginMarkName(lines[j]) === null + && endMarkName(lines[j]) === null) { + block.push(lines[j++]); + } + blocks.set(tn, block.join("\n")); // ours to regenerate or remove + i = j; + continue; + } + // An end-mark outside any block is an orphan half-pair — malformed. + if (endMarkName(line) !== null) malformed.push(`orphan end marker "${line.trim()}"`); + foreign.push(line); + i++; + } + return { foreign: foreign.join("\n"), blocks, malformed }; +} + +function buildTomlBlock(name, entry) { + const lines = [beginMark(name), `[mcp_servers.${tomlKey(name)}]`]; + for (const [k, v] of Object.entries(entry)) lines.push(`${tomlKey(k)} = ${tomlValue(v)}`); + lines.push(endMark(name)); + return lines.join("\n"); +} + +function normaliseToml(text) { + return text.replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "").trimEnd(); +} + +const BEGIN_RE = /^#\s*adlc-mcp-begin\s+(.+?)\s*$/; +const END_RE = /^#\s*adlc-mcp-end\s+(.+?)\s*$/; +const TABLE_RE = /^\s*\[mcp_servers\.([^\]]+)\]\s*(?:#.*)?$/; +const TABLE_HEAD_RE = /^\s*\[/; // any TOML table header (bounds a marker block) + +// ── Marker keys: one line, byte-exact ────────────────────────────────── +// The marker comment is the ownership key read back on the next run, so a +// name must round-trip byte-exactly through a single comment line: +// - a control character (notably a raw newline) would END the comment +// and turn the rest of the name into live TOML — an injection channel +// from a hostile .mcp.json straight into codex's project config; +// - leading/trailing whitespace is stripped by BEGIN_RE/END_RE when the +// marker is read back, which mis-keys the block: the next run cannot +// find its own entry and would emit a second table for the same name +// (invalid TOML). +// Such a name cannot be owned in a TOML target: refused per-server, +// never written, never removed. JSON targets key on the exact string and +// have no marker surface — they keep such names (pinned by tests). +function markerSafeName(name) { + if (name.length === 0 || name !== name.trim()) return false; + for (let i = 0; i < name.length; i++) { + const code = name.charCodeAt(i); + if (code <= 0x1f || code === 0x7f) return false; // C0 controls + DEL + } + return true; +} +function unsafeNameRefusal(name) { + return { + kind: "refuse", + reason: `name ${JSON.stringify(name)} cannot be owned in a TOML target: marker keys must be one line (no control characters, no leading/trailing whitespace)`, + }; +} + +// Names reaching these passed markerSafeName at classification time, so +// the interpolation is byte-exact and can never inject into the comment. +const beginMark = (name) => `# adlc-mcp-begin ${name}`; +const endMark = (name) => `# adlc-mcp-end ${name}`; +const beginMarkName = (line) => (BEGIN_RE.exec(line)?.[1] ?? null); +const endMarkName = (line) => (END_RE.exec(line)?.[1] ?? null); +function tableName(line) { + const m = TABLE_RE.exec(line); + return m ? unquoteKey(m[1].trim()) : null; +} + +// Does the resolved target path land on the protected source .mcp.json? +// Lexical check — enough for the claude-code no-op dispatch above (its +// target IS the source by construction). The write layer needs the +// symlink-aware version below. +function isSourcePath(absPath, projectRoot) { + return resolve(absPath) === resolve(projectRoot, MCP_SOURCE); +} + +// ── Write-layer guard: judge the write by where it LANDS ────────────── +// Two invariants, checked on a realpath basis (symlinks followed) so a +// link cannot redirect a write past them: +// - the real target is never the real .mcp.json — the source of truth +// is never rewritten, whatever row drives us (ADR-455/PDR-139); +// - the real target stays inside the project tree — a link out of the +// tree would make the tool write files the project does not own. +// A dangling symlink is refused outright: its destination is unknowable, +// and writing through it would create a file nobody declared. Same +// refusal channel as every target-level failure: report, never throw. +function writeTargetGuard(absPath, projectRoot, report) { + const refuse = (reason) => { + report.errors.push(`${report.target}: ${reason}`); + return false; + }; + let real; + try { + real = realpathSync(absPath); + } catch { + // ENOENT covers both "not created yet" and "dangling symlink" — + // lstat tells them apart. + if (isDanglingSymlink(absPath)) { + return refuse("is a dangling symlink; refusing to write through it"); + } + real = realPathOfCreation(absPath); + } + const root = realPathOfCreation(projectRoot); + const source = realSourcePath(projectRoot); + if (source !== null && real === source) { + return refuse(`is the source of truth (${MCP_SOURCE}); refusing to write`); + } + const rel = relative(root, real); + if (rel.startsWith("..") || isAbsolute(rel)) { + return refuse("resolves outside the project (symlink redirect); refusing to write"); + } + return true; +} + +function isDanglingSymlink(p) { + try { + return lstatSync(p).isSymbolicLink(); + } catch { + return false; // nothing there at all + } +} + +// realpath of the path, or of its nearest existing ancestor with the +// not-yet-created tail appended — a file about to be CREATED is judged +// by where mkdir/write would land (a symlinked parent directory still +// resolves; only an unresolvable gap falls back to the lexical path). +function realPathOfCreation(absPath) { + const tail = []; + let current = absPath; + for (;;) { + try { + return join(realpathSync(current), ...tail.reverse()); + } catch { + const parent = dirname(current); + if (parent === current) return absPath; // filesystem root: give up, lexical + tail.push(basename(current)); + current = parent; + } + } +} + +// The real .mcp.json, or null when it does not exist — an absent source +// cannot be equal to anything, so the guard skips that comparison. +function realSourcePath(projectRoot) { + try { + return realpathSync(join(projectRoot, MCP_SOURCE)); + } catch { + return null; + } +} + +// Create the target's directory and write the content, converting I/O +// failures into a target-level error — never a crash that would abort +// every remaining named agent (PDR-139's report contract). On failure +// nothing was written, so no per-server outcome is claimed either: the +// report degrades to the documented errors[]-only refusal shape. +function writeTargetFile(absPath, content, report) { + try { + mkdirSync(dirname(absPath), { recursive: true }); + writeFileSync(absPath, content, "utf-8"); + } catch (e) { + report.errors.push(`${report.target}: write failed (${e.code ?? e.message}); file left untouched`); + report.written.length = 0; + } +} + +function sameBlock(a, b) { + return String(a).trim() === String(b).trim(); +} + +// ── TOML value rendering ────────────────────────────────────────────── +function tomlKey(k) { + return /^[A-Za-z0-9_-]+$/.test(k) ? k : tomlString(k); +} +// TOML basic strings escape backslash and quote, and every control +// character must be escaped too (\n \r \t by name, the rest \uXXXX): a +// raw control character makes the WHOLE file unparseable — bricking the +// agent's config while the run reports success. describeRefusal +// deliberately allows such values (multiline PEMs, JSON blobs in env), +// so this renderer is the only place the written file can be kept valid. +function tomlString(s) { + let out = ""; + for (const ch of String(s)) { + const code = ch.codePointAt(0); + if (ch === "\\") out += "\\\\"; + else if (ch === '"') out += '\\"'; + else if (ch === "\n") out += "\\n"; + else if (ch === "\r") out += "\\r"; + else if (ch === "\t") out += "\\t"; + else if (code <= 0x1f || code === 0x7f) out += "\\u" + code.toString(16).padStart(4, "0"); + else out += ch; + } + return `"${out}"`; +} +function tomlValue(v) { + if (typeof v === "string") return tomlString(v); + if (typeof v === "boolean") return v ? "true" : "false"; + if (typeof v === "number") return String(v); + if (Array.isArray(v)) return `[${v.map(tomlValue).join(", ")}]`; + if (isPlainObject(v)) { + const inner = Object.entries(v).map(([k, val]) => `${tomlKey(k)} = ${tomlValue(val)}`).join(", "); + return `{ ${inner} }`; + } + return tomlString(String(v)); +} +function unquoteKey(k) { + if (k.startsWith('"') && k.endsWith('"')) { + return k.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + } + return k; +} + +// ── Small object helpers (own-key safe) ─────────────────────────────── +function isPlainObject(v) { + return v !== null && typeof v === "object" && !Array.isArray(v); +} +function hasOwn(obj, key) { + return Object.hasOwn(obj, key); +} +function setOwn(obj, key, val) { + // defineProperty sets an own key even for "__proto__" without triggering + // the prototype setter. + Object.defineProperty(obj, key, { value: val, enumerable: true, writable: true, configurable: true }); +} + +// Order-insensitive structural equality (compares keys/values, not order). +function deepEqual(a, b) { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((v, i) => deepEqual(v, b[i])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const ka = Object.keys(a); + const kb = Object.keys(b); + return ka.length === kb.length && ka.every((k) => hasOwn(b, k) && deepEqual(a[k], b[k])); + } + return false; +} diff --git a/src/mcp/translate.mjs b/src/mcp/translate.mjs new file mode 100644 index 0000000..c6795cb --- /dev/null +++ b/src/mcp/translate.mjs @@ -0,0 +1,383 @@ +// Pure translation from a .mcp.json server spec to a target agent's MCP +// entry shape. No I/O, and ${VAR} is never resolved from the machine's +// environment — variables are rewritten into the target's syntax or the +// entry is refused when the target cannot express it. +// +// ── Two independent axes ────────────────────────────────────────────── +// Variable substitution is driven by row.var_syntax (what the target +// understands); entry shape is driven by row.format (how the target writes +// entries). The registry pairs them today, but nothing here assumes a +// pair: a codex-shaped row that understands {env:NAME} renders it, an +// opencode-shaped row with no substitution refuses what it cannot inherit. +// +// ── VarSyntax rendering (the label is not the contract; this is) ────── +// "passthrough" — unchanged (claude-code: ${VAR} and ${VAR:-default} +// stay as written; the standard IS its file) +// "env-braces" — {env:NAME} (opencode) +// "env-prefix" — ${env:NAME} (cursor) +// "env-vars" — no string substitution at all (codex): a whole-value +// ${NAME} in env under its own name becomes an inherited +// name (env_vars = ["NAME"]); a header value may inherit +// under any name, and an Authorization of exactly +// "Bearer ${NAME}" becomes bearer_token_env_var. A +// ${VAR:-default} is expressible wherever the bare +// ${VAR} is — the default is dropped and reported +// (expressibleVars().droppedDefaults; the variable +// simply becomes required). A rename, a composed value, +// or a var anywhere else is inexpressible and refused. +// +// Refusal contract: describeRefusal(spec, row) is the single source of +// truth for "can this entry be fully expressed here"; translateEntry calls +// it first and returns null whenever it reports a reason. An entry is +// never half-written: what cannot be expressed is refused and explained. + +const STDIO_FIELDS = ["command", "args", "env", "type", "disabled"]; +const URL_FIELDS = ["type", "url", "headers", "disabled"]; +const SPEC_FIELDS = ["command", "args", "env", "type", "url", "headers", "disabled"]; + +// Entry shapes, keyed on row.format. `inherit` names the fields that carry +// inherited variable names under the "env-vars" syntax — the syntax +// decides that a name is inherited, the shape decides where it goes. +const SHAPES = { + claude: { build: buildClaude, inherit: null }, + opencode: { build: buildOpenCode, inherit: null }, + cursor: { build: buildCursor, inherit: null }, + codex: { + build: buildCodex, + inherit: { env: "env_vars", headers: "env_http_headers", bearer: "bearer_token_env_var" }, + }, +}; + +function varPattern() { + return /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g; +} + +// Every ${VAR} / ${VAR:-default} reference in one string, in order. +function findVars(value) { + const out = []; + for (const m of value.matchAll(varPattern())) { + out.push({ name: m[1], hadDefault: m[2] !== undefined, ref: m[0] }); + } + return out; +} + +// The whole value is exactly one ${VAR} / ${VAR:-default}, or null. +function wholeVar(value) { + const m = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}$/.exec(value); + return m ? { name: m[1], hadDefault: m[2] !== undefined, ref: m[0] } : null; +} + +// The whole value is exactly "Bearer ${VAR}" / "Bearer ${VAR:-default}", +// or null. A default is dropped and reported — the variable becomes +// required, exactly as for env values. +function bearerVar(value) { + const m = /^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}$/.exec(value); + return m ? { name: m[1], hadDefault: m[2] !== undefined } : null; +} + +// Rewrites ${VAR} and ${VAR:-default} in one string, per the row's +// variable syntax. Internal: the builders below are its only callers. +// "passthrough" and "env-vars" rewrite nothing — passthrough because the +// target substitutes itself, env-vars because describeRefusal has already +// routed or refused every reference before rendering. +function translateValue(value, varSyntax) { + if (varSyntax !== "env-braces" && varSyntax !== "env-prefix") return value; + const open = varSyntax === "env-braces" ? "{env:" : "${env:"; + return value.replace(varPattern(), (_m, name) => `${open}${name}}`); +} + +function mapValues(map, varSyntax) { + const out = {}; + for (const [key, value] of Object.entries(map)) { + out[key] = translateValue(value, varSyntax); + } + return out; +} + +function mapStrings(value, varSyntax) { + if (typeof value === "string") return translateValue(value, varSyntax); + if (Array.isArray(value)) return value.map((v) => mapStrings(v, varSyntax)); + if (value && typeof value === "object") { + const out = {}; + for (const [key, v] of Object.entries(value)) out[key] = mapStrings(v, varSyntax); + return out; + } + return value; +} + +function hasCommand(spec) { + return typeof spec.command === "string" && spec.command !== ""; +} + +function hasUrl(spec) { + return typeof spec.url === "string" && spec.url !== ""; +} + +/** + * Human explanation for a spec the target cannot fully express, or null + * when the spec is expressible. The reason names the first inexpressible + * construct — the same one translateEntry refuses on. + */ +export function describeRefusal(spec, row) { + if (!spec || typeof spec !== "object" || Array.isArray(spec)) { + return "entry is not a JSON object"; + } + if (!row || !Object.hasOwn(SHAPES, row.format)) { + return `unknown target format "${row && row.format}"`; + } + if (!hasCommand(spec) && !hasUrl(spec)) { + return "entry has neither command nor url"; + } + + // disabled is expressible only where the target has an off switch. + // claude-code has none either: `disabled` has no effect in .mcp.json and + // deleting the entry is the only real off switch (registry, settled + // decision 3) — refused exactly as for cursor, and the engine omits it. + if (spec.disabled && !row.has_off_switch) { + return `disabled is not expressible in ${row.target_files[0]}: deleting the entry is the only real off switch`; + } + + // The "env-vars" syntax substitutes nothing: every reference must land + // in an inherited-name channel of the target's shape or be refused. + // Keyed on the variable syntax — this runs for every format. + if (row.var_syntax === "env-vars") { + const strings = [["command", spec.command], ["url", spec.url]]; + if (Array.isArray(spec.args)) { + for (const arg of spec.args) strings.push(["args", arg]); + } + for (const [field, value] of strings) { + if (typeof value !== "string") continue; + const found = findVars(value); + if (found.length > 0) { + return `${found[0].ref} in ${field} cannot be expressed: ${row.format} does no variable substitution`; + } + } + if (spec.env != null) { + for (const [key, value] of Object.entries(spec.env)) { + if (typeof value !== "string") continue; + const vars = findVars(value); + if (vars.length === 0) continue; + const whole = wholeVar(value); + if (!whole) { + return `${key} in env is not one whole \${VAR}: ${row.format} inherits whole variable names only`; + } + // Inherited names keep their key: env_vars is a name list, so a + // rename would silently deliver the wrong variable. A default is + // NOT a blocker — it is dropped and reported (expressibleVars); + // the variable simply becomes required. + if (whole.name !== key) { + return `${key} in env renames ${whole.ref}: ${row.format} inherits names unchanged and would set ${whole.name}, not ${key}`; + } + if (!SHAPES[row.format].inherit?.env) { + return `${key} in env cannot be inherited: ${row.format} has no inherited-name field`; + } + } + } + if (spec.headers != null) { + for (const [name, value] of Object.entries(spec.headers)) { + if (typeof value !== "string") continue; + const vars = findVars(value); + if (vars.length === 0) continue; + const whole = wholeVar(value); + if (whole) { + // Header maps name -> variable, so any name may be inherited. A + // default is dropped and reported, never a blocker. + if (!SHAPES[row.format].inherit?.headers) { + return `${name} in headers cannot be inherited: ${row.format} has no inherited-header field`; + } + continue; + } + const bearer = bearerVar(value); + if (bearer && name.toLowerCase() === "authorization") { + if (!SHAPES[row.format].inherit?.bearer) { + return `${name} in headers cannot be inherited: ${row.format} has no bearer-token field`; + } + continue; + } + return `${name} in headers is not one whole \${VAR}: ${row.format} inherits whole header values only`; + } + } + } + + // claude-code is a no-op: the standard IS its file, so whatever it + // carries is already in the target's own vocabulary. + if (row.format === "claude") return null; + + const isStdio = hasCommand(spec); + if (isStdio && hasUrl(spec)) { + return "entry has both command and url"; + } + if (spec.args !== undefined + && (!Array.isArray(spec.args) || spec.args.some((a) => typeof a !== "string"))) { + return "args must be an array of strings"; + } + if (spec.env != null + && (typeof spec.env !== "object" || Array.isArray(spec.env) + || Object.values(spec.env).some((v) => typeof v !== "string"))) { + return "env must be a map of string values"; + } + if (spec.headers != null + && (typeof spec.headers !== "object" || Array.isArray(spec.headers) + || Object.values(spec.headers).some((v) => typeof v !== "string"))) { + return "headers must be a map of string values"; + } + + if (isStdio) { + if (spec.type !== undefined && spec.type !== "stdio") { + return `type "${spec.type}" conflicts with command`; + } + } else { + if (spec.type === undefined) { + return `url entries need type "http" or "sse"`; + } + if (spec.type !== "http" && spec.type !== "sse") { + return `type "${spec.type}" conflicts with url`; + } + // Codex's remote transport is StreamableHttp (its transports are + // Stdio | StreamableHttp) and OpenCode's remote entry is streamable + // HTTP the same way (its config schema knows local | remote only — no + // SSE transport exists): an SSE server would connect with the wrong + // protocol and fail at agent runtime AFTER this tool reported success + // — the silent-success class PDR-138 exists to eliminate. Refused, + // not half-written. Cursor is the exception: it speaks SSE natively + // and keeps type:"sse". + if (spec.type === "sse" && (row.format === "codex" || row.format === "opencode")) { + const remote = row.format === "codex" ? "StreamableHttp" : "streamable HTTP"; + return `sse is not expressible in ${row.format} entries: its remote transport is ${remote}, not SSE`; + } + } + + const allowed = isStdio ? STDIO_FIELDS : URL_FIELDS; + for (const key of Object.keys(spec)) { + if (allowed.includes(key)) continue; + if (SPEC_FIELDS.includes(key)) { + return `field "${key}" does not belong in a ${isStdio ? "command-based" : "url-based"} entry for ${row.format}`; + } + return `field "${key}" has no equivalent in ${row.format} entries`; + } + + return null; +} + +/** + * The target-shaped entry for a .mcp.json server spec, or null when the + * entry cannot be fully expressed in the target (see describeRefusal). + */ +export function translateEntry(spec, row) { + if (describeRefusal(spec, row) !== null) return null; + return SHAPES[row.format].build(spec, row); +} + +// claude-code: the standard IS its file — the shape is the spec itself, +// with strings rendered in the row's variable syntax (identity for the +// real row's "passthrough"). +function buildClaude(spec, row) { + return mapStrings(spec, row.var_syntax); +} + +// opencode: command+args become one array, env becomes environment. +function buildOpenCode(spec, row) { + const v = row.var_syntax; + const enabled = !spec.disabled; + if (!hasCommand(spec)) { + const entry = { type: "remote", enabled, url: translateValue(spec.url, v) }; + if (spec.headers != null) entry.headers = mapValues(spec.headers, v); + return entry; + } + const entry = { + type: "local", + enabled, + command: [spec.command, ...(spec.args ?? [])].map((s) => translateValue(s, v)), + }; + if (spec.env != null) entry.environment = mapValues(spec.env, v); + return entry; +} + +// cursor: command and args stay separate, stdio entries get type:"stdio". +function buildCursor(spec, row) { + const v = row.var_syntax; + if (!hasCommand(spec)) { + const entry = { type: spec.type, url: translateValue(spec.url, v) }; + if (spec.headers != null) entry.headers = mapValues(spec.headers, v); + return entry; + } + const entry = { type: "stdio", command: translateValue(spec.command, v) }; + if (spec.args !== undefined) entry.args = spec.args.map((a) => translateValue(a, v)); + if (spec.env != null) entry.env = mapValues(spec.env, v); + return entry; +} + +// codex: shape fields per the design's mapping table. Under "env-vars" +// nothing is substituted — whole-value vars become inherited names in the +// shape's inherit fields (env_vars, env_http_headers, +// bearer_token_env_var); describeRefusal has already refused everything +// else. Under any other syntax values are rendered like everywhere else. +function buildCodex(spec, row) { + const v = row.var_syntax; + const enabled = !spec.disabled; + if (!hasCommand(spec)) { + const entry = { url: translateValue(spec.url, v), enabled }; + if (spec.headers != null) { + for (const [name, value] of Object.entries(spec.headers)) { + const whole = v === "env-vars" ? wholeVar(value) : null; + const bearer = v === "env-vars" ? bearerVar(value) : null; + if (whole) (entry.env_http_headers ??= {})[name] = whole.name; + else if (bearer && name.toLowerCase() === "authorization") entry.bearer_token_env_var = bearer.name; + else (entry.http_headers ??= {})[name] = translateValue(value, v); + } + } + return entry; + } + const entry = { command: translateValue(spec.command, v), enabled }; + if (spec.args !== undefined) entry.args = spec.args.map((a) => translateValue(a, v)); + if (spec.env != null) { + for (const [key, value] of Object.entries(spec.env)) { + const whole = v === "env-vars" ? wholeVar(value) : null; + if (whole) (entry.env_vars ??= []).push(whole.name); + else (entry.env ??= {})[key] = translateValue(value, v); + } + } + return entry; +} + +/** + * The format-agnostic field map that disables a server in this target, + * or null when the target has no off switch. The caller serialises it per + * target (JSON object or TOML fragment). + */ +export function offSwitchEntry(row) { + return row.has_off_switch ? { enabled: false } : null; +} + +/** + * The variables a spec references — the "set these env vars" report. + * Names appear in first-reference order across command, args, url and + * env values. droppedDefaults lists those whose ${VAR:-default} fallback + * cannot survive translation into targets without default support, so + * they must be set explicitly. + */ +export function expressibleVars(spec) { + const needed = []; + const droppedDefaults = []; + const seen = new Set(); + const scan = (value) => { + if (typeof value !== "string") return; + for (const v of findVars(value)) { + if (!seen.has(v.name)) { + seen.add(v.name); + needed.push(v.name); + } + if (v.hadDefault && !droppedDefaults.includes(v.name)) { + droppedDefaults.push(v.name); + } + } + }; + if (spec && typeof spec === "object") { + for (const value of Object.values(spec)) { + if (Array.isArray(value)) value.forEach(scan); + else if (value && typeof value === "object") Object.values(value).forEach(scan); + else scan(value); + } + } + return { needed, droppedDefaults }; +} diff --git a/src/registry.mjs b/src/registry.mjs index 9430624..d59b9bb 100644 --- a/src/registry.mjs +++ b/src/registry.mjs @@ -690,6 +690,64 @@ export const EVENT_AGENTS = { }, }; +// ── MCP install ──────────────────────────────────────────────────────── +// Per-agent metadata for `adlc-cli mcp install`: .mcp.json is the single +// standard declaration of MCP servers; each row describes how to materialise +// those servers in the agent's own config shape. +// target_files — candidate config paths, project-relative; first existing +// wins (resolution lives in the install engine). +// section — the key/section holding the server map in that file. +// format — the entry shape to write: how the server block is +// written for that target. +// var_syntax — the string rewriting for ${VAR}: how ${VAR} is rendered. +// Independent of format; the two can vary separately: +// "passthrough" — unchanged (claude-code IS the standard) +// "env-braces" — {env:NAME} (opencode) +// "env-vars" — env_vars = ["NAME"], inherited from the +// parent process; no string substitution +// (codex) +// "env-prefix" — ${env:NAME} (cursor) +// has_off_switch — whether the target has a field that disables a server +// (codex/opencode: enabled = false). claude-code has none: +// `disabled` has no effect in .mcp.json and deleting the +// entry is the only real off switch (design spec, settled +// decision 3). +export const MCP_AGENTS = { + "claude-code": { + target_files: [".mcp.json"], + section: "mcpServers", + format: "claude", + var_syntax: "passthrough", + has_off_switch: false, + }, + opencode: { + target_files: ["opencode.json", ".opencode/opencode.json"], + section: "mcp", + format: "opencode", + var_syntax: "env-braces", + has_off_switch: true, + }, + codex: { + target_files: [".codex/config.toml"], + section: "mcp_servers", + format: "codex", + var_syntax: "env-vars", + has_off_switch: true, + }, + cursor: { + target_files: [".cursor/mcp.json"], + section: "mcpServers", + format: "cursor", + var_syntax: "env-prefix", + has_off_switch: false, + }, +}; + +export function getMcpAgent(key) { + const normalized = key === "claude" ? "claude-code" : key; + return Object.hasOwn(MCP_AGENTS, normalized) ? MCP_AGENTS[normalized] : null; +} + export function getEventAgentConfig(agentKey) { return EVENT_AGENTS[agentKey] || null; } diff --git a/tests/mcp-command.test.mjs b/tests/mcp-command.test.mjs new file mode 100644 index 0000000..602ebf1 --- /dev/null +++ b/tests/mcp-command.test.mjs @@ -0,0 +1,369 @@ +// tests/mcp-command.test.mjs — `adlc-cli mcp install`: the command, the +// precondition, and the exit contract. +// +// The command is the promise surface (PDR-138/139): exit 0 only when every +// NAMED agent's config is delivered; exit 1 on any failure — never 3, which +// src/commands/workflow.mjs reserves for PAUSED. The source `.mcp.json` is +// read-only here: nothing in this file may ever rewrite it. + +import { it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { cmdMcpInstall } from "../src/commands/mcp.mjs"; + +const exec = promisify(execFile); +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const BIN = join(REPO_ROOT, "bin", "adlc-cli.mjs"); + +// Minimal project sandbox (same shape as tests/mcp-install.test.mjs). +function project() { + const dir = mkdtempSync(join(tmpdir(), "adlc-mcp-command-")); + return { + dir, + path: (rel) => join(dir, rel), + write(rel, content) { + const abs = join(dir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content, "utf-8"); + }, + read(rel) { return readFileSync(join(dir, rel), "utf-8"); }, + exists(rel) { return existsSync(join(dir, rel)); }, + cleanup() { rmSync(dir, { recursive: true, force: true }); }, + }; +} + +// Capture the ui writers (process.stdout/stderr) while the command runs — +// the assertions are on what a user sees and on which stream sees it. +async function capture(fn) { + const outChunks = []; + const errChunks = []; + const origOut = process.stdout.write.bind(process.stdout); + const origErr = process.stderr.write.bind(process.stderr); + process.stdout.write = (chunk) => { outChunks.push(String(chunk)); return true; }; + process.stderr.write = (chunk) => { errChunks.push(String(chunk)); return true; }; + try { + const code = await fn(); + return { code, stdout: outChunks.join(""), stderr: errChunks.join("") }; + } finally { + process.stdout.write = origOut; + process.stderr.write = origErr; + } +} + +const CONTEXT7 = { + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + env: { TOKEN: "${GITLAB_TOKEN}" }, +}; +const GITLAB = { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-gitlab"], + env: { GITLAB_TOKEN: "${GITLAB_TOKEN}" }, +}; +const withServers = (servers) => JSON.stringify({ mcpServers: servers }, null, 2); + +// ── The brief's contract tests (titles verbatim) ───────────────────────── + +it("refuses loudly when team-setup has not run: no init-options and no --agent", async () => { + const p = project(); + try { + p.write(".mcp.json", withServers({ context7: CONTEXT7 })); + const { code, stdout, stderr } = await capture(() => cmdMcpInstall([p.dir], {})); + assert.equal(code, 1); + assert.match(stderr, /team-setup has not run/); + assert.match(stderr, /init-options\.json/); + assert.match(stderr, /--agent/); + assert.equal(stdout, ""); + } finally { + p.cleanup(); + } +}); + +it("reports 'nothing to install' and exits 0 when .mcp.json is absent", async () => { + const p = project(); + try { + const { code, stdout, stderr } = await capture(() => cmdMcpInstall([p.dir], { agents: ["opencode"] })); + assert.equal(code, 0); + assert.match(stdout, /nothing to install/); + // gap report warnings live on stderr now (no team source configured here) + assert.match(stderr, /team MCP source unavailable/); + } finally { + p.cleanup(); + } +}); + +it("a named agent with no adapter fails and lists the servers by hand", async () => { + const p = project(); + try { + p.write(".mcp.json", withServers({ context7: CONTEXT7, gitlab: GITLAB })); + const { code, stderr } = await capture(() => cmdMcpInstall([p.dir], { agents: ["gemini"] })); + assert.equal(code, 1); // not 3 -- 3 is PAUSED + assert.match(stderr, /add these servers by hand/); + assert.match(stderr, /gemini/); + assert.match(stderr, /target file/); + assert.match(stderr, /context7/); + assert.match(stderr, /gitlab/); + } finally { + p.cleanup(); + } +}); + +it("exits 0 when every named agent is configured", async () => { + const p = project(); + try { + const source = withServers({ context7: CONTEXT7 }); + p.write(".mcp.json", source); + const { code, stdout, stderr } = await capture(() => + cmdMcpInstall([p.dir], { agents: ["opencode", "cursor"] })); + assert.equal(code, 0); + // gap report warnings live on stderr now (no team source configured here) + assert.match(stderr, /team MCP source unavailable/); + assert.ok(!stdout.includes("MCP gap")); + + const oc = JSON.parse(p.read("opencode.json")); + assert.deepEqual(oc.mcp.context7, { + type: "local", + enabled: true, + command: ["npx", "-y", "@upstash/context7-mcp"], + environment: { TOKEN: "{env:GITLAB_TOKEN}" }, + }); + const cur = JSON.parse(p.read(".cursor/mcp.json")); + assert.deepEqual(cur.mcpServers.context7, { + type: "stdio", + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + env: { TOKEN: "${env:GITLAB_TOKEN}" }, + }); + + assert.equal(p.read(".mcp.json"), source); // the source is never rewritten + assert.match(stdout, /opencode\.json/); + assert.match(stdout, /\.cursor\/mcp\.json/); + assert.equal(p.exists(".codex"), false); // agents never named are out of scope + } finally { + p.cleanup(); + } +}); + +it("exits 1 when one of several named agents cannot be delivered", async () => { + const p = project(); + try { + p.write(".mcp.json", withServers({ context7: CONTEXT7, gitlab: GITLAB })); + const { code, stderr } = await capture(() => + cmdMcpInstall([p.dir], { agents: ["opencode", "gemini"] })); + assert.equal(code, 1); + assert.ok(p.exists("opencode.json")); // what can be delivered still is + assert.match(stderr, /add these servers by hand/); + assert.match(stderr, /gemini/); + } finally { + p.cleanup(); + } +}); + +// ── The rulings that settle the brief's ambiguities ───────────────────── + +it("a refused server fails the run and is named for hand-install", async () => { + // Ruling 1: errors OR a per-server refusal fail the run — a named agent + // would start without that server. A ${VAR} inside a codex url is the + // plan's headline refusal (codex does no substitution). The delivered + // server carries no env: codex inherits whole names only, and a renamed + // one is a refusal of its own (Task 2). + const p = project(); + try { + p.write(".mcp.json", withServers({ + docs: { type: "http", url: "https://docs.example/${TENANT}/mcp" }, + plain: { command: "npx", args: ["-y", "@upstash/context7-mcp"] }, + })); + const { code, stderr } = await capture(() => cmdMcpInstall([p.dir], { agents: ["codex"] })); + assert.equal(code, 1); + assert.match(stderr, /add these servers by hand/); + assert.match(stderr, /\.codex\/config\.toml/); // the target file is named + assert.match(stderr, /docs/); + assert.match(stderr, /refused/); + assert.doesNotMatch(stderr, /plain/); // delivered servers are not listed by hand + } finally { + p.cleanup(); + } +}); + +it("a target-level error fails the run and leaves the file untouched", async () => { + const p = project(); + try { + p.write(".mcp.json", withServers({ context7: CONTEXT7 })); + const broken = '{\n // not plain JSON\n}\n'; + p.write("opencode.json", broken); + const { code, stderr } = await capture(() => cmdMcpInstall([p.dir], { agents: ["opencode"] })); + assert.equal(code, 1); + assert.match(stderr, /not plain JSON/); + assert.match(stderr, /add these servers by hand/); + assert.match(stderr, /opencode\.json/); // the target file is named + assert.equal(p.read("opencode.json"), broken); // left untouched + } finally { + p.cleanup(); + } +}); + +it("claude-code is a no-op success and .mcp.json is never rewritten", async () => { + const p = project(); + try { + const source = withServers({ context7: CONTEXT7 }); + p.write(".mcp.json", source); + const { code, stdout, stderr } = await capture(() => cmdMcpInstall([p.dir], { agents: ["claude"] })); + assert.equal(code, 0); + // gap report warnings live on stderr now (no team source configured here) + assert.match(stderr, /team MCP source unavailable/); + assert.equal(p.read(".mcp.json"), source); + assert.match(stdout, /kept/); + } finally { + p.cleanup(); + } +}); + +it("falls back to the agent recorded by team-setup when no --agent is given", async () => { + const p = project(); + try { + p.write(".adlc/init-options.json", JSON.stringify({ agent: "opencode" })); + p.write(".mcp.json", withServers({ context7: CONTEXT7 })); + const { code } = await capture(() => cmdMcpInstall([p.dir], {})); + assert.equal(code, 0); + assert.ok(p.exists("opencode.json")); + } finally { + p.cleanup(); + } +}); + +it("--agent wins over the agent recorded in init-options", async () => { + const p = project(); + try { + p.write(".adlc/init-options.json", JSON.stringify({ agent: "cursor" })); + p.write(".mcp.json", withServers({ context7: CONTEXT7 })); + const { code } = await capture(() => cmdMcpInstall([p.dir], { agents: ["opencode"] })); + assert.equal(code, 0); + assert.ok(p.exists("opencode.json")); + assert.equal(p.exists(".cursor/mcp.json"), false); + } finally { + p.cleanup(); + } +}); + +it("fails the precondition when init-options records no agent and no --agent is given", async () => { + const p = project(); + try { + p.write(".adlc/init-options.json", JSON.stringify({ skills_source: "tikalk/x" })); + p.write(".mcp.json", withServers({ context7: CONTEXT7 })); + const { code, stderr } = await capture(() => cmdMcpInstall([p.dir], {})); + assert.equal(code, 1); + assert.match(stderr, /init-options\.json/); + assert.match(stderr, /--agent/); + } finally { + p.cleanup(); + } +}); + +it("the precondition comes first: no .mcp.json and no agent is a loud failure, not a silent no-op", async () => { + // Rulings 3 and 4 collide when BOTH the source and the agent are absent. + // Resolved: the precondition (who to configure) gates the source read — + // "never silently doing nothing" (PDR-139). Ruling 4's no-op applies once + // an agent is named. + const p = project(); + try { + const { code, stderr } = await capture(() => cmdMcpInstall([p.dir], {})); + assert.equal(code, 1); + assert.match(stderr, /team-setup has not run/); + } finally { + p.cleanup(); + } +}); + +it("a .mcp.json that declares no servers reports 'nothing to install' and exits 0", async () => { + // Lenient source validation (design, settled decision 4): unknown + // top-level keys — the team file ships a "tools" block — are never an + // error; only `mcpServers` is translated. + const p = project(); + try { + p.write(".mcp.json", JSON.stringify({ tools: { github: { type: "github" } } }, null, 2)); + const { code, stdout, stderr } = await capture(() => cmdMcpInstall([p.dir], { agents: ["opencode"] })); + assert.equal(code, 0); + assert.match(stdout, /nothing to install/); + // gap report warnings live on stderr now (no team source configured here) + assert.match(stderr, /team MCP source unavailable/); + } finally { + p.cleanup(); + } +}); + +it("an unparseable .mcp.json fails the run instead of installing nothing", async () => { + const p = project(); + try { + p.write(".mcp.json", "{ // not plain JSON\n"); + const { code, stderr } = await capture(() => cmdMcpInstall([p.dir], { agents: ["opencode"] })); + assert.equal(code, 1); + assert.match(stderr, /not plain JSON/); + } finally { + p.cleanup(); + } +}); + +// ── Dispatch wiring (the CLI surface, not just the function) ──────────── + +it("dispatch: adlc-cli mcp install routes to the command and exits with its code", async () => { + const p = project(); + try { + p.write(".mcp.json", withServers({ context7: CONTEXT7 })); + const r = await exec(process.execPath, [BIN, "mcp", "install", p.dir, "-a", "opencode"]).catch((e) => e); + assert.equal(r.code ?? r.exitCode ?? 0, 0); + assert.ok(p.exists("opencode.json")); + } finally { + p.cleanup(); + } +}); + +it("dispatch: --agents names agents exactly like --agent", async () => { + const p = project(); + try { + p.write(".mcp.json", withServers({ context7: CONTEXT7 })); + const r = await exec(process.execPath, [BIN, "mcp", "install", p.dir, "--agents", "cursor"]).catch((e) => e); + assert.equal(r.code ?? r.exitCode ?? 0, 0); + assert.ok(p.exists(".cursor/mcp.json")); + } finally { + p.cleanup(); + } +}); + +it("dispatch: DIR defaults to the current working directory", async () => { + const p = project(); + try { + p.write(".mcp.json", withServers({ context7: CONTEXT7 })); + const r = await exec(process.execPath, [BIN, "mcp", "install", "-a", "opencode"], { cwd: p.dir }).catch((e) => e); + assert.equal(r.code ?? r.exitCode ?? 0, 0); + assert.ok(p.exists("opencode.json")); + } finally { + p.cleanup(); + } +}); + +it("dispatch: the precondition failure surfaces as exit 1, never 3", async () => { + const p = project(); + try { + p.write(".mcp.json", withServers({ context7: CONTEXT7 })); + const r = await exec(process.execPath, [BIN, "mcp", "install", p.dir]).catch((e) => e); + assert.equal(r.code ?? r.exitCode, 1); + assert.match(String(r.stderr), /team-setup has not run/); + } finally { + p.cleanup(); + } +}); + +it("dispatch: mcp help prints usage and an unknown subcommand exits 1", async () => { + const help = await exec(process.execPath, [BIN, "mcp", "help"]).catch((e) => e); + assert.equal(help.code ?? help.exitCode ?? 0, 0); + assert.match(String(help.stdout), /mcp install/); + + const bogus = await exec(process.execPath, [BIN, "mcp", "bogus"]).catch((e) => e); + assert.equal(bogus.code ?? bogus.exitCode, 1); + assert.match(String(bogus.stderr ?? bogus.stdout ?? bogus.message), /Unknown mcp command/i); +}); diff --git a/tests/mcp-gap.test.mjs b/tests/mcp-gap.test.mjs new file mode 100644 index 0000000..481288f --- /dev/null +++ b/tests/mcp-gap.test.mjs @@ -0,0 +1,270 @@ +// tests/mcp-gap.test.mjs — the copy-once gap report (PDR-140). +// +// The team's .mcp.json is the upstream declaration; the project's is the +// copy made once. Drift between them is REPORTED — never written, never +// blocking, never a merge. A missing team source is itself reported: a +// report that quietly reports nothing is worse than no report. +// +// The no-write property of the whole command is pinned by Task 3's tests; +// here the assertions are on the report's return value and on what the +// command prints (ruling 1: no invented projectFileUnchanged helper — the +// two byte-compare assertions below read the files directly). + +import { it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { reportGap, droppedDefaults } from "../src/mcp/gap.mjs"; +import { cmdMcpInstall } from "../src/commands/mcp.mjs"; + +// Minimal project sandbox (same shape as tests/mcp-command.test.mjs). +function sandbox() { + const dir = mkdtempSync(join(tmpdir(), "adlc-mcp-gap-")); + return { + dir, + path: (rel) => join(dir, rel), + write(rel, content) { + const abs = join(dir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content, "utf-8"); + }, + read(rel) { return readFileSync(join(dir, rel), "utf-8"); }, + exists(rel) { return existsSync(join(dir, rel)); }, + cleanup() { rmSync(dir, { recursive: true, force: true }); }, + }; +} + +// A sandbox wired like a real project: team-ai-directives cloned at +// ./team (its .mcp.json is the upstream declaration — including the +// top-level "tools" block the real file ships) and a project .mcp.json +// (the copy). Overrides let each test shape the drift. +const TEAM_SERVERS = { + context7: { command: "npx", args: ["-y", "@upstash/context7-mcp"] }, + "new-team-server": { command: "npx", args: ["-y", "@example/new-server"] }, +}; +const TOOLS_BLOCK = { github: { type: "github", tokenEnv: "GITHUB_TOKEN" } }; + +function wired({ teamDoc, projectServers } = {}) { + const p = sandbox(); + p.write(".adlc/init-options.json", JSON.stringify({ + agent: "opencode", + team_ai_directives: "./team", + })); + p.write("team/.mcp.json", JSON.stringify(teamDoc ?? { mcpServers: TEAM_SERVERS, tools: TOOLS_BLOCK })); + p.write(".mcp.json", JSON.stringify({ mcpServers: projectServers ?? { context7: TEAM_SERVERS.context7 } })); + return p; +} + +// Capture the ui writers (process.stdout/stderr) while the command runs — +// the assertions are on what a user sees and on which stream sees it. +// `events` keeps one cross-stream timeline so ordering between the install +// report (stdout) and the gap report (stderr) stays assertable. +async function capture(fn) { + const outChunks = []; + const errChunks = []; + const events = []; + const origOut = process.stdout.write.bind(process.stdout); + const origErr = process.stderr.write.bind(process.stderr); + process.stdout.write = (chunk) => { outChunks.push(String(chunk)); events.push(["out", String(chunk)]); return true; }; + process.stderr.write = (chunk) => { errChunks.push(String(chunk)); events.push(["err", String(chunk)]); return true; }; + try { + const code = await fn(); + return { code, stdout: outChunks.join(""), stderr: errChunks.join(""), events }; + } finally { + process.stdout.write = origOut; + process.stderr.write = origErr; + } +} + +// ── The brief's contract tests ────────────────────────────────────────── + +it("lists team-declared names the project lacks (reported, never written)", () => { + const p = wired(); + try { + const g = reportGap({ projectRoot: p.dir, servers: { context7: TEAM_SERVERS.context7 } }); + assert.deepEqual(g.missing, ["new-team-server"]); + assert.equal(g.teamSourceUnavailable, false); + } finally { + p.cleanup(); + } +}); + +it("reports an unavailable team source instead of degrading to 'no gap'", () => { + const p = sandbox(); + try { + p.write(".mcp.json", JSON.stringify({ mcpServers: { context7: TEAM_SERVERS.context7 } })); + const g = reportGap({ projectRoot: p.dir, servers: { context7: TEAM_SERVERS.context7 } }); + assert.equal(g.teamSourceUnavailable, true); + assert.deepEqual(g.missing, []); + assert.deepEqual(g.notCarried, []); + } finally { + p.cleanup(); + } +}); + +// ── The rulings that settle the brief's ambiguities ───────────────────── + +it("a recorded team source that cannot be found is unavailable", () => { + const p = sandbox(); + try { + p.write(".adlc/init-options.json", JSON.stringify({ team_ai_directives: "./nowhere" })); + const g = reportGap({ projectRoot: p.dir, servers: {} }); + assert.equal(g.teamSourceUnavailable, true); + } finally { + p.cleanup(); + } +}); + +it("an unreadable team .mcp.json is unavailable, not silently empty", () => { + const p = wired(); + try { + p.write("team/.mcp.json", "{ // not plain JSON"); + const g = reportGap({ projectRoot: p.dir, servers: {} }); + assert.equal(g.teamSourceUnavailable, true); + } finally { + p.cleanup(); + } +}); + +it("a team .mcp.json whose mcpServers cannot be read is unavailable", () => { + // A wrong-shaped mcpServers cannot be compared honestly — report the + // source as unavailable rather than guess at its meaning. + const p = wired(); + try { + p.write("team/.mcp.json", JSON.stringify({ mcpServers: "broken" })); + const g = reportGap({ projectRoot: p.dir, servers: {} }); + assert.equal(g.teamSourceUnavailable, true); + } finally { + p.cleanup(); + } +}); + +it("unknown top-level keys in the team's .mcp.json are reported as not carried", () => { + // Ruling 5 / settled decision 4: the team file ships a top-level "tools" + // block. It is never an error and never silently dropped — it is the + // same "upstream declares, downstream doesn't get it" surface. + const p = wired(); + try { + const g = reportGap({ + projectRoot: p.dir, + servers: { context7: TEAM_SERVERS.context7, "new-team-server": TEAM_SERVERS["new-team-server"] }, + }); + assert.deepEqual(g.missing, []); + assert.deepEqual(g.notCarried, ["tools"]); + assert.equal(g.teamSourceUnavailable, false); + } finally { + p.cleanup(); + } +}); + +it("a clean copy reports no gap at all", () => { + const p = wired({ teamDoc: { mcpServers: TEAM_SERVERS }, projectServers: TEAM_SERVERS }); + try { + const g = reportGap({ projectRoot: p.dir, servers: TEAM_SERVERS }); + assert.deepEqual(g, { missing: [], notCarried: [], teamSourceUnavailable: false }); + } finally { + p.cleanup(); + } +}); + +it("droppedDefaults lists per-server defaults lost in translation", () => { + // Ruling 4: ${VAR:-default} has no equivalent in targets without default + // support — the drop must be printed or it stays silent. + const servers = { + withDefault: { command: "npx", env: { TOK: "${TOK:-d}" } }, + plain: { command: "npx" }, + }; + assert.deepEqual(droppedDefaults(servers), [{ server: "withDefault", vars: ["TOK"] }]); + assert.deepEqual(droppedDefaults({}), []); +}); + +// ── The command wiring: after the install report, never blocking ──────── + +it("the gap report prints after the install report and never blocks (exit stays 0)", async () => { + const p = wired({ + projectServers: { + context7: { command: "npx", args: ["-y", "@upstash/context7-mcp"], env: { TOK: "${TOK:-d}" } }, + }, + }); + try { + const before = p.read(".mcp.json"); + const teamBefore = p.read("team/.mcp.json"); + const { code, stdout, stderr, events } = await capture(() => cmdMcpInstall([p.dir], { agents: ["opencode"] })); + assert.equal(code, 0); // the gap never blocks (PDR-140) + assert.ok(p.exists("opencode.json")); // the install itself happened + + // the gap report is a warning on stderr — stdout stays the result channel + assert.ok(!stdout.includes("MCP gap"), "gap report not on stdout"); + + // additive lines AFTER the install report, not instead of it (the + // cross-stream timeline preserves the print order) + const installAt = events.findIndex(([, t]) => t.includes("opencode.json")); + const gapAt = events.findIndex(([, t]) => t.includes("MCP gap")); + assert.ok(installAt !== -1, "install report printed"); + assert.ok(gapAt > installAt, "gap report comes after the install report"); + + assert.match(stderr, /warning only/); // the vocabulary of never-blocking + assert.match(stderr, /new-team-server/); // the missing name is listed + assert.match(stderr, /not carried to any target: tools/); + assert.match(stderr, /defaults dropped in translation to opencode/); + assert.match(stderr, /set explicitly: TOK/); + + assert.equal(p.read(".mcp.json"), before); // the copy is never written + assert.equal(p.read("team/.mcp.json"), teamBefore); // the upstream neither + } finally { + p.cleanup(); + } +}); + +it("the command reports a missing team source instead of staying silent", async () => { + const p = sandbox(); + try { + p.write(".mcp.json", JSON.stringify({ mcpServers: { context7: TEAM_SERVERS.context7 } })); + const { code, stdout, stderr } = await capture(() => cmdMcpInstall([p.dir], { agents: ["opencode"] })); + assert.equal(code, 0); + assert.match(stderr, /team MCP source unavailable/); // a warning on stderr + assert.ok(!stdout.includes("team MCP source unavailable")); + } finally { + p.cleanup(); + } +}); + +it("a codex-only run does not duplicate the dropped-defaults note the install report already carries", async () => { + // Ruling 4, check-first: installForAgent's detail already prints + // "dropped defaults (set explicitly)" for env-vars targets (codex), so + // the gap report must not repeat it there. + const p = sandbox(); + try { + p.write(".mcp.json", JSON.stringify({ + mcpServers: { + context7: { command: "npx", args: ["-y", "@upstash/context7-mcp"], env: { TOK: "${TOK:-d}" } }, + }, + })); + const { code, stdout, stderr } = await capture(() => cmdMcpInstall([p.dir], { agents: ["codex"] })); + assert.equal(code, 0); + assert.match(stdout, /dropped defaults \(set explicitly\): TOK/); // the install detail + assert.ok(stderr.includes("MCP gap")); // the gap section exists (no team source)… + const gapPart = stderr.slice(stderr.indexOf("MCP gap")); + assert.doesNotMatch(gapPart, /dropped/); // …without repeating the note + } finally { + p.cleanup(); + } +}); + +it("a project with no .mcp.json still hears the gap (nothing to install is not nothing to say)", async () => { + // The copy-once contract is drift detection, not an install postscript: + // a project that has not copied yet lacks every team-declared server. + const p = sandbox(); + try { + p.write(".adlc/init-options.json", JSON.stringify({ agent: "opencode", team_ai_directives: "./team" })); + p.write("team/.mcp.json", JSON.stringify({ mcpServers: TEAM_SERVERS, tools: TOOLS_BLOCK })); + const { code, stdout, stderr } = await capture(() => cmdMcpInstall([p.dir], {})); + assert.equal(code, 0); // never blocking + assert.match(stdout, /nothing to install/); // the result channel stays stdout + assert.match(stderr, /new-team-server/); // the warnings moved to stderr + assert.match(stderr, /not carried to any target: tools/); + } finally { + p.cleanup(); + } +}); diff --git a/tests/mcp-install.test.mjs b/tests/mcp-install.test.mjs new file mode 100644 index 0000000..f7ab2b5 --- /dev/null +++ b/tests/mcp-install.test.mjs @@ -0,0 +1,631 @@ +import { it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, symlinkSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { installForAgent } from "../src/mcp/install.mjs"; + +// Minimal project sandbox: a temp dir plus helpers to lay out foreign configs. +function project() { + const dir = mkdtempSync(join(tmpdir(), "adlc-mcp-install-")); + return { + dir, + path: (rel) => join(dir, rel), + write(rel, content) { + const abs = join(dir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content, "utf-8"); + }, + read(rel) { return readFileSync(join(dir, rel), "utf-8"); }, + readBytes(rel) { return readFileSync(join(dir, rel)); }, + exists(rel) { return existsSync(join(dir, rel)); }, + cleanup() { rmSync(dir, { recursive: true, force: true }); }, + }; +} + +const CONTEXT7 = { + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + env: { TOKEN: "${GITLAB_TOKEN}" }, +}; + +// ── The brief's contract tests (values verbatim) ─────────────────────── + +it("adds entries in the target's shape and leaves foreign keys alone", () => { + const p = project(); + try { + p.write("opencode.json", JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: ["p@git+https://x/p.git"], + mcp: { unrelated: { type: "local", command: ["x"] } }, + }, null, 2)); + + const r = installForAgent({ projectRoot: p.dir, agent: "opencode", servers: { context7: CONTEXT7 } }); + + const cfg = JSON.parse(p.read("opencode.json")); + assert.equal(cfg.$schema, "https://opencode.ai/config.json"); + assert.deepEqual(cfg.plugin, ["p@git+https://x/p.git"]); + assert.equal(cfg.mcp.unrelated.type, "local"); // untouched + assert.deepEqual(cfg.mcp.unrelated.command, ["x"]); + assert.equal(cfg.mcp.context7.type, "local"); // translated + assert.deepEqual(cfg.mcp.context7.command, ["npx", "-y", "@upstash/context7-mcp"]); + assert.equal(cfg.mcp.context7.environment.TOKEN, "{env:GITLAB_TOKEN}"); + assert.equal(r.written.find((w) => w.name === "context7").action, "added"); + assert.equal(r.errors.length, 0); + } finally { + p.cleanup(); + } +}); + +it("a name in .mcp.json is tool-owned: a hand-edited body is overwritten and reported", () => { + const p = project(); + try { + p.write("opencode.json", JSON.stringify({ + mcp: { context7: { type: "local", command: ["hand", "edited"], environment: {} } }, + }, null, 2)); + + const r = installForAgent({ projectRoot: p.dir, agent: "opencode", servers: { context7: CONTEXT7 } }); + + assert.equal(r.written.find((w) => w.name === "context7").action, "updated"); + const cfg = JSON.parse(p.read("opencode.json")); + assert.deepEqual(cfg.mcp.context7.command, ["npx", "-y", "@upstash/context7-mcp"]); + } finally { + p.cleanup(); + } +}); + +// ── Fix round 2 — Important 1: no bare off-switch exemption (ADR-455) ── +// A name in .mcp.json is tool-owned and regenerated every run. The user's +// off switch is `disabled: true` in .mcp.json itself; a bare +// {"enabled": } override in the target is regenerated like any other +// owned body and reported `updated` — keeping it would silently trap a +// disabled→re-enabled server in the off state while .mcp.json says on. + +it("re-enabled in .mcp.json: the full entry is regenerated and the server would start (opencode, JSON)", () => { + // The traced scenario, end to end: disabled → run (off-switch written) → + // remove `disabled` from .mcp.json → re-run. The tool-owned name — whose + // current body is exactly the bare off-switch the first run wrote — is + // regenerated, not kept. + const p = project(); + try { + const spec = { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-gitlab"], + env: { GITLAB_TOKEN: "${GITLAB_TOKEN}" }, + }; + p.write("opencode.json", JSON.stringify({ mcp: {} }, null, 2)); + + // disabled: true → the off switch is written: {"enabled": false} + installForAgent({ projectRoot: p.dir, agent: "opencode", servers: { gitlab: { ...spec, disabled: true } } }); + assert.deepEqual(JSON.parse(p.read("opencode.json")).mcp.gitlab, { enabled: false }); + + // the user removes `disabled` from .mcp.json — the source of truth says on + const r = installForAgent({ projectRoot: p.dir, agent: "opencode", servers: { gitlab: spec } }); + + assert.equal(r.written.find((w) => w.name === "gitlab").action, "updated"); + const entry = JSON.parse(p.read("opencode.json")).mcp.gitlab; + assert.equal(entry.enabled, true); // the server would start + assert.deepEqual(entry.command, ["npx", "-y", "@modelcontextprotocol/server-gitlab"]); + assert.deepEqual(entry.environment, { GITLAB_TOKEN: "{env:GITLAB_TOKEN}" }); + } finally { + p.cleanup(); + } +}); + +it("a hand-written bare {\"enabled\": false} override is regenerated, not kept", () => { + const p = project(); + try { + p.write("opencode.json", JSON.stringify({ mcp: { gitlab: { enabled: false } } }, null, 2)); + + const r = installForAgent({ + projectRoot: p.dir, + agent: "opencode", + servers: { + gitlab: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-gitlab"], + env: { GITLAB_TOKEN: "${GITLAB_TOKEN}" }, + }, + }, + }); + + assert.equal(r.written.find((w) => w.name === "gitlab").action, "updated"); + const cfg = JSON.parse(p.read("opencode.json")); + assert.equal(cfg.mcp.gitlab.enabled, true); // .mcp.json wins, not the stale override + assert.deepEqual(cfg.mcp.gitlab.command, ["npx", "-y", "@modelcontextprotocol/server-gitlab"]); + } finally { + p.cleanup(); + } +}); + +it("a refused entry is not written, and names the server and the reason", () => { + const p = project(); + try { + const r = installForAgent({ + projectRoot: p.dir, + agent: "codex", + servers: { docs: { type: "http", url: "https://mcp.example/${TENANT}" } }, + }); + + const w = r.written.find((x) => x.name === "docs"); + assert.equal(w.action, "refused"); + assert.match(w.detail, /url/); + // The codex target is TOML and was never created (ruling 2: never JSON.parse a .toml). + assert.equal(p.exists(".codex/config.toml"), false); + // Refusals live in written[], never in errors[] (ruling 1). + assert.equal(r.errors.length, 0); + } finally { + p.cleanup(); + } +}); + +it("disabled:true writes the off switch where one exists and skips where none does", () => { + // opencode has an off switch -> {"enabled": false}; cursor has none -> omitted. + const po = project(); + try { + po.write("opencode.json", JSON.stringify({ mcp: {} }, null, 2)); + const ro = installForAgent({ + projectRoot: po.dir, + agent: "opencode", + servers: { foo: { command: "x", disabled: true } }, + }); + assert.deepEqual(JSON.parse(po.read("opencode.json")).mcp.foo, { enabled: false }); + const wo = ro.written.find((w) => w.name === "foo"); + assert.ok(wo.action === "added" || wo.action === "updated"); + } finally { + po.cleanup(); + } + + const pc = project(); + try { + pc.write(".cursor/mcp.json", JSON.stringify({ mcpServers: {} }, null, 2)); + const rc = installForAgent({ + projectRoot: pc.dir, + agent: "cursor", + servers: { foo: { command: "x", disabled: true } }, + }); + assert.equal(JSON.parse(pc.read(".cursor/mcp.json")).mcpServers.foo, undefined); + assert.equal(rc.written.find((w) => w.name === "foo").action, "disabled-skipped"); + } finally { + pc.cleanup(); + } +}); + +it("is idempotent: a second run writes byte-identical files and reports kept", () => { + const p = project(); + try { + p.write("opencode.json", JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { unrelated: { type: "local", command: ["x"] } }, + }, null, 2)); + const servers = { + context7: CONTEXT7, + gitlab: { command: "y", disabled: true }, + }; + + installForAgent({ projectRoot: p.dir, agent: "opencode", servers }); // first run + const first = p.readBytes("opencode.json"); + const r2 = installForAgent({ projectRoot: p.dir, agent: "opencode", servers }); // second run + + assert.deepEqual(p.readBytes("opencode.json"), first); + assert.ok(r2.written.every((w) => w.action === "kept")); + } finally { + p.cleanup(); + } +}); + +it("refuses a config that is not plain JSON instead of editing it", () => { + const p = project(); + try { + const original = '{\n // a JSONC comment\n "mcp": {}\n}\n'; + p.write("opencode.json", original); + + const r = installForAgent({ projectRoot: p.dir, agent: "opencode", servers: { context7: CONTEXT7 } }); + + assert.ok(r.errors.some((e) => /not plain JSON/.test(e))); + assert.equal(p.read("opencode.json"), original); // untouched + assert.equal(r.written.length, 0); // target-level failure, not per-server (ruling 1) + } finally { + p.cleanup(); + } +}); + +// ── Codex (TOML) merge: marker fragments, foreign preservation, ownership ── + +it("codex writes a marker-paired TOML fragment and preserves foreign TOML", () => { + const p = project(); + try { + p.write(".codex/config.toml", 'model = "gpt"\n\n[mcp_servers.foreign]\ncommand = "keep"\n'); + + const r = installForAgent({ + projectRoot: p.dir, + agent: "codex", + servers: { context7: { command: "npx", args: ["-y", "x"] } }, + }); + + const text = p.read(".codex/config.toml"); // read as text, never JSON.parse a .toml + assert.match(text, /\[mcp_servers\.context7\]/); + assert.match(text, /adlc-mcp-begin context7/); + assert.match(text, /adlc-mcp-end context7/); + assert.match(text, /model = "gpt"/); // foreign root preserved + assert.match(text, /\[mcp_servers\.foreign\]/); // foreign server preserved + assert.match(text, /command = "keep"/); + assert.equal(r.written.find((w) => w.name === "context7").action, "added"); + assert.equal(r.errors.length, 0); + } finally { + p.cleanup(); + } +}); + +it("codex is idempotent and regenerates a hand-edited owned block", () => { + const p = project(); + try { + p.write(".codex/config.toml", 'model = "gpt"\n'); + const servers = { context7: { command: "npx", args: ["-y", "x"] } }; + + installForAgent({ projectRoot: p.dir, agent: "codex", servers }); // first run + const first = p.readBytes(".codex/config.toml"); + const r2 = installForAgent({ projectRoot: p.dir, agent: "codex", servers }); // second run + assert.deepEqual(p.readBytes(".codex/config.toml"), first); + assert.ok(r2.written.every((w) => w.action === "kept")); + + // Hand-edit the owned block: ownership is the name, so it is regenerated. + p.write(".codex/config.toml", + p.read(".codex/config.toml").replace('command = "npx"', 'command = "hand-edited"')); + const r3 = installForAgent({ projectRoot: p.dir, agent: "codex", servers }); + assert.equal(r3.written.find((w) => w.name === "context7").action, "updated"); + assert.match(p.read(".codex/config.toml"), /command = "npx"/); // regenerated + assert.match(p.read(".codex/config.toml"), /model = "gpt"/); // foreign still preserved + } finally { + p.cleanup(); + } +}); + +// ── Target-level failures (ruling 1): wrong shape, reported and untouched ── + +it("refuses a target whose section is the wrong shape instead of editing it", () => { + const p = project(); + try { + const original = JSON.stringify({ mcp: ["not", "an", "object"] }, null, 2) + "\n"; + p.write("opencode.json", original); + + const r = installForAgent({ projectRoot: p.dir, agent: "opencode", servers: { context7: CONTEXT7 } }); + + assert.ok(r.errors.length > 0); + assert.equal(p.read("opencode.json"), original); + assert.equal(r.written.length, 0); + } finally { + p.cleanup(); + } +}); + +// ── Security: a server named __proto__ must not pollute the prototype ── + +it("writes a server named __proto__ as a normal key without prototype pollution", () => { + const p = project(); + try { + p.write("opencode.json", "{}"); + const servers = JSON.parse('{ "__proto__": { "command": "x" } }'); // own key, as JSON.parse produces + + const r = installForAgent({ projectRoot: p.dir, agent: "opencode", servers }); + + assert.equal(r.written.find((w) => w.name === "__proto__").action, "added"); + const cfg = JSON.parse(p.read("opencode.json")); + assert.ok(Object.hasOwn(cfg.mcp, "__proto__")); // stored as a normal key + assert.equal(({}).polluted, undefined); // the prototype is untouched + } finally { + p.cleanup(); + } +}); + +// ── disabled-skipped actually disables: a stale owned entry is removed ── + +it("disabled:true with no off switch removes a stale owned entry to actually disable", () => { + const p = project(); + try { + p.write(".cursor/mcp.json", JSON.stringify({ + mcpServers: { foo: { type: "stdio", command: "stale" } }, + }, null, 2)); + + const r = installForAgent({ + projectRoot: p.dir, + agent: "cursor", + servers: { foo: { command: "x", disabled: true } }, + }); + + assert.equal(r.written.find((w) => w.name === "foo").action, "disabled-skipped"); + assert.equal(JSON.parse(p.read(".cursor/mcp.json")).mcpServers.foo, undefined); + } finally { + p.cleanup(); + } +}); + +// ── Fix round 1 — Important 1: .mcp.json is the source, never written ── + +it("claude is a no-op: .mcp.json is never rewritten and every server reports kept", () => { + const p = project(); + try { + const original = JSON.stringify({ + mcpServers: { context7: CONTEXT7, gitlab: { command: "y", disabled: true } }, + }, null, 2) + "\n"; + p.write(".mcp.json", original); + const servers = { context7: CONTEXT7, gitlab: { command: "y", disabled: true } }; + + const before = p.readBytes(".mcp.json"); + const r = installForAgent({ projectRoot: p.dir, agent: "claude", servers }); + + assert.deepEqual(p.readBytes(".mcp.json"), before); // byte-identical + assert.equal(r.written.find((w) => w.name === "context7").action, "kept"); + const gw = r.written.find((w) => w.name === "gitlab"); + assert.equal(gw.action, "kept"); + assert.match(gw.detail, /no effect in claude/); // disabled does not delete + assert.match(gw.detail, /delete the entry/); + assert.equal(r.errors.length, 0); + } finally { + p.cleanup(); + } +}); + +// ── Fix round 2 — Important 1 (TOML path): a bare off-switch override is +// regenerated, never kept (ADR-455) ──────────────────────────────────── + +it("re-enabled in .mcp.json: the full entry is regenerated and the server would start (codex, TOML)", () => { + // The traced scenario, end to end, on the TOML target: disabled → run + // (off-switch written) → remove `disabled` from .mcp.json → re-run. + const p = project(); + try { + const spec = { command: "npx", args: ["-y", "x"] }; + + // disabled: true → the off switch is written: enabled = false, no command + installForAgent({ projectRoot: p.dir, agent: "codex", servers: { gitlab: { ...spec, disabled: true } } }); + let text = p.read(".codex/config.toml"); + assert.match(text, /enabled = false/); + assert.doesNotMatch(text, /command =/); + + // the user removes `disabled` from .mcp.json — the source of truth says on + const r = installForAgent({ projectRoot: p.dir, agent: "codex", servers: { gitlab: spec } }); + + assert.equal(r.written.find((w) => w.name === "gitlab").action, "updated"); + text = p.read(".codex/config.toml"); + assert.match(text, /command = "npx"/); // the full entry is back + assert.match(text, /enabled = true/); // the server would start + assert.doesNotMatch(text, /enabled = false/); // the stale off switch is gone + } finally { + p.cleanup(); + } +}); + +it("codex regenerates a hand-written bare off-switch override into a full entry", () => { + const p = project(); + try { + p.write(".codex/config.toml", '[mcp_servers.gitlab]\nenabled = false\n'); + + const r = installForAgent({ + projectRoot: p.dir, + agent: "codex", + servers: { gitlab: { command: "npx", args: ["-y", "x"] } }, + }); + + const text = p.read(".codex/config.toml"); + assert.match(text, /command = "npx"/); // rebuilt into a full entry + assert.match(text, /enabled = true/); // .mcp.json wins, not the stale override + assert.match(text, /adlc-mcp-begin gitlab/); // marker-paired like any owned block + assert.doesNotMatch(text, /enabled = false/); + assert.equal(r.written.find((w) => w.name === "gitlab").action, "updated"); + } finally { + p.cleanup(); + } +}); + +// ── Fix round 1 — Important 3: malformed marker pairs lose no data ── + +it("an unterminated marker block is reported and the file is left untouched", () => { + const p = project(); + try { + const original = '# adlc-mcp-begin context7\n[mcp_servers.context7]\ncommand = "x"\n' + + '[mcp_servers.foreign]\ncommand = "keep"\n'; + p.write(".codex/config.toml", original); + + const r = installForAgent({ + projectRoot: p.dir, + agent: "codex", + servers: { context7: { command: "npx", args: ["-y", "x"] } }, + }); + + assert.ok(r.errors.some((e) => /unterminated/.test(e))); // reported + assert.equal(p.read(".codex/config.toml"), original); // untouched, foreign kept + assert.equal(r.written.length, 0); // target-level failure + } finally { + p.cleanup(); + } +}); + +it("an orphan end marker is reported and the file is left untouched", () => { + const p = project(); + try { + const original = '# adlc-mcp-end context7\nmodel = "gpt"\n'; + p.write(".codex/config.toml", original); + + const r = installForAgent({ + projectRoot: p.dir, + agent: "codex", + servers: { context7: { command: "npx", args: ["-y", "x"] } }, + }); + + assert.ok(r.errors.some((e) => /orphan/.test(e))); + assert.equal(p.read(".codex/config.toml"), original); + assert.equal(r.written.length, 0); + } finally { + p.cleanup(); + } +}); + +// ── Self-heal round: marker keys, TOML escaping, unreadable targets ──── +// Four Important findings pinned together: a server name the marker +// machinery cannot key byte-exactly (control characters — an injection +// channel into the generated TOML — or edge whitespace that would +// mis-key the block) is refused per-server; control characters in VALUES +// are escaped so the written TOML stays parseable; an unreadable TOML +// target is a reported error (never a crash that aborts the remaining +// named agents); and a JSON target keeps an exact-string name safely — +// it has no marker surface. + +it("a server name with a newline is refused for the TOML target: it cannot be a marker key", () => { + const p = project(); + try { + const original = 'model = "gpt"\n'; + p.write(".codex/config.toml", original); + const evil = "a\nb"; // a raw newline would END the marker comment and inject live TOML + + const r = installForAgent({ projectRoot: p.dir, agent: "codex", servers: { [evil]: CONTEXT7 } }); + + const w = r.written.find((x) => x.name === evil); + assert.equal(w.action, "refused"); + assert.match(w.detail, /marker keys must be one line/); + assert.equal(p.read(".codex/config.toml"), original); // nothing written, nothing lost + assert.equal(r.errors.length, 0); // per-server refusal, not a target-level error + } finally { + p.cleanup(); + } +}); + +it("a server name with edge whitespace is refused for the TOML target: the marker would mis-key it", () => { + const p = project(); + try { + const r = installForAgent({ projectRoot: p.dir, agent: "codex", servers: { " spaced ": CONTEXT7 } }); + assert.equal(r.written.find((w) => w.name === " spaced ").action, "refused"); + assert.ok(!p.exists(".codex/config.toml")); + } finally { + p.cleanup(); + } +}); + +it("control characters in values are escaped: the written TOML stays valid", () => { + const p = project(); + try { + const r = installForAgent({ + projectRoot: p.dir, + agent: "codex", + servers: { + multi: { + command: "npx", + args: ["-y", "x"], + env: { CERT: "line1\nline2\r\nend", TAB: "a\tb", DEL: "d\x7f", CTRL: "c\x01" }, + }, + }, + }); + + assert.equal(r.written.find((w) => w.name === "multi").action, "added"); + const toml = p.read(".codex/config.toml"); + assert.match(toml, /CERT = "line1\\nline2\\r\\nend"/); + assert.match(toml, /TAB = "a\\tb"/); + assert.match(toml, /DEL = "d\\u007f"/); + assert.match(toml, /CTRL = "c\\u0001"/); + // the generated block carries no raw control character (only \n line separators) + const block = toml.slice(toml.indexOf("adlc-mcp-begin multi")); + assert.ok(!/[\x00-\x09\x0b-\x1f\x7f]/.test(block), "raw control character in generated block"); + } finally { + p.cleanup(); + } +}); + +it("an unreadable TOML target is a reported error, never a crash", () => { + const p = project(); + try { + mkdirSync(p.path(".codex"), { recursive: true }); + mkdirSync(p.path(".codex/config.toml")); // a directory: readFileSync → EISDIR + + const r = installForAgent({ projectRoot: p.dir, agent: "codex", servers: { context7: CONTEXT7 } }); + + assert.ok(r.errors.some((e) => /unreadable; refusing to edit/.test(e))); + assert.equal(r.written.length, 0); + } finally { + p.cleanup(); + } +}); + +it("JSON targets key on the exact string: a newline name is written safely (no marker surface)", () => { + const p = project(); + try { + const r = installForAgent({ projectRoot: p.dir, agent: "opencode", servers: { "a\nb": CONTEXT7 } }); + + assert.equal(r.written.find((w) => w.name === "a\nb").action, "added"); + const cfg = JSON.parse(p.read("opencode.json")); // JSON.stringify escapes the key + assert.deepEqual(cfg.mcp["a\nb"].command, ["npx", "-y", "@upstash/context7-mcp"]); + } finally { + p.cleanup(); + } +}); + +// ── Self-heal round: the write-layer guard judges real paths ────────── +// The guard follows symlinks: a target that lands on .mcp.json (whatever +// link chain brought it there) is refused — the source of truth is never +// rewritten (ADR-455/PDR-139) — and so is a target that resolves outside +// the project tree or dangles. + +it("a symlinked target that lands on .mcp.json is refused: the source is never rewritten", () => { + const p = project(); + try { + const source = JSON.stringify({ mcpServers: { context7: CONTEXT7 } }, null, 2) + "\n"; + p.write(".mcp.json", source); + mkdirSync(p.path(".cursor"), { recursive: true }); + symlinkSync(p.path(".mcp.json"), p.path(".cursor/mcp.json")); // "let cursor read the standard directly" + + const before = p.readBytes(".mcp.json"); + const r = installForAgent({ projectRoot: p.dir, agent: "cursor", servers: { context7: CONTEXT7 } }); + + assert.ok(r.errors.some((e) => /source of truth/.test(e))); + assert.deepEqual(p.readBytes(".mcp.json"), before); // byte-identical + } finally { + p.cleanup(); + } +}); + +it("a symlinked target resolving outside the project is refused", () => { + const p = project(); + const outside = project(); + try { + outside.write("real-target.json", "{}"); + mkdirSync(p.path(".cursor"), { recursive: true }); + symlinkSync(outside.path("real-target.json"), p.path(".cursor/mcp.json")); + + const r = installForAgent({ projectRoot: p.dir, agent: "cursor", servers: { context7: CONTEXT7 } }); + + assert.ok(r.errors.some((e) => /outside the project/.test(e))); + assert.equal(outside.read("real-target.json"), "{}"); // untouched + } finally { + p.cleanup(); + outside.cleanup(); + } +}); + +it("a dangling symlink target is refused outright", () => { + const p = project(); + try { + mkdirSync(p.path(".cursor"), { recursive: true }); + symlinkSync(p.path(".cursor/missing.json"), p.path(".cursor/mcp.json")); + + const r = installForAgent({ projectRoot: p.dir, agent: "cursor", servers: { context7: CONTEXT7 } }); + + assert.ok(r.errors.some((e) => /dangling symlink/.test(e))); + } finally { + p.cleanup(); + } +}); + +it("a write failure is a reported error, never a crash (readonly target)", () => { + if (process.getuid && process.getuid() === 0) return; // root ignores mode bits + const p = project(); + try { + p.write("opencode.json", JSON.stringify({ mcp: {} })); + chmodSync(p.path("opencode.json"), 0o444); + + const r = installForAgent({ projectRoot: p.dir, agent: "opencode", servers: { context7: CONTEXT7 } }); + + assert.ok(r.errors.some((e) => /write failed/.test(e))); + assert.equal(r.written.length, 0); // nothing was written, so nothing is claimed + } finally { + try { chmodSync(p.path("opencode.json"), 0o644); } catch { /* already gone */ } + p.cleanup(); + } +}); diff --git a/tests/mcp-registry.test.mjs b/tests/mcp-registry.test.mjs new file mode 100644 index 0000000..d2efeb1 --- /dev/null +++ b/tests/mcp-registry.test.mjs @@ -0,0 +1,64 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { MCP_AGENTS, getMcpAgent } from "../src/registry.mjs"; + +describe("MCP_AGENTS adapter table", () => { + it("covers exactly the day-one four", () => { + assert.deepEqual(Object.keys(MCP_AGENTS).sort(), + ["claude-code", "codex", "cursor", "opencode"]); + }); + + it("declares target_files, section, format, var_syntax and has_off_switch per row", () => { + for (const row of Object.values(MCP_AGENTS)) { + assert.ok(Array.isArray(row.target_files) && row.target_files.length > 0); + assert.equal(typeof row.section, "string"); + assert.ok(["claude", "opencode", "codex", "cursor"].includes(row.format)); + assert.ok(["passthrough", "env-braces", "env-vars", "env-prefix"].includes(row.var_syntax)); + assert.equal(typeof row.has_off_switch, "boolean"); + } + }); + + it("maps the real target files and sections", () => { + assert.deepEqual(MCP_AGENTS["claude-code"].target_files, [".mcp.json"]); + assert.equal(MCP_AGENTS["claude-code"].section, "mcpServers"); + assert.equal(MCP_AGENTS["opencode"].section, "mcp"); + assert.equal(MCP_AGENTS["codex"].section, "mcp_servers"); + assert.equal(MCP_AGENTS["cursor"].section, "mcpServers"); + }); + + it("codex and opencode have an off switch; cursor does not", () => { + assert.equal(MCP_AGENTS.codex.has_off_switch, true); + assert.equal(MCP_AGENTS.opencode.has_off_switch, true); + assert.equal(MCP_AGENTS.cursor.has_off_switch, false); + }); + + it("getMcpAgent normalises the claude alias and rejects unknown agents", () => { + assert.equal(getMcpAgent("claude-code"), MCP_AGENTS["claude-code"]); + assert.equal(getMcpAgent("claude"), getMcpAgent("claude-code")); + assert.equal(getMcpAgent("gemini"), null); + // Garbage keys must not resolve through the prototype chain: the contract + // is McpAgentRow | null, never a truthy non-row. + assert.equal(getMcpAgent("constructor"), null); + }); + + it("maps the opencode, codex and cursor target files", () => { + // opencode: project file wins over the dotdir variant (first existing wins). + assert.deepEqual(MCP_AGENTS["opencode"].target_files, + ["opencode.json", ".opencode/opencode.json"]); + assert.deepEqual(MCP_AGENTS["codex"].target_files, [".codex/config.toml"]); + assert.deepEqual(MCP_AGENTS["cursor"].target_files, [".cursor/mcp.json"]); + }); + + it("pins the per-target var_syntax and the claude off-switch", () => { + // ${VAR} rendering per the design spec's per-agent mapping table: + // claude-code — unchanged; opencode — {env:NAME}; codex — env_vars + // inheritance (no string substitution); cursor — ${env:NAME}. + assert.equal(MCP_AGENTS["claude-code"].var_syntax, "passthrough"); + assert.equal(MCP_AGENTS["opencode"].var_syntax, "env-braces"); + assert.equal(MCP_AGENTS["codex"].var_syntax, "env-vars"); + assert.equal(MCP_AGENTS["cursor"].var_syntax, "env-prefix"); + // `disabled` has no effect in .mcp.json — deleting the entry is the only + // real off switch for claude-code (design spec, settled decision 3). + assert.equal(MCP_AGENTS["claude-code"].has_off_switch, false); + }); +}); diff --git a/tests/mcp-translate.test.mjs b/tests/mcp-translate.test.mjs new file mode 100644 index 0000000..e3e1030 --- /dev/null +++ b/tests/mcp-translate.test.mjs @@ -0,0 +1,365 @@ +import { it } from "node:test"; +import assert from "node:assert/strict"; +import { translateEntry, describeRefusal, offSwitchEntry, expressibleVars } from "../src/mcp/translate.mjs"; +import { MCP_AGENTS } from "../src/registry.mjs"; + +// ── The brief's contract tests (values verbatim) ─────────────────────── + +it("opencode: command+args become one array, env becomes environment, vars become {env:NAME}", () => { + const out = translateEntry( + { command: "npx", args: ["-y", "@upstash/context7-mcp"], + env: { TOKEN: "${GITLAB_TOKEN}" } }, MCP_AGENTS.opencode); + assert.deepEqual(out, { + type: "local", enabled: true, + command: ["npx", "-y", "@upstash/context7-mcp"], + environment: { TOKEN: "{env:GITLAB_TOKEN}" }, + }); +}); + +it("cursor: stdio entries require type, vars become ${env:NAME}", () => { + const out = translateEntry({ command: "npx", args: ["-y", "x"], env: { T: "${TOK}" } }, + MCP_AGENTS.cursor); + assert.equal(out.type, "stdio"); + assert.equal(out.env.T, "${env:TOK}"); +}); + +it("codex: no substitution -- a whole-value var in env inherits under its own name", () => { + // AMENDED from the plan's pin (env: { T: "${TOK}" }): inheritance cannot + // rename, so the matching-name case is the expressible one. The renamed + // case is refused below (fix round 1, finding 2). + const out = translateEntry({ command: "npx", args: ["-y", "x"], env: { TOK: "${TOK}", U: "plain" } }, + MCP_AGENTS.codex); + assert.deepEqual(out.env_vars, ["TOK"]); + assert.equal(out.env.U, "plain"); +}); + +it("claude is a no-op", () => { + const spec = { command: "npx", args: ["-y", "x"], env: { T: "${TOK:-d}" } }; + assert.deepEqual(translateEntry(spec, MCP_AGENTS["claude-code"]), spec); +}); + +it("refuses an entry that cannot be fully expressed in the target", () => { + // codex has no substitution in url/args: a ${VAR} there is inexpressible. + assert.equal(translateEntry({ type: "http", url: "https://mcp.example/${TENANT}" }, + MCP_AGENTS.codex), null); + // the same entry is fine for opencode. + assert.notEqual(translateEntry({ type: "http", url: "https://mcp.example/${TENANT}" }, + MCP_AGENTS.opencode), null); +}); + +it("refuses an entry with neither command nor url rather than writing command:[null]", () => { + assert.equal(translateEntry({}, MCP_AGENTS.opencode), null); + assert.equal(translateEntry({ type: "http" }, MCP_AGENTS.opencode), null); +}); + +it("offSwitchEntry honours has_off_switch and reports absence honestly", () => { + assert.deepEqual(offSwitchEntry(MCP_AGENTS.opencode), { enabled: false }); + assert.deepEqual(offSwitchEntry(MCP_AGENTS.codex), { enabled: false }); + assert.equal(offSwitchEntry(MCP_AGENTS.cursor), null); +}); + +it("expressibleVars separates required names from dropped defaults", () => { + assert.deepEqual( + expressibleVars({ command: "npx", env: { A: "${ONE}", B: "${TWO:-fallback}" } }), + { needed: ["ONE", "TWO"], droppedDefaults: ["TWO"] }); +}); + +// ── describeRefusal: one source of truth for refusals ───────────────── + +it("describeRefusal agrees with translateEntry on every refusal and acceptance", () => { + const specs = [ + null, + {}, + { type: "http" }, + { command: "x", url: "https://y" }, + { command: "x", args: "${P}" }, + { command: "x", env: { T: 5 } }, + { type: "http", url: "https://y" }, + { type: "sse", url: "https://y" }, + { type: "http", url: "https://y/${V}" }, + { type: "http", url: "https://y", headers: { A: "b" } }, + { type: "http", url: "https://y", args: ["a"] }, + { command: "${BIN}/x" }, + { command: "x", args: ["${P}"] }, + { command: "x", env: { T: "prefix-${V}" } }, + { command: "x", env: { T: "${V}" } }, + { command: "x", env: { TOK: "${TOK}" } }, + { command: "x", env: { TOK: "${TOK:-d}" } }, + { command: "x", env: { T: "${TOK:-d}" } }, + { type: "http", url: "https://y", headers: { Authorization: "Bearer ${V}" } }, + { type: "http", url: "https://y", headers: { X: "${V:-d}" } }, + { command: "x", disabled: true }, + { command: "x" }, + ]; + // The real rows plus cross-axis rows: substitution is keyed on + // var_syntax, shape on format — they must agree in every pairing. + const rows = [ + ...Object.values(MCP_AGENTS), + { ...MCP_AGENTS.codex, var_syntax: "env-braces" }, + { ...MCP_AGENTS.opencode, var_syntax: "env-vars" }, + { ...MCP_AGENTS.cursor, var_syntax: "passthrough" }, + ]; + for (const spec of specs) { + for (const row of rows) { + const refused = describeRefusal(spec, row); + assert.equal(translateEntry(spec, row) === null, refused !== null, + `disagreement for ${JSON.stringify(spec)} / ${row.format}`); + } + } +}); + +it("describeRefusal names the field and the variable codex cannot substitute", () => { + const reason = describeRefusal({ type: "http", url: "https://mcp.example/${TENANT}" }, MCP_AGENTS.codex); + assert.match(reason, /url/); + assert.match(reason, /\$\{TENANT\}/); +}); + +it("describeRefusal explains an unstartable entry for every target", () => { + for (const row of Object.values(MCP_AGENTS)) { + assert.match(describeRefusal({}, row), /neither command nor url/); + } +}); + +it("describeRefusal has nothing to say about an expressible entry", () => { + assert.equal(describeRefusal( + { command: "npx", args: ["-y", "x"], env: { TOKEN: "${GITLAB_TOKEN}" } }, + MCP_AGENTS.opencode), null); +}); + +// ── Entry shapes beyond the brief's pins ────────────────────────────── + +it("codex url entry is just url plus the off switch field", () => { + assert.deepEqual(translateEntry({ type: "http", url: "https://mcp.example" }, MCP_AGENTS.codex), + { url: "https://mcp.example", enabled: true }); +}); + +it("codex maps http onto its StreamableHttp url and refuses sse", () => { + // codex transports are Stdio | StreamableHttp (fix round 2): an SSE + // server is a different protocol and would start wrongly against a + // StreamableHttp client — refused, not half-written. + assert.deepEqual(translateEntry({ type: "http", url: "https://mcp.example" }, MCP_AGENTS.codex), + { url: "https://mcp.example", enabled: true }); + assert.equal(translateEntry({ type: "sse", url: "https://mcp.example" }, MCP_AGENTS.codex), null); + assert.match(describeRefusal({ type: "sse", url: "https://mcp.example" }, MCP_AGENTS.codex), /sse/); +}); + +it("opencode refuses sse (its remote entry is streamable HTTP only); cursor keeps the native type", () => { + // The same silent-success class as codex, one branch over: opencode's + // documented remote schema has no sse type and its client has no SSE + // transport, so an sse entry would fail to connect at agent runtime + // AFTER this tool reported success. Cursor speaks SSE natively and + // keeps type:"sse" — the refusal is per-target, never global. + const sse = { type: "sse", url: "https://mcp.example" }; + assert.equal(translateEntry(sse, MCP_AGENTS.opencode), null); + assert.match(describeRefusal(sse, MCP_AGENTS.opencode), /streamable HTTP, not SSE/); + assert.equal(translateEntry(sse, MCP_AGENTS.codex), null); + assert.match(describeRefusal(sse, MCP_AGENTS.codex), /StreamableHttp, not SSE/); + assert.deepEqual(translateEntry(sse, MCP_AGENTS.cursor), { type: "sse", url: "https://mcp.example" }); +}); + +it("opencode url entry becomes a remote entry with env-braces vars", () => { + assert.deepEqual(translateEntry({ type: "http", url: "https://mcp.example/${TENANT}" }, MCP_AGENTS.opencode), + { type: "remote", enabled: true, url: "https://mcp.example/{env:TENANT}" }); +}); + +it("cursor keeps the http/sse type and rewrites the url with env-prefix vars", () => { + assert.deepEqual(translateEntry({ type: "sse", url: "https://mcp.example/${TENANT}" }, MCP_AGENTS.cursor), + { type: "sse", url: "https://mcp.example/${env:TENANT}" }); +}); + +it("cursor requires type on a url entry", () => { + assert.equal(translateEntry({ url: "https://mcp.example" }, MCP_AGENTS.cursor), null); + assert.match(describeRefusal({ url: "https://mcp.example" }, MCP_AGENTS.cursor), /need type/); +}); + +it("opencode rewrites variables in args", () => { + assert.deepEqual(translateEntry({ command: "npx", args: ["${P}"] }, MCP_AGENTS.opencode), + { type: "local", enabled: true, command: ["npx", "{env:P}"] }); +}); + +it("type that contradicts the connection is refused", () => { + assert.equal(translateEntry({ type: "http", command: "x" }, MCP_AGENTS.opencode), null); + assert.match(describeRefusal({ type: "http", command: "x" }, MCP_AGENTS.opencode), /conflicts with command/); +}); + +it("refuses an entry with both command and url rather than dropping one", () => { + const spec = { command: "x", url: "https://y" }; + assert.equal(translateEntry(spec, MCP_AGENTS.opencode), null); + assert.equal(translateEntry(spec, MCP_AGENTS.cursor), null); + assert.equal(translateEntry(spec, MCP_AGENTS.codex), null); + // claude is a no-op: the standard's own shape passes through untouched. + assert.deepEqual(translateEntry(spec, MCP_AGENTS["claude-code"]), spec); +}); + +it("refuses fields the target cannot express instead of dropping them", () => { + const spec = { command: "x", headers: { Authorization: "Bearer z" } }; + assert.equal(translateEntry(spec, MCP_AGENTS.cursor), null); + assert.match(describeRefusal(spec, MCP_AGENTS.cursor), /headers/); + assert.match(describeRefusal(spec, MCP_AGENTS.cursor), /cursor/); +}); + +it("refuses env and args on url entries", () => { + const withArgs = { type: "http", url: "https://y", args: ["a"] }; + const withEnv = { type: "http", url: "https://y", env: { A: "b" } }; + assert.equal(translateEntry(withArgs, MCP_AGENTS.cursor), null); + assert.equal(translateEntry(withEnv, MCP_AGENTS.cursor), null); + assert.match(describeRefusal(withArgs, MCP_AGENTS.cursor), /args/); + assert.match(describeRefusal(withEnv, MCP_AGENTS.cursor), /env/); +}); + +// ── headers mapping (design's per-agent mapping table) ─────────────── + +it("headers ride along on remote entries with vars rendered per var_syntax", () => { + const spec = { type: "http", url: "https://mcp.example/${TENANT}", + headers: { Authorization: "Bearer ${DOCS_TOKEN}" } }; + assert.deepEqual(translateEntry(spec, MCP_AGENTS.opencode), { + type: "remote", enabled: true, + url: "https://mcp.example/{env:TENANT}", + headers: { Authorization: "Bearer {env:DOCS_TOKEN}" }, + }); + assert.deepEqual(translateEntry(spec, MCP_AGENTS.cursor), { + type: "http", + url: "https://mcp.example/${env:TENANT}", + headers: { Authorization: "Bearer ${env:DOCS_TOKEN}" }, + }); + assert.deepEqual(translateEntry(spec, MCP_AGENTS["claude-code"]), spec); +}); + +it("codex splits headers into http_headers and inherited names", () => { + const out = translateEntry( + { type: "http", url: "https://mcp.example", + headers: { X: "lit", Authorization: "${TOKEN}" } }, MCP_AGENTS.codex); + assert.deepEqual(out.http_headers, { X: "lit" }); + assert.deepEqual(out.env_http_headers, { Authorization: "TOKEN" }); +}); + +it("codex maps a bearer token header to bearer_token_env_var", () => { + // The design's flagship example: Authorization "Bearer ${DOCS_TOKEN}" + // maps to bearer_token_env_var with the variable name from the standard. + assert.deepEqual( + translateEntry({ type: "http", url: "https://mcp.example", + headers: { Authorization: "Bearer ${DOCS_TOKEN}" } }, MCP_AGENTS.codex), + { url: "https://mcp.example", enabled: true, bearer_token_env_var: "DOCS_TOKEN" }); +}); + +it("codex refuses composed header values but drops defaults on whole vars", () => { + const composed = { type: "http", url: "https://y", headers: { Authorization: "Basic ${V}" } }; + assert.equal(translateEntry(composed, MCP_AGENTS.codex), null); + assert.deepEqual( + translateEntry({ type: "http", url: "https://y", headers: { X: "${V:-d}" } }, MCP_AGENTS.codex), + { url: "https://y", enabled: true, env_http_headers: { X: "V" } }); + assert.deepEqual( + expressibleVars({ type: "http", url: "https://y", headers: { X: "${V:-d}" } }), + { needed: ["V"], droppedDefaults: ["V"] }); +}); + +// ── substitution follows var_syntax, shape follows format ──────────── + +it("a codex-shaped row that understands env-braces renders them, never raw literals", () => { + const row = { ...MCP_AGENTS.codex, var_syntax: "env-braces" }; + assert.deepEqual(translateEntry({ command: "npx", args: ["${P}"] }, row), + { command: "npx", enabled: true, args: ["{env:P}"] }); +}); + +it("an opencode-shaped row with env-vars refuses what it cannot inherit", () => { + const row = { ...MCP_AGENTS.opencode, var_syntax: "env-vars" }; + assert.equal(translateEntry({ command: "npx", args: ["${P}"] }, row), null); + assert.match(describeRefusal({ command: "npx", args: ["${P}"] }, row), /does no variable substitution/); +}); + +it("a cursor-shaped row with passthrough leaves values untouched", () => { + const row = { ...MCP_AGENTS.cursor, var_syntax: "passthrough" }; + assert.deepEqual(translateEntry({ command: "x", env: { T: "${TOK}" } }, row), + { type: "stdio", command: "x", env: { T: "${TOK}" } }); +}); + +// ── disabled mapping ───────────────────────────────────────────────── + +it("disabled maps to enabled:false where the target has an off switch", () => { + assert.deepEqual(translateEntry({ command: "x", disabled: true }, MCP_AGENTS.opencode), + { type: "local", enabled: false, command: ["x"] }); + assert.deepEqual(translateEntry({ command: "x", disabled: true }, MCP_AGENTS.codex), + { command: "x", enabled: false }); +}); + +it("cursor refuses a disabled entry it cannot switch off", () => { + assert.equal(translateEntry({ command: "x", disabled: true }, MCP_AGENTS.cursor), null); + assert.match(describeRefusal({ command: "x", disabled: true }, MCP_AGENTS.cursor), /disable/); +}); + +it("claude refuses disabled -- deleting the entry is the only real off switch", () => { + // `disabled` has no effect in .mcp.json; a written disabled entry would + // start anyway. Refused exactly as for cursor (fix round 1, finding 1). + const spec = { command: "x", disabled: true }; + assert.equal(translateEntry(spec, MCP_AGENTS["claude-code"]), null); + assert.match(describeRefusal(spec, MCP_AGENTS["claude-code"]), /delet/); +}); + +it("claude-code has no off switch either", () => { + assert.equal(offSwitchEntry(MCP_AGENTS["claude-code"]), null); +}); + +// ── codex env inheritance limits ───────────────────────────────────── + +it("codex refuses a value that mixes text and variables in env", () => { + const spec = { command: "x", env: { T: "prefix-${V}" } }; + assert.equal(translateEntry(spec, MCP_AGENTS.codex), null); + assert.match(describeRefusal(spec, MCP_AGENTS.codex), /in env is not one whole/); +}); + +it("codex refuses a renamed env var -- inheritance cannot rename", () => { + // { T: "${TOK}" } would deliver TOK to the server, not T (fix round 1, + // finding 2). The matching case is pinned in the codex test above. + const spec = { command: "x", env: { T: "${TOK}" } }; + assert.equal(translateEntry(spec, MCP_AGENTS.codex), null); + assert.match(describeRefusal(spec, MCP_AGENTS.codex), /inherits names unchanged/); +}); + +it("codex drops a defaulted env var's fallback and reports it", () => { + // Design carve-out (fix round 2): `${VAR:-default}` has no codex (or + // opencode) equivalent — the variable simply becomes required and the + // dropped default is reported via expressibleVars().droppedDefaults. + assert.deepEqual(translateEntry({ command: "x", env: { TOK: "${TOK:-d}" } }, MCP_AGENTS.codex), + { command: "x", enabled: true, env_vars: ["TOK"] }); + assert.deepEqual( + expressibleVars({ command: "x", env: { TOK: "${TOK:-d}" } }), + { needed: ["TOK"], droppedDefaults: ["TOK"] }); +}); + +it("codex refuses a renamed env var even when it carries a default -- the rename is the blocker", () => { + const spec = { command: "x", env: { T: "${TOK:-d}" } }; + assert.equal(translateEntry(spec, MCP_AGENTS.codex), null); + assert.match(describeRefusal(spec, MCP_AGENTS.codex), /inherits names unchanged/); +}); + +it("codex refuses variables in command and args", () => { + assert.equal(translateEntry({ command: "${BIN}/x" }, MCP_AGENTS.codex), null); + assert.equal(translateEntry({ command: "x", args: ["${P}"] }, MCP_AGENTS.codex), null); + assert.match(describeRefusal({ command: "${BIN}/x" }, MCP_AGENTS.codex), /command/); + assert.match(describeRefusal({ command: "x", args: ["${P}"] }, MCP_AGENTS.codex), /args/); +}); + +// ── The no-eager-resolution constraint ─────────────────────────────── + +it("variables are never resolved from the machine's environment", () => { + process.env.MCP_TRANSLATE_TEST_TOKEN = "secret-value"; + try { + const out = translateEntry({ command: "npx", env: { T: "${MCP_TRANSLATE_TEST_TOKEN}" } }, MCP_AGENTS.opencode); + assert.equal(out.environment.T, "{env:MCP_TRANSLATE_TEST_TOKEN}"); + assert.ok(!JSON.stringify(out).includes("secret-value")); + } finally { + delete process.env.MCP_TRANSLATE_TEST_TOKEN; + } +}); + +// ── expressibleVars beyond the brief's pin ─────────────────────────── + +it("expressibleVars finds variables in args and url and dedupes names", () => { + assert.deepEqual( + expressibleVars({ command: "npx", args: ["${A}"], url: "https://x/${B}", env: { C: "${A:-z}" } }), + { needed: ["A", "B"], droppedDefaults: ["A"] }); +}); + +it("expressibleVars reports nothing for a spec without variables", () => { + assert.deepEqual(expressibleVars({ command: "npx" }), { needed: [], droppedDefaults: [] }); +}); diff --git a/tests/team-mcp-chain.test.mjs b/tests/team-mcp-chain.test.mjs new file mode 100644 index 0000000..c40af84 --- /dev/null +++ b/tests/team-mcp-chain.test.mjs @@ -0,0 +1,314 @@ +// tests/team-mcp-chain.test.mjs — `team setup` / `team update` chain +// `mcp install` (Task 6): the agent's MCP servers are configured before its +// session starts, and an MCP failure aborts the command with exit 1 (ruling +// 1a) — never 3, which workflow.mjs reserves for PAUSED. +// +// The units under test (team commands, chainMcpInstall, cmdMcpInstall, +// installForAgent) run for real against temp-dir fixtures. Two external +// boundaries are stubbed the way the codebase already stubs them: the +// `npx skills` installer (a PATH stub — cmdUpdate's pull loop must not hit +// the network in tests) and the agent binary (the ADLC_MOCK_AGENT seam from +// workspace.test.mjs, which doubles as the "agent session never reached" +// observable: cmdAgentRun appends to the stub file iff it is entered). + +import { it } from "node:test"; +import assert from "node:assert/strict"; +import { execFile, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, + chmodSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve, delimiter } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chainMcpInstall } from "../src/commands/team.mjs"; + +const exec = promisify(execFile); +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const BIN = join(REPO_ROOT, "bin", "adlc-cli.mjs"); +const run = (args, { cwd, env } = {}) => + exec(process.execPath, [BIN, ...args], { cwd, env: { ...process.env, ...env } }).catch((e) => e); + +// Minimal project sandbox (same shape as tests/mcp-command.test.mjs). +function project() { + const dir = mkdtempSync(join(tmpdir(), "adlc-team-mcp-chain-")); + return { + dir, + path: (rel) => join(dir, rel), + write(rel, content) { + const abs = join(dir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content, "utf-8"); + }, + read(rel) { return readFileSync(join(dir, rel), "utf-8"); }, + exists(rel) { return existsSync(join(dir, rel)); }, + cleanup() { rmSync(dir, { recursive: true, force: true }); }, + }; +} + +// Capture the ui writers (process.stdout/stderr) while the command runs — +// the assertions are on what a user sees and on which stream sees it. +async function capture(fn) { + const outChunks = []; + const errChunks = []; + const origOut = process.stdout.write.bind(process.stdout); + const origErr = process.stderr.write.bind(process.stderr); + process.stdout.write = (chunk) => { outChunks.push(String(chunk)); return true; }; + process.stderr.write = (chunk) => { errChunks.push(String(chunk)); return true; }; + try { + const code = await fn(); + return { code, stdout: outChunks.join(""), stderr: errChunks.join("") }; + } finally { + process.stdout.write = origOut; + process.stderr.write = origErr; + } +} + +const CONTEXT7 = { + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + env: { TOKEN: "${GITLAB_TOKEN}" }, +}; +// codex does no substitution: ${TENANT} inside a url is the plan's headline +// refusal — codex would start the agent without that server. +const TENANT_URL = { type: "http", url: "https://docs.example/${TENANT}/mcp" }; +// codex (env-vars syntax) inherits whole env names unchanged — own-name +// whole vars are expressible, renames are not. +const CODEX_OK = { + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + env: { GITLAB_TOKEN: "${GITLAB_TOKEN}" }, +}; +// An entry with neither command nor url is unstartable and refused by every +// target — the one refusal shape that does not depend on the agent's +// var syntax (used for the opencode setup test). +const UNSTARTABLE = {}; +const withServers = (servers) => JSON.stringify({ mcpServers: servers }, null, 2); + +// ── chainMcpInstall (unit: the thin helper, real cmdMcpInstall beneath) ── + +it("chain: no .mcp.json → exit 0 and the gap report still prints (ruling 3)", async () => { + const p = project(); + try { + const { code, stdout, stderr } = await capture(() => + chainMcpInstall(p.dir, "opencode", "team update")); + assert.equal(code, 0); + assert.match(stdout, /nothing to install/); + // the copy-once gap report is not special-cased away in the chained context + assert.match(stderr, /team MCP source unavailable/); + } finally { + p.cleanup(); + } +}); + +it("chain: valid .mcp.json + opencode → writes opencode.json, exit 0", async () => { + const p = project(); + try { + const source = withServers({ context7: CONTEXT7 }); + p.write(".mcp.json", source); + const { code } = await capture(() => chainMcpInstall(p.dir, "opencode", "team setup")); + assert.equal(code, 0); + const oc = JSON.parse(p.read("opencode.json")); + assert.deepEqual(oc.mcp.context7, { + type: "local", + enabled: true, + command: ["npx", "-y", "@upstash/context7-mcp"], + environment: { TOKEN: "{env:GITLAB_TOKEN}" }, + }); + assert.equal(p.read(".mcp.json"), source); // the source is never rewritten + } finally { + p.cleanup(); + } +}); + +it("chain: a refused server → exit 1 with 'aborting