@bridge_gpt/mcp-server 0.1.17 → 0.2.0

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 (36) hide show
  1. package/README.md +333 -197
  2. package/build/agent-capabilities/cli.js +152 -0
  3. package/build/agent-capabilities/default-deps.js +45 -0
  4. package/build/agent-capabilities/probe-context.js +111 -0
  5. package/build/agent-capabilities/probes.js +278 -0
  6. package/build/agent-capabilities/reporter.js +50 -0
  7. package/build/agent-capabilities/runner.js +56 -0
  8. package/build/agent-capabilities/types.js +10 -0
  9. package/build/agent-launchers/claude.js +4 -4
  10. package/build/agents.generated.js +1 -1
  11. package/build/brainstorm-files.js +89 -0
  12. package/build/bridge-config.js +404 -0
  13. package/build/chain-orchestrator.js +247 -33
  14. package/build/commands.generated.js +5 -5
  15. package/build/credential-materialization.js +128 -0
  16. package/build/credential-store.js +232 -0
  17. package/build/decision-page-schema.js +39 -6
  18. package/build/decision-page-template.js +54 -18
  19. package/build/doctor.js +18 -2
  20. package/build/git-ignore-utils.js +63 -0
  21. package/build/index.js +1510 -560
  22. package/build/mcp-invoke.js +417 -0
  23. package/build/mcp-provisioning.js +249 -0
  24. package/build/mcp-registration-doctor.js +96 -0
  25. package/build/pipeline-orchestrator.js +9 -1
  26. package/build/pipeline-utils.js +33 -0
  27. package/build/pipelines.generated.js +36 -5
  28. package/build/schedule-run.js +6 -6
  29. package/build/start-tickets-prereqs.js +90 -1
  30. package/build/start-tickets.js +106 -14
  31. package/build/third-party-mcp-targets.js +75 -0
  32. package/build/version.generated.js +1 -1
  33. package/package.json +3 -3
  34. package/pipelines/full-automation.json +3 -1
  35. package/pipelines/implement-ticket.json +28 -2
  36. package/smoke-test/SMOKE-TEST.md +4 -2
