@bridge_gpt/mcp-server 0.2.39 → 0.2.42

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 (74) hide show
  1. package/README.md +10 -10
  2. package/build/agent-capabilities/cli.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  4. package/build/claude-user-config-doctor.js +42 -11
  5. package/build/cli-release.js +2 -1
  6. package/build/commands.generated.js +4 -4
  7. package/build/conduct-epic/bridge-client.js +354 -113
  8. package/build/conduct-epic/checkpoint-store.js +75 -2
  9. package/build/conduct-epic/cli.js +795 -109
  10. package/build/conduct-epic/cut-protocol.js +327 -0
  11. package/build/conduct-epic/pr-state.js +113 -24
  12. package/build/conduct-epic/spawn.js +14 -2
  13. package/build/conductor/bridge-api-client.js +27 -1
  14. package/build/conductor/cli.js +46 -1
  15. package/build/conductor/doctor.js +101 -16
  16. package/build/conductor/epic-reconcile.js +72 -19
  17. package/build/conductor/epic-runtime.js +15 -3
  18. package/build/conductor/errors.js +47 -0
  19. package/build/conductor/git-hooks.js +205 -11
  20. package/build/conductor/install-doctor.js +230 -1
  21. package/build/conductor/local-merge.js +130 -28
  22. package/build/conductor/tools.js +32 -3
  23. package/build/conductor/worker-ledger-cli.js +27 -1
  24. package/build/conductor-bin.js +15 -15
  25. package/build/credentials-cli.js +3 -2
  26. package/build/doctor.js +107 -41
  27. package/build/executor/cli.js +48 -1
  28. package/build/executor/env.js +21 -0
  29. package/build/executor/index-scope.js +39 -0
  30. package/build/executor/job-log-registry.js +69 -0
  31. package/build/executor/job-runner.js +148 -26
  32. package/build/executor/live-worker-registry.js +83 -0
  33. package/build/executor/observation.js +167 -6
  34. package/build/executor/platform.js +147 -3
  35. package/build/executor/process.js +58 -14
  36. package/build/executor/runner.js +235 -48
  37. package/build/executor/test-clock.js +3 -2
  38. package/build/index-scope-contract.js +96 -0
  39. package/build/index.js +153 -204
  40. package/build/init.js +83 -22
  41. package/build/install-bridge-conductor.js +323 -14
  42. package/build/install-bridge.js +202 -38
  43. package/build/install-doctor.js +23 -9
  44. package/build/install-reexec.js +2 -1
  45. package/build/launcher-config-inspection.js +83 -22
  46. package/build/mcp-host-config.js +331 -67
  47. package/build/mcp-host-targets.js +45 -21
  48. package/build/mcp-identity.js +92 -0
  49. package/build/mcp-install-state.js +94 -1
  50. package/build/mcp-invoke.js +2 -1
  51. package/build/mcp-provisioning.js +45 -12
  52. package/build/mcp-registration-doctor.js +35 -13
  53. package/build/mcp-server-invocation.js +4 -2
  54. package/build/merge-pull-request.js +208 -9
  55. package/build/pipelines.generated.js +3 -3
  56. package/build/plane/defaults.js +4 -1
  57. package/build/plane/preflight.js +81 -10
  58. package/build/plane/test-fakes.js +9 -1
  59. package/build/readme.generated.js +1 -1
  60. package/build/regression-check.js +3 -2
  61. package/build/review-tickets.js +8 -7
  62. package/build/run-unit-tests-launcher.js +74 -1
  63. package/build/schedule-run.js +3 -2
  64. package/build/setup-epic.js +453 -78
  65. package/build/sfcc/tool-wrapper.js +15 -0
  66. package/build/start-tickets-prereqs.js +11 -6
  67. package/build/start-tickets.js +91 -85
  68. package/build/update-check.js +3 -2
  69. package/build/upgrade-advice.js +2 -1
  70. package/build/upgrade-cli.js +50 -18
  71. package/build/version.generated.js +1 -1
  72. package/docs/CONDUCTOR.md +22 -0
  73. package/docs/install/mcp-tool-integrations.md +19 -3
  74. package/package.json +2 -2
@@ -23,12 +23,21 @@
23
23
  *
24
24
  * TOML NOTE: there is no comment-preserving TOML library available, so Codex
25
25
  * direct writes render a canonical document for a missing file, APPEND canonical
26
- * tables to an existing file that has no `bridge-api` table (preserving every
27
- * existing byte), and DECLINE (`manual-required`) an existing `bridge-api` table
26
+ * tables to an existing file that has no recognized Bridge table (preserving every
27
+ * existing byte), and DECLINE (`manual-required`) an existing Bridge table
28
28
  * rather than reserialize unrelated content.
