Skip to content

scripts(mcp_swap): add --scope {user,project} for Claude two-layer config#35

Merged
tony merged 21 commits intomainfrom
mcp-swap-user-scope
May 7, 2026
Merged

scripts(mcp_swap): add --scope {user,project} for Claude two-layer config#35
tony merged 21 commits intomainfrom
mcp-swap-user-scope

Conversation

@tony
Copy link
Copy Markdown
Member

@tony tony commented May 4, 2026

Summary

  • Add --scope {user,project} to scripts/mcp_swap.py's use-local and revert subcommands. --scope user rewrites Claude's top-level mcpServers fallback (every project without an override picks it up); --scope project (the default — preserves pre-flag behaviour) writes the per-project projects[<abs>].mcpServers entry.
  • Migrate state-file schema to v2: keys go from bare cli strings to f"{cli}:{scope}" so a single Claude install can hold both scopes simultaneously with independent backups. v1 entries migrate transparently on load (claude("claude","project"); others → ("<cli>","user")).
  • Fix a real backup-collision bug surfaced by the coexist test — two Claude swaps inside the same second produced identical backup filenames, so the second swap's backup overwrote the first's. Backup suffix now embeds the scope for Claude (non-Claude filenames stay byte-compatible with v1).
  • Document the new flag in scripts/README.md with a "Scope" subsection and the layered-config explanation that motivates it.

Motivation

mcp_swap.py was conservative by design — it only wrote under the current repo's per-project key in ~/.claude.json, leaving every other project's MCP entry untouched. Side-effect: when QA-ing a libtmux-mcp branch from any non-libtmux-mcp directory (e.g. ~/work/cv), Claude kept resolving the user-level fallback to the PyPI release. The flag adds an explicit "yes, swap the global default" path without losing the safe-by-default per-project behaviour.

Changes by area

Script (scripts/mcp_swap.py)

  • Scope literal + helpers: New Scope = Literal["user","project"], plus _normalize_scope (coerces non-Claude scope to "user"), _state_key, and _parse_state_key (the v1→v2 migration shim). All centralised so the rest of the file just threads scope through.
  • get_server / set_server / delete_server: New keyword-only scope parameter (default "project"). Claude branches gate on it; non-Claude branches ignore it. _claude_project_node is unchanged — only called when scope == "project".
  • load_state / save_state / clear_state: Switched from dict[CLIName, SwapEntry] to dict[tuple[CLIName, Scope], SwapEntry]. Migration runs in load_state via _parse_state_key; unknown keys are dropped silently.
  • cmd_status: For Claude, prints separate [claude:user] / [claude:project] lines when both have entries (or one if only one exists). Non-Claude CLIs continue to print one [<cli>] line.
  • cmd_use_local / cmd_revert: Read args.scope, normalise per CLI, key state by (cli, scope). revert without --scope rolls back every recorded scope for the targeted CLIs; with --scope, only that one.
  • build_parser: New --scope argument on use-local and revert with choices=ALL_SCOPES and default=None.
  • Backup suffix: For Claude, embeds the scope to disambiguate within-second collisions. Non-Claude suffix unchanged.

Tests (tests/test_mcp_swap.py)

Test Validates
test_claude_user_scope_writes_top_level_mcpServers --scope user writes mcpServers and creates no projects[<abs>] node
test_claude_user_scope_round_trip_restores_byte_identical swap → revert at user-scope yields byte-identical bytes
test_claude_user_and_project_swaps_coexist_independently Both scopes can swap with separate backups; reverting one leaves the other intact
test_legacy_v1_state_migrates_to_v2_keys Bare-cli v1 keys load as the right (cli, scope) tuple
test_non_claude_scope_user_passes_through_to_global_config --scope is silently coerced for non-Claude CLIs; never produces a phantom (cursor, project)

Two existing tests were updated to use the new (cli, scope) tuple keys (test_codex_adds_block_when_absent_and_revert_removes_it, test_save_state_writes_atomically).

