@bridge_gpt/mcp-server 0.2.36 → 0.2.38
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 +48 -8
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-user-config-doctor.js +317 -0
- package/build/commands.generated.js +2 -1
- package/build/conductor/bridge-api-client.js +178 -4
- package/build/conductor-bin.js +1 -1
- package/build/conductor-bundle-artifacts.js +7 -6
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +239 -80
- package/build/executor/cli.js +51 -1
- package/build/executor/credentials.js +1 -7
- package/build/executor/deps.js +18 -1
- package/build/executor/env.js +51 -25
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +422 -22
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +104 -8
- package/build/executor/preflight.js +32 -0
- package/build/executor/runner.js +8 -0
- package/build/executor/test-clock.js +67 -3
- package/build/executor/types.js +4 -1
- package/build/executor/worker-command.js +11 -3
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +614 -244
- package/build/init.js +363 -73
- package/build/install-bridge.js +568 -80
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +30 -7
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +22 -9
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +2 -0
- package/build/setup-epic.js +662 -27
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +70 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-body.js +92 -0
- package/build/sfcc/read-projection.js +185 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +57 -34
- package/build/sfcc/reads-site-preference.js +86 -33
- package/build/sfcc/reads-system-object.js +50 -38
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +71 -15
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-custom-object-def.js +6 -2
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +417 -101
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +23 -8
- package/package.json +3 -3
- package/pipelines/implement-ticket.json +15 -5
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, read-only inspection of every project-local MCP launcher config
|
|
3
|
+
* (BAPI-728).
|
|
4
|
+
*
|
|
5
|
+
* Before this module, three callers each carried their own idea of what a
|
|
6
|
+
* launcher config looks like:
|
|
7
|
+
*
|
|
8
|
+
* - `doctor.ts` parsed the pin correctly and reported `STALE-PINNED`.
|
|
9
|
+
* - `upgrade-cli.ts` kept a duplicate path list and never read the pin at all,
|
|
10
|
+
* deciding from the running process `VERSION` instead — which is why a
|
|
11
|
+
* twenty-release repin reported itself as `0.2.36 -> 0.2.36`.
|
|
12
|
+
* - `init.ts` repaired the pin as an undocumented side effect of writing.
|
|
13
|
+
*
|
|
14
|
+
* This module is the single authority for *what a config currently says*. It is
|
|
15
|
+
* strictly read-only: it never writes, never spawns, never touches the network,
|
|
16
|
+
* and never resolves credentials. Callers layer their own concerns on top —
|
|
17
|
+
* `doctor` adds a read-only npx cache probe, `init` performs the mutation,
|
|
18
|
+
* `upgrade` renders the transition report and owns exit status.
|
|
19
|
+
*
|
|
20
|
+
* Secret safety: an inspection result carries only sanitized launcher facts —
|
|
21
|
+
* relative path, launcher shape, the package spec token, and versions. It never
|
|
22
|
+
* carries config bodies, `env` maps, credential values, or raw error text.
|
|
23
|
+
*/
|
|
24
|
+
import path from "path";
|
|
25
|
+
/** The npm package every Bridge launcher spec names. */
|
|
26
|
+
export const BRIDGE_PACKAGE_NAME = "@bridge_gpt/mcp-server";
|
|
27
|
+
/**
|
|
28
|
+
* The subcommand token that marks a worktree shim registration. A shim points at
|
|
29
|
+
* a sibling worktree's server rather than a published package, so no pin applies
|
|
30
|
+
* and no rewrite is ever safe (BAPI-714 / worktree provisioning).
|
|
31
|
+
*/
|
|
32
|
+
const WORKTREE_SHIM_TOKEN = "mcp-invoke";
|
|
33
|
+
/** The MCP server entry key every Bridge registration uses. */
|
|
34
|
+
export const BRIDGE_ENTRY_KEY = "bridge-api";
|
|
35
|
+
/**
|
|
36
|
+
* Every project-local MCP config Bridge registers into. Exported so `doctor`,
|
|
37
|
+
* `init`, and `upgrade` can no longer drift apart on which paths exist or which
|
|
38
|
+
* root key each one uses. Callers that need a different *order* should map over
|
|
39
|
+
* their own list and use {@link launcherTargetFor} for the metadata.
|
|
40
|
+
*/
|
|
41
|
+
export const LAUNCHER_CONFIG_TARGETS = [
|
|
42
|
+
{ relPath: ".mcp.json", topLevelKey: "mcpServers" },
|
|
43
|
+
{ relPath: ".cursor/mcp.json", topLevelKey: "mcpServers" },
|
|
44
|
+
{ relPath: ".vscode/mcp.json", topLevelKey: "servers" },
|
|
45
|
+
];
|
|
46
|
+
/** Look up a target's metadata by relative path, or `undefined` if unknown. */
|
|
47
|
+
export function launcherTargetFor(relPath) {
|
|
48
|
+
return LAUNCHER_CONFIG_TARGETS.find((t) => t.relPath === relPath);
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Exact-version parsing and comparison
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/**
|
|
54
|
+
* Strict exact-release form: `MAJOR.MINOR.PATCH`, nothing else. Deliberately
|
|
55
|
+
* narrower than semver at large — a prerelease, build metadata, a `v` prefix, a
|
|
56
|
+
* two-part `0.2`, a range (`^0.2.0`), or a dist-tag (`beta`) is NOT an exact
|
|
57
|
+
* version and must never be treated as one. Everything the package publishes
|
|
58
|
+
* takes this form, so anything else in a config is something a human wrote and
|
|
59
|
+
* an automated repin has no business silently rewriting.
|
|
60
|
+
*/
|
|
61
|
+
const EXACT_VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
62
|
+
/** Parse an exact `MAJOR.MINOR.PATCH` string, or return null for any other form. */
|
|
63
|
+
export function parseExactVersion(raw) {
|
|
64
|
+
if (typeof raw !== "string")
|
|
65
|
+
return null;
|
|
66
|
+
const match = EXACT_VERSION_RE.exec(raw.trim());
|
|
67
|
+
if (!match)
|
|
68
|
+
return null;
|
|
69
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
70
|
+
}
|
|
71
|
+
/** True when `value` is an exact `MAJOR.MINOR.PATCH` release string. */
|
|
72
|
+
export function isExactVersion(raw) {
|
|
73
|
+
return parseExactVersion(raw) !== null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Compare two exact versions: negative when `a < b`, zero when equal, positive
|
|
77
|
+
* when `a > b`. Returns null when either side is not an exact version, so a
|
|
78
|
+
* caller can never accidentally order a range against a release.
|
|
79
|
+
*/
|
|
80
|
+
export function compareExactVersions(a, b) {
|
|
81
|
+
const left = parseExactVersion(a);
|
|
82
|
+
const right = parseExactVersion(b);
|
|
83
|
+
if (!left || !right)
|
|
84
|
+
return null;
|
|
85
|
+
for (let i = 0; i < 3; i++) {
|
|
86
|
+
if (left[i] !== right[i])
|
|
87
|
+
return left[i] - right[i];
|
|
88
|
+
}
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Extract the Bridge package token from a launcher args array.
|
|
93
|
+
*
|
|
94
|
+
* The package name is itself scoped (`@scope/name`), so the version delimiter is
|
|
95
|
+
* the FIRST `@` *after* the package name — not the last `@` in the string.
|
|
96
|
+
* `@bridge_gpt/mcp-server` is bare; `@bridge_gpt/mcp-server@0.2.16` pins
|
|
97
|
+
* `0.2.16`. A non-Bridge package token is never matched.
|
|
98
|
+
*/
|
|
99
|
+
export function parseLauncherPin(args) {
|
|
100
|
+
if (!Array.isArray(args))
|
|
101
|
+
return null;
|
|
102
|
+
for (const arg of args) {
|
|
103
|
+
if (typeof arg !== "string")
|
|
104
|
+
continue;
|
|
105
|
+
if (arg === BRIDGE_PACKAGE_NAME) {
|
|
106
|
+
return { spec: arg, specifier: null, exactVersion: null };
|
|
107
|
+
}
|
|
108
|
+
if (arg.startsWith(`${BRIDGE_PACKAGE_NAME}@`)) {
|
|
109
|
+
const specifier = arg.slice(BRIDGE_PACKAGE_NAME.length + 1).trim();
|
|
110
|
+
if (specifier.length === 0) {
|
|
111
|
+
return { spec: arg, specifier: null, exactVersion: null };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
spec: arg,
|
|
115
|
+
specifier,
|
|
116
|
+
exactVersion: isExactVersion(specifier) ? specifier : null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
/** True when the args array carries the worktree-shim subcommand token. */
|
|
123
|
+
export function isWorktreeShimArgs(args) {
|
|
124
|
+
return Array.isArray(args) && args.some((a) => a === WORKTREE_SHIM_TOKEN);
|
|
125
|
+
}
|
|
126
|
+
/** Human-readable text for each reason code (no dynamic content). */
|
|
127
|
+
const REASON_TEXT = {
|
|
128
|
+
"file-unreadable": "the file exists but could not be read",
|
|
129
|
+
"invalid-json": "the file is not valid JSON",
|
|
130
|
+
"root-not-object": "the JSON root is not an object",
|
|
131
|
+
"servers-not-object": "the servers section is not an object",
|
|
132
|
+
"entry-not-object": "the bridge-api entry is not an object",
|
|
133
|
+
"no-package-token": "the bridge-api launcher has no recognizable @bridge_gpt/mcp-server token",
|
|
134
|
+
"version-range": "the launcher pin is a version range, which an automated repin must not rewrite",
|
|
135
|
+
"non-release-specifier": "the launcher pin is a dist-tag or channel, not an exact release",
|
|
136
|
+
"malformed-version": "the launcher pin is not a valid MAJOR.MINOR.PATCH release",
|
|
137
|
+
"pinned-ahead-of-target": "the launcher pin is newer than the target version and must not be downgraded",
|
|
138
|
+
};
|
|
139
|
+
/** Render a reason code as sanitized prose. */
|
|
140
|
+
export function describeLauncherReason(reason) {
|
|
141
|
+
return REASON_TEXT[reason];
|
|
142
|
+
}
|
|
143
|
+
/** True for the two actions that must abort a mutation run before any write. */
|
|
144
|
+
export function isPrerequisiteFailure(inspection) {
|
|
145
|
+
return inspection.action === "invalid" || inspection.action === "unsupported";
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* True when this target carries a standard launcher whose pin participates in
|
|
149
|
+
* the "is everything already current?" question. Worktree shims, inactive
|
|
150
|
+
* targets, and absent files deliberately do not.
|
|
151
|
+
*/
|
|
152
|
+
export function isApplicableStandardLauncher(inspection) {
|
|
153
|
+
return (inspection.shape === "standard" &&
|
|
154
|
+
(inspection.action === "repin" ||
|
|
155
|
+
inspection.action === "already-current" ||
|
|
156
|
+
inspection.action === "normalize-unpinned"));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Inspect every project-local MCP config's `bridge-api` launcher, read-only.
|
|
160
|
+
*
|
|
161
|
+
* Every target always produces exactly one result, so a caller can render one
|
|
162
|
+
* line per config without re-deriving which paths it looked at. Only an `ENOENT`
|
|
163
|
+
* read is treated as absence — a permission error on an existing file is an
|
|
164
|
+
* `invalid` prerequisite failure, never a "missing file we can freshly create".
|
|
165
|
+
*/
|
|
166
|
+
export async function inspectLauncherConfigs(options) {
|
|
167
|
+
const targets = options.targets ?? LAUNCHER_CONFIG_TARGETS;
|
|
168
|
+
const results = [];
|
|
169
|
+
for (const target of targets) {
|
|
170
|
+
results.push(await inspectOne(target, options));
|
|
171
|
+
}
|
|
172
|
+
return results;
|
|
173
|
+
}
|
|
174
|
+
async function inspectOne(target, options) {
|
|
175
|
+
const { targetVersion } = options;
|
|
176
|
+
const base = {
|
|
177
|
+
relPath: target.relPath,
|
|
178
|
+
topLevelKey: target.topLevelKey,
|
|
179
|
+
targetVersion,
|
|
180
|
+
spec: null,
|
|
181
|
+
currentVersion: null,
|
|
182
|
+
};
|
|
183
|
+
let raw;
|
|
184
|
+
try {
|
|
185
|
+
raw = await options.readFile(path.join(options.cwd, target.relPath));
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
const code = err && typeof err === "object" ? err.code : undefined;
|
|
189
|
+
if (code === "ENOENT") {
|
|
190
|
+
const creatable = options.isCreatable?.(target.relPath) ?? false;
|
|
191
|
+
return {
|
|
192
|
+
...base,
|
|
193
|
+
filePresent: false,
|
|
194
|
+
bridgeEntryPresent: false,
|
|
195
|
+
shape: "none",
|
|
196
|
+
action: creatable ? "create" : "skip-inactive",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// Present-but-unreadable is NOT absence. Creating a fresh file over a config
|
|
200
|
+
// we merely failed to read would silently destroy a working registration.
|
|
201
|
+
return {
|
|
202
|
+
...base,
|
|
203
|
+
filePresent: true,
|
|
204
|
+
bridgeEntryPresent: false,
|
|
205
|
+
shape: "none",
|
|
206
|
+
action: "invalid",
|
|
207
|
+
reason: "file-unreadable",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = JSON.parse(raw);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return {
|
|
216
|
+
...base,
|
|
217
|
+
filePresent: true,
|
|
218
|
+
bridgeEntryPresent: false,
|
|
219
|
+
shape: "none",
|
|
220
|
+
action: "invalid",
|
|
221
|
+
reason: "invalid-json",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (!isPlainObject(parsed)) {
|
|
225
|
+
return {
|
|
226
|
+
...base,
|
|
227
|
+
filePresent: true,
|
|
228
|
+
bridgeEntryPresent: false,
|
|
229
|
+
shape: "none",
|
|
230
|
+
action: "invalid",
|
|
231
|
+
reason: "root-not-object",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const servers = parsed[target.topLevelKey];
|
|
235
|
+
if (servers !== undefined && !isPlainObject(servers)) {
|
|
236
|
+
return {
|
|
237
|
+
...base,
|
|
238
|
+
filePresent: true,
|
|
239
|
+
bridgeEntryPresent: false,
|
|
240
|
+
shape: "none",
|
|
241
|
+
action: "invalid",
|
|
242
|
+
reason: "servers-not-object",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const entry = isPlainObject(servers)
|
|
246
|
+
? servers[BRIDGE_ENTRY_KEY]
|
|
247
|
+
: undefined;
|
|
248
|
+
if (entry === undefined) {
|
|
249
|
+
// A valid host config that simply has no Bridge registration yet. Other
|
|
250
|
+
// servers in the same file are deliberately not inspected or reported.
|
|
251
|
+
return {
|
|
252
|
+
...base,
|
|
253
|
+
filePresent: true,
|
|
254
|
+
bridgeEntryPresent: false,
|
|
255
|
+
shape: "none",
|
|
256
|
+
action: "add",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (!isPlainObject(entry)) {
|
|
260
|
+
return {
|
|
261
|
+
...base,
|
|
262
|
+
filePresent: true,
|
|
263
|
+
bridgeEntryPresent: true,
|
|
264
|
+
shape: "custom",
|
|
265
|
+
action: "invalid",
|
|
266
|
+
reason: "entry-not-object",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const args = entry.args;
|
|
270
|
+
// Worktree shims are detected BEFORE any package-token requirement: the
|
|
271
|
+
// absolute-build-path form (`node /abs/path/build/index.js mcp-invoke`) has no
|
|
272
|
+
// package token at all, and must not be misread as an unsupported launcher.
|
|
273
|
+
if (isWorktreeShimArgs(args)) {
|
|
274
|
+
return {
|
|
275
|
+
...base,
|
|
276
|
+
filePresent: true,
|
|
277
|
+
bridgeEntryPresent: true,
|
|
278
|
+
shape: "worktree-shim",
|
|
279
|
+
action: "skip-worktree-shim",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const pin = parseLauncherPin(args);
|
|
283
|
+
if (!pin) {
|
|
284
|
+
return {
|
|
285
|
+
...base,
|
|
286
|
+
filePresent: true,
|
|
287
|
+
bridgeEntryPresent: true,
|
|
288
|
+
shape: "custom",
|
|
289
|
+
action: "unsupported",
|
|
290
|
+
reason: "no-package-token",
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const common = {
|
|
294
|
+
...base,
|
|
295
|
+
filePresent: true,
|
|
296
|
+
bridgeEntryPresent: true,
|
|
297
|
+
shape: "standard",
|
|
298
|
+
spec: pin.spec,
|
|
299
|
+
};
|
|
300
|
+
// Bare and explicit `@latest` are the two forms an exact-pin policy normalizes
|
|
301
|
+
// rather than rejects — both mean "whatever npx resolves", which is exactly the
|
|
302
|
+
// non-determinism BAPI-429 pinned away from.
|
|
303
|
+
if (pin.specifier === null || pin.specifier === "latest") {
|
|
304
|
+
return { ...common, action: "normalize-unpinned" };
|
|
305
|
+
}
|
|
306
|
+
if (!isExactVersion(pin.specifier)) {
|
|
307
|
+
return {
|
|
308
|
+
...common,
|
|
309
|
+
action: "unsupported",
|
|
310
|
+
reason: classifyNonExactSpecifier(pin.specifier),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const comparison = compareExactVersions(pin.specifier, targetVersion);
|
|
314
|
+
if (comparison === null) {
|
|
315
|
+
// The TARGET is not an exact release. Refuse rather than guess.
|
|
316
|
+
return {
|
|
317
|
+
...common,
|
|
318
|
+
currentVersion: pin.specifier,
|
|
319
|
+
action: "unsupported",
|
|
320
|
+
reason: "malformed-version",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (comparison > 0) {
|
|
324
|
+
return {
|
|
325
|
+
...common,
|
|
326
|
+
currentVersion: pin.specifier,
|
|
327
|
+
action: "ahead",
|
|
328
|
+
reason: "pinned-ahead-of-target",
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
...common,
|
|
333
|
+
currentVersion: pin.specifier,
|
|
334
|
+
action: comparison === 0 ? "already-current" : "repin",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/** Classify why a non-exact specifier cannot be treated as a release pin. */
|
|
338
|
+
function classifyNonExactSpecifier(specifier) {
|
|
339
|
+
if (/^[\^~]|^[<>=]/.test(specifier) || specifier.includes(" - ") || specifier.includes("||")) {
|
|
340
|
+
return "version-range";
|
|
341
|
+
}
|
|
342
|
+
// A leading digit means the author meant a version and got the shape wrong
|
|
343
|
+
// (`0.2`, `1.2.3.4`); anything else is a dist-tag or channel (`beta`, `next`).
|
|
344
|
+
if (/^v?\d/.test(specifier))
|
|
345
|
+
return "malformed-version";
|
|
346
|
+
return "non-release-specifier";
|
|
347
|
+
}
|
|
348
|
+
/** True for a non-null, non-array object. */
|
|
349
|
+
function isPlainObject(value) {
|
|
350
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
351
|
+
}
|
package/build/mcp-invoke.js
CHANGED
|
@@ -25,6 +25,7 @@ import path from "path";
|
|
|
25
25
|
import os from "os";
|
|
26
26
|
import { resolveRepoNameForProjectRoot, readBridgeConfig, validateMcpTarget, } from "./bridge-config.js";
|
|
27
27
|
import { resolveBapiCredentials } from "./credential-store.js";
|
|
28
|
+
import { describeBaseUrlRejection, validateHttpBaseUrl } from "./base-url.js";
|
|
28
29
|
import { getThirdPartyTargetDefinition, resolveThirdPartyTargetEnv, validateThirdPartyTargetManifestEntry, } from "./third-party-mcp-targets.js";
|
|
29
30
|
// ---------------------------------------------------------------------------
|
|
30
31
|
// Usage / argument parsing
|
|
@@ -47,18 +48,30 @@ export function getMcpInvokeUsage() {
|
|
|
47
48
|
" API server; a configured third-party target from",
|
|
48
49
|
" .bridge/config (e.g. sfcc) launches that server.",
|
|
49
50
|
" --project-root <ABS_PATH> Absolute path to the worktree (required)",
|
|
51
|
+
" --base-url <HTTP(S)_URL> Optional. Bridge API endpoint for the spawned",
|
|
52
|
+
" server; applies ONLY to --target bapi. When",
|
|
53
|
+
" omitted, the child inherits BAPI_BASE_URL",
|
|
54
|
+
" unchanged. Must be an absolute http/https URL",
|
|
55
|
+
" with no embedded username or password.",
|
|
50
56
|
" -h, --help Show this help",
|
|
51
57
|
].join("\n");
|
|
52
58
|
}
|
|
53
|
-
const KNOWN_FLAGS = new Set(["--target", "--project-root"]);
|
|
59
|
+
const KNOWN_FLAGS = new Set(["--target", "--project-root", "--base-url"]);
|
|
54
60
|
/**
|
|
55
|
-
* Parse `--target
|
|
56
|
-
* flags, positional arguments, missing values, and
|
|
57
|
-
* non-empty `--target` identifier and a
|
|
61
|
+
* Parse `--target`, `--project-root`, and the optional `--base-url` (split or `=`
|
|
62
|
+
* form). Rejects unknown flags, positional arguments, missing values, and
|
|
63
|
+
* duplicates. Requires a safe non-empty `--target` identifier and a
|
|
64
|
+
* host-platform-absolute `--project-root`.
|
|
65
|
+
*
|
|
66
|
+
* `--base-url` is validated strictly and is valid ONLY with `--target bapi`;
|
|
67
|
+
* pairing it with any other target is an error rather than a silent no-op, so a
|
|
68
|
+
* misgenerated registration fails loudly instead of quietly routing a Tier-2
|
|
69
|
+
* server nowhere. No rejection message ever echoes the supplied URL.
|
|
58
70
|
*/
|
|
59
71
|
export function parseMcpInvokeArgs(argv) {
|
|
60
72
|
let target;
|
|
61
73
|
let projectRoot;
|
|
74
|
+
let baseUrl;
|
|
62
75
|
for (let i = 0; i < argv.length; i++) {
|
|
63
76
|
const token = argv[i];
|
|
64
77
|
if (token === "-h" || token === "--help") {
|
|
@@ -91,6 +104,16 @@ export function parseMcpInvokeArgs(argv) {
|
|
|
91
104
|
}
|
|
92
105
|
target = value;
|
|
93
106
|
}
|
|
107
|
+
else if (flag === "--base-url") {
|
|
108
|
+
if (baseUrl !== undefined) {
|
|
109
|
+
return { status: "error", message: "Duplicate --base-url flag" };
|
|
110
|
+
}
|
|
111
|
+
const validation = validateHttpBaseUrl(value);
|
|
112
|
+
if (!validation.ok) {
|
|
113
|
+
return { status: "error", message: describeBaseUrlRejection("--base-url", validation.reason) };
|
|
114
|
+
}
|
|
115
|
+
baseUrl = validation.baseUrl;
|
|
116
|
+
}
|
|
94
117
|
else {
|
|
95
118
|
if (projectRoot !== undefined) {
|
|
96
119
|
return { status: "error", message: "Duplicate --project-root flag" };
|
|
@@ -111,7 +134,15 @@ export function parseMcpInvokeArgs(argv) {
|
|
|
111
134
|
if (!path.isAbsolute(projectRoot)) {
|
|
112
135
|
return { status: "error", message: "--project-root must be an absolute path" };
|
|
113
136
|
}
|
|
114
|
-
|
|
137
|
+
if (baseUrl !== undefined && targetValidation.value !== "bapi") {
|
|
138
|
+
return {
|
|
139
|
+
status: "error",
|
|
140
|
+
message: "--base-url is only valid with --target bapi",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return baseUrl === undefined
|
|
144
|
+
? { status: "ok", target: targetValidation.value, projectRoot }
|
|
145
|
+
: { status: "ok", target: targetValidation.value, projectRoot, baseUrl };
|
|
115
146
|
}
|
|
116
147
|
// ---------------------------------------------------------------------------
|
|
117
148
|
// Validation, env construction, signals
|
|
@@ -284,6 +315,15 @@ export async function resolveBapiInvocation(projectRoot, deps) {
|
|
|
284
315
|
if (typeof conductorNodePath === "string" && conductorNodePath.trim().length > 0) {
|
|
285
316
|
overlay.CONDUCTOR_NODE_PATH = conductorNodePath;
|
|
286
317
|
}
|
|
318
|
+
// BAPI-727: overlay the endpoint ONLY when `--base-url` was supplied, mirroring
|
|
319
|
+
// the conditional `CONDUCTOR_NODE_PATH` overlay directly above. The absent case
|
|
320
|
+
// must add, delete, normalize, and override nothing — `buildChildEnv` already
|
|
321
|
+
// inherits the parent's `BAPI_BASE_URL`, and the server keeps its own production
|
|
322
|
+
// default for callers that were never given a URL. Like `CONDUCTOR_NODE_PATH`,
|
|
323
|
+
// this is non-secret routing metadata, not a credential.
|
|
324
|
+
if (typeof deps.baseUrl === "string" && deps.baseUrl.length > 0) {
|
|
325
|
+
overlay.BAPI_BASE_URL = deps.baseUrl;
|
|
326
|
+
}
|
|
287
327
|
const env = buildChildEnv(deps.env, overlay);
|
|
288
328
|
return {
|
|
289
329
|
ok: true,
|
|
@@ -356,7 +396,7 @@ export async function runMcpInvokeCli(argv, overrides = {}) {
|
|
|
356
396
|
stderr(getMcpInvokeUsage());
|
|
357
397
|
return 1;
|
|
358
398
|
}
|
|
359
|
-
const { target, projectRoot } = parsed;
|
|
399
|
+
const { target, projectRoot, baseUrl } = parsed;
|
|
360
400
|
const statFn = overrides.stat ?? ((p) => stat(p));
|
|
361
401
|
const dirCheck = await validateProjectRootDirectory(projectRoot, statFn);
|
|
362
402
|
if (!dirCheck.ok) {
|
|
@@ -379,12 +419,15 @@ export async function runMcpInvokeCli(argv, overrides = {}) {
|
|
|
379
419
|
}));
|
|
380
420
|
const resolveCredentials = overrides.resolveCredentials ??
|
|
381
421
|
((rn) => resolveBapiCredentials(rn, credentialDeps));
|
|
422
|
+
// The parsed URL reaches ONLY this branch; the parser already rejected
|
|
423
|
+
// `--base-url` for every other target, so the Tier-2 path below cannot see it.
|
|
382
424
|
const resolved = await resolveBapiInvocation(projectRoot, {
|
|
383
425
|
env,
|
|
384
426
|
execPath: process.execPath,
|
|
385
427
|
scriptPath: process.argv[1],
|
|
386
428
|
resolveRepoName,
|
|
387
429
|
resolveCredentials,
|
|
430
|
+
baseUrl,
|
|
388
431
|
});
|
|
389
432
|
if (!resolved.ok) {
|
|
390
433
|
stderr(`Error: ${resolved.error}`);
|
|
@@ -78,13 +78,29 @@ export function serverNameForMcpTarget(target) {
|
|
|
78
78
|
* form preferred) and never depends on a worker-injected `CONDUCTOR_NODE_PATH`
|
|
79
79
|
* or any other worker-shell env — the executor worker environment carries no
|
|
80
80
|
* such key at all (see `executor/env.ts`).
|
|
81
|
+
*
|
|
82
|
+
* BAPI-727 extends the BAPI-527 note above rather than contradicting it. The
|
|
83
|
+
* optional `bridgeApiBaseUrl` is appended as the ARGUMENT `--base-url <url>`, and
|
|
84
|
+
* only for the `bapi` target. A Bridge API endpoint is non-secret ROUTING
|
|
85
|
+
* metadata — the same value that appears in an operator's `--base-url` flag and in
|
|
86
|
+
* generated executor service units — so persisting it does not weaken the
|
|
87
|
+
* no-`env`, no-credential registration invariant. The entry keeps exactly
|
|
88
|
+
* `command` and `args`; credentials continue to be resolved by the `mcp-invoke`
|
|
89
|
+
* shim at launch and are never written into the worktree.
|
|
81
90
|
*/
|
|
82
|
-
export function buildShimMcpServerEntry(target, absoluteWorktreePath, invocation) {
|
|
83
|
-
|
|
91
|
+
export function buildShimMcpServerEntry(target, absoluteWorktreePath, invocation, bridgeApiBaseUrl) {
|
|
92
|
+
const entry = buildMcpShimCommand(invocation, target, absoluteWorktreePath);
|
|
93
|
+
// Tier-2 targets are structurally unchanged: `--base-url` is a `bapi`-only flag
|
|
94
|
+
// and `mcp-invoke` rejects it for any other target, so appending it elsewhere
|
|
95
|
+
// would generate a registration that refuses to launch.
|
|
96
|
+
if (target !== "bapi" || bridgeApiBaseUrl === undefined)
|
|
97
|
+
return entry;
|
|
98
|
+
// Clone/extend the generated args; never add a property beyond command/args.
|
|
99
|
+
return { command: entry.command, args: [...entry.args, "--base-url", bridgeApiBaseUrl] };
|
|
84
100
|
}
|
|
85
101
|
/** Back-compat wrapper around `buildShimMcpServerEntry("bapi", ...)`. */
|
|
86
|
-
export function buildBridgeApiShimMcpServerEntry(absoluteWorktreePath, invocation) {
|
|
87
|
-
return buildShimMcpServerEntry("bapi", absoluteWorktreePath, invocation);
|
|
102
|
+
export function buildBridgeApiShimMcpServerEntry(absoluteWorktreePath, invocation, bridgeApiBaseUrl) {
|
|
103
|
+
return buildShimMcpServerEntry("bapi", absoluteWorktreePath, invocation, bridgeApiBaseUrl);
|
|
88
104
|
}
|
|
89
105
|
/**
|
|
90
106
|
* Convert all supported manifest `mcp` entries into a map of server name -> shim
|
|
@@ -93,12 +109,14 @@ export function buildBridgeApiShimMcpServerEntry(absoluteWorktreePath, invocatio
|
|
|
93
109
|
* or incomplete non-`bapi` targets are skipped with a secret-free warning (they
|
|
94
110
|
* never appear in the returned entries).
|
|
95
111
|
*/
|
|
96
|
-
export function buildMcpServerEntriesForManifest(manifest, absoluteWorktreePath, invocation) {
|
|
112
|
+
export function buildMcpServerEntriesForManifest(manifest, absoluteWorktreePath, invocation, bridgeApiBaseUrl) {
|
|
97
113
|
const entries = {};
|
|
98
114
|
const warnings = [];
|
|
99
115
|
for (const mcp of manifest.mcp) {
|
|
100
116
|
if (mcp.target === "bapi") {
|
|
101
|
-
|
|
117
|
+
// The optional base URL reaches ONLY this branch — Tier-2 entries below are
|
|
118
|
+
// built without it and stay byte-identical to their pre-BAPI-727 shape.
|
|
119
|
+
entries[serverNameForMcpTarget("bapi")] = buildShimMcpServerEntry("bapi", absoluteWorktreePath, invocation, bridgeApiBaseUrl);
|
|
102
120
|
continue;
|
|
103
121
|
}
|
|
104
122
|
const definition = getThirdPartyTargetDefinition(mcp.target);
|
|
@@ -162,6 +180,11 @@ export function mergeEnabledMcpjsonServers(existing, serverNames) {
|
|
|
162
180
|
* Unrelated top-level fields and unrelated MCP servers are preserved; only the
|
|
163
181
|
* generated server names are replaced. Any legacy secret-embedded entry for a
|
|
164
182
|
* generated server name is force-upgraded to the secret-free shim shape.
|
|
183
|
+
*
|
|
184
|
+
* Deliberately UNCHANGED by BAPI-727: because a generated name is replaced rather
|
|
185
|
+
* than merged field-by-field, re-provisioning an existing worktree automatically
|
|
186
|
+
* upgrades an older `bridge-api` entry that lacks `--base-url` to the URL-bearing
|
|
187
|
+
* one. No migration step is needed.
|
|
165
188
|
*/
|
|
166
189
|
export function mergeMcpRegistrations(existing, topLevelKey, entries) {
|
|
167
190
|
const result = existing && typeof existing === "object" && !Array.isArray(existing)
|
|
@@ -321,7 +344,7 @@ export async function provisionMcpRegistrationForWorktree(row, deps) {
|
|
|
321
344
|
if (!normalized.ok) {
|
|
322
345
|
return { ...row, status: "spawn-failed", error: `MCP provisioning failed: ${normalized.error}` };
|
|
323
346
|
}
|
|
324
|
-
const built = buildMcpServerEntriesForManifest(read.manifest, normalized.path, deps.mcpServerInvocation);
|
|
347
|
+
const built = buildMcpServerEntriesForManifest(read.manifest, normalized.path, deps.mcpServerInvocation, deps.bridgeApiBaseUrl);
|
|
325
348
|
if (Object.keys(built.entries).length === 0) {
|
|
326
349
|
// Nothing supported to write (e.g. a manifest with no bapi target and no
|
|
327
350
|
// supported Tier-2 targets). Surface any warnings but leave status unchanged.
|
|
@@ -8,17 +8,26 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import path from "path";
|
|
10
10
|
/**
|
|
11
|
-
* Read and parse a JSON file.
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
11
|
+
* Read and parse a JSON file.
|
|
12
|
+
*
|
|
13
|
+
* A read failure whose error carries an `ENOENT` code is `missing` — the file
|
|
14
|
+
* genuinely is not there. Any OTHER read failure (e.g. `EACCES`) is `unreadable`:
|
|
15
|
+
* BAPI-727's shadow inspector must not treat "I could not look" as "there is
|
|
16
|
+
* nothing there", because an unreadable `~/.claude.json` could still be shadowing
|
|
17
|
+
* a worker's MCP registration. Invalid JSON stays `malformed`; both non-present
|
|
18
|
+
* failure states carry ONLY the path — never the raw content and never the read
|
|
19
|
+
* exception's message, which can contain arbitrary text.
|
|
14
20
|
*/
|
|
15
21
|
export async function readJsonIfPresent(filePath, deps) {
|
|
16
22
|
let raw;
|
|
17
23
|
try {
|
|
18
24
|
raw = await deps.readFile(filePath);
|
|
19
25
|
}
|
|
20
|
-
catch {
|
|
21
|
-
|
|
26
|
+
catch (err) {
|
|
27
|
+
const code = err && typeof err === "object" ? err.code : undefined;
|
|
28
|
+
if (code === "ENOENT")
|
|
29
|
+
return { state: "missing" };
|
|
30
|
+
return { state: "unreadable", path: filePath };
|
|
22
31
|
}
|
|
23
32
|
try {
|
|
24
33
|
return { state: "present", value: JSON.parse(raw) };
|