@@ -0,0 +1,417 @@
1
+ /**
2
+ * `mcp-invoke` — internal worktree shim subcommand.
3
+ *
4
+ * Resolves an MCP target's launch command + environment from an explicit
5
+ * absolute `--project-root` (NEVER from `process.cwd()`), then spawns the real
6
+ * MCP server as a child of the current Node process with inherited stdio and
7
+ * forwarded signals/exit codes. Secrets are passed to the child ONLY through its
8
+ * environment — never in argv, logs, or error messages.
9
+ *
10
+ * Two target families:
11
+ * - `bapi` — resolves Bridge API repo identity + `BAPI_API_KEY`, overlays the
12
+ * three `BAPI_*` values, and spawns this same Node script (the real Bridge
13
+ * API MCP server).
14
+ * - any Tier-2 target declared in `.bridge/config` (e.g. `sfcc`) — reads the
15
+ * manifest entry, resolves the target's required env overlay atomically from
16
+ * the secret store, and spawns the manifest `command`/`args`.
17
+ *
18
+ * This file is the highest-risk surface of the worktree-credentials work: the
19
+ * stdio passthrough plus the signal/exit-code contract is covered exhaustively
20
+ * by its unit tests.
21
+ */
22
+ import { spawn, execFile } from "child_process";
23
+ import { stat, readFile } from "fs/promises";
24
+ import path from "path";
25
+ import os from "os";
26
+ import { resolveRepoNameForProjectRoot, readBridgeConfig, validateMcpTarget, } from "./bridge-config.js";
27
+ import { resolveBapiCredentials } from "./credential-store.js";
28
+ import { getThirdPartyTargetDefinition, resolveThirdPartyTargetEnv, validateThirdPartyTargetManifestEntry, } from "./third-party-mcp-targets.js";
29
+ // ---------------------------------------------------------------------------
30
+ // Usage / argument parsing
31
+ // ---------------------------------------------------------------------------
32
+ export function getMcpInvokeUsage() {
33
+ return [
34
+ "Usage:",
35
+ " npx -y @bridge_gpt/mcp-server mcp-invoke --target <target> --project-root <ABS_WORKTREE_PATH>",
36
+ "",
37
+ "Internal worktree shim: resolves the target's launch command and credentials",
38
+ "from the given project root, then spawns the real MCP server over stdio.",
39
+ "",
40
+ "Flags:",
41
+ " --target <target> MCP target to launch. 'bapi' launches the Bridge",
42
+ " API server; a configured third-party target from",
43
+ " .bridge/config (e.g. sfcc) launches that server.",
44
+ " --project-root <ABS_PATH> Absolute path to the worktree (required)",
45
+ " -h, --help Show this help",
46
+ ].join("\n");
47
+ }
48
+ const KNOWN_FLAGS = new Set(["--target", "--project-root"]);
49
+ /**
50
+ * Parse `--target` and `--project-root` (split or `=` form). Rejects unknown
51
+ * flags, positional arguments, missing values, and duplicates. Requires a safe
52
+ * non-empty `--target` identifier and a host-platform-absolute `--project-root`.
53
+ */
54
+ export function parseMcpInvokeArgs(argv) {
55
+ let target;
56
+ let projectRoot;
57
+ for (let i = 0; i < argv.length; i++) {
58
+ const token = argv[i];
59
+ if (token === "-h" || token === "--help") {
60
+ return { status: "help", usage: getMcpInvokeUsage() };
61
+ }
62
+ let flag = token;
63
+ let value;
64
+ const eq = token.indexOf("=");
65
+ if (token.startsWith("--") && eq !== -1) {
66
+ flag = token.slice(0, eq);
67
+ value = token.slice(eq + 1);
68
+ }
69
+ if (!KNOWN_FLAGS.has(flag)) {
70
+ if (token.startsWith("-")) {
71
+ return { status: "error", message: `Unknown flag: ${flag}` };
72
+ }
73
+ return { status: "error", message: `Unexpected positional argument: ${token}` };
74
+ }
75
+ if (value === undefined) {
76
+ const next = argv[i + 1];
77
+ if (next === undefined || next.startsWith("-")) {
78
+ return { status: "error", message: `Missing value for ${flag}` };
79
+ }
80
+ value = next;
81
+ i++;
82
+ }
83
+ if (flag === "--target") {
84
+ if (target !== undefined) {
85
+ return { status: "error", message: "Duplicate --target flag" };
86
+ }
87
+ target = value;
88
+ }
89
+ else {
90
+ if (projectRoot !== undefined) {
91
+ return { status: "error", message: "Duplicate --project-root flag" };
92
+ }
93
+ projectRoot = value;
94
+ }
95
+ }
96
+ if (target === undefined) {
97
+ return { status: "error", message: "Missing required --target flag" };
98
+ }
99
+ const targetValidation = validateMcpTarget(target);
100
+ if (!targetValidation.ok) {
101
+ return { status: "error", message: `Invalid --target: ${targetValidation.error}` };
102
+ }
103
+ if (projectRoot === undefined) {
104
+ return { status: "error", message: "Missing required --project-root flag" };
105
+ }
106
+ if (!path.isAbsolute(projectRoot)) {
107
+ return { status: "error", message: "--project-root must be an absolute path" };
108
+ }
109
+ return { status: "ok", target: targetValidation.value, projectRoot };
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // Validation, env construction, signals
113
+ // ---------------------------------------------------------------------------
114
+ /** Fail fast when `--project-root` does not exist or is not a directory. */
115
+ export async function validateProjectRootDirectory(projectRoot, statFn) {
116
+ try {
117
+ const result = await statFn(projectRoot);
118
+ if (!result.isDirectory()) {
119
+ return { ok: false, error: `--project-root is not a directory: ${projectRoot}` };
120
+ }
121
+ return { ok: true };
122
+ }
123
+ catch {
124
+ return { ok: false, error: `--project-root does not exist: ${projectRoot}` };
125
+ }
126
+ }
127
+ /**
128
+ * Build the child environment: start from the parent env, overlay an arbitrary
129
+ * set of resolved values. The parent env object is never mutated; unrelated
130
+ * parent values (`BAPI_BASE_URL`, `BAPI_DOCS_DIR`, proxy variables, Node env)
131
+ * pass through unchanged. Overlay values are typically secrets and must only
132
+ * ever reach the child through this env object — never argv or logs.
133
+ */
134
+ export function buildChildEnv(parentEnv, overlay) {
135
+ return { ...parentEnv, ...overlay };
136
+ }
137
+ /** Map a terminating signal to a conventional `128 + signum` exit code. */
138
+ export function signalExitCode(signal) {
139
+ if (signal === "SIGINT")
140
+ return 130;
141
+ if (signal === "SIGTERM")
142
+ return 143;
143
+ const signals = os.constants.signals;
144
+ const num = signals[signal];
145
+ if (typeof num === "number")
146
+ return 128 + num;
147
+ return 1;
148
+ }
149
+ // ---------------------------------------------------------------------------
150
+ // Child server spawn
151
+ // ---------------------------------------------------------------------------
152
+ /**
153
+ * Spawn an MCP server child with the given command/args, inherited stdio, and
154
+ * the merged child env. SIGINT/SIGTERM received by the shim are forwarded to the
155
+ * child; the returned promise resolves once the child closes, propagating its
156
+ * numeric exit code (or the signal exit code). Signal handlers are registered
157
+ * after spawn and removed exactly once on close.
158
+ */
159
+ export function spawnMcpCommand(deps) {
160
+ return new Promise((resolve, reject) => {
161
+ let child;
162
+ try {
163
+ child = deps.spawn(deps.command, deps.args, {
164
+ stdio: "inherit",
165
+ env: deps.env,
166
+ });
167
+ }
168
+ catch (err) {
169
+ reject(err);
170
+ return;
171
+ }
172
+ const signals = ["SIGINT", "SIGTERM"];
173
+ const handlers = {};
174
+ let cleaned = false;
175
+ const cleanup = () => {
176
+ if (cleaned)
177
+ return;
178
+ cleaned = true;
179
+ for (const sig of signals)
180
+ deps.offSignal(sig, handlers[sig]);
181
+ };
182
+ for (const sig of signals) {
183
+ const handler = () => {
184
+ try {
185
+ child.kill(sig);
186
+ }
187
+ catch {
188
+ /* best-effort forward; the close handler still resolves */
189
+ }
190
+ };
191
+ handlers[sig] = handler;
192
+ deps.onSignal(sig, handler);
193
+ }
194
+ child.on("error", (err) => {
195
+ cleanup();
196
+ reject(err);
197
+ });
198
+ child.on("close", (...args) => {
199
+ cleanup();
200
+ const code = args[0];
201
+ const signal = args[1];
202
+ if (typeof code === "number") {
203
+ resolve(code);
204
+ }
205
+ else if (signal) {
206
+ resolve(signalExitCode(signal));
207
+ }
208
+ else {
209
+ resolve(0);
210
+ }
211
+ });
212
+ });
213
+ }
214
+ /**
215
+ * Back-compat wrapper: spawn the real Bridge API MCP server as
216
+ * `node <thisScript>` (no `mcp-invoke` subcommand args, so the child enters the
217
+ * normal no-subcommand startup path). Delegates to {@link spawnMcpCommand}.
218
+ */
219
+ export function spawnRealMcpServer(deps) {
220
+ return spawnMcpCommand({
221
+ spawn: deps.spawn,
222
+ command: deps.execPath,
223
+ args: [deps.scriptPath],
224
+ env: deps.env,
225
+ onSignal: deps.onSignal,
226
+ offSignal: deps.offSignal,
227
+ });
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // Default production runners (used when overrides are not supplied)
231
+ // ---------------------------------------------------------------------------
232
+ function defaultRunCommand(file, args, options) {
233
+ return new Promise((resolve) => {
234
+ execFile(file, args, { cwd: options?.cwd }, (err, stdout, stderr) => {
235
+ if (err) {
236
+ const code = typeof err.code === "number"
237
+ ? (err.code)
238
+ : 1;
239
+ resolve({
240
+ stdout: stdout?.toString() ?? "",
241
+ stderr: stderr?.toString() ?? err.message,
242
+ exitCode: code,
243
+ });
244
+ }
245
+ else {
246
+ resolve({ stdout: stdout.toString(), stderr: stderr.toString(), exitCode: 0 });
247
+ }
248
+ });
249
+ });
250
+ }
251
+ /**
252
+ * Resolve the Bridge API invocation: derive repo identity, resolve the API key
253
+ * (env→store), and overlay the three `BAPI_*` values onto the parent env. The
254
+ * spawned command is `process.execPath` with `[scriptPath]` (no subcommand args
255
+ * — the child runs the normal server). Secret-free errors on any failure.
256
+ */
257
+ export async function resolveBapiInvocation(projectRoot, deps) {
258
+ const repoResult = await deps.resolveRepoName(projectRoot);
259
+ if (!repoResult.ok) {
260
+ return { ok: false, error: `failed to resolve repo identity: ${repoResult.error}` };
261
+ }
262
+ const repoName = repoResult.value;
263
+ const credResult = await deps.resolveCredentials(repoName, projectRoot);
264
+ if (!credResult.ok) {
265
+ return { ok: false, error: `failed to resolve credentials: ${credResult.error}` };
266
+ }
267
+ const env = buildChildEnv(deps.env, {
268
+ BAPI_API_KEY: credResult.credentials.apiKey,
269
+ BAPI_PROJECT_ROOT: projectRoot,
270
+ BAPI_REPO_NAME: repoName,
271
+ });
272
+ return {
273
+ ok: true,
274
+ invocation: { command: deps.execPath, args: [deps.scriptPath], env },
275
+ };
276
+ }
277
+ /**
278
+ * Resolve a Tier-2 third-party invocation: read `.bridge/config`, locate the
279
+ * requested target entry, resolve the target's required env overlay atomically
280
+ * from the secret store, and return the manifest `command`/`args` plus the
281
+ * overlaid child env. Credentials never enter argv. Secret-free errors.
282
+ */
283
+ export async function resolveThirdPartyInvocation(target, projectRoot, deps) {
284
+ const definition = deps.getTargetDefinition(target);
285
+ if (!definition) {
286
+ return {
287
+ ok: false,
288
+ error: `unsupported MCP target '${target}'; not a known third-party target`,
289
+ };
290
+ }
291
+ const read = await deps.readBridgeConfig(projectRoot);
292
+ if (!read.ok) {
293
+ if (read.kind === "missing") {
294
+ return { ok: false, error: `target '${target}' requires a .bridge/config; none was found` };
295
+ }
296
+ return { ok: false, error: `unable to read .bridge/config for target '${target}'` };
297
+ }
298
+ const entry = read.manifest.mcp.find((m) => m.target === target);
299
+ if (!entry) {
300
+ return {
301
+ ok: false,
302
+ error: `target '${target}' is not declared in .bridge/config`,
303
+ };
304
+ }
305
+ const entryValidation = validateThirdPartyTargetManifestEntry(entry);
306
+ if (!entryValidation.ok) {
307
+ return { ok: false, error: entryValidation.error };
308
+ }
309
+ // After validation these are guaranteed present.
310
+ const command = entry.command;
311
+ const args = entry.args;
312
+ const secretBundle = entry.secretBundle;
313
+ const envResult = await deps.resolveTargetEnv(definition, secretBundle);
314
+ if (!envResult.ok) {
315
+ return { ok: false, error: `failed to resolve credentials for '${target}': ${envResult.error}` };
316
+ }
317
+ const env = buildChildEnv(deps.env, envResult.env);
318
+ return { ok: true, invocation: { command, args, env } };
319
+ }
320
+ /**
321
+ * Run the `mcp-invoke` subcommand. Returns a process exit code (never calls
322
+ * `process.exit`). Parses args BEFORE any validation/resolution; validates the
323
+ * project root; dispatches `bapi` to {@link resolveBapiInvocation} and any other
324
+ * target to {@link resolveThirdPartyInvocation}; then spawns. Any failure prints
325
+ * an actionable, secret-free message to stderr and returns non-zero without
326
+ * spawning.
327
+ */
328
+ export async function runMcpInvokeCli(argv, overrides = {}) {
329
+ const log = overrides.log ?? ((m) => process.stdout.write(`${m}\n`));
330
+ const stderr = overrides.stderr ?? ((m) => process.stderr.write(`${m}\n`));
331
+ const env = overrides.env ?? process.env;
332
+ const parsed = parseMcpInvokeArgs(argv);
333
+ if (parsed.status === "help") {
334
+ log(parsed.usage);
335
+ return 0;
336
+ }
337
+ if (parsed.status === "error") {
338
+ stderr(`Error: ${parsed.message}`);
339
+ stderr("");
340
+ stderr(getMcpInvokeUsage());
341
+ return 1;
342
+ }
343
+ const { target, projectRoot } = parsed;
344
+ const statFn = overrides.stat ?? ((p) => stat(p));
345
+ const dirCheck = await validateProjectRootDirectory(projectRoot, statFn);
346
+ if (!dirCheck.ok) {
347
+ stderr(`Error: ${dirCheck.error}`);
348
+ return 1;
349
+ }
350
+ const credentialDeps = {
351
+ env,
352
+ homedir: os.homedir,
353
+ platform: process.platform,
354
+ readFile: (p) => readFile(p, "utf-8"),
355
+ stat: (p) => stat(p),
356
+ stderr,
357
+ };
358
+ if (target === "bapi") {
359
+ const resolveRepoName = overrides.resolveRepoName ??
360
+ ((pr) => resolveRepoNameForProjectRoot(pr, {
361
+ readFile: (p) => readFile(p, "utf-8"),
362
+ runCommand: defaultRunCommand,
363
+ }));
364
+ const resolveCredentials = overrides.resolveCredentials ??
365
+ ((rn) => resolveBapiCredentials(rn, credentialDeps));
366
+ const resolved = await resolveBapiInvocation(projectRoot, {
367
+ env,
368
+ execPath: process.execPath,
369
+ scriptPath: process.argv[1],
370
+ resolveRepoName,
371
+ resolveCredentials,
372
+ });
373
+ if (!resolved.ok) {
374
+ stderr(`Error: ${resolved.error}`);
375
+ return 1;
376
+ }
377
+ return spawnInvocation(resolved.invocation, overrides);
378
+ }
379
+ // Tier-2 third-party target.
380
+ const readConfig = overrides.readBridgeConfig ??
381
+ ((pr) => readBridgeConfig(pr, { readFile: (p) => readFile(p, "utf-8") }));
382
+ const getTargetDefinition = overrides.getTargetDefinition ?? getThirdPartyTargetDefinition;
383
+ const resolveTargetEnv = overrides.resolveThirdPartyEnv ??
384
+ ((definition, secretBundle) => resolveThirdPartyTargetEnv(definition, secretBundle, credentialDeps));
385
+ const resolved = await resolveThirdPartyInvocation(target, projectRoot, {
386
+ env,
387
+ readBridgeConfig: readConfig,
388
+ getTargetDefinition,
389
+ resolveTargetEnv,
390
+ });
391
+ if (!resolved.ok) {
392
+ stderr(`Error: ${resolved.error}`);
393
+ return 1;
394
+ }
395
+ return spawnInvocation(resolved.invocation, overrides);
396
+ }
397
+ /** Spawn a resolved invocation, honoring the legacy and generic spawn overrides. */
398
+ function spawnInvocation(invocation, overrides) {
399
+ if (overrides.spawnMcpCommand) {
400
+ return overrides.spawnMcpCommand(invocation);
401
+ }
402
+ if (overrides.spawnRealMcpServer) {
403
+ return overrides.spawnRealMcpServer(invocation.env);
404
+ }
405
+ return spawnMcpCommand({
406
+ spawn: spawn,
407
+ command: invocation.command,
408
+ args: invocation.args,
409
+ env: invocation.env,
410
+ onSignal: (s, h) => {
411
+ process.on(s, h);
412
+ },
413
+ offSignal: (s, h) => {
414
+ process.off(s, h);
415
+ },
416
+ });
417
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Worktree MCP registration provisioning.
3
+ *
4
+ * After `start-tickets` creates a worktree, this module writes secret-free MCP
5
+ * registrations into both Claude (`.mcp.json`) and Cursor (`.cursor/mcp.json`)
6
+ * so the configured MCP servers are reachable from either editor. Every
7
+ * generated entry contains NO `env` block — it points at the `mcp-invoke` shim
8
+ * with an absolute `--project-root` and the target name; credentials are
9
+ * resolved at runtime by the shim, never written into the worktree.
10
+ *
11
+ * Registrations are driven by `.bridge/config`: the `bapi` target is always
12
+ * provisioned when present, and every supported Tier-2 target (e.g. `sfcc`) is
13
+ * provisioned as its own shim entry. Unsupported or incomplete non-`bapi`
14
+ * targets produce a non-fatal, secret-free warning.
15
+ *
16
+ * All filesystem access is dependency-injected so this is unit-testable, and the
17
+ * platform path API is resolved locally (never imported from `start-tickets.ts`)
18
+ * to avoid a runtime import cycle.
19
+ */
20
+ import path from "path";
21
+ import { VERSION } from "./version.generated.js";
22
+ import { readBridgeConfig } from "./bridge-config.js";
23
+ import { getThirdPartyTargetDefinition, validateThirdPartyTargetManifestEntry, } from "./third-party-mcp-targets.js";
24
+ // ---------------------------------------------------------------------------
25
+ // Pure helpers
26
+ // ---------------------------------------------------------------------------
27
+ /**
28
+ * Resolve the path API for the target platform. Local (not imported from
29
+ * `start-tickets.ts`) to avoid a runtime cycle, since that module imports the
30
+ * provisioning entry point.
31
+ */
32
+ export function pathApiForProvisioningPlatform(platform) {
33
+ return platform === "win32" ? path.win32 : path.posix;
34
+ }
35
+ /**
36
+ * Normalize a worktree path to an absolute, platform-correct registration path.
37
+ * Absolute inputs are normalized; relative inputs are resolved against
38
+ * `deps.cwd`. A path that cannot be made absolute is a structured error.
39
+ */
40
+ export function normalizeWorktreePathForRegistration(worktreePath, deps) {
41
+ const api = pathApiForProvisioningPlatform(deps.platform);
42
+ if (typeof worktreePath !== "string" || worktreePath.trim().length === 0) {
43
+ return { ok: false, error: "worktree path is empty" };
44
+ }
45
+ const resolved = api.isAbsolute(worktreePath)
46
+ ? api.normalize(worktreePath)
47
+ : api.resolve(deps.cwd, worktreePath);
48
+ if (!api.isAbsolute(resolved)) {
49
+ return { ok: false, error: `unable to resolve an absolute worktree path from "${worktreePath}"` };
50
+ }
51
+ return { ok: true, path: resolved };
52
+ }
53
+ /** Map an MCP target to its registration server name (`bapi` -> `bridge-api`). */
54
+ export function serverNameForMcpTarget(target) {
55
+ return target === "bapi" ? "bridge-api" : target;
56
+ }
57
+ /**
58
+ * Build a secret-free shim entry for any target. `args` are version-pinned and
59
+ * carry the target name plus the absolute project root; there is intentionally
60
+ * no `env` block.
61
+ */
62
+ export function buildShimMcpServerEntry(target, absoluteWorktreePath) {
63
+ return {
64
+ command: "npx",
65
+ args: [
66
+ "-y",
67
+ `@bridge_gpt/mcp-server@${VERSION}`,
68
+ "mcp-invoke",
69
+ "--target",
70
+ target,
71
+ "--project-root",
72
+ absoluteWorktreePath,
73
+ ],
74
+ };
75
+ }
76
+ /** Back-compat wrapper around `buildShimMcpServerEntry("bapi", ...)`. */
77
+ export function buildBridgeApiShimMcpServerEntry(absoluteWorktreePath) {
78
+ return buildShimMcpServerEntry("bapi", absoluteWorktreePath);
79
+ }
80
+ /**
81
+ * Convert all supported manifest `mcp` entries into a map of server name -> shim
82
+ * entry. `bapi` is always supported; a Tier-2 target is supported only when it
83
+ * has a known target definition AND its manifest entry is complete. Unsupported
84
+ * or incomplete non-`bapi` targets are skipped with a secret-free warning (they
85
+ * never appear in the returned entries).
86
+ */
87
+ export function buildMcpServerEntriesForManifest(manifest, absoluteWorktreePath) {
88
+ const entries = {};
89
+ const warnings = [];
90
+ for (const mcp of manifest.mcp) {
91
+ if (mcp.target === "bapi") {
92
+ entries[serverNameForMcpTarget("bapi")] = buildShimMcpServerEntry("bapi", absoluteWorktreePath);
93
+ continue;
94
+ }
95
+ const definition = getThirdPartyTargetDefinition(mcp.target);
96
+ if (!definition) {
97
+ warnings.push(`MCP target '${mcp.target}' is not a supported third-party target; skipping its registration.`);
98
+ continue;
99
+ }
100
+ const validation = validateThirdPartyTargetManifestEntry(mcp);
101
+ if (!validation.ok) {
102
+ warnings.push(`MCP target '${mcp.target}' registration skipped: ${validation.error}.`);
103
+ continue;
104
+ }
105
+ entries[serverNameForMcpTarget(mcp.target)] = buildShimMcpServerEntry(mcp.target, absoluteWorktreePath);
106
+ }
107
+ return { entries, warnings };
108
+ }
109
+ /** Both registration files written for every provisioned worktree. */
110
+ export function getWorktreeMcpRegistrationTargets(worktreePath, platform) {
111
+ const api = pathApiForProvisioningPlatform(platform);
112
+ return [
113
+ { filePath: api.join(worktreePath, ".mcp.json"), topLevelKey: "mcpServers" },
114
+ { filePath: api.join(worktreePath, ".cursor", "mcp.json"), topLevelKey: "mcpServers" },
115
+ ];
116
+ }
117
+ /**
118
+ * Merge multiple shim entries into an existing parsed registration document.
119
+ * Unrelated top-level fields and unrelated MCP servers are preserved; only the
120
+ * generated server names are replaced. Any legacy secret-embedded entry for a
121
+ * generated server name is force-upgraded to the secret-free shim shape.
122
+ */
123
+ export function mergeMcpRegistrations(existing, topLevelKey, entries) {
124
+ const result = existing && typeof existing === "object" && !Array.isArray(existing)
125
+ ? { ...existing }
126
+ : {};
127
+ const current = result[topLevelKey];
128
+ const servers = current && typeof current === "object" && !Array.isArray(current)
129
+ ? { ...current }
130
+ : {};
131
+ for (const [name, entry] of Object.entries(entries)) {
132
+ servers[name] = entry;
133
+ }
134
+ result[topLevelKey] = servers;
135
+ return result;
136
+ }
137
+ /**
138
+ * Back-compat single-entry merge. Sets only `mcpServers["bridge-api"]`; unrelated
139
+ * top-level fields and servers are preserved.
140
+ */
141
+ export function mergeBridgeApiMcpRegistration(existing, topLevelKey, entry) {
142
+ return mergeMcpRegistrations(existing, topLevelKey, { "bridge-api": entry });
143
+ }
144
+ // ---------------------------------------------------------------------------
145
+ // Filesystem writes
146
+ // ---------------------------------------------------------------------------
147
+ /**
148
+ * Write or merge a single registration file with the given entries. A missing
149
+ * file is created (with parent directories); an existing valid file is merged; an
150
+ * existing file with malformed JSON is left untouched and reported as a failure.
151
+ * JSON is written with two-space indentation and a trailing newline.
152
+ */
153
+ export async function writeMcpRegistrationFile(target, entries, deps) {
154
+ const api = pathApiForProvisioningPlatform(deps.platform);
155
+ let existing;
156
+ try {
157
+ const raw = await deps.readFile(target.filePath);
158
+ try {
159
+ existing = JSON.parse(raw);
160
+ }
161
+ catch {
162
+ return {
163
+ ok: false,
164
+ error: `existing ${target.filePath} contains malformed JSON; not overwriting`,
165
+ };
166
+ }
167
+ }
168
+ catch (err) {
169
+ const code = err && typeof err === "object" ? err.code : undefined;
170
+ if (code !== "ENOENT") {
171
+ return { ok: false, error: `unable to read ${target.filePath}` };
172
+ }
173
+ // ENOENT: create a fresh document below.
174
+ }
175
+ const merged = mergeMcpRegistrations(existing, target.topLevelKey, entries);
176
+ try {
177
+ await deps.mkdir(api.dirname(target.filePath), { recursive: true });
178
+ await deps.writeFile(target.filePath, `${JSON.stringify(merged, null, 2)}\n`);
179
+ }
180
+ catch {
181
+ return { ok: false, error: `failed to write ${target.filePath}` };
182
+ }
183
+ return { ok: true };
184
+ }
185
+ // ---------------------------------------------------------------------------
186
+ // Per-worktree orchestration
187
+ // ---------------------------------------------------------------------------
188
+ function withWarning(row, warning) {
189
+ return { ...row, warnings: [...(row.warnings ?? []), warning] };
190
+ }
191
+ function withWarnings(row, warnings) {
192
+ if (warnings.length === 0)
193
+ return row;
194
+ return { ...row, warnings: [...(row.warnings ?? []), ...warnings] };
195
+ }
196
+ /**
197
+ * Provision MCP registrations for one created worktree row.
198
+ *
199
+ * - Non-`created` rows and rows without a path are returned unchanged.
200
+ * - A missing / malformed manifest yields a non-fatal warning and leaves the row
201
+ * status unchanged.
202
+ * - A valid manifest writes BOTH Claude and Cursor registration files with a
203
+ * shim entry for every supported target (`bapi` + complete Tier-2 targets),
204
+ * regardless of the selected agent. Unsupported/incomplete non-`bapi` targets
205
+ * add a secret-free warning but never abort provisioning.
206
+ * - A required write failure (or a malformed existing registration file) marks
207
+ * only this row `spawn-failed` with a descriptive error; other rows continue.
208
+ */
209
+ export async function provisionMcpRegistrationForWorktree(row, deps) {
210
+ if (row.status !== "created" || !row.path) {
211
+ return row;
212
+ }
213
+ const read = await readBridgeConfig(row.path, { readFile: deps.readFile });
214
+ if (!read.ok) {
215
+ if (read.kind === "missing") {
216
+ return withWarning(row, "MCP provisioning skipped: .bridge/config is missing in the worktree.");
217
+ }
218
+ return withWarning(row, "MCP provisioning skipped: .bridge/config is malformed or invalid.");
219
+ }
220
+ const normalized = normalizeWorktreePathForRegistration(row.path, deps);
221
+ if (!normalized.ok) {
222
+ return { ...row, status: "spawn-failed", error: `MCP provisioning failed: ${normalized.error}` };
223
+ }
224
+ const built = buildMcpServerEntriesForManifest(read.manifest, normalized.path);
225
+ if (Object.keys(built.entries).length === 0) {
226
+ // Nothing supported to write (e.g. a manifest with no bapi target and no
227
+ // supported Tier-2 targets). Surface any warnings but leave status unchanged.
228
+ return withWarnings(withWarning(row, "MCP registration skipped: .bridge/config declares no supported MCP targets."), built.warnings);
229
+ }
230
+ const targets = getWorktreeMcpRegistrationTargets(normalized.path, deps.platform);
231
+ for (const target of targets) {
232
+ const result = await writeMcpRegistrationFile(target, built.entries, deps);
233
+ if (!result.ok) {
234
+ return { ...row, status: "spawn-failed", error: `MCP provisioning failed: ${result.error}` };
235
+ }
236
+ }
237
+ return withWarnings(row, built.warnings);
238
+ }
239
+ /**
240
+ * Provision MCP registrations for every created worktree row, in order.
241
+ * Per-row failures are isolated — a failed row never aborts later rows.
242
+ */
243
+ export async function provisionMcpRegistrationsForCreatedWorktrees(rows, deps) {
244
+ const out = [];
245
+ for (const row of rows) {
246
+ out.push(await provisionMcpRegistrationForWorktree(row, deps));
247
+ }
248
+ return out;
249
+ }