Docs

  • scripts/README.md — new "--scope {user,project} (Claude only)" subsection with the two-layer config explanation, the silent-coerce-for-others rule, and the simultaneous-scopes paragraph. Safety section updated to note the scope-suffixed Claude backups and the v2 schema with v1 migration.
  • CHANGES — entry under ### Development (per project convention: dev tooling is not shipped product surface).

Design decisions

  • Default --scope project: keeps every existing call site — including just mcp-use-local from the libtmux-mcp repo itself — behaviourally identical. The flag is purely additive.
  • Silent coerce for non-Claude CLIs instead of erroring: Codex / Cursor / Gemini have no per-project layer in their config files, so --scope is meaningless for them. Erroring would create friction in scripts that pass --scope user for cross-CLI consistency. _normalize_scope does the coerce in one spot.
  • Tuple keys ((cli, scope)) in memory; cli:scope strings on disk: Tuple gives static guarantees on key shape; the string form is friendlier for the JSON file and stays human-editable.
  • v1 migration in _parse_state_key, not at write time: save_state always emits v2; the migration runs once whenever a v1 file is read, then disappears on the next write. No long-lived "v1-mode" state to leak.
  • Scope in Claude backup suffix; non-Claude unchanged: only Claude can produce same-second swap collisions (two scopes, one config file). Adding the suffix universally would break byte-compatibility with existing v1 *.bak.mcp-swap-<ts> files for users mid-revert.

Verification

The flag-related code paths are all in scripts/mcp_swap.py:

rg -n 'scope' scripts/mcp_swap.py

State-file schema bump is reflected at one constant:

rg -n 'STATE_VERSION' scripts/mcp_swap.py

The two existing tests that reach into the in-memory state dict were updated; no other test file references the swap state shape:

rg -n '("claude"|"codex"|"cursor"|"gemini")\s*[:,].*Swap' tests/

Test plan

  • test_claude_user_scope_writes_top_level_mcpServers — user-scope writes mcpServers, leaves projects[<abs>] untouched
  • test_claude_user_scope_round_trip_restores_byte_identical — round-trip preserves bytes
  • test_claude_user_and_project_swaps_coexist_independently — both scopes coexist; selective revert works
  • test_legacy_v1_state_migrates_to_v2_keys — v1 keys (claude, codex, ...) migrate to (cli, scope) tuples
  • test_non_claude_scope_user_passes_through_to_global_config--scope silently coerced for non-Claude
  • All 18 existing tests/test_mcp_swap.py tests still pass (two updated for new key shape)
  • just ruff — clean
  • just ruff-format — no diff
  • just mypy — strict, zero errors
  • uv run pytest --reruns 0 — full suite green
  • rm -rf docs/_build && just build-docs — same warning baseline as main

Manual QA (real configs, post-backup)

Run from /home/d/work/python/libtmux-mcp after backing up ~/.claude.json / ~/.codex/config.toml / ~/.cursor/mcp.json / ~/.gemini/settings.json to a safe location:

just mcp-status                                                            # baseline shows [claude:user] PyPI + [claude:project] local
uv run scripts/mcp_swap.py use-local --cli claude --scope user --dry-run   # diff preview
uv run scripts/mcp_swap.py use-local --cli claude --scope user             # swap
(cd ~/work/cv && claude mcp list | grep libtmux)                           # → "uv --directory ... run libtmux-mcp"
uv run scripts/mcp_swap.py revert --cli claude --scope user                # revert just user-scope
(cd ~/work/cv && claude mcp list | grep libtmux)                           # → "uvx --no-config libtmux-mcp" (rolled back)
just mcp-status                                                            # project-scope still local

Confirmed end-to-end on this branch. The user-scope swap reaches every project that has no per-project override; reverting it leaves the per-project libtmux-mcp swap intact.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 4, 2026

Codecov Report

❌ Patch coverage is 82.43243% with 26 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.81%. Comparing base (3d60be1) to head (7768b72).

