Files
Claude-Code-Monitor/docs/superpowers/plans/2026-08-05-mcp-sync.md
T
nntrivi2001 d25a20089a docs(lanes): plan F1 — ccam lanes mcp sync (F)
4 tasks: the lane-mcp.js sync core (relocate + pin + seed profiles, no
permission/settings writes per the design spec's scope decision), the
route, the CLI, and docs.
2026-08-05 15:14:31 +07:00

25 KiB

F1 — ccam lanes mcp sync Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: ccam lanes mcp sync writes a lane's .mcp.json from its source repo's already-configured MCP servers (relocating paths, pinning Playwright's proof output dir) and seeds Chromium browser profiles, so SKILL.md's manual-fallback text stops being the only option.

Architecture: One filesystem/JSON-only core module (server/lib/lane-mcp.js — no git calls, unlike E2/E3's cores), one route, one CLI subcommand, a SKILL.md edit.

Tech Stack: Plain node:fs/node:path/node:os. No new dependency.

Global Constraints

  • Every applicable source file (.js) MUST start with the project's authorship header — verify with bash .claude/skills/file-headers/scripts/check-headers.sh.
  • No permission/settings writes of any kind. This task never touches <lane>/.claude/settings.local.json or any permission/auto-approval surface — that's out of scope by explicit design decision (see the design spec's Scope section), not an oversight to "complete later" in this plan.
  • .mcp.json is a full overwrite on every call; profile seeding is additive-only. A profile directory that already exists at the destination is never touched (an existing profile means a session already logged in there).
  • server/lib/cc-discovery.js already reads ~/.claude.json (its readMcpServers(), used by the Claude Config Explorer page) — but it REDACTS secret-like keys and summarizes server defs for display, so it cannot be reused here; lane-mcp.js needs the raw, unredacted server config to write a working .mcp.json. Do not attempt to reuse readMcpServers(); write a small local reader instead (a few lines — see Task 1).
  • Never use git add -A. Stage exactly the files each task names.
  • Run npm run test:server (full suite) plus bash .claude/skills/file-headers/scripts/check-headers.sh before every commit.

Task 1: server/lib/lane-mcp.js — the sync core

Files:

  • Create: server/lib/lane-mcp.js
  • Test: server/__tests__/lane-mcp.test.js

Interfaces:

  • Produces: syncMcp(lane) => {servers: string[], profilesSeeded: string[]} where lane is {cwd, source_repo} (only these two fields are read). Throws an error with .code === "ENOMCPCONFIG" when the source repo has no mcpServers configured in ~/.claude.json.

  • Step 1: Write the failing tests — reading and relocating

Create server/__tests__/lane-mcp.test.js:

/**
 * @file Tests for server/lib/lane-mcp.js: relocating a source repo's
 * ~/.claude.json MCP server config into a lane's own .mcp.json, pinning
 * Playwright's proof output dir, and seeding Chromium profiles. Uses a real
 * temp $HOME (via process.env.HOME override) so the module's own
 * os.homedir()-based path resolution is exercised, not mocked around.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");

const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-mcp-"));
const FAKE_HOME = path.join(SUITE_ROOT, "home");
fs.mkdirSync(FAKE_HOME, { recursive: true });
process.env.HOME = FAKE_HOME;

const laneMcp = require("../lib/lane-mcp");

after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));

function writeClaudeJson(projects) {
  fs.writeFileSync(
    path.join(FAKE_HOME, ".claude.json"),
    JSON.stringify({ projects }, null, 2)
  );
}

let laneSeq = 0;
function makeLane(sourceRepo) {
  laneSeq += 1;
  const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`);
  fs.mkdirSync(cwd, { recursive: true });
  return { cwd, source_repo: sourceRepo };
}

describe("syncMcp — reading and relocating", () => {
  it("throws ENOMCPCONFIG when the source repo has no mcpServers", async () => {
    const sourceRepo = path.join(SUITE_ROOT, "src-none");
    fs.mkdirSync(sourceRepo, { recursive: true });
    writeClaudeJson({ [sourceRepo]: {} });
    await assert.rejects(() => laneMcp.syncMcp(makeLane(sourceRepo)), {
      code: "ENOMCPCONFIG",
    });
  });

  it("throws ENOMCPCONFIG when ~/.claude.json doesn't exist at all", async () => {
    fs.rmSync(path.join(FAKE_HOME, ".claude.json"), { force: true });
    await assert.rejects(
      () => laneMcp.syncMcp(makeLane(path.join(SUITE_ROOT, "src-missing"))),
      { code: "ENOMCPCONFIG" }
    );
  });

  it("relocates absolute paths under source_repo to the lane's cwd", async () => {
    const sourceRepo = path.join(SUITE_ROOT, "src-relocate");
    fs.mkdirSync(sourceRepo, { recursive: true });
    writeClaudeJson({
      [sourceRepo]: {
        mcpServers: {
          playwright: {
            command: "npx",
            args: ["-y", "@playwright/mcp@latest", "--user-data-dir", `${sourceRepo}/.playwright-mcp/profiles/default`],
          },
        },
      },
    });
    const lane = makeLane(sourceRepo);
    const result = await laneMcp.syncMcp(lane);
    assert.deepEqual(result.servers, ["playwright"]);

    const written = JSON.parse(fs.readFileSync(path.join(lane.cwd, ".mcp.json"), "utf8"));
    assert.equal(
      written.mcpServers.playwright.args[2],
      `${lane.cwd}/.playwright-mcp/profiles/default`
    );
  });
});
  • Step 2: Run test to verify it fails

Run: node --test server/__tests__/lane-mcp.test.js Expected: FAIL — require("../lib/lane-mcp") throws MODULE_NOT_FOUND.

  • Step 3: Implement lane-mcp.js — reading, relocating, writing .mcp.json

Create server/lib/lane-mcp.js:

/**
 * @file Gives a lane the same MCP servers as its source repo: reads the
 * source repo's already-configured mcpServers from ~/.claude.json (normal
 * Claude Code project-scope config — the human sets this up once, the same
 * way they would for any project), relocates any absolute path under the
 * source repo to the lane's own directory, pins a Playwright server's proof
 * output dir, and writes <lane.cwd>/.mcp.json. Also seeds the lane's
 * Chromium browser profiles from the source repo's own (preserves saved
 * logins — a QA account only needs to log in once per machine).
 *
 * Deliberately does NOT write any permission or settings.local.json content
 * — see the F1 design spec's Scope section for why. Plain file I/O; no git
 * calls, unlike lane-sync.js (E2) or lane-agents.js (E3).
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");

/** Read the source repo's mcpServers from ~/.claude.json. Throws
 *  ENOMCPCONFIG if the file is missing or the project has none declared —
 *  a lane with zero MCP servers synced would silently break Stage 3/6 much
 *  later, at a far less debuggable point, so this fails loud and early. */
function readSourceMcpServers(sourceRepo) {
  const claudeJsonPath = path.join(os.homedir(), ".claude.json");
  let cfg;
  try {
    cfg = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8"));
  } catch {
    cfg = null;
  }
  const servers = cfg?.projects?.[sourceRepo]?.mcpServers;
  if (!servers || !Object.keys(servers).length) {
    throw Object.assign(
      new Error(
        `no mcpServers configured for source repo ${sourceRepo} in ~/.claude.json — ` +
          `configure them there first (see the ship-feature-lane skill's Setup section)`
      ),
      { code: "ENOMCPCONFIG" }
    );
  }
  return servers;
}

/** Deep-walk a server config, replacing every occurrence of `sourceRepo`
 *  inside a string with `laneDir`. Strings, arrays, and plain objects only
 *  — an MCP server def never contains anything else. */
function relocate(value, sourceRepo, laneDir) {
  if (typeof value === "string") return value.split(sourceRepo).join(laneDir);
  if (Array.isArray(value)) return value.map((v) => relocate(v, sourceRepo, laneDir));
  if (value && typeof value === "object") {
    const out = {};
    for (const [k, v] of Object.entries(value)) out[k] = relocate(v, sourceRepo, laneDir);
    return out;
  }
  return value;
}

/** Pin --output-dir for a @playwright/mcp server that doesn't already
 *  declare one, so relative proof filenames (proof/<feature>/...) always
 *  land under <laneDir>/.playwright-mcp — the one place the dashboard's
 *  proof gallery reads. */
function pinPlaywrightOutputDir(servers, laneDir) {
  for (const server of Object.values(servers)) {
    const args = server.args;
    if (!Array.isArray(args)) continue;
    const isPlaywright = args.some((a) => typeof a === "string" && a.startsWith("@playwright/mcp"));
    if (isPlaywright && !args.includes("--output-dir")) {
      server.args = [...args, "--output-dir", path.join(laneDir, ".playwright-mcp")];
    }
  }
}

/** Copy each <sourceRepo>/.playwright-mcp/profiles/<name>/ into the lane
 *  ONLY if that name doesn't already exist there — an existing profile
 *  means a session already logged in with it; never clobber that. Strips
 *  Singleton* lock files from the freshly-seeded copy (stale locks from the
 *  source's own last browser process would make the lane's browser refuse
 *  to start, thinking another instance already holds the profile). */
function seedProfiles(sourceRepo, laneDir) {
  const srcProfiles = path.join(sourceRepo, ".playwright-mcp", "profiles");
  if (!fs.existsSync(srcProfiles)) return [];

  const seeded = [];
  for (const name of fs.readdirSync(srcProfiles)) {
    const src = path.join(srcProfiles, name);
    if (!fs.statSync(src).isDirectory()) continue;
    const dest = path.join(laneDir, ".playwright-mcp", "profiles", name);
    if (fs.existsSync(dest)) continue;

    fs.mkdirSync(path.dirname(dest), { recursive: true });
    fs.cpSync(src, dest, { recursive: true });
    for (const entry of fs.readdirSync(dest)) {
      if (entry.startsWith("Singleton")) fs.rmSync(path.join(dest, entry), { force: true });
    }
    seeded.push(name);
  }
  return seeded;
}

/** Idempotently append a line to <laneDir>/.git/info/exclude — same
 *  read-existing-then-append-if-missing pattern E2/E3 already use for
 *  .git/info/attributes and .git/info/exclude. .mcp.json sits at the lane
 *  root (not inside .git), so this needs no --git-common-dir resolution —
 *  the exclude file itself is always local to this lane's own working copy. */
function excludeFromGit(laneDir, line) {
  const excludePath = path.join(laneDir, ".git", "info", "exclude");
  fs.mkdirSync(path.dirname(excludePath), { recursive: true });
  const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
  const lines = existing.split("\n").filter(Boolean);
  if (!lines.includes(line)) {
    lines.push(line);
    fs.writeFileSync(excludePath, lines.join("\n") + "\n");
  }
}

/**
 * @param {{cwd: string, source_repo: string}} lane
 * @returns {{servers: string[], profilesSeeded: string[]}}
 */
async function syncMcp(lane) {
  const sourceServers = readSourceMcpServers(lane.source_repo);
  const relocated = relocate(sourceServers, lane.source_repo, lane.cwd);
  pinPlaywrightOutputDir(relocated, lane.cwd);

  fs.writeFileSync(
    path.join(lane.cwd, ".mcp.json"),
    JSON.stringify({ mcpServers: relocated }, null, 2) + "\n"
  );
  excludeFromGit(lane.cwd, ".mcp.json");

  const profilesSeeded = seedProfiles(lane.source_repo, lane.cwd);

  return { servers: Object.keys(relocated), profilesSeeded };
}

module.exports = { syncMcp };
  • Step 4: Run tests to verify they pass

Run: node --test server/__tests__/lane-mcp.test.js Expected: PASS — all 3 tests so far.

  • Step 5: Write the failing tests — output-dir pinning, profile seeding, exclude idempotency

Append to server/__tests__/lane-mcp.test.js:

describe("syncMcp — Playwright output-dir pinning", () => {
  it("pins --output-dir only when a @playwright/mcp server doesn't already declare one", async () => {
    const sourceRepo = path.join(SUITE_ROOT, "src-pin");
    fs.mkdirSync(sourceRepo, { recursive: true });
    writeClaudeJson({
      [sourceRepo]: {
        mcpServers: {
          playwright: { command: "npx", args: ["-y", "@playwright/mcp@latest"] },
          "playwright-custom": {
            command: "npx",
            args: ["-y", "@playwright/mcp@latest", "--output-dir", "/already/set"],
          },
          "not-playwright": { command: "npx", args: ["-y", "some-other-mcp"] },
        },
      },
    });
    const lane = makeLane(sourceRepo);
    await laneMcp.syncMcp(lane);
    const written = JSON.parse(fs.readFileSync(path.join(lane.cwd, ".mcp.json"), "utf8"));

    assert.deepEqual(written.mcpServers.playwright.args.slice(-2), [
      "--output-dir",
      path.join(lane.cwd, ".playwright-mcp"),
    ]);
    assert.deepEqual(written.mcpServers["playwright-custom"].args.slice(-2), [
      "--output-dir",
      "/already/set",
    ]);
    assert.equal(written.mcpServers["not-playwright"].args.includes("--output-dir"), false);
  });
});

describe("syncMcp — profile seeding", () => {
  it("seeds a source profile into the lane and strips Singleton* lock files", async () => {
    const sourceRepo = path.join(SUITE_ROOT, "src-profiles");
    const srcProfileDir = path.join(sourceRepo, ".playwright-mcp", "profiles", "default");
    fs.mkdirSync(srcProfileDir, { recursive: true });
    fs.writeFileSync(path.join(srcProfileDir, "Cookies"), "fake-cookie-db");
    fs.writeFileSync(path.join(srcProfileDir, "SingletonLock"), "stale-lock");
    writeClaudeJson({
      [sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } },
    });

    const lane = makeLane(sourceRepo);
    const result = await laneMcp.syncMcp(lane);
    assert.deepEqual(result.profilesSeeded, ["default"]);

    const destDir = path.join(lane.cwd, ".playwright-mcp", "profiles", "default");
    assert.ok(fs.existsSync(path.join(destDir, "Cookies")));
    assert.ok(!fs.existsSync(path.join(destDir, "SingletonLock")));
  });

  it("never overwrites a profile that already exists at the destination", async () => {
    const sourceRepo = path.join(SUITE_ROOT, "src-profiles-2");
    const srcProfileDir = path.join(sourceRepo, ".playwright-mcp", "profiles", "default");
    fs.mkdirSync(srcProfileDir, { recursive: true });
    fs.writeFileSync(path.join(srcProfileDir, "Cookies"), "new-cookie-db");
    writeClaudeJson({
      [sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } },
    });

    const lane = makeLane(sourceRepo);
    const destDir = path.join(lane.cwd, ".playwright-mcp", "profiles", "default");
    fs.mkdirSync(destDir, { recursive: true });
    fs.writeFileSync(path.join(destDir, "Cookies"), "already-logged-in-cookie-db");

    const result = await laneMcp.syncMcp(lane);
    assert.deepEqual(result.profilesSeeded, []);
    assert.equal(fs.readFileSync(path.join(destDir, "Cookies"), "utf8"), "already-logged-in-cookie-db");
  });
});

