claude-multiacc 2.0.60 → 2.0.61

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.
Files changed (106) hide show
  1. package/README.md +6 -1
  2. package/bin/claude +14 -3
  3. package/bin/codex +12 -3
  4. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  5. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  6. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/mcp_mirror.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  39. package/lib/mcp_mirror.py +49 -0
  40. package/lib/mcp_mirror.sh +47 -0
  41. package/lib/mcp_registry.py +7 -2
  42. package/package.json +2 -2
  43. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  104. package/tests/run-tests.sh +3 -1
  105. package/tests/test_mcp_mirror.py +148 -0
  106. package/tests/test_mcp_mirror_args.py +129 -0
package/README.md CHANGED
@@ -571,7 +571,12 @@ claude-accounts mcp apply # re-apply now (repair drift)
571
571
  config dir outside the pool is plain passthrough. Global options in front of `mcp` are
572
572
  looked past the way the real parser does (`alias claude='claude
573
573
  --dangerously-skip-permissions'` puts one before every typed `claude mcp add`; until
574
- 2026-10-22 that kept motionsites in 2 of 11 accounts).
574
+ 2026-10-22 that kept motionsites in 2 of 11 accounts). Mirroring is limited to the server
575
+ named by the command, including its project approvals: a concurrent runner update to
576
+ another server cannot become a synced override or tombstone. An unrecognized command
577
+ option leaves the native command intact and warns that its change was not mirrored.
578
+ Claude's desktop bulk import mirrors changes except names and retirements belonging to
579
+ another machine-local overlay owner; an explicit named command can still override one.
575
580
  - **Adoption**: whatever reached an account by another route — the real binary run
576
581
  directly, a hand edit, another tool's installer — is adopted by the next reconcile: a
577
582
  server (user scope or a project entry) or a project's `.mcp.json` approval that no section
package/bin/claude CHANGED
@@ -224,15 +224,26 @@ mcp_mutation_argv() {
224
224
  i="$(cli_positional_at "$@")" || return 1
225
225
  [ "${!i}" = "mcp" ] || return 1
226
226
  j=$((i + 1))
227
- case "${!j:-}" in add|add-json|add-from-claude-desktop|remove) return 0 ;; esac
227
+ case "${!j:-}" in add|add-json|add-from-claude-desktop|remove) MCP_MUTATION_AT="$j"; return 0 ;; esac
228
228
  return 1
229
229
  }
230
230
 
231
231
  mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled the run
232
- local d="$1" snap="" rc
232
+ local d="$1" snap="" rc parse_rc name="" mirror_args=()
233
233
  shift
234
234
  mcp_mutation_argv "$@" || return 1
235
235
  [ "${CLAUDE_MULTIACC_MCP:-1}" != "0" ] && [ -f "$MCP_PY" ] || return 1
236
+ if [ "${!MCP_MUTATION_AT}" != add-from-claude-desktop ]; then
237
+ if name="$(. "$SELF_DIR/../lib/mcp_mirror.sh" && mcp_mutation_name claude "${@:$MCP_MUTATION_AT}")"; then
238
+ mirror_args=("--name=$name")
239
+ else
240
+ parse_rc=$?
241
+ "$REAL" "$@"; rc=$?
242
+ [ "$rc" -ne 0 ] || [ "$parse_rc" = 2 ] \
243
+ || printf 'claude-multiacc: could not identify the MCP target; change not mirrored\n' >&2
244
+ exit "$rc"
245
+ fi
246
+ fi
236
247
  mcp_ensure "$d"
237
248
  snap="$(mktemp "${TMPDIR:-/tmp}/claude-mcp-snap.XXXXXX" 2>/dev/null)" || snap=""
238
249
  if [ -n "$snap" ] && ! "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider claude snapshot \
@@ -243,7 +254,7 @@ mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled
243
254
  rc=$?
244
255
  if [ "$rc" -eq 0 ] && [ -n "$snap" ]; then