Files with missing lines Patch % Lines
scripts/mcp_swap.py 82.43% 18 Missing and 8 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #35      +/-   ##
==========================================
+ Coverage   83.77%   84.81%   +1.04%     
==========================================
  Files          40       40              
  Lines        2132     2233     +101     
  Branches      270      290      +20     
==========================================
+ Hits         1786     1894     +108     
+ Misses        266      255      -11     
- Partials       80       84       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tony
Copy link
Copy Markdown
Member Author

tony commented May 4, 2026

Code review

Found 1 issue:

  1. cmd_revert without --scope for Claude with both scopes recorded restores in dict-insertion (i.e. swap-chronological) order rather than reverse. The two atomic_write calls target the same ~/.claude.json; the second backup was snapshotted after the first swap, so it contains the first swap's changes. Iterating forward means the second write overwrites the first, leaving the file partially reverted (one scope's swap survives) while state.json is fully cleared. The new test_claude_user_and_project_swaps_coexist_independently only covers selective --scope user revert; the unscoped path is not exercised. Fix: iterate cli_keys in reverse-chronological order so each backup restores the prior state.

print(f"[{label}] no state entry — skip")
continue
for key in cli_keys:
sc_cli, sc_scope = key
entry = state[key]
label = f"{sc_cli}:{sc_scope}" if sc_cli == "claude" else sc_cli
backup = pathlib.Path(entry.backup_path)
dest = pathlib.Path(entry.config_path)
if not backup.exists():
print(f"[{label}] backup missing: {backup}", file=sys.stderr)
continue
if args.dry_run:
print(f"[{label}] would restore {dest} from {backup}")
continue
atomic_write(dest, backup.read_bytes())
print(f"[{label}] restored from {backup}")
reverted.append(key)

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

tony added 16 commits May 6, 2026 19:23
…yer config

Claude's MCP config has two layers — top-level mcpServers (used as a
fallback for any project without an override) and per-project
projects.<abs>.mcpServers. Until now mcp_swap only wrote the per-project
entry under the current repo's path, so projects with no per-project
override (e.g. ~/work/cv) kept seeing the user-level fallback unchanged.

Adds an explicit --scope {user,project} flag on use-local and revert.
Default 'project' preserves pre-flag behaviour. --scope user flips the
top-level fallback so unrelated project directories pick up the swap.
Codex / Cursor / Gemini have no per-project layer in their config files;
the flag is silently coerced to 'user' for them via _normalize_scope.

State-file schema bumps to v2: keys go from bare cli names to
"cli:scope" so a single Claude install can hold simultaneous user-scope
and project-scope swaps with independent backups. v1 entries migrate
transparently in load_state — bare 'claude' becomes ('claude','project')
(only mode that existed); the others become ('<cli>','user').

cmd_status now prints per-scope lines for Claude when both exist —
[claude:user] and [claude:project] — and a single [<cli>] line for the
others.

Updates the two existing tests that reach into the state dict to use
the new (cli, scope) tuple keys; all other tests pass unchanged.
…p disambiguation

Five new tests:

- test_claude_user_scope_writes_top_level_mcpServers — confirms the
  user scope writes to ~/.claude.json's top-level mcpServers and does
  NOT touch the per-project layer.
- test_claude_user_scope_round_trip_restores_byte_identical — swap
  then revert at user scope yields byte-identical bytes.
- test_claude_user_and_project_swaps_coexist_independently — both
  scopes can swap in the same Claude install with separate state
  entries and separate backups; reverting one scope leaves the other
  intact.
- test_legacy_v1_state_migrates_to_v2_keys — v1 state files with bare
  cli keys load as the right (cli, scope) tuple; bare claude becomes
  ('claude', 'project'); other CLIs become user-scope.
- test_non_claude_scope_user_passes_through_to_global_config — the
  flag is silently coerced for non-Claude CLIs; --scope project
  against cursor never produces a phantom (cursor, project) state
  entry.

