Skip to content

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.
  • get health-checks the server, so the probe itself started npx task-master-ai. The CLI waits up to 30 s; the bound was 8 s, and the child outlived it (see BUG-321).

Pattern

  1. Read, never probe. Take the entry from the config file (${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json, top-level mcpServers) with a read-only parser. Nothing starts, nothing is written, and no temp file ever holds the entry.
  2. Whole-object equality against what you actually shipped. Enumerate every object the installer ever wrote (git log -S found 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 absent env counts as {}; an env: null does not). Any extra key, any non-empty env, any other value makes it foreign.
  3. 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 when CLAUDE_CODE_CUSTOM_OAUTH_URL is set, and ./.config.json in the working directory when CLAUDE_CONFIG_DIR is set but empty. Treat every sign of such a divergence as "unreadable".
  4. 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 a skip ("nothing to retire"), not silence.
  5. Remove through the owner's tool, bounded. claude mcp remove -s user under run_bounded; never edit the file yourself. The function returns 0 on every path, because its call site runs under set -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, missing type);
  • 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.

  • 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_bounded leaves a timed-out command's own children running.