describe("syncMcp — .git/info/exclude idempotency", () => {
  it("appends .mcp.json once and does not duplicate it on a second call", async () => {
    const sourceRepo = path.join(SUITE_ROOT, "src-exclude");
    fs.mkdirSync(sourceRepo, { recursive: true });
    writeClaudeJson({
      [sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } },
    });
    const lane = makeLane(sourceRepo);
    fs.mkdirSync(path.join(lane.cwd, ".git"), { recursive: true });

    await laneMcp.syncMcp(lane);
    await laneMcp.syncMcp(lane);

    const exclude = fs.readFileSync(path.join(lane.cwd, ".git", "info", "exclude"), "utf8");
    const matches = exclude.split("\n").filter((line) => line === ".mcp.json");
    assert.equal(matches.length, 1);
  });
});
  • Step 6: Run tests to verify they pass

Run: node --test server/__tests__/lane-mcp.test.js Expected: PASS — all 8 tests.

  • Step 7: Header check + full suite
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
  • Step 8: Commit
git add server/lib/lane-mcp.js server/__tests__/lane-mcp.test.js
git commit -m "feat(lanes): add lane-mcp sync core (F1)"

Task 2: POST /api/lanes/:id/mcp/sync route

Files:

  • Modify: server/routes/lanes.js

Interfaces:

  • Consumes: syncMcp(lane) => Promise<{servers: string[], profilesSeeded: string[]}> from Task 1 (require("../lib/lane-mcp")).

  • Produces: POST /api/lanes/:id/mcp/sync200 with {servers, profilesSeeded}, 404 for an unknown lane, 400 with {error: {code: "ENOMCPCONFIG", message}} when the source repo has nothing configured.

  • Step 1: Add the import

In server/routes/lanes.js, add near the lane-agents import added in E3:

const { syncMcp } = require("../lib/lane-mcp");
  • Step 2: Extend sendRuntimeError's bad-request code list

In sendRuntimeError (server/routes/lanes.js, the same function E2 extended with EBADBRANCH/EUNRESOLVED/EMERGEUNCOMMITTED), add ENOMCPCONFIG:

  const badRequest = [
    "ENOPROFILE",
    "ENOHOOK",
    "EBADLANEDIR",
    "EBADSVC",
    "EBADBRANCH",
    "EUNRESOLVED",
    "EMERGEUNCOMMITTED",
    "ENOMCPCONFIG",
  ];
  • Step 3: Add the route

Insert directly after the /:id/agents/install route (server/routes/lanes.js:339, the route E3 added):

/**
 * Give this lane the same MCP servers as its source repo — relocates the
 * source repo's already-configured mcpServers (from ~/.claude.json) into
 * <lane>/.mcp.json, pins Playwright's proof output dir, seeds Chromium
 * profiles. Never automatic, same as proof-link/agents-install: a session
 * calls this explicitly. Never touches permissions/settings.local.json —
 * see the F1 design spec for why.
 */