Also fixes a real bug uncovered by the coexist test: two Claude
swaps in the same second collided on backup filename, so the
second backup overwrote the first. Backup suffix now embeds the
scope for Claude (only — non-Claude filenames stay byte-compatible
with v1 backups).
…tion

Add a "Scope" subsection to scripts/README.md explaining Claude's
two-layer config (top-level mcpServers fallback vs per-project
mcpServers override) and how --scope user vs --scope project select
between them. Note that the flag is silently coerced for non-Claude
CLIs and that both scopes can coexist with independent backups.

Add a Dev scripts bullet under unreleased "What's new" summarising
the new flag and the v1→v2 state-file migration.
mcp_swap.py is dev-support tooling, not shipped product surface — it
helps contributors iterate on the server, but downstream package
consumers never see it. ### What's new is reserved for things a user
would scan a release for; dev scripts go under ### Development.
… complete

cmd_revert iterated cli_keys in dict-insertion (i.e. swap-chronological)
order and applied atomic_write to each backup against the same dest. When
two scopes back the same physical file (Claude user + project), the second
backup contains the first swap's modifications, so forward iteration
overwrote the first restoration with the second backup's contents — the
file ended up half-rolled-back while state.json was fully cleared.

Switch to reversed(cli_keys) so backups are unwound in reverse-registration
order (LIFO). Same discipline as contextlib.ExitStack and atexit: when
layered modifications form a stack, each layer must be peeled off newest
first so it restores the prior layer's state, not the original.

Test added: test_claude_full_revert_unwinds_both_scopes_in_lifo_order
covers two swaps (project then user) followed by a no-scope revert and
asserts byte-identical original. Confirmed to fail on the unfixed code
and pass with the fix.
…rite

set_server's user-scope branch did config.setdefault("mcpServers", {})
without first checking the shape. A non-mapping top-level mcpServers
(scalar, list, malformed Claude release) would surface as an opaque
AttributeError from .setdefault() rather than the actionable RuntimeError
that the project-scope path already raises via _claude_project_node.

Add the same isinstance check + RuntimeError pattern that the
project-scope path uses, so the failure mode is symmetric. Mirrors
hatchling's pre-mutation config validation in builders/config.py and
flit's typed-error pattern in flit_core/flit_core/config.py.

Test test_claude_user_scope_rejects_non_mapping_mcpServers covers the
guard; pairs with the existing test_claude_project_node_rejects_non_mapping_*
tests so the shape-validation contract reads symmetrically across both
Claude scopes.
The module docstring's "Scope" section was specifically about scoping
behaviour (which configs are walked, which are ignored, how Claude's
per-project layer fits in) but did not mention the new --scope flag
that this branch added. A reader skimming the docstring would not
know the user-level fallback layer is now writeable.

Add a "Claude scope" bullet covering: the project / user trade-off,
the silent coercion for non-Claude CLIs, the coexistence guarantee,
and the LIFO unwind discipline. The bullet sits next to the existing
"Global configs only" bullet so the layered config narrative reads
top-to-bottom.

scripts/README.md and the --scope argparse help already cover this;
the docstring update makes the in-file narrative consistent with
both.
The comment "No --cli filter:" sat directly above a line that DOES
honour args.cli when supplied — only the else branch is "no filter".
A reader scanning for CLI-filter logic would expect the line to ignore
args.cli entirely, which is the opposite of what it does.

Reword as "Without --cli, …" so the caveat is clearly scoped to the
fallback path, not the whole expression.
…symmetric guard

The previous round of follow-ups added a shape guard inline at the
write site of set_server's user-scope Claude branch, but get_server
and delete_server's user-scope branches still reached into
config.get("mcpServers", {}) without one. A non-mapping mcpServers
would raise an opaque AttributeError on read or a TypeError on delete
instead of the actionable RuntimeError the project-scope path gives
via _claude_project_node.

Extract _claude_user_servers as a small companion helper to
_claude_project_node. Both now centralise the shape guard once and
serve all three of read / write / delete via overloads on a `create:
bool` parameter, mirroring the pattern Flit uses (`_check_type`
extracted as a utility) and CPython's configparser uses (uniform
`_sections[section]` access across get/set/remove).

