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