@bridge_gpt/mcp-server 0.2.21 → 0.2.24

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 (41) hide show
  1. package/README.md +144 -18
  2. package/build/base-ref.js +151 -0
  3. package/build/commands.generated.js +6 -4
  4. package/build/conductor/bridge-api-client.js +44 -3
  5. package/build/conductor/doctor.js +33 -22
  6. package/build/conductor/epic-runtime.js +101 -5
  7. package/build/conductor/pr-ci-producer.js +21 -2
  8. package/build/conductor/pr-discovery.js +12 -2
  9. package/build/conductor-bin.js +50 -20
  10. package/build/credential-store.js +564 -64
  11. package/build/decision-page-template.js +9 -4
  12. package/build/docs.generated.js +5 -0
  13. package/build/executor/base-branch.js +50 -0
  14. package/build/executor/env.js +12 -1
  15. package/build/executor/job-errors.js +1 -0
  16. package/build/executor/job-runner.js +38 -7
  17. package/build/executor/test-clock.js +6 -1
  18. package/build/executor/worker-finalization.js +88 -1
  19. package/build/executor/worktree.js +21 -1
  20. package/build/index.js +2741 -702
  21. package/build/init.js +29 -0
  22. package/build/install-bridge.js +1076 -114
  23. package/build/pipelines.generated.js +2 -2
  24. package/build/pr-base-contract.js +36 -0
  25. package/build/readme.generated.js +1 -1
  26. package/build/setup-epic.js +483 -0
  27. package/build/sfcc/log-gate.js +85 -0
  28. package/build/sfcc/log-query.js +170 -0
  29. package/build/sfcc/register.js +10 -0
  30. package/build/sfcc/setup-status.js +33 -3
  31. package/build/start-tickets.js +164 -75
  32. package/build/version.generated.js +1 -1
  33. package/build/worktree-core.js +62 -10
  34. package/{CONDUCTOR.md → docs/CONDUCTOR.md} +88 -29
  35. package/docs/install/github-app.md +189 -0
  36. package/docs/install/mcp-tool-integrations.md +305 -0
  37. package/docs/install/sfcc-integration.md +140 -0
  38. package/package.json +5 -5
  39. package/public/js/main.min.js +55 -10
  40. package/public/js/main.min.js.map +1 -1
  41. package/smoke-test/SMOKE-TEST.md +3 -2
