claude-multiacc 2.0.38 → 2.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -107,6 +107,46 @@ class AccountSeedingTests(unittest.TestCase):
107
107
  self.assertTrue((root / "acct-01" / name).is_symlink())
108
108
  self.assertFalse(os.path.lexists(root / "acct-99" / name))
109
109
 
110
+ def test_seeding_applies_the_pool_mcp_registry(self):
111
+ """The registry reaches a fresh account at seed time; an account's own servers survive."""
112
+ registry = {"version": 1, "retired": [], "projects": {}, "mcpServers": {
113
+ "seeded": {"type": "stdio", "command": "echo", "args": ["hi"], "env": {"K": "v"}}}}
114
+ for provider, name in (("claude", ".claude.json"), ("codex", "config.toml")):
115
+ with self.subTest(provider=provider):
116
+ root = self.home / f".{provider}-accounts"
117
+ root.mkdir(exist_ok=True)
118
+ (root / "accounts.json").write_text(json.dumps({
119
+ "version": 1, "server": "none",
120
+ "accounts": [{"id": "acct-01", "email": "fixture@example.test", "home": "mac"}]}))
121
+ (root / "mcp-servers.json").write_text(json.dumps(registry))
122
+ account = root / "acct-01"
123
+ account.mkdir()
124
+ if provider == "claude":
125
+ (account / name).write_text(json.dumps(
126
+ {"mcpServers": {"own": {"type": "stdio", "command": "true", "args": [], "env": {}}}}))
127
+ else:
128
+ (account / name).write_text('model = "gpt-5"\n\n[mcp_servers.own]\ncommand = "true"\nargs = []\n')
129
+ for _ in range(2):
130
+ self.seed(provider, account)
131
+ text = (account / name).read_text()
132
+ if provider == "claude":
133
+ servers = json.loads(text)["mcpServers"]
134
+ self.assertEqual(servers["seeded"]["command"], "echo")
135
+ self.assertIn("own", servers)
136
+ else:
137
+ self.assertIn("[mcp_servers.seeded]", text)
138
+ self.assertIn('command = "echo"', text)
139
+ self.assertIn("[mcp_servers.own]", text)
140
+ self.assertIn('model = "gpt-5"', text)
141
+ self.assertTrue((account / ".mcp-applied").exists())
142
+
143
+ def test_seeding_without_a_registry_touches_no_config(self):
144
+ for provider, _, name in PROVIDERS:
145
+ with self.subTest(provider=provider):
146
+ account = self.seed(provider)
147
+ self.assertFalse((account / ".mcp-applied").exists())
148
+ self.assertFalse((self.home / f".{provider}-accounts" / "mcp-servers.json").exists())
149
+
110
150
 
111
151
  if __name__ == "__main__":
112
152
  unittest.main()