29
29
  */
30
30
  import { VERSION } from "./version.generated.js";
31
31
  import { refreshBridgeApiPackageSpec } from "./init.js";
32
+ import { hostAdapterForTarget, } from "./mcp-host-targets.js";
33
+ import { LEGACY_SERVER_NAMES, MCP_SERVER_NAME, MCP_PACKAGE_NAME, RECOGNIZED_SERVER_NAMES, resolveRegistrationKey, } from "./mcp-identity.js";
34
+ /**
35
+ * The secret-free, repair-oriented detail every duplicate-registration outcome
36
+ * carries. Names both keys, prescribes manual removal, and deliberately does not
37
+ * choose a survivor — see {@link resolveRegistrationKey}.
38
+ */
39
+ export const DUPLICATE_REGISTRATION_DETAIL = `both \`${MCP_SERVER_NAME}\` and \`${LEGACY_SERVER_NAMES.join("`, `")}\` are registered here; ` +
40
+ "confirm which one to keep, remove the other manually, then re-run. Nothing was changed.";
32
41
  // ---------------------------------------------------------------------------
33
42
  // Path helpers (POSIX-normalized; module stays free of node:path).
34
43
  // ---------------------------------------------------------------------------
@@ -64,15 +73,16 @@ export function resolveTargetAbsPath(target, ctx) {
64
73
  * use `stdio`; Copilot CLI uses `local` + `tools: ["*"]`.
65
74
  */
66
75
  export function adaptBridgeEntryForHostTarget(entry, target) {
76
+ const adapter = hostAdapterForTarget(target);
67
77
  const physical = {};
68
- if (target.transportType !== undefined) {
69
- physical.type = target.transportType;
78
+ if (adapter.transportType !== undefined) {
79
+ physical.type = adapter.transportType;
70
80
  }
71
81
  physical.command = entry.command;
72
82
  physical.args = [...entry.args];
73
83
  physical.env = { ...entry.env };
74
- if (target.extraEntryKeys) {
75
- for (const [k, v] of Object.entries(target.extraEntryKeys)) {
84
+ if (adapter.extraEntryKeys) {
85
+ for (const [k, v] of Object.entries(adapter.extraEntryKeys)) {
76
86
  physical[k] = v;
77
87
  }
78
88
  }
@@ -110,36 +120,63 @@ export async function readJsonHostConfig(path, deps) {
110
120
  return { state: "valid", value: parsed };
111
121
  }
112
122
  /**
113
- * Merge the adapted `bridge-api` entry into an existing JSON document under the
123
+ * Merge the adapted Bridge entry into an existing JSON document under the
114
124
  * registry-selected root key. Unrelated top-level keys and unrelated servers are
115
- * preserved; only the `bridge-api` entry is replaced.
125
+ * preserved; only the resolved Bridge entry is replaced.
126
+ *
127
+ * BAPI-807 — resolve THEN write. The write key comes from
128
+ * {@link resolveRegistrationKey} rather than being assumed canonical:
129
+ *
130
+ * - a legacy `bridge-api` entry is replaced UNDER `bridge-api`, so an existing
131
+ * installation is never renamed and never gains a second entry;
132
+ * - a canonical entry is replaced under `bridge`;
133
+ * - when neither key exists a new entry is created under {@link MCP_SERVER_NAME};
134
+ * - when BOTH exist nothing is merged — the caller gets a conflict result.
116
135
  */
117
136
  export function mergeJsonHostConfig(existing, target, adaptedEntry) {
137
+ const adapter = hostAdapterForTarget(target);
118
138
  const merged = { ...existing };
119
- const rootRaw = merged[target.topLevelKey];
139
+ const rootRaw = merged[adapter.topLevelKey];
120
140
  const root = rootRaw && typeof rootRaw === "object" && !Array.isArray(rootRaw)
121
141
  ? { ...rootRaw }
122
142
  : {};
143
+ const resolution = resolveRegistrationKey(root);
144
+ if (resolution.state === "conflict") {
145
+ return { status: "duplicate-registration", keys: resolution.keys };
146
+ }
147
+ const writeKey = resolution.state === "absent" ? resolution.writeKey : resolution.key;
123
148
  // BAPI-714 (Group C): an UPDATE to a pre-existing Bridge registration keeps that
124
149
  // registration's own launcher args composition — only the package-spec token is
125
150
  // refreshed — so a global/manual host config written before `serve` existed is
126
151
  // never migrated to it. A registration this path CREATES receives the adapted
127
- // template args, `serve` included.
128
- const prior = root["bridge-api"];
152
+ // template args, `serve` included. The prior entry is read through the RESOLVED
153
+ // key, so a legacy registration's args are preserved exactly like a canonical
154
+ // one's.
155
+ const prior = root[writeKey];
129
156
  const preservedArgs = prior && typeof prior === "object" && !Array.isArray(prior)
130
157
  ? refreshBridgeApiPackageSpec(prior.args, currentLauncherSpec())
131
158
  : null;
132
- root["bridge-api"] = preservedArgs ? { ...adaptedEntry, args: preservedArgs } : adaptedEntry;
133
- merged[target.topLevelKey] = root;
134
- return merged;
159
+ root[writeKey] = preservedArgs ? { ...adaptedEntry, args: preservedArgs } : adaptedEntry;
160
+ merged[adapter.topLevelKey] = root;
161
+ return { status: "merged", document: merged, registrationKey: writeKey };
135
162
  }
136
- /** Build a fresh JSON document containing only the Bridge entry. */
163
+ /**
164
+ * Build a fresh JSON document containing only the Bridge entry.
165
+ *
166
+ * A FRESH document is by definition an absent registration, so it uses
167
+ * {@link MCP_SERVER_NAME} unconditionally — Claude, Cursor, VS Code, and Copilot
168
+ * documents created from nothing all say `bridge`. A legacy key can only ever be
169
+ * reached through the merge path above, which requires the key to already exist.
170
+ */
137
171
  function freshJsonDocument(target, adaptedEntry) {
138
- return { [target.topLevelKey]: { "bridge-api": adaptedEntry } };
172
+ const adapter = hostAdapterForTarget(target);
173
+ return { [adapter.topLevelKey]: { [MCP_SERVER_NAME]: adaptedEntry } };
139
174
  }
140
175
  /**
141
176
  * Direct-write a JSON host config. Malformed existing JSON is skipped with an
142
- * explicit `skipped — invalid JSON` result and is never overwritten.
177
+ * explicit `skipped — invalid JSON` result and is never overwritten; a both-key
178
+ * config is skipped the same way and — critically — before `mkdir` or
179
+ * `writeFile` is reached (BAPI-807).
143
180
  */
144
181
  export async function writeJsonHostConfig(path, target, adaptedEntry, deps) {
145
182
  const read = await readJsonHostConfig(path, deps);
@@ -147,9 +184,22 @@ export async function writeJsonHostConfig(path, target, adaptedEntry, deps) {
147
184
  return { status: "skipped-invalid", message: "skipped — invalid JSON" };
148
185
  }
149
186
  const isCreate = read.state === "missing";
150
- const doc = isCreate
151
- ? freshJsonDocument(target, adaptedEntry)
152
- : mergeJsonHostConfig(read.value, target, adaptedEntry);
187
+ let doc;
188
+ if (isCreate) {
189
+ doc = freshJsonDocument(target, adaptedEntry);
190
+ }
191
+ else {
192
+ const merge = mergeJsonHostConfig(read.value, target, adaptedEntry);
193
+ if (merge.status === "duplicate-registration") {
194
+ // Return BEFORE any filesystem side effect. The config keeps its exact
195
+ // bytes, including both registrations, for a human to disambiguate.
196
+ return {
197
+ status: "skipped-duplicate-registration",
198
+ message: DUPLICATE_REGISTRATION_DETAIL,
199
+ };
200
+ }
201
+ doc = merge.document;
202
+ }
153
203
  await deps.mkdir(dirnameOf(path), { recursive: true });
154
204
  await deps.writeFile(path, JSON.stringify(doc, null, 2) + "\n");
155
205
  return { status: isCreate ? "created" : "direct-written", path };
@@ -202,6 +252,10 @@ function tomlBasicString(value) {
202
252
  function tomlStringArray(values) {
203
253
  return `[${values.map(tomlBasicString).join(", ")}]`;
204
254
  }
255
+ /** Escape dynamic text before inserting it into a regular expression. */
256
+ export function escapeRegExp(value) {
257
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
258
+ }
205
259
  /** Deterministic env-key order for the Codex table (matches the web snippet). */
206
260
  const CODEX_ENV_ORDER = [
207
261
  "BAPI_BASE_URL",
@@ -223,28 +277,56 @@ function orderedEnvKeys(env) {
223
277
  */
224
278
  export function renderCodexBridgeToml(entry) {
225
279
  const lines = [
226
- "[mcp_servers.bridge-api]",
280
+ `[mcp_servers.${MCP_SERVER_NAME}]`,
227
281
  `command = ${tomlBasicString(entry.command)}`,
228
282
  `args = ${tomlStringArray(entry.args)}`,
229
283
  "",
230
- "[mcp_servers.bridge-api.env]",
284
+ `[mcp_servers.${MCP_SERVER_NAME}.env]`,
231
285
  ];
232
286
  for (const key of orderedEnvKeys(entry.env)) {
233
287
  lines.push(`${key} = ${tomlBasicString(entry.env[key])}`);
234
288
  }
235
289
  return lines.join("\n") + "\n";
236
290
  }
237
- /** Whether a TOML document already declares a `[mcp_servers.bridge-api]` table. */
291
+ /**
292
+ * One matcher per recognized registration name (BAPI-807), each matching the
293
+ * bare `[mcp_servers.<name>]` table or a dotted subtable such as
294
+ * `[mcp_servers.<name>.env]`, at line start (allowing leading horizontal
295
+ * whitespace).
296
+ *
297
+ * The name is escaped through the single {@link escapeRegExp} helper and the
298
+ * pattern is anchored at COMPLETE table-header boundaries — `]` must follow the
299
+ * name or a dotted segment. That is what keeps `[mcp_servers.bridge-api-extra]`
300
+ * (and `[mcp_servers.bridgex]`) from matching: without the anchor, `bridge`
301
+ * would be a prefix of both. `bridge-api` also contains a `-`, which is not a
302
+ * regex metacharacter, but escaping is applied uniformly rather than reasoned
303
+ * about per name.
304
+ */
305
+ const BRIDGE_TABLE_PATTERNS = RECOGNIZED_SERVER_NAMES.map((name) => ({
306
+ name,
307
+ pattern: new RegExp(`^[ \\t]*\\[mcp_servers\\.${escapeRegExp(name)}(\\.[^\\]]*)?\\][ \\t]*$`, "m"),
308
+ }));
309
+ /** Every recognized Bridge table name declared in a TOML document, in canonical-first order. */
310
+ export function codexBridgeTableNames(content) {
311
+ // Never reserialize — presence only.
312
+ return BRIDGE_TABLE_PATTERNS.filter(({ pattern }) => pattern.test(content)).map(({ name }) => name);
313
+ }
314
+ /** Whether a TOML document already declares any recognized Bridge table. */
238
315
  export function codexHasBridgeTable(content) {
239
- // Match the bare table header or any dotted subtable header, at line start
240
- // (allowing leading whitespace). Never reserialize — presence only.
241
- return /^[ \t]*\[mcp_servers\.bridge-api(\.[^\]]*)?\][ \t]*$/m.test(content);
316
+ return codexBridgeTableNames(content).length > 0;
242
317
  }
243
318
  /**
244
319
  * Compute the safe Codex merge action. A missing document → canonical create; an
245
- * existing document without a Bridge table → append (existing bytes preserved,
246
- * minimal newline boundary); an existing Bridge table decline (manual-required)
247
- * rather than reserialize.
320
+ * existing document without any recognized Bridge table → append (existing bytes
321
+ * preserved, minimal newline boundary); an existing Bridge table under EITHER
322
+ * recognized name → decline (manual-required) rather than reserialize.
323
+ *
324
+ * BAPI-807: declining on a legacy-only table is what stops this path from
325
+ * appending a canonical `[mcp_servers.bridge]` table beside an existing
326
+ * `[mcp_servers.bridge-api]` one — which would be a duplicate registration
327
+ * created by Bridge itself. Both table names present is reported distinctly, so
328
+ * a caller can say "you already have two" rather than the generic "your existing
329
+ * config could not be safely updated".
248
330
  */
249
331
  export function mergeCodexHostConfig(existing, entry) {
250
332
  if (existing.state === "missing") {
@@ -258,7 +340,13 @@ export function mergeCodexHostConfig(existing, entry) {
258
340
  ? existing.content
259
341
  : // "valid" JsonReadResult is not used for TOML; guard anyway.
260
342
  "";
261
- if (codexHasBridgeTable(content)) {
343
+ const present = codexBridgeTableNames(content);
344
+ if (present.length > 1) {
345
+ return { action: "duplicate-registration", keys: present };
346
+ }
347
+ if (present.length === 1) {
348
+ // Canonical-only or legacy-only: an existing table is never reserialized,
349
+ // and never duplicated under the other name.
262
350
  return { action: "manual-required" };
263
351
  }
264
352
  const boundary = content.length === 0 || content.endsWith("\n") ? "\n" : "\n\n";
@@ -280,6 +368,13 @@ async function readTomlHostConfig(path, deps) {
280
368
  export async function writeCodexHostConfig(path, entry, deps) {
281
369
  const read = await readTomlHostConfig(path, deps);
282
370
  const merge = mergeCodexHostConfig(read, entry);
371
+ if (merge.action === "duplicate-registration") {
372
+ // Before mkdir/writeFile, exactly like the JSON path.
373
+ return {
374
+ status: "skipped-duplicate-registration",
375
+ message: DUPLICATE_REGISTRATION_DETAIL,
376
+ };
377
+ }
283
378
  if (merge.action === "manual-required") {
284
379
  return { status: "manual-required" };
285
380
  }
@@ -287,30 +382,51 @@ export async function writeCodexHostConfig(path, entry, deps) {
287
382
  await deps.writeFile(path, merge.content);
288
383
  return { status: merge.action === "create" ? "created" : "direct-written", path };
289
384
  }
290
- /** Inspect a JSON host config for a `bridge-api` entry (no env values read). */
385
+ /**
386
+ * Inspect a JSON host config for a Bridge entry under EITHER recognized key
387
+ * (no env values read).
388
+ */
291
389
  export async function inspectJsonHostEntry(path, target, deps) {
292
390
  const read = await readJsonHostConfig(path, deps);
293
391
  if (read.state !== "valid")
294
- return { present: false };
295
- const root = read.value[target.topLevelKey];
296
- if (!root || typeof root !== "object" || Array.isArray(root))
297
- return { present: false };
298
- const entry = root["bridge-api"];
299
- if (!entry || typeof entry !== "object")
300
- return { present: false };
392
+ return { present: false, registrationKey: null };
393
+ const root = read.value[hostAdapterForTarget(target).topLevelKey];
394
+ if (!root || typeof root !== "object" || Array.isArray(root)) {
395
+ return { present: false, registrationKey: null };
396
+ }
397
+ const resolution = resolveRegistrationKey(root);
398
+ if (resolution.state === "conflict") {
399
+ // Reported explicitly rather than resolved: `present: true` alone would let
400
+ // a vendor-first caller conclude "the entry landed" and move on.
401
+ return { present: true, registrationKey: null, duplicateRegistration: true };
402
+ }
403
+ if (resolution.state === "absent")
404
+ return { present: false, registrationKey: null };
405
+ const entry = root[resolution.key];
406
+ if (!entry || typeof entry !== "object") {
407
+ return { present: false, registrationKey: null };
408
+ }
301
409
  const args = entry.args;
302
410
  return {
303
411
  present: true,
412
+ registrationKey: resolution.key,
304
413
  args: Array.isArray(args) ? args.filter((a) => typeof a === "string") : undefined,
305
414
  };
306
415
  }
307
- /** Inspect a Codex TOML config for a `bridge-api` table (no env values read). */
416
+ /**
417
+ * Inspect a Codex TOML config for a Bridge table under EITHER exact recognized
418
+ * table name (no env values read).
419
+ */
308
420
  export async function inspectTomlHostEntry(path, deps) {
309
421
  const read = await readTomlHostConfig(path, deps);
310
422
  if (read.state !== "text")
311
- return { present: false };
312
- if (!codexHasBridgeTable(read.content))
313
- return { present: false };
423
+ return { present: false, registrationKey: null };
424
+ const names = codexBridgeTableNames(read.content);
425
+ if (names.length === 0)
426
+ return { present: false, registrationKey: null };
427
+ if (names.length > 1) {
428
+ return { present: true, registrationKey: null, duplicateRegistration: true };
429
+ }
314
430
  // Extract the args = [...] line from the main table for pin classification.
315
431
  const m = read.content.match(/^\s*args\s*=\s*\[(.*?)\]/m);
316
432
  let args;
@@ -320,18 +436,133 @@ export async function inspectTomlHostEntry(path, deps) {
320
436
  .map((s) => s.trim().replace(/^["']|["']$/g, ""))
321
437
  .filter((s) => s.length > 0);
322
438
  }
323
- return { present: true, args };
439
+ return { present: true, registrationKey: names[0], args };
324
440
  }
325
- /** Inspect any target (format-aware) for a `bridge-api` entry. */
441
+ /** Inspect any target (format-aware) for a recognized Bridge entry. */
326
442
  export async function inspectHostEntry(target, ctx, deps) {
327
443
  const path = resolveTargetAbsPath(target, ctx);
328
- return target.format === "toml"
444
+ return hostAdapterForTarget(target).format === "toml"
329
445
  ? inspectTomlHostEntry(path, deps)
330
446
  : inspectJsonHostEntry(path, target, deps);
331
447
  }
332
448
  // ---------------------------------------------------------------------------
333
449
  // Vendor CLI invocation builders (argv-isolated; secret-bearing for some kinds)
334
450
  // ---------------------------------------------------------------------------
451
+ // ---------------------------------------------------------------------------
452
+ // Bridge MCP profile-token inspection (BAPI-775)
453
+ //
454
+ // The READ-ONLY counterpart of `mergeBridgeApiProfileToken` (init.ts). That
455
+ // helper is the only writer of `BRIDGE_MCP_PROFILE`, and it writes exactly three
456
+ // project-local host configs. This inspector answers "did that write land?"
457
+ // without repeating the write path and without ever surfacing configuration
458
+ // content.
459
+ //
460
+ // SECRET DISCIPLINE: the result carries fixed repo-relative config paths, the
461
+ // inspected token, and booleans — never the MCP entry, its `env` object, its
462
+ // command/args, an API key, or any unrelated profile token. A caller cannot
463
+ // reconstruct configuration content from what this returns.
464
+ // ---------------------------------------------------------------------------
465
+ /**
466
+ * The three project-local host configs `mergeBridgeApiProfileToken` writes.
467
+ *
468
+ * Deliberately NOT derived from {@link file:./mcp-host-targets.ts}: the merge
469
+ * path's surface is these three files and nothing else, so an inspector built
470
+ * from the wider registry would report on Codex/Copilot hosts the writer never
471
+ * touches — and a report implying those hosts gained the token would be wrong.
472
+ */
473
+ export const BRIDGE_PROFILE_TOKEN_CONFIGS = [
474
+ { path: ".mcp.json", topLevelKey: "mcpServers" },
475
+ { path: ".vscode/mcp.json", topLevelKey: "servers" },
476
+ { path: ".cursor/mcp.json", topLevelKey: "mcpServers" },
477
+ ];
478
+ /**
479
+ * Split a stored `BRIDGE_MCP_PROFILE` value into tokens exactly the way
480
+ * `mergeBridgeApiProfileToken` does — raw comma splitting with trimming, and
481
+ * deliberately NOT `resolveProfiles`, which force-injects the implicit `core`
482
+ * group the stored value never contains.
483
+ */
484
+ function storedProfileTokens(value) {
485
+ if (typeof value !== "string")
486
+ return [];
487
+ return value
488
+ .split(",")
489
+ .map((t) => t.trim())
490
+ .filter((t) => t.length > 0);
491
+ }
492
+ /** The `BRIDGE_MCP_PROFILE` tokens stored on one registration entry, if any. */
493
+ function entryProfileTokens(entry) {
494
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
495
+ return null;
496
+ const env = entry.env;
497
+ const stored = env && typeof env === "object" && !Array.isArray(env)
498
+ ? env.BRIDGE_MCP_PROFILE
499
+ : undefined;
500
+ return storedProfileTokens(stored);
501
+ }
502
+ /**
503
+ * Inspect one supported host config for a Bridge entry carrying `token`.
504
+ *
505
+ * BAPI-807: the entry is looked up under EVERY recognized registration key, not
506
+ * the `bridge-api` literal alone. A fresh install writes `bridge`, so reading
507
+ * only the legacy key would report `no-bridge-entry` for every current install
508
+ * and send the operator to re-run the base install that already ran.
509
+ *
510
+ * A config carrying BOTH keys is read as one presence question, not resolved:
511
+ * this function only answers "does a Bridge entry here carry the token", and
512
+ * either entry carrying it makes that true. Choosing WHICH duplicate wins is a
513
+ * write-path decision, and the write path refuses it loudly
514
+ * (`DuplicateRegistrationError`) rather than guessing.
515
+ *
516
+ * Every failure mode is an explicit state rather than a thrown error: a missing
517
+ * file is the ordinary case (the writer never creates one), and a malformed or
518
+ * unreadable file is a diagnosable outcome the operator can act on. Neither may
519
+ * surface a filesystem message or JSON parser text.
520
+ */
521
+ async function inspectOneProfileConfig(projectRoot, target, token, deps) {
522
+ const read = await readJsonHostConfig(joinPath(projectRoot, target.path), deps);
523
+ if (read.state === "missing")
524
+ return { path: target.path, state: "absent" };
525
+ if (read.state !== "valid")
526
+ return { path: target.path, state: "unreadable" };
527
+ const root = read.value[target.topLevelKey];
528
+ if (!root || typeof root !== "object" || Array.isArray(root)) {
529
+ return { path: target.path, state: "no-bridge-entry" };
530
+ }
531
+ const servers = root;
532
+ // Own properties only, for the same reason `resolveRegistrationKey` insists on
533
+ // it: a `bridge` inherited from a polluted prototype is not a registration.
534
+ const tokenSets = RECOGNIZED_SERVER_NAMES.filter((name) => Object.prototype.hasOwnProperty.call(servers, name))
535
+ .map((name) => entryProfileTokens(servers[name]))
536
+ .filter((tokens) => tokens !== null);
537
+ if (tokenSets.length === 0)
538
+ return { path: target.path, state: "no-bridge-entry" };
539
+ // Only a boolean derived from the token set escapes this function — the env
540
+ // object itself is read here and nowhere else.
541
+ return {
542
+ path: target.path,
543
+ state: tokenSets.some((tokens) => tokens.includes(token)) ? "token-present" : "token-absent",
544
+ };
545
+ }
546
+ /**
547
+ * Read-only inspection of a `BRIDGE_MCP_PROFILE` token across the three configs
548
+ * `mergeBridgeApiProfileToken` can write (BAPI-775).
549
+ *
550
+ * Performs NO write and creates NO file, mirroring the writer's own
551
+ * never-create rule. Never throws for the ordinary
552
+ * absent/malformed/unreadable cases.
553
+ */
554
+ export async function inspectBridgeApiProfileToken(projectRoot, token, deps) {
555
+ const configs = [];
556
+ for (const target of BRIDGE_PROFILE_TOKEN_CONFIGS) {
557
+ configs.push(await inspectOneProfileConfig(projectRoot, target, token, deps));
558
+ }
559
+ return {
560
+ token,
561
+ configs,
562
+ tokenPresent: configs.some((c) => c.state === "token-present"),
563
+ bridgeEntryPresent: configs.some((c) => c.state === "token-present" || c.state === "token-absent"),
564
+ };
565
+ }
335
566
  /**
336
567
  * Whether a vendor kind's secret-delivery contract is VERIFIED. Only verified
337
568
  * contracts are ever invoked; an uncertain contract falls back to a direct
@@ -363,11 +594,11 @@ function buildVendorInvocation(target, entry) {
363
594
  });
364
595
  return {
365
596
  bin: vendor.bin,
366
- args: ["mcp", "add-json", "bridge-api", json, "--scope", "project"],
597
+ args: ["mcp", "add-json", MCP_SERVER_NAME, json, "--scope", "project"],
367
598
  };
368
599
  }
369
600
  if (vendor.kind === "copilot-add") {
370
- // copilot mcp add bridge-api --tools "*" --env K=V ... -- <command> <args...>
601
+ // copilot mcp add <name> --tools "*" --env K=V ... -- <command> <args...>
371
602
  // Env pairs (incl BAPI_API_KEY) are secret-bearing argv.
372
603
  const envArgs = [];
373
604
  for (const [k, v] of Object.entries(entry.env)) {
@@ -378,7 +609,7 @@ function buildVendorInvocation(target, entry) {
378
609
  args: [
379
610
  "mcp",
380
611
  "add",
381
- "bridge-api",
612
+ MCP_SERVER_NAME,
382
613
  "--tools",
383
614
  "*",
384
615
  ...envArgs,
@@ -404,8 +635,11 @@ function outcome(target, status, detail) {
404
635
  /** Direct-write a single target (JSON or Codex TOML) and map to an outcome. */
405
636
  async function directWriteTarget(target, entry, deps) {
406
637
  const path = resolveTargetAbsPath(target, deps);
407
- if (target.format === "toml") {
638
+ if (hostAdapterForTarget(target).format === "toml") {
408
639
  const res = await writeCodexHostConfig(path, entry, deps.fs);
640
+ if (res.status === "skipped-duplicate-registration") {
641
+ return outcome(target, "skipped-duplicate-registration", res.message);
642
+ }
409
643
  if (res.status === "manual-required") {
410
644
  return outcome(target, "manual-required", "existing config could not be safely updated");
411
645
  }
@@ -413,6 +647,9 @@ async function directWriteTarget(target, entry, deps) {
413
647
  }
414
648
  const adapted = adaptBridgeEntryForHostTarget(entry, target);
415
649
  const res = await writeJsonHostConfig(path, target, adapted, deps.fs);
650
+ if (res.status === "skipped-duplicate-registration") {
651
+ return outcome(target, "skipped-duplicate-registration", res.message);
652
+ }
416
653
  if (res.status === "skipped-invalid") {
417
654
  return outcome(target, "skipped-invalid", res.message);
418
655
  }
@@ -422,10 +659,23 @@ async function directWriteTarget(target, entry, deps) {
422
659
  * Provision a single host target, honoring its write strategy:
423
660
  * - manual-instructions → `manual-required`
424
661
  * - direct → JSON merge-write (or Codex safe write)
425
- * - vendor-first → probe + invoke the verified vendor CLI, verify the entry
426
- * landed, else fall back to a safe direct write. Any missing binary, spawn
427
- * error, timeout, non-zero exit, or missing post-write entry triggers the
428
- * fallback rather than a fatal error.
662
+ * - vendor-first → inspect, then probe + invoke the verified vendor CLI,
663
+ * verify the entry landed, else fall back to a safe direct write. Any
664
+ * missing binary, spawn error, timeout, non-zero exit, or missing post-write
665
+ * entry triggers the fallback rather than a fatal error.
666
+ *
667
+ * BAPI-807 adds a read-only inspection BEFORE the vendor process is reached, for
668
+ * two reasons the vendor CLIs cannot handle themselves:
669
+ *
670
+ * - **Both keys present → refuse.** `claude mcp add-json bridge …` would write a
671
+ * third state on top of an already-ambiguous config, and a spawn cannot be
672
+ * undone. The refusal happens before any process is started.
673
+ * - **Legacy-only → bypass the vendor add.** Every verified vendor contract
674
+ * takes the registration NAME as an argument, and the only name Bridge may
675
+ * pass for a fresh add is the canonical one — so invoking it against a
676
+ * `bridge-api` config would create a second entry rather than update the
677
+ * existing one. The safe direct-update path resolves the key and replaces the
678
+ * legacy entry in place instead.
429
679
  */
430
680
  export async function provisionHostTarget(target, entry, deps) {
431
681
  if (target.writeStrategy === "manual-instructions") {
@@ -434,24 +684,38 @@ export async function provisionHostTarget(target, entry, deps) {
434
684
  if (target.writeStrategy === "direct") {
435
685
  return directWriteTarget(target, entry, deps);
436
686
  }
437
- // vendor-first
438
- const invocation = buildVendorInvocation(target, entry);
439
- if (invocation) {
440
- const available = await deps.vendor.probeBinary(invocation.bin, deps.env);
441
- if (available) {
442
- const result = await deps.vendor.invokeVendorAdd(invocation, deps.env);
443
- if (result.ok) {
444
- // Verify the entry actually landed before trusting the vendor CLI.
445
- const inspected = await inspectHostEntry(target, deps, deps.fs);
446
- if (inspected.present) {
447
- return outcome(target, "vendor-written");
687
+ // vendor-first — inspect first, so neither refusal nor legacy preservation
688
+ // depends on a vendor process having already run.
689
+ const preInspection = await inspectHostEntry(target, deps, deps.fs);
690
+ if (preInspection.duplicateRegistration) {
691
+ return outcome(target, "skipped-duplicate-registration", DUPLICATE_REGISTRATION_DETAIL);
692
+ }
693
+ const mustPreserveLegacy = preInspection.present &&
694
+ preInspection.registrationKey !== null &&
695
+ preInspection.registrationKey !== undefined &&
696
+ preInspection.registrationKey !== MCP_SERVER_NAME;
697
+ if (!mustPreserveLegacy) {
698
+ const invocation = buildVendorInvocation(target, entry);
699
+ if (invocation) {
700
+ const available = await deps.vendor.probeBinary(invocation.bin, deps.env);
701
+ if (available) {
702
+ const result = await deps.vendor.invokeVendorAdd(invocation, deps.env);
703
+ if (result.ok) {
704
+ // Verify the entry actually landed before trusting the vendor CLI.
705
+ const inspected = await inspectHostEntry(target, deps, deps.fs);
706
+ if (inspected.duplicateRegistration) {
707
+ return outcome(target, "skipped-duplicate-registration", DUPLICATE_REGISTRATION_DETAIL);
708
+ }
709
+ if (inspected.present) {
710
+ return outcome(target, "vendor-written");
711
+ }
448
712
  }
449
713
  }
450
714
  }
451
715
  }
452
716
  // Fallback: safe direct write (covers unverified contracts like Codex, a
453
- // missing/failed vendor binary, or a nominally-successful write that left no
454
- // entry).
717
+ // missing/failed vendor binary, a nominally-successful write that left no
718
+ // entry, and the legacy-preservation bypass above).
455
719
  return directWriteTarget(target, entry, deps);
456
720
  }
457
721
  // ---------------------------------------------------------------------------
@@ -468,7 +732,7 @@ export function sanitizeProbeEnv(env) {
468
732
  }
469
733
  /** The launcher spec for the current version (pins are version-locked). */
470
734
  export function currentLauncherSpec() {
471
- return `@bridge_gpt/mcp-server@${VERSION}`;
735
+ return `${MCP_PACKAGE_NAME}@${VERSION}`;
472
736
  }
473
737
  /** Bounded probe/invoke timeout (ms). */
474
738
  const VENDOR_PROCESS_TIMEOUT_MS = 8000;