router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  try {
    res.json(await syncMcp(lane));
  } catch (err) {
    if (err.code === "ENOMCPCONFIG") {
      return res.status(400).json({ error: { code: err.code, message: err.message } });
    }
    res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
  }
});
  • Step 4: Manual smoke check
npm run dev &
sleep 3
# Replace 1 with a real lane id whose source repo has mcpServers configured
# project-scope in ~/.claude.json (or expect a 400 ENOMCPCONFIG if not).
curl -s -X POST http://localhost:4820/api/lanes/1/mcp/sync | head -c 300
echo

Expected: {"servers":[...],"profilesSeeded":[...]} or a 400 ENOMCPCONFIG body — either is correctly-wired, not a bug. Stop the dev server afterward.

  • Step 5: Run the full suite + header check
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
  • Step 6: Commit
git add server/routes/lanes.js
git commit -m "feat(lanes): add POST /:id/mcp/sync route (F1)"

Task 3: ccam lanes mcp sync CLI

Files:

  • Modify: bin/ccam.js

Interfaces:

  • Consumes: POST /api/lanes/:id/mcp/sync (Task 2); resolveLaneArg(args), post(path, body, options) (both already defined in bin/ccam.js).

  • Produces: ccam lanes mcp sync [<id>].

  • Step 1: Add the subcommand function

