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.
@@ -0,0 +1,1372 @@
1
+ #!/usr/bin/env python3
2
+ """Pool-level MCP server registry: one document, reconciled into every account.
3
+
4
+ WHY (2026-09-22). Claude Code and Codex keep their MCP servers inside the config
5
+ dir — `<CLAUDE_CONFIG_DIR>/.claude.json` (user scope, plus a per-project entry
6
+ under `projects[<cwd>]`) and `<CODEX_HOME>/config.toml` (`[mcp_servers.<name>]`).
7
+ Under the pool every launch runs in a different config dir, so a stock
8
+ `claude mcp add` (or `npx appinspire-mcp install`) lands in ONE random account:
9
+ the next session, picked on another account, has no such server, and the one
10
+ after that connects an older copy somebody registered months ago. That is how
11
+ `appinspire-mcp` was "not connecting in every session".
12
+
13
+ The fix is one registry per pool root that is authoritative for the servers it
14
+ names and is reconciled into every account dir:
15
+
16
+ ROOT/mcp-servers.json the operator's registry — SYNCED to the server and
17
+ every manifest peer like the manifest itself
18
+ ROOT/mcp-servers.local.json a machine-local overlay other software on this
19
+ machine (app-robot's runner) publishes into, never
20
+ synced; the synced registry wins on any conflict.
21
+ A REPLICA pool (sync-role = replica) also keeps its
22
+ own adds/removes/learned changes here, under the
23
+ owner `local`: the source's next push would
24
+ overwrite its registry, but never this file.
25
+ <acct>/.mcp-applied a one-line stamp of what the last reconcile saw,
26
+ so the shims can skip python when nothing moved
27
+
28
+ Reconcile rules, the same for both providers:
29
+ * every registry server is upserted into the account under its own name;
30
+ * a name in `retired` (the tombstone an explicit `mcp remove` leaves behind)
31
+ is deleted EVERYWHERE — user scope and every project entry — even if the
32
+ registry never managed it, which is what "we do not use adspower-local-api
33
+ any more" needs to hold fleet-wide; a name in `retiredUser` (a learned stock
34
+ `claude mcp remove -s user`, or `mcp remove --user-only`) leaves project
35
+ entries alone, exactly as the client itself did;
36
+ * anything else the account holds is left alone (an account may carry its own
37
+ extras);
38
+ * project-scoped entries (`projects` in the registry) exist for claude only:
39
+ Codex 0.156 reads no per-project config at all.
40
+
41
+ Both providers speak the same block shape — Claude Code's own (`type`, `command`,
42
+ `args`, `env` / `url`, `headers`) — and the codex side is a translation of it
43
+ (`KEY=${KEY}` env references become `env_vars`, headers become `http_headers` /
44
+ `env_http_headers` / `bearer_token_env_var`, a stdio server gets
45
+ `startup_timeout_sec` 60 so a cold `npx` download does not trip codex's 10 s
46
+ default). Codex-only keys ride along in the registry (`startup_timeout_sec`,
47
+ `env_vars`, … and a `codex` passthrough dict for anything else codex wrote, such
48
+ as an `oauth` sub-table) and are stripped for claude.
49
+
50
+ The claude path runs on stock macOS python (3.9). The codex path needs tomllib
51
+ (3.11+) and re-executes itself on an installed newer interpreter through
52
+ lib/codex_python.py — the same rule codex_settings.py already lives by.
53
+
54
+ Exit codes: 0 done; 1 refused (nothing saved); 2 usage; 3 the registry WAS saved
55
+ but some account failed to reconcile (details on stderr, `apply` repairs it).
56
+ """
57
+ from __future__ import annotations
58
+
59
+ import argparse
60
+ import copy
61
+ import datetime as _dt
62
+ import json
63
+ import math
64
+ import os
65
+ import re
66
+ import shlex
67
+ import sys
68
+ import tempfile
69
+ import time
70
+
71
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
72
+
73
+ REGISTRY_FILE = "mcp-servers.json"
74
+ OVERLAY_FILE = "mcp-servers.local.json"
75
+ STAMP_FILE = ".mcp-applied"
76
+ LOCK_DIR = os.path.join(".locks", "mcp")
77
+ PROVIDERS = ("claude", "codex")
78
+ CONFIG_FILE = {"claude": ".claude.json", "codex": "config.toml"}
79
+ TRANSPORTS = ("stdio", "http", "sse", "ws")
80
+ #: Keys a codex block may carry that Claude Code must never see in .claude.json.
81
+ CODEX_ONLY_KEYS = frozenset({"startup_timeout_sec", "tool_timeout_sec", "env_vars",
82
+ "enabled", "cwd", "http_headers", "env_http_headers",
83
+ "bearer_token_env_var", "codex"})
84
+ #: The codex table keys this module models; everything else codex writes round-trips
85
+ #: through the block's `codex` passthrough dict untouched.
86
+ CODEX_MODELLED_KEYS = frozenset({"command", "args", "env", "env_vars", "url", "http_headers",
87
+ "env_http_headers", "bearer_token_env_var",
88
+ "startup_timeout_sec", "tool_timeout_sec", "cwd", "enabled"})
89
+ #: A cold `npx -y <pkg>` resolves and downloads before the server answers
90
+ #: `initialize`; codex gives a server 10 s by default and then reports it dead
91
+ #: for the whole session. Claude Code waits longer on its own.
92
+ DEFAULT_STARTUP_TIMEOUT = 60
93
+ MAX_TIMEOUT = 86400
94
+ ENV_REF = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)(:-[^}]*)?\}$")
95
+ BEARER_REF = re.compile(r"^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
96
+ ACCT_ID = re.compile(r"^acct-\d{2,4}$")
97
+ BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
98
+ #: Claude Code's own rule for a server name (`claude mcp add` refuses anything else).
99
+ STRICT_NAME = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
100
+ #: The keys Claude Code creates for a project entry the first time a session
101
+ #: opens that directory. A registry-created entry carries the same skeleton so
102
+ #: the client finds every field it expects.
103
+ PROJECT_SKELETON = {
104
+ "allowedTools": [], "mcpContextUris": [], "mcpServers": {},
105
+ "enabledMcpjsonServers": [], "disabledMcpjsonServers": [],
106
+ "hasTrustDialogAccepted": False, "hasClaudeMdExternalIncludesApproved": False,
107
+ "hasClaudeMdExternalIncludesWarningShown": False,
108
+ }
109
+ EXIT_PARTIAL = 3
110
+
111
+
112
+ class RegistryError(Exception):
113
+ """A refusal that reaches the operator as `mcp-registry: <message>`."""
114
+
115
+
116
+ def _warn(message):
117
+ print(f"mcp-registry: {message}", file=sys.stderr)
118
+
119
+
120
+ # --------------------------------------------------------------------------- io
121
+
122
+ def _now_iso():
123
+ return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
124
+
125
+
126
+ def _read_json(path, default):
127
+ try:
128
+ with open(path, encoding="utf-8") as handle:
129
+ data = json.load(handle)
130
+ except FileNotFoundError:
131
+ return copy.deepcopy(default)
132
+ except (OSError, ValueError) as exc:
133
+ raise RegistryError(f"{path} is not readable JSON: {exc}") from exc
134
+ if not isinstance(data, dict):
135
+ raise RegistryError(f"{path} does not hold a JSON object")
136
+ return data
137
+
138
+
139
+ def _atomic_write(path, text, default_mode=0o600):
140
+ directory = os.path.dirname(path) or "."
141
+ os.makedirs(directory, exist_ok=True)
142
+ try:
143
+ mode = os.stat(path).st_mode & 0o777
144
+ except FileNotFoundError:
145
+ mode = default_mode
146
+ handle, scratch = tempfile.mkstemp(prefix=".mcp-registry-", dir=directory)
147
+ try:
148
+ with os.fdopen(handle, "w", encoding="utf-8") as stream:
149
+ os.fchmod(stream.fileno(), mode)
150
+ stream.write(text)
151
+ os.replace(scratch, path)
152
+ except BaseException:
153
+ try:
154
+ os.unlink(scratch)
155
+ except OSError:
156
+ pass
157
+ raise
158
+
159
+
160
+ def _log(root, message):
161
+ try:
162
+ with open(os.path.join(root, "ops.log"), "a", encoding="utf-8") as handle:
163
+ handle.write(f"{_now_iso()} mcp {message}\n")
164
+ except OSError:
165
+ pass
166
+
167
+
168
+ class _Lock:
169
+ """mkdir lock, the same shape as lib/common.sh mutate_lock (30 s stale)."""
170
+
171
+ def __init__(self, root):
172
+ self.path = os.path.join(root, LOCK_DIR)
173
+ self.held = False
174
+
175
+ def __enter__(self):
176
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
177
+ deadline = time.time() + 60
178
+ while True:
179
+ try:
180
+ os.mkdir(self.path)
181
+ self.held = True
182
+ return self
183
+ except FileExistsError:
184
+ try:
185
+ if time.time() - os.stat(self.path).st_mtime > 30:
186
+ os.rmdir(self.path)
187
+ continue
188
+ except OSError:
189
+ pass
190
+ if time.time() > deadline:
191
+ raise RegistryError("could not take the registry lock; try again")
192
+ time.sleep(0.1)
193
+
194
+ def __exit__(self, *_exc):
195
+ if self.held:
196
+ try:
197
+ os.rmdir(self.path)
198
+ except OSError:
199
+ pass
200
+ self.held = False
201
+
202
+
203
+ # ------------------------------------------------------------ documents
204
+
205
+ def _normalize_section(section):
206
+ """Every server section — the registry, an overlay owner — has the same shape."""
207
+ if not isinstance(section, dict):
208
+ section = {}
209
+ if not isinstance(section.get("mcpServers"), dict):
210
+ section["mcpServers"] = {}
211
+ for key in ("retired", "retiredUser"):
212
+ if not isinstance(section.get(key), list):
213
+ section[key] = []
214
+ section[key] = [str(name) for name in section[key]]
215
+ if not isinstance(section.get("projects"), dict):
216
+ section["projects"] = {}
217
+ for path, entry in list(section["projects"].items()):
218
+ if not isinstance(entry, dict):
219
+ section["projects"][path] = entry = {}
220
+ if not isinstance(entry.get("mcpServers"), dict):
221
+ entry["mcpServers"] = {}
222
+ if not isinstance(entry.get("retired"), list):
223
+ entry["retired"] = []
224
+ if "enabledMcpjsonServers" in entry and not isinstance(entry["enabledMcpjsonServers"], list):
225
+ entry.pop("enabledMcpjsonServers")
226
+ return section
227
+
228
+
229
+ def _empty_registry():
230
+ return {"version": 1, "mcpServers": {}, "retired": [], "retiredUser": [], "projects": {}}
231
+
232
+
233
+ def load_registry(root):
234
+ doc = _normalize_section(_read_json(os.path.join(root, REGISTRY_FILE), _empty_registry()))
235
+ doc.setdefault("version", 1)
236
+ return doc
237
+
238
+
239
+ def save_registry(root, doc):
240
+ doc["version"] = 1
241
+ doc["updated_at"] = _now_iso()
242
+ _atomic_write(os.path.join(root, REGISTRY_FILE), json.dumps(doc, indent=2) + "\n", 0o644)
243
+
244
+
245
+ def load_overlay(root):
246
+ doc = _read_json(os.path.join(root, OVERLAY_FILE), {"version": 1, "owners": {}})
247
+ if not isinstance(doc.get("owners"), dict):
248
+ doc["owners"] = {}
249
+ for owner in list(doc["owners"]):
250
+ doc["owners"][owner] = _normalize_section(doc["owners"][owner])
251
+ return doc
252
+
253
+
254
+ def save_overlay(root, doc):
255
+ doc["version"] = 1
256
+ doc["updated_at"] = _now_iso()
257
+ _atomic_write(os.path.join(root, OVERLAY_FILE), json.dumps(doc, indent=2) + "\n", 0o644)
258
+
259
+
260
+ def is_replica(root):
261
+ """A pool the source machine pushes to: its registry is the source's copy."""
262
+ try:
263
+ with open(os.path.join(root, "sync-role"), encoding="utf-8") as handle:
264
+ return handle.read().strip().lower() == "replica"
265
+ except OSError:
266
+ return False
267
+
268
+
269
+ class Effective:
270
+ """What every account must hold and what it must not, merged from all sections."""
271
+
272
+ def __init__(self):
273
+ self.servers = {}
274
+ self.retired = set() # everywhere
275
+ self.retired_user = set() # user scope only
276
+ self.projects = {}
277
+
278
+ def _merge_projects(self, projects):
279
+ for path, entry in projects.items():
280
+ target = self.projects.setdefault(path, {"mcpServers": {}, "retired": [],
281
+ "enabledMcpjsonServers": []})
282
+ for name, block in (entry.get("mcpServers") or {}).items():
283
+ if isinstance(block, dict):
284
+ target["mcpServers"][str(name)] = block
285
+ target["retired"] = [n for n in target["retired"] if n != name]
286
+ for name in entry.get("retired") or []:
287
+ target["mcpServers"].pop(name, None)
288
+ if name not in target["retired"]:
289
+ target["retired"].append(name)
290
+ for name in entry.get("enabledMcpjsonServers") or []:
291
+ if name not in target["enabledMcpjsonServers"]:
292
+ target["enabledMcpjsonServers"].append(name)
293
+
294
+ def _apply_section(self, section):
295
+ gone = set(section["retired"])
296
+ gone_user = set(section["retiredUser"])
297
+ for name, block in section["mcpServers"].items():
298
+ if isinstance(block, dict) and name not in gone and name not in gone_user:
299
+ self.servers[str(name)] = block
300
+ self.retired.discard(name)
301
+ self.retired_user.discard(name)
302
+ for name in gone:
303
+ self.servers.pop(name, None)
304
+ self.retired.add(name)
305
+ self.retired_user.discard(name)
306
+ for name in gone_user:
307
+ self.servers.pop(name, None)
308
+ if name not in self.retired:
309
+ self.retired_user.add(name)
310
+ self._merge_projects(section["projects"])
311
+
312
+
313
+ def effective(registry, overlay):
314
+ """Overlay owners first (alphabetically, a later owner wins a clash, a tombstone
315
+ beats a server inside one owner); the synced registry has the final word both
316
+ ways: its servers survive any overlay tombstone and its tombstones remove any
317
+ overlay server."""
318
+ result = Effective()
319
+ for owner in sorted(overlay.get("owners", {})):
320
+ result._apply_section(overlay["owners"][owner])
321
+ result._apply_section(registry)
322
+ return result
323
+
324
+
325
+ # ------------------------------------------------------------- validation
326
+
327
+ def _validate_name(name, strict=True):
328
+ name = str(name)
329
+ if strict:
330
+ if not STRICT_NAME.match(name):
331
+ raise RegistryError(f"not a usable MCP server name: {name!r} "
332
+ "(letters, digits, '_' and '-', up to 128 characters)")
333
+ return name
334
+ if not name or name in (".", "..") or len(name) > 128 or re.search(r"[\s/\\]", name):
335
+ raise RegistryError(f"not a usable MCP server name: {name!r}")
336
+ return name
337
+
338
+
339
+ def _scalar(value):
340
+ return isinstance(value, (str, int, float)) and not isinstance(value, bool)
341
+
342
+
343
+ def _number_ok(value, lo=0, hi=MAX_TIMEOUT):
344
+ return (isinstance(value, (int, float)) and not isinstance(value, bool)
345
+ and math.isfinite(value) and lo <= value <= hi)
346
+
347
+
348
+ def validate_block(name, block):
349
+ """The block shape both clients can consume; raises RegistryError otherwise."""
350
+ if not isinstance(block, dict):
351
+ raise RegistryError(f"{name}: the server block must be a JSON object")
352
+ kind = block.get("type")
353
+ if kind is not None and kind not in TRANSPORTS:
354
+ raise RegistryError(f"{name}: unknown transport {kind!r} (stdio, http, sse, ws)")
355
+ command, url = block.get("command"), block.get("url")
356
+ if not ((isinstance(command, str) and command) or (isinstance(url, str) and url)):
357
+ raise RegistryError(f"{name}: needs a non-empty \"command\" or \"url\"")
358
+ if command is not None and not isinstance(command, str):
359
+ raise RegistryError(f"{name}: \"command\" must be a string")
360
+ if url is not None and not isinstance(url, str):
361
+ raise RegistryError(f"{name}: \"url\" must be a string")
362
+ args = block.get("args")
363
+ if args is not None and (not isinstance(args, list) or not all(_scalar(a) for a in args)):
364
+ raise RegistryError(f"{name}: \"args\" must be a list of strings")
365
+ for key in ("env", "headers", "http_headers", "env_http_headers"):
366
+ value = block.get(key)
367
+ if value is not None and (not isinstance(value, dict)
368
+ or not all(_scalar(v) for v in value.values())):
369
+ raise RegistryError(f"{name}: \"{key}\" must be an object of string values")
370
+ env_vars = block.get("env_vars")
371
+ if env_vars is not None and (not isinstance(env_vars, list)
372
+ or not all(isinstance(v, str) for v in env_vars)):
373
+ raise RegistryError(f"{name}: \"env_vars\" must be a list of variable names")
374
+ for key in ("startup_timeout_sec", "tool_timeout_sec"):
375
+ if key in block and not _number_ok(block[key]):
376
+ raise RegistryError(f"{name}: \"{key}\" must be a number of seconds "
377
+ f"between 0 and {MAX_TIMEOUT}")
378
+ if "enabled" in block and not isinstance(block["enabled"], bool):
379
+ raise RegistryError(f"{name}: \"enabled\" must be true or false")
380
+ for key in ("cwd", "bearer_token_env_var"):
381
+ if key in block and (not isinstance(block[key], str) or not block[key]):
382
+ raise RegistryError(f"{name}: \"{key}\" must be a non-empty string")
383
+ if "codex" in block and not isinstance(block["codex"], dict):
384
+ raise RegistryError(f"{name}: \"codex\" must be an object")
385
+ return block
386
+
387
+
388
+ # ------------------------------------------------------------------ accounts
389
+
390
+ def manifest_account_dirs(root):
391
+ manifest = os.path.join(root, "accounts.json")
392
+ try:
393
+ with open(manifest, encoding="utf-8") as handle:
394
+ doc = json.load(handle)
395
+ except FileNotFoundError:
396
+ return []
397
+ except (OSError, ValueError) as exc:
398
+ raise RegistryError(f"{manifest} is not readable JSON: {exc}") from exc
399
+ dirs = []
400
+ for account in doc.get("accounts") or []:
401
+ account_id = account.get("id", "") if isinstance(account, dict) else ""
402
+ if isinstance(account_id, str) and ACCT_ID.match(account_id):
403
+ path = os.path.join(root, account_id)
404
+ if os.path.isdir(path):
405
+ dirs.append(path)
406
+ return dirs
407
+
408
+
409
+ def _sig_part(path):
410
+ try:
411
+ st = os.stat(path)
412
+ except OSError:
413
+ return "-"
414
+ return f"{int(st.st_mtime)}:{st.st_size}"
415
+
416
+
417
+ def stamp_signature(root, provider, account_dir):
418
+ return ",".join(_sig_part(path) for path in (
419
+ os.path.join(root, REGISTRY_FILE), os.path.join(root, OVERLAY_FILE),
420
+ os.path.join(account_dir, CONFIG_FILE[provider])))
421
+
422
+
423
+ def write_stamp(root, provider, account_dir):
424
+ try:
425
+ _atomic_write(os.path.join(account_dir, STAMP_FILE),
426
+ stamp_signature(root, provider, account_dir) + "\n")
427
+ except OSError:
428
+ pass
429
+
430
+
431
+ # --------------------------------------------------------------- claude side
432
+
433
+ def _kind_of(block):
434
+ kind = block.get("type")
435
+ if kind in TRANSPORTS:
436
+ return kind
437
+ return "stdio" if block.get("command") else ("http" if block.get("url") else "stdio")
438
+
439
+
440
+ def claude_block(block):
441
+ """Claude Code's own shape, codex-only keys dropped."""
442
+ validate_block(block.get("__name__", "server"), block)
443
+ out = {key: copy.deepcopy(value) for key, value in block.items()
444
+ if key not in CODEX_ONLY_KEYS and key != "__name__"}
445
+ kind = _kind_of(out)
446
+ ordered = {"type": kind}
447
+ if kind == "stdio":
448
+ ordered["command"] = str(out.get("command", ""))
449
+ ordered["args"] = [str(arg) for arg in (out.get("args") or [])]
450
+ env = out.get("env") or {}
451
+ ordered["env"] = {str(k): str(v) for k, v in env.items()}
452
+ else:
453
+ ordered["url"] = str(out.get("url", ""))
454
+ headers = out.get("headers")
455
+ if isinstance(headers, dict) and headers:
456
+ ordered["headers"] = {str(k): str(v) for k, v in headers.items()}
457
+ for key, value in out.items():
458
+ if key not in ordered:
459
+ ordered[key] = value
460
+ return ordered
461
+
462
+
463
+ def _claude_load(account_dir):
464
+ path = os.path.join(account_dir, CONFIG_FILE["claude"])
465
+ try:
466
+ with open(path, encoding="utf-8") as handle:
467
+ doc = json.load(handle)
468
+ except FileNotFoundError:
469
+ return {}
470
+ except (OSError, ValueError) as exc:
471
+ raise RegistryError(f"{path} is not readable JSON: {exc}") from exc
472
+ if not isinstance(doc, dict):
473
+ raise RegistryError(f"{path} does not hold a JSON object")
474
+ return doc
475
+
476
+
477
+ def _claude_save(account_dir, doc):
478
+ _atomic_write(os.path.join(account_dir, CONFIG_FILE["claude"]),
479
+ json.dumps(doc, indent=2) + "\n")
480
+
481
+
482
+ def _project_entry(doc, path):
483
+ projects = doc.setdefault("projects", {})
484
+ if not isinstance(projects, dict):
485
+ projects = doc["projects"] = {}
486
+ entry = projects.get(path)
487
+ if not isinstance(entry, dict):
488
+ entry = projects[path] = copy.deepcopy(PROJECT_SKELETON)
489
+ if not isinstance(entry.get("mcpServers"), dict):
490
+ entry["mcpServers"] = {}
491
+ return entry
492
+
493
+
494
+ def reconcile_claude(doc, eff):
495
+ """Return (reconciled document, notes). Compare to the input to know if it changed."""
496
+ doc = copy.deepcopy(doc)
497
+ notes = []
498
+ user = doc.get("mcpServers")
499
+ if not isinstance(user, dict):
500
+ user = doc["mcpServers"] = {}
501
+ for name, block in eff.servers.items():
502
+ try:
503
+ user[name] = claude_block(dict(block, __name__=name))
504
+ except RegistryError as exc:
505
+ notes.append(f"{exc} — skipped")
506
+ for name in eff.retired | eff.retired_user:
507
+ user.pop(name, None)
508
+ existing_projects = doc.get("projects")
509
+ if isinstance(existing_projects, dict) and eff.retired:
510
+ for entry in existing_projects.values():
511
+ if isinstance(entry, dict) and isinstance(entry.get("mcpServers"), dict):
512
+ for name in eff.retired:
513
+ entry["mcpServers"].pop(name, None)
514
+ for path, spec in eff.projects.items():
515
+ wanted = spec.get("mcpServers") or {}
516
+ gone = set(spec.get("retired") or []) | eff.retired
517
+ enabled = spec.get("enabledMcpjsonServers") or []
518
+ if not wanted and not (spec.get("retired") or []) and not enabled:
519
+ continue
520
+ entry = _project_entry(doc, path)
521
+ for name, block in wanted.items():
522
+ if name in eff.retired:
523
+ continue
524
+ try:
525
+ entry["mcpServers"][name] = claude_block(dict(block, __name__=name))
526
+ except RegistryError as exc:
527
+ notes.append(f"{exc} — skipped")
528
+ for name in gone:
529
+ entry["mcpServers"].pop(name, None)
530
+ if enabled:
531
+ current = entry.get("enabledMcpjsonServers")
532
+ current = list(current) if isinstance(current, list) else []
533
+ for name in enabled:
534
+ if name not in current:
535
+ current.append(name)
536
+ entry["enabledMcpjsonServers"] = current
537
+ disabled = entry.get("disabledMcpjsonServers")
538
+ if isinstance(disabled, list):
539
+ entry["disabledMcpjsonServers"] = [n for n in disabled if n not in enabled]
540
+ return doc, notes
541
+
542
+
543
+ def apply_claude(account_dir, eff):
544
+ before = _claude_load(account_dir)
545
+ after, notes = reconcile_claude(before, eff)
546
+ for note in notes:
547
+ _warn(f"{os.path.basename(account_dir.rstrip('/'))}: {note}")
548
+ if after == before and os.path.exists(os.path.join(account_dir, CONFIG_FILE["claude"])):
549
+ return "unchanged"
550
+ _claude_save(account_dir, after)
551
+ return "updated"
552
+
553
+
554
+ def snapshot_claude(account_dir):
555
+ doc = _claude_load(account_dir)
556
+ user = doc.get("mcpServers") if isinstance(doc.get("mcpServers"), dict) else {}
557
+ projects = {}
558
+ for path, entry in (doc.get("projects") or {}).items():
559
+ if not isinstance(entry, dict):
560
+ continue
561
+ mcp = entry.get("mcpServers") if isinstance(entry.get("mcpServers"), dict) else {}
562
+ enabled = entry.get("enabledMcpjsonServers")
563
+ disabled = entry.get("disabledMcpjsonServers")
564
+ projects[path] = {
565
+ "mcpServers": mcp,
566
+ "enabledMcpjsonServers": list(enabled) if isinstance(enabled, list) else [],
567
+ "disabledMcpjsonServers": list(disabled) if isinstance(disabled, list) else [],
568
+ }
569
+ return {"provider": "claude", "user": user, "projects": projects}
570
+
571
+
572
+ # ---------------------------------------------------------------- codex side
573
+
574
+ def _tomllib():
575
+ try:
576
+ import tomllib # noqa: WPS433 — 3.11+ only, hence the lazy import
577
+ except ImportError:
578
+ raise RegistryError("the codex registry needs Python 3.11+ (tomllib); "
579
+ "install a newer python3 (brew install python)") from None
580
+ return tomllib
581
+
582
+
583
+ def codex_block(name, block):
584
+ """Translate a Claude-shaped block into the keys codex's config.toml takes.
585
+
586
+ Returns (table, notes). A None table means "not expressible" (an SSE or
587
+ WebSocket transport): the caller skips the server rather than writing
588
+ something codex would refuse to start. `notes` lists what had to be left out.
589
+ """
590
+ validate_block(name, block)
591
+ notes = []
592
+ kind = _kind_of(block)
593
+ if kind not in ("stdio", "http"):
594
+ return None, [f"{name} uses a transport codex cannot express ({kind}) — skipped"]
595
+ out = {}
596
+ if kind == "stdio":
597
+ out["command"] = str(block.get("command", ""))
598
+ out["args"] = [str(arg) for arg in (block.get("args") or [])]
599
+ env, env_vars = {}, []
600
+ for key, value in (block.get("env") or {}).items():
601
+ key, value = str(key), str(value)
602
+ reference = ENV_REF.match(value)
603
+ if not reference:
604
+ env[key] = value
605
+ elif reference.group(1) == key and not reference.group(2):
606
+ # codex forwards a variable under its own name and expands nothing
607
+ # else, so only KEY=${KEY} survives the translation.
608
+ env_vars.append(key)
609
+ else:
610
+ notes.append(f"{name}: env {key}={value} cannot be expressed for codex "
611
+ "(it forwards a variable only under its own name, with no "
612
+ "default) — that variable is left out of the codex server")
613
+ for var in block.get("env_vars") or []:
614
+ if var not in env_vars:
615
+ env_vars.append(str(var))
616
+ if env:
617
+ out["env"] = env
618
+ if env_vars:
619
+ out["env_vars"] = env_vars
620
+ out["startup_timeout_sec"] = block.get("startup_timeout_sec", DEFAULT_STARTUP_TIMEOUT)
621
+ else:
622
+ out["url"] = str(block.get("url", ""))
623
+ headers, env_headers, bearer = {}, {}, None
624
+ for key, value in (block.get("headers") or {}).items():
625
+ key, value = str(key), str(value)
626
+ bearer_ref = BEARER_REF.match(value)
627
+ if bearer_ref and key.lower() == "authorization":
628
+ bearer = bearer_ref.group(1)
629
+ continue
630
+ reference = ENV_REF.match(value)
631
+ if reference and not reference.group(2):
632
+ env_headers[key] = reference.group(1)
633
+ elif reference:
634
+ notes.append(f"{name}: header {key}={value} carries a default codex cannot "
635
+ "express — header left out of the codex server")
636
+ else:
637
+ headers[key] = value
638
+ for key, source in (("http_headers", headers), ("env_http_headers", env_headers)):
639
+ explicit = block.get(key)
640
+ if isinstance(explicit, dict):
641
+ source.update({str(k): str(v) for k, v in explicit.items()})
642
+ if source:
643
+ out[key] = source
644
+ bearer = block.get("bearer_token_env_var") or bearer
645
+ if bearer:
646
+ out["bearer_token_env_var"] = str(bearer)
647
+ if "startup_timeout_sec" in block:
648
+ out["startup_timeout_sec"] = block["startup_timeout_sec"]
649
+ for key in ("tool_timeout_sec", "cwd", "enabled"):
650
+ if key in block:
651
+ out[key] = block[key]
652
+ for key, value in (block.get("codex") or {}).items():
653
+ if key not in out:
654
+ out[str(key)] = copy.deepcopy(value)
655
+ return out, notes
656
+
657
+
658
+ def codex_to_claude(name, table):
659
+ """The registry (Claude-shaped) block for a table read from codex's config."""
660
+ block = {}
661
+ if table.get("command"):
662
+ block["type"] = "stdio"
663
+ block["command"] = str(table["command"])
664
+ args = table.get("args") or []
665
+ block["args"] = [str(arg) for arg in (args if isinstance(args, list) else [args])]
666
+ env = {str(k): str(v) for k, v in (table.get("env") or {}).items()} \
667
+ if isinstance(table.get("env"), dict) else {}
668
+ env_vars = table.get("env_vars") or []
669
+ for var in (env_vars if isinstance(env_vars, list) else [env_vars]):
670
+ var = var.get("name") if isinstance(var, dict) else var
671
+ if isinstance(var, str) and var:
672
+ env[var] = "${" + var + "}"
673
+ block["env"] = env
674
+ elif table.get("url"):
675
+ block["type"] = "http"
676
+ block["url"] = str(table["url"])
677
+ headers = {}
678
+ if isinstance(table.get("http_headers"), dict):
679
+ headers.update({str(k): str(v) for k, v in table["http_headers"].items()})
680
+ if isinstance(table.get("env_http_headers"), dict):
681
+ headers.update({str(k): "${" + str(v) + "}" for k, v in table["env_http_headers"].items()})
682
+ if table.get("bearer_token_env_var"):
683
+ headers["Authorization"] = "Bearer ${" + str(table["bearer_token_env_var"]) + "}"
684
+ if headers:
685
+ block["headers"] = headers
686
+ else:
687
+ raise RegistryError(f"codex server {name!r} has neither a command nor a url")
688
+ for key in ("startup_timeout_sec", "tool_timeout_sec", "cwd", "enabled"):
689
+ if key in table:
690
+ block[key] = table[key]
691
+ extra = {key: copy.deepcopy(value) for key, value in table.items()
692
+ if key not in CODEX_MODELLED_KEYS}
693
+ if extra:
694
+ block["codex"] = extra
695
+ validate_block(name, block)
696
+ return block
697
+
698
+
699
+ def _toml_key(key):
700
+ return key if BARE_KEY.match(key) else json.dumps(key, ensure_ascii=False)
701
+
702
+
703
+ def _toml_value(value):
704
+ if isinstance(value, bool):
705
+ return "true" if value else "false"
706
+ if isinstance(value, (int, float)):
707
+ if not math.isfinite(value):
708
+ raise RegistryError("cannot write a non-finite number into config.toml")
709
+ return repr(value)
710
+ if isinstance(value, str):
711
+ return json.dumps(value, ensure_ascii=False)
712
+ if isinstance(value, list):
713
+ return "[" + ", ".join(_toml_value(item) for item in value) + "]"
714
+ if isinstance(value, dict):
715
+ return "{ " + ", ".join(f"{_toml_key(str(k))} = {_toml_value(v)}"
716
+ for k, v in value.items()) + " }"
717
+ raise RegistryError(f"cannot write {type(value).__name__} into config.toml")
718
+
719
+
720
+ def _toml_table(name, table):
721
+ lines = [f"[mcp_servers.{_toml_key(name)}]"]
722
+ for key, value in table.items():
723
+ lines.append(f"{_toml_key(str(key))} = {_toml_value(value)}")
724
+ return "\n".join(lines) + "\n"
725
+
726
+
727
+ def _name_pattern(name):
728
+ escaped = re.escape(name)
729
+ return (r"(?:" + escaped + r"|\"" + escaped + r"\"|'" + escaped + r"')")
730
+
731
+
732
+ def _strip_server_text(text, name):
733
+ """Drop every line that defines `mcp_servers.<name>` — its tables, its
734
+ sub-tables, a dotted key under a bare [mcp_servers] table, or a dotted root
735
+ key. Everything else is preserved byte for byte, including the comments and
736
+ blank lines that sit directly above the next table (they belong to it)."""
737
+ own_header = re.compile(r"^\s*\[\s*mcp_servers\s*\.\s*" + _name_pattern(name)
738
+ + r"\s*(?:\.[^\]]*)?\]\s*(?:#.*)?$")
739
+ root_header = re.compile(r"^\s*\[\s*mcp_servers\s*\]\s*(?:#.*)?$")
740
+ any_header = re.compile(r"^\s*\[")
741
+ dotted_in_root = re.compile(r"^\s*" + _name_pattern(name) + r"\s*(?:\.|=)")
742
+ dotted_top = re.compile(r"^\s*mcp_servers\s*\.\s*" + _name_pattern(name) + r"\s*(?:\.|=)")
743
+ state = "top"
744
+ out, pending = [], []
745
+ for line in text.splitlines(keepends=True):
746
+ if any_header.match(line):
747
+ if own_header.match(line.rstrip("\r\n")):
748
+ state = "mine"
749
+ pending = []
750
+ continue
751
+ out.extend(pending)
752
+ pending = []
753
+ state = "root" if root_header.match(line.rstrip("\r\n")) else "other"
754
+ out.append(line)
755
+ continue
756
+ if state == "mine":
757
+ if not line.strip() or line.lstrip().startswith("#"):
758
+ pending.append(line)
759
+ else:
760
+ pending = []
761
+ continue
762
+ if state == "root" and dotted_in_root.match(line):
763
+ continue
764
+ if state == "top" and dotted_top.match(line):
765
+ continue
766
+ out.append(line)
767
+ out.extend(line for line in pending if line.strip())
768
+ return "".join(out)
769
+
770
+
771
+ def _without(doc, names):
772
+ doc = copy.deepcopy(doc)
773
+ servers = doc.get("mcp_servers")
774
+ if isinstance(servers, dict):
775
+ for name in names:
776
+ servers.pop(name, None)
777
+ if not servers:
778
+ doc.pop("mcp_servers")
779
+ return doc
780
+
781
+
782
+ def reconcile_codex_text(text, eff):
783
+ """Return (new_text, notes)."""
784
+ tomllib = _tomllib()
785
+ try:
786
+ current = tomllib.loads(text)
787
+ except tomllib.TOMLDecodeError as exc:
788
+ raise RegistryError(f"config.toml does not parse: {exc}") from exc
789
+ existing = current.get("mcp_servers") if isinstance(current.get("mcp_servers"), dict) else {}
790
+ wanted, notes = {}, []
791
+ for name, block in eff.servers.items():
792
+ try:
793
+ table, block_notes = codex_block(name, block)
794
+ except RegistryError as exc:
795
+ notes.append(f"{exc} — skipped")
796
+ continue
797
+ notes.extend(block_notes)
798
+ if table is not None:
799
+ wanted[name] = table
800
+ retired = eff.retired | eff.retired_user
801
+ touched = [name for name, table in wanted.items() if existing.get(name) != table]
802
+ touched += [name for name in retired if name in existing]
803
+ if not touched:
804
+ return text, notes
805
+ result = text
806
+ for name in touched:
807
+ result = _strip_server_text(result, name)
808
+ additions = [_toml_table(name, wanted[name]) for name in touched if name in wanted]
809
+ if additions:
810
+ if result and not result.endswith("\n"):
811
+ result += "\n"
812
+ if result and not result.endswith("\n\n"):
813
+ result += "\n"
814
+ result += "\n".join(additions)
815
+ try:
816
+ parsed = tomllib.loads(result)
817
+ except tomllib.TOMLDecodeError as exc:
818
+ raise RegistryError(f"refusing to write config.toml: the edit would not parse ({exc})") from exc
819
+ if _without(parsed, touched) != _without(current, touched):
820
+ raise RegistryError("refusing to write config.toml: an unrelated setting would change "
821
+ "(unsupported layout for mcp_servers)")
822
+ written = parsed.get("mcp_servers") if isinstance(parsed.get("mcp_servers"), dict) else {}
823
+ for name in touched:
824
+ if name in wanted and written.get(name) != wanted[name]:
825
+ raise RegistryError(f"refusing to write config.toml: {name} did not round-trip")
826
+ if name not in wanted and name in written:
827
+ raise RegistryError(f"refusing to write config.toml: {name} could not be removed")
828
+ return result, notes
829
+
830
+
831
+ def apply_codex(account_dir, eff):
832
+ path = os.path.join(account_dir, CONFIG_FILE["codex"])
833
+ try:
834
+ with open(path, encoding="utf-8") as handle:
835
+ text = handle.read()
836
+ except FileNotFoundError:
837
+ text = ""
838
+ except (OSError, UnicodeDecodeError) as exc:
839
+ raise RegistryError(f"{path}: {exc}") from exc
840
+ result, notes = reconcile_codex_text(text, eff)
841
+ label = os.path.basename(account_dir.rstrip("/"))
842
+ for note in notes:
843
+ _warn(f"{label}: {note}")
844
+ if result == text and os.path.exists(path):
845
+ return "unchanged"
846
+ _atomic_write(path, result)
847
+ return "updated"
848
+
849
+
850
+ def snapshot_codex(account_dir):
851
+ tomllib = _tomllib()
852
+ path = os.path.join(account_dir, CONFIG_FILE["codex"])
853
+ try:
854
+ with open(path, encoding="utf-8") as handle:
855
+ doc = tomllib.loads(handle.read())
856
+ except FileNotFoundError:
857
+ doc = {}
858
+ except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
859
+ raise RegistryError(f"{path}: {exc}") from exc
860
+ servers = doc.get("mcp_servers") if isinstance(doc.get("mcp_servers"), dict) else {}
861
+ return {"provider": "codex",
862
+ "user": {name: table for name, table in servers.items() if isinstance(table, dict)}}
863
+
864
+
865
+ # ---------------------------------------------------------------- reconcile
866
+
867
+ def apply_account(root, provider, account_dir):
868
+ eff = effective(load_registry(root), load_overlay(root))
869
+ if provider == "claude":
870
+ state = apply_claude(account_dir, eff)
871
+ else:
872
+ state = apply_codex(account_dir, eff)
873
+ write_stamp(root, provider, account_dir)
874
+ return state
875
+
876
+
877
+ def apply_many(root, provider, account_dirs, quiet=False, fail_open=False):
878
+ """Reconcile each dir; returns the number of accounts that could not be."""
879
+ failures = 0
880
+ for account_dir in account_dirs:
881
+ label = os.path.basename(account_dir.rstrip("/"))
882
+ if not os.path.isdir(account_dir):
883
+ if not quiet:
884
+ print(f"{label}: skipped (no such directory)")
885
+ continue
886
+ try:
887
+ state = apply_account(root, provider, account_dir)
888
+ except Exception as exc: # noqa: BLE001 — one account must never stop the rest
889
+ failures += 1
890
+ _warn(f"{label}: {type(exc).__name__ if not isinstance(exc, RegistryError) else ''}"
891
+ f"{': ' if not isinstance(exc, RegistryError) else ''}{exc}")
892
+ continue
893
+ if not quiet:
894
+ print(f"{label}: {state}")
895
+ return failures
896
+
897
+
898
+ def _registry_present(root):
899
+ return any(os.path.exists(os.path.join(root, name)) for name in (REGISTRY_FILE, OVERLAY_FILE))
900
+
901
+
902
+ # ------------------------------------------------------------------- learn
903
+
904
+ def snapshot(provider, account_dir):
905
+ return snapshot_claude(account_dir) if provider == "claude" else snapshot_codex(account_dir)
906
+
907
+
908
+ def _learn_into(section, before, after):
909
+ """Mirror the difference between two snapshots into a section (registry or
910
+ overlay owner). Returns human-readable change descriptions (empty = nothing)."""
911
+ changes = []
912
+ provider = after.get("provider")
913
+ before_user, after_user = before.get("user") or {}, after.get("user") or {}
914
+ for name, block in after_user.items():
915
+ if before_user.get(name) == block:
916
+ continue
917
+ try:
918
+ learned = block if provider == "claude" else codex_to_claude(name, block)
919
+ validate_block(name, learned)
920
+ except RegistryError as exc:
921
+ _warn(f"not mirrored: {exc}")
922
+ continue
923
+ section["mcpServers"][name] = learned
924
+ section["retired"] = [n for n in section["retired"] if n != name]
925
+ section["retiredUser"] = [n for n in section["retiredUser"] if n != name]
926
+ changes.append(f'"{name}"')
927
+ for name in before_user:
928
+ if name not in after_user:
929
+ section["mcpServers"].pop(name, None)
930
+ # The client removed the USER-scope entry and nothing else: mirror exactly
931
+ # that. Project entries of the same name stay, in every account.
932
+ if name not in section["retiredUser"] and name not in section["retired"]:
933
+ section["retiredUser"].append(name)
934
+ changes.append(f'"{name}" (removed)')
935
+ if provider != "claude":
936
+ return changes
937
+ before_projects, after_projects = before.get("projects") or {}, after.get("projects") or {}
938
+ for path in sorted(set(before_projects) | set(after_projects)):
939
+ old, new = before_projects.get(path) or {}, after_projects.get(path) or {}
940
+ old_mcp, new_mcp = old.get("mcpServers") or {}, new.get("mcpServers") or {}
941
+ for name, block in new_mcp.items():
942
+ if old_mcp.get(name) != block:
943
+ try:
944
+ validate_block(name, block)
945
+ except RegistryError as exc:
946
+ _warn(f"not mirrored: {exc}")
947
+ continue
948
+ entry = section["projects"].setdefault(path, {"mcpServers": {}, "retired": []})
949
+ entry.setdefault("mcpServers", {})[name] = block
950
+ entry["retired"] = [n for n in entry.get("retired", []) if n != name]
951
+ changes.append(f'"{name}" (project {path})')
952
+ for name in old_mcp:
953
+ if name not in new_mcp:
954
+ entry = section["projects"].setdefault(path, {"mcpServers": {}, "retired": []})
955
+ entry.setdefault("mcpServers", {}).pop(name, None)
956
+ entry.setdefault("retired", [])
957
+ if name not in entry["retired"]:
958
+ entry["retired"].append(name)
959
+ changes.append(f'"{name}" (removed from project {path})')
960
+ old_enabled = set(old.get("enabledMcpjsonServers") or [])
961
+ new_enabled = set(new.get("enabledMcpjsonServers") or [])
962
+ if new_enabled - old_enabled:
963
+ entry = section["projects"].setdefault(path, {"mcpServers": {}, "retired": []})
964
+ current = entry.get("enabledMcpjsonServers")
965
+ current = list(current) if isinstance(current, list) else []
966
+ for name in sorted(new_enabled - old_enabled):
967
+ if name not in current:
968
+ current.append(name)
969
+ entry["enabledMcpjsonServers"] = current
970
+ changes.append(f"approved .mcp.json servers for project {path}")
971
+ return changes
972
+
973
+
974
+ # --------------------------------------------------------------------- CLI
975
+
976
+ def _parse_env(items):
977
+ env = {}
978
+ for item in items or []:
979
+ if "=" not in item:
980
+ raise RegistryError(f"-e expects KEY=VALUE, got {item!r}")
981
+ key, value = item.split("=", 1)
982
+ env[key] = value
983
+ return env
984
+
985
+
986
+ def _parse_headers(items):
987
+ headers = {}
988
+ for item in items or []:
989
+ if ":" not in item:
990
+ raise RegistryError(f"-H expects 'Header: value', got {item!r}")
991
+ key, value = item.split(":", 1)
992
+ headers[key.strip()] = value.strip()
993
+ return headers
994
+
995
+
996
+ def _split_command(argv):
997
+ if "--" in argv:
998
+ index = argv.index("--")
999
+ return argv[:index], argv[index + 1:]
1000
+ return argv, []
1001
+
1002
+
1003
+ def _normalize_project(path):
1004
+ if not path:
1005
+ return path
1006
+ return os.path.realpath(os.path.expanduser(path))
1007
+
1008
+
1009
+ class _Target:
1010
+ """Where a mutation lands: the synced registry, or — on a replica, whose registry
1011
+ the source machine overwrites on every push — this machine's overlay."""
1012
+
1013
+ def __init__(self, root):
1014
+ self.root = root
1015
+ self.replica = is_replica(root)
1016
+ self.overlay = load_overlay(root) if self.replica else None
1017
+ if self.replica:
1018
+ self.section = self.overlay["owners"].setdefault("local", _normalize_section({}))
1019
+ else:
1020
+ self.section = load_registry(root)
1021
+
1022
+ def save(self):
1023
+ if self.replica:
1024
+ save_overlay(self.root, self.overlay)
1025
+ else:
1026
+ save_registry(self.root, self.section)
1027
+
1028
+ @property
1029
+ def where(self):
1030
+ return "this machine's overlay (replica pool)" if self.replica else "the registry"
1031
+
1032
+
1033
+ def _upsert(section, name, block, scope, project):
1034
+ if scope == "project":
1035
+ if not project:
1036
+ raise RegistryError("--scope project needs --project PATH")
1037
+ entry = section["projects"].setdefault(project, {"mcpServers": {}, "retired": []})
1038
+ entry.setdefault("mcpServers", {})[name] = block
1039
+ entry["retired"] = [n for n in entry.get("retired", []) if n != name]
1040
+ else:
1041
+ section["mcpServers"][name] = block
1042
+ section["retired"] = [n for n in section["retired"] if n != name]
1043
+ section["retiredUser"] = [n for n in section["retiredUser"] if n != name]
1044
+
1045
+
1046
+ def _retire(section, name, scope, project, user_only=False):
1047
+ if scope == "project":
1048
+ if not project:
1049
+ raise RegistryError("--scope project needs --project PATH")
1050
+ entry = section["projects"].setdefault(project, {"mcpServers": {}, "retired": []})
1051
+ entry.setdefault("mcpServers", {}).pop(name, None)
1052
+ entry.setdefault("retired", [])
1053
+ if name not in entry["retired"]:
1054
+ entry["retired"].append(name)
1055
+ return
1056
+ section["mcpServers"].pop(name, None)
1057
+ key, other = ("retiredUser", "retired") if user_only else ("retired", "retiredUser")
1058
+ if name not in section[key]:
1059
+ section[key].append(name)
1060
+ section[other] = [n for n in section[other] if n != name]
1061
+
1062
+
1063
+ def _describe(block):
1064
+ if not isinstance(block, dict):
1065
+ return "invalid block"
1066
+ kind = _kind_of(block)
1067
+ if kind == "stdio":
1068
+ args = block.get("args") or []
1069
+ args = args if isinstance(args, list) else [args]
1070
+ return f"{kind:5} " + " ".join(shlex.quote(str(x)) for x in [block.get("command", "")] + args)
1071
+ return f"{kind:5} {block.get('url', '')}"
1072
+
1073
+
1074
+ def cmd_list(args):
1075
+ registry = load_registry(args.root)
1076
+ overlay = load_overlay(args.root)
1077
+ eff = effective(registry, overlay)
1078
+ if args.json:
1079
+ print(json.dumps({"mcpServers": eff.servers, "retired": sorted(eff.retired),
1080
+ "retiredUser": sorted(eff.retired_user), "projects": eff.projects,
1081
+ "overlayOwners": sorted(overlay.get("owners", {})),
1082
+ "replica": is_replica(args.root)}, indent=2))
1083
+ return 0
1084
+ if not eff.servers and not eff.retired and not eff.retired_user and not eff.projects:
1085
+ print(f"no MCP servers registered for the {args.provider} pool")
1086
+ return 0
1087
+ if eff.servers:
1088
+ print("servers (every account):")
1089
+ for name in sorted(eff.servers):
1090
+ origin = "registry" if name in registry["mcpServers"] else "overlay"
1091
+ print(f" {name:24} {_describe(eff.servers[name])} [{origin}]")
1092
+ if eff.retired:
1093
+ print("retired (removed from every account, every scope): " + ", ".join(sorted(eff.retired)))
1094
+ if eff.retired_user:
1095
+ print("retired at user scope only: " + ", ".join(sorted(eff.retired_user)))
1096
+ for path, entry in sorted(eff.projects.items()):
1097
+ print(f"project {path}:")
1098
+ for name in sorted(entry.get("mcpServers") or {}):
1099
+ print(f" {name:24} {_describe(entry['mcpServers'][name])}")
1100
+ if entry.get("retired"):
1101
+ print(" retired: " + ", ".join(sorted(entry["retired"])))
1102
+ if entry.get("enabledMcpjsonServers"):
1103
+ print(" approved .mcp.json servers: " + ", ".join(entry["enabledMcpjsonServers"]))
1104
+ if is_replica(args.root):
1105
+ print("(replica pool: changes made here stay in this machine's overlay; make fleet-wide "
1106
+ "changes on the source machine)")
1107
+ return 0
1108
+
1109
+
1110
+ def _apply_all(args, quiet):
1111
+ dirs = manifest_account_dirs(args.root)
1112
+ if not dirs and not quiet:
1113
+ print("no accounts in the manifest yet — the registry applies as accounts are added")
1114
+ failures = apply_many(args.root, args.provider, dirs, quiet=quiet)
1115
+ if failures:
1116
+ _warn(f"{failures} account(s) not reconciled — fix them and run: "
1117
+ f"{args.provider}-accounts mcp apply")
1118
+ return EXIT_PARTIAL
1119
+ return 0
1120
+
1121
+
1122
+ def cmd_add(args, command, leftovers):
1123
+ _validate_name(args.name)
1124
+ url = args.url
1125
+ if leftovers:
1126
+ if url or len(leftovers) > 1 or leftovers[0].startswith("-"):
1127
+ raise RegistryError(f"unexpected argument(s): {' '.join(leftovers)}")
1128
+ url = leftovers[0]
1129
+ transport = args.transport
1130
+ if transport is None:
1131
+ transport = "http" if (url and not command) else "stdio"
1132
+ if transport == "stdio":
1133
+ if not command:
1134
+ raise RegistryError("a stdio server needs `-- COMMAND [ARGS...]`")
1135
+ block = {"type": "stdio", "command": command[0], "args": command[1:],
1136
+ "env": _parse_env(args.env)}
1137
+ else:
1138
+ if not url:
1139
+ raise RegistryError(f"a {transport} server needs its URL (--url URL)")
1140
+ block = {"type": transport, "url": url}
1141
+ headers = _parse_headers(args.header)
1142
+ if headers:
1143
+ block["headers"] = headers
1144
+ validate_block(args.name, block)
1145
+ return _store(args, block)
1146
+
1147
+
1148
+ def cmd_add_json(args, leftovers):
1149
+ if leftovers:
1150
+ raise RegistryError(f"unexpected argument(s): {' '.join(leftovers)}")
1151
+ _validate_name(args.name)
1152
+ try:
1153
+ block = json.loads(args.block)
1154
+ except ValueError as exc:
1155
+ raise RegistryError(f"not JSON: {exc}") from exc
1156
+ validate_block(args.name, block)
1157
+ return _store(args, block)
1158
+
1159
+
1160
+ def _store(args, block):
1161
+ project = _normalize_project(args.project)
1162
+ with _Lock(args.root):
1163
+ target = _Target(args.root)
1164
+ _upsert(target.section, args.name, block, args.scope, project)
1165
+ target.save()
1166
+ where = f"project {project}" if args.scope == "project" else "user scope"
1167
+ _log(args.root, f"add {args.name} ({where}) {_describe(block)}")
1168
+ print(f"registered {args.name} ({where}) in {target.where} for every {args.provider} account")
1169
+ return _apply_all(args, quiet=False)
1170
+
1171
+
1172
+ def cmd_remove(args, leftovers):
1173
+ if leftovers:
1174
+ raise RegistryError(f"unexpected argument(s): {' '.join(leftovers)}")
1175
+ _validate_name(args.name, strict=False)
1176
+ project = _normalize_project(args.project)
1177
+ with _Lock(args.root):
1178
+ target = _Target(args.root)
1179
+ _retire(target.section, args.name, args.scope, project, user_only=args.user_only)
1180
+ target.save()
1181
+ if args.scope == "project":
1182
+ where = f"project {project}"
1183
+ else:
1184
+ where = "user scope only" if args.user_only else "every scope"
1185
+ _log(args.root, f"remove {args.name} ({where})")
1186
+ print(f"retired {args.name} ({where}) in {target.where}: removed from every "
1187
+ f"{args.provider} account")
1188
+ return _apply_all(args, quiet=False)
1189
+
1190
+
1191
+ def cmd_apply(args, leftovers):
1192
+ if leftovers:
1193
+ raise RegistryError(f"unexpected argument(s): {' '.join(leftovers)}")
1194
+ if args.all or not args.account_dir:
1195
+ rc = _apply_all(args, quiet=args.quiet)
1196
+ return 0 if args.fail_open else rc
1197
+ failures = apply_many(args.root, args.provider, args.account_dir, quiet=args.quiet)
1198
+ return 0 if args.fail_open or not failures else 1
1199
+
1200
+
1201
+ def cmd_snapshot(args):
1202
+ print(json.dumps(snapshot(args.provider, args.account_dir)))
1203
+ return 0
1204
+
1205
+
1206
+ def cmd_learn(args):
1207
+ try:
1208
+ with open(args.before, encoding="utf-8") as handle:
1209
+ before = json.load(handle)
1210
+ except (OSError, ValueError) as exc:
1211
+ raise RegistryError(f"snapshot {args.before}: {exc}") from exc
1212
+ after = snapshot(args.provider, args.account_dir)
1213
+ with _Lock(args.root):
1214
+ target = _Target(args.root)
1215
+ changes = _learn_into(target.section, before, after)
1216
+ if changes:
1217
+ target.save()
1218
+ if not changes:
1219
+ return 0
1220
+ _log(args.root, "learn " + ", ".join(changes))
1221
+ dirs = manifest_account_dirs(args.root)
1222
+ failures = apply_many(args.root, args.provider, dirs, quiet=True)
1223
+ print(f"mcp-registry: mirrored {', '.join(changes)} to {len(dirs)} {args.provider} "
1224
+ f"account(s) via {target.where}", file=sys.stderr)
1225
+ if target.replica:
1226
+ print("mcp-registry: this pool is a replica — the change stays on this machine; "
1227
+ "make fleet-wide changes on the source machine", file=sys.stderr)
1228
+ return EXIT_PARTIAL if failures else 0
1229
+
1230
+
1231
+ def cmd_import_local(args):
1232
+ try:
1233
+ payload = json.loads(sys.stdin.read() or "{}")
1234
+ except ValueError as exc:
1235
+ raise RegistryError(f"stdin is not JSON: {exc}") from exc
1236
+ if not isinstance(payload, dict):
1237
+ raise RegistryError("stdin must hold a JSON object")
1238
+ servers = payload.get("mcpServers") or {}
1239
+ retired = payload.get("retired") or []
1240
+ if not isinstance(servers, dict) or not isinstance(retired, list):
1241
+ raise RegistryError("expected {\"mcpServers\": {...}, \"retired\": [...]}")
1242
+ owner = _validate_name(args.owner)
1243
+ for name in list(servers):
1244
+ _validate_name(name)
1245
+ validate_block(name, servers[name])
1246
+ retired = [_validate_name(str(name), strict=False) for name in retired]
1247
+ with _Lock(args.root):
1248
+ overlay = load_overlay(args.root)
1249
+ previous = overlay["owners"].get(owner) or _normalize_section({})
1250
+ # A server this owner published before and no longer names is retired for
1251
+ # it: an owner that stops shipping a server means it, and every account
1252
+ # would otherwise keep the stale copy for ever.
1253
+ dropped = [name for name in previous["mcpServers"] if name not in servers]
1254
+ section = _normalize_section({"mcpServers": servers,
1255
+ "retired": sorted(set(retired) | set(dropped))})
1256
+ if previous == section and owner in overlay["owners"]:
1257
+ changed = False
1258
+ else:
1259
+ overlay["owners"][owner] = section
1260
+ save_overlay(args.root, overlay)
1261
+ changed = True
1262
+ if changed:
1263
+ _log(args.root, f"import-local owner={owner} servers={','.join(sorted(servers)) or '-'} "
1264
+ f"retired={','.join(sorted(section['retired'])) or '-'}")
1265
+ return _apply_all(args, quiet=args.quiet)
1266
+
1267
+
1268
+ def build_parser():
1269
+ parser = argparse.ArgumentParser(prog="mcp_registry.py", description=__doc__.split("\n\n")[0])
1270
+ parser.add_argument("--root", required=True, help="pool root (holds accounts.json)")
1271
+ parser.add_argument("--provider", required=True, choices=PROVIDERS)
1272
+ modes = parser.add_subparsers(dest="mode")
1273
+
1274
+ p = modes.add_parser("list")
1275
+ p.add_argument("--json", action="store_true")
1276
+
1277
+ for mode in ("add", "add-json"):
1278
+ p = modes.add_parser(mode)
1279
+ p.add_argument("name")
1280
+ if mode == "add-json":
1281
+ p.add_argument("block", help="Claude-style JSON block")
1282
+ else:
1283
+ p.add_argument("--url", help="URL for --transport http/sse (a bare URL argument works too)")
1284
+ p.add_argument("--transport", choices=("stdio", "http", "sse"))
1285
+ p.add_argument("-e", "--env", action="append", default=[])
1286
+ p.add_argument("-H", "--header", action="append", default=[])
1287
+ p.add_argument("--scope", choices=("user", "project"), default="user")
1288
+ p.add_argument("--project")
1289
+
1290
+ p = modes.add_parser("remove")
1291
+ p.add_argument("name")
1292
+ p.add_argument("--scope", choices=("user", "project"), default="user")
1293
+ p.add_argument("--project")
1294
+ p.add_argument("--user-only", action="store_true",
1295
+ help="retire the user-scope entry only; project entries keep the name")
1296
+
1297
+ p = modes.add_parser("apply")
1298
+ p.add_argument("--account-dir", action="append", default=[])
1299
+ p.add_argument("--all", action="store_true")
1300
+ p.add_argument("--fail-open", action="store_true")
1301
+ p.add_argument("--quiet", action="store_true")
1302
+
1303
+ p = modes.add_parser("snapshot")
1304
+ p.add_argument("--account-dir", required=True)
1305
+
1306
+ p = modes.add_parser("learn")
1307
+ p.add_argument("--account-dir", required=True)
1308
+ p.add_argument("--before", required=True)
1309
+ p.add_argument("--project", help="accepted for the shim's sake; every changed project is mirrored")
1310
+
1311
+ p = modes.add_parser("import-local")
1312
+ p.add_argument("--owner", required=True)
1313
+ p.add_argument("--quiet", action="store_true")
1314
+ return parser
1315
+
1316
+
1317
+ def main(argv=None):
1318
+ argv = list(sys.argv[1:] if argv is None else argv)
1319
+ head, command = _split_command(argv)
1320
+ parser = build_parser()
1321
+ # parse_known_args, not parse_args: an optional positional after options (the URL
1322
+ # in `add NAME --transport http URL`) is "unrecognized" to argparse before 3.12.
1323
+ args, leftovers = parser.parse_known_args(head)
1324
+ if not args.mode:
1325
+ parser.error("a mode is required (list, add, add-json, remove, apply, snapshot, "
1326
+ "learn, import-local)")
1327
+ if not os.path.isdir(args.root):
1328
+ raise RegistryError(f"pool root {args.root} does not exist")
1329
+ if args.provider == "codex" and sys.version_info < (3, 11):
1330
+ from codex_python import require_python311
1331
+ require_python311(os.path.abspath(__file__))
1332
+ if args.mode == "apply" and not _registry_present(args.root):
1333
+ return 0 # nothing registered: every account is already "reconciled"
1334
+ if args.mode == "list":
1335
+ return cmd_list(args)
1336
+ if args.mode == "add":
1337
+ return cmd_add(args, command, leftovers)
1338
+ if args.mode == "add-json":
1339
+ return cmd_add_json(args, leftovers)
1340
+ if args.mode == "remove":
1341
+ return cmd_remove(args, leftovers)
1342
+ if args.mode == "apply":
1343
+ return cmd_apply(args, leftovers)
1344
+ if leftovers:
1345
+ raise RegistryError(f"unexpected argument(s): {' '.join(leftovers)}")
1346
+ if args.mode == "snapshot":
1347
+ return cmd_snapshot(args)
1348
+ if args.mode == "learn":
1349
+ return cmd_learn(args)
1350
+ if args.mode == "import-local":
1351
+ return cmd_import_local(args)
1352
+ parser.error(f"unknown mode {args.mode}")
1353
+ return 2
1354
+
1355
+
1356
+ if __name__ == "__main__":
1357
+ fail_open = "--fail-open" in sys.argv
1358
+ try:
1359
+ sys.exit(main())
1360
+ except RegistryError as error:
1361
+ _warn(str(error))
1362
+ sys.exit(0 if fail_open else 1)
1363
+ except SystemExit as error:
1364
+ # argparse usage errors and codex_python's "no python 3.11" both land here.
1365
+ if fail_open and error.code not in (0, None):
1366
+ if isinstance(error.code, str):
1367
+ _warn(error.code)
1368
+ sys.exit(0)
1369
+ raise
1370
+ except Exception as error: # noqa: BLE001 — a traceback is never the shim's problem
1371
+ _warn(f"{type(error).__name__}: {error}")
1372
+ sys.exit(0 if fail_open else 1)