### Mode: mcp

<!-- opt-in-capability: mcp-channel -->

**The typed channel, registered in ONE project.** The kit ships a stdio MCP server (`tools/mcp-server.mjs`) exposing the two promptless readers as TYPED tools — `path_inventory` (exists / type / size / line count / listing / a small file's text, many paths in ONE call) and `repo_search` (LITERAL search, the pattern a JSON string). Both take their arguments as named JSON fields rather than as a string handed to a shell, so a pipe, a redirect, a quote or an `||` inside a pattern or a path stays DATA and is never interpreted — they are legal bytes to search for, and there is no shell to read them as operators. Shipping that server does nothing on its own: a client only sees it once the project DECLARES it. This mode is that declaration, and nothing else.

It writes exactly two files:

- **`.mcp.json`** at the project root — the `"agent-workflow"` entry under `mcpServers`: `"type": "stdio"`, `"command": "node"`, and `args` = the **absolute path of the RUNNING kit's** `tools/mcp-server.mjs` (the literal path, resolved at write time — a moved kit fails visibly rather than resolving to something else).
- **`.claude/settings.json`** — `enabledMcpjsonServers` gains `"agent-workflow"` (a project-scoped `.mcp.json` server is inert until it is enabled), and `permissions.allow` gains the two rules derived from the server's own name and tool list: `mcp__agent-workflow__path_inventory` and `mcp__agent-workflow__repo_search`. Both rules are DERIVED, never re-typed — a renamed tool cannot ship a rule that grants nothing.

**Trust posture (state it plainly when asking consent):** the registered server is a **read-only child of your MCP client** — it answers path facts and literal searches over the project root, and exposes no write and no exec API. It runs **outside the Bash sandbox**, exactly as the client itself does, so the sandbox is not what bounds it; the server's own root containment is. The two allow rules make its tool calls promptless — that is the whole velocity gain, and it applies to those two tools only. Registering a server means your client will RUN the named command, which is why the **exact entry is printed before consent is asked**, never described.

Run `node ${CLAUDE_SKILL_DIR}/tools/mcp.mjs [--dry-run | --apply] [--cwd <dir>]`:

1. **`--dry-run` first, always** (the default — changes nothing): prints what each file would gain and the **exact entry it would declare** — the same structured value, re-serialized for the preview rather than copied byte for byte — plus the apply one-liner. The preflight is **READ-ONLY**: an absent `.claude/` is a named state here and is created on `--apply` only — a preview that made a directory would not be a preview.
2. **Ask consent** via **`AskUserQuestion` where supported**, the no-change option first: keep the shell lanes, or register the typed channel — presenting the trust posture above in plain language.
3. **Only on an explicit yes**, re-run with `--apply`. It writes **`.mcp.json` FIRST, then `.claude/settings.json`** — settings that enable a server whose entry is not yet there is a client error on every startup. Both writes are merge-don't-clobber (foreign servers, foreign keys and existing allow rules preserved; a re-apply adds nothing twice) and keep each file's existing EOL. New MCP servers are picked up when the client next starts — unlike the `hook` mode, this is not a hot reload.

4. **Hidden-mode deployments:** after apply, run the hide-footprint reconcile (`node ${CLAUDE_SKILL_DIR}/tools/hide-footprint.mjs --dir <project> --reconcile`) so the registration stays invisible to `git status` — `/.mcp.json` is in the known-footprint registry; every output arm reminds you.

**The two arms that are not a write:**

- **DIFFERING** — `.mcp.json` already carries an `"agent-workflow"` entry that **structurally differs** from ours (another kit copy, a hand-edited path, an added `env`, a `null`). The comparison is deliberately key-order-independent, so a re-serialized identical entry is the SAME registration, while any real difference in what would be launched is a difference. The run **STOPs unwritten, on both lanes**: silently changing what an MCP server launches is exactly what consent must not slide past. The recovery is named — review that entry, then remove or rename it and re-run. The kit never resolves this for you.
- **MASKED** — an OS sandbox injects a **character device** where `.mcp.json` would be (this is the normal condition of a sandboxed session, not a fault; see `${CLAUDE_SKILL_DIR}/references/modes/sandbox-masks.md`). Writing through a device node is refused by construction, so the mode **hands over what to merge, writes nothing, and exits 0**. The two halves of that handoff are worded differently, and the difference is load-bearing: `.claude/settings.json` was **observable — and read where present** — so its body is a real merge already carrying every foreign key it had, while the masked `.mcp.json` was not observable at all, so the mode hands over **the entry alone**, to be merged under `mcpServers` keeping every other server. A whole-file body would name only our server, and pasted as instructed it would delete every server the mask hid. Every other never-committable class (block device, FIFO, socket) takes the same arm. The handoff is scoped to `.mcp.json`: a masked `settings.json` is a **refusal**, because this mode can neither write it nor merge into what it cannot read.

**Refusals (STOP, exit 1, zero writes) — decided BEFORE the handoff, so a mask can never swallow one:** a symlinked project root · a symlinked or **directory** `.mcp.json` / `settings.json` (the atomic publish renames over the path, which would replace the **link itself** — silently destroying a deliberate indirection, while the file the reader was actually looking at goes untouched). Note what is **not** in this list: a device node, FIFO or socket at `.mcp.json` takes the successful MASKED handoff above, not a refusal — only at `settings.json` is that class a refusal, since no handoff is sanctioned there · a symlinked or non-directory `.claude` · a masked `settings.json` · malformed or unreadable JSON in either target · a **managed key of the wrong type** — `mcpServers`, `enabledMcpjsonServers`, `permissions` or `permissions.allow` present but not the shape this mode writes, which read as empty containers would be merged over and silently destroyed (a foreign key of any shape is data and is never judged) · the DIFFERING entry above.

**Reading posture (the residual, stated).** The **container is classified before the file inside it is read**: path resolution follows an *intermediate* symlink (`O_NOFOLLOW` guards only the final component), so a symlinked `.claude` would otherwise deliver a settings file from outside the work tree — into memory, into a verdict, and into a rendered merge body — before anything could refuse it. A foreign container makes its settings target `unreadable` with that reason, and nothing inside it is read. Beyond that, every target is classified by `lstat` **before** any open, so a target decided **foreign** is never followed, never opened and never read: a symlink is refused by class, and a device node or FIFO is named rather than consumed. (A target decided **regular** is of course opened — that is the read.)

The **residual is stated rather than implied**: every guard here is a path check followed by a path read, and Node exposes no `openat`. What is closed is the **static** case — a symlinked container or leaf that is already there is never followed and never read. What is **not** closed is a path that **changes underneath** the guard, and the leaf and the container are not the same problem. The **leaf** is protected as far as a path-based reader can go — the shared reader opens `O_NOFOLLOW`, stats the descriptor and reads through it, so a swapped *symlink* can never be followed; what escapes it is substitution by another *regular file*, which needs the open bound to an earlier inode observation inside that shared primitive (four consumers use it, so it is that leaf's decision, not this mode's). The **container** cannot be closed that way at all: nothing about the leaf's descriptor speaks for the directory the path was resolved through, and that needs directory-relative opening (`openat`), which Node does not expose — a platform limit rather than a missing check. **The bar this mode meets is therefore: a path already foreign is never followed or read, and a target decided foreign is never opened** — nothing here claims more.

**Invariants:** writes ONLY `.mcp.json` + `.claude/settings.json` · **never `settings.local.json`** · never commits · never edits `docs/ai/gates.json`, `lanes.json` or any other declaration · the server itself gains no writer tool from this mode · user MCP scope and other clients (Codex, Cursor) are out of scope — this registers the project scope for Claude Code.

**Out of scope, by name: `disabledMcpjsonServers`.** A server listed there is rejected by the client in **every** mode, so a project can hold the entry, the enable and both rules and still have a dark channel. **This mode does not detect that**, and "registered" therefore means *the three things this mode writes are in place* — never *the client will load it*. The check was built during review and then **subtracted**: honouring a veto means reading it from every scope the client merges, and each scope has its own masked, symlinked, malformed and unreadable states in which a hidden deny still yields a confident answer. Three review rounds each closed one such hole and opened the next. A check that is wrong in states it cannot enumerate is worse than a stated limit — so this is the stated limit, and the queue carries it.

**Reporting and removal.** The read half (`tools/mcp-registration.mjs`) is what the `recommendations` advisor's `mcp-channel` item and `uninstall` both ask; it never edits. `uninstall` reports both seams as **KEEP / report-only** with the exact edit to make by hand — the same posture it takes for every `.claude/settings.json` key.

**Exit codes:** `0` done / dry-run / the hand-apply masked state; `1` a precondition STOP; `2` bad arguments.