@@ -0,0 +1,597 @@
1
+ #!/usr/bin/env python3
2
+ """lib/mcp_registry.py: one registry, reconciled into every account, for both providers."""
3
+
4
+ import contextlib
5
+ import importlib.util
6
+ import io
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import stat
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import unittest
15
+
16
+ REPO = Path(__file__).resolve().parents[1]
17
+ LIB = REPO / "lib" / "mcp_registry.py"
18
+ HAS_TOMLLIB = sys.version_info >= (3, 11)
19
+
20
+
21
+ def _load():
22
+ spec = importlib.util.spec_from_file_location("mcp_registry", LIB)
23
+ module = importlib.util.module_from_spec(spec)
24
+ spec.loader.exec_module(module)
25
+ return module
26
+
27
+
28
+ R = _load()
29
+ STDIO = {"type": "stdio", "command": "npx", "args": ["-y", "appinspire-mcp@latest", "serve"],
30
+ "env": {}}
31
+ HTTP = {"type": "http", "url": "https://mcp.example/mcp",
32
+ "headers": {"Authorization": "Bearer ${TOKEN}", "X-Plain": "1", "X-Env": "${OTHER}"}}
33
+
34
+
35
+ class RegistryCase(unittest.TestCase):
36
+ def setUp(self):
37
+ self.work = tempfile.TemporaryDirectory()
38
+ self.addCleanup(self.work.cleanup)
39
+ self.root = Path(self.work.name) / "pool"
40
+ self.root.mkdir()
41
+ (self.root / "accounts.json").write_text(json.dumps({
42
+ "version": 1, "accounts": [{"id": "acct-01"}, {"id": "acct-02"}, {"id": "acct-99"}]}))
43
+ for account in ("acct-01", "acct-02"):
44
+ (self.root / account).mkdir()
45
+
46
+ def acct(self, account="acct-01"):
47
+ return str(self.root / account)
48
+
49
+ def run_cli(self, provider, *argv, stdin=None):
50
+ out, err = io.StringIO(), io.StringIO()
51
+ old_stdin = sys.stdin
52
+ if stdin is not None:
53
+ sys.stdin = io.StringIO(stdin)
54
+ try:
55
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
56
+ rc = R.main(["--root", str(self.root), "--provider", provider, *argv])
57
+ finally:
58
+ sys.stdin = old_stdin
59
+ return rc, out.getvalue(), err.getvalue()
60
+
61
+ def claude_doc(self, account="acct-01"):
62
+ return json.loads((self.root / account / ".claude.json").read_text())
63
+
64
+ def registry(self):
65
+ return json.loads((self.root / "mcp-servers.json").read_text())
66
+
67
+ def overlay(self):
68
+ return json.loads((self.root / "mcp-servers.local.json").read_text())
69
+
70
+
71
+ class EffectiveMergeTests(RegistryCase):
72
+ def test_registry_wins_over_overlay_both_ways(self):
73
+ registry = R._normalize_section({"mcpServers": {"a": STDIO}, "retired": ["b"]})
74
+ overlay = {"owners": {"robot": R._normalize_section({"mcpServers": {"b": STDIO, "c": STDIO},
75
+ "retired": ["a", "d"]})}}
76
+ eff = R.effective(registry, overlay)
77
+ self.assertEqual(sorted(eff.servers), ["a", "c"]) # a survives the overlay tombstone
78
+ self.assertEqual(eff.retired, {"b", "d"}) # b is gone despite the overlay
79
+ self.assertNotIn("a", eff.retired)
80
+
81
+ def test_overlay_tombstone_beats_overlay_server_and_user_scope_is_kept_apart(self):
82
+ overlay = {"owners": {"one": R._normalize_section({"mcpServers": {"x": STDIO, "y": STDIO}}),
83
+ "two": R._normalize_section({"retired": ["x"], "retiredUser": ["y"]})}}
84
+ eff = R.effective(R.load_registry(str(self.root)), overlay)
85
+ self.assertEqual(eff.servers, {})
86
+ self.assertEqual(eff.retired, {"x"})
87
+ self.assertEqual(eff.retired_user, {"y"})
88
+
89
+
90
+ class ClaudeReconcileTests(RegistryCase):
91
+ def test_upsert_purges_tombstones_everywhere_and_keeps_extras(self):
92
+ (self.root / "acct-01" / ".claude.json").write_text(json.dumps({
93
+ "numStartups": 3,
94
+ "mcpServers": {"adspower-local-api": STDIO, "mine": STDIO,
95
+ "appinspire-mcp": {"type": "stdio", "command": "old", "args": [], "env": {}}},
96
+ "projects": {"/p": {"mcpServers": {"adspower-local-api": STDIO, "keep": STDIO}}},
97
+ }))
98
+ rc, _, _ = self.run_cli("claude", "add", "appinspire-mcp", "-e", "K=V", "--",
99
+ "npx", "-y", "appinspire-mcp@latest", "serve")
100
+ self.assertEqual(rc, 0)
101
+ rc, _, _ = self.run_cli("claude", "remove", "adspower-local-api")
102
+ self.assertEqual(rc, 0)
103
+ doc = self.claude_doc()
104
+ self.assertEqual(doc["numStartups"], 3)
105
+ self.assertEqual(sorted(doc["mcpServers"]), ["appinspire-mcp", "mine"])
106
+ self.assertEqual(doc["mcpServers"]["appinspire-mcp"],
107
+ {"type": "stdio", "command": "npx",
108
+ "args": ["-y", "appinspire-mcp@latest", "serve"], "env": {"K": "V"}})
109
+ self.assertEqual(sorted(doc["projects"]["/p"]["mcpServers"]), ["keep"])
110
+ self.assertEqual(sorted(self.claude_doc("acct-02")["mcpServers"]), ["appinspire-mcp"])
111
+ self.assertEqual(self.registry()["retired"], ["adspower-local-api"])
112
+ self.assertTrue((self.root / "acct-01" / ".mcp-applied").exists())
113
+
114
+ def test_user_only_tombstone_leaves_project_entries_alone(self):
115
+ (self.root / "acct-01" / ".claude.json").write_text(json.dumps({
116
+ "mcpServers": {"pg": STDIO}, "projects": {"/w": {"mcpServers": {"pg": STDIO}}}}))
117
+ rc, _, _ = self.run_cli("claude", "remove", "pg", "--user-only")
118
+ self.assertEqual(rc, 0)
119
+ doc = self.claude_doc()
120
+ self.assertNotIn("pg", doc["mcpServers"])
121
+ self.assertIn("pg", doc["projects"]["/w"]["mcpServers"])
122
+ self.assertEqual(self.registry()["retiredUser"], ["pg"])
123
+ self.assertEqual(self.registry()["retired"], [])
124
+ # a full remove afterwards moves it to the everywhere list and purges the project too
125
+ self.run_cli("claude", "remove", "pg")
126
+ self.assertEqual((self.registry()["retired"], self.registry()["retiredUser"]), (["pg"], []))
127
+ self.assertNotIn("pg", self.claude_doc()["projects"]["/w"]["mcpServers"])
128
+
129
+ def test_reseed_is_idempotent_and_re_adding_lifts_the_tombstone(self):
130
+ self.run_cli("claude", "remove", "gone")
131
+ rc, out, _ = self.run_cli("claude", "apply", "--all")
132
+ self.assertEqual(rc, 0)
133
+ before = (self.root / "acct-01" / ".claude.json").read_text()
134
+ rc, out, _ = self.run_cli("claude", "apply", "--all")
135
+ self.assertIn("acct-01: unchanged", out)
136
+ self.assertEqual(before, (self.root / "acct-01" / ".claude.json").read_text())
137
+ self.run_cli("claude", "add", "gone", "--", "echo")
138
+ self.assertEqual(self.registry()["retired"], [])
139
+ self.assertIn("gone", self.claude_doc()["mcpServers"])
140
+
141
+ def test_project_scope_creates_the_client_skeleton_and_unions_approvals(self):
142
+ rc, _, _ = self.run_cli("claude", "add", "appinspire", "--scope", "project",
143
+ "--project", "/Users/x/lab", "-e", "APPINSPIRE_LIBRARY_DIR=/lib",
144
+ "--", "node", "/src/bin/appinspire-mcp.mjs", "serve")
145
+ self.assertEqual(rc, 0)
146
+ entry = self.claude_doc()["projects"]["/Users/x/lab"]
147
+ for key in R.PROJECT_SKELETON:
148
+ self.assertIn(key, entry)
149
+ self.assertEqual(entry["mcpServers"]["appinspire"]["env"], {"APPINSPIRE_LIBRARY_DIR": "/lib"})
150
+ self.assertNotIn("appinspire", self.claude_doc()["mcpServers"])
151
+ registry = self.registry()
152
+ registry["projects"]["/Users/x/lab"]["enabledMcpjsonServers"] = ["pj"]
153
+ (self.root / "mcp-servers.json").write_text(json.dumps(registry))
154
+ (self.root / "acct-02" / ".claude.json").write_text(json.dumps({"projects": {
155
+ "/Users/x/lab": {"mcpServers": {}, "enabledMcpjsonServers": ["own"],
156
+ "disabledMcpjsonServers": ["pj"]}}}))
157
+ self.run_cli("claude", "apply", "--all")
158
+ entry = self.claude_doc("acct-02")["projects"]["/Users/x/lab"]
159
+ self.assertEqual(entry["enabledMcpjsonServers"], ["own", "pj"])
160
+ self.assertEqual(entry["disabledMcpjsonServers"], [])
161
+ rc, _, _ = self.run_cli("claude", "remove", "appinspire", "--scope", "project",
162
+ "--project", "/Users/x/lab")
163
+ self.assertNotIn("appinspire", self.claude_doc()["projects"]["/Users/x/lab"]["mcpServers"])
164
+
165
+ def test_project_paths_are_normalized_like_the_client_keys_them(self):
166
+ home = Path(self.work.name) / "home"
167
+ (home / "lab").mkdir(parents=True)
168
+ os.environ["HOME"] = str(home)
169
+ try:
170
+ rc, _, _ = self.run_cli("claude", "add", "x", "--scope", "project", "--project", "~/lab",
171
+ "--", "echo")
172
+ finally:
173
+ os.environ.pop("HOME", None)
174
+ self.assertEqual(rc, 0)
175
+ self.assertEqual(list(self.registry()["projects"]), [os.path.realpath(str(home / "lab"))])
176
+
177
+ def test_codex_only_keys_never_reach_claude_and_types_are_kept_or_inferred(self):
178
+ block = {"command": "npx", "args": ["x"], "startup_timeout_sec": 90, "env_vars": ["A"],
179
+ "codex": {"oauth": {"client_id": "c"}}}
180
+ rc, _, _ = self.run_cli("claude", "add-json", "svc", json.dumps(block))
181
+ self.assertEqual(rc, 0)
182
+ self.assertEqual(self.claude_doc()["mcpServers"]["svc"],
183
+ {"type": "stdio", "command": "npx", "args": ["x"], "env": {}})
184
+ self.run_cli("claude", "add-json", "web", json.dumps({"url": "https://h/mcp"}))
185
+ self.assertEqual(self.claude_doc()["mcpServers"]["web"], {"type": "http", "url": "https://h/mcp"})
186
+ self.run_cli("claude", "add-json", "sock", json.dumps({"type": "ws", "url": "wss://h/ws"}))
187
+ self.assertEqual(self.claude_doc()["mcpServers"]["sock"], {"type": "ws", "url": "wss://h/ws"})
188
+
189
+ def test_http_server_from_the_cli_in_every_argument_order(self):
190
+ rc, _, _ = self.run_cli("claude", "add", "inspo", "--transport", "http",
191
+ "https://inspomcp.dev/api/mcp", "-H", "X-Key: abc")
192
+ self.assertEqual(rc, 0)
193
+ self.assertEqual(self.claude_doc()["mcpServers"]["inspo"],
194
+ {"type": "http", "url": "https://inspomcp.dev/api/mcp", "headers": {"X-Key": "abc"}})
195
+ rc, _, _ = self.run_cli("claude", "add", "two", "--url", "https://two/mcp")
196
+ self.assertEqual(self.claude_doc()["mcpServers"]["two"], {"type": "http", "url": "https://two/mcp"})
197
+ rc, _, _ = self.run_cli("claude", "add", "three", "https://three/mcp", "--transport", "http")
198
+ self.assertEqual(self.claude_doc()["mcpServers"]["three"]["url"], "https://three/mcp")
199
+ with self.assertRaises(R.RegistryError):
200
+ self.run_cli("claude", "add", "four", "https://a", "https://b")
201
+
202
+ def test_writes_are_atomic_and_keep_the_file_mode(self):
203
+ target = self.root / "acct-01" / ".claude.json"
204
+ target.write_text("{}")
205
+ os.chmod(target, 0o640)
206
+ self.run_cli("claude", "add", "s", "--", "echo")
207
+ self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o640)
208
+ self.assertEqual(stat.S_IMODE((self.root / "acct-02" / ".claude.json").stat().st_mode), 0o600)
209
+ self.assertEqual([p.name for p in (self.root / "acct-01").glob(".mcp-registry-*")], [])
210
+
211
+ def test_a_corrupt_account_document_is_reported_not_overwritten_and_exits_3(self):
212
+ (self.root / "acct-01" / ".claude.json").write_text("{not json")
213
+ rc, out, err = self.run_cli("claude", "add", "s", "--", "echo")
214
+ self.assertEqual(rc, R.EXIT_PARTIAL)
215
+ self.assertIn("acct-01", err)
216
+ self.assertIn("mcp apply", err)
217
+ self.assertEqual((self.root / "acct-01" / ".claude.json").read_text(), "{not json")
218
+ self.assertIn("acct-02: updated", out)
219
+ self.assertIn("s", self.registry()["mcpServers"]) # the registry WAS saved
220
+
221
+ def test_missing_manifest_account_dirs_are_skipped(self):
222
+ rc, out, _ = self.run_cli("claude", "add", "s", "--", "echo")
223
+ self.assertEqual(rc, 0)
224
+ self.assertNotIn("acct-99", out)
225
+ self.assertFalse((self.root / "acct-99").exists())
226
+
227
+
228
+ class ValidationTests(RegistryCase):
229
+ def test_malformed_blocks_are_refused_before_the_registry_is_saved(self):
230
+ for block in ({"command": "x", "args": 5}, {"command": "npx", "args": "-y pkg"},
231
+ {"command": "x", "enabled": "false"}, {"command": "x", "startup_timeout_sec": "90"},
232
+ {"command": "x", "startup_timeout_sec": -1}, {"command": "x", "startup_timeout_sec": 1e300},
233
+ {"command": "x", "env": {"A": ["list"]}}, {"type": "carrier", "command": "x"},
234
+ {"command": ""}, {"url": 7}):
235
+ with self.subTest(block=block):
236
+ with self.assertRaises(R.RegistryError):
237
+ self.run_cli("claude", "add-json", "bad", json.dumps(block))
238
+ self.assertFalse((self.root / "mcp-servers.json").exists())
239
+
240
+ def test_a_bad_block_that_slipped_in_skips_only_itself(self):
241
+ self.run_cli("claude", "add", "good", "--", "echo", "ok")
242
+ registry = self.registry()
243
+ registry["mcpServers"]["bad"] = {"command": "x", "args": 5}
244
+ (self.root / "mcp-servers.json").write_text(json.dumps(registry))
245
+ rc, out, err = self.run_cli("claude", "apply", "--all")
246
+ self.assertEqual(rc, 0)
247
+ self.assertIn("bad", err)
248
+ self.assertIn("good", self.claude_doc("acct-02")["mcpServers"])
249
+ self.assertNotIn("bad", self.claude_doc("acct-02")["mcpServers"])
250
+ rc, out, _ = self.run_cli("claude", "list")
251
+ self.assertEqual(rc, 0) # a listing never crashes on it
252
+ self.assertIn("bad", out)
253
+
254
+ def test_names_follow_the_clients_rule_and_remove_is_lenient(self):
255
+ rc, _, _ = self.run_cli("claude", "add", "_dev-1", "--", "echo")
256
+ self.assertEqual(rc, 0)
257
+ for bad in ("../x", "my.server", "a b", "", "-"[1:]):
258
+ with self.subTest(bad=bad):
259
+ with self.assertRaises(R.RegistryError):
260
+ self.run_cli("claude", "add", bad, "--", "echo")
261
+ rc, _, _ = self.run_cli("claude", "remove", "my.server") # learned names can always go
262
+ self.assertEqual(rc, 0)
263
+ self.assertIn("my.server", self.registry()["retired"])
264
+ with self.assertRaises(R.RegistryError):
265
+ self.run_cli("claude", "remove", "a/b")
266
+
267
+ def test_bad_json_and_missing_command_are_refused(self):
268
+ with self.assertRaises(R.RegistryError):
269
+ self.run_cli("claude", "add-json", "x", "{nope")
270
+ with self.assertRaises(R.RegistryError):
271
+ self.run_cli("claude", "add-json", "x", json.dumps({"type": "stdio"}))
272
+ with self.assertRaises(R.RegistryError):
273
+ self.run_cli("claude", "add", "x")
274
+
275
+
276
+ class StampTests(RegistryCase):
277
+ def test_signature_matches_the_shim_contract(self):
278
+ self.run_cli("claude", "add", "s", "--", "echo")
279
+ registry = self.root / "mcp-servers.json"
280
+ config = self.root / "acct-01" / ".claude.json"
281
+ stamp = (self.root / "acct-01" / ".mcp-applied").read_text().strip()
282
+ expected = ",".join([f"{int(registry.stat().st_mtime)}:{registry.stat().st_size}", "-",
283
+ f"{int(config.stat().st_mtime)}:{config.stat().st_size}"])
284
+ self.assertEqual(stamp, expected)
285
+ self.assertEqual(R.stamp_signature(str(self.root), "claude", self.acct()), expected)
286
+ stat_flag = "-f" if sys.platform == "darwin" else "-c"
287
+ fmt = "%m:%z" if sys.platform == "darwin" else "%Y:%s"
288
+ shell = subprocess.run(["stat", "-L", stat_flag, fmt, str(registry), str(config)],
289
+ capture_output=True, text=True, check=True).stdout.split()
290
+ self.assertEqual(shell, [expected.split(",")[0], expected.split(",")[2]])
291
+
292
+
293
+ class LearnTests(RegistryCase):
294
+ def test_claude_learns_user_project_and_approval_changes(self):
295
+ (self.root / "acct-01" / ".claude.json").write_text(json.dumps({
296
+ "mcpServers": {"old": STDIO},
297
+ "projects": {"/proj": {"mcpServers": {"gone": STDIO}, "enabledMcpjsonServers": []}}}))
298
+ rc, snap, _ = self.run_cli("claude", "snapshot", "--account-dir", self.acct())
299
+ before = self.root / "before.json"
300
+ before.write_text(snap)
301
+ doc = self.claude_doc()
302
+ doc["mcpServers"] = {"learned": HTTP} # old removed, learned added
303
+ doc["projects"]["/proj"] = {"mcpServers": {"local1": STDIO}, "enabledMcpjsonServers": ["pj"]}
304
+ (self.root / "acct-01" / ".claude.json").write_text(json.dumps(doc))
305
+ rc, _, err = self.run_cli("claude", "learn", "--account-dir", self.acct(),
306
+ "--before", str(before), "--project", "/proj")
307
+ self.assertEqual(rc, 0)
308
+ self.assertIn('mirrored "learned"', err)
309
+ registry = self.registry()
310
+ self.assertEqual(registry["mcpServers"], {"learned": HTTP})
311
+ self.assertEqual(registry["retiredUser"], ["old"])
312
+ self.assertEqual(registry["retired"], [])
313
+ project = registry["projects"]["/proj"]
314
+ self.assertEqual(project["mcpServers"], {"local1": STDIO})
315
+ self.assertEqual(project["retired"], ["gone"])
316
+ self.assertEqual(project["enabledMcpjsonServers"], ["pj"])
317
+ other = self.claude_doc("acct-02")
318
+ self.assertEqual(sorted(other["mcpServers"]), ["learned"])
319
+ self.assertEqual(sorted(other["projects"]["/proj"]["mcpServers"]), ["local1"])
320
+ self.assertEqual(other["projects"]["/proj"]["enabledMcpjsonServers"], ["pj"])
321
+
322
+ def test_a_learned_user_scope_removal_never_touches_project_entries(self):
323
+ (self.root / "acct-01" / ".claude.json").write_text(json.dumps({
324
+ "mcpServers": {"pg": STDIO},
325
+ "projects": {"/a": {"mcpServers": {"pg": STDIO}}, "/b": {"mcpServers": {"pg": STDIO}}}}))
326
+ (self.root / "acct-02" / ".claude.json").write_text(json.dumps({
327
+ "projects": {"/c": {"mcpServers": {"pg": STDIO}}}}))
328
+ rc, snap, _ = self.run_cli("claude", "snapshot", "--account-dir", self.acct())
329
+ before = self.root / "before.json"
330
+ before.write_text(snap)
331
+ doc = self.claude_doc()
332
+ doc["mcpServers"] = {} # what `claude mcp remove -s user pg` does
333
+ (self.root / "acct-01" / ".claude.json").write_text(json.dumps(doc))
334
+ rc, _, _ = self.run_cli("claude", "learn", "--account-dir", self.acct(), "--before", str(before))
335
+ self.assertEqual(rc, 0)
336
+ self.assertEqual(self.registry()["retiredUser"], ["pg"])
337
+ for account, path in (("acct-01", "/a"), ("acct-01", "/b"), ("acct-02", "/c")):
338
+ self.assertIn("pg", self.claude_doc(account)["projects"][path]["mcpServers"])
339
+ self.assertNotIn("pg", self.claude_doc("acct-01")["mcpServers"])
340
+
341
+ def test_nothing_changed_is_quiet(self):
342
+ rc, snap, _ = self.run_cli("claude", "snapshot", "--account-dir", self.acct())
343
+ before = self.root / "before.json"
344
+ before.write_text(snap)
345
+ rc, out, err = self.run_cli("claude", "learn", "--account-dir", self.acct(), "--before", str(before))
346
+ self.assertEqual((rc, out, err), (0, "", ""))
347
+ self.assertFalse((self.root / "mcp-servers.json").exists())
348
+
349
+ def test_on_a_replica_everything_lands_in_the_overlay(self):
350
+ (self.root / "sync-role").write_text("replica\n")
351
+ rc, out, _ = self.run_cli("claude", "add", "here", "--", "echo")
352
+ self.assertEqual(rc, 0)
353
+ self.assertIn("overlay (replica pool)", out)
354
+ self.assertFalse((self.root / "mcp-servers.json").exists())
355
+ self.assertEqual(sorted(self.overlay()["owners"]["local"]["mcpServers"]), ["here"])
356
+ self.assertIn("here", self.claude_doc("acct-02")["mcpServers"])
357
+ rc, snap, _ = self.run_cli("claude", "snapshot", "--account-dir", self.acct())
358
+ before = self.root / "before.json"
359
+ before.write_text(snap)
360
+ doc = self.claude_doc()
361
+ doc["mcpServers"]["learned"] = STDIO
362
+ (self.root / "acct-01" / ".claude.json").write_text(json.dumps(doc))
363
+ rc, _, err = self.run_cli("claude", "learn", "--account-dir", self.acct(), "--before", str(before))
364
+ self.assertEqual(rc, 0)
365
+ self.assertIn("replica", err)
366
+ self.assertIn("learned", self.overlay()["owners"]["local"]["mcpServers"])
367
+ self.assertIn("learned", self.claude_doc("acct-02")["mcpServers"])
368
+ rc, out, _ = self.run_cli("claude", "remove", "here")
369
+ self.assertEqual(self.overlay()["owners"]["local"]["retired"], ["here"])
370
+ self.assertNotIn("here", self.claude_doc("acct-02")["mcpServers"])
371
+ rc, out, _ = self.run_cli("claude", "list", "--json")
372
+ self.assertTrue(json.loads(out)["replica"])
373
+
374
+
375
+ class OverlayTests(RegistryCase):
376
+ def import_local(self, owner, payload):
377
+ return self.run_cli("claude", "import-local", "--owner", owner, stdin=json.dumps(payload))
378
+
379
+ def test_import_local_owner_sections_and_registry_precedence(self):
380
+ self.run_cli("claude", "add", "keep", "--", "echo")
381
+ self.run_cli("claude", "remove", "never")
382
+ (self.root / "acct-01" / ".claude.json").write_text(json.dumps({"mcpServers": {"adspower-local-api": STDIO}}))
383
+ rc, _, _ = self.import_local("app-robot", {
384
+ "mcpServers": {"appinspire-mcp": STDIO, "never": STDIO, "keep": {"command": "other"}},
385
+ "retired": ["adspower-local-api", "keep"]})
386
+ self.assertEqual(rc, 0)
387
+ doc = self.claude_doc()
388
+ self.assertEqual(sorted(doc["mcpServers"]), ["appinspire-mcp", "keep"])
389
+ self.assertEqual(doc["mcpServers"]["keep"]["command"], "echo") # registry block, not the overlay's
390
+ self.assertEqual(sorted(self.overlay()["owners"]), ["app-robot"])
391
+ # a second owner adds beside the first without disturbing it
392
+ rc, _, _ = self.import_local("other", {"mcpServers": {"extra": STDIO}})
393
+ self.assertEqual(sorted(self.overlay()["owners"]), ["app-robot", "other"])
394
+ self.assertEqual(sorted(self.claude_doc()["mcpServers"]), ["appinspire-mcp", "extra", "keep"])
395
+ # re-importing the same section is a no-op for the ops log
396
+ log_lines = (self.root / "ops.log").read_text().count("import-local")
397
+ self.import_local("other", {"mcpServers": {"extra": STDIO}})
398
+ self.assertEqual((self.root / "ops.log").read_text().count("import-local"), log_lines)
399
+
400
+ def test_an_owner_that_drops_a_server_retires_it(self):
401
+ self.import_local("app-robot", {"mcpServers": {"appinspire-mcp": STDIO, "tmp": STDIO}})
402
+ self.assertIn("tmp", self.claude_doc("acct-02")["mcpServers"])
403
+ self.import_local("app-robot", {"mcpServers": {"appinspire-mcp": STDIO}})
404
+ self.assertNotIn("tmp", self.claude_doc("acct-02")["mcpServers"])
405
+ self.assertEqual(self.overlay()["owners"]["app-robot"]["retired"], ["tmp"])
406
+ self.import_local("app-robot", {"mcpServers": {}, "retired": ["appinspire-mcp"]})
407
+ self.assertEqual(self.claude_doc("acct-02")["mcpServers"], {})
408
+
409
+ def test_import_local_rejects_bad_payloads_loudly(self):
410
+ with self.assertRaises(R.RegistryError):
411
+ self.import_local("app-robot", {"mcpServers": {"x": {"command": "c", "args": 5}}})
412
+ with self.assertRaises(R.RegistryError):
413
+ self.import_local("bad owner", {"mcpServers": {}})
414
+ self.assertFalse((self.root / "mcp-servers.local.json").exists())
415
+
416
+
417
+ class CliGuardTests(RegistryCase):
418
+ def test_apply_without_a_registry_is_a_silent_no_op(self):
419
+ rc, out, err = self.run_cli("claude", "apply", "--account-dir", self.acct())
420
+ self.assertEqual((rc, out, err), (0, "", ""))
421
+ self.assertFalse((self.root / "acct-01" / ".claude.json").exists())
422
+
423
+ def test_fail_open_exits_zero_from_a_subprocess(self):
424
+ proc = subprocess.run([sys.executable, str(LIB), "--root", "/nonexistent", "--provider", "claude",
425
+ "apply", "--fail-open", "--account-dir", "/x"],
426
+ capture_output=True, text=True)
427
+ self.assertEqual(proc.returncode, 0)
428
+ self.assertIn("mcp-registry:", proc.stderr)
429
+ proc = subprocess.run([sys.executable, str(LIB), "--root", "/nonexistent", "--provider", "claude",
430
+ "apply", "--account-dir", "/x"], capture_output=True, text=True)
431
+ self.assertEqual(proc.returncode, 1)
432
+
433
+ def test_an_unexpected_python_error_never_escapes_as_a_traceback(self):
434
+ (self.root / "acct-01" / ".claude.json").write_bytes(b"\xff\xfe{}")
435
+ proc = subprocess.run([sys.executable, str(LIB), "--root", str(self.root), "--provider", "claude",
436
+ "add", "s", "--", "echo"], capture_output=True, text=True)
437
+ self.assertEqual(proc.returncode, R.EXIT_PARTIAL)
438
+ self.assertNotIn("Traceback", proc.stderr)
439
+ self.assertIn("acct-02: updated", proc.stdout)
440
+
441
+ def test_list_reports_origin_and_tombstones(self):
442
+ self.run_cli("claude", "add", "svc", "--", "echo", "hi there")
443
+ self.run_cli("claude", "remove", "adspower-local-api")
444
+ self.run_cli("claude", "remove", "pg", "--user-only")
445
+ rc, out, _ = self.run_cli("claude", "list")
446
+ self.assertIn("svc", out)
447
+ self.assertIn("echo 'hi there'", out)
448
+ self.assertIn("[registry]", out)
449
+ self.assertIn("retired (removed from every account, every scope): adspower-local-api", out)
450
+ self.assertIn("retired at user scope only: pg", out)
451
+ rc, out, _ = self.run_cli("claude", "list", "--json")
452
+ parsed = json.loads(out)
453
+ self.assertEqual((parsed["retired"], parsed["retiredUser"]), (["adspower-local-api"], ["pg"]))
454
+
455
+ def test_a_stale_lock_is_broken(self):
456
+ lock = self.root / ".locks" / "mcp"
457
+ lock.mkdir(parents=True)
458
+ os.utime(lock, (0, 0))
459
+ rc, _, _ = self.run_cli("claude", "add", "svc", "--", "echo")
460
+ self.assertEqual(rc, 0)
461
+ self.assertFalse(lock.exists())
462
+
463
+ def test_leftover_arguments_are_refused(self):
464
+ with self.assertRaises(R.RegistryError):
465
+ self.run_cli("claude", "remove", "x", "stray")
466
+ with self.assertRaises(R.RegistryError):
467
+ self.run_cli("claude", "add-json", "x", json.dumps(STDIO), "stray")
468
+
469
+
470
+ @unittest.skipUnless(HAS_TOMLLIB, "codex reconcile needs tomllib (Python 3.11+)")
471
+ class CodexReconcileTests(RegistryCase):
472
+ def config(self, account="acct-01"):
473
+ return (self.root / account / "config.toml")
474
+
475
+ def parsed(self, account="acct-01"):
476
+ import tomllib
477
+ return tomllib.loads(self.config(account).read_text())
478
+
479
+ def test_tables_are_appended_and_unrelated_text_is_untouched(self):
480
+ original = ('model = "gpt-5" # keep this comment\n\n'
481
+ '[mcp_servers.adspower-local-api]\ncommand = "npx"\nargs = ["-y", "x"]\n\n'
482
+ '[mcp_servers.adspower-local-api.env]\nPORT = "1"\n\n'
483
+ '# Feature flags (keep me)\n[features.multi_agent_v2]\nenabled = true\n')
484
+ self.config().write_text(original)
485
+ rc, _, _ = self.run_cli("codex", "add", "appinspire-mcp", "-e", "LIT=1", "-e", "SECRET=${SECRET}",
486
+ "--", "npx", "-y", "appinspire-mcp@latest", "serve")
487
+ self.assertEqual(rc, 0)
488
+ rc, _, _ = self.run_cli("codex", "remove", "adspower-local-api")
489
+ self.assertEqual(rc, 0)
490
+ text = self.config().read_text()
491
+ self.assertTrue(text.startswith('model = "gpt-5" # keep this comment\n'))
492
+ self.assertIn("# Feature flags (keep me)\n[features.multi_agent_v2]\nenabled = true\n", text)
493
+ self.assertNotIn("adspower", text)
494
+ self.assertEqual(self.parsed()["mcp_servers"], {"appinspire-mcp": {
495
+ "command": "npx", "args": ["-y", "appinspire-mcp@latest", "serve"],
496
+ "env": {"LIT": "1"}, "env_vars": ["SECRET"], "startup_timeout_sec": 60}})
497
+ self.assertEqual(sorted(self.parsed("acct-02")), ["mcp_servers"])
498
+ before = text
499
+ rc, out, _ = self.run_cli("codex", "apply", "--all")
500
+ self.assertIn("acct-01: unchanged", out)
501
+ self.assertEqual(before, self.config().read_text())
502
+
503
+ def test_comments_above_the_next_table_survive_an_update(self):
504
+ self.config().write_text('[mcp_servers.old]\ncommand = "a"\n# belongs to old\n\n'
505
+ '# Feature flags (keep me)\n[features]\nx = true\n')
506
+ self.run_cli("codex", "add", "old", "--", "new")
507
+ text = self.config().read_text()
508
+ self.assertIn("# Feature flags (keep me)\n[features]\nx = true\n", text)
509
+ self.assertEqual(self.parsed()["mcp_servers"]["old"]["command"], "new")
510
+
511
+ def test_dotted_keys_under_a_bare_table_are_replaced_too(self):
512
+ self.config().write_text('[mcp_servers]\nold.command = "a"\nold.args = ["b"]\nother.command = "c"\n')
513
+ rc, _, _ = self.run_cli("codex", "add", "old", "--", "new")
514
+ self.assertEqual(rc, 0)
515
+ doc = self.parsed()
516
+ self.assertEqual(doc["mcp_servers"]["old"]["command"], "new")
517
+ self.assertEqual(doc["mcp_servers"]["other"], {"command": "c"})
518
+
519
+ def test_an_unsupported_layout_is_refused_and_the_file_survives(self):
520
+ original = 'mcp_servers = { old = { command = "a" } }\n'
521
+ self.config().write_text(original)
522
+ rc, out, err = self.run_cli("codex", "add", "old", "--", "new")
523
+ self.assertEqual(rc, R.EXIT_PARTIAL)
524
+ self.assertIn("refusing to write config.toml", err)
525
+ self.assertEqual(self.config().read_text(), original)
526
+ self.assertIn("acct-02: updated", out)
527
+
528
+ def test_http_headers_env_references_and_inexpressible_transports(self):
529
+ rc, _, _ = self.run_cli("codex", "add-json", "web", json.dumps(HTTP))
530
+ self.assertEqual(rc, 0)
531
+ self.assertEqual(self.parsed()["mcp_servers"]["web"],
532
+ {"url": "https://mcp.example/mcp", "http_headers": {"X-Plain": "1"},
533
+ "env_http_headers": {"X-Env": "OTHER"}, "bearer_token_env_var": "TOKEN"})
534
+ for name, block in (("events", {"type": "sse", "url": "https://s/sse"}),
535
+ ("sock", {"type": "ws", "url": "wss://s/ws"})):
536
+ rc, out, err = self.run_cli("codex", "add-json", name, json.dumps(block))
537
+ self.assertEqual(rc, 0)
538
+ self.assertIn("cannot express", err)
539
+ self.assertNotIn(name, self.config().read_text())
540
+
541
+ def test_env_references_are_forwarded_only_under_their_own_name(self):
542
+ block = {"command": "npx", "args": ["-y", "gh"],
543
+ "env": {"GITHUB_TOKEN": "${GH_TOKEN}", "B": "${B:-dflt}", "OK": "${OK}",
544
+ "PATH": "/opt/bin:${PATH}"}}
545
+ rc, out, err = self.run_cli("codex", "add-json", "gh", json.dumps(block))
546
+ self.assertEqual(rc, 0)
547
+ self.assertIn("GITHUB_TOKEN=${GH_TOKEN} cannot be expressed", err)
548
+ self.assertIn("B=${B:-dflt}", err)
549
+ table = self.parsed()["mcp_servers"]["gh"]
550
+ self.assertEqual(table["env_vars"], ["OK"])
551
+ self.assertEqual(table["env"], {"PATH": "/opt/bin:${PATH}"})
552
+
553
+ def test_explicit_codex_keys_and_odd_names_round_trip(self):
554
+ block = {"command": "node", "args": ["s.mjs"], "startup_timeout_sec": 5, "tool_timeout_sec": 30,
555
+ "cwd": "/srv", "enabled": False}
556
+ rc, _, _ = self.run_cli("codex", "add-json", "my_server-1", json.dumps(block))
557
+ self.assertEqual(rc, 0)
558
+ self.assertEqual(self.parsed()["mcp_servers"]["my_server-1"],
559
+ {"command": "node", "args": ["s.mjs"], "startup_timeout_sec": 5,
560
+ "tool_timeout_sec": 30, "cwd": "/srv", "enabled": False})
561
+
562
+ def test_codex_learns_a_stock_add_and_remove_and_keeps_oauth_extras(self):
563
+ self.config().write_text('[mcp_servers.old]\ncommand = "o"\n')
564
+ rc, snap, _ = self.run_cli("codex", "snapshot", "--account-dir", self.acct())
565
+ before = self.root / "before.json"
566
+ before.write_text(snap)
567
+ self.config().write_text('[mcp_servers.new]\ncommand = "echo"\nargs = ["hi"]\nenv_vars = ["TOK"]\n'
568
+ '[mcp_servers.new.env]\nA = "1"\n'
569
+ '[mcp_servers.web]\nurl = "http://127.0.0.1:9/mcp"\n'
570
+ 'oauth_resource = "https://res.example"\nenabled_tools = ["a"]\n'
571
+ '[mcp_servers.web.oauth]\nclient_id = "cid-123"\ncallback_url = "http://cb"\n')
572
+ rc, _, err = self.run_cli("codex", "learn", "--account-dir", self.acct(), "--before", str(before))
573
+ self.assertEqual(rc, 0)
574
+ self.assertIn('mirrored "new", "web", "old" (removed)', err)
575
+ registry = self.registry()
576
+ self.assertEqual(registry["mcpServers"]["new"],
577
+ {"type": "stdio", "command": "echo", "args": ["hi"], "env": {"A": "1", "TOK": "${TOK}"}})
578
+ self.assertEqual(registry["mcpServers"]["web"]["codex"],
579
+ {"oauth_resource": "https://res.example", "enabled_tools": ["a"],
580
+ "oauth": {"client_id": "cid-123", "callback_url": "http://cb"}})
581
+ self.assertEqual(registry["retiredUser"], ["old"])
582
+ # the source account keeps its OAuth settings, and the other account gets them
583
+ for account in ("acct-01", "acct-02"):
584
+ web = self.parsed(account)["mcp_servers"]["web"]
585
+ self.assertEqual(web["oauth"], {"client_id": "cid-123", "callback_url": "http://cb"})
586
+ self.assertEqual(web["oauth_resource"], "https://res.example")
587
+ self.assertEqual(web["enabled_tools"], ["a"])
588
+ doc2 = self.parsed("acct-02")
589
+ self.assertEqual(doc2["mcp_servers"]["new"]["env_vars"], ["TOK"])
590
+ self.assertEqual(doc2["mcp_servers"]["new"]["env"], {"A": "1"})
591
+ # nothing to rewrite on a second pass: the passthrough round-trips exactly
592
+ rc, out, _ = self.run_cli("codex", "apply", "--all")
593
+ self.assertIn("acct-01: unchanged", out)
594
+
595
+
596
+ if __name__ == "__main__":
597
+ unittest.main()