@mmerterden/multi-agent-pipeline 20.8.1 → 20.8.2

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 (75) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/docs/facts.json +3 -3
  3. package/index.js +1 -0
  4. package/install/_codex-agents.mjs +1 -1
  5. package/install/_common.mjs +486 -53
  6. package/install/_mcp-register.mjs +173 -117
  7. package/install/claude.mjs +281 -220
  8. package/install/codex.mjs +7 -7
  9. package/install/copilot.mjs +13 -11
  10. package/install/index.mjs +92 -27
  11. package/install/templates/claude-hooks.json +9 -9
  12. package/manifest.json +75 -78
  13. package/package.json +4 -1
  14. package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
  15. package/pipeline/lib/confusables.json +79 -33
  16. package/pipeline/lib/extract-conventions.sh +3 -3
  17. package/pipeline/lib/json-file-lock.mjs +27 -7
  18. package/pipeline/lib/normalize-text.mjs +86 -17
  19. package/pipeline/lib/outbound-gate.mjs +13 -4
  20. package/pipeline/lib/redact.mjs +87 -14
  21. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  22. package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
  23. package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
  24. package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
  25. package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
  26. package/pipeline/scripts/agent-guard.py +150 -25
  27. package/pipeline/scripts/audit-log.sh +3 -4
  28. package/pipeline/scripts/autopilot-runner.mjs +14 -5
  29. package/pipeline/scripts/doctor.mjs +8 -2
  30. package/pipeline/scripts/log-metric.sh +9 -3
  31. package/pipeline/scripts/migrate-prefs.mjs +18 -4
  32. package/pipeline/scripts/pre-commit-check.sh +119 -31
  33. package/pipeline/scripts/scan-agent-config.sh +9 -9
  34. package/pipeline/scripts/unattended_policy.py +12 -3
  35. package/pipeline/scripts/uninstall.mjs +88 -1
  36. package/pipeline/scripts/usage-identity.mjs +1 -1
  37. package/pipeline/scripts/usage-register.mjs +1 -1
  38. package/pipeline/skills/.skill-manifest.json +11 -11
  39. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
  40. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
  41. package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
  42. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  43. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
  44. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
  45. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +2 -1
  46. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +14 -13
  47. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
  48. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +7 -7
  49. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +105 -4
  50. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +20 -5
  51. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +8 -7
  52. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -8
  53. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +20 -7
  54. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
  55. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +8 -7
  56. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +15 -8
  57. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +17 -9
  58. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -17
  59. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +28 -7
  60. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
  61. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
  62. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
  63. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
  64. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
  65. package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
  66. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
  67. package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
  68. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
  69. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
  70. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
  71. package/pipeline/skills/shared/external/skill-creator/template.md +1 -1
  72. package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
  73. package/pipeline/scripts/gen-ref-toc.mjs +0 -279
  74. package/pipeline/scripts/make-manifest.mjs +0 -199
  75. package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
@@ -1,22 +1,12 @@
1
1
  /**
2
2
  * Register the companion MCP server with a host CLI.
3
3
  *
4
- * WHY THIS IS SHARED
5
- *
6
- * Codex got MCP registration when its installer was written; Copilot never did, and
7
- * neither did Claude Code. The result was a host that carried the full pipeline skill
8
- * set and could not call a single one of the 83 toolkit tools those skills depend
9
- * on - `design-check`, every `ios_*` / `android_*` simulator call, the archive audits.
10
- * Nothing failed at install time, and the skills only fail when a run actually reaches
11
- * for a tool.
12
- *
13
- * Claude Code was the longest-lived instance of that bug and the easiest to miss,
14
- * because a maintainer who registered the server by hand once sees a working tree
15
- * forever while every fresh install ships without it.
4
+ * Every host's skills call the toolkit's tools (`design-check`, every `ios_*` /
5
+ * `android_*` simulator call, the archive audits), and a missing server shows up
6
+ * only when a run reaches for one of them, so each installer registers it.
16
7
  *
17
8
  * All three CLIs take the same shape (`<cli> mcp add [scope] <name> -- <command>
18
- * [args...]`), so the difference was purely which installers called it. One
19
- * implementation, three callers.
9
+ * [args...]`), so one implementation serves all three installers.
20
10
  *
21
11
  * @module install/_mcp-register
22
12
  */
@@ -32,18 +22,14 @@ export const MCP_SERVER_NAME = "multi-agent-toolkit";
32
22
  export const MCP_SERVER_PACKAGE = "@mmerterden/multi-agent-toolkit-mcp";
33
23
 
