Skip to content

Commit bb9917f

Browse files
committed
scripts(feat[mcp_swap]): add cross-CLI MCP config detect/swap/revert tool
why: when developing an MCP server locally, each agent CLI (Claude, Codex, Cursor, Gemini) needs its config pointed at the checkout so uv run picks up source edits editable-style. Doing that by hand across four config formats (three JSON shapes + TOML) is fragile — Codex's TOML in particular needs comment/order preservation that sed/jq can't offer. A scripted swap with timestamped backups + a state file makes the flow reversible and reproducible across any MCP repo, not just this one. what: - Add scripts/mcp_swap.py as a PEP 723 single-file script modeled on ~/scripts/py/agentex_mcp.py: inline uv deps (tomlkit>=0.13), CLIName = Literal["claude","codex","cursor","gemini"], per-CLI get/set/delete on the MCP server entry. Subcommands: detect, status, use-local, revert. Server name + entry command auto-derived from the repo's pyproject.toml (libtmux-mcp -> libtmux, first [project.scripts] key for the entry). - Claude's per-project keying (projects."<abs-repo>".mcpServers) is handled explicitly — only the current repo's key is rewritten, leaving other projects untouched. Claude's full entry shape is preserved (type/env fields). - Codex TOML edits go through tomlkit so [notice] blocks, top-level comments, and sibling tables survive the round-trip. When no libtmux entry exists yet (common for Codex), use-local records action="added" so revert correctly removes the block. - Safety: atomic tempfile+os.replace writes, timestamped .bak.mcp-swap-<ts> backups (never overwrites existing .bak.*), post- write re-parse validation with rollback on failure, --dry-run that prints unified diffs and writes nothing, state file at ~/.local/state/libtmux-mcp/mcp_swap.json tracking per-CLI backup paths. - Add scripts/README.md with usage + extension notes (add an entry to CLIS and extend the three per-CLI branches). - Add 12 tests in tests/test_mcp_swap.py using importlib.util to load the out-of-tree script: JSON round-trip (cursor/gemini), Claude per-project keying, Codex comment preservation + add-then-revert, unrelated-server preservation, --dry-run writes nothing, second-swap-is-noop, state-file cleared on full revert, spec helpers. - Add four [group: 'mcp'] recipes to justfile: mcp-detect, mcp-status, mcp-use-local, mcp-revert. - Add tomlkit>=0.13 to the dev dependency group so pytest can import the PEP 723 script without uv run bootstrapping.
1 parent b86e5c0 commit bb9917f

6 files changed

Lines changed: 1091 additions & 0 deletions

File tree

justfile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,26 @@ watch-mypy:
119119
format-markdown:
120120
prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES
121121

122+
# Detect which CLI agents (claude/codex/cursor/gemini) exist on this machine
123+
[group: 'mcp']
124+
mcp-detect:
125+
uv run scripts/mcp_swap.py detect
126+
127+
# Show how each detected CLI resolves this MCP server today
128+
[group: 'mcp']
129+
mcp-status *args:
130+
uv run scripts/mcp_swap.py status {{ args }}
131+
132+
# Rewrite each detected CLI's config to run this checkout (editable)
133+
[group: 'mcp']
134+
mcp-use-local *args:
135+
uv run scripts/mcp_swap.py use-local {{ args }}
136+
137+
# Restore each CLI's config from the backup written by mcp-use-local
138+
[group: 'mcp']
139+
mcp-revert *args:
140+
uv run scripts/mcp_swap.py revert {{ args }}
141+
122142
[private]
123143
_entr-warn:
124144
@echo "----------------------------------------------------------"

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ dev = [
7070
"pytest-watcher",
7171
"pytest-xdist",
7272
"syrupy>=5.1.0",
73+
# scripts/mcp_swap.py (PEP 723 script; dep listed here so tests can import it)
74+
"tomlkit>=0.13",
7375
# Coverage
7476
"codecov",
7577
"coverage",

scripts/README.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# scripts/
2+
3+
Developer utilities shipped with the repo but not part of the installed
4+
package.
5+
6+
## `mcp_swap.py`
7+
8+
Swap the libtmux MCP server entry across every detected agent CLI
9+
(Claude Code, Codex, Cursor, Gemini) so all four run the **local checkout**
10+
instead of a pinned PyPI release. Useful when testing a branch or working
11+
on the server itself.
12+
13+
### Usage
14+
15+
From the repo root:
16+
17+
```console
18+
$ uv run scripts/mcp_swap.py detect # which CLIs are installed?
19+
$ uv run scripts/mcp_swap.py status # what does each point at today?
20+
$ uv run scripts/mcp_swap.py use-local --dry-run
21+
$ uv run scripts/mcp_swap.py use-local # rewrite configs (with backups)
22+
$ uv run scripts/mcp_swap.py revert # restore from backups
23+
```
24+
25+
Or via `just`:
26+
27+
```console
28+
$ just mcp-detect
29+
$ just mcp-status
30+
$ just mcp-use-local --dry-run
31+
$ just mcp-use-local
32+
$ just mcp-revert
33+
```
34+
35+
### What `use-local` does
36+
37+
For each detected CLI, the libtmux entry (or equivalent — derived from
38+
`pyproject.toml` project name, trailing `-mcp` stripped) is rewritten to:
39+
40+
```
41+
command = "uv"
42+
args = ["--directory", "<repo-abs-path>", "run", "libtmux-mcp"]
43+
```
44+
45+
This matches Claude's conventional dev form and takes advantage of `uv
46+
run`'s automatic editable install — source edits flow through on the next
47+
invocation with no reinstall step.
48+
49+
### Safety
50+
51+
- Every rewrite writes a timestamped backup (`<config>.bak.mcp-swap-<ts>`)
52+
before touching the file.
53+
- State is tracked in `~/.local/state/libtmux-mcp/mcp_swap.json` so
54+
`revert` knows which backup to restore per CLI, including the "added"
55+
case where Codex had no libtmux block before.
56+
- Writes are atomic (tempfile + `os.replace`) and re-validated by
57+
re-parsing; a bad write is rolled back immediately.
58+
- `--dry-run` prints a unified diff and writes nothing.
59+
60+
### Scope
61+
62+
Covers four CLIs and their canonical config paths:
63+
64+
| CLI | Config | Format |
65+
|--------|-------------------------------|--------|
66+
| Claude | `~/.claude.json` | JSON (per-project keying) |
67+
| Codex | `~/.codex/config.toml` | TOML (format-preserving via `tomlkit`) |
68+
| Cursor | `~/.cursor/mcp.json` | JSON |
69+
| Gemini | `~/.gemini/settings.json` | JSON |
70+
71+
Claude's config is keyed per-project under the repo's absolute path — the
72+
script writes only under the current repo's key, leaving other projects'
73+
entries untouched.
74+
75+
### Extending to a new CLI
76+
77+
Add an entry to the `CLIS` table in `mcp_swap.py` and extend the three
78+
per-CLI branches in `get_server` / `set_server` / `delete_server`. Tests
79+
in `tests/test_mcp_swap.py` use a `fake_home` fixture that monkeypatches
80+
`CLIS`, so the extension pattern is already established.

0 commit comments

Comments
 (0)