Tests: add the read- and delete-side companions to the existing
test_claude_user_scope_rejects_non_mapping_mcpServers so all three
paths raise the same actionable RuntimeError. The existing test stays
in place; the two new ones land beside it so the symmetric coverage
reads top-to-bottom.
… LIFO order

cmd_revert's LIFO unwind used reversed(cli_keys) where cli_keys was
built by iterating the state dict — making correctness depend on a
chain of implicit guarantees (Python 3.7+ dict insertion order →
json.dumps preservation → json.loads round-trip preservation). None
of these were documented or asserted. CPython's contextlib.ExitStack
(the precedent the LIFO comment cites) uses an explicit deque with
.pop(), not dict iteration. uv lockfiles .sort_by(id) for
deterministic order. Adopt the same pattern: track a
swapped_at: str = "" timestamp on each SwapEntry and sort
cli_keys by (swapped_at, original_index) DESC for true LIFO.

The original_index secondary key preserves the previous behaviour for
sub-second collisions (two scopes registered in the same second share
the same swapped_at; the larger original index wins, matching the
old reversed() semantics). And the comment update folds in the
"reapplied" → "restored" wording correction surfaced in the same
review pass.

Schema bumps to v3 with transparent migration: v2 entries that lack
swapped_at synthesise it from the timestamp embedded in backup_path
(<config>.bak.mcp-swap-<YYYYMMDDHHMMSS>[-scope]) via the new
_BACKUP_TS_RE / _swapped_at_from_backup pair. The load path also
filters unknown SwapEntry kwargs so a forward-version state file with
extra metadata still loads without crashing.

Tests: three new tests cover (a) swapped_at is populated by
use-local, (b) v2 → v3 migration synthesises the field from
backup_path, and (c) revert sorts by swapped_at even when the state
file's dict order is deliberately wrong. The pre-existing
test_claude_full_revert_unwinds_both_scopes_in_lifo_order continues
to pass since same-second collisions are handled by the secondary
index key.
Compatibility layers were accumulating: a regex parser for legacy
backup_path-derived timestamps, a kwargs filter for forward-compat,
a v1-bare-cli branch in _parse_state_key, a swapped_at synthesis
block in load_state, a STATE_VERSION constant whose docstring
narrated three schema versions. The script is dev tooling under
scripts/ — no published API contract — so we declare no-compat and
strip it all.

Drops:
- _BACKUP_TS_RE and _swapped_at_from_backup (regex + helper)
- The legacy bare-cli branch in _parse_state_key (only v3 cli:scope
  form parses now)
- The kwargs filter in load_state (SwapEntry(**v) directly)
- The "if not entry.swapped_at" migration block
- STATE_VERSION constant + its multi-version docstring
- The unused `import re`
- Two migration tests:
  test_legacy_v1_state_migrates_to_v2_keys
  test_legacy_v2_state_migrates_swapped_at_from_backup_path

Marks SwapEntry.swapped_at as a required field (no default) so
forgetting to populate it would now be a programming error caught
at instantiation. Updates load_state docstring to declare "schema
is internal — no compatibility contract." Test
test_save_state_writes_atomically updated to pass swapped_at
explicitly per the new required-field contract.

Local state on this machine was reverted and ~/.local/state/libtmux-mcp-dev/swap/
deleted before this commit so the schema change starts clean.

Net: -90 lines / +20 lines. The CHANGES + scripts/README.md
updates land in a follow-up commit.
…ed.py precedent

cmd_revert previously sorted by (swapped_at, original_index) with
reverse=True, where original_index derived from dict iteration order
through the JSON round-trip — exactly the implicit dependency the
preceding LIFO comment claimed to have eliminated. Same-second
collisions (two scopes registered in one second) fell back entirely
to dict order.