245
256
  "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider claude learn --account-dir "$d" \
246
- --before "$snap" --project "$(pwd -P 2>/dev/null || pwd)" </dev/null \
257
+ --before "$snap" --project "$(pwd -P 2>/dev/null || pwd)" ${mirror_args[@]+"${mirror_args[@]}"} </dev/null \
247
258
  || printf 'claude-multiacc: could not mirror the MCP change to the other accounts — run: claude-accounts mcp apply\n' >&2
248
259
  elif [ "$rc" -eq 0 ]; then
249
260
  printf 'claude-multiacc: could not mirror the MCP change to the other accounts — run: claude-accounts mcp apply\n' >&2
package/bin/codex CHANGED
@@ -165,15 +165,24 @@ mcp_mutation_argv() {
165
165
  *) break ;;
166
166
  esac
167
167
  done
168
- case "${!j:-}" in add|remove) return 0 ;; esac
168
+ case "${!j:-}" in add|remove) MCP_MUTATION_AT="$j"; return 0 ;; esac
169
169
  return 1
170
170
  }
171
171
 
172
172
  mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled the run
173
- local d="$1" snap="" rc
173
+ local d="$1" snap="" rc parse_rc name=""
174
174
  shift
175
175
  mcp_mutation_argv "$@" || return 1
176
176
  [ "${CODEX_MULTIACC_MCP:-1}" != "0" ] && [ -f "$MCP_PY" ] || return 1
177
+ if name="$(. "$SELF_DIR/../lib/mcp_mirror.sh" && mcp_mutation_name codex "${@:$MCP_MUTATION_AT}")"; then
178
+ :
179
+ else
180
+ parse_rc=$?
181
+ "$REAL" "$@"; rc=$?
182
+ [ "$rc" -ne 0 ] || [ "$parse_rc" = 2 ] \
183
+ || printf 'codex-multiacc: could not identify the MCP target; change not mirrored\n' >&2
184
+ exit "$rc"
185
+ fi
177
186
  mcp_ensure "$d"
178
187
  snap="$(mktemp "${TMPDIR:-/tmp}/codex-mcp-snap.XXXXXX" 2>/dev/null)" || snap=""
179
188
  if [ -n "$snap" ] && ! "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider codex snapshot \
@@ -184,7 +193,7 @@ mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled
184
193
  rc=$?
185
194
  if [ "$rc" -eq 0 ] && [ -n "$snap" ]; then
186
195
  "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider codex learn --account-dir "$d" \
187
- --before "$snap" </dev/null \
196
+ --before "$snap" "--name=$name" </dev/null \
188
197
  || printf 'codex-multiacc: could not mirror the MCP change to the other accounts — run: codex-accounts mcp apply\n' >&2
189
198
  elif [ "$rc" -eq 0 ]; then
190
199
  printf 'codex-multiacc: could not mirror the MCP change to the other accounts — run: codex-accounts mcp apply\n' >&2