34
24
  /**
35
- * The spec the host is registered with. `@latest` is deliberate and it is the
36
- * difference between "the toolkit was published" and "the toolkit is running".
37
- *
38
- * Measured before this was added: the registration was the bare package name,
39
- * npx found ANY cached version and reused it, and a machine that had cached
40
- * 3.0.0 kept starting 3.0.0 after 3.1.0 was published and tagged `latest`. The
41
- * new tool existed on the registry and in the docs, and in no running server.
25
+ * The spec the host is registered with. `@latest` makes each server start
26
+ * resolve the newest published toolkit: with a bare package name, npx reuses
27
+ * any cached version, so a machine keeps starting whatever it cached first
28
+ * after newer releases are published.
42
29
  *
43
30
  * The cost is a registry round-trip when the MCP server starts, and an offline
44
- * start now depends on what npx can resolve from its cache rather than on a
45
- * cached version being present at all. That trade was made explicitly: a stale
46
- * server is a silent wrong answer, a failed start is a loud one.
31
+ * start depends on what npx can resolve from its cache. A stale server is a
32
+ * silent wrong answer, a failed start is a loud one.
47
33
  */
48
34
  export const MCP_SERVER_SPEC = `${MCP_SERVER_PACKAGE}@latest`;
49
35
 
@@ -87,7 +73,8 @@ export function scopeRegistryFlag(pkg = MCP_SERVER_PACKAGE, registry = MCP_REGIS
87
73
  export const LEGACY_MCP_SERVER_NAME = "dev-toolkit";
88
74
 
89
75
  /**
90
- * Per-host CLI invocation details.
76
+ * Per-host CLI invocation details, built at call time so a caller that changes
77
+ * HOME (a test, a sandboxed install) gets candidates under that HOME.
91
78
  *
92
79
  * `candidates`: where the CLI might live. A bare name is resolved through PATH by
93
80
  * execFileSync. The explicit paths matter for Copilot: `copilot` is frequently a
@@ -97,26 +84,32 @@ export const LEGACY_MCP_SERVER_NAME = "dev-toolkit";
97
84
  * ~/.local/share/gh/copilot/.
98
85
  *
99
86
  * `scopeArgs`: options inserted before the server name. Only Claude Code needs
100
- * them, and it needs them badly: `claude mcp add` defaults to `--scope local`,
101
- * which binds the server to whichever directory the installer happened to run
102
- * in. Every other project would then see no multi-agent-toolkit server at all. Codex
103
- * writes `~/.codex/config.toml` and Copilot writes `~/.copilot/mcp-config.json`,
104
- * both already user-global, so both take no scope flag - passing one errors.
87
+ * them: `claude mcp add` defaults to `--scope local`, which binds the server to
88
+ * whichever directory the installer happened to run in. Codex writes
89
+ * `~/.codex/config.toml` and Copilot writes `~/.copilot/mcp-config.json`, both
90
+ * already user-global, so both take no scope flag - passing one errors.
91
+ *
92
+ * `getArgs`: extra options for `mcp get` that make it print JSON.
93
+ *
94
+ * @param {"claude"|"codex"|"copilot"} host
105
95
  */
106
- const HOSTS = Object.freeze({
107
- claude: {
108
- candidates: ["claude", join(homedir(), ".local", "bin", "claude")],
109
- scopeArgs: ["--scope", "user"],
110
- },
111
- codex: {
112
- candidates: ["codex"],
113
- scopeArgs: [],
114
- },
115
- copilot: {
116
- candidates: ["copilot", join(homedir(), ".local", "share", "gh", "copilot", "copilot")],
117
- scopeArgs: [],
118
- },
119
- });
96
+ function hostConfig(host) {
97
+ const home = homedir();
98
+ const hosts = {
99
+ claude: {
100
+ candidates: ["claude", join(home, ".local", "bin", "claude")],
101
+ scopeArgs: ["--scope", "user"],
102
+ getArgs: [],
103
+ },
104
+ codex: { candidates: ["codex"], scopeArgs: [], getArgs: ["--json"] },
105
+ copilot: {
106
+ candidates: ["copilot", join(home, ".local", "share", "gh", "copilot", "copilot")],
107
+ scopeArgs: [],
108
+ getArgs: ["--json"],
109
+ },
110
+ };
111
+ return hosts[host] || { candidates: [host], scopeArgs: [], getArgs: [] };
112
+ }
120
113
 
121
114
  /**
122
115
  * First candidate that exists as a file, or the first bare name to let PATH resolution
@@ -127,7 +120,7 @@ const HOSTS = Object.freeze({
127
120
  * @returns {string}
128
121
  */
129
122
  function resolveCli(host) {
130
- const candidates = HOSTS[host]?.candidates || [host];
123
+ const { candidates } = hostConfig(host);
131
124
  for (const c of candidates) {
132
125
  if (c.includes("/") && existsSync(c)) return c;
133
126
  }
@@ -139,37 +132,76 @@ function saysAlreadyExists(text) {
139
132
  return /already exists/i.test(text);
140
133
  }
141
134
 
135
+ const outputOf = (e) =>
136
+ `${e?.stderr?.toString() ?? ""}\n${e?.stdout?.toString() ?? ""}\n${e?.message ?? ""}`;
137
+
138
+ /**
139
+ * The command line a registered server starts, as `[command, ...args]`, read
140
+ * from `<cli> mcp get`. Accepts the JSON form (Codex, Copilot: an object
141
+ * carrying `command` and `args`, possibly nested) and the text form (Claude
142
+ * Code: `Command:` and `Args:` lines; Copilot's text puts both on `Command:`).
143
+ *
144
+ * @param {string} text
145
+ * @returns {string[]|null}
146
+ */
147
+ export function parseRegisteredSpec(text) {
148
+ const raw = String(text || "").trim();
149
+ if (!raw) return null;
150
+ try {
151
+ const find = (node) => {
152
+ if (!node || typeof node !== "object") return null;
153
+ if (typeof node.command === "string") {
154
+ return [node.command, ...(Array.isArray(node.args) ? node.args.map(String) : [])];
155
+ }
156
+ for (const v of Object.values(node)) {
157
+ const hit = find(v);
158
+ if (hit) return hit;
159
+ }
160
+ return null;
161
+ };
162
+ const hit = find(JSON.parse(raw));
163
+ if (hit) return hit;
164
+ } catch {
165
+ /* not JSON: read the text form */
166
+ }
167
+ const command = /^\s*Command:\s*(.+)$/m.exec(raw)?.[1]?.trim();
168
+ if (!command) return null;
169
+ const args = /^\s*Args:\s*(.*)$/m.exec(raw)?.[1]?.trim();
170
+ const words = (s) => s.split(/\s+/).filter(Boolean);
171
+ return args === undefined ? words(command) : [...words(command), ...words(args)];
172
+ }
173
+
142
174
  /**
143
- * Register the MCP server. Idempotent from the caller's point of view, though the
144
- * three CLIs get there by three different routes:
175
+ * Register the MCP server, keeping whatever registration exists until a
176
+ * replacement is in place.
177
+ *
178
+ * The add runs first. The three CLIs answer a duplicate differently:
145
179
  *
146
180
  * - `codex mcp add` replaces an existing entry and exits 0.
147
181
  * - `copilot mcp add` refuses, printing `Server "<name>" already exists` and exiting 1.
148
182
  * - `claude mcp add` refuses, printing `MCP server <name> already exists in <scope>
149
183
  * config` and exiting **0**.
150
184
  *
151
- * So neither the exit code nor the stream tells you on its own. A non-zero exit is not
152
- * automatically a failure, and a zero exit is not automatically a fresh write - which
153
- * is why the "already exists" test runs on both paths. Reporting an existing server as
154
- * "skipped MCP registration (Command failed)" would send the user to fix something that
155
- * is already correct.
156
- *
157
- * An existing entry under the same name is removed and re-added so its spec follows
158
- * the installed version (the entry is installer-owned; the reason is at the remove
159
- * below). A user who points the toolkit at a local checkout registers it under a
160
- * different name or re-applies the change after an update.
185
+ * so "already exists" is looked for on both the success and the failure path.
186
+ * On a duplicate, the registered spec is read back with `mcp get` and compared
187
+ * with the one this release wants. The same spec is left alone. A different
188
+ * spec is replaced (remove, then add); if that add fails, the previous spec is
189
+ * added back so the host never ends up with no server. A spec that cannot be
190
+ * read back is not replaced, because it could not be restored.
161
191
  *
162
192
  * Never fatal: a missing CLI or a failed write must not fail the install, because the
163
193
  * rest of the tree is still correct and useful. It prints the manual command instead.
164
194
  *
165
195
  * @param {"claude"|"codex"|"copilot"} host
166
196
  * @param {string} label - host label for log lines
167
- * @returns {{registered: boolean, alreadyPresent?: boolean, reason?: string}}
197
+ * @param {{cli?: string}} [opts] - `cli` overrides the CLI lookup (tests)
198
+ * @returns {{registered: boolean, alreadyPresent?: boolean, staleSpec?: boolean, replaced?: boolean, reason?: string}}
168
199
  */
169
- export function registerMcpServer(host, label) {
170
- const cli = resolveCli(host);
171
- const scopeArgs = HOSTS[host]?.scopeArgs || [];
200
+ export function registerMcpServer(host, label, { cli: cliOverride } = {}) {
201
+ const cli = cliOverride || resolveCli(host);
202
+ const { scopeArgs, getArgs } = hostConfig(host);
172
203
  const npxArgs = ["-y", ...scopeRegistryFlag(), MCP_SERVER_SPEC];
204
+ const desired = ["npx", ...npxArgs];
173
205
  const manual = `${host} mcp add ${scopeArgs.join(" ")}${scopeArgs.length ? " " : ""}${MCP_SERVER_NAME} -- npx ${npxArgs.join(" ")}`;
174
206
 
175
207
  if (isDryRun()) {
@@ -186,75 +218,99 @@ export function registerMcpServer(host, label) {
186
218
  return { registered: false, reason: "opted out" };
187
219
  }
188
220
 
189
- // Drop the pre-rename entry before adding the new one. Best-effort by design:
190
- // "not registered" is the common case (a fresh install) and is not an error,
191
- // and a host that cannot remove it is not a reason to skip the add.
221
+ // Bounded: an installer must never hang on a child process. These are local
222
+ // config reads and writes and return in milliseconds.
223
+ const run = (args) => execFileSync(cli, args, { stdio: "pipe", timeout: 20_000 });
224
+ const add = (spec) => {
225
+ try {
226
+ const stdout = run(["mcp", "add", ...scopeArgs, MCP_SERVER_NAME, "--", ...spec]);
227
+ // Claude Code refuses a duplicate on the success path (exit 0).
228
+ return saysAlreadyExists(stdout?.toString() ?? "") ? "exists" : "added";
229
+ } catch (e) {
230
+ const out = outputOf(e);
231
+ if (saysAlreadyExists(out)) return "exists";
232
+ const why =
233
+ e?.code === "ENOENT"
234
+ ? `${host} not on PATH`
235
+ : (out.split("\n").find((l) => l.trim()) || "unknown error").trim();
236
+ return { failed: why };
237
+ }
238
+ };
239
+
240
+ // The pre-rename entry (LEGACY_MCP_SERVER_NAME) would advertise the same tools
241
+ // under a second name. Best-effort: "not registered" is the common case.
192
242
  try {
193
- execFileSync(cli, ["mcp", "remove", ...scopeArgs, LEGACY_MCP_SERVER_NAME], {
194
- stdio: "pipe",
195
- timeout: 20_000,
196
- });
243
+ run(["mcp", "remove", ...scopeArgs, LEGACY_MCP_SERVER_NAME]);
197
244
  console.log(` -> ${label}: removed the legacy ${LEGACY_MCP_SERVER_NAME} registration`);
198
245
  } catch {
199
246
  /* not present, or the host declines - either way the add below is what matters */
200
247
  }
201
248
 
202
- // Drop the CURRENT entry too, so the add below rewrites its spec. Without this
203
- // the host answers "already exists", the installer reported success, and the
204
- // stored args stayed whatever an older version wrote - which is how a machine
205
- // kept starting the toolkit from a bare package name (and therefore from
206
- // whatever npx had cached) long after the spec gained `@latest`. The entry is
207
- // installer-owned, so replacing it is not clobbering a user's work; a host
208
- // that refuses the remove is reported below rather than counted as current.
209
- let replacedExisting = false;
249
+ const first = add(desired);
250
+ if (first === "added") {
251
+ console.log(
252
+ ` -> ${label}: registered the ${MCP_SERVER_NAME} MCP server (${MCP_SERVER_SPEC})`,
253
+ );
254
+ return { registered: true };
255
+ }
256
+ if (first !== "exists") {
257
+ console.log(` -> ${label}: skipped MCP registration (${first.failed})`);
258
+ console.log(` run manually: ${manual}`);
259
+ return { registered: false, reason: first.failed };
260
+ }
261
+
262
+ let previous;
210
263
  try {
211
- execFileSync(cli, ["mcp", "remove", ...scopeArgs, MCP_SERVER_NAME], {
212
- stdio: "pipe",
213
- timeout: 20_000,
214
- });
215
- replacedExisting = true;
264
+ previous = parseRegisteredSpec(run(["mcp", "get", MCP_SERVER_NAME, ...getArgs])?.toString());
216
265
  } catch {
217
- /* not registered yet - the common case on a fresh install */
266
+ previous = null;
267
+ }
268
+ if (previous && previous.join("\u0000") === desired.join("\u0000")) {
269
+ console.log(
270
+ ` -> ${label}: ${MCP_SERVER_NAME} MCP server already registered (${MCP_SERVER_SPEC})`,
271
+ );
272
+ return { registered: true, alreadyPresent: true };
273
+ }
274
+ if (!previous) {
275
+ console.log(
276
+ ` -> ${label}: ${MCP_SERVER_NAME} already registered; its spec could not be read, so it was ` +
277
+ `left as-is. To make sure it runs ${MCP_SERVER_SPEC}, re-register with: ${manual}`,
278
+ );
279
+ return { registered: true, alreadyPresent: true, staleSpec: true };
218
280
  }
219
281
 
220
282
  try {
221
- // Bounded: an installer must never hang on a child process. This is a local config
222
- // write and returns in milliseconds.
223
- const stdout = execFileSync(
224
- cli,
225
- ["mcp", "add", ...scopeArgs, MCP_SERVER_NAME, "--", "npx", ...npxArgs],
226
- { stdio: "pipe", timeout: 20_000 },
283
+ run(["mcp", "remove", ...scopeArgs, MCP_SERVER_NAME]);
284
+ } catch (e) {
285
+ console.log(
286
+ ` -> ${label}: ${MCP_SERVER_NAME} is registered with an older spec and the host declined ` +
287
+ `to remove it (${(
288
+ outputOf(e)
289
+ .split("\n")
290
+ .find((l) => l.trim()) || "unknown error"
291
+ ).trim()}). ` +
292
+ `Re-register manually with: ${manual}`,
227
293
  );
228
- // Claude Code refuses a duplicate on the SUCCESS path (exit 0), so the check
229
- // cannot live in the catch alone.
230
- if (saysAlreadyExists(stdout?.toString() ?? "")) {
231
- // Reached only when the remove above did not take. The entry survives, but
232
- // its spec is whatever was there before, so this is not a current
233
- // registration - say that instead of reporting a clean pass.
234
- console.log(
235
- ` -> ${label}: ${MCP_SERVER_NAME} already registered and the host declined to replace it; ` +
236
- `its spec may predate ${MCP_SERVER_SPEC}. Re-register manually with: ${manual}`,
237
- );
238
- return { registered: true, alreadyPresent: true, staleSpec: true };
239
- }
294
+ return { registered: true, alreadyPresent: true, staleSpec: true };
295
+ }
296
+ const replaced = add(desired);
297
+ if (replaced === "added" || replaced === "exists") {
240
298
  console.log(
241
- ` -> ${label}: ${replacedExisting ? "re-registered" : "registered"} the ${MCP_SERVER_NAME} MCP server (${MCP_SERVER_SPEC})`,
299
+ ` -> ${label}: re-registered the ${MCP_SERVER_NAME} MCP server (${MCP_SERVER_SPEC})`,
242
300
  );
243
- return { registered: true };
244
- } catch (e) {
245
- // The CLI writes its diagnosis to stderr, which execFileSync parks on the error
246
- // rather than in `message`. Read both, or "already exists" is invisible.
247
- const out = `${e?.stderr?.toString() ?? ""}\n${e?.stdout?.toString() ?? ""}\n${e?.message ?? ""}`;
248
- if (saysAlreadyExists(out)) {
249
- console.log(` -> ${label}: ${MCP_SERVER_NAME} MCP server already registered, left as-is`);
250
- return { registered: true, alreadyPresent: true };
251
- }
252
- const why =
253
- e?.code === "ENOENT"
254
- ? `${host} not on PATH`
255
- : (out.split("\n").find((l) => l.trim()) || "unknown error").trim();
256
- console.log(` -> ${label}: skipped MCP registration (${why})`);
257
- console.log(` run manually: ${manual}`);
258
- return { registered: false, reason: why };
301
+ return { registered: true, replaced: true };
302
+ }
303
+ const restored = add(previous);
304
+ if (restored === "added" || restored === "exists") {
305
+ console.log(
306
+ ` -> ${label}: could not update ${MCP_SERVER_NAME} (${replaced.failed}); the previous ` +
307
+ `registration was restored. Re-register manually with: ${manual}`,
308
+ );
309
+ return { registered: true, alreadyPresent: true, staleSpec: true, reason: replaced.failed };
259
310
  }
311
+ console.log(
312
+ ` -> ${label}: could not update ${MCP_SERVER_NAME} (${replaced.failed}) or restore the previous ` +
313
+ `registration (${restored.failed}). Register it manually with: ${manual}`,
314
+ );
315
+ return { registered: false, reason: replaced.failed };
260
316
  }