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.
- package/README.md +78 -0
- package/bin/claude +122 -6
- package/bin/claude-accounts +145 -3
- package/bin/codex +116 -7
- package/bin/codex-accounts +137 -3
- package/docs/ACCOUNT_OPERATIONS.md +34 -0
- package/docs/CODEX.md +26 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/common.sh +19 -0
- package/lib/mcp_registry.py +1372 -0
- package/package.json +2 -2
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/run-tests.sh +605 -4
- package/tests/test_account_seeding.py +40 -0
- package/tests/test_mcp_registry.py +597 -0
|
@@ -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)
|