@@ -0,0 +1,49 @@
1
+ """Keep stock MCP mutations scoped to the server the command actually named."""
2
+
3
+ import copy
4
+
5
+
6
+ def _external_names(overlay, target_owner):
7
+ """Bulk imports cannot promote another local owner's machine-specific entries."""
8
+ user, everywhere, projects = set(), set(), {}
9
+ for owner, section in overlay.get("owners", {}).items():
10
+ if owner == target_owner:
11
+ continue
12
+ everywhere.update(section.get("retired") or [])
13
+ user.update(section.get("mcpServers") or {})
14
+ user.update(section.get("retiredUser") or [])
15
+ for path, entry in (section.get("projects") or {}).items():
16
+ names = projects.setdefault(path, set())
17
+ names.update(entry.get("mcpServers") or {})
18
+ names.update(entry.get("retired") or [])
19
+ names.update(entry.get("enabledMcpjsonServers") or [])
20
+ return user | everywhere, everywhere, projects
21
+
22
+
23
+ def _filtered_snapshot(snapshot, keep_user, keep_project):
24
+ result = copy.deepcopy(snapshot)
25
+ result["user"] = {name: block for name, block in (result.get("user") or {}).items()
26
+ if keep_user(name)}
27
+ for path, entry in (result.get("projects") or {}).items():
28
+ entry["mcpServers"] = {name: block for name, block in (entry.get("mcpServers") or {}).items()
29
+ if keep_project(path, name)}
30
+ for key in ("enabledMcpjsonServers", "disabledMcpjsonServers"):
31
+ entry[key] = [name for name in (entry.get(key) or []) if keep_project(path, name)]
32
+ return result
33
+
34
+
35
+ def scoped_snapshots(before, after, name, overlay, target_owner=None):
36
+ """Named commands mirror only their target, including project approvals.
37
+
38
+ Without a name (desktop bulk import), externally owned names are excluded even
39
+ if a delayed reconcile writes an older generation than the current overlay.
40
+ """
41
+ if name is not None:
42
+ keep_user = lambda candidate: candidate == name
43
+ keep_project = lambda _path, candidate: candidate == name
44
+ else:
45
+ user, everywhere, projects = _external_names(overlay, target_owner)
46
+ keep_user = lambda candidate: candidate not in user
47
+ keep_project = lambda path, candidate: candidate not in everywhere | projects.get(path, set())
48
+ return (_filtered_snapshot(before, keep_user, keep_project),
49
+ _filtered_snapshot(after, keep_user, keep_project))
@@ -0,0 +1,47 @@
1
+ # Target of a stock MCP mutation. Global CLI options are already skipped by the shim.
2
+ # Unknown options fail closed: the native command still runs, but is not mirrored.
3
+ # Status 2 is a help request before the target; pass it through without a warning.
4
+ mcp_mutation_name() { # provider, verb, then the verb's argv -> server name
5
+ local provider="$1" verb="$2" values="" variadic="" flags=" " a opt attached
6
+ shift 2
7
+ case "$provider:$verb" in
8
+ claude:add)
9
+ values=" -s --scope -t --transport --callback-port --client-id "
10
+ variadic=" -e --env -H --header "; flags="$flags--client-secret " ;;
11
+ claude:add-json) values=" -s --scope "; flags="$flags--client-secret " ;;
12
+ claude:remove) values=" -s --scope " ;;
13
+ codex:add)
14
+ values=" -c --config --env --enable --disable --url --bearer-token-env-var \
15
+ --oauth-client-id --oauth-client-secret --oauth-client-registration --oauth-resource " ;;
16
+ codex:remove) values=" -c --config --enable --disable " ;;
17
+ *) return 1 ;;
18
+ esac
19
+ while [ "$#" -gt 0 ]; do
20
+ a="$1"; shift
21
+ case "$a" in
22
+ --) [ "$#" -gt 0 ] || return 1; printf '%s\n' "$1"; return 0 ;;
23
+ -h|--help) return 2 ;;
24
+ -?*) ;;
25
+ *) printf '%s\n' "$a"; return 0 ;;
26
+ esac
27
+ opt="$a"; attached=0
28
+ case "$a" in
29
+ --*=*) opt="${a%%=*}"; attached=1 ;;
30
+ --*) ;;
31
+ -?*) opt="${a:0:2}"; [ "${#a}" -le 2 ] || attached=1 ;;
32
+ esac
33
+ case "$values" in
34
+ *" $opt "*)
35
+ if [ "$attached" = 0 ]; then [ "$#" -gt 0 ] || return 1; shift; fi
36
+ continue ;;
37
+ esac
38
+ case "$variadic" in
39
+ *" $opt "*)
40
+ while [ "$#" -gt 0 ]; do case "$1" in -*) break ;; esac; shift; done
41
+ continue ;;
42
+ esac
43
+ case "$flags" in *" $opt "*) [ "$attached" = 0 ] || return 1; continue ;; esac
44
+ return 1
45
+ done
46
+ return 1
47
+ }
@@ -1413,14 +1413,18 @@ def cmd_snapshot(args):
1413
1413
 