@@ -0,0 +1,483 @@
1
+ /**
2
+ * `setup-epic` — one-command bootstrap for an Epic Conductor v2 run.
3
+ *
4
+ * Creates the epic run, stores the plan DAG, and approves it. Before this
5
+ * existed the only path was hand-rolled HTTP with a hand-computed plan hash,
6
+ * which is why the shipped `store-and-approve-epic-plan` pipeline step told the
7
+ * agent to "make a direct API call" — there was no tool to call.
8
+ *
9
+ * Ordering is load-bearing:
10
+ *
11
+ * 0. GET the run state FIRST. `POST /epic-runs/runs` cannot be probed with a
12
+ * "create and tolerate 409" — it never returned 409, so a blind repeat
13
+ * create used to mint a SECOND active run, wedging the epic permanently
14
+ * (every later plan call 409s on "Multiple active runs") and double-charging
15
+ * billing. The server is idempotent now, but the pre-check is still what
16
+ * lets us report honestly and skip work.
17
+ * 1. Create only when no live run exists.
18
+ * 2. Hash locally — ADVISORY ONLY. The server re-hashes after applying
19
+ * file-overlap serialization, so a divergence is legitimate, not corruption.
20
+ * 3. Store, then approve.
21
+ *
22
+ * Read-only against the local filesystem: it reads the plan sidecar and writes
23
+ * nothing.
24
+ */
25
+ import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
26
+ import os from "node:os";
27
+ import { approveEpicPlan, createEpicRun, fetchEpicRunState, resolveConductorBridgeApiAccess, storeEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
28
+ import { hashPlan } from "./conductor/plan.js";
29
+ export function createDefaultSetupEpicDeps() {
30
+ return {
31
+ env: process.env,
32
+ cwd: process.cwd(),
33
+ platform: process.platform,
34
+ homedir: os.homedir,
35
+ readFile: (p) => fsReadFile(p, "utf-8"),
36
+ stat: (p) => fsStat(p),
37
+ fetch: globalThis.fetch,
38
+ log: (m) => console.log(m),
39
+ errorLog: (m) => console.error(m),
40
+ };
41
+ }
42
+ /** User-facing usage text. */
43
+ export function getSetupEpicUsage() {
44
+ return [
45
+ "Usage: mcp-server setup-epic --epic-key <KEY> --plan-file <path> [options]",
46
+ "",
47
+ "Bootstraps an Epic Conductor v2 run: creates the run, stores the plan DAG,",
48
+ "and approves it. Idempotent — re-running reuses an existing live run.",
49
+ "",
50
+ "Required:",
51
+ " --epic-key <KEY> Jira epic key (e.g. BAPI-405)",
52
+ " --plan-file <path> Path to epic-plan.dag.json (from decompose-epic)",
53
+ "",
54
+ "Options:",
55
+ " --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
56
+ " --plan-version <n> Assert the sidecar's plan_version equals <n>",
57
+ " --dry-run Validate and preview; make no mutating calls",
58
+ " --json Emit a single JSON result object on stdout",
59
+ " -h, --help Show this help",
60
+ "",
61
+ "After setup, the server-side reconciler picks the run up within ~30s.",
62
+ "To execute claimed jobs on this machine, run:",
63
+ " npx -y @bridge_gpt/mcp-server executor --repo <name>",
64
+ ].join("\n");
65
+ }
66
+ function takeValue(argv, i, flag) {
67
+ const next = argv[i + 1];
68
+ if (next === undefined || next.startsWith("-"))
69
+ return null;
70
+ return next;
71
+ }
72
+ export function parseSetupEpicArgs(argv) {
73
+ if (argv.includes("-h") || argv.includes("--help")) {
74
+ return { status: "help", usage: getSetupEpicUsage() };
75
+ }
76
+ let epicKey;
77
+ let planFile;
78
+ let repo;
79
+ let planVersion;
80
+ let dryRun = false;
81
+ let json = false;
82
+ for (let i = 0; i < argv.length; i++) {
83
+ const arg = argv[i];
84
+ switch (arg) {
85
+ case "--epic-key": {
86
+ const v = takeValue(argv, i, arg);
87
+ if (v === null)
88
+ return { status: "error", message: "--epic-key requires a value." };
89
+ epicKey = v;
90
+ i++;
91
+ break;
92
+ }
93
+ case "--plan-file": {
94
+ const v = takeValue(argv, i, arg);
95
+ if (v === null)
96
+ return { status: "error", message: "--plan-file requires a value." };
97
+ planFile = v;
98
+ i++;
99
+ break;
100
+ }
101
+ case "--repo": {
102
+ const v = takeValue(argv, i, arg);
103
+ if (v === null)
104
+ return { status: "error", message: "--repo requires a value." };
105
+ repo = v;
106
+ i++;
107
+ break;
108
+ }
109
+ case "--plan-version": {
110
+ const v = takeValue(argv, i, arg);
111
+ if (v === null)
112
+ return { status: "error", message: "--plan-version requires a value." };
113
+ if (!/^\d+$/.test(v)) {
114
+ return { status: "error", message: `--plan-version must be a positive integer, got '${v}'.` };
115
+ }
116
+ planVersion = Number(v);
117
+ if (planVersion < 1) {
118
+ return { status: "error", message: "--plan-version must be >= 1." };
119
+ }
120
+ i++;
121
+ break;
122
+ }
123
+ case "--dry-run":
124
+ dryRun = true;
125
+ break;
126
+ case "--json":
127
+ json = true;
128
+ break;
129
+ default:
130
+ return {
131
+ status: "error",
132
+ message: `Unknown argument '${arg}'. Run "setup-epic --help" for usage.`,
133
+ };
134
+ }
135
+ }
136
+ if (!epicKey)
137
+ return { status: "error", message: "setup-epic requires --epic-key <KEY>." };
138
+ if (!planFile)
139
+ return { status: "error", message: "setup-epic requires --plan-file <path>." };
140
+ return {
141
+ status: "ok",
142
+ options: { epicKey, planFile, repo, planVersion, dryRun, json },
143
+ };
144
+ }
145
+ /**
146
+ * Validate the plan DAG locally, mirroring the server's `validate_epic_plan_dag`.
147
+ *
148
+ * This is not belt-and-braces: without it, a malformed plan surfaces as a bare
149
+ * HTTP 400 with no indication of which node is at fault. Fail here, legibly.
150
+ */
151
+ export function validateEpicPlanSidecar(parsed) {
152
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
153
+ return { ok: false, error: "Plan sidecar must be a JSON object." };
154
+ }
155
+ const plan = parsed;
156
+ const version = plan.plan_version;
157
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
158
+ return {
159
+ ok: false,
160
+ error: `plan_version must be an integer >= 1, got ${JSON.stringify(version)}.`,
161
+ };
162
+ }
163
+ if (!Array.isArray(plan.nodes) || plan.nodes.length === 0) {
164
+ return { ok: false, error: "plan.nodes must be a non-empty array." };
165
+ }
166
+ if (!Array.isArray(plan.edges)) {
167
+ return { ok: false, error: "plan.edges must be an array (use [] for none)." };
168
+ }
169
+ const keys = new Set();
170
+ const warnings = [];
171
+ for (const node of plan.nodes) {
172
+ if (!node || typeof node !== "object") {
173
+ return { ok: false, error: "Every plan node must be an object." };
174
+ }
175
+ const key = typeof node.ticket_key === "string" ? node.ticket_key.trim() : "";
176
+ if (!key)
177
+ return { ok: false, error: "Every plan node needs a non-empty ticket_key." };
178
+ if (keys.has(key))
179
+ return { ok: false, error: `Duplicate ticket_key in plan: ${key}.` };
180
+ keys.add(key);
181
+ if (node.touched_files === undefined) {
182
+ // Only fatal when the repo has file-overlap serialization enabled, in
183
+ // which case the server 400s. Warn either way — it is the difference
184
+ // between siblings serializing and colliding.
185
+ warnings.push(`Node ${key} has no touched_files. File-overlap serialization cannot ` +
186
+ `protect it; if the repo has that flag on, the server will reject this plan.`);
187
+ }
188
+ }
189
+ // Build one adjacency in a consistent predecessor -> successor direction, the
190
+ // same way the server does. `depends_on` points at the predecessor; an edge's
191
+ // `from` IS the predecessor.
192
+ const adjacency = new Map();
193
+ const addEdge = (from, to) => {
194
+ const list = adjacency.get(from) ?? [];
195
+ list.push(to);
196
+ adjacency.set(from, list);
197
+ };
198
+ for (const node of plan.nodes) {
199
+ const deps = Array.isArray(node.depends_on) ? node.depends_on : [];
200
+ for (const dep of deps) {
201
+ if (!keys.has(dep)) {
202
+ return {
203
+ ok: false,
204
+ error: `Node ${node.ticket_key} depends_on unknown ticket '${dep}'.`,
205
+ };
206
+ }
207
+ addEdge(dep, node.ticket_key);
208
+ }
209
+ }
210
+ for (const edge of plan.edges) {
211
+ if (!edge || typeof edge !== "object") {
212
+ return { ok: false, error: "Every plan edge must be an object." };
213
+ }
214
+ if (!keys.has(edge.from) || !keys.has(edge.to)) {
215
+ return {
216
+ ok: false,
217
+ error: `Edge ${JSON.stringify(edge.from)} -> ${JSON.stringify(edge.to)} references an unknown ticket.`,
218
+ };
219
+ }
220
+ addEdge(edge.from, edge.to);
221
+ }
222
+ const cycle = findCycle(keys, adjacency);
223
+ if (cycle) {
224
+ return { ok: false, error: `Plan DAG has a cycle: ${cycle.join(" -> ")}.` };
225
+ }
226
+ return { ok: true, plan: parsed, warnings };
227
+ }
228
+ /** Iterative DFS cycle detection; returns the offending path or null. */
229
+ function findCycle(keys, adjacency) {
230
+ const WHITE = 0;
231
+ const GREY = 1;
232
+ const BLACK = 2;
233
+ const color = new Map();
234
+ for (const k of keys)
235
+ color.set(k, WHITE);
236
+ for (const start of keys) {
237
+ if (color.get(start) !== WHITE)
238
+ continue;
239
+ const stack = [{ node: start, path: [start] }];
240
+ while (stack.length > 0) {
241
+ const { node, path } = stack[stack.length - 1];
242
+ if (color.get(node) === WHITE) {
243
+ color.set(node, GREY);
244
+ for (const next of adjacency.get(node) ?? []) {
245
+ if (color.get(next) === GREY)
246
+ return [...path, next];
247
+ if (color.get(next) === WHITE) {
248
+ stack.push({ node: next, path: [...path, next] });
249
+ }
250
+ }
251
+ }
252
+ else {
253
+ if (color.get(node) === GREY)
254
+ color.set(node, BLACK);
255
+ stack.pop();
256
+ }
257
+ }
258
+ }
259
+ return null;
260
+ }
261
+ function errorDetail(err) {
262
+ if (err instanceof ConductorBridgeApiError) {
263
+ const status = err.status !== undefined ? ` (HTTP ${err.status})` : "";
264
+ const preview = err.bodyPreview ? `: ${err.bodyPreview}` : "";
265
+ return `${err.message}${status}${preview}`;
266
+ }
267
+ return err instanceof Error ? err.message : String(err);
268
+ }
269
+ export async function runSetupEpicCli(argv, overrides = {}) {
270
+ const deps = { ...createDefaultSetupEpicDeps(), ...overrides };
271
+ const parsed = parseSetupEpicArgs(argv);
272
+ if (parsed.status === "help") {
273
+ deps.log(parsed.usage);
274
+ return 0;
275
+ }
276
+ if (parsed.status === "error") {
277
+ deps.errorLog(parsed.message);
278
+ deps.errorLog("");
279
+ deps.errorLog(getSetupEpicUsage());
280
+ return 1;
281
+ }
282
+ const opts = parsed.options;
283
+ // With --json, stdout carries the single result object and nothing else.
284
+ const say = opts.json ? deps.errorLog : deps.log;
285
+ // --- Read + validate the plan sidecar (no network yet) -------------------
286
+ let raw;
287
+ try {
288
+ raw = await deps.readFile(opts.planFile);
289
+ }
290
+ catch (err) {
291
+ deps.errorLog(`Could not read plan file '${opts.planFile}': ${errorDetail(err)}`);
292
+ return 1;
293
+ }
294
+ let parsedJson;
295
+ try {
296
+ parsedJson = JSON.parse(raw);
297
+ }
298
+ catch (err) {
299
+ deps.errorLog(`Plan file '${opts.planFile}' is not valid JSON: ${errorDetail(err)}`);
300
+ return 1;
301
+ }
302
+ const validation = validateEpicPlanSidecar(parsedJson);
303
+ if (!validation.ok) {
304
+ deps.errorLog(`Invalid plan DAG: ${validation.error}`);
305
+ return 1;
306
+ }
307
+ const plan = validation.plan;
308
+ const warnings = [...validation.warnings];
309
+ if (opts.planVersion !== undefined && opts.planVersion !== plan.plan_version) {
310
+ deps.errorLog(`--plan-version ${opts.planVersion} does not match the sidecar's plan_version ` +
311
+ `${plan.plan_version}. Fix the sidecar (or drop the flag) — setup-epic never ` +
312
+ `rewrites the blob, because that would change its hash.`);
313
+ return 1;
314
+ }
315
+ const localHash = hashPlan(plan);
316
+ // --- Resolve access ------------------------------------------------------
317
+ const accessResult = await resolveConductorBridgeApiAccess({
318
+ env: deps.env,
319
+ cwd: deps.cwd,
320
+ homedir: deps.homedir,
321
+ platform: deps.platform,
322
+ readFile: deps.readFile,
323
+ stat: deps.stat,
324
+ repoName: opts.repo,
325
+ });
326
+ if (!accessResult.ok) {
327
+ deps.errorLog(`Cannot reach the Bridge API: ${accessResult.error}`);
328
+ return 1;
329
+ }
330
+ const access = accessResult.access;
331
+ say(`Epic: ${opts.epicKey}`);
332
+ say(`Repo: ${access.repoName}`);
333
+ say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${plan.edges.length} edge(s)`);
334
+ say(`Local hash: ${localHash}`);
335
+ for (const w of warnings)
336
+ say(` [warn] ${w}`);
337
+ // --- Step 0: pre-check ---------------------------------------------------
338
+ // Never create on an ambiguous read. A wrong answer here mints a duplicate run.
339
+ let existingRunId = null;
340
+ let existingStatus = null;
341
+ try {
342
+ const state = await fetchEpicRunState(access, opts.epicKey, deps.fetch);
343
+ existingRunId = state.epic_run?.epic_run_id ?? null;
344
+ existingStatus = state.epic_run?.status ?? null;
345
+ }
346
+ catch (err) {
347
+ if (err instanceof ConductorBridgeApiError && err.status === 404) {
348
+ existingRunId = null; // No live run — the normal first-run path.
349
+ }
350
+ else if (err instanceof ConductorBridgeApiError && err.status === 409) {
351
+ deps.errorLog(`Epic ${opts.epicKey} has MULTIPLE active runs — it is wedged, and every ` +
352
+ `plan call will keep failing. Abandon the duplicate before retrying:\n` +
353
+ ` PATCH /jira/epic-runs/runs/<epic_run_id> {"status": "abandoned"}\n` +
354
+ `Detail: ${errorDetail(err)}`);
355
+ return 1;
356
+ }
357
+ else {
358
+ deps.errorLog(`Could not read the epic run state, so creating one would risk a duplicate ` +
359
+ `(which wedges the epic permanently). Refusing to continue.\n` +
360
+ `Detail: ${errorDetail(err)}`);
361
+ return 1;
362
+ }
363
+ }
364
+ if (opts.dryRun) {
365
+ say("");
366
+ say("[dry-run] No changes made. Would:");
367
+ if (existingRunId) {
368
+ say(` - reuse existing run ${existingRunId} (status: ${existingStatus})`);
369
+ }
370
+ else {
371
+ say(` - POST /jira/epic-runs/runs (create run for ${opts.epicKey})`);
372
+ }
373
+ say(` - POST /jira/epic-runs/runs/${opts.epicKey}/plan (v${plan.plan_version})`);
374
+ say(` - POST /jira/epic-runs/runs/${opts.epicKey}/approve-plan (v${plan.plan_version})`);
375
+ if (opts.json) {
376
+ deps.log(JSON.stringify({
377
+ dry_run: true,
378
+ epic_key: opts.epicKey,
379
+ repo_name: access.repoName,
380
+ plan_version: plan.plan_version,
381
+ local_plan_hash: localHash,
382
+ existing_run_id: existingRunId,
383
+ warnings,
384
+ }, null, 2));
385
+ }
386
+ return 0;
387
+ }
388
+ const result = {
389
+ epic_run_id: existingRunId ?? "",
390
+ epic_key: opts.epicKey,
391
+ repo_name: access.repoName,
392
+ plan_version: plan.plan_version,
393
+ plan_hash: null,
394
+ local_plan_hash: localHash,
395
+ status: existingStatus,
396
+ run_created: false,
397
+ plan_stored: false,
398
+ plan_approved: false,
399
+ warnings,
400
+ };
401
+ // --- Step 1: create (only when there is no live run) ---------------------
402
+ if (existingRunId) {
403
+ say(`Run: reusing ${existingRunId} (status: ${existingStatus})`);
404
+ }
405
+ else {
406
+ try {
407
+ const run = await createEpicRun(access, { epicKey: opts.epicKey }, deps.fetch);
408
+ result.epic_run_id = run.epic_run_id;
409
+ result.status = run.status;
410
+ result.run_created = true;
411
+ say(`Run: created ${run.epic_run_id}`);
412
+ }
413
+ catch (err) {
414
+ deps.errorLog(`Failed to create the epic run: ${errorDetail(err)}`);
415
+ return 1;
416
+ }
417
+ }
418
+ // --- Step 2/3: store the plan -------------------------------------------
419
+ try {
420
+ const stored = await storeEpicPlan(access, {
421
+ epicKey: opts.epicKey,
422
+ planVersion: plan.plan_version,
423
+ planBlob: plan,
424
+ planHash: localHash,
425
+ }, deps.fetch);
426
+ result.plan_stored = true;
427
+ const serverHash = stored?.plan_hash;
428
+ if (typeof serverHash === "string")
429
+ result.plan_hash = serverHash;
430
+ say(`Plan: stored v${plan.plan_version}`);
431
+ }
432
+ catch (err) {
433
+ if (err instanceof ConductorBridgeApiError && err.status === 409) {
434
+ deps.errorLog(`Plan v${plan.plan_version} is already stored with a DIFFERENT hash. The stored ` +
435
+ `blob is immutable — bump plan_version in the sidecar and re-run.\n` +
436
+ `Detail: ${errorDetail(err)}`);
437
+ return 1;
438
+ }
439
+ deps.errorLog(`Failed to store the plan: ${errorDetail(err)}`);
440
+ return 1;
441
+ }
442
+ // --- Step 4: approve -----------------------------------------------------
443
+ const approval = await approveEpicPlan(access, { epicKey: opts.epicKey, planVersion: plan.plan_version }, deps.fetch).catch((err) => {
444
+ deps.errorLog(`Failed to approve the plan: ${errorDetail(err)}`);
445
+ return null;
446
+ });
447
+ if (approval === null)
448
+ return 1;
449
+ if (approval.ok) {
450
+ result.plan_approved = true;
451
+ result.plan_hash = approval.plan_hash;
452
+ result.status = "active";
453
+ say(`Plan: approved v${plan.plan_version}`);
454
+ }
455
+ else if (approval.reason === "multiple_active_runs") {
456
+ deps.errorLog(`Epic ${opts.epicKey} has MULTIPLE active runs — the plan could not be approved ` +
457
+ `and the epic is wedged. Abandon the duplicate run, then re-run setup-epic.`);
458
+ return 1;
459
+ }
460
+ else {
461
+ // A strictly later version is already approved. Benign: the caller is behind.
462
+ const msg = `A later plan version is already approved — approval skipped.`;
463
+ result.warnings.push(msg);
464
+ say(`Plan: [warn] ${msg}`);
465
+ }
466
+ if (result.plan_hash && result.plan_hash !== localHash) {
467
+ // Expected when the repo serializes file-overlapping siblings: the server
468
+ // rewrites edges and re-hashes. Report, do not fail.
469
+ result.warnings.push(`Server plan hash differs from the local hash (the server re-hashes after ` +
470
+ `applying file-overlap serialization). The server hash is authoritative.`);
471
+ }
472
+ if (opts.json) {
473
+ deps.log(JSON.stringify(result, null, 2));
474
+ }
475
+ else {
476
+ say("");
477
+ say(`Epic run ${result.epic_run_id} is ${result.status ?? "unknown"}.`);
478
+ say("The server-side reconciler will pick it up within ~30s.");
479
+ say("To execute claimed jobs on this machine, run:");
480
+ say(` npx -y @bridge_gpt/mcp-server executor --repo ${access.repoName}`);
481
+ }
482
+ return 0;
483
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Call-time SFCC *log-capability* gate (T7 / BAPI-556).
3
+ *
4
+ * Wraps the `sfcc_log_query` handler. Evaluated at call time (NOT startup) so the
5
+ * tool is always registered under the `sfcc` profile — the gate decides at
6
+ * invocation whether the repo's LOG capability (WebDAV Basic auth) is configured.
7
+ *
8
+ * This gate is deliberately distinct from `withSfccGate` (tool-wrapper.ts): it
9
+ * does NOT read the `version` config field, does NOT resolve `dw.json`, and does
10
+ * NOT acquire an Account Manager OAuth token. Log access uses HTTP Basic auth
11
+ * (BM username + 40-char WebDAV access key) that lives entirely server-side; the
12
+ * spike proved an AM bearer token 401s on `/Logs`. Readiness is resolved by the
13
+ * backend `GET /jira/sfcc/logs/capability` probe, which never returns a secret.
14
+ */
15
+ // ---------------------------------------------------------------------------
16
+ // Envelope helpers
17
+ // ---------------------------------------------------------------------------
18
+ function jsonResult(payload) {
19
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
20
+ }
21
+ function notConfigured(failureClass, message, limits) {
22
+ return jsonResult({
23
+ error: "NOT_CONFIGURED",
24
+ status: 503,
25
+ failure_class: failureClass,
26
+ message,
27
+ ...(limits !== undefined ? { limits } : {}),
28
+ });
29
+ }
30
+ // ---------------------------------------------------------------------------
31
+ // Gate
32
+ // ---------------------------------------------------------------------------
33
+ /**
34
+ * Wrap the log-query handler with the call-time log-capability gate.
35
+ *
36
+ * The returned handler:
37
+ * 1. GETs `/jira/sfcc/logs/capability` via Bridge API.
38
+ * 2. On a Bridge transport/auth failure, returns a distinct (non-NOT_CONFIGURED)
39
+ * secret-free envelope so a temporary outage is not misread as "not set up".
40
+ * 3. On `configured: false`, returns the structured NOT_CONFIGURED 503 envelope
41
+ * pointing the caller at `sfcc_setup_status`.
42
+ * 4. Calls the inner handler only when the capability is configured.
43
+ *
44
+ * Every failure is caught inside the wrapper and returned as normal MCP content —
45
+ * an exception never escapes to the stdio transport.
46
+ */
47
+ export function withSfccLogGate(deps, handler) {
48
+ return async (args) => {
49
+ let body;
50
+ try {
51
+ const url = deps.buildGetUrl("/sfcc/logs/capability", {
52
+ repo_name: deps.repoName,
53
+ });
54
+ const resp = await fetch(url, { headers: await deps.getGetHeaders() });
55
+ if (!resp.ok) {
56
+ // Distinguish a Bridge auth/connectivity failure from "not configured".
57
+ return jsonResult({
58
+ error: resp.status === 401 || resp.status === 403 ? "UNAUTHORIZED" : "SERVICE_UNAVAILABLE",
59
+ status: resp.status,
60
+ message: "Could not read the SFCC log capability from Bridge API " +
61
+ "(/jira/sfcc/logs/capability). Ensure your Bridge API key is set and the " +
62
+ "repo is authorized. Run sfcc_setup_status for a full diagnostic.",
63
+ });
64
+ }
65
+ body = (await resp.json());
66
+ }
67
+ catch {
68
+ return jsonResult({
69
+ error: "BAD_GATEWAY",
70
+ status: 502,
71
+ message: "Could not reach Bridge API to resolve the SFCC log capability. " +
72
+ "Check that BAPI_BASE_URL points to a running Bridge API instance.",
73
+ });
74
+ }
75
+ if (body?.configured !== true) {
76
+ const failureClass = typeof body?.failure_class === "string" ? body.failure_class : "not_configured";
77
+ const message = typeof body?.message === "string"
78
+ ? body.message
79
+ : "SFCC on-demand log queries are not configured for this repository. " +
80
+ "Run sfcc_setup_status for a diagnostic.";
81
+ return notConfigured(failureClass, message, body?.limits);
82
+ }
83
+ return handler(args);
84
+ };
85
+ }