Add a required seq_no: int field on SwapEntry. cmd_use_local computes
the next value as max(existing_seq_nos, default=-1) + 1 — strictly
monotonic per swap, immune to wall-clock collisions, JSON parse
order, and hand-edited state. cmd_revert now sorts by seq_no DESC
alone, dropping the enumerate() plumbing and the (swapped_at,
original_index) tuple key. The LIFO comment becomes honestly true.

Same explicit-counter pattern CPython's Lib/sched.py uses:
    Event = namedtuple('Event', 'time, priority, sequence, ...')
    self._sequence_generator = count()
swapped_at stays as a human-readable timestamp for anyone inspecting
state.json directly; seq_no enforces sort order.

Tests:
- test_use_local_populates_swapped_at → split into
  test_use_local_populates_swapped_at_and_seq_no (asserts both
  fields populated) and test_seq_no_increments_across_swaps
  (asserts second swap's seq_no = first + 1).
- test_lifo_revert_orders_by_swapped_at_not_dict_iteration →
  renamed to test_lifo_revert_orders_by_seq_no_not_dict_iteration;
  state-file seed now includes explicit seq_no values; assertion
  unchanged (file restores to original).
- test_save_state_writes_atomically updated to pass seq_no.
…2/v3 talk

Both the CHANGES Development entry and the scripts/README.md Safety
section referenced "v2 schema" / "v1 migration" — narratives that
were stale even before the round-2 work bumped to v3, and irrelevant
now that all migration code is gone (no-compat dev tooling).

Replace with what actually shipped: --scope flag (with both scopes
coexisting), centralised shape validation via _claude_user_servers /
_claude_project_node helpers, and explicit seq_no LIFO ordering. Note
that the schema is internal — no compatibility contract; running
older mcp_swap against newer state.json is undefined behaviour.
…e claim

The cmd_revert LIFO comment listed "hand-edited state" alongside
"JSON parse order", "dict iteration", and "wall-clock collisions" as
things the seq_no sort is independent of. That overclaimed: load_state
doesn't validate seq_no's type or values, so a hand-edited state.json
with seq_no="foo" or duplicates would crash or misorder.

Drop "hand-edited state" from the independence list and add a sentence
clarifying that hand-edits to seq_no values are outside the guarantee.
The remaining claims (JSON parse order, dict iteration, wall-clock
collisions) are actually true.
After commit 874dbcb stripped all migration code, the script no
longer carries any "v1" / version-tracking vocabulary — except this
one comment in cmd_use_local that still referenced "byte-compatible
with v1 backups". The intent was that non-Claude backup filenames
keep the un-suffixed shape, not introduce a scope suffix.

Restate without the version language: "Non-Claude backup filenames
carry no scope suffix." Same observable behaviour, no orphaned
vocabulary.
…ion"

The "Out of scope: Custom binary install locations" bullet referenced
"post-migration paths (~/.claude/local/claude, ~/.gemini/local/gemini)"
— meaning the install layouts users land on after Claude / Gemini
moved away from npm-global. The word "migration" reads ambiguously
now that the script's own state-schema migration code has been
stripped: a fresh reader sees "migration" and pattern-matches to the
recently removed compat layer.

Restate as "the canonical local-install layouts" — same paths, no
"migration" word, no ambiguity. Pure terminology cleanup; no
behavioural change.
@tony tony force-pushed the mcp-swap-user-scope branch from 688cdc5 to 7d98e58 Compare May 7, 2026 00:23
tony added 5 commits May 6, 2026 19:32
…— no traceback

set_server / get_server raise RuntimeError from the Claude shape guards
(_claude_user_servers, _claude_project_node) when ~/.claude.json's
mcpServers or projects key is not the expected mapping shape. The error
message is actionable, but cmd_use_local's per-CLI try/except wrapped
only atomic_write + _revalidate, so the shape-guard exception
propagated past it and surfaced as a Python traceback at the CLI
boundary.

Add a per-CLI try/except RuntimeError around the config-prep region
(read + load_config + get_server + set_server + dump) in both
cmd_use_local and cmd_status. Same per-CLI continuation pattern the
existing write-failure handler uses. Pattern follows pytest's
main-level UsageError formatter
(_pytest/config/__init__.py:168-218): catch the *domain* exception
specifically (RuntimeError), not Exception broadly — that would mask
bugs.

Tests: a malformed top-level mcpServers triggers exit 1 with the
RuntimeError message tagged [claude:user] on stderr (no traceback);
status continues to print other CLIs' lines when one CLI's config is
corrupt.
…e load

cmd_revert sorts cli_keys by SwapEntry.seq_no for LIFO ordering. If a
hand-edited state.json had a non-int seq_no in one of two same-CLI
entries (e.g. claude:user + claude:project), the sort raised
"TypeError: '<' not supported between instances of 'int' and 'str'"
and the entire revert aborted with a traceback. Failure mode was
asymmetric: cross-CLI buckets (cursor + gemini) are length-1 and
never invoke comparison, so the same corruption silently worked
there. The cmd_revert comment acknowledged the gap explicitly
("hand-edited state.json with corrupted counter values is outside
the guarantees here") rather than fixing it.

Validate seq_no at load time. New helper _parse_state_entry coerces
seq_no via int() and returns None for any KeyError / TypeError /
ValueError; load_state drops the entry. Pattern follows CPython
sched.py's discipline (Lib/sched.py:73,138): validate at the
counter's *origin* (enterabs/load_state), not at sort time. Extends
the existing _parse_state_key drop-on-malformed pattern so the
script's resilience model stays uniform — schema is internal, no
compatibility contract, silent drop on parse failure.

The cmd_revert sort comment is updated to reflect that seq_no is
now always int-vs-int by the time the sort runs.

Tests: a non-coercible seq_no string drops the entry without
crashing; a numeric-string seq_no is coerced rather than dropped
(distinguishes "user typed quotes around the number" from "user
typed something non-numeric"); missing required fields drop the
entry; a same-CLI two-scope state with one corrupt entry no longer
raises TypeError on revert.
cmd_revert restored each CLI's config from its .bak.mcp-swap-<ts>[-scope]
backup but never deleted the backup file. Repeated swap/revert cycles
left ~/.claude.json.bak.* and friends accumulating indefinitely —
particularly visible for Claude where each scope swap writes its own
scope-suffixed backup. clear_state cleaned up state.json entries; no
parallel discipline existed for the backup files.

Delete on success, keep on error — the standard idiom from CPython's
tempfile.NamedTemporaryFile (Lib/tempfile.py:614-618). The new
backup.unlink() runs only after atomic_write succeeds, so a failed
revert (e.g. dest unwritable mid-restore) still leaves the backup on
disk for post-mortem. The dry-run path was already a `continue`
before the restore, so it naturally skips the unlink as well.

Tests: the backup file is gone after a successful revert; --dry-run
preserves it. Both lock the lifecycle invariant against future
refactors of the loop body.
use-local and revert both accept --scope {user,project}; status was
the asymmetric outlier — it always printed both Claude scope lines
when both had entries. For someone QA-ing only the user-level
fallback (e.g. across many unrelated repos), or only the per-project
override for the current repo, scoped status output is useful.

Add --scope to the status argparse with the same shape and help
text used on use-local / revert. cmd_status gates the user-scope
and project-scope get_server reads on args.scope (lazy reads — skip
the get_server call entirely for the filtered-out scope so a
malformed projects node doesn't raise when the user only asked
about user scope). Non-Claude CLIs ignore the flag (their config
has no per-project layer). When the selected scope has no entry,
the "no entry" line is scope-tagged ([claude:user] no entry for
...) for symmetry with use-local / revert output.

Tests: --scope user filters out project, --scope project filters
out user, default behaviour unchanged, --scope is a no-op for
non-Claude CLIs, scope-tagged "no entry" line appears when the
selected layer is missing.
@tony tony merged commit 7747d16 into main May 7, 2026
9 checks passed
@tony tony deleted the mcp-swap-user-scope branch May 7, 2026 01:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants