Retire what you installed by reading the config, never by probing it¶
Problem¶
SPEC-213 removed the taskmaster-ai MCP server that setup.sh had registered on every install.
Stopping the add was easy. The hard part was cleaning up machines that already had the entry
without touching one the user had added under the same name.
The first design asked the CLI (claude mcp get taskmaster-ai) and compared three fields of its
output. Review pass 1 measured two failures:
- A user entry created with
-e ANTHROPIC_API_KEY=…matched all three fields and was removed, key included. gethealth-checks the server, so the probe itself startednpx task-master-ai. The CLI waits up to 30 s; the bound was 8 s, and the child outlived it (see BUG-321).
Pattern¶
- Read, never probe. Take the entry from the config file (
${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json, top-levelmcpServers) with a read-only parser. Nothing starts, nothing is written, and no temp file ever holds the entry. - Whole-object equality against what you actually shipped. Enumerate every object the
installer ever wrote (
git log -Sfound two: the pinned and the unpinned command). The entry is yours only if it equals one of them. Only a key you never wrote may be defaulted, and only in the exact form you would have written it (an absentenvcounts as{}; anenv: nulldoes not). Any extra key, any non-emptyenv, any other value makes it foreign. - Read the file the CLI will edit, or remove nothing. The CLI chooses its file by rules you do
not control: a legacy
.config.json, a suffix whenCLAUDE_CODE_CUSTOM_OAUTH_URLis set, and./.config.jsonin the working directory whenCLAUDE_CONFIG_DIRis set but empty. Treat every sign of such a divergence as "unreadable". - Fail safe on everything unexpected, and say which case it was. Unreadable file, not a
regular file (a FIFO blocks the reader), bad JSON, JSON the CLI parses differently (Python
accepts
NaN, Node does not, and the CLI then resets the whole file on write), odd shape, reader crash: remove nothing and warn. A file that is truly absent, in a directory that exists, is the only quiet case, and it is askip("nothing to retire"), not silence. - Remove through the owner's tool, bounded.
claude mcp remove -s userunderrun_bounded; never edit the file yourself. The function returns 0 on every path, because its call site runs underset -euo pipefail.
Prevention¶
Guard 127 (scripts/tests/setup/127-taskmaster-retired-only-when-toolkit-authored.sh) drives the
real functions against a stub claude and private HOME/CLAUDE_CONFIG_DIR. It has one case for
each way "owned" could be widened and each fail-safe path, and every case was mutation-tested:
the mutant that loosens that one condition turns exactly its case red. When adding a similar
retirement:
- enumerate the shipped objects from history, not from memory;
- write the foreign cases first (non-empty
env, extra key,env: null, missingtype); - mutate each condition and each fail-safe branch, and check the named case goes red;
- assert the message text, not just the warn/skip counts, or a mis-routed branch passes.
What it cannot close¶
The check and the remove are two steps, and the CLI offers no compare-and-delete. A concurrent edit to the same entry inside that window (well under a second) is not detected.
Related¶
defensive-toolkit-install.md— ownership of files the toolkit writes; this pattern is the same principle for entries inside a file another program owns.exact-count-mirrors-collide-on-merge.md— hit three times while merging SPEC-213's guard.- BUG-321 —
run_boundedleaves a timed-out command's own children running.