1414
1414
 
1415
1415
  def cmd_learn(args):
1416
+ from mcp_mirror import scoped_snapshots
1417
+
1416
1418
  try:
1417
1419
  with open(args.before, encoding="utf-8") as handle:
1418
1420
  before = json.load(handle)
1419
1421
  except (OSError, ValueError) as exc:
1420
1422
  raise RegistryError(f"snapshot {args.before}: {exc}") from exc
1421
- after = snapshot(args.provider, args.account_dir)
1422
1423
  with _Lock(args.root):
1424
+ after = snapshot(args.provider, args.account_dir)
1423
1425
  target = _Target(args.root)
1426
+ before, after = scoped_snapshots(before, after, args.name, load_overlay(args.root),
1427
+ "local" if target.replica else None)
1424
1428
  changes = _learn_into(target.section, before, after)
1425
1429
  if changes:
1426
1430
  target.save()
@@ -1519,7 +1523,8 @@ def build_parser():
1519
1523
  p = modes.add_parser("learn")
1520
1524
  p.add_argument("--account-dir", required=True)
1521
1525
  p.add_argument("--before", required=True)
1522
- p.add_argument("--project", help="accepted for the shim's sake; every changed project is mirrored")
1526
+ p.add_argument("--name", help="mirror only this stock command's server, including project approvals")
1527
+ p.add_argument("--project", help="accepted for the shim; --name applies across project entries")
1523
1528
 
1524
1529
  p = modes.add_parser("import-local")
1525
1530
  p.add_argument("--owner", required=True)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.60",
3
+ "version": "2.0.61",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "test": "npm run test:core && npm run test:seeding && npm run test:commands",
59
59
  "test:core": "bash tests/run-tests.sh && npm run test:selector && python3 tests/test_shim_path.py",
60
60
  "test:selector": "python3 -m unittest discover -s tests -p 'test_selector*.py'",
61
- "test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3 tests/test_mcp_registry.py && python3 tests/test_mcp_oauth.py && npm run test:codex-settings",
61
+ "test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3 -m unittest discover -s tests -p 'test_mcp_*.py' && npm run test:codex-settings",
62
62
  "test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
63
63
  "test:commands": "python3 tests/test_packaged_commands.py"
64
64
  },
@@ -10251,7 +10251,9 @@ fi
10251
10251
  # reconciles with whatever `python3` is on PATH, and a runner Mac's is /usr/bin/python3.
10252
10252
  # Skipped silently where that interpreter is absent or already 3.11+ (CI is ubuntu).
10253
10253
  if [ -x /usr/bin/python3 ] && /usr/bin/python3 -c 'import sys; sys.exit(0 if sys.version_info < (3, 11) else 1)' 2>/dev/null; then
10254
- if /usr/bin/python3 "$REPO_DIR/tests/test_mcp_registry.py" >/dev/null 2>&1; then
10254
+ # Only this unit suite's disposable pools opt back in; legacy sections stay protected.
10255
+ if CLAUDE_MULTIACC_MCP_ADOPT=1 CODEX_MULTIACC_MCP_ADOPT=1 \
10256
+ /usr/bin/python3 "$REPO_DIR/tests/test_mcp_registry.py" >/dev/null 2>&1; then
10255
10257
  t_ok "MCP registry unit suite on stock python $(/usr/bin/python3 -c 'import sys;print("%d.%d"%sys.version_info[:2])')"
10256
10258
  else