In bin/ccam.js, add near cmdLanesAgentsInstall (bin/ccam.js:2161):

/** `ccam lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
 *  already-configured MCP servers into <lane>/.mcp.json and seed Chromium
 *  profiles. Never run automatically; a session calls it explicitly. */
async function cmdLanesMcpSync(args) {
  const resolved = await resolveLaneArg(args);
  if (!resolved) return;
  const result = await post(
    `/api/lanes/${resolved.laneId}/mcp/sync`,
    {},
    { allowError: true }
  );
  if (result.status) {
    console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
    process.exitCode = 1;
    return;
  }
  console.log(`${c.green("✔")} synced: ${result.servers.join(", ")}`);
  if (result.profilesSeeded.length) {
    console.log(`  seeded profiles: ${result.profilesSeeded.join(", ")}`);
  }
}
  • Step 2: Wire the dispatcher

In bin/ccam.js's lanes case, right after the agents install check (bin/ccam.js:3186-3188):

      if (rest[0] === "agents" && rest[1] === "install") {
        return cmdLanesAgentsInstall(rest.slice(2));
      }

Add directly below it:

      if (rest[0] === "mcp" && rest[1] === "sync") {
        return cmdLanesMcpSync(rest.slice(2));
      }
  • Step 3: Add the help-catalog entry

Right after the lanes agents install catalog entry (bin/ccam.js:2364 area):

      [
        "lanes mcp sync",
        "[<id>]",
        "Relocate the source repo's MCP servers into <lane>/.mcp.json and seed Chromium profiles",
      ],
  • Step 4: Manual smoke test
npm run dev &
sleep 3
node bin/ccam.js lanes mcp sync 1
echo "exit: $?"

Expected: ✔ synced: ... with exit 0, or a clear error with exit 1. Stop the dev server afterward.

  • Step 5: Run the full suite + header check
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
  • Step 6: Commit
git add bin/ccam.js
git commit -m "feat(lanes): add ccam lanes mcp sync CLI (F1)"

Task 4: Docs

Files:

  • Modify: .claude/skills/ship-feature-lane/SKILL.md
  • Modify: docs/LANES.md
  • Modify: docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md

Interfaces: none — documentation only.

  • Step 1: SKILL.md — replace the manual-fallback text

Find (Setup section, .claude/skills/ship-feature-lane/SKILL.md:37):

- **MCP preflight (fail fast):** confirm this session actually loaded the lane's required Playwright MCPs (their `browser_*` tools must be available) — `playwright` and a local-QC MCP are always required for Stage 3/6. `ccam lanes mcp sync` (F, not built yet) would normally do this for you; until then, tell the human to configure `.mcp.json` manually and restart the session if a required MCP is missing. Catching this at Stage 0 costs a minute; catching it at Stage 13 strands a merged feature unverified.

Replace with:

- **MCP preflight (fail fast):** if this is the lane's first run, or a required Playwright MCP's `browser_*` tools aren't available in this session, run `ccam lanes mcp sync` — it relocates the source repo's already-configured MCP servers into this lane's `.mcp.json`. If the source repo has none configured (`ENOMCPCONFIG`), tell the human to configure `.mcp.json` manually for the source repo first. Either way, restart the session after syncing so the new config loads — `playwright` and a local-QC MCP are always required for Stage 3/6. Catching this at Stage 0 costs a minute; catching it at Stage 13 strands a merged feature unverified.
  • Step 2: docs/LANES.md — add an mcp-sync subsection

In docs/LANES.md, under ## The ship-feature-lane skill (E1), insert a new subsection after "### Installing the QC/gate agents: agents install" (added in E3) and before "### Pipeline template: ship-feature (16 node stages)":

### Syncing MCP servers: mcp sync

A lane needs the same MCP servers (Playwright, a local-QC server) as its source repo to run Stage 3/6. `ccam lanes mcp sync` gives it those:

```bash
ccam lanes mcp sync

Reads the source repo's already-configured mcpServers from ~/.claude.json (normal Claude Code project-scope config — set this up for the source repo once, the same way you would for any project), relocates any absolute path under the source repo to the lane's own directory, pins a @playwright/mcp server's --output-dir to the lane's .playwright-mcp (so proof screenshots land where the proof gallery reads them), and writes <lane>/.mcp.json. Also seeds the lane's Chromium browser profiles from the source repo's own — preserves saved logins, and never overwrites a profile that already exists at the destination.

No permission/settings changes. Unlike Shipyard's original lane-mcp-sync.sh, this command never writes to <lane>/.claude/settings.local.json — no auto-approval rules, no autoMode bypass entries. A session's gh/git push commands go through the normal permission prompt like any other command.

A lane whose source repo has no mcpServers configured gets a clear ENOMCPCONFIG error, not a silently-empty .mcp.json — configure the source repo's MCP servers first, then re-run.

Restart the lane's Claude session after syncing — MCP config is read at session start.


- [ ] **Step 3: Roadmap progress line**

In `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`, find the `## F` section (search `grep -n "^## F "`). It currently has no `**Progress:**` line (F hasn't started). Add one directly under its `**Goal:**` line:

```markdown
**Progress:** `mcp sync` (F1) done 2026-08-05 — see `docs/superpowers/specs/2026-08-05-mcp-sync-design.md`. Tracker, dev-QC, CI-wait remain.
  • Step 4: Verify and commit
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
git add .claude/skills/ship-feature-lane/SKILL.md docs/LANES.md docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md
git commit -m "docs(lanes): document ccam lanes mcp sync (F1)"