Skip to content

revert can leave a config swapped and delete every backup #117

Description

@tony

Summary

scripts/mcp_swap.py revert can finish with exit 0, print restored from … for every entry, delete all backups, and still leave the config holding the swap. It happens whenever two swap-state entries point at the same file: the older snapshot already contains the newer swap, so restoring it last re-applies what was just undone. Because the backups are then removed, the pre-swap config is unrecoverable.

Two ways to reach it, both from the documented flag surface:

  • Claude's user and project scopes both write ~/.claude.json, so revert --scope unwinds one layer at a time in whatever order they are invoked.
  • Any two CLIs whose config paths resolve to one file — for example ~/.cursor/mcp.json symlinked to ~/.gemini/config/mcp_config.json, a normal dotfiles arrangement.

Reproduction

Claude's two scopes, reverted oldest-last. Everything runs under a throwaway HOME:

SB=$(mktemp -d); mkdir -p "$SB/.config"
printf '{\n  "mcpServers": {\n    "other": {"command": "x", "args": ["y"]}\n  }\n}\n' > "$SB/.claude.json"
cp "$SB/.claude.json" "$SB/pristine.json"
run() { HOME="$SB" XDG_CONFIG_HOME="$SB/.config" XDG_STATE_HOME="$SB/.local/state" uv run scripts/mcp_swap.py "$@"; }
run use-local --repo "$PWD" --cli claude --scope project --no-preflight
run use-local --repo "$PWD" --cli claude --scope user --no-preflight
run revert --cli claude --scope project
run revert --cli claude --scope user
diff "$SB/pristine.json" "$SB/.claude.json"; ls "$SB"/.claude.json.bak.* 2>/dev/null || echo NONE

The second trigger needs no --scope. Point two CLIs at one file and revert them together:

ln -s "$SB/.cursor/mcp.json" "$SB/.gemini/config/mcp_config.json"
run use-local --repo "$PWD" --cli cursor --server alpha --no-preflight
run use-local --repo "$PWD" --cli agy --server beta --no-preflight
run revert --cli cursor --cli agy

Expected

revert unwinds every selected entry newest-first by seq_no, so the oldest snapshot — the only one that predates all the swaps — is restored last. A revert that cannot reach that state should refuse and keep the backups rather than report success.

Actual

Both reverts print restored from … and exit 0. ~/.claude.json still carries the libtmux entry under projects.<repo>.mcpServers, and both .bak.mcp-swap-* files are gone. The shared-file variant leaves the first CLI's server name behind in the same way.

Ordering is also not deterministic without --cli: targets is built from a set comprehension, so which CLI unwinds first depends on string-hash randomization.

Environment

Versions
  • libtmux-mcp 0.1.0a20 (v0.1.0a20)
  • Python 3.14.6
  • uv 0.12.1
  • Linux 6.18.33.2 (WSL2)

Evidence

Claude two-scope revert, run against v0.1.0a20
$ run revert --cli claude --scope project
[claude:project] restored from <sandbox>/.claude.json.bak.mcp-swap-20260809203355-project
rc=0
$ run revert --cli claude --scope user
[claude:user] restored from <sandbox>/.claude.json.bak.mcp-swap-20260809203355-user
rc=0
$ diff pristine.json .claude.json
CORRUPT — swap still present: "libtmux"
$ ls .claude.json.bak.*
NONE — all backups deleted
Two CLIs sharing one file via symlink
$ rg -o '"(cursor|agy):user"|"target_path": "[^"]*"' state.json
"cursor:user"
"target_path": "$HOME/.cursor/mcp.json"
"agy:user"
"target_path": "$HOME/.cursor/mcp.json"
$ run revert --cli cursor --cli agy
(2 "restored from" lines, rc=0)
$ diff pristine.json .cursor/mcp.json
CORRUPT — leftover: "alpha"
$ ls .cursor/*.bak.*
NONE

Proposal

SwapEntry.seq_no already documents itself as "the primary LIFO sort key for cmd_revert", and the sort honouring it is correct — it is just applied in the wrong place. In _cmd_revert the entries are filtered per CLI and per scope before being sorted, so seq_no only ever orders entries within one CLI.

Sort the whole selected set by seq_no descending once, then iterate, rather than sorting inside the per-CLI loop. That fixes the shared-file case directly and makes the iteration order deterministic without needing to sort targets separately.

That leaves the scoped case, where the user has asked to unwind only part of a stack. Restoring an older snapshot while a newer one for the same target_path is still outstanding cannot be done safely from a whole-file backup. revert --scope should refuse when a higher-seq_no entry shares its target_path, naming the entry that has to go first.

The module docstring's claim that "Both Claude scopes can coexist with independent backups" should go with it: the backups are stacked snapshots of one file, not independent.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions