Repository navigation
feat(mcp): adlc-cli mcp install — materialize .mcp.json into each agent's own config - #62
Conversation
…to stderr
Branch fix round — two Important findings plus an accepted stream decision.
Important 1: the bare off-switch exemption is removed entirely, both paths.
ADR-455 is explicit: a name in .mcp.json is tool-owned and regenerated on
each run; overrides belong in .mcp.json (disabled), not in the agent's
file. The exemption silently trapped a disabled->re-enabled server in the
off state: the tool saw its own bare {"enabled": false} / enabled = false
block and kept it while .mcp.json said on. Drops isBareOffSwitch (JSON),
isBareTomlOffSwitch + the bare set (TOML) and both keep-rules; a bare
override is regenerated like any other owned body and reported updated.
This reverses fix round 1's Finding 2 and the Task 3 ruling; the
marker-first TOML middle ground was rejected (same scenario must behave
the same by target format). Regression traced end to end and pinned for
opencode (JSON) and codex (TOML): disabled -> run -> remove disabled ->
re-run -> full entry regenerated, action updated, server would start.
Stream decision: the copy-once gap report moves to stderr via the ui
layer's warn() convention; exit code untouched (0) and stdout stays the
result channel. The stderr === "" success-path pins were test-local and
now pin the warning instead.
Docs: README gains an MCP group in Commands (behaviour, precondition, the
four agents, exit contract, 2-space JSON indent note); CHANGELOG gains an
Unreleased Added entry for the command.
tests: 706 pass (was 704; mcp-install 18, +2 covering tests)
Both team commands now configure the committed agent's MCP servers before its session starts (setup) or the repair pass runs (update) — a named agent that would start without its servers is never reported as success (PDR-138/139). The chained call is the full cmdMcpInstall behavior with no chained-context special-casing: the missing-source no-op and the copy-once gap report run chained too (ruling 3), and an MCP failure aborts the command with exit 1 (ruling 1a). Also fixes a latent bug the success-path test exposed: cmdTeamRepair returned `result.status || 1`, coercing a successful repair's exit 0 into 1 — every successful `team update` exited 1. Now `?? 1`.
kanfil
left a comment
There was a problem hiding this comment.
factory-review verdict: not-merge-ready
Reviewed revision: head bf487a8f693e4ec15c80dba985ce1439cf57bb58 · base f00f274f8e0707ab3c3d2cedf795f8d1327e0e19 · merge-base f00f274f (fully rebased). Reviewed from a detached checkout at the exact head (factory-review-bf487a8), not from a rendered diff.
Policy: the repo has no REVIEW.md, so the factory-review default policy was applied: passes bugs, security, spec-compliance (Important) and quality (Nit), against ADR-455 + PDR-138/139/140.
Verdict: 1 Important finding is open, so the PR is blocked from merge under the review policy until it is resolved. Everything else is in strong shape — see “What passed” below.
🔴 Important — type: "sse" entries are silently written to opencode in a transport it cannot speak (PDR-138 promise / refusal contract)
Where: src/mcp/translate.mjs:239-241 (the sse refusal is codex-only) and src/mcp/translate.mjs:273-280 (buildOpenCode drops spec.type for url entries and always writes {type: "remote", …}).
Repro (real registry rows, exact head):
$ node -e '…translateEntry(sseSpec, MCP_AGENTS[name])…'
codex → REFUSED: sse is not expressible in codex entries: its remote transport is StreamableHttp, not SSE
opencode → {"type":"remote","enabled":true,"url":"https://example.com/sse","headers":{"Authorization":"Bearer {env:TOKEN}"}}
cursor → {"type":"sse","url":"https://example.com/sse","headers":{"Authorization":"Bearer ${env:TOKEN}"}}
Evidence that opencode cannot consume it:
- OpenCode's documented remote schema (opencode.ai/docs/mcp-servers → Remote) is
type/url/enabled/headers/oauth/timeout— there is no sse type. - Code search of
sst/opencode:SSEClientTransport→ 0 matches. OpenCode's remote MCP client speaks streamable HTTP only.
Impact: an .mcp.json declaring "type": "sse" (a real legacy shape — e.g. the old GitHub MCP /sse endpoint) lands in opencode.json as a remote entry that fails to connect at agent runtime, while mcp install reports it added/updated and exits 0. That is exactly the silent-failure class PDR-138 exists to eliminate ("the bug being fixed is reporting success while [the config is wrong]"), and exactly what the codex refusal one branch above prevents. The PR's own contract says an entry that cannot be fully expressed in a target is refused for that target and reported — opencode is the one target where that promise is not kept for sse.
Test gap: tests/mcp-translate.test.mjs pins "codex … refuses sse" and "cursor keeps the http/sse type", but has no opencode-sse case — the matrix cross-products the axes, yet this row×spec combination goes unasserted.
Required correction: extend the sse refusal to the opencode row (same "cannot be fully expressed" wording — cursor's keep-sse is correct and needs no change), pin it with a test in the agreement matrix, and carry the codex-style reason so the report explains why. If opencode is believed to negotiate sse endpoints, that belief needs a source and a pinning test instead.
💬 Nit — server names with newlines/control characters produce corrupt .codex/config.toml
Where: src/mcp/install.mjs:376-377 (beginMark/endMark interpolate the raw server name into marker comment lines) and src/mcp/install.mjs:398-400 (tomlString escapes only \ and ", not newlines or control characters).
A .mcp.json server named with a JSON \n escape writes a raw newline into the marker comment (line injection into the generated TOML) and/or into a quoted table key, leaving config.toml unparseable for codex — the whole-file failure mode the malformed-marker guard (install.mjs:249-252) exists to prevent. Foreign user content is preserved (nothing is lost), and the trigger requires a pathological .mcp.json, so this is defensive-hardening rather than data loss — but the fix is cheap: reject control characters in server names (or escape them in tomlString and sanitize the marker names) and fail closed like every other malformed-input path. JSON targets are unaffected (JSON.stringify escapes).
💬 Nit — no REVIEW.md in the repository root
Every review run against this repo falls back to the factory default policy. Committing a REVIEW.md (even the default template, specialized with this repo's skip lists — tests/e2e-node-gate.mjs fixtures, generated surfaces) would make the review contract explicit and versioned.
What passed (verified, not assumed)
- Suite green at the exact head: 815/815 unit tests + e2e node gate pass in the detached checkout (reproduced locally; matches the PR's claim).
- Spec-compliance vs ADR-455 / PDR-138/139/140: name-keyed ownership without a lock file; hand-edited owned bodies regenerated and reported;
.mcp.jsonnever written (claude no-op + write-layer source-path guards in both merge paths); refusals fail the run and name agent/target/reason; exit contract is 0/1 only (3untouched); gap report is stderr-only, never blocking, and reports an unavailable team source instead of degrading to "no gap";disabledmaps correctly per target (off switch / removal / no-op). - Codex field mapping checked against the official Codex config reference:
env_vars,env_http_headers,bearer_token_env_var,http_headers,enabled,urlare all realmcp_servers.<id>fields — the literal-headerhttp_headersbranch is correct. - Security: prototype-chain rejection in
getMcpAgent(Object.hasOwn),setOwnviadefinePropertyon all writes,__proto__server-name test present; no shell invocation, no secret material in logs or reports (only variable names are printed). - TOML safety: marker-pair line surgery with bounded scanning; unterminated/orphan marker pairs fail closed with the file untouched; foreign file content preserved byte-for-byte when unchanged.
- Chaining:
team setupruns the install after skills install and before the agent session;team updatebefore the repair; the no-agent init-options behavior change and thecmdTeamRepair|| 1→?? 1fix are both disclosed in the CHANGELOG; README/help/dispatch (--agentsalias) all wired. - Quality: TDD structure with the review-focus traces the PR body claims are all present and pinned (no-command/no-url refusal, codex-only refusal with other targets still written, hand-edit overwrite, missing precondition, byte-identical re-run, disabled→re-enabled for JSON and TOML).
Separation of duties: this review neither approves nor merges. Merge-readiness is carried by the marker above (outcome=not-merge-ready) and is re-evaluated on any new head — a fix push invalidates this verdict and triggers a full re-review.
self-heal iter 0 — adversarial review (Round 1 + Round 2 + synthesis)Three-persona Round 1 (Auditor / Adversary / Pragmatist, fresh detached checkout at Synthesis tags — fix gate
The 5 tagged Important findings are actionable and go to the fix step. Nits stay advisory and are not fixed by the agent. Review performed by factory-review self-heal (cross-runtime lane unavailable — degraded to same-runtime personas, disclosed). This agent neither approves nor merges. |
…l path Five Important findings from the adversarial review round (run pr62-sh1), each pinned by a test: - sse is refused for opencode too (streamable HTTP only), not just codex; cursor keeps its native sse type. A written-but-unconnectable entry reported as success was the PDR-138 silent-success class. - TOML marker keys: a server name with control characters or edge whitespace cannot round-trip a marker comment byte-exactly — refused per-server (an injection channel closed); JSON targets key on the exact string and are unaffected. - tomlString escapes every control character (\n \r \t by name, the rest \uXXXX): a raw control character in a value bricked the whole config.toml while the run reported success. - mergeToml reads its target under try/catch like mergeJson, and the write layer converts I/O failures to target-level errors: an unreadable/unwritable target is reported (PDR-139), never a stack trace that aborts the remaining named agents. - The write-layer guard judges real paths (symlinks followed): a target landing on .mcp.json is refused — the source of truth is never rewritten — and so is a target resolving outside the project tree or a dangling symlink. Suite: 825/825 + e2e gate green.
Windows CI (introduced by this PR's own chained tests) failed for one
root cause with three layers:
- The npx PATH stub was a #!/bin/sh script — invisible to cmd.exe.
spawnNpxSync goes through shell:true on Windows (spawn.mjs), which
resolves the command via PATHEXT and cannot execute an extensionless
script, so CI ran the REAL npx: a network install that crashes on
Node 18 (util.styleText) and ENOENTs writing commands on 20/22. Fix:
an npx.cmd twin (CRLF) beside the POSIX script.
- The success path ends in cmdTeamRepair's spawnSync("bash", ...) —
bare bash is not reliably on a Node-spawned process's PATH on
windows-latest (documented in e2e-windows-path.test.mjs). Fix: the
test injects Git for Windows' bash dir into the child's PATH, same
lookup order as that test's resolveBash, with a fail-open skip only
when no bash exists at all (repo SKIP_NO_BASH convention).
- team.mjs passed the raw Windows script path to Git Bash, which treats
backslashes as escape characters. Fix: normalize to forward slashes
on win32 — the same convention dispatcher.mjs's sh branch established.
Suite: 825/825 + e2e gate green.
|
Fix — iteration 1 (inline execution; subagent lane unavailable — disclosed degradation) Finding: Windows CI red on this PR's own chained tests ( Root cause — one root, three layers:
Fix (f3e508b):
Verification: suite 825/825 + e2e green locally; CI green on all 10 jobs including windows-latest 18/20/22 at f3e508b. |
|
Converge — iteration 1 (independent verification at a fresh detached checkout of f3e508b)
Finding-set comparison: iteration-1 set (1 finding) ⊂ iteration-0 set (5 actionable Important) → progress; none remaining. Verdict: DONE → merge-ready review verdict follows. |
kanfil
left a comment
There was a problem hiding this comment.
🟢 PASS — Ready for human review
PR #62 @ f3e508b — feat(mcp): adlc-cli mcp install — materialize .mcp.json into each agent's own config
Reviewed against ADR-455 + PDR-138/139/140 (REVIEW.md absent → default 4-pass policy: bugs/security/spec-compliance Important, quality Nit). Iteration 0 ran the adversarial trio (Auditor/Adversary/Pragmatist) with cross-examination and deterministic synthesis; iterations 0–1 ran the self-heal fix → independent converge loop.
Important findings: 0 open — all 5 from the adversarial round are fixed and pinned by tests (430a24a), and the Windows CI failure is fixed (f3e508b):
| # | Finding | Fix |
|---|---|---|
| R1 | sse servers silently written for opencode (unconnectable at agent runtime, reported as success) | refused — opencode/codex remote transport is streamable HTTP; cursor keeps native sse |
| S1 | newline in a server name escapes the TOML marker comment → live TOML injection | refused per-server (marker keys must be one line) |
| S2 | control characters in values brick the whole config.toml while reporting success | full \uXXXX escaping (\n \r \t by name) |
| A1 | unreadable TOML target crashes the run, aborting the remaining named agents | target-level error, reported; write failures too |
| S3 | symlinked target bypasses the source-write guard | guard judges real paths: onto .mcp.json / outside the project / dangling all refused |
| CI | Windows red on the PR's own chained tests (3-layer stub/bash/path root cause) | npx.cmd stub twin + Git-bash PATH injection + forward-slash script path |
CI: all 10 jobs green — ubuntu/macos/windows × Node 18/20/22 + e2e.
Advisory nits (12, non-blocking, deliberately outside the fix gate):
- S4 and P1–P8 from the iteration-0 review — unchanged, see that review for detail.
- Pre-existing issues surfaced by the Windows diagnosis (not introduced by this PR):
src/commands/skills.mjs:339—cmdUpdatewrites commands intocommands_dirwithoutmkdirSync; a real Windows user without~/.codex/promptscrashes after a real skills install. AmkdirSync(dirname(filepath), { recursive: true })would close it.cmdTeamRepair's barespawnSync("bash")won't resolve bash on real Windows machines (Git bin not on PATH for GUI-spawned processes) and exits 1 silently;dispatcher.mjs'sfindLauncherpattern is the in-repo precedent for robust resolution.tests/provider-transport.test.mjsintermittently crashes the full suite with a Node test-runner IPC deserialization error (passes in isolation; observed at both the 430a24a and f3e508b checkouts).
This is a review verdict only — approval and merge remain with the code-owner.
What
adlc-cli mcp install [DIR] --agent NAMEreads the project's.mcp.json(the standard declaration, PDR-138) and writes each named agent's MCP config in that agent's own shape. Implements ADR-455 / PDR-139 / PDR-140.Update:
team setupandteam updatenow chain the install (user decision) — setup chains it after skills install, before the agent session, so the session starts with its MCP servers configured; update chains it before the repair pass. An MCP failure aborts the team command with exit 1 (fail-hard); the gap report runs in the chained context exactly as standalone. Bonus fix surfaced by the success-path test:cmdTeamRepairreturnedresult.status || 1, coercing every successful repair's exit 0 into 1 — now?? 1.Design
MCP_AGENTSadapter table besideEVENT_AGENTSinsrc/registry.mjs— two independent axes per row:format(entry shape) andvar_syntax(string rewriting). Day-one agents:claude-code,opencode,codex,cursor. Agent five is a registry row plus a builder, not new engine code..mcp.jsonis tool-owned and regenerated on each run — including hand-edited bodies (updated). Overrides live in.mcp.json(disabled: true), never in the agent's file. A bare{"enabled": false}override in a target is regenerated, not kept.${VAR}in a codexurl— codex has no substitution; an env rename codex cannot reproduce). Never half-written, never eagerly resolved from the machine's environment. Refusals and target-level errors fail the run: exit 0 only when every named agent is configured. Exit codes are 0/1 only —3stays reserved forPAUSED..mcp.jsonis never written. Three independent layers enforce it: the claude-code no-op (the standard is its file — every server reportskept), write-layer source-path guards in both merge paths, and byte-compare tests.disabled: truefor claude reports "delete by hand"..mcp.json(missing servers, unknown top-level keys "not carried", dropped${VAR:-default}fallbacks) prints as warnings on stderr — never written, never blocking, never changing the exit code. A missing team source is reported, not degraded to "no gap".Verification
main.command:[null]); codex-only refusal with other targets still written; hand-edited tool-owned slot overwritten + reported; missing precondition loud; re-run byte-identical withkept; disabled→re-enabled two-run regression for both JSON and TOML.ADLC_MOCK_AGENTseam proves the session never started); update aborts before repair (marker-file observable); gap report runs in the chained context.translateEntrydefers todescribeRefusal, agreement matrix cross-products 22 specs × 7 rows including synthetic cross-axis rows.getMcpAgent,__proto__server-name pollution test,setOwn/hasOwnthroughout.Known limitations (deliberate)
init-options.jsonthat records no agent now failsteam updateloudly at the chained install (previously such a hand-maintained file completed) — disclosed in the CHANGELOG.GITIGNORE_RULES_ALLOWLISTflip (tracking.mcp.json) is a recorded obligation in adlc-team-skills (PR #105) — deliberately out of scope here.