@bridge_gpt/mcp-server 0.1.13 → 0.1.16

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/build/index.js CHANGED
@@ -23,8 +23,11 @@ import { VERSION } from "./version.generated.js";
23
23
  import { checkForUpdate } from "./update-check.js";
24
24
  import { reconstructAgentMarkdown, translateAgentToCopilot } from "./agent-utils.js";
25
25
  import { resolveRecipe, loadCustomPipelines } from "./pipeline-utils.js";
26
+ import { runStartTicketsCli } from "./start-tickets.js";
27
+ import { runDoctorCli } from "./doctor.js";
26
28
  import { generateDecisionPageHtml } from "./decision-page-template.js";
27
29
  import { DecisionPageInputShape } from "./decision-page-schema.js";
30
+ import { runPipeline, resumePipeline, listPipelineRuns, deletePipelineRun, } from "./pipeline-orchestrator.js";
28
31
  // Mutable pipeline/instruction state — starts with bundled, merged with user at startup
29
32
  const PIPELINES = { ...BUNDLED_PIPELINES };
30
33
  const INSTRUCTIONS = { ...BUNDLED_INSTRUCTIONS };
@@ -49,6 +52,9 @@ const POST_HEADERS = {
49
52
  function buildUrl(path) {
50
53
  return `${BASE_URL.replace(/\/+$/, "")}/jira${path}`;
51
54
  }
55
+ function buildApiUrl(path) {
56
+ return `${BASE_URL.replace(/\/+$/, "")}${path}`;
57
+ }
52
58
  function buildGetUrl(path, params) {
53
59
  const url = new URL(buildUrl(path));
54
60
  for (const [key, value] of Object.entries(params)) {
@@ -518,6 +524,32 @@ automatically provided by the server.
518
524
 
519
525
  - \`"halt"\` (default) — stop the pipeline immediately on failure
520
526
  - \`"warn_and_continue"\` — log a warning and proceed to the next step
527
+
528
+ ## Executing Pipelines
529
+
530
+ Pipelines defined here can be executed end-to-end via the \`run_pipeline\` MCP
531
+ tool. \`run_pipeline\` returns a unified envelope keyed on \`status\`:
532
+
533
+ - \`completed\` — the pipeline finished and \`results\` holds the per-step output.
534
+ - \`needs_agent_task\` — the orchestrator paused on an \`agent_task\` step (or an
535
+ approval-gated \`mcp_call\` when \`auto_approve\` was false). Perform the task
536
+ described by \`instruction\`, then call \`resume_pipeline\` with the
537
+ \`pipeline_run_id\` and the resulting string as \`agent_result\`.
538
+ - \`failed\` — terminal failure; \`error_code\` is one of \`VALIDATION\`,
539
+ \`NOT_FOUND\`, \`EXPIRED\`, \`REPO_MISMATCH\`, or \`TOOL_ERROR\`.
540
+
541
+ Paused runs auto-expire after an idle TTL (default 24 hours, override via
542
+ \`ttl_seconds\` on \`run_pipeline\`). The TTL is reset on every state transition.
543
+ Use \`list_pipeline_runs\` to recover a \`pipeline_run_id\` if the prior
544
+ \`needs_agent_task\` envelope is no longer in scope.
545
+
546
+ ## \`agent_task\` Instruction Files
547
+
548
+ When you reference an instruction file (\`instruction_file: "…"\`), the file
549
+ markdown MUST end with a terminal \`## Return\` H2 section describing what the
550
+ agent should pass back as \`resume_pipeline.agent_result\`. The return value
551
+ is a string — do NOT ask the agent to wrap it in JSON unless the instruction
552
+ body explicitly says to serialize structured output.
521
553
  `;
522
554
  const exampleContent = JSON.stringify({
523
555
  name: "Example Pipeline",
@@ -580,41 +612,54 @@ automatically provided by the server.
580
612
  }
581
613
  }
582
614
  // ---------------------------------------------------------------------------
583
- // CLI: --init
615
+ // CLI subcommand dispatch
584
616
  // ---------------------------------------------------------------------------
585
- if (process.argv.includes("--init")) {
617
+ //
618
+ // `--init`, `--upgrade`, and the positional `start-tickets` subcommand are all
619
+ // routed through a single ``dispatchCliSubcommand`` guard that runs and exits
620
+ // BEFORE the MCP server is constructed and connected. This keeps the single
621
+ // existing ``bridge-api-mcp-server`` bin and leaves a clean seam for future
622
+ // subcommands.
623
+ /**
624
+ * Ensure a ``package.json`` exists in ``cwd`` for CLI commands that scaffold
625
+ * into a project. Returns ``null`` on success, or the exact user-facing error
626
+ * string (preserved verbatim from the previous standalone guards).
627
+ */
628
+ async function ensurePackageJsonForCliCommand(flagName, cwd) {
586
629
  try {
587
- await stat(path.join(process.cwd(), "package.json"));
630
+ await stat(path.join(cwd, "package.json"));
631
+ return null;
588
632
  }
589
633
  catch {
590
- console.error("Error: No package.json found in current directory.\n" +
591
- "--init must be run from your project root (the directory containing package.json).");
592
- process.exit(1);
634
+ return ("Error: No package.json found in current directory.\n" +
635
+ `${flagName} must be run from your project root (the directory containing package.json).`);
636
+ }
637
+ }
638
+ /** Run the ``--init`` scaffolding flow. Returns a process exit code. */
639
+ async function runInitCli(cwd) {
640
+ const guardError = await ensurePackageJsonForCliCommand("--init", cwd);
641
+ if (guardError) {
642
+ console.error(guardError);
643
+ return 1;
593
644
  }
594
645
  try {
595
- await runInit(process.cwd());
596
- process.exit(0);
646
+ await runInit(cwd);
647
+ return 0;
597
648
  }
598
649
  catch (err) {
599
650
  const msg = err instanceof Error ? err.message : String(err);
600
651
  console.error(`Bridge API --init failed: ${msg}`);
601
- process.exit(1);
652
+ return 1;
602
653
  }
603
654
  }
604
- // ---------------------------------------------------------------------------
605
- // CLI: --upgrade
606
- // ---------------------------------------------------------------------------
607
- if (process.argv.includes("--upgrade")) {
608
- try {
609
- await stat(path.join(process.cwd(), "package.json"));
610
- }
611
- catch {
612
- console.error("Error: No package.json found in current directory.\n" +
613
- "--upgrade must be run from your project root (the directory containing package.json).");
614
- process.exit(1);
655
+ /** Run the ``--upgrade`` flow (npm install latest + re-scaffold). */
656
+ async function runUpgradeCli(cwd) {
657
+ const guardError = await ensurePackageJsonForCliCommand("--upgrade", cwd);
658
+ if (guardError) {
659
+ console.error(guardError);
660
+ return 1;
615
661
  }
616
662
  try {
617
- const cwd = process.cwd();
618
663
  const oldVersion = VERSION;
619
664
  console.log("Upgrading @bridge_gpt/mcp-server to latest...\n");
620
665
  execSync("npm i @bridge_gpt/mcp-server@latest", { stdio: "inherit" });
@@ -630,14 +675,52 @@ if (process.argv.includes("--upgrade")) {
630
675
  console.log("\nRefreshing scaffolded artifacts...\n");
631
676
  await runInit(cwd);
632
677
  console.log("\nUpgrade complete.");
633
- process.exit(0);
678
+ return 0;
634
679
  }
635
680
  catch (err) {
636
681
  const msg = err instanceof Error ? err.message : String(err);
637
682
  console.error(`Bridge API --upgrade failed: ${msg}`);
638
- process.exit(1);
683
+ return 1;
639
684
  }
640
685
  }
686
+ /**
687
+ * Route CLI subcommands before MCP server startup. Returns an exit code to
688
+ * exit the process with, or ``null`` to continue to normal MCP server startup.
689
+ *
690
+ * ``--init`` takes precedence over ``--upgrade`` (both are position-independent
691
+ * flags); ``start-tickets`` is a positional subcommand. Any other unknown,
692
+ * non-flag positional first token is rejected.
693
+ */
694
+ async function dispatchCliSubcommand(argv) {
695
+ const cwd = process.cwd();
696
+ // A positional subcommand owns the rest of argv, so e.g. `start-tickets --init`
697
+ // is a start-tickets invocation, not an --init one. Check it before the
698
+ // position-independent --init / --upgrade flag guards.
699
+ if (argv[0] === "start-tickets") {
700
+ return runStartTicketsCli(argv.slice(1));
701
+ }
702
+ // The read-only `doctor` subcommand is routed beside start-tickets, before the
703
+ // flag guards and well before MCP server construction (it never starts the server).
704
+ if (argv[0] === "doctor") {
705
+ return runDoctorCli(argv.slice(1));
706
+ }
707
+ // --init takes precedence over --upgrade; both are position-independent flags.
708
+ if (argv.includes("--init")) {
709
+ return runInitCli(cwd);
710
+ }
711
+ if (argv.includes("--upgrade")) {
712
+ return runUpgradeCli(cwd);
713
+ }
714
+ if (argv.length > 0 && !argv[0].startsWith("-")) {
715
+ console.error(`Error: Unknown subcommand '${argv[0]}'. Run with --help for usage, or omit subcommands to start the MCP server.`);
716
+ return 1;
717
+ }
718
+ return null;
719
+ }
720
+ const cliExitCode = await dispatchCliSubcommand(process.argv.slice(2));
721
+ if (cliExitCode !== null) {
722
+ process.exit(cliExitCode);
723
+ }
641
724
  // ---------------------------------------------------------------------------
642
725
  // Server
643
726
  // ---------------------------------------------------------------------------
@@ -646,9 +729,70 @@ const server = new McpServer({
646
729
  version: "1.0.0",
647
730
  });
648
731
  // ---------------------------------------------------------------------------
732
+ // Tool registration wrapper (BAPI-275)
733
+ // ---------------------------------------------------------------------------
734
+ //
735
+ // Every MCP tool registered through ``registerTool`` is also recorded in
736
+ // ``TOOL_HANDLERS`` so the new pipeline orchestrator can dispatch existing
737
+ // tools in-process (without round-tripping through stdio). The wrapper
738
+ // preserves the exact return value of ``server.registerTool`` — including
739
+ // its ``.enable()`` / ``.disable()`` methods — and keeps a closure-local
740
+ // ``active`` flag in sync with them, so disabling a tool through the SDK
741
+ // also blocks in-process dispatch through ``TOOL_HANDLERS``.
742
+ const TOOL_HANDLERS = new Map();
743
+ // The wrapper preserves the SDK's generic type signature so existing call
744
+ // sites keep their per-tool input-type inference.
745
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
746
+ const registerTool = ((name, config, handler) => {
747
+ let active = true;
748
+ const wrappedHandler = async (args, extra) => {
749
+ if (!active) {
750
+ return {
751
+ content: [
752
+ {
753
+ type: "text",
754
+ text: JSON.stringify({
755
+ error: "TOOL_DISABLED",
756
+ status: 503,
757
+ message: `Tool "${name}" is currently disabled.`,
758
+ }),
759
+ },
760
+ ],
761
+ };
762
+ }
763
+ return await handler(args, extra);
764
+ };
765
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
766
+ const sdkRegister = server.registerTool.bind(server);
767
+ const toolHandle = sdkRegister(name, config, wrappedHandler);
768
+ const inProcessHandler = async (params) => {
769
+ return await wrappedHandler(params);
770
+ };
771
+ TOOL_HANDLERS.set(name, {
772
+ handler: inProcessHandler,
773
+ isEnabled: () => active,
774
+ });
775
+ // Wrap enable/disable to keep the closure flag in sync with SDK state.
776
+ if (toolHandle && typeof toolHandle.enable === "function") {
777
+ const sdkEnable = toolHandle.enable.bind(toolHandle);
778
+ toolHandle.enable = () => {
779
+ active = true;
780
+ return sdkEnable();
781
+ };
782
+ }
783
+ if (toolHandle && typeof toolHandle.disable === "function") {
784
+ const sdkDisable = toolHandle.disable.bind(toolHandle);
785
+ toolHandle.disable = () => {
786
+ active = false;
787
+ return sdkDisable();
788
+ };
789
+ }
790
+ return toolHandle;
791
+ });
792
+ // ---------------------------------------------------------------------------
649
793
  // Tools
650
794
  // ---------------------------------------------------------------------------
651
- server.registerTool("ping", {
795
+ registerTool("ping", {
652
796
  description: "Test connectivity to Bridge API. Validates that the API key is accepted and the configured repository is accessible. " +
653
797
  "Returns JSON with {status: 'ok', repo_name: '<configured repo>'}. " +
654
798
  "Use this as a quick health check before other operations, or to verify your Bridge API configuration is working. " +
@@ -661,7 +805,42 @@ server.registerTool("ping", {
661
805
  const text = await handleResponse(resp);
662
806
  return { content: [{ type: "text", text }] };
663
807
  });
664
- server.registerTool("get_project_standards", {
808
+ registerTool("second_opinion", {
809
+ description: "Consult a different LLM model family for a sanity check or technical pushback on a recommendation, plan, or analysis you have already produced. " +
810
+ "Use this when you want a second, independent opinion before acting on a non-trivial decision — for example, when committing to an implementation approach, a risky refactor, an architectural trade-off, or a recommendation you would otherwise present to a user with no further validation. " +
811
+ "Pick a provider from a DIFFERENT model family than the one you are running on so the response is genuinely independent (e.g. if you are an OpenAI agent, ask 'anthropic' or 'gemini'). " +
812
+ "Pick a model tier appropriate for the depth of pushback you want: CHEAP_MODEL for a quick sanity check, BASIC_MODEL for a focused review, PREMIUM_MODEL for serious architectural pushback. " +
813
+ "The 'prompt' you supply should be a complete, self-contained brief: the full plan, recommendation, or question you want challenged, along with whatever context is needed to evaluate it. The server adds a system prompt that frames the responder as an independent senior engineer and injects lightweight project context. " +
814
+ "Returns the responding model's reply text plus the resolved provider, tier, and model id. The reply is returned to you as-is; you decide whether to incorporate the feedback. " +
815
+ "Project authorization uses the standard repo access check; per-project provider gating is intentionally not enforced for this tool.",
816
+ inputSchema: {
817
+ prompt: z
818
+ .string()
819
+ .describe("The complete, self-contained brief to send to the second-opinion model. " +
820
+ "Include the full plan, recommendation, analysis, or question you want challenged, plus enough context for the responder to evaluate it independently. " +
821
+ "This is sent as the user message; the server constructs the system prompt."),
822
+ provider: z
823
+ .enum(["anthropic", "openai", "gemini"])
824
+ .describe("LLM provider family for the second opinion. Choose a family DIFFERENT from the one you are running on so the response is genuinely independent."),
825
+ model: z
826
+ .enum(["CHEAP_MODEL", "BASIC_MODEL", "PREMIUM_MODEL"])
827
+ .describe("Model tier within the chosen provider. CHEAP_MODEL for quick sanity checks, BASIC_MODEL for focused reviews, PREMIUM_MODEL for serious architectural pushback."),
828
+ },
829
+ }, async ({ prompt, provider, model }) => {
830
+ const resp = await fetch(buildApiUrl("/llm/second-opinion"), {
831
+ method: "POST",
832
+ headers: POST_HEADERS,
833
+ body: JSON.stringify({
834
+ repo_name: REPO_NAME,
835
+ prompt,
836
+ provider,
837
+ model,
838
+ }),
839
+ });
840
+ const text = await handleResponse(resp);
841
+ return { content: [{ type: "text", text }] };
842
+ });
843
+ registerTool("get_project_standards", {
665
844
  description: "Retrieve project-specific coding standards, architecture guidelines, testing standards, code review standards, and project context (platform, version, project description) for the configured repository. " +
666
845
  "Returns structured markdown with sections for project context, architecture instructions, code review correctness standards, testing stack information, and build analysis. " +
667
846
  "Only sections with configured values are included. Returns 404 if no standards are configured. " +
@@ -673,7 +852,7 @@ server.registerTool("get_project_standards", {
673
852
  const text = await handleResponse(resp);
674
853
  return { content: [{ type: "text", text }] };
675
854
  });
676
- server.registerTool("get_tickets", {
855
+ registerTool("get_tickets", {
677
856
  description: "Search for and list Jira tickets from the configured project. " +
678
857
  "Filters by query text, status name, or date. Returns up to 'limit' tickets ordered by most recently updated. " +
679
858
  "All data is fetched live from Jira. Use get_ticket to retrieve full details for a specific ticket.",
@@ -720,10 +899,11 @@ server.registerTool("get_tickets", {
720
899
  const text = await handleResponse(resp);
721
900
  return { content: [{ type: "text", text }] };
722
901
  });
723
- server.registerTool("get_ticket", {
902
+ registerTool("get_ticket", {
724
903
  description: "Retrieve full details for a single Jira ticket by its key. " +
725
904
  "Returns summary, status, type, assignee, reporter, description, and timestamps. " +
726
- "All data is fetched live from Jira. Use get_tickets to search/list multiple tickets.",
905
+ "All data is fetched live from Jira. Use get_tickets to search/list multiple tickets. " +
906
+ "Use get_comments to fetch comments on the ticket.",
727
907
  inputSchema: {
728
908
  ticket_number: z
729
909
  .string()
@@ -735,7 +915,24 @@ server.registerTool("get_ticket", {
735
915
  const text = await handleResponse(resp);
736
916
  return { content: [{ type: "text", text }] };
737
917
  });
738
- server.registerTool("create_ticket", {
918
+ registerTool("get_comments", {
919
+ description: "Retrieve all comments on a Jira ticket, oldest-first. " +
920
+ "Returns an array of {id, author, body, created, updated}. " +
921
+ "Comment bodies are Markdown (converted from Jira wiki markup). " +
922
+ "Use this to read what a developer or stakeholder has said on a ticket. " +
923
+ "Use add_comment to post a new comment.",
924
+ inputSchema: {
925
+ ticket_number: z
926
+ .string()
927
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
928
+ },
929
+ }, async ({ ticket_number }) => {
930
+ const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}/comments`, { repo_name: REPO_NAME });
931
+ const resp = await fetch(url, { headers: GET_HEADERS });
932
+ const text = await handleResponse(resp);
933
+ return { content: [{ type: "text", text }] };
934
+ });
935
+ registerTool("create_ticket", {
739
936
  description: "Create a new Jira ticket in the configured project. Requires either description or file_path (or both — file_path takes precedence). " +
740
937
  "Returns JSON with {ticket_key: 'PROJ-123', url: 'https://...'}. " +
741
938
  "The ticket is created immediately in Jira — confirm details with the user before calling. " +
@@ -777,7 +974,7 @@ server.registerTool("create_ticket", {
777
974
  const text = await createTicketRequest({ summary, description: resolved.text, issue_type, priority, labels, assignee });
778
975
  return { content: [{ type: "text", text: text + resolved.note }] };
779
976
  });
780
- server.registerTool("get_plan", {
977
+ registerTool("get_plan", {
781
978
  description: "Retrieve the AI-generated implementation plan for a Jira ticket. " +
782
979
  "Returns the full plan as markdown text — present it verbatim without summarizing. " +
783
980
  "The plan includes step-by-step implementation guidance with code file references. " +
@@ -805,7 +1002,7 @@ server.registerTool("get_plan", {
805
1002
  }
806
1003
  return { content: [{ type: "text", text }] };
807
1004
  });
808
- server.registerTool("get_architecture", {
1005
+ registerTool("get_architecture", {
809
1006
  description: "Retrieve the AI-generated architecture plan for a Jira ticket. " +
810
1007
  "Returns the full architecture plan as markdown text — present it verbatim without summarizing. " +
811
1008
  "The plan includes high-level architectural decisions, component design, and integration guidance. " +
@@ -833,7 +1030,7 @@ server.registerTool("get_architecture", {
833
1030
  }
834
1031
  return { content: [{ type: "text", text }] };
835
1032
  });
836
- server.registerTool("get_clarifying_questions", {
1033
+ registerTool("get_clarifying_questions", {
837
1034
  description: "Retrieve AI-generated clarifying questions (for feature/task tickets) or debugging guidance (for bug tickets) for a Jira ticket. " +
838
1035
  "Returns markdown text with questions that should be resolved before implementation begins. " +
839
1036
  "Returns 404 if no questions have been generated yet. " +
@@ -860,7 +1057,7 @@ server.registerTool("get_clarifying_questions", {
860
1057
  }
861
1058
  return { content: [{ type: "text", text }] };
862
1059
  });
863
- server.registerTool("parse_repository", {
1060
+ registerTool("parse_repository", {
864
1061
  description: "Queue a background job to parse and index the repository for Bridge API's AI agents. " +
865
1062
  "This should be run after major codebase changes so that plans and questions reflect the latest code. " +
866
1063
  "Returns 202 with {message: 'Repository parsing queued'} on success, " +
@@ -886,7 +1083,7 @@ server.registerTool("parse_repository", {
886
1083
  const text = await handleResponse(resp);
887
1084
  return { content: [{ type: "text", text }] };
888
1085
  });
889
- server.registerTool("regenerate_directory_map", {
1086
+ registerTool("regenerate_directory_map", {
890
1087
  description: "Regenerate the repository directory map and return the result. " +
891
1088
  "Unlike parse_repository (which is async), this tool is synchronous — it blocks until " +
892
1089
  "the directory map is generated and returns the full map text directly. " +
@@ -910,7 +1107,7 @@ server.registerTool("regenerate_directory_map", {
910
1107
  clearTimeout(timeout);
911
1108
  }
912
1109
  });
913
- server.registerTool("get_parse_status", {
1110
+ registerTool("get_parse_status", {
914
1111
  description: "Check whether a repository parse job is currently running. " +
915
1112
  "Returns {status: 'in_progress', started_at: '<ISO timestamp>'} if a parse is active, " +
916
1113
  "or {status: 'idle'} if no parse is running. " +
@@ -923,7 +1120,7 @@ server.registerTool("get_parse_status", {
923
1120
  const text = await handleResponse(resp);
924
1121
  return { content: [{ type: "text", text }] };
925
1122
  });
926
- server.registerTool("add_comment", {
1123
+ registerTool("add_comment", {
927
1124
  description: "Post a comment on a Jira ticket. The comment appears immediately in Jira. " +
928
1125
  "Supports markdown formatting. " +
929
1126
  "For long comments (over ~2000 characters), set attach_as_file to true — " +
@@ -977,7 +1174,7 @@ server.registerTool("add_comment", {
977
1174
  const text = await handleResponse(resp);
978
1175
  return { content: [{ type: "text", text: text + resolved.note }] };
979
1176
  });
980
- server.registerTool("update_ticket_description", {
1177
+ registerTool("update_ticket_description", {
981
1178
  description: "Update the description of an existing Jira ticket. This is a direct, synchronous update that overwrites the existing description with the provided text. " +
982
1179
  "The description should be in markdown format — it will be automatically converted to Jira wiki markup. " +
983
1180
  "This does NOT create a new ticket. Use create_ticket for that. " +
@@ -1009,7 +1206,7 @@ server.registerTool("update_ticket_description", {
1009
1206
  const text = await handleResponse(resp);
1010
1207
  return { content: [{ type: "text", text: text + resolved.note }] };
1011
1208
  });
1012
- server.registerTool("upload_attachment", {
1209
+ registerTool("upload_attachment", {
1013
1210
  description: "Upload a local file as an attachment to a Jira ticket. " +
1014
1211
  "Supports text/UTF-8 files only (markdown, plain text, etc.). " +
1015
1212
  "Optionally syncs the content to Bridge API's tickets_links table so retrieval endpoints " +
@@ -1072,7 +1269,7 @@ server.registerTool("upload_attachment", {
1072
1269
  const text = await handleResponse(resp);
1073
1270
  return { content: [{ type: "text", text: text + resolved.note }] };
1074
1271
  });
1075
- server.registerTool("list_attachments", {
1272
+ registerTool("list_attachments", {
1076
1273
  description: "List attachments on a Jira ticket. " +
1077
1274
  "Returns metadata (ID, filename, MIME type, size, created date) for each attachment. " +
1078
1275
  "By default, AI-generated attachments are excluded. " +
@@ -1097,7 +1294,7 @@ server.registerTool("list_attachments", {
1097
1294
  return { content: [{ type: "text", text }] };
1098
1295
  });
1099
1296
  const MAX_INLINE_TEXT_LENGTH = 50_000;
1100
- server.registerTool("download_attachment", {
1297
+ registerTool("download_attachment", {
1101
1298
  description: "Download an attachment from a Jira ticket and save it to disk. " +
1102
1299
  "Specify either attachment_id or filename (not both). " +
1103
1300
  "For text files, the content is returned inline (truncated at ~50KB) and also saved to disk. " +
@@ -1185,7 +1382,7 @@ server.registerTool("download_attachment", {
1185
1382
  }
1186
1383
  return { content: [{ type: "text", text: resultText }] };
1187
1384
  });
1188
- server.registerTool("request_plan_generation", {
1385
+ registerTool("request_plan_generation", {
1189
1386
  description: "Request AI-generated implementation plan for a Jira ticket. " +
1190
1387
  "This triggers an asynchronous background job — results are NOT immediate. " +
1191
1388
  "Processing typically takes 1-5 minutes depending on ticket complexity and number of attachments. " +
@@ -1254,7 +1451,7 @@ server.registerTool("request_plan_generation", {
1254
1451
  `Use get_plan with ticket_number "${ticket_number}" to retrieve the plan once processing completes.`;
1255
1452
  return { content: [{ type: "text", text: confirmationText }] };
1256
1453
  });
1257
- server.registerTool("request_architecture", {
1454
+ registerTool("request_architecture", {
1258
1455
  description: "Request AI-generated architecture plan for a Jira ticket. " +
1259
1456
  "This triggers an asynchronous background job — results are NOT immediate. " +
1260
1457
  "Processing typically takes 2-4 minutes depending on ticket complexity. " +
@@ -1323,7 +1520,7 @@ server.registerTool("request_architecture", {
1323
1520
  `Use get_architecture with ticket_number "${ticket_number}" to retrieve the architecture plan once processing completes.`;
1324
1521
  return { content: [{ type: "text", text: confirmationText }] };
1325
1522
  });
1326
- server.registerTool("request_clarifying_questions", {
1523
+ registerTool("request_clarifying_questions", {
1327
1524
  description: "Request AI-generated clarifying questions or debugging guidance for a Jira ticket. " +
1328
1525
  "This triggers an asynchronous background job — results are NOT immediate. " +
1329
1526
  "Processing typically takes 1-5 minutes. " +
@@ -1396,7 +1593,7 @@ server.registerTool("request_clarifying_questions", {
1396
1593
  // ---------------------------------------------------------------------------
1397
1594
  // Ticket Quality Critique
1398
1595
  // ---------------------------------------------------------------------------
1399
- server.registerTool("get_ticket_critique", {
1596
+ registerTool("get_ticket_critique", {
1400
1597
  description: "Retrieve AI-generated ticket quality critique for a Jira ticket. " +
1401
1598
  "Returns markdown text with a structured critique covering Standards Conformance Analysis, " +
1402
1599
  "Standards Deviations, and Suggested Improvements. " +
@@ -1424,7 +1621,7 @@ server.registerTool("get_ticket_critique", {
1424
1621
  }
1425
1622
  return { content: [{ type: "text", text }] };
1426
1623
  });
1427
- server.registerTool("request_ticket_critique", {
1624
+ registerTool("request_ticket_critique", {
1428
1625
  description: "Request AI-generated ticket critique for a Jira ticket. " +
1429
1626
  "This triggers an asynchronous background job — results are NOT immediate. " +
1430
1627
  "Processing typically takes 1-5 minutes. " +
@@ -1496,7 +1693,7 @@ server.registerTool("request_ticket_critique", {
1496
1693
  // ---------------------------------------------------------------------------
1497
1694
  // Combined Ticket Review (clarify + critique)
1498
1695
  // ---------------------------------------------------------------------------
1499
- server.registerTool("request_ticket_review", {
1696
+ registerTool("request_ticket_review", {
1500
1697
  description: "Request a combined ticket review that generates BOTH clarifying questions (or debugging guidance for bug tickets) " +
1501
1698
  "AND a ticket quality critique in parallel on the server, halving wall-clock latency vs. running the two " +
1502
1699
  "requests sequentially. This triggers an asynchronous background job — results are NOT immediate. " +
@@ -1603,7 +1800,7 @@ server.registerTool("request_ticket_review", {
1603
1800
  // ---------------------------------------------------------------------------
1604
1801
  // Reimplement Context
1605
1802
  // ---------------------------------------------------------------------------
1606
- server.registerTool("request_reimplement_context", {
1803
+ registerTool("request_reimplement_context", {
1607
1804
  description: "Request processing of new attachments and context assembly for a previously-implemented Jira ticket. " +
1608
1805
  "Use this for follow-up requests on tickets that have already been through the plan+implement cycle. " +
1609
1806
  "This triggers an asynchronous background job to process new attachments/images. " +
@@ -1669,7 +1866,7 @@ server.registerTool("request_reimplement_context", {
1669
1866
  `Use get_reimplement_context with ticket_number "${ticket_number}" to retrieve the results once processing completes.`;
1670
1867
  return { content: [{ type: "text", text: confirmationText }] };
1671
1868
  });
1672
- server.registerTool("get_reimplement_context", {
1869
+ registerTool("get_reimplement_context", {
1673
1870
  description: "Retrieve the assembled reimplement context for a Jira ticket. " +
1674
1871
  "Returns a markdown document with new/changed information diffed against stored state, " +
1675
1872
  "the original ticket description, and the existing implementation plan. " +
@@ -1710,7 +1907,7 @@ server.registerTool("get_reimplement_context", {
1710
1907
  // ---------------------------------------------------------------------------
1711
1908
  // Ticket Lifecycle Tracking
1712
1909
  // ---------------------------------------------------------------------------
1713
- server.registerTool("track_ticket", {
1910
+ registerTool("track_ticket", {
1714
1911
  description: "Insert a ticket into Bridge API's database for lifecycle tracking. This registers the ticket so that workflow state timestamps (critique, clarify, plan, implement) can be tracked. " +
1715
1912
  "If the ticket is already tracked, this is a safe no-op — it upserts the description and repo_name without error. " +
1716
1913
  "Call this after creating a ticket with create_ticket to enable state tracking. " +
@@ -1731,7 +1928,7 @@ server.registerTool("track_ticket", {
1731
1928
  const text = await handleResponse(resp);
1732
1929
  return { content: [{ type: "text", text }] };
1733
1930
  });
1734
- server.registerTool("update_ticket_state", {
1931
+ registerTool("update_ticket_state", {
1735
1932
  description: "Update workflow state timestamps on a tracked ticket. Each specified field is set to the current UTC timestamp on the server. " +
1736
1933
  "Valid field names: 'critique_called', 'critique_answered', 'clarify_called', 'clarify_answered', 'plan_generated', 'implemented', 'reimplement_called'. " +
1737
1934
  "The ticket must already be tracked (via track_ticket) or a 404 error is returned. " +
@@ -1751,7 +1948,7 @@ server.registerTool("update_ticket_state", {
1751
1948
  const text = await handleResponse(resp);
1752
1949
  return { content: [{ type: "text", text }] };
1753
1950
  });
1754
- server.registerTool("get_ticket_state", {
1951
+ registerTool("get_ticket_state", {
1755
1952
  description: "Retrieve workflow state timestamps and artifact existence flags for a tracked ticket. " +
1756
1953
  "Returns timestamps for each state field (critique_called, critique_answered, clarify_called, clarify_answered, plan_generated, implemented, reimplement_called) " +
1757
1954
  "and boolean flags indicating whether artifacts exist (has_clarifying_questions, has_critique, has_plan). " +
@@ -1769,7 +1966,7 @@ server.registerTool("get_ticket_state", {
1769
1966
  // ---------------------------------------------------------------------------
1770
1967
  // Jira Transitions
1771
1968
  // ---------------------------------------------------------------------------
1772
- server.registerTool("get_jira_transitions", {
1969
+ registerTool("get_jira_transitions", {
1773
1970
  description: "List available Jira workflow transitions for a ticket. Returns each transition's id, name, and target status. " +
1774
1971
  "Use this to discover what status changes are possible for a given ticket. " +
1775
1972
  "The repo_name is automatically injected from the configured environment.",
@@ -1782,7 +1979,7 @@ server.registerTool("get_jira_transitions", {
1782
1979
  const text = await handleResponse(resp);
1783
1980
  return { content: [{ type: "text", text }] };
1784
1981
  });
1785
- server.registerTool("update_jira_status", {
1982
+ registerTool("update_jira_status", {
1786
1983
  description: "Transition a Jira ticket to a specified target status by executing a workflow transition. " +
1787
1984
  "Provide either target_status (matched case-insensitively against available transitions) or transition_id (used directly). " +
1788
1985
  "If transition_id is provided, it takes precedence over target_status. " +
@@ -1809,7 +2006,7 @@ server.registerTool("update_jira_status", {
1809
2006
  const text = await handleResponse(resp);
1810
2007
  return { content: [{ type: "text", text }] };
1811
2008
  });
1812
- server.registerTool("resolve_target_status", {
2009
+ registerTool("resolve_target_status", {
1813
2010
  description: "Resolve the post-PR target Jira status for the configured repository using an LLM agent. " +
1814
2011
  "The agent selects the workflow status that best represents 'code committed via PR but not yet tested.' " +
1815
2012
  "Results are cached per-project — subsequent calls return the cached value unless force_rerun is true. " +
@@ -1840,12 +2037,15 @@ server.registerTool("resolve_target_status", {
1840
2037
  const VALID_CONFIG_FIELDS = [
1841
2038
  "review_instructions", "documentation_instructions", "architecture_instructions",
1842
2039
  "unit_testing_instructions", "e2e_testing_instructions",
2040
+ "unit_testing_stack", "e2e_testing_stack",
1843
2041
  "frontend_correctness_standards", "backend_correctness_standards",
1844
2042
  "template_correctness_standards", "style_correctness_standards",
1845
2043
  "design_principles",
1846
- "post_pr_target_status", "ci_check_config",
2044
+ "post_pr_target_status", "ci_check_config", "ci_followup_config",
2045
+ "allow_mutating_smoke_ops",
2046
+ "selected_mcp_slugs",
1847
2047
  ].join(", ");
1848
- server.registerTool("list_config_fields", {
2048
+ registerTool("list_config_fields", {
1849
2049
  description: "List all configurable fields available for reading and updating via the Bridge API. " +
1850
2050
  "Returns each field's name and a description of its purpose. No database values are returned — " +
1851
2051
  "use get_config_field to read a specific field's current value. " +
@@ -1857,7 +2057,7 @@ server.registerTool("list_config_fields", {
1857
2057
  const text = await handleResponse(resp);
1858
2058
  return { content: [{ type: "text", text }] };
1859
2059
  });
1860
- server.registerTool("get_my_role", {
2060
+ registerTool("get_my_role", {
1861
2061
  description: "Check the role and auth source for the current API key. " +
1862
2062
  "Returns JSON with {role: \"admin\" | \"member\" | null, source: \"user_access\" | \"legacy\"}. " +
1863
2063
  "Use this to check if the current key has admin permissions before attempting configuration updates " +
@@ -1869,7 +2069,7 @@ server.registerTool("get_my_role", {
1869
2069
  const text = await handleResponse(resp);
1870
2070
  return { content: [{ type: "text", text }] };
1871
2071
  });
1872
- server.registerTool("get_config_field", {
2072
+ registerTool("get_config_field", {
1873
2073
  description: "Read the current value and metadata for a specific configuration field. " +
1874
2074
  "Returns the field's current database value (or null if not set), along with a description of its purpose and examples of helpful content. " +
1875
2075
  "Use this before update_config_field to understand the current state and build upon it rather than overwriting blindly.",
@@ -1882,25 +2082,108 @@ server.registerTool("get_config_field", {
1882
2082
  const text = await handleResponse(resp);
1883
2083
  return { content: [{ type: "text", text }] };
1884
2084
  });
1885
- server.registerTool("update_config_field", {
2085
+ registerTool("update_config_field", {
1886
2086
  description: "Update a specific configuration field in the Bridge database. " +
1887
2087
  "These fields control LLM behavior during code review, planning, and documentation. " +
1888
2088
  "Always call get_config_field first to read the current value and build upon it. " +
1889
2089
  "Returns 400 if the field name is invalid, 404 if the repository has no configuration row yet.",
1890
2090
  inputSchema: {
1891
2091
  field_name: z.string().describe(`The configuration field to update. Valid options: ${VALID_CONFIG_FIELDS}`),
1892
- value: z.string().optional().describe("The new value for the configuration field. Provide either value or file_path, not both. " +
1893
- "Omit both value and file_path to set the field to NULL (clearing it)."),
2092
+ value: z.union([z.string(), z.boolean(), z.array(z.string())]).optional().describe("The new value for the configuration field. Provide either value or file_path, not both. " +
2093
+ "Most fields take a string; scalar boolean fields (e.g. allow_mutating_smoke_ops) take true/false. " +
2094
+ "The selected_mcp_slugs field takes a JSON array of supported MCP validation manual slug strings " +
2095
+ "(e.g. [\"b2c-commerce-developer\", \"playwright-mcp\", \"pwa-kit-mcp\"]) — pass an array of strings, " +
2096
+ "not a comma-delimited string; an empty array clears the selection. " +
2097
+ "For string fields, omit both value and file_path to set the field to NULL (clearing it). " +
2098
+ "Scalar boolean fields are NOT NULL and have no clear/null state: omitting the value writes false " +
2099
+ "(matching the API-layer coercion), so pass true/false explicitly."),
1894
2100
  file_path: z.string().optional().describe("Path to a local file whose contents will be used as the new value. " +
1895
2101
  "Useful for large configuration values like detailed review instructions. " +
1896
- "The file must be UTF-8 encoded and under 1MB."),
2102
+ "The file must be UTF-8 encoded and under 1MB. " +
2103
+ "Not supported for scalar boolean fields like allow_mutating_smoke_ops."),
1897
2104
  },
1898
2105
  }, async ({ field_name, value, file_path }) => {
2106
+ // JSONB array config fields (e.g. selected_mcp_slugs): forward the array value
2107
+ // as JSON. Never join into a comma-delimited string — the backend expects a
2108
+ // real JSON array and validates each slug against the mcp_docs allowlist.
2109
+ const ARRAY_CONFIG_FIELDS = ["selected_mcp_slugs"];
2110
+ if (ARRAY_CONFIG_FIELDS.includes(field_name)) {
2111
+ if (file_path) {
2112
+ return {
2113
+ isError: true,
2114
+ content: [{
2115
+ type: "text",
2116
+ text: JSON.stringify({
2117
+ error: `'${field_name}' is a JSON array field; file_path updates are not supported. Pass value as an array of slug strings.`,
2118
+ }),
2119
+ }],
2120
+ };
2121
+ }
2122
+ // An omitted value clears the selection (empty array). Any defined value is
2123
+ // forwarded verbatim as JSON so the backend can validate/reject it.
2124
+ const arrayValue = value === undefined ? [] : value;
2125
+ const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
2126
+ method: "PUT",
2127
+ headers: POST_HEADERS,
2128
+ body: JSON.stringify({ repo_name: REPO_NAME, value: arrayValue }),
2129
+ });
2130
+ const text = await handleResponse(resp);
2131
+ return { content: [{ type: "text", text }] };
2132
+ }
2133
+ // Scalar boolean config fields: reject file-path updates and normalize boolean
2134
+ // true/false and string "true"/"false" to a real boolean before persisting.
2135
+ const BOOLEAN_CONFIG_FIELDS = ["allow_mutating_smoke_ops"];
2136
+ if (BOOLEAN_CONFIG_FIELDS.includes(field_name)) {
2137
+ if (file_path) {
2138
+ return {
2139
+ isError: true,
2140
+ content: [{
2141
+ type: "text",
2142
+ text: JSON.stringify({
2143
+ error: `'${field_name}' is a scalar boolean field; file_path updates are not supported. Pass value: true or value: false.`,
2144
+ }),
2145
+ }],
2146
+ };
2147
+ }
2148
+ // NOT NULL boolean column: an omitted value writes false (no clear/null state),
2149
+ // intentionally aligned with the API-layer coercion in update_config_field_endpoint.
2150
+ let boolValue = false;
2151
+ if (typeof value === "boolean") {
2152
+ boolValue = value;
2153
+ }
2154
+ else if (typeof value === "string") {
2155
+ const normalized = value.trim().toLowerCase();
2156
+ if (normalized === "true") {
2157
+ boolValue = true;
2158
+ }
2159
+ else if (normalized === "false" || normalized === "") {
2160
+ boolValue = false;
2161
+ }
2162
+ else {
2163
+ return {
2164
+ isError: true,
2165
+ content: [{
2166
+ type: "text",
2167
+ text: JSON.stringify({
2168
+ error: `Invalid value for '${field_name}': '${value}'. Expected true or false.`,
2169
+ }),
2170
+ }],
2171
+ };
2172
+ }
2173
+ }
2174
+ const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
2175
+ method: "PUT",
2176
+ headers: POST_HEADERS,
2177
+ body: JSON.stringify({ repo_name: REPO_NAME, value: boolValue }),
2178
+ });
2179
+ const text = await handleResponse(resp);
2180
+ return { content: [{ type: "text", text }] };
2181
+ }
1899
2182
  // Allow omitting both value and file_path to set NULL
1900
2183
  let finalValue = null;
1901
2184
  let note = "";
1902
2185
  if (value || file_path) {
1903
- const resolved = await resolveTextOrFile(value, file_path, "value");
2186
+ const resolved = await resolveTextOrFile(typeof value === "string" ? value : undefined, file_path, "value");
1904
2187
  if (!resolved.ok)
1905
2188
  return resolved.errorResponse;
1906
2189
  finalValue = resolved.text;
@@ -1914,10 +2197,63 @@ server.registerTool("update_config_field", {
1914
2197
  const text = await handleResponse(resp);
1915
2198
  return { content: [{ type: "text", text: text + note }] };
1916
2199
  });
1917
- // ---------------------------------------------------------------------------
1918
- // Deep Research
1919
- // ---------------------------------------------------------------------------
1920
- server.registerTool("request_deep_research", {
2200
+ function formatDeepResearchProviderReason(meta) {
2201
+ if (!meta)
2202
+ return "";
2203
+ const parts = [];
2204
+ const reason = meta.incomplete_details?.reason;
2205
+ if (reason) {
2206
+ parts.push(`provider reason: ${reason}`);
2207
+ }
2208
+ const errMsg = meta.error?.message;
2209
+ const errCode = meta.error?.code;
2210
+ if (errMsg || errCode) {
2211
+ if (errCode && errMsg) {
2212
+ parts.push(`provider error: ${errCode}: ${errMsg}`);
2213
+ }
2214
+ else if (errMsg) {
2215
+ parts.push(`provider error: ${errMsg}`);
2216
+ }
2217
+ else if (errCode) {
2218
+ parts.push(`provider error: ${errCode}`);
2219
+ }
2220
+ }
2221
+ return parts.length ? ` (${parts.join("; ")})` : "";
2222
+ }
2223
+ function _safeIsoMs(value) {
2224
+ if (!value)
2225
+ return null;
2226
+ const ms = new Date(value).getTime();
2227
+ if (Number.isNaN(ms))
2228
+ return null;
2229
+ return ms;
2230
+ }
2231
+ function formatDeepResearchElapsed(createdAt, lastPollAt) {
2232
+ const createdMs = _safeIsoMs(createdAt);
2233
+ if (createdMs === null)
2234
+ return "";
2235
+ const now = Date.now();
2236
+ const startedMs = Math.max(0, now - createdMs);
2237
+ const startedMin = Math.floor(startedMs / 60_000);
2238
+ const lastPollMs = _safeIsoMs(lastPollAt);
2239
+ let pollSuffix = "";
2240
+ if (lastPollMs !== null) {
2241
+ const ageSec = Math.max(0, Math.floor((now - lastPollMs) / 1000));
2242
+ pollSuffix = `, last poll ${ageSec}s ago`;
2243
+ }
2244
+ return ` (running ${startedMin}m${pollSuffix})`;
2245
+ }
2246
+ function formatDeepResearchFailure(body) {
2247
+ const kind = body.error_kind || body.error_message || "Unknown error";
2248
+ const reason = formatDeepResearchProviderReason(body.provider_status_meta);
2249
+ return `Deep research failed: ${kind}${reason}. Consider using standard web searches to gather the information incrementally.`;
2250
+ }
2251
+ function formatDeepResearchStatus(body, taskId) {
2252
+ const elapsed = formatDeepResearchElapsed(body.created_at, body.last_poll_at);
2253
+ const reason = formatDeepResearchProviderReason(body.provider_status_meta);
2254
+ return `Status: ${body.status}${elapsed}${reason} (task_id: ${taskId}). Try again in a minute.`;
2255
+ }
2256
+ registerTool("request_deep_research", {
1921
2257
  description: "Submit a deep research request on a technical topic using AI-powered web search. " +
1922
2258
  "Returns a task_id for tracking the research progress. " +
1923
2259
  "\n\n" +
@@ -1981,6 +2317,7 @@ server.registerTool("request_deep_research", {
1981
2317
  const MAX_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
1982
2318
  let pollIntervalMs = 15_000; // start at 15 seconds
1983
2319
  let lastStatus = "queued";
2320
+ let latestStatusBody = null;
1984
2321
  while (Date.now() - startTime < MAX_TIMEOUT_MS) {
1985
2322
  await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
1986
2323
  const elapsed = Math.round((Date.now() - startTime) / 1000);
@@ -1993,15 +2330,15 @@ server.registerTool("request_deep_research", {
1993
2330
  }
1994
2331
  const statusBody = (await statusResp.json());
1995
2332
  lastStatus = statusBody.status;
2333
+ latestStatusBody = statusBody;
1996
2334
  if (lastStatus === "completed") {
1997
2335
  break;
1998
2336
  }
1999
2337
  if (lastStatus === "failed") {
2000
- const errorMsg = statusBody.error_message || "Unknown error";
2001
2338
  return {
2002
2339
  content: [{
2003
2340
  type: "text",
2004
- text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Deep research failed: ${errorMsg}. Consider using standard web searches to gather the information incrementally.` }),
2341
+ text: formatDeepResearchFailure(statusBody),
2005
2342
  }],
2006
2343
  };
2007
2344
  }
@@ -2011,10 +2348,13 @@ server.registerTool("request_deep_research", {
2011
2348
  }
2012
2349
  }
2013
2350
  if (lastStatus !== "completed") {
2351
+ const statusSuffix = latestStatusBody
2352
+ ? ` ${formatDeepResearchStatus(latestStatusBody, taskId)}`
2353
+ : "";
2014
2354
  return {
2015
2355
  content: [{
2016
2356
  type: "text",
2017
- text: JSON.stringify({ error: "GATEWAY_TIMEOUT", status: 504, message: `Deep research timed out after 15 minutes (task_id: ${taskId}). The task may still be processing on the server. Use get_deep_research with this task_id to check later, or use standard web searches to gather the information incrementally.` }),
2357
+ text: `Deep research timed out after 15 minutes (task_id: ${taskId}).${statusSuffix} The task may still be processing on the server. Use get_deep_research with this task_id to check later, or use standard web searches to gather the information incrementally.`,
2018
2358
  }],
2019
2359
  };
2020
2360
  }
@@ -2034,7 +2374,7 @@ server.registerTool("request_deep_research", {
2034
2374
  }
2035
2375
  return { content: [{ type: "text", text: resultText }] };
2036
2376
  });
2037
- server.registerTool("get_deep_research", {
2377
+ registerTool("get_deep_research", {
2038
2378
  description: "Retrieve the result of a previously submitted deep research request. " +
2039
2379
  "Returns the full markdown research report if the task is completed, " +
2040
2380
  "or a structured status response if still processing or failed. " +
@@ -2055,11 +2395,10 @@ server.registerTool("get_deep_research", {
2055
2395
  }
2056
2396
  const statusBody = (await statusResp.json());
2057
2397
  if (statusBody.status === "failed") {
2058
- const errorMsg = statusBody.error_message || "Unknown error";
2059
2398
  return {
2060
2399
  content: [{
2061
2400
  type: "text",
2062
- text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Deep research failed: ${errorMsg}. Consider using standard web searches to gather the information incrementally.` }),
2401
+ text: formatDeepResearchFailure(statusBody),
2063
2402
  }],
2064
2403
  };
2065
2404
  }
@@ -2067,7 +2406,7 @@ server.registerTool("get_deep_research", {
2067
2406
  return {
2068
2407
  content: [{
2069
2408
  type: "text",
2070
- text: JSON.stringify({ status: statusBody.status, task_id, message: `Deep research is still ${statusBody.status}. Try again in a minute.` }),
2409
+ text: formatDeepResearchStatus(statusBody, task_id),
2071
2410
  }],
2072
2411
  };
2073
2412
  }
@@ -2087,10 +2426,219 @@ server.registerTool("get_deep_research", {
2087
2426
  }
2088
2427
  return { content: [{ type: "text", text: resultText }] };
2089
2428
  });
2429
+ const BRAINSTORM_TERMINAL_STATUSES = new Set([
2430
+ "completed",
2431
+ "failed",
2432
+ "skipped",
2433
+ ]);
2434
+ function isBrainstormTerminalStatus(status) {
2435
+ return BRAINSTORM_TERMINAL_STATUSES.has(status);
2436
+ }
2437
+ function sanitizeProviderForFilename(provider) {
2438
+ // Defensive: allow letters/digits/hyphen/underscore only; collapse runs and
2439
+ // trim trailing punctuation. Falls back to "provider" for an empty result.
2440
+ const cleaned = provider
2441
+ .toLowerCase()
2442
+ .replace(/[^a-z0-9_-]+/g, "-")
2443
+ .replace(/-+/g, "-")
2444
+ .replace(/^-+|-+$/g, "");
2445
+ return cleaned || "provider";
2446
+ }
2447
+ async function pollBrainstormUntilTerminal(brainstormId, repoName) {
2448
+ const startTime = Date.now();
2449
+ const MAX_TIMEOUT_MS = 15 * 60 * 1000;
2450
+ let pollIntervalMs = 15_000;
2451
+ let latest = null;
2452
+ while (Date.now() - startTime < MAX_TIMEOUT_MS) {
2453
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
2454
+ const statusUrl = buildGetUrl(`/brainstorms/${brainstormId}/status`, { repo_name: repoName });
2455
+ const statusResp = await fetch(statusUrl, { headers: GET_HEADERS });
2456
+ if (!statusResp.ok) {
2457
+ return latest;
2458
+ }
2459
+ latest = (await statusResp.json());
2460
+ const allTerminal = latest.rows.every((row) => isBrainstormTerminalStatus(row.status));
2461
+ if (allTerminal) {
2462
+ return latest;
2463
+ }
2464
+ if (Date.now() - startTime > 60_000) {
2465
+ pollIntervalMs = 30_000;
2466
+ }
2467
+ }
2468
+ return latest;
2469
+ }
2470
+ async function saveBrainstormResultsLocally(envelope) {
2471
+ const dir = getDocsPath("brainstorm");
2472
+ const savedPaths = [];
2473
+ for (const row of envelope.results) {
2474
+ const markdown = row.markdown;
2475
+ if (!markdown) {
2476
+ continue;
2477
+ }
2478
+ const providerSegment = sanitizeProviderForFilename(row.provider);
2479
+ const filename = `${envelope.brainstorm_id}-${providerSegment}.md`;
2480
+ const filePath = path.join(dir, filename);
2481
+ try {
2482
+ await mkdir(dir, { recursive: true });
2483
+ await writeFile(filePath, markdown, "utf-8");
2484
+ savedPaths.push(filePath);
2485
+ }
2486
+ catch {
2487
+ // Skip rows that fail to write — never block the response.
2488
+ }
2489
+ }
2490
+ return savedPaths;
2491
+ }
2492
+ function formatBrainstormToolResponse(envelope, savedPaths) {
2493
+ const lines = [];
2494
+ lines.push(`# Brainstorm ${envelope.brainstorm_id}`);
2495
+ lines.push(`Repo: ${envelope.repo_name}`);
2496
+ lines.push("");
2497
+ for (const row of envelope.results) {
2498
+ lines.push(`## ${row.provider} — status: ${row.status}`);
2499
+ lines.push(`error_kind: ${row.error_kind ?? "null"}`);
2500
+ if (row.error_message) {
2501
+ lines.push(`error_message: ${row.error_message}`);
2502
+ }
2503
+ if (row.markdown) {
2504
+ lines.push("");
2505
+ lines.push(row.markdown);
2506
+ }
2507
+ lines.push("");
2508
+ }
2509
+ if (savedPaths.length > 0) {
2510
+ lines.push("---");
2511
+ lines.push("Saved files:");
2512
+ for (const p of savedPaths) {
2513
+ lines.push(`- ${p}`);
2514
+ }
2515
+ }
2516
+ return lines.join("\n");
2517
+ }
2518
+ registerTool("request_brainstorm", {
2519
+ description: "Submit a brainstorm request that fans out the task to two opinion-provider LLMs " +
2520
+ "(default: OpenAI + Gemini) and then runs a synthesizer pass over the completed opinions. " +
2521
+ "Returns a brainstorm_id you can use with get_brainstorm. " +
2522
+ "\n\n" +
2523
+ "BEHAVIOR: By default, returns immediately with a brainstorm_id. Set wait_for_result=true " +
2524
+ "to poll for terminal status (up to 15 minutes), then retrieve and optionally save the result. " +
2525
+ "When save_locally=true (default), each provider's markdown is written to " +
2526
+ "BAPI_DOCS_DIR/brainstorm/{brainstorm_id}-{provider}.md, including {brainstorm_id}-synthesizer.md.",
2527
+ inputSchema: {
2528
+ task_description: z.string().describe("Free-form description of the task to brainstorm about. Sent verbatim — " +
2529
+ "this tool does NOT read task_description from a file."),
2530
+ repo_name: z.string().optional().describe("Repository name. Defaults to BAPI_REPO_NAME from the environment."),
2531
+ ticket_number: z.string().optional().describe("Optional Jira ticket key (e.g. PROJ-123) to associate with the brainstorm. " +
2532
+ "Ticket 1 only stores this for cross-reference — no Jira writes happen."),
2533
+ providers: z.array(z.string()).optional().describe("Opinion-provider LLMs. Defaults to ['openai', 'gemini']. " +
2534
+ "A single-provider request still inserts a synthesizer row but pre-skips it."),
2535
+ concerns: z.string().optional().describe("Optional caller-supplied concerns to surface to the brainstorm agents."),
2536
+ wait_for_result: z.boolean().optional().describe("When true, polls until every row reaches a terminal status (max 15 minutes), " +
2537
+ "then returns the full result envelope. When false (default), returns immediately."),
2538
+ save_locally: z.boolean().optional().describe("When true (default), writes each provider's markdown to BAPI_DOCS_DIR/brainstorm/ " +
2539
+ "after the result is fetched."),
2540
+ prior_brainstorm_id: z.string().optional().describe("Optional brainstorm_id from an earlier brainstorm to refine. " +
2541
+ "When provided, the new brainstorm receives the prior brainstorm's " +
2542
+ "synthesizer markdown, or a completed opinion-provider fallback, as prior context."),
2543
+ },
2544
+ }, async ({ task_description, repo_name, ticket_number, providers, concerns, wait_for_result, save_locally, prior_brainstorm_id, }) => {
2545
+ const effectiveRepo = repo_name && repo_name.length > 0 ? repo_name : REPO_NAME;
2546
+ const effectiveProviders = providers !== undefined ? providers : ["openai", "gemini"];
2547
+ const shouldWait = wait_for_result === true;
2548
+ const shouldSave = save_locally !== false;
2549
+ const submitPayload = {
2550
+ repo_name: effectiveRepo,
2551
+ task_description,
2552
+ providers: effectiveProviders,
2553
+ };
2554
+ if (ticket_number)
2555
+ submitPayload.ticket_number = ticket_number;
2556
+ if (concerns)
2557
+ submitPayload.concerns = concerns;
2558
+ if (prior_brainstorm_id) {
2559
+ submitPayload.prior_brainstorm_request_id = prior_brainstorm_id;
2560
+ }
2561
+ const submitResp = await fetch(buildUrl("/brainstorms"), {
2562
+ method: "POST",
2563
+ headers: POST_HEADERS,
2564
+ body: JSON.stringify(submitPayload),
2565
+ });
2566
+ if (!submitResp.ok) {
2567
+ const errorText = await handleResponse(submitResp);
2568
+ return { content: [{ type: "text", text: errorText }] };
2569
+ }
2570
+ const submitBody = (await submitResp.json());
2571
+ if (!shouldWait) {
2572
+ const confirmation = `Brainstorm submitted (brainstorm_id: ${submitBody.brainstorm_id}). ` +
2573
+ `Providers: ${submitBody.providers.join(", ")}. ` +
2574
+ `Synthesizer status: ${submitBody.synthesizer_status}. ` +
2575
+ `Use get_brainstorm with brainstorm_id ${submitBody.brainstorm_id} to retrieve results.`;
2576
+ return { content: [{ type: "text", text: confirmation }] };
2577
+ }
2578
+ const finalStatus = await pollBrainstormUntilTerminal(submitBody.brainstorm_id, effectiveRepo);
2579
+ if (!finalStatus) {
2580
+ return {
2581
+ content: [{
2582
+ type: "text",
2583
+ text: `Brainstorm timed out before reaching terminal status ` +
2584
+ `(brainstorm_id: ${submitBody.brainstorm_id}). Use get_brainstorm later.`,
2585
+ }],
2586
+ };
2587
+ }
2588
+ const resultUrl = buildGetUrl(`/brainstorms/${submitBody.brainstorm_id}/result`, { repo_name: effectiveRepo });
2589
+ const resultResp = await fetch(resultUrl, { headers: GET_HEADERS });
2590
+ if (!resultResp.ok) {
2591
+ const errorText = await handleResponse(resultResp);
2592
+ return { content: [{ type: "text", text: errorText }] };
2593
+ }
2594
+ const envelope = (await resultResp.json());
2595
+ let savedPaths = [];
2596
+ if (shouldSave) {
2597
+ savedPaths = await saveBrainstormResultsLocally(envelope);
2598
+ }
2599
+ return {
2600
+ content: [{
2601
+ type: "text",
2602
+ text: formatBrainstormToolResponse(envelope, savedPaths),
2603
+ }],
2604
+ };
2605
+ });
2606
+ registerTool("get_brainstorm", {
2607
+ description: "Retrieve the result envelope for a previously submitted brainstorm by brainstorm_id. " +
2608
+ "Returns all rows (opinion providers + synthesizer), including error_kind for every row. " +
2609
+ "When save_locally=true (default), writes each provider's markdown to " +
2610
+ "BAPI_DOCS_DIR/brainstorm/{brainstorm_id}-{provider}.md (including the synthesizer file). " +
2611
+ "DB-only retrieval — never falls back to Jira attachments.",
2612
+ inputSchema: {
2613
+ brainstorm_id: z.string().describe("The brainstorm_id (UUID) returned by request_brainstorm."),
2614
+ repo_name: z.string().optional().describe("Repository name. Defaults to BAPI_REPO_NAME from the environment."),
2615
+ save_locally: z.boolean().optional().describe("When true (default), writes each provider's markdown to BAPI_DOCS_DIR/brainstorm/."),
2616
+ },
2617
+ }, async ({ brainstorm_id, repo_name, save_locally }) => {
2618
+ const effectiveRepo = repo_name && repo_name.length > 0 ? repo_name : REPO_NAME;
2619
+ const shouldSave = save_locally !== false;
2620
+ const resultUrl = buildGetUrl(`/brainstorms/${brainstorm_id}/result`, { repo_name: effectiveRepo });
2621
+ const resultResp = await fetch(resultUrl, { headers: GET_HEADERS });
2622
+ if (!resultResp.ok) {
2623
+ const errorText = await handleResponse(resultResp);
2624
+ return { content: [{ type: "text", text: errorText }] };
2625
+ }
2626
+ const envelope = (await resultResp.json());
2627
+ let savedPaths = [];
2628
+ if (shouldSave) {
2629
+ savedPaths = await saveBrainstormResultsLocally(envelope);
2630
+ }
2631
+ return {
2632
+ content: [{
2633
+ type: "text",
2634
+ text: formatBrainstormToolResponse(envelope, savedPaths),
2635
+ }],
2636
+ };
2637
+ });
2090
2638
  // ---------------------------------------------------------------------------
2091
2639
  // VCS & CI Tools
2092
2640
  // ---------------------------------------------------------------------------
2093
- server.registerTool("create_pull_request", {
2641
+ registerTool("create_pull_request", {
2094
2642
  description: "Create a pull request on the configured VCS provider (GitHub or Bitbucket). " +
2095
2643
  "Returns a structured response with {available, reason, action, detail}. " +
2096
2644
  "If a PR already exists for the head branch, returns it with created=false. " +
@@ -2119,7 +2667,7 @@ server.registerTool("create_pull_request", {
2119
2667
  const text = await handleResponse(resp);
2120
2668
  return { content: [{ type: "text", text }] };
2121
2669
  });
2122
- const resolveCiChecksTool = server.registerTool("resolve_ci_checks", {
2670
+ const resolveCiChecksTool = registerTool("resolve_ci_checks", {
2123
2671
  description: "Discover and classify CI checks for the configured repository. " +
2124
2672
  "Queries GitHub Check Runs + Commit Statuses APIs (or Bitbucket Build Statuses), " +
2125
2673
  "then uses Branch Protection API or LLM to determine which checks are required for merging. " +
@@ -2155,7 +2703,7 @@ const resolveCiChecksTool = server.registerTool("resolve_ci_checks", {
2155
2703
  }
2156
2704
  return { content: [{ type: "text", text }] };
2157
2705
  });
2158
- const pollCiChecksTool = server.registerTool("poll_ci_checks", {
2706
+ const pollCiChecksTool = registerTool("poll_ci_checks", {
2159
2707
  description: "Poll the current status of CI checks for a specific commit. " +
2160
2708
  "Requires that resolve_ci_checks has been called first to populate the check configuration. " +
2161
2709
  "Returns per-check status, all_complete, all_passed, and unknown_checks fields. " +
@@ -2204,9 +2752,30 @@ async function checkCiConfigAndDisablePoll() {
2204
2752
  // Check config before connecting
2205
2753
  await checkCiConfigAndDisablePoll();
2206
2754
  // ---------------------------------------------------------------------------
2755
+ // Custom pipeline loading (BAPI-275)
2756
+ // ---------------------------------------------------------------------------
2757
+ //
2758
+ // Custom user pipelines must be merged into PIPELINES + INSTRUCTIONS BEFORE
2759
+ // the run_pipeline tool is registered so its embedded catalog includes both
2760
+ // bundled and user entries. The merge result is also used by list_pipelines
2761
+ // and get_pipeline_recipe below — every caller must see the same final
2762
+ // objects.
2763
+ {
2764
+ const instructionsDir = path.join(path.dirname(BAPI_PIPELINES_DIR), "instructions");
2765
+ const customResult = await loadCustomPipelines(BAPI_PIPELINES_DIR, instructionsDir, BUNDLED_INSTRUCTIONS);
2766
+ for (const [key, pipeline] of Object.entries(customResult.pipelines)) {
2767
+ if (key in BUNDLED_PIPELINES) {
2768
+ console.error(`Warning: user pipeline "${key}" overrides bundled pipeline.`);
2769
+ }
2770
+ PIPELINES[key] = pipeline;
2771
+ }
2772
+ Object.assign(INSTRUCTIONS, customResult.instructions);
2773
+ userPipelineKeys = customResult.userPipelineKeys;
2774
+ }
2775
+ // ---------------------------------------------------------------------------
2207
2776
  // Pipeline Recipe Tools
2208
2777
  // ---------------------------------------------------------------------------
2209
- server.registerTool("get_docs_dir", {
2778
+ registerTool("get_docs_dir", {
2210
2779
  description: "Return the locally configured docs directory path (BAPI_DOCS_DIR, default docs/tmp). " +
2211
2780
  "No parameters. Use this instead of reading the BAPI_DOCS_DIR environment variable directly, " +
2212
2781
  "which requires shell access and may be blocked on some AI coding platforms.",
@@ -2214,7 +2783,7 @@ server.registerTool("get_docs_dir", {
2214
2783
  }, async () => {
2215
2784
  return { content: [{ type: "text", text: BAPI_DOCS_DIR }] };
2216
2785
  });
2217
- server.registerTool("list_pipelines", {
2786
+ registerTool("list_pipelines", {
2218
2787
  description: "List all available pipeline recipes with their names, descriptions, and required variables. " +
2219
2788
  "No parameters. Use this to discover available pipelines before calling get_pipeline_recipe.",
2220
2789
  inputSchema: {},
@@ -2229,7 +2798,7 @@ server.registerTool("list_pipelines", {
2229
2798
  content: [{ type: "text", text: JSON.stringify(list, null, 2) }],
2230
2799
  };
2231
2800
  });
2232
- server.registerTool("get_pipeline_recipe", {
2801
+ registerTool("get_pipeline_recipe", {
2233
2802
  description: "Retrieve a fully resolved pipeline recipe by name. Substitutes variables, resolves instruction " +
2234
2803
  "file references to inline content, and returns an ordered array of executable steps. " +
2235
2804
  "Each step is either an mcp_call (with tool name and params) or an agent_task (with instruction text). " +
@@ -2247,8 +2816,16 @@ server.registerTool("get_pipeline_recipe", {
2247
2816
  .array(z.string())
2248
2817
  .optional()
2249
2818
  .describe("Step tool names or descriptions to omit from the recipe"),
2819
+ auto_approve: z
2820
+ .boolean()
2821
+ .optional()
2822
+ .describe("When true, automatically approve all approval-gated steps. " +
2823
+ "For implement-ticket this skips the commit/push approval pause; " +
2824
+ "for review-ticket this skips the HTML decision page and selects " +
2825
+ "each item's recommended option. Pass via this top-level parameter, " +
2826
+ "not via the variables map."),
2250
2827
  },
2251
- }, async ({ pipeline: pipelineName, variables, skip_steps }) => {
2828
+ }, async ({ pipeline: pipelineName, variables, skip_steps, auto_approve }) => {
2252
2829
  const pipelineDef = PIPELINES[pipelineName];
2253
2830
  if (!pipelineDef) {
2254
2831
  const available = Object.keys(PIPELINES).join(", ");
@@ -2263,9 +2840,27 @@ server.registerTool("get_pipeline_recipe", {
2263
2840
  }],
2264
2841
  };
2265
2842
  }
2843
+ if (variables && "auto_approve" in variables) {
2844
+ return {
2845
+ content: [{
2846
+ type: "text",
2847
+ text: JSON.stringify({
2848
+ error: ERROR_CODES[400] ?? "BAD_REQUEST",
2849
+ status: 400,
2850
+ message: "Pass auto_approve via the top-level parameter, not via the variables map.",
2851
+ }),
2852
+ }],
2853
+ };
2854
+ }
2266
2855
  try {
2267
- const mergedVariables = { docs_dir: BAPI_DOCS_DIR, provider: "", second_opinion: "", ...(variables ?? {}) };
2268
- const recipe = resolveRecipe(pipelineDef, INSTRUCTIONS, mergedVariables, skip_steps);
2856
+ const mergedVariables = {
2857
+ docs_dir: BAPI_DOCS_DIR,
2858
+ provider: "",
2859
+ second_opinion: "",
2860
+ auto_approve: auto_approve ? "true" : "",
2861
+ ...(variables ?? {}),
2862
+ };
2863
+ const recipe = resolveRecipe(pipelineDef, INSTRUCTIONS, mergedVariables, skip_steps, !!auto_approve);
2269
2864
  return {
2270
2865
  content: [{
2271
2866
  type: "text",
@@ -2287,9 +2882,148 @@ server.registerTool("get_pipeline_recipe", {
2287
2882
  }
2288
2883
  });
2289
2884
  // ---------------------------------------------------------------------------
2885
+ // Pipeline Execution Tools (BAPI-275)
2886
+ // ---------------------------------------------------------------------------
2887
+ //
2888
+ // run_pipeline / resume_pipeline / list_pipeline_runs promote pipelines from
2889
+ // declarative JSON recipes into first-class callable operations. They share
2890
+ // a unified response envelope (status: completed | needs_agent_task | failed)
2891
+ // with a strict error_code enum (VALIDATION | NOT_FOUND | EXPIRED |
2892
+ // REPO_MISMATCH | TOOL_ERROR). Pipeline state is persisted server-side; the
2893
+ // idle TTL defaults to 24 hours and is auto-extended on every state
2894
+ // transition.
2895
+ function buildPipelineCatalogDescription() {
2896
+ const lines = [];
2897
+ for (const [key, pipeline] of Object.entries(PIPELINES)) {
2898
+ const source = userPipelineKeys.has(key) ? " (user)" : "";
2899
+ const desc = pipeline.description ?? "";
2900
+ lines.push(`- ${key}${source} — ${desc}`);
2901
+ }
2902
+ return lines.join("\n");
2903
+ }
2904
+ function buildPipelineOrchestratorDeps() {
2905
+ return {
2906
+ baseUrl: BASE_URL,
2907
+ apiKey: API_KEY,
2908
+ repoName: REPO_NAME,
2909
+ docsDir: BAPI_DOCS_DIR,
2910
+ pipelines: PIPELINES,
2911
+ instructions: INSTRUCTIONS,
2912
+ toolHandlers: TOOL_HANDLERS,
2913
+ };
2914
+ }
2915
+ registerTool("run_pipeline", {
2916
+ description: "Execute a Bridge API pipeline by name. The orchestrator runs steps sequentially, " +
2917
+ "dispatching mcp_call steps in-process and pausing on agent_task steps with a " +
2918
+ "needs_agent_task envelope. Returns a unified envelope keyed on `status`: " +
2919
+ "`completed` (terminal success with `results`), `needs_agent_task` (pause — read " +
2920
+ "`instruction`, perform the task, then call `resume_pipeline` with the resulting " +
2921
+ "string as `agent_result`), or `failed` (terminal error — check `error_code`: " +
2922
+ "VALIDATION | NOT_FOUND | EXPIRED | REPO_MISMATCH | TOOL_ERROR). " +
2923
+ "Paused runs auto-expire after an idle TTL (default 24 hours; override with " +
2924
+ "`ttl_seconds`). The TTL is reset on every state transition.\n\n" +
2925
+ "Available pipelines:\n" +
2926
+ buildPipelineCatalogDescription(),
2927
+ inputSchema: {
2928
+ pipeline: z
2929
+ .string()
2930
+ .describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),
2931
+ variables: z
2932
+ .record(z.string())
2933
+ .optional()
2934
+ .describe("Key-value pairs for variable substitution (e.g. { ticket_key: 'BAPI-123' }). " +
2935
+ "Do NOT pass `auto_approve` here — use the top-level parameter."),
2936
+ auto_approve: z
2937
+ .union([z.boolean(), z.literal("true"), z.literal("false")])
2938
+ .optional()
2939
+ .describe("When true, approval-gated mcp_call steps execute directly. When false or " +
2940
+ "omitted, the orchestrator synthesises a needs_agent_task pause so the agent " +
2941
+ "can confirm with the user before resuming. Accepts boolean or 'true'/'false' " +
2942
+ "strings for MCP clients that serialize booleans as strings."),
2943
+ ttl_seconds: z
2944
+ .number()
2945
+ .int()
2946
+ .positive()
2947
+ .optional()
2948
+ .describe("Override the default 24-hour idle TTL for this run. Must be a positive integer."),
2949
+ },
2950
+ }, async (input) => {
2951
+ const result = await runPipeline(buildPipelineOrchestratorDeps(), input);
2952
+ return {
2953
+ content: [
2954
+ { type: "text", text: JSON.stringify(result, null, 2) },
2955
+ ],
2956
+ };
2957
+ });
2958
+ registerTool("resume_pipeline", {
2959
+ description: "Resume a paused pipeline run with the result of the agent_task. Provide the " +
2960
+ "`pipeline_run_id` returned by the prior needs_agent_task envelope, and the string " +
2961
+ "the instruction's `## Return` section asked you to produce as `agent_result`. " +
2962
+ "`agent_result` is always a string — do not wrap it in JSON unless the instruction " +
2963
+ "explicitly asked you to serialize structured output. Returns the same unified " +
2964
+ "envelope shape as `run_pipeline`.",
2965
+ inputSchema: {
2966
+ pipeline_run_id: z
2967
+ .string()
2968
+ .describe("The pipeline_run_id returned by a prior needs_agent_task envelope"),
2969
+ agent_result: z
2970
+ .string()
2971
+ .describe("The string the paused instruction's ## Return section asked you to produce"),
2972
+ },
2973
+ }, async (input) => {
2974
+ const result = await resumePipeline(buildPipelineOrchestratorDeps(), input);
2975
+ return {
2976
+ content: [
2977
+ { type: "text", text: JSON.stringify(result, null, 2) },
2978
+ ],
2979
+ };
2980
+ });
2981
+ registerTool("list_pipeline_runs", {
2982
+ description: "List recent pipeline runs for the configured repository, newest first. Returns " +
2983
+ "metadata only — `resolved_recipe`, resolved params, instruction text, results, " +
2984
+ "and agent outputs are intentionally excluded. Use this to recover a " +
2985
+ "`pipeline_run_id` when an earlier needs_agent_task envelope is no longer in " +
2986
+ "scope (e.g. after compaction or a client restart). " +
2987
+ "Optionally filter by `status`: running | paused | completed | failed | expired.",
2988
+ inputSchema: {
2989
+ status: z
2990
+ .enum(["running", "paused", "completed", "failed", "expired"])
2991
+ .optional()
2992
+ .describe("Optional status filter"),
2993
+ },
2994
+ }, async (input) => {
2995
+ const result = await listPipelineRuns(buildPipelineOrchestratorDeps(), input);
2996
+ return {
2997
+ content: [
2998
+ { type: "text", text: JSON.stringify(result, null, 2) },
2999
+ ],
3000
+ };
3001
+ });
3002
+ registerTool("delete_pipeline_run", {
3003
+ description: "Delete a pipeline run row (any status). Use this to discard orphaned `running` " +
3004
+ "rows from a previous session that can't be resumed (resume_pipeline only accepts " +
3005
+ "`paused`), to clean up after a failed run, or to remove a no-longer-needed paused " +
3006
+ "session. Returns `{ status: 'completed', deleted: true, pipeline_run_id }` on " +
3007
+ "success, or a `failed` envelope with error_code in (VALIDATION | NOT_FOUND | " +
3008
+ "REPO_MISMATCH | TOOL_ERROR). Repo-scoped: the row's stored repo_name must match " +
3009
+ "the caller's repo.",
3010
+ inputSchema: {
3011
+ pipeline_run_id: z
3012
+ .string()
3013
+ .describe("UUID of the pipeline run to delete."),
3014
+ },
3015
+ }, async (input) => {
3016
+ const result = await deletePipelineRun(buildPipelineOrchestratorDeps(), input);
3017
+ return {
3018
+ content: [
3019
+ { type: "text", text: JSON.stringify(result, null, 2) },
3020
+ ],
3021
+ };
3022
+ });
3023
+ // ---------------------------------------------------------------------------
2290
3024
  // generate_decision_page
2291
3025
  // ---------------------------------------------------------------------------
2292
- server.registerTool("generate_decision_page", {
3026
+ registerTool("generate_decision_page", {
2293
3027
  description: "Generate a local HTML decision page for capturing user decisions on ticket review findings. " +
2294
3028
  "Renders recommendation-driven review decisions sourced from the combined review-and-resolution " +
2295
3029
  "document, with per-option consequence lines, a closed-by-default codebase-evidence disclosure, " +
@@ -2385,17 +3119,6 @@ server.registerTool("generate_decision_page", {
2385
3119
  // ---------------------------------------------------------------------------
2386
3120
  // Entry point
2387
3121
  // ---------------------------------------------------------------------------
2388
- // Load custom user pipelines before accepting connections
2389
- const instructionsDir = path.join(path.dirname(BAPI_PIPELINES_DIR), "instructions");
2390
- const customResult = await loadCustomPipelines(BAPI_PIPELINES_DIR, instructionsDir, BUNDLED_INSTRUCTIONS);
2391
- for (const [key, pipeline] of Object.entries(customResult.pipelines)) {
2392
- if (key in BUNDLED_PIPELINES) {
2393
- console.error(`Warning: user pipeline "${key}" overrides bundled pipeline.`);
2394
- }
2395
- PIPELINES[key] = pipeline;
2396
- }
2397
- Object.assign(INSTRUCTIONS, customResult.instructions);
2398
- userPipelineKeys = customResult.userPipelineKeys;
2399
3122
  const transport = new StdioServerTransport();
2400
3123
  await server.connect(transport);
2401
3124
  console.error("Bridge API MCP server running on stdio");