@bridge_gpt/mcp-server 0.1.12 → 0.1.14

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.
@@ -0,0 +1,593 @@
1
+ /**
2
+ * Pipeline orchestrator — sequential server-side execution for the new
3
+ * ``run_pipeline`` / ``resume_pipeline`` / ``list_pipeline_runs`` MCP tools.
4
+ *
5
+ * Design rules (BAPI-275):
6
+ * - The orchestrator never imports ``mcp_server/src/index.ts``. All runtime
7
+ * dependencies (Bridge API config, pipelines/instructions registries, the
8
+ * tool-handler map) are injected through ``PipelineOrchestratorDeps``.
9
+ * - Pipeline runs are persisted server-side via the Bridge API routes at
10
+ * ``/jira/pipelines/runs``. Every state transition extends the idle TTL.
11
+ * - ``mcp_call`` failure detection uses a strict envelope match: the parsed
12
+ * ``content[0].text`` value must be an object with a string ``error`` key
13
+ * AND a numeric ``status`` key (matches ``handleResponse`` in index.ts).
14
+ * - Approval-gated ``mcp_call`` steps synthesise a ``needs_agent_task``
15
+ * pause unless ``auto_approve`` is true; the resolved tool name + params
16
+ * are inlined into the instruction string so the agent can review and
17
+ * confirm before resuming. (BAPI-275 decisions E-4 / E-36.)
18
+ */
19
+ import { resolveRecipe } from "./pipeline-utils.js";
20
+ class PipelinePersistenceError extends Error {
21
+ code;
22
+ constructor(code, message) {
23
+ super(message);
24
+ this.code = code;
25
+ }
26
+ }
27
+ export function createPipelinePersistenceClient(deps) {
28
+ const headers = {
29
+ "X-API-Key": deps.apiKey,
30
+ "Content-Type": "application/json",
31
+ };
32
+ const base = deps.baseUrl.replace(/\/+$/, "");
33
+ function urlFor(suffix, query) {
34
+ const u = new URL(`${base}/jira/pipelines${suffix}`);
35
+ if (query) {
36
+ for (const [k, v] of Object.entries(query)) {
37
+ u.searchParams.set(k, v);
38
+ }
39
+ }
40
+ return u.toString();
41
+ }
42
+ async function readError(resp) {
43
+ const raw = await resp.text();
44
+ let codeFromBody = null;
45
+ let messageFromBody = null;
46
+ try {
47
+ const parsed = JSON.parse(raw);
48
+ const detail = parsed?.detail;
49
+ if (detail && typeof detail === "object") {
50
+ if (typeof detail.error_code === "string") {
51
+ codeFromBody = detail.error_code;
52
+ }
53
+ if (typeof detail.error === "string") {
54
+ messageFromBody = detail.error;
55
+ }
56
+ }
57
+ }
58
+ catch {
59
+ // not JSON
60
+ }
61
+ const codeFromStatus = resp.status === 404
62
+ ? "NOT_FOUND"
63
+ : resp.status === 410
64
+ ? "EXPIRED"
65
+ : resp.status === 403
66
+ ? "REPO_MISMATCH"
67
+ : resp.status === 400 || resp.status === 422
68
+ ? "VALIDATION"
69
+ : "TOOL_ERROR";
70
+ return {
71
+ code: codeFromBody ?? codeFromStatus,
72
+ message: messageFromBody ?? (raw || `HTTP ${resp.status}`),
73
+ };
74
+ }
75
+ async function createRun(input) {
76
+ const resp = await fetch(urlFor("/runs"), {
77
+ method: "POST",
78
+ headers,
79
+ body: JSON.stringify({
80
+ repo_name: deps.repoName,
81
+ pipeline_name: input.pipeline_name,
82
+ resolved_recipe: input.resolved_recipe,
83
+ current_step_index: input.current_step_index,
84
+ results: input.results,
85
+ status: input.status,
86
+ ttl_seconds: input.ttl_seconds,
87
+ }),
88
+ });
89
+ if (!resp.ok) {
90
+ const err = await readError(resp);
91
+ throw new PipelinePersistenceError(err.code, err.message);
92
+ }
93
+ return (await resp.json());
94
+ }
95
+ async function getRun(pipelineRunId) {
96
+ const resp = await fetch(urlFor(`/runs/${encodeURIComponent(pipelineRunId)}`, {
97
+ repo_name: deps.repoName,
98
+ }), { headers: { "X-API-Key": deps.apiKey } });
99
+ if (!resp.ok) {
100
+ const err = await readError(resp);
101
+ throw new PipelinePersistenceError(err.code, err.message);
102
+ }
103
+ return (await resp.json());
104
+ }
105
+ async function patchRun(pipelineRunId, body) {
106
+ const resp = await fetch(urlFor(`/runs/${encodeURIComponent(pipelineRunId)}`), {
107
+ method: "PATCH",
108
+ headers,
109
+ body: JSON.stringify({ repo_name: deps.repoName, ...body }),
110
+ });
111
+ if (!resp.ok) {
112
+ const err = await readError(resp);
113
+ throw new PipelinePersistenceError(err.code, err.message);
114
+ }
115
+ return (await resp.json());
116
+ }
117
+ async function listRuns(status) {
118
+ const query = { repo_name: deps.repoName };
119
+ if (status)
120
+ query.status = status;
121
+ const resp = await fetch(urlFor("/runs", query), {
122
+ headers: { "X-API-Key": deps.apiKey },
123
+ });
124
+ if (!resp.ok) {
125
+ const err = await readError(resp);
126
+ throw new PipelinePersistenceError(err.code, err.message);
127
+ }
128
+ const body = (await resp.json());
129
+ return body.runs ?? [];
130
+ }
131
+ async function deleteRun(pipelineRunId) {
132
+ const resp = await fetch(urlFor(`/runs/${encodeURIComponent(pipelineRunId)}`, {
133
+ repo_name: deps.repoName,
134
+ }), {
135
+ method: "DELETE",
136
+ headers: { "X-API-Key": deps.apiKey },
137
+ });
138
+ if (!resp.ok) {
139
+ const err = await readError(resp);
140
+ throw new PipelinePersistenceError(err.code, err.message);
141
+ }
142
+ }
143
+ return { createRun, getRun, patchRun, listRuns, deleteRun };
144
+ }
145
+ // ---------------------------------------------------------------------------
146
+ // Helpers
147
+ // ---------------------------------------------------------------------------
148
+ function normalizeAutoApprove(value) {
149
+ if (value === true || value === "true")
150
+ return true;
151
+ return false;
152
+ }
153
+ function failedEnvelope(code, message, extras = {}) {
154
+ return {
155
+ status: "failed",
156
+ error_code: code,
157
+ error: message,
158
+ ...extras,
159
+ };
160
+ }
161
+ function isToolFailureEnvelope(text) {
162
+ try {
163
+ const parsed = JSON.parse(text);
164
+ return (parsed !== null &&
165
+ typeof parsed === "object" &&
166
+ typeof parsed.error === "string" &&
167
+ typeof parsed.status === "number");
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ }
173
+ const ORCHESTRATION_TOOLS = new Set([
174
+ "run_pipeline",
175
+ "resume_pipeline",
176
+ "list_pipeline_runs",
177
+ "delete_pipeline_run",
178
+ ]);
179
+ // ---------------------------------------------------------------------------
180
+ // runPipeline
181
+ // ---------------------------------------------------------------------------
182
+ export async function runPipeline(deps, input) {
183
+ const pipelineDef = deps.pipelines[input.pipeline];
184
+ if (!pipelineDef) {
185
+ const available = Object.keys(deps.pipelines).join(", ");
186
+ return failedEnvelope("VALIDATION", `Pipeline "${input.pipeline}" not found. Available pipelines: ${available || "(none)"}`);
187
+ }
188
+ if (input.variables && "auto_approve" in input.variables) {
189
+ return failedEnvelope("VALIDATION", "Pass auto_approve via the top-level parameter, not via the variables map.");
190
+ }
191
+ const autoApprove = normalizeAutoApprove(input.auto_approve);
192
+ const mergedVariables = {
193
+ docs_dir: deps.docsDir,
194
+ provider: "",
195
+ second_opinion: "",
196
+ auto_approve: autoApprove ? "true" : "",
197
+ ...(input.variables ?? {}),
198
+ };
199
+ let recipe;
200
+ try {
201
+ recipe = resolveRecipe(pipelineDef, deps.instructions, mergedVariables, undefined, autoApprove);
202
+ }
203
+ catch (err) {
204
+ return failedEnvelope("VALIDATION", err instanceof Error ? err.message : String(err), { pipeline: input.pipeline });
205
+ }
206
+ const persistence = createPipelinePersistenceClient({
207
+ baseUrl: deps.baseUrl,
208
+ apiKey: deps.apiKey,
209
+ repoName: deps.repoName,
210
+ });
211
+ let row;
212
+ try {
213
+ row = await persistence.createRun({
214
+ pipeline_name: input.pipeline,
215
+ resolved_recipe: recipe,
216
+ current_step_index: 0,
217
+ results: [],
218
+ status: "running",
219
+ ttl_seconds: input.ttl_seconds,
220
+ });
221
+ }
222
+ catch (err) {
223
+ if (err instanceof PipelinePersistenceError) {
224
+ return failedEnvelope(err.code, err.message, { pipeline: input.pipeline });
225
+ }
226
+ return failedEnvelope("TOOL_ERROR", err instanceof Error ? err.message : String(err), { pipeline: input.pipeline });
227
+ }
228
+ return continuePipelineExecution(deps, persistence, row, recipe, autoApprove);
229
+ }
230
+ // ---------------------------------------------------------------------------
231
+ // resumePipeline
232
+ // ---------------------------------------------------------------------------
233
+ export async function resumePipeline(deps, input) {
234
+ const persistence = createPipelinePersistenceClient({
235
+ baseUrl: deps.baseUrl,
236
+ apiKey: deps.apiKey,
237
+ repoName: deps.repoName,
238
+ });
239
+ let row;
240
+ try {
241
+ row = await persistence.getRun(input.pipeline_run_id);
242
+ }
243
+ catch (err) {
244
+ if (err instanceof PipelinePersistenceError) {
245
+ return failedEnvelope(err.code, err.message, {
246
+ pipeline_run_id: input.pipeline_run_id,
247
+ });
248
+ }
249
+ return failedEnvelope("TOOL_ERROR", err instanceof Error ? err.message : String(err), { pipeline_run_id: input.pipeline_run_id });
250
+ }
251
+ if (row.status !== "paused") {
252
+ return failedEnvelope("VALIDATION", `Cannot resume pipeline run in status "${row.status}". Only paused runs can be resumed.`, {
253
+ pipeline_run_id: row.pipeline_run_id,
254
+ pipeline: row.pipeline_name,
255
+ results: row.results,
256
+ });
257
+ }
258
+ const recipe = row.resolved_recipe;
259
+ const stepIndex = row.current_step_index;
260
+ const step = recipe.steps[stepIndex];
261
+ if (!step) {
262
+ return failedEnvelope("VALIDATION", `Paused step index ${stepIndex} is out of bounds for resolved recipe (total_steps=${recipe.total_steps}).`, {
263
+ pipeline_run_id: row.pipeline_run_id,
264
+ pipeline: row.pipeline_name,
265
+ results: row.results,
266
+ });
267
+ }
268
+ const accumulatedResults = [...row.results];
269
+ let nextIndex = stepIndex;
270
+ let approvalAgentResult;
271
+ if (step.type === "agent_task") {
272
+ accumulatedResults.push({
273
+ step: step.step,
274
+ type: "agent_task",
275
+ ok: true,
276
+ description: step.description,
277
+ result: input.agent_result,
278
+ });
279
+ nextIndex = stepIndex + 1;
280
+ }
281
+ else {
282
+ // Approval-gated mcp_call: don't push a synthetic acknowledgement here —
283
+ // ``continuePipelineExecution`` will push exactly one result entry for
284
+ // this step after executing the tool. Carry the confirmation forward so
285
+ // the eventual entry can record it via ``approval_agent_result``.
286
+ approvalAgentResult = input.agent_result;
287
+ nextIndex = stepIndex;
288
+ }
289
+ let updated;
290
+ try {
291
+ updated = await persistence.patchRun(row.pipeline_run_id, {
292
+ current_step_index: nextIndex,
293
+ results: accumulatedResults,
294
+ status: "running",
295
+ expected_status: "paused",
296
+ expected_current_step_index: stepIndex,
297
+ });
298
+ }
299
+ catch (err) {
300
+ if (err instanceof PipelinePersistenceError) {
301
+ return failedEnvelope(err.code, err.message, {
302
+ pipeline_run_id: row.pipeline_run_id,
303
+ pipeline: row.pipeline_name,
304
+ results: accumulatedResults,
305
+ });
306
+ }
307
+ return failedEnvelope("TOOL_ERROR", err instanceof Error ? err.message : String(err), {
308
+ pipeline_run_id: row.pipeline_run_id,
309
+ pipeline: row.pipeline_name,
310
+ results: accumulatedResults,
311
+ });
312
+ }
313
+ const autoApprove = recipe.auto_approve;
314
+ return continuePipelineExecution(deps, persistence, updated, recipe, autoApprove, step.type === "mcp_call" ? stepIndex : undefined, approvalAgentResult);
315
+ }
316
+ // ---------------------------------------------------------------------------
317
+ // listPipelineRuns
318
+ // ---------------------------------------------------------------------------
319
+ export async function listPipelineRuns(deps, input = {}) {
320
+ const persistence = createPipelinePersistenceClient({
321
+ baseUrl: deps.baseUrl,
322
+ apiKey: deps.apiKey,
323
+ repoName: deps.repoName,
324
+ });
325
+ let metadata;
326
+ try {
327
+ metadata = await persistence.listRuns(input.status);
328
+ }
329
+ catch (err) {
330
+ if (err instanceof PipelinePersistenceError) {
331
+ return failedEnvelope(err.code, err.message);
332
+ }
333
+ return failedEnvelope("TOOL_ERROR", err instanceof Error ? err.message : String(err));
334
+ }
335
+ return {
336
+ status: "completed",
337
+ runs: metadata.map((row) => ({
338
+ pipeline_run_id: row.pipeline_run_id,
339
+ pipeline: row.pipeline_name,
340
+ step_index: row.total_steps !== null && row.status === "completed"
341
+ ? row.total_steps
342
+ : row.current_step_index + 1,
343
+ total_steps: row.total_steps,
344
+ status: row.status,
345
+ created_at: row.created_at,
346
+ updated_at: row.updated_at,
347
+ expires_at: row.expires_at,
348
+ })),
349
+ };
350
+ }
351
+ // ---------------------------------------------------------------------------
352
+ // deletePipelineRun
353
+ // ---------------------------------------------------------------------------
354
+ export async function deletePipelineRun(deps, input) {
355
+ if (!input ||
356
+ typeof input.pipeline_run_id !== "string" ||
357
+ input.pipeline_run_id.length === 0) {
358
+ return failedEnvelope("VALIDATION", "pipeline_run_id is required.");
359
+ }
360
+ const persistence = createPipelinePersistenceClient({
361
+ baseUrl: deps.baseUrl,
362
+ apiKey: deps.apiKey,
363
+ repoName: deps.repoName,
364
+ });
365
+ try {
366
+ await persistence.deleteRun(input.pipeline_run_id);
367
+ }
368
+ catch (err) {
369
+ if (err instanceof PipelinePersistenceError) {
370
+ return failedEnvelope(err.code, err.message);
371
+ }
372
+ return failedEnvelope("TOOL_ERROR", err instanceof Error ? err.message : String(err));
373
+ }
374
+ return {
375
+ status: "completed",
376
+ pipeline_run_id: input.pipeline_run_id,
377
+ deleted: true,
378
+ };
379
+ }
380
+ // ---------------------------------------------------------------------------
381
+ // continuePipelineExecution — shared by run + resume
382
+ // ---------------------------------------------------------------------------
383
+ async function continuePipelineExecution(deps, persistence, row, recipe, autoApprove, forceExecuteAtIndex, approvalAgentResult) {
384
+ let pipelineRunId = row.pipeline_run_id;
385
+ let stepIndex = row.current_step_index;
386
+ let results = [...row.results];
387
+ const totalSteps = recipe.total_steps;
388
+ while (stepIndex < totalSteps) {
389
+ const step = recipe.steps[stepIndex];
390
+ // Approval gate for mcp_call (E-4 / E-36): when the step declared
391
+ // requires_approval and auto_approve is false, synthesise a
392
+ // needs_agent_task pause unless we just resumed onto this same step.
393
+ if (step.type === "mcp_call" &&
394
+ step.requires_approval &&
395
+ !autoApprove &&
396
+ forceExecuteAtIndex !== stepIndex) {
397
+ const approvalInstruction = `Approval gate for pipeline step ${step.step} of ${totalSteps}: "${step.description}".\n\n` +
398
+ `The pipeline is about to call MCP tool \`${step.tool}\` with these resolved params:\n\n` +
399
+ `\`\`\`json\n${JSON.stringify(step.params ?? {}, null, 2)}\n\`\`\`\n\n` +
400
+ `Review the tool and params, then confirm with the user before resuming. ` +
401
+ `When confirmed, call \`resume_pipeline\` with \`pipeline_run_id\` "${pipelineRunId}" and ` +
402
+ `\`agent_result\` describing the confirmation (e.g. "approved" or a brief reason).`;
403
+ try {
404
+ await persistence.patchRun(pipelineRunId, {
405
+ current_step_index: stepIndex,
406
+ results,
407
+ status: "paused",
408
+ });
409
+ }
410
+ catch (err) {
411
+ return handlePersistenceErrorForEnvelope(err, {
412
+ pipeline_run_id: pipelineRunId,
413
+ pipeline: recipe.pipeline,
414
+ results,
415
+ step_index: step.step,
416
+ });
417
+ }
418
+ return {
419
+ status: "needs_agent_task",
420
+ pipeline_run_id: pipelineRunId,
421
+ pipeline: recipe.pipeline,
422
+ step_index: step.step,
423
+ total_steps: totalSteps,
424
+ step_description: step.description,
425
+ instruction: approvalInstruction,
426
+ results,
427
+ };
428
+ }
429
+ if (step.type === "agent_task") {
430
+ try {
431
+ await persistence.patchRun(pipelineRunId, {
432
+ current_step_index: stepIndex,
433
+ results,
434
+ status: "paused",
435
+ });
436
+ }
437
+ catch (err) {
438
+ return handlePersistenceErrorForEnvelope(err, {
439
+ pipeline_run_id: pipelineRunId,
440
+ pipeline: recipe.pipeline,
441
+ results,
442
+ step_index: step.step,
443
+ });
444
+ }
445
+ return {
446
+ status: "needs_agent_task",
447
+ pipeline_run_id: pipelineRunId,
448
+ pipeline: recipe.pipeline,
449
+ step_index: step.step,
450
+ total_steps: totalSteps,
451
+ step_description: step.description,
452
+ instruction: step.instruction ?? "",
453
+ results,
454
+ };
455
+ }
456
+ // mcp_call branch (executing now)
457
+ const callResult = await executeMcpCallStep(deps, step);
458
+ const capturedApprovalAgentResult = approvalAgentResult;
459
+ forceExecuteAtIndex = undefined;
460
+ approvalAgentResult = undefined;
461
+ if (callResult.ok) {
462
+ const entry = {
463
+ step: step.step,
464
+ type: "mcp_call",
465
+ ok: true,
466
+ description: step.description,
467
+ tool: step.tool,
468
+ result: callResult.value,
469
+ };
470
+ if (capturedApprovalAgentResult !== undefined) {
471
+ entry.approval_agent_result = capturedApprovalAgentResult;
472
+ }
473
+ results.push(entry);
474
+ stepIndex += 1;
475
+ try {
476
+ await persistence.patchRun(pipelineRunId, {
477
+ current_step_index: stepIndex,
478
+ results,
479
+ status: stepIndex >= totalSteps ? "completed" : "running",
480
+ });
481
+ }
482
+ catch (err) {
483
+ return handlePersistenceErrorForEnvelope(err, {
484
+ pipeline_run_id: pipelineRunId,
485
+ pipeline: recipe.pipeline,
486
+ results,
487
+ step_index: step.step,
488
+ });
489
+ }
490
+ continue;
491
+ }
492
+ // Failure path
493
+ const failureEntry = {
494
+ step: step.step,
495
+ type: "mcp_call",
496
+ ok: false,
497
+ description: step.description,
498
+ tool: step.tool,
499
+ error: callResult.error,
500
+ };
501
+ if (capturedApprovalAgentResult !== undefined) {
502
+ failureEntry.approval_agent_result = capturedApprovalAgentResult;
503
+ }
504
+ results.push(failureEntry);
505
+ if (step.on_error === "warn_and_continue") {
506
+ stepIndex += 1;
507
+ try {
508
+ await persistence.patchRun(pipelineRunId, {
509
+ current_step_index: stepIndex,
510
+ results,
511
+ status: stepIndex >= totalSteps ? "completed" : "running",
512
+ });
513
+ }
514
+ catch (err) {
515
+ return handlePersistenceErrorForEnvelope(err, {
516
+ pipeline_run_id: pipelineRunId,
517
+ pipeline: recipe.pipeline,
518
+ results,
519
+ step_index: step.step,
520
+ });
521
+ }
522
+ continue;
523
+ }
524
+ // halt
525
+ try {
526
+ await persistence.patchRun(pipelineRunId, {
527
+ current_step_index: stepIndex,
528
+ results,
529
+ status: "failed",
530
+ });
531
+ }
532
+ catch {
533
+ // best-effort — failure already happened
534
+ }
535
+ return failedEnvelope("TOOL_ERROR", callResult.error, {
536
+ pipeline_run_id: pipelineRunId,
537
+ pipeline: recipe.pipeline,
538
+ results,
539
+ step_index: step.step,
540
+ });
541
+ }
542
+ return {
543
+ status: "completed",
544
+ pipeline_run_id: pipelineRunId,
545
+ pipeline: recipe.pipeline,
546
+ total_steps: totalSteps,
547
+ results,
548
+ };
549
+ }
550
+ function handlePersistenceErrorForEnvelope(err, extras) {
551
+ if (err instanceof PipelinePersistenceError) {
552
+ return failedEnvelope(err.code, err.message, extras);
553
+ }
554
+ return failedEnvelope("TOOL_ERROR", err instanceof Error ? err.message : String(err), extras);
555
+ }
556
+ async function executeMcpCallStep(deps, step) {
557
+ if (!step.tool) {
558
+ return { ok: false, error: "mcp_call step missing 'tool' field" };
559
+ }
560
+ if (ORCHESTRATION_TOOLS.has(step.tool)) {
561
+ return {
562
+ ok: false,
563
+ error: `Refusing to invoke orchestration tool "${step.tool}" from within a pipeline step.`,
564
+ };
565
+ }
566
+ const entry = deps.toolHandlers.get(step.tool);
567
+ if (!entry) {
568
+ return { ok: false, error: `Unknown MCP tool "${step.tool}"` };
569
+ }
570
+ if (!entry.isEnabled()) {
571
+ return {
572
+ ok: false,
573
+ error: `MCP tool "${step.tool}" is currently disabled by server policy.`,
574
+ };
575
+ }
576
+ let toolResult;
577
+ try {
578
+ toolResult = await entry.handler(step.params ?? {});
579
+ }
580
+ catch (err) {
581
+ return {
582
+ ok: false,
583
+ error: err instanceof Error ? err.message : String(err),
584
+ };
585
+ }
586
+ const text = toolResult?.content?.[0]?.type === "text"
587
+ ? toolResult.content[0].text
588
+ : "";
589
+ if (typeof text === "string" && isToolFailureEnvelope(text)) {
590
+ return { ok: false, error: text };
591
+ }
592
+ return { ok: true, value: text };
593
+ }