@bridge_gpt/mcp-server 0.2.41 → 0.2.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +330 -191
- package/build/agent-capabilities/cli.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +2 -2
- package/build/claude-review-workflow.js +510 -45
- package/build/claude-user-config-doctor.js +42 -11
- package/build/cli-release.js +2 -1
- package/build/commands.generated.js +6 -5
- package/build/conduct-epic/bridge-client.js +354 -113
- package/build/conduct-epic/checkpoint-store.js +17 -0
- package/build/conduct-epic/cli.js +947 -99
- package/build/conduct-epic/cut-protocol.js +327 -0
- package/build/conduct-epic/spawn.js +14 -2
- package/build/conductor/bridge-api-client.js +148 -1
- package/build/conductor/cli.js +109 -1
- package/build/conductor/doctor.js +101 -16
- package/build/conductor/epic-reconcile.js +72 -19
- package/build/conductor/epic-runtime.js +15 -3
- package/build/conductor/errors.js +47 -0
- package/build/conductor/git-hooks.js +205 -11
- package/build/conductor/install-doctor.js +230 -1
- package/build/conductor/local-merge.js +130 -28
- package/build/conductor/recovery-cli.js +313 -0
- package/build/conductor/recovery-operations.js +219 -0
- package/build/conductor/tools.js +32 -3
- package/build/conductor/worker-ledger-cli.js +27 -1
- package/build/conductor-bin.js +20 -16
- package/build/credentials-cli.js +3 -2
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +120 -44
- package/build/drive-epic.js +375 -0
- package/build/executor/cli.js +48 -1
- package/build/executor/env.js +21 -0
- package/build/executor/http-client.js +71 -3
- package/build/executor/index-scope.js +39 -0
- package/build/executor/job-errors.js +9 -0
- package/build/executor/job-log-registry.js +69 -0
- package/build/executor/job-runner.js +198 -29
- package/build/executor/live-worker-registry.js +83 -0
- package/build/executor/observation.js +259 -6
- package/build/executor/platform.js +147 -3
- package/build/executor/process.js +58 -14
- package/build/executor/runner.js +454 -48
- package/build/executor/test-clock.js +3 -2
- package/build/executor/worker-finalization.js +233 -56
- package/build/executor/worktree.js +8 -1
- package/build/index-scope-contract.js +96 -0
- package/build/index.js +2277 -270
- package/build/init.js +83 -22
- package/build/install-bridge-conductor.js +323 -14
- package/build/install-bridge.js +225 -47
- package/build/install-doctor.js +23 -9
- package/build/install-reexec.js +2 -1
- package/build/launcher-config-inspection.js +83 -22
- package/build/mcp-host-config.js +331 -67
- package/build/mcp-host-targets.js +45 -21
- package/build/mcp-identity.js +92 -0
- package/build/mcp-install-state.js +94 -1
- package/build/mcp-invoke.js +2 -1
- package/build/mcp-provisioning.js +45 -12
- package/build/mcp-registration-doctor.js +35 -13
- package/build/mcp-server-invocation.js +4 -2
- package/build/merge-pull-request.js +208 -9
- package/build/pipelines.generated.js +305 -15
- package/build/plane/cli.js +73 -7
- package/build/plane/defaults.js +18 -5
- package/build/plane/manifest.js +90 -0
- package/build/plane/preflight.js +100 -10
- package/build/plane/shutdown.js +71 -3
- package/build/plane/test-fakes.js +9 -1
- package/build/readme.generated.js +1 -1
- package/build/regression-check.js +3 -2
- package/build/review-tickets.js +8 -7
- package/build/run-unit-tests-launcher.js +149 -6
- package/build/schedule-run.js +3 -2
- package/build/setup-epic.js +531 -82
- package/build/sfcc/tool-wrapper.js +15 -0
- package/build/start-tickets-prereqs.js +11 -6
- package/build/start-tickets.js +91 -85
- package/build/update-check.js +3 -2
- package/build/upgrade-advice.js +2 -1
- package/build/upgrade-cli.js +50 -18
- package/build/version.generated.js +2 -1
- package/build/worktree-core.js +31 -17
- package/docs/CONDUCTOR.md +22 -0
- package/docs/install/mcp-tool-integrations.md +19 -3
- package/package.json +2 -2
- package/pipelines/greenfield-setup.json +286 -0
package/build/mcp-host-config.js
CHANGED
|
@@ -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
|
|
27
|
-
* existing byte), and DECLINE (`manual-required`) an existing
|
|
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 (
|
|
69
|
-
physical.type =
|
|
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 (
|
|
75
|
-
for (const [k, v] of Object.entries(
|
|
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
|
|
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
|
|
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[
|
|
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
|
-
|
|
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[
|
|
133
|
-
merged[
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
280
|
+
`[mcp_servers.${MCP_SERVER_NAME}]`,
|
|
227
281
|
`command = ${tomlBasicString(entry.command)}`,
|
|
228
282
|
`args = ${tomlStringArray(entry.args)}`,
|
|
229
283
|
"",
|
|
230
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
246
|
-
* minimal newline boundary); an existing Bridge table
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
313
|
-
|
|
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
|
|
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",
|
|
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
|
|
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
|
-
|
|
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,
|
|
426
|
-
* landed, else fall back to a safe direct write. Any
|
|
427
|
-
* error, timeout, non-zero exit, or missing post-write
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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,
|
|
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
|
|
735
|
+
return `${MCP_PACKAGE_NAME}@${VERSION}`;
|
|
472
736
|
}
|
|
473
737
|
/** Bounded probe/invoke timeout (ms). */
|
|
474
738
|
const VENDOR_PROCESS_TIMEOUT_MS = 8000;
|