10257
10259
  t_fail "MCP registry unit suite on stock python" "/usr/bin/python3 tests/test_mcp_registry.py failed"
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env python3
2
+ """Stock mutations must not promote concurrent runner publications into the registry."""
3
+
4
+ import json
5
+ from pathlib import Path
6
+ import unittest
7
+ from unittest.mock import patch
8
+
9
+ from test_mcp_registry import HAS_TOMLLIB, HTTP, R, RegistryCase, STDIO
10
+
11
+ APP = "appinspire-mcp"
12
+ MOTION = "motionsites"
13
+ V1 = {"type": "stdio", "command": "/releases/1000249/python", "args": ["launcher.pyc"], "env": {}}
14
+ V2 = {"type": "stdio", "command": "/releases/1000250/python", "args": ["launcher.pyc"], "env": {}}
15
+ V3 = {"type": "stdio", "command": "/bin/sh", "args": ["-p", "-c", "exec node server.mjs"], "env": {}}
16
+
17
+
18
+ class MirrorCases:
19
+ def setUp(self):
20
+ super().setUp()
21
+ if self.provider == "codex" and not HAS_TOMLLIB:
22
+ self.skipTest("Codex requires tomllib")
23
+ if self.replica:
24
+ (self.root / "sync-role").write_text("replica\n")
25
+
26
+ def publish(self, servers):
27
+ rc, _, err = self.run_cli(self.provider, "import-local", "--owner", "app-robot",
28
+ stdin=json.dumps({"mcpServers": servers}))
29
+ self.assertEqual(rc, 0, err)
30
+
31
+ def write_account(self, servers, projects=None):
32
+ if self.provider == "claude":
33
+ R._claude_save(self.acct(), {"mcpServers": servers, "projects": projects or {}})
34
+ else:
35
+ eff = R.Effective()
36
+ eff.servers = servers
37
+ text, notes = R.reconcile_codex_text("", eff)
38
+ self.assertFalse(notes)
39
+ (Path(self.acct()) / "config.toml").write_text(text)
40
+
41
+ def before(self):
42
+ rc, snapshot, err = self.run_cli(self.provider, "snapshot", "--account-dir", self.acct())
43
+ self.assertEqual(rc, 0, err)
44
+ path = self.root / "before.json"
45
+ path.write_text(snapshot)
46
+ return str(path)
47
+
48
+ def learn(self, before, name):
49
+ scope = ["--name", name] if name is not None else []
50
+ rc, _, err = self.run_cli(self.provider, "learn", "--account-dir", self.acct(),
51
+ "--before", before, *scope)
52
+ self.assertEqual(rc, 0, err)
53
+ return R._Target(str(self.root)).section
54
+
55
+ def test_named_add_ignores_delayed_runner_generation(self):
56
+ self.publish({APP: V1})
57
+ before = self.before()
58
+ self.publish({APP: V2})
59
+ # import-local commits G3 under the lock, then applies accounts outside it.
60
+ # Pause before apply: the client still sees G2, which differs from current G3.
61
+ with patch.object(R, "_apply_all", return_value=0):
62
+ self.publish({APP: V3})
63
+ self.write_account({APP: V2, MOTION: HTTP})
64
+ section = self.learn(before, MOTION)
65
+ self.assertEqual(set(section["mcpServers"]), {MOTION})
66
+ self.assertEqual(section["retired"] + section["retiredUser"], [])
67
+ self.assertEqual(self.overlay()["owners"]["app-robot"]["mcpServers"], {APP: V3})
68
+ for account in ("acct-01", "acct-02"):
69
+ snapshot = R.snapshot(self.provider, self.acct(account))
70
+ self.assertEqual(snapshot["user"][APP]["command"], "/bin/sh")
71
+ self.assertEqual(snapshot["user"][MOTION]["url"], HTTP["url"])
72
+
73
+ def test_named_remove_does_not_promote_runner_retirement(self):
74
+ self.publish({APP: V1})
75
+ self.run_cli(self.provider, "add-json", MOTION, json.dumps(HTTP))
76
+ before = self.before()
77
+ self.publish({})
78
+ self.write_account({})
79
+ section = self.learn(before, MOTION)
80
+ self.assertEqual(section["mcpServers"], {})
81
+ self.assertEqual(section["retiredUser"], [MOTION])
82
+ self.assertEqual(section["retired"], [])
83
+ self.assertEqual(self.overlay()["owners"]["app-robot"]["retired"], [APP])
84
+
85
+ def test_explicit_owner_override_and_removal_still_mirror(self):
86
+ self.publish({APP: V1})
87
+ before = self.before()
88
+ self.write_account({APP: HTTP})
89
+ section = self.learn(before, APP)
90
+ self.assertEqual(section["mcpServers"][APP]["url"], HTTP["url"])
91
+ self.assertEqual(self.overlay()["owners"]["app-robot"]["mcpServers"], {APP: V1})
92
+ before = self.before()
93
+ self.write_account({})
94
+ section = self.learn(before, APP)
95
+ self.assertEqual(section["mcpServers"], {})
96
+ self.assertEqual(section["retiredUser"], [APP])
97
+ self.assertEqual(section["retired"], [])
98
+
99
+ def test_bulk_import_excludes_external_updates_and_retirements(self):
100
+ self.publish({APP: V1, "old-runner-tool": STDIO})
101
+ self.run_cli(self.provider, "import-local", "--owner", "local",
102
+ stdin=json.dumps({"mcpServers": {"local-tool": V1}}))
103
+ before = self.before()
104
+ self.publish({APP: V2})
105
+ with patch.object(R, "_apply_all", return_value=0):
106
+ self.publish({APP: V3})
107
+ self.write_account({APP: V2, MOTION: HTTP, "local-tool": HTTP})
108
+ section = self.learn(before, None)
109
+ self.assertEqual(set(section["mcpServers"]), {MOTION, "local-tool"} if self.replica else {MOTION})
110
+ local = self.overlay()["owners"]["local"]["mcpServers"]["local-tool"]
111
+ self.assertEqual(local.get("url") if self.replica else local, HTTP["url"] if self.replica else V1)
112
+ self.assertEqual(section["retired"] + section["retiredUser"], [])
113
+
114
+ def test_named_project_changes_and_approvals_stay_scoped(self):
115
+ if self.provider != "claude":
116
+ self.skipTest("project MCP entries are Claude-only")
117
+ for name in (MOTION, "other"):
118
+ self.run_cli("claude", "add-json", name, json.dumps(STDIO), "--scope", "project",
119
+ "--project", "/project")
120
+ before = self.before()
121
+ self.write_account({}, {"/project": {"mcpServers": {MOTION: HTTP, "other": HTTP},
122
+ "enabledMcpjsonServers": [MOTION, "other"]}})
123
+ section = self.learn(before, MOTION)
124
+ project = section["projects"]["/project"]
125
+ self.assertEqual(project["mcpServers"], {MOTION: HTTP, "other": STDIO})
126
+ self.assertEqual(project["enabledMcpjsonServers"], [MOTION])
127
+ other = self.claude_doc("acct-02")["projects"]["/project"]
128
+ self.assertEqual(other["enabledMcpjsonServers"], [MOTION])
129
+
130
+
131
+ class ClaudeSourceTests(MirrorCases, RegistryCase):
132
+ provider, replica = "claude", False
133
+
134
+
135
+ class ClaudeReplicaTests(MirrorCases, RegistryCase):
136
+ provider, replica = "claude", True
137
+
138
+
139
+ class CodexSourceTests(MirrorCases, RegistryCase):
140
+ provider, replica = "codex", False
141
+
142
+
143
+ class CodexReplicaTests(MirrorCases, RegistryCase):
144
+ provider, replica = "codex", True
145
+
146
+
147
+ if __name__ == "__main__":
148
+ unittest.main()
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """MCP target parsing uses native verb options, without reading command payloads."""
3
+
4
+ from pathlib import Path
5
+ import subprocess
6
+ import tempfile
7
+ import unittest
8
+
9
+ from test_mcp_registry import R
10
+
11
+ HELPER = Path(__file__).resolve().parents[1] / "lib" / "mcp_mirror.sh"
12
+
13
+
14
+ class MutationNameTests(unittest.TestCase):
15
+ def parse(self, *args):
16
+ return subprocess.run(["/bin/bash", "-uc", '. "$1"; shift; mcp_mutation_name "$@"',
17
+ "mcp-target-test", str(HELPER), *args], capture_output=True, text=True)
18
+
19
+ def test_native_options_before_name(self):
20
+ cases = [
21
+ ("claude", "add", "-s", "user", "-t", "http", "motionsites", "https://example.test"),
22
+ ("claude", "add", "-suser", "-thttp", "motionsites", "https://example.test"),
23
+ ("claude", "add", "--scope=user", "--callback-port", "1234", "--client-id", "cid",
24
+ "--client-secret", "motionsites", "https://example.test"),
25
+ ("claude", "add", "-e", "A=1", "B=2", "-H", "X: 1", "Y: 2", "--", "motionsites", "echo"),
26
+ ("claude", "add", "-eA=1", "B=2", "--scope=user", "motionsites", "--", "echo", "--unknown"),
27
+ ("claude", "add-json", "-suser", "--client-secret", "motionsites", '{"command":"echo"}'),
28
+ ("claude", "remove", "--scope=user", "motionsites"),
29
+ ("codex", "add", "-c", "x=1", "--env", "A=1", "--url=https://example.test", "motionsites"),
30
+ ("codex", "add", "--enable", "x", "--disable=y", "--bearer-token-env-var", "MCP_TOKEN",
31
+ "--oauth-client-id", "cid", "--oauth-client-secret", "dummy", "--oauth-resource", "r",
32
+ "--oauth-client-registration", "dynamic", "motionsites"),
33
+ ("codex", "remove", "-cx=1", "--enable=x", "--disable", "y", "motionsites"),
34
+ ("codex", "add", "motionsites", "--", "node", "--env", "appinspire-mcp"),
35
+ ("claude", "add", "motionsites", "--", "node", "--help"),
36
+ ("codex", "add", "motionsites", "--help"),
37
+ ("claude", "remove", "motionsites", "-h"),
38
+ ("claude", "add", "--client-id", "--help", "motionsites", "https://example.test"),
39
+ ]
40
+ for args in cases:
41
+ with self.subTest(args=args):
42
+ result = self.parse(*args)
43
+ self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "motionsites\n", ""))
44
+
45
+ def test_unknown_or_incomplete_options_never_become_a_broad_mirror(self):
46
+ cases = [
47
+ ("claude", "add", "--future-value", "appinspire-mcp", "motionsites"),
48
+ ("claude", "remove", "--scope"),
49
+ ("claude", "add", "-e", "A=1", "motionsites", "echo"),
50
+ ("claude", "add-json", "--client-secret=unexpected", "motionsites", "{}"),
51
+ ("codex", "add", "--unknown=value", "motionsites"),
52
+ ("codex", "remove", "--config"),
53
+ ("codex", "add", "--"),
54
+ ("claude", "add-from-claude-desktop"),
55
+ ]
56
+ for args in cases:
57
+ with self.subTest(args=args):
58
+ result = self.parse(*args)
59
+ self.assertNotEqual(result.returncode, 0)
60
+ self.assertEqual(result.stdout, "")
61
+
62
+ def test_help_without_a_target_has_a_distinct_quiet_status(self):
63
+ for provider in ("claude", "codex"):
64
+ for flag in ("-h", "--help"):
65
+ result = self.parse(provider, "add", flag)
66
+ self.assertEqual((result.returncode, result.stdout, result.stderr), (2, "", ""))
67
+
68
+ def run_shim(self, provider, args, native_exit=0):
69
+ with tempfile.TemporaryDirectory() as work:
70
+ root = Path(work)
71
+ pool, fakebin = root / "pool", root / "bin"
72
+ (pool / "acct-01").mkdir(parents=True)
73
+ fakebin.mkdir()
74
+ (pool / "accounts.json").write_text('{"version":1,"accounts":[{"id":"acct-01"}]}')
75
+ native = fakebin / provider
76
+ native.write_text('#!/bin/sh\nprintf "NATIVE\\n"\nprintf "%s\\n" "$@"\nexit "$FAKE_EXIT"\n')
77
+ native.chmod(0o755)
78
+ logger = fakebin / "mcp-python"
79
+ logger.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >> "$MCP_TEST_LOG"\n'
80
+ 'case "$*" in *" snapshot "*) printf "{}\\n" ;; esac\n')
81
+ logger.chmod(0o755)
82
+ log = root / "mirror.log"
83
+ env = {"PATH": f"{fakebin}:/usr/bin:/bin", "HOME": work, "USER": "test", "LOGNAME": "test",
84
+ "TMPDIR": work, "FAKE_EXIT": str(native_exit), "MCP_TEST_LOG": str(log),
85
+ "CLAUDE_MULTIACC_PYTHON": str(logger), f"{provider.upper()}_ACCOUNTS_ROOT": str(pool),
86
+ f"{provider.upper()}_SHIM_ACTIVE": "1",
87
+ "CLAUDE_CONFIG_DIR" if provider == "claude" else "CODEX_HOME": str(pool / "acct-01")}
88
+ result = subprocess.run([str(HELPER.parents[1] / "bin" / provider), *args], env=env,
89
+ capture_output=True, text=True, timeout=10)
90
+ return result, log.read_text() if log.exists() else ""
91
+
92
+ def test_literal_help_global_value_still_mirrors(self):
93
+ args = ["--system-prompt", "--help", "mcp", "add-json", "--scope", "user", "motionsites",
94
+ '{"command":"echo","args":[]}']
95
+ result, log = self.run_shim("claude", args)
96
+ self.assertEqual((result.returncode, result.stderr), (0, ""))
97
+ self.assertEqual(result.stdout.splitlines(), ["NATIVE", *args])
98
+ self.assertIn("\nlearn\n", log)
99
+ self.assertIn("\n--name=motionsites\n", log)
100
+
101
+ def test_leading_dash_name_reaches_the_registry_parser(self):
102
+ args = ["mcp", "add", "--", "-probe-name", "echo"]
103
+ for provider in ("claude", "codex"):
104
+ result, log = self.run_shim(provider, args)
105
+ self.assertEqual((result.returncode, result.stderr), (0, ""))
106
+ self.assertEqual(result.stdout.splitlines(), ["NATIVE", *args])
107
+ words = log.splitlines()
108
+ tail = words[words.index("learn") + 1:]
109
+ parsed = R.build_parser().parse_args(["--root", "unused", "--provider", provider, "learn", *tail])
110
+ self.assertEqual(parsed.name, "-probe-name")
111
+
112
+ def test_help_is_quiet_native_passthrough(self):
113
+ for provider in ("claude", "codex"):
114
+ result, log = self.run_shim(provider, ["mcp", "add", "--help"])
115
+ self.assertEqual((result.returncode, result.stderr, log), (0, "", ""))
116
+ self.assertEqual(result.stdout.splitlines(), ["NATIVE", "mcp", "add", "--help"])
117
+
118
+ def test_unknown_options_keep_native_argv_and_exit_without_mirroring(self):
119
+ args = ["mcp", "add", "--future-value", "appinspire-mcp", "motionsites"]
120
+ for provider in ("claude", "codex"):
121
+ for native_exit in (0, 7):
122
+ result, log = self.run_shim(provider, args, native_exit)
123
+ self.assertEqual((result.returncode, log), (native_exit, ""))
124
+ self.assertEqual(result.stdout.splitlines(), ["NATIVE", *args])
125
+ self.assertEqual("change not mirrored" in result.stderr, native_exit == 0)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ unittest.main()