@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.
package/build/index.js CHANGED
@@ -24,6 +24,8 @@ 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
26
  import { generateDecisionPageHtml } from "./decision-page-template.js";
27
+ import { DecisionPageInputShape } from "./decision-page-schema.js";
28
+ import { runPipeline, resumePipeline, listPipelineRuns, deletePipelineRun, } from "./pipeline-orchestrator.js";
27
29
  // Mutable pipeline/instruction state — starts with bundled, merged with user at startup
28
30
  const PIPELINES = { ...BUNDLED_PIPELINES };
29
31
  const INSTRUCTIONS = { ...BUNDLED_INSTRUCTIONS };
@@ -48,6 +50,9 @@ const POST_HEADERS = {
48
50
  function buildUrl(path) {
49
51
  return `${BASE_URL.replace(/\/+$/, "")}/jira${path}`;
50
52
  }
53
+ function buildApiUrl(path) {
54
+ return `${BASE_URL.replace(/\/+$/, "")}${path}`;
55
+ }
51
56
  function buildGetUrl(path, params) {
52
57
  const url = new URL(buildUrl(path));
53
58
  for (const [key, value] of Object.entries(params)) {
@@ -517,6 +522,32 @@ automatically provided by the server.
517
522
 
518
523
  - \`"halt"\` (default) — stop the pipeline immediately on failure
519
524
  - \`"warn_and_continue"\` — log a warning and proceed to the next step
525
+
526
+ ## Executing Pipelines
527
+
528
+ Pipelines defined here can be executed end-to-end via the \`run_pipeline\` MCP
529
+ tool. \`run_pipeline\` returns a unified envelope keyed on \`status\`:
530
+
531
+ - \`completed\` — the pipeline finished and \`results\` holds the per-step output.
532
+ - \`needs_agent_task\` — the orchestrator paused on an \`agent_task\` step (or an
533
+ approval-gated \`mcp_call\` when \`auto_approve\` was false). Perform the task
534
+ described by \`instruction\`, then call \`resume_pipeline\` with the
535
+ \`pipeline_run_id\` and the resulting string as \`agent_result\`.
536
+ - \`failed\` — terminal failure; \`error_code\` is one of \`VALIDATION\`,
537
+ \`NOT_FOUND\`, \`EXPIRED\`, \`REPO_MISMATCH\`, or \`TOOL_ERROR\`.
538
+
539
+ Paused runs auto-expire after an idle TTL (default 24 hours, override via
540
+ \`ttl_seconds\` on \`run_pipeline\`). The TTL is reset on every state transition.
541
+ Use \`list_pipeline_runs\` to recover a \`pipeline_run_id\` if the prior
542
+ \`needs_agent_task\` envelope is no longer in scope.
543
+
544
+ ## \`agent_task\` Instruction Files
545
+
546
+ When you reference an instruction file (\`instruction_file: "…"\`), the file
547
+ markdown MUST end with a terminal \`## Return\` H2 section describing what the
548
+ agent should pass back as \`resume_pipeline.agent_result\`. The return value
549
+ is a string — do NOT ask the agent to wrap it in JSON unless the instruction
550
+ body explicitly says to serialize structured output.
520
551
  `;
521
552
  const exampleContent = JSON.stringify({
522
553
  name: "Example Pipeline",
@@ -645,9 +676,70 @@ const server = new McpServer({
645
676
  version: "1.0.0",
646
677
  });
647
678
  // ---------------------------------------------------------------------------
679
+ // Tool registration wrapper (BAPI-275)
680
+ // ---------------------------------------------------------------------------
681
+ //
682
+ // Every MCP tool registered through ``registerTool`` is also recorded in
683
+ // ``TOOL_HANDLERS`` so the new pipeline orchestrator can dispatch existing
684
+ // tools in-process (without round-tripping through stdio). The wrapper
685
+ // preserves the exact return value of ``server.registerTool`` — including
686
+ // its ``.enable()`` / ``.disable()`` methods — and keeps a closure-local
687
+ // ``active`` flag in sync with them, so disabling a tool through the SDK
688
+ // also blocks in-process dispatch through ``TOOL_HANDLERS``.
689
+ const TOOL_HANDLERS = new Map();
690
+ // The wrapper preserves the SDK's generic type signature so existing call
691
+ // sites keep their per-tool input-type inference.
692
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
693
+ const registerTool = ((name, config, handler) => {
694
+ let active = true;
695
+ const wrappedHandler = async (args, extra) => {
696
+ if (!active) {
697
+ return {
698
+ content: [
699
+ {
700
+ type: "text",
701
+ text: JSON.stringify({
702
+ error: "TOOL_DISABLED",
703
+ status: 503,
704
+ message: `Tool "${name}" is currently disabled.`,
705
+ }),
706
+ },
707
+ ],
708
+ };
709
+ }
710
+ return await handler(args, extra);
711
+ };
712
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
713
+ const sdkRegister = server.registerTool.bind(server);
714
+ const toolHandle = sdkRegister(name, config, wrappedHandler);
715
+ const inProcessHandler = async (params) => {
716
+ return await wrappedHandler(params);
717
+ };
718
+ TOOL_HANDLERS.set(name, {
719
+ handler: inProcessHandler,
720
+ isEnabled: () => active,
721
+ });
722
+ // Wrap enable/disable to keep the closure flag in sync with SDK state.
723
+ if (toolHandle && typeof toolHandle.enable === "function") {
724
+ const sdkEnable = toolHandle.enable.bind(toolHandle);
725
+ toolHandle.enable = () => {
726
+ active = true;
727
+ return sdkEnable();
728
+ };
729
+ }
730
+ if (toolHandle && typeof toolHandle.disable === "function") {
731
+ const sdkDisable = toolHandle.disable.bind(toolHandle);
732
+ toolHandle.disable = () => {
733
+ active = false;
734
+ return sdkDisable();
735
+ };
736
+ }
737
+ return toolHandle;
738
+ });
739
+ // ---------------------------------------------------------------------------
648
740
  // Tools
649
741
  // ---------------------------------------------------------------------------
650
- server.registerTool("ping", {
742
+ registerTool("ping", {
651
743
  description: "Test connectivity to Bridge API. Validates that the API key is accepted and the configured repository is accessible. " +
652
744
  "Returns JSON with {status: 'ok', repo_name: '<configured repo>'}. " +
653
745
  "Use this as a quick health check before other operations, or to verify your Bridge API configuration is working. " +
@@ -660,7 +752,42 @@ server.registerTool("ping", {
660
752
  const text = await handleResponse(resp);
661
753
  return { content: [{ type: "text", text }] };
662
754
  });
663
- server.registerTool("get_project_standards", {
755
+ registerTool("second_opinion", {
756
+ description: "Consult a different LLM model family for a sanity check or technical pushback on a recommendation, plan, or analysis you have already produced. " +
757
+ "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. " +
758
+ "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'). " +
759
+ "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. " +
760
+ "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. " +
761
+ "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. " +
762
+ "Project authorization uses the standard repo access check; per-project provider gating is intentionally not enforced for this tool.",
763
+ inputSchema: {
764
+ prompt: z
765
+ .string()
766
+ .describe("The complete, self-contained brief to send to the second-opinion model. " +
767
+ "Include the full plan, recommendation, analysis, or question you want challenged, plus enough context for the responder to evaluate it independently. " +
768
+ "This is sent as the user message; the server constructs the system prompt."),
769
+ provider: z
770
+ .enum(["anthropic", "openai", "gemini"])
771
+ .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."),
772
+ model: z
773
+ .enum(["CHEAP_MODEL", "BASIC_MODEL", "PREMIUM_MODEL"])
774
+ .describe("Model tier within the chosen provider. CHEAP_MODEL for quick sanity checks, BASIC_MODEL for focused reviews, PREMIUM_MODEL for serious architectural pushback."),
775
+ },
776
+ }, async ({ prompt, provider, model }) => {
777
+ const resp = await fetch(buildApiUrl("/llm/second-opinion"), {
778
+ method: "POST",
779
+ headers: POST_HEADERS,
780
+ body: JSON.stringify({
781
+ repo_name: REPO_NAME,
782
+ prompt,
783
+ provider,
784
+ model,
785
+ }),
786
+ });
787
+ const text = await handleResponse(resp);
788
+ return { content: [{ type: "text", text }] };
789
+ });
790
+ registerTool("get_project_standards", {
664
791
  description: "Retrieve project-specific coding standards, architecture guidelines, testing standards, code review standards, and project context (platform, version, project description) for the configured repository. " +
665
792
  "Returns structured markdown with sections for project context, architecture instructions, code review correctness standards, testing stack information, and build analysis. " +
666
793
  "Only sections with configured values are included. Returns 404 if no standards are configured. " +
@@ -672,7 +799,7 @@ server.registerTool("get_project_standards", {
672
799
  const text = await handleResponse(resp);
673
800
  return { content: [{ type: "text", text }] };
674
801
  });
675
- server.registerTool("get_tickets", {
802
+ registerTool("get_tickets", {
676
803
  description: "Search for and list Jira tickets from the configured project. " +
677
804
  "Filters by query text, status name, or date. Returns up to 'limit' tickets ordered by most recently updated. " +
678
805
  "All data is fetched live from Jira. Use get_ticket to retrieve full details for a specific ticket.",
@@ -719,7 +846,7 @@ server.registerTool("get_tickets", {
719
846
  const text = await handleResponse(resp);
720
847
  return { content: [{ type: "text", text }] };
721
848
  });
722
- server.registerTool("get_ticket", {
849
+ registerTool("get_ticket", {
723
850
  description: "Retrieve full details for a single Jira ticket by its key. " +
724
851
  "Returns summary, status, type, assignee, reporter, description, and timestamps. " +
725
852
  "All data is fetched live from Jira. Use get_tickets to search/list multiple tickets.",
@@ -734,7 +861,7 @@ server.registerTool("get_ticket", {
734
861
  const text = await handleResponse(resp);
735
862
  return { content: [{ type: "text", text }] };
736
863
  });
737
- server.registerTool("create_ticket", {
864
+ registerTool("create_ticket", {
738
865
  description: "Create a new Jira ticket in the configured project. Requires either description or file_path (or both — file_path takes precedence). " +
739
866
  "Returns JSON with {ticket_key: 'PROJ-123', url: 'https://...'}. " +
740
867
  "The ticket is created immediately in Jira — confirm details with the user before calling. " +
@@ -776,7 +903,7 @@ server.registerTool("create_ticket", {
776
903
  const text = await createTicketRequest({ summary, description: resolved.text, issue_type, priority, labels, assignee });
777
904
  return { content: [{ type: "text", text: text + resolved.note }] };
778
905
  });
779
- server.registerTool("get_plan", {
906
+ registerTool("get_plan", {
780
907
  description: "Retrieve the AI-generated implementation plan for a Jira ticket. " +
781
908
  "Returns the full plan as markdown text — present it verbatim without summarizing. " +
782
909
  "The plan includes step-by-step implementation guidance with code file references. " +
@@ -804,7 +931,7 @@ server.registerTool("get_plan", {
804
931
  }
805
932
  return { content: [{ type: "text", text }] };
806
933
  });
807
- server.registerTool("get_architecture", {
934
+ registerTool("get_architecture", {
808
935
  description: "Retrieve the AI-generated architecture plan for a Jira ticket. " +
809
936
  "Returns the full architecture plan as markdown text — present it verbatim without summarizing. " +
810
937
  "The plan includes high-level architectural decisions, component design, and integration guidance. " +
@@ -832,7 +959,7 @@ server.registerTool("get_architecture", {
832
959
  }
833
960
  return { content: [{ type: "text", text }] };
834
961
  });
835
- server.registerTool("get_clarifying_questions", {
962
+ registerTool("get_clarifying_questions", {
836
963
  description: "Retrieve AI-generated clarifying questions (for feature/task tickets) or debugging guidance (for bug tickets) for a Jira ticket. " +
837
964
  "Returns markdown text with questions that should be resolved before implementation begins. " +
838
965
  "Returns 404 if no questions have been generated yet. " +
@@ -859,7 +986,7 @@ server.registerTool("get_clarifying_questions", {
859
986
  }
860
987
  return { content: [{ type: "text", text }] };
861
988
  });
862
- server.registerTool("parse_repository", {
989
+ registerTool("parse_repository", {
863
990
  description: "Queue a background job to parse and index the repository for Bridge API's AI agents. " +
864
991
  "This should be run after major codebase changes so that plans and questions reflect the latest code. " +
865
992
  "Returns 202 with {message: 'Repository parsing queued'} on success, " +
@@ -885,7 +1012,7 @@ server.registerTool("parse_repository", {
885
1012
  const text = await handleResponse(resp);
886
1013
  return { content: [{ type: "text", text }] };
887
1014
  });
888
- server.registerTool("regenerate_directory_map", {
1015
+ registerTool("regenerate_directory_map", {
889
1016
  description: "Regenerate the repository directory map and return the result. " +
890
1017
  "Unlike parse_repository (which is async), this tool is synchronous — it blocks until " +
891
1018
  "the directory map is generated and returns the full map text directly. " +
@@ -909,7 +1036,7 @@ server.registerTool("regenerate_directory_map", {
909
1036
  clearTimeout(timeout);
910
1037
  }
911
1038
  });
912
- server.registerTool("get_parse_status", {
1039
+ registerTool("get_parse_status", {
913
1040
  description: "Check whether a repository parse job is currently running. " +
914
1041
  "Returns {status: 'in_progress', started_at: '<ISO timestamp>'} if a parse is active, " +
915
1042
  "or {status: 'idle'} if no parse is running. " +
@@ -922,7 +1049,7 @@ server.registerTool("get_parse_status", {
922
1049
  const text = await handleResponse(resp);
923
1050
  return { content: [{ type: "text", text }] };
924
1051
  });
925
- server.registerTool("add_comment", {
1052
+ registerTool("add_comment", {
926
1053
  description: "Post a comment on a Jira ticket. The comment appears immediately in Jira. " +
927
1054
  "Supports markdown formatting. " +
928
1055
  "For long comments (over ~2000 characters), set attach_as_file to true — " +
@@ -976,7 +1103,7 @@ server.registerTool("add_comment", {
976
1103
  const text = await handleResponse(resp);
977
1104
  return { content: [{ type: "text", text: text + resolved.note }] };
978
1105
  });
979
- server.registerTool("update_ticket_description", {
1106
+ registerTool("update_ticket_description", {
980
1107
  description: "Update the description of an existing Jira ticket. This is a direct, synchronous update that overwrites the existing description with the provided text. " +
981
1108
  "The description should be in markdown format — it will be automatically converted to Jira wiki markup. " +
982
1109
  "This does NOT create a new ticket. Use create_ticket for that. " +
@@ -1008,7 +1135,7 @@ server.registerTool("update_ticket_description", {
1008
1135
  const text = await handleResponse(resp);
1009
1136
  return { content: [{ type: "text", text: text + resolved.note }] };
1010
1137
  });
1011
- server.registerTool("upload_attachment", {
1138
+ registerTool("upload_attachment", {
1012
1139
  description: "Upload a local file as an attachment to a Jira ticket. " +
1013
1140
  "Supports text/UTF-8 files only (markdown, plain text, etc.). " +
1014
1141
  "Optionally syncs the content to Bridge API's tickets_links table so retrieval endpoints " +
@@ -1071,7 +1198,7 @@ server.registerTool("upload_attachment", {
1071
1198
  const text = await handleResponse(resp);
1072
1199
  return { content: [{ type: "text", text: text + resolved.note }] };
1073
1200
  });
1074
- server.registerTool("list_attachments", {
1201
+ registerTool("list_attachments", {
1075
1202
  description: "List attachments on a Jira ticket. " +
1076
1203
  "Returns metadata (ID, filename, MIME type, size, created date) for each attachment. " +
1077
1204
  "By default, AI-generated attachments are excluded. " +
@@ -1096,7 +1223,7 @@ server.registerTool("list_attachments", {
1096
1223
  return { content: [{ type: "text", text }] };
1097
1224
  });
1098
1225
  const MAX_INLINE_TEXT_LENGTH = 50_000;
1099
- server.registerTool("download_attachment", {
1226
+ registerTool("download_attachment", {
1100
1227
  description: "Download an attachment from a Jira ticket and save it to disk. " +
1101
1228
  "Specify either attachment_id or filename (not both). " +
1102
1229
  "For text files, the content is returned inline (truncated at ~50KB) and also saved to disk. " +
@@ -1184,7 +1311,7 @@ server.registerTool("download_attachment", {
1184
1311
  }
1185
1312
  return { content: [{ type: "text", text: resultText }] };
1186
1313
  });
1187
- server.registerTool("request_plan_generation", {
1314
+ registerTool("request_plan_generation", {
1188
1315
  description: "Request AI-generated implementation plan for a Jira ticket. " +
1189
1316
  "This triggers an asynchronous background job — results are NOT immediate. " +
1190
1317
  "Processing typically takes 1-5 minutes depending on ticket complexity and number of attachments. " +
@@ -1253,7 +1380,7 @@ server.registerTool("request_plan_generation", {
1253
1380
  `Use get_plan with ticket_number "${ticket_number}" to retrieve the plan once processing completes.`;
1254
1381
  return { content: [{ type: "text", text: confirmationText }] };
1255
1382
  });
1256
- server.registerTool("request_architecture", {
1383
+ registerTool("request_architecture", {
1257
1384
  description: "Request AI-generated architecture plan for a Jira ticket. " +
1258
1385
  "This triggers an asynchronous background job — results are NOT immediate. " +
1259
1386
  "Processing typically takes 2-4 minutes depending on ticket complexity. " +
@@ -1322,7 +1449,7 @@ server.registerTool("request_architecture", {
1322
1449
  `Use get_architecture with ticket_number "${ticket_number}" to retrieve the architecture plan once processing completes.`;
1323
1450
  return { content: [{ type: "text", text: confirmationText }] };
1324
1451
  });
1325
- server.registerTool("request_clarifying_questions", {
1452
+ registerTool("request_clarifying_questions", {
1326
1453
  description: "Request AI-generated clarifying questions or debugging guidance for a Jira ticket. " +
1327
1454
  "This triggers an asynchronous background job — results are NOT immediate. " +
1328
1455
  "Processing typically takes 1-5 minutes. " +
@@ -1395,7 +1522,7 @@ server.registerTool("request_clarifying_questions", {
1395
1522
  // ---------------------------------------------------------------------------
1396
1523
  // Ticket Quality Critique
1397
1524
  // ---------------------------------------------------------------------------
1398
- server.registerTool("get_ticket_critique", {
1525
+ registerTool("get_ticket_critique", {
1399
1526
  description: "Retrieve AI-generated ticket quality critique for a Jira ticket. " +
1400
1527
  "Returns markdown text with a structured critique covering Standards Conformance Analysis, " +
1401
1528
  "Standards Deviations, and Suggested Improvements. " +
@@ -1423,7 +1550,7 @@ server.registerTool("get_ticket_critique", {
1423
1550
  }
1424
1551
  return { content: [{ type: "text", text }] };
1425
1552
  });
1426
- server.registerTool("request_ticket_critique", {
1553
+ registerTool("request_ticket_critique", {
1427
1554
  description: "Request AI-generated ticket critique for a Jira ticket. " +
1428
1555
  "This triggers an asynchronous background job — results are NOT immediate. " +
1429
1556
  "Processing typically takes 1-5 minutes. " +
@@ -1495,7 +1622,7 @@ server.registerTool("request_ticket_critique", {
1495
1622
  // ---------------------------------------------------------------------------
1496
1623
  // Combined Ticket Review (clarify + critique)
1497
1624
  // ---------------------------------------------------------------------------
1498
- server.registerTool("request_ticket_review", {
1625
+ registerTool("request_ticket_review", {
1499
1626
  description: "Request a combined ticket review that generates BOTH clarifying questions (or debugging guidance for bug tickets) " +
1500
1627
  "AND a ticket quality critique in parallel on the server, halving wall-clock latency vs. running the two " +
1501
1628
  "requests sequentially. This triggers an asynchronous background job — results are NOT immediate. " +
@@ -1602,7 +1729,7 @@ server.registerTool("request_ticket_review", {
1602
1729
  // ---------------------------------------------------------------------------
1603
1730
  // Reimplement Context
1604
1731
  // ---------------------------------------------------------------------------
1605
- server.registerTool("request_reimplement_context", {
1732
+ registerTool("request_reimplement_context", {
1606
1733
  description: "Request processing of new attachments and context assembly for a previously-implemented Jira ticket. " +
1607
1734
  "Use this for follow-up requests on tickets that have already been through the plan+implement cycle. " +
1608
1735
  "This triggers an asynchronous background job to process new attachments/images. " +
@@ -1668,7 +1795,7 @@ server.registerTool("request_reimplement_context", {
1668
1795
  `Use get_reimplement_context with ticket_number "${ticket_number}" to retrieve the results once processing completes.`;
1669
1796
  return { content: [{ type: "text", text: confirmationText }] };
1670
1797
  });
1671
- server.registerTool("get_reimplement_context", {
1798
+ registerTool("get_reimplement_context", {
1672
1799
  description: "Retrieve the assembled reimplement context for a Jira ticket. " +
1673
1800
  "Returns a markdown document with new/changed information diffed against stored state, " +
1674
1801
  "the original ticket description, and the existing implementation plan. " +
@@ -1709,7 +1836,7 @@ server.registerTool("get_reimplement_context", {
1709
1836
  // ---------------------------------------------------------------------------
1710
1837
  // Ticket Lifecycle Tracking
1711
1838
  // ---------------------------------------------------------------------------
1712
- server.registerTool("track_ticket", {
1839
+ registerTool("track_ticket", {
1713
1840
  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. " +
1714
1841
  "If the ticket is already tracked, this is a safe no-op — it upserts the description and repo_name without error. " +
1715
1842
  "Call this after creating a ticket with create_ticket to enable state tracking. " +
@@ -1730,7 +1857,7 @@ server.registerTool("track_ticket", {
1730
1857
  const text = await handleResponse(resp);
1731
1858
  return { content: [{ type: "text", text }] };
1732
1859
  });
1733
- server.registerTool("update_ticket_state", {
1860
+ registerTool("update_ticket_state", {
1734
1861
  description: "Update workflow state timestamps on a tracked ticket. Each specified field is set to the current UTC timestamp on the server. " +
1735
1862
  "Valid field names: 'critique_called', 'critique_answered', 'clarify_called', 'clarify_answered', 'plan_generated', 'implemented', 'reimplement_called'. " +
1736
1863
  "The ticket must already be tracked (via track_ticket) or a 404 error is returned. " +
@@ -1750,7 +1877,7 @@ server.registerTool("update_ticket_state", {
1750
1877
  const text = await handleResponse(resp);
1751
1878
  return { content: [{ type: "text", text }] };
1752
1879
  });
1753
- server.registerTool("get_ticket_state", {
1880
+ registerTool("get_ticket_state", {
1754
1881
  description: "Retrieve workflow state timestamps and artifact existence flags for a tracked ticket. " +
1755
1882
  "Returns timestamps for each state field (critique_called, critique_answered, clarify_called, clarify_answered, plan_generated, implemented, reimplement_called) " +
1756
1883
  "and boolean flags indicating whether artifacts exist (has_clarifying_questions, has_critique, has_plan). " +
@@ -1768,7 +1895,7 @@ server.registerTool("get_ticket_state", {
1768
1895
  // ---------------------------------------------------------------------------
1769
1896
  // Jira Transitions
1770
1897
  // ---------------------------------------------------------------------------
1771
- server.registerTool("get_jira_transitions", {
1898
+ registerTool("get_jira_transitions", {
1772
1899
  description: "List available Jira workflow transitions for a ticket. Returns each transition's id, name, and target status. " +
1773
1900
  "Use this to discover what status changes are possible for a given ticket. " +
1774
1901
  "The repo_name is automatically injected from the configured environment.",
@@ -1781,7 +1908,7 @@ server.registerTool("get_jira_transitions", {
1781
1908
  const text = await handleResponse(resp);
1782
1909
  return { content: [{ type: "text", text }] };
1783
1910
  });
1784
- server.registerTool("update_jira_status", {
1911
+ registerTool("update_jira_status", {
1785
1912
  description: "Transition a Jira ticket to a specified target status by executing a workflow transition. " +
1786
1913
  "Provide either target_status (matched case-insensitively against available transitions) or transition_id (used directly). " +
1787
1914
  "If transition_id is provided, it takes precedence over target_status. " +
@@ -1808,7 +1935,7 @@ server.registerTool("update_jira_status", {
1808
1935
  const text = await handleResponse(resp);
1809
1936
  return { content: [{ type: "text", text }] };
1810
1937
  });
1811
- server.registerTool("resolve_target_status", {
1938
+ registerTool("resolve_target_status", {
1812
1939
  description: "Resolve the post-PR target Jira status for the configured repository using an LLM agent. " +
1813
1940
  "The agent selects the workflow status that best represents 'code committed via PR but not yet tested.' " +
1814
1941
  "Results are cached per-project — subsequent calls return the cached value unless force_rerun is true. " +
@@ -1839,12 +1966,13 @@ server.registerTool("resolve_target_status", {
1839
1966
  const VALID_CONFIG_FIELDS = [
1840
1967
  "review_instructions", "documentation_instructions", "architecture_instructions",
1841
1968
  "unit_testing_instructions", "e2e_testing_instructions",
1969
+ "unit_testing_stack", "e2e_testing_stack",
1842
1970
  "frontend_correctness_standards", "backend_correctness_standards",
1843
1971
  "template_correctness_standards", "style_correctness_standards",
1844
1972
  "design_principles",
1845
- "post_pr_target_status", "ci_check_config",
1973
+ "post_pr_target_status", "ci_check_config", "ci_followup_config",
1846
1974
  ].join(", ");
1847
- server.registerTool("list_config_fields", {
1975
+ registerTool("list_config_fields", {
1848
1976
  description: "List all configurable fields available for reading and updating via the Bridge API. " +
1849
1977
  "Returns each field's name and a description of its purpose. No database values are returned — " +
1850
1978
  "use get_config_field to read a specific field's current value. " +
@@ -1856,7 +1984,7 @@ server.registerTool("list_config_fields", {
1856
1984
  const text = await handleResponse(resp);
1857
1985
  return { content: [{ type: "text", text }] };
1858
1986
  });
1859
- server.registerTool("get_my_role", {
1987
+ registerTool("get_my_role", {
1860
1988
  description: "Check the role and auth source for the current API key. " +
1861
1989
  "Returns JSON with {role: \"admin\" | \"member\" | null, source: \"user_access\" | \"legacy\"}. " +
1862
1990
  "Use this to check if the current key has admin permissions before attempting configuration updates " +
@@ -1868,7 +1996,7 @@ server.registerTool("get_my_role", {
1868
1996
  const text = await handleResponse(resp);
1869
1997
  return { content: [{ type: "text", text }] };
1870
1998
  });
1871
- server.registerTool("get_config_field", {
1999
+ registerTool("get_config_field", {
1872
2000
  description: "Read the current value and metadata for a specific configuration field. " +
1873
2001
  "Returns the field's current database value (or null if not set), along with a description of its purpose and examples of helpful content. " +
1874
2002
  "Use this before update_config_field to understand the current state and build upon it rather than overwriting blindly.",
@@ -1881,7 +2009,7 @@ server.registerTool("get_config_field", {
1881
2009
  const text = await handleResponse(resp);
1882
2010
  return { content: [{ type: "text", text }] };
1883
2011
  });
1884
- server.registerTool("update_config_field", {
2012
+ registerTool("update_config_field", {
1885
2013
  description: "Update a specific configuration field in the Bridge database. " +
1886
2014
  "These fields control LLM behavior during code review, planning, and documentation. " +
1887
2015
  "Always call get_config_field first to read the current value and build upon it. " +
@@ -1913,10 +2041,63 @@ server.registerTool("update_config_field", {
1913
2041
  const text = await handleResponse(resp);
1914
2042
  return { content: [{ type: "text", text: text + note }] };
1915
2043
  });
1916
- // ---------------------------------------------------------------------------
1917
- // Deep Research
1918
- // ---------------------------------------------------------------------------
1919
- server.registerTool("request_deep_research", {
2044
+ function formatDeepResearchProviderReason(meta) {
2045
+ if (!meta)
2046
+ return "";
2047
+ const parts = [];
2048
+ const reason = meta.incomplete_details?.reason;
2049
+ if (reason) {
2050
+ parts.push(`provider reason: ${reason}`);
2051
+ }
2052
+ const errMsg = meta.error?.message;
2053
+ const errCode = meta.error?.code;
2054
+ if (errMsg || errCode) {
2055
+ if (errCode && errMsg) {
2056
+ parts.push(`provider error: ${errCode}: ${errMsg}`);
2057
+ }
2058
+ else if (errMsg) {
2059
+ parts.push(`provider error: ${errMsg}`);
2060
+ }
2061
+ else if (errCode) {
2062
+ parts.push(`provider error: ${errCode}`);
2063
+ }
2064
+ }
2065
+ return parts.length ? ` (${parts.join("; ")})` : "";
2066
+ }
2067
+ function _safeIsoMs(value) {
2068
+ if (!value)
2069
+ return null;
2070
+ const ms = new Date(value).getTime();
2071
+ if (Number.isNaN(ms))
2072
+ return null;
2073
+ return ms;
2074
+ }
2075
+ function formatDeepResearchElapsed(createdAt, lastPollAt) {
2076
+ const createdMs = _safeIsoMs(createdAt);
2077
+ if (createdMs === null)
2078
+ return "";
2079
+ const now = Date.now();
2080
+ const startedMs = Math.max(0, now - createdMs);
2081
+ const startedMin = Math.floor(startedMs / 60_000);
2082
+ const lastPollMs = _safeIsoMs(lastPollAt);
2083
+ let pollSuffix = "";
2084
+ if (lastPollMs !== null) {
2085
+ const ageSec = Math.max(0, Math.floor((now - lastPollMs) / 1000));
2086
+ pollSuffix = `, last poll ${ageSec}s ago`;
2087
+ }
2088
+ return ` (running ${startedMin}m${pollSuffix})`;
2089
+ }
2090
+ function formatDeepResearchFailure(body) {
2091
+ const kind = body.error_kind || body.error_message || "Unknown error";
2092
+ const reason = formatDeepResearchProviderReason(body.provider_status_meta);
2093
+ return `Deep research failed: ${kind}${reason}. Consider using standard web searches to gather the information incrementally.`;
2094
+ }
2095
+ function formatDeepResearchStatus(body, taskId) {
2096
+ const elapsed = formatDeepResearchElapsed(body.created_at, body.last_poll_at);
2097
+ const reason = formatDeepResearchProviderReason(body.provider_status_meta);
2098
+ return `Status: ${body.status}${elapsed}${reason} (task_id: ${taskId}). Try again in a minute.`;
2099
+ }
2100
+ registerTool("request_deep_research", {
1920
2101
  description: "Submit a deep research request on a technical topic using AI-powered web search. " +
1921
2102
  "Returns a task_id for tracking the research progress. " +
1922
2103
  "\n\n" +
@@ -1980,6 +2161,7 @@ server.registerTool("request_deep_research", {
1980
2161
  const MAX_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
1981
2162
  let pollIntervalMs = 15_000; // start at 15 seconds
1982
2163
  let lastStatus = "queued";
2164
+ let latestStatusBody = null;
1983
2165
  while (Date.now() - startTime < MAX_TIMEOUT_MS) {
1984
2166
  await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
1985
2167
  const elapsed = Math.round((Date.now() - startTime) / 1000);
@@ -1992,15 +2174,15 @@ server.registerTool("request_deep_research", {
1992
2174
  }
1993
2175
  const statusBody = (await statusResp.json());
1994
2176
  lastStatus = statusBody.status;
2177
+ latestStatusBody = statusBody;
1995
2178
  if (lastStatus === "completed") {
1996
2179
  break;
1997
2180
  }
1998
2181
  if (lastStatus === "failed") {
1999
- const errorMsg = statusBody.error_message || "Unknown error";
2000
2182
  return {
2001
2183
  content: [{
2002
2184
  type: "text",
2003
- text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Deep research failed: ${errorMsg}. Consider using standard web searches to gather the information incrementally.` }),
2185
+ text: formatDeepResearchFailure(statusBody),
2004
2186
  }],
2005
2187
  };
2006
2188
  }
@@ -2010,10 +2192,13 @@ server.registerTool("request_deep_research", {
2010
2192
  }
2011
2193
  }
2012
2194
  if (lastStatus !== "completed") {
2195
+ const statusSuffix = latestStatusBody
2196
+ ? ` ${formatDeepResearchStatus(latestStatusBody, taskId)}`
2197
+ : "";
2013
2198
  return {
2014
2199
  content: [{
2015
2200
  type: "text",
2016
- 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.` }),
2201
+ 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.`,
2017
2202
  }],
2018
2203
  };
2019
2204
  }
@@ -2033,7 +2218,7 @@ server.registerTool("request_deep_research", {
2033
2218
  }
2034
2219
  return { content: [{ type: "text", text: resultText }] };
2035
2220
  });
2036
- server.registerTool("get_deep_research", {
2221
+ registerTool("get_deep_research", {
2037
2222
  description: "Retrieve the result of a previously submitted deep research request. " +
2038
2223
  "Returns the full markdown research report if the task is completed, " +
2039
2224
  "or a structured status response if still processing or failed. " +
@@ -2054,11 +2239,10 @@ server.registerTool("get_deep_research", {
2054
2239
  }
2055
2240
  const statusBody = (await statusResp.json());
2056
2241
  if (statusBody.status === "failed") {
2057
- const errorMsg = statusBody.error_message || "Unknown error";
2058
2242
  return {
2059
2243
  content: [{
2060
2244
  type: "text",
2061
- text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Deep research failed: ${errorMsg}. Consider using standard web searches to gather the information incrementally.` }),
2245
+ text: formatDeepResearchFailure(statusBody),
2062
2246
  }],
2063
2247
  };
2064
2248
  }
@@ -2066,7 +2250,7 @@ server.registerTool("get_deep_research", {
2066
2250
  return {
2067
2251
  content: [{
2068
2252
  type: "text",
2069
- text: JSON.stringify({ status: statusBody.status, task_id, message: `Deep research is still ${statusBody.status}. Try again in a minute.` }),
2253
+ text: formatDeepResearchStatus(statusBody, task_id),
2070
2254
  }],
2071
2255
  };
2072
2256
  }
@@ -2086,10 +2270,219 @@ server.registerTool("get_deep_research", {
2086
2270
  }
2087
2271
  return { content: [{ type: "text", text: resultText }] };
2088
2272
  });
2273
+ const BRAINSTORM_TERMINAL_STATUSES = new Set([
2274
+ "completed",
2275
+ "failed",
2276
+ "skipped",
2277
+ ]);
2278
+ function isBrainstormTerminalStatus(status) {
2279
+ return BRAINSTORM_TERMINAL_STATUSES.has(status);
2280
+ }
2281
+ function sanitizeProviderForFilename(provider) {
2282
+ // Defensive: allow letters/digits/hyphen/underscore only; collapse runs and
2283
+ // trim trailing punctuation. Falls back to "provider" for an empty result.
2284
+ const cleaned = provider
2285
+ .toLowerCase()
2286
+ .replace(/[^a-z0-9_-]+/g, "-")
2287
+ .replace(/-+/g, "-")
2288
+ .replace(/^-+|-+$/g, "");
2289
+ return cleaned || "provider";
2290
+ }
2291
+ async function pollBrainstormUntilTerminal(brainstormId, repoName) {
2292
+ const startTime = Date.now();
2293
+ const MAX_TIMEOUT_MS = 15 * 60 * 1000;
2294
+ let pollIntervalMs = 15_000;
2295
+ let latest = null;
2296
+ while (Date.now() - startTime < MAX_TIMEOUT_MS) {
2297
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
2298
+ const statusUrl = buildGetUrl(`/brainstorms/${brainstormId}/status`, { repo_name: repoName });
2299
+ const statusResp = await fetch(statusUrl, { headers: GET_HEADERS });
2300
+ if (!statusResp.ok) {
2301
+ return latest;
2302
+ }
2303
+ latest = (await statusResp.json());
2304
+ const allTerminal = latest.rows.every((row) => isBrainstormTerminalStatus(row.status));
2305
+ if (allTerminal) {
2306
+ return latest;
2307
+ }
2308
+ if (Date.now() - startTime > 60_000) {
2309
+ pollIntervalMs = 30_000;
2310
+ }
2311
+ }
2312
+ return latest;
2313
+ }
2314
+ async function saveBrainstormResultsLocally(envelope) {
2315
+ const dir = getDocsPath("brainstorm");
2316
+ const savedPaths = [];
2317
+ for (const row of envelope.results) {
2318
+ const markdown = row.markdown;
2319
+ if (!markdown) {
2320
+ continue;
2321
+ }
2322
+ const providerSegment = sanitizeProviderForFilename(row.provider);
2323
+ const filename = `${envelope.brainstorm_id}-${providerSegment}.md`;
2324
+ const filePath = path.join(dir, filename);
2325
+ try {
2326
+ await mkdir(dir, { recursive: true });
2327
+ await writeFile(filePath, markdown, "utf-8");
2328
+ savedPaths.push(filePath);
2329
+ }
2330
+ catch {
2331
+ // Skip rows that fail to write — never block the response.
2332
+ }
2333
+ }
2334
+ return savedPaths;
2335
+ }
2336
+ function formatBrainstormToolResponse(envelope, savedPaths) {
2337
+ const lines = [];
2338
+ lines.push(`# Brainstorm ${envelope.brainstorm_id}`);
2339
+ lines.push(`Repo: ${envelope.repo_name}`);
2340
+ lines.push("");
2341
+ for (const row of envelope.results) {
2342
+ lines.push(`## ${row.provider} — status: ${row.status}`);
2343
+ lines.push(`error_kind: ${row.error_kind ?? "null"}`);
2344
+ if (row.error_message) {
2345
+ lines.push(`error_message: ${row.error_message}`);
2346
+ }
2347
+ if (row.markdown) {
2348
+ lines.push("");
2349
+ lines.push(row.markdown);
2350
+ }
2351
+ lines.push("");
2352
+ }
2353
+ if (savedPaths.length > 0) {
2354
+ lines.push("---");
2355
+ lines.push("Saved files:");
2356
+ for (const p of savedPaths) {
2357
+ lines.push(`- ${p}`);
2358
+ }
2359
+ }
2360
+ return lines.join("\n");
2361
+ }
2362
+ registerTool("request_brainstorm", {
2363
+ description: "Submit a brainstorm request that fans out the task to two opinion-provider LLMs " +
2364
+ "(default: OpenAI + Gemini) and then runs a synthesizer pass over the completed opinions. " +
2365
+ "Returns a brainstorm_id you can use with get_brainstorm. " +
2366
+ "\n\n" +
2367
+ "BEHAVIOR: By default, returns immediately with a brainstorm_id. Set wait_for_result=true " +
2368
+ "to poll for terminal status (up to 15 minutes), then retrieve and optionally save the result. " +
2369
+ "When save_locally=true (default), each provider's markdown is written to " +
2370
+ "BAPI_DOCS_DIR/brainstorm/{brainstorm_id}-{provider}.md, including {brainstorm_id}-synthesizer.md.",
2371
+ inputSchema: {
2372
+ task_description: z.string().describe("Free-form description of the task to brainstorm about. Sent verbatim — " +
2373
+ "this tool does NOT read task_description from a file."),
2374
+ repo_name: z.string().optional().describe("Repository name. Defaults to BAPI_REPO_NAME from the environment."),
2375
+ ticket_number: z.string().optional().describe("Optional Jira ticket key (e.g. PROJ-123) to associate with the brainstorm. " +
2376
+ "Ticket 1 only stores this for cross-reference — no Jira writes happen."),
2377
+ providers: z.array(z.string()).optional().describe("Opinion-provider LLMs. Defaults to ['openai', 'gemini']. " +
2378
+ "A single-provider request still inserts a synthesizer row but pre-skips it."),
2379
+ concerns: z.string().optional().describe("Optional caller-supplied concerns to surface to the brainstorm agents."),
2380
+ wait_for_result: z.boolean().optional().describe("When true, polls until every row reaches a terminal status (max 15 minutes), " +
2381
+ "then returns the full result envelope. When false (default), returns immediately."),
2382
+ save_locally: z.boolean().optional().describe("When true (default), writes each provider's markdown to BAPI_DOCS_DIR/brainstorm/ " +
2383
+ "after the result is fetched."),
2384
+ prior_brainstorm_id: z.string().optional().describe("Optional brainstorm_id from an earlier brainstorm to refine. " +
2385
+ "When provided, the new brainstorm receives the prior brainstorm's " +
2386
+ "synthesizer markdown, or a completed opinion-provider fallback, as prior context."),
2387
+ },
2388
+ }, async ({ task_description, repo_name, ticket_number, providers, concerns, wait_for_result, save_locally, prior_brainstorm_id, }) => {
2389
+ const effectiveRepo = repo_name && repo_name.length > 0 ? repo_name : REPO_NAME;
2390
+ const effectiveProviders = providers !== undefined ? providers : ["openai", "gemini"];
2391
+ const shouldWait = wait_for_result === true;
2392
+ const shouldSave = save_locally !== false;
2393
+ const submitPayload = {
2394
+ repo_name: effectiveRepo,
2395
+ task_description,
2396
+ providers: effectiveProviders,
2397
+ };
2398
+ if (ticket_number)
2399
+ submitPayload.ticket_number = ticket_number;
2400
+ if (concerns)
2401
+ submitPayload.concerns = concerns;
2402
+ if (prior_brainstorm_id) {
2403
+ submitPayload.prior_brainstorm_request_id = prior_brainstorm_id;
2404
+ }
2405
+ const submitResp = await fetch(buildUrl("/brainstorms"), {
2406
+ method: "POST",
2407
+ headers: POST_HEADERS,
2408
+ body: JSON.stringify(submitPayload),
2409
+ });
2410
+ if (!submitResp.ok) {
2411
+ const errorText = await handleResponse(submitResp);
2412
+ return { content: [{ type: "text", text: errorText }] };
2413
+ }
2414
+ const submitBody = (await submitResp.json());
2415
+ if (!shouldWait) {
2416
+ const confirmation = `Brainstorm submitted (brainstorm_id: ${submitBody.brainstorm_id}). ` +
2417
+ `Providers: ${submitBody.providers.join(", ")}. ` +
2418
+ `Synthesizer status: ${submitBody.synthesizer_status}. ` +
2419
+ `Use get_brainstorm with brainstorm_id ${submitBody.brainstorm_id} to retrieve results.`;
2420
+ return { content: [{ type: "text", text: confirmation }] };
2421
+ }
2422
+ const finalStatus = await pollBrainstormUntilTerminal(submitBody.brainstorm_id, effectiveRepo);
2423
+ if (!finalStatus) {
2424
+ return {
2425
+ content: [{
2426
+ type: "text",
2427
+ text: `Brainstorm timed out before reaching terminal status ` +
2428
+ `(brainstorm_id: ${submitBody.brainstorm_id}). Use get_brainstorm later.`,
2429
+ }],
2430
+ };
2431
+ }
2432
+ const resultUrl = buildGetUrl(`/brainstorms/${submitBody.brainstorm_id}/result`, { repo_name: effectiveRepo });
2433
+ const resultResp = await fetch(resultUrl, { headers: GET_HEADERS });
2434
+ if (!resultResp.ok) {
2435
+ const errorText = await handleResponse(resultResp);
2436
+ return { content: [{ type: "text", text: errorText }] };
2437
+ }
2438
+ const envelope = (await resultResp.json());
2439
+ let savedPaths = [];
2440
+ if (shouldSave) {
2441
+ savedPaths = await saveBrainstormResultsLocally(envelope);
2442
+ }
2443
+ return {
2444
+ content: [{
2445
+ type: "text",
2446
+ text: formatBrainstormToolResponse(envelope, savedPaths),
2447
+ }],
2448
+ };
2449
+ });
2450
+ registerTool("get_brainstorm", {
2451
+ description: "Retrieve the result envelope for a previously submitted brainstorm by brainstorm_id. " +
2452
+ "Returns all rows (opinion providers + synthesizer), including error_kind for every row. " +
2453
+ "When save_locally=true (default), writes each provider's markdown to " +
2454
+ "BAPI_DOCS_DIR/brainstorm/{brainstorm_id}-{provider}.md (including the synthesizer file). " +
2455
+ "DB-only retrieval — never falls back to Jira attachments.",
2456
+ inputSchema: {
2457
+ brainstorm_id: z.string().describe("The brainstorm_id (UUID) returned by request_brainstorm."),
2458
+ repo_name: z.string().optional().describe("Repository name. Defaults to BAPI_REPO_NAME from the environment."),
2459
+ save_locally: z.boolean().optional().describe("When true (default), writes each provider's markdown to BAPI_DOCS_DIR/brainstorm/."),
2460
+ },
2461
+ }, async ({ brainstorm_id, repo_name, save_locally }) => {
2462
+ const effectiveRepo = repo_name && repo_name.length > 0 ? repo_name : REPO_NAME;
2463
+ const shouldSave = save_locally !== false;
2464
+ const resultUrl = buildGetUrl(`/brainstorms/${brainstorm_id}/result`, { repo_name: effectiveRepo });
2465
+ const resultResp = await fetch(resultUrl, { headers: GET_HEADERS });
2466
+ if (!resultResp.ok) {
2467
+ const errorText = await handleResponse(resultResp);
2468
+ return { content: [{ type: "text", text: errorText }] };
2469
+ }
2470
+ const envelope = (await resultResp.json());
2471
+ let savedPaths = [];
2472
+ if (shouldSave) {
2473
+ savedPaths = await saveBrainstormResultsLocally(envelope);
2474
+ }
2475
+ return {
2476
+ content: [{
2477
+ type: "text",
2478
+ text: formatBrainstormToolResponse(envelope, savedPaths),
2479
+ }],
2480
+ };
2481
+ });
2089
2482
  // ---------------------------------------------------------------------------
2090
2483
  // VCS & CI Tools
2091
2484
  // ---------------------------------------------------------------------------
2092
- server.registerTool("create_pull_request", {
2485
+ registerTool("create_pull_request", {
2093
2486
  description: "Create a pull request on the configured VCS provider (GitHub or Bitbucket). " +
2094
2487
  "Returns a structured response with {available, reason, action, detail}. " +
2095
2488
  "If a PR already exists for the head branch, returns it with created=false. " +
@@ -2118,7 +2511,7 @@ server.registerTool("create_pull_request", {
2118
2511
  const text = await handleResponse(resp);
2119
2512
  return { content: [{ type: "text", text }] };
2120
2513
  });
2121
- const resolveCiChecksTool = server.registerTool("resolve_ci_checks", {
2514
+ const resolveCiChecksTool = registerTool("resolve_ci_checks", {
2122
2515
  description: "Discover and classify CI checks for the configured repository. " +
2123
2516
  "Queries GitHub Check Runs + Commit Statuses APIs (or Bitbucket Build Statuses), " +
2124
2517
  "then uses Branch Protection API or LLM to determine which checks are required for merging. " +
@@ -2154,7 +2547,7 @@ const resolveCiChecksTool = server.registerTool("resolve_ci_checks", {
2154
2547
  }
2155
2548
  return { content: [{ type: "text", text }] };
2156
2549
  });
2157
- const pollCiChecksTool = server.registerTool("poll_ci_checks", {
2550
+ const pollCiChecksTool = registerTool("poll_ci_checks", {
2158
2551
  description: "Poll the current status of CI checks for a specific commit. " +
2159
2552
  "Requires that resolve_ci_checks has been called first to populate the check configuration. " +
2160
2553
  "Returns per-check status, all_complete, all_passed, and unknown_checks fields. " +
@@ -2203,9 +2596,30 @@ async function checkCiConfigAndDisablePoll() {
2203
2596
  // Check config before connecting
2204
2597
  await checkCiConfigAndDisablePoll();
2205
2598
  // ---------------------------------------------------------------------------
2599
+ // Custom pipeline loading (BAPI-275)
2600
+ // ---------------------------------------------------------------------------
2601
+ //
2602
+ // Custom user pipelines must be merged into PIPELINES + INSTRUCTIONS BEFORE
2603
+ // the run_pipeline tool is registered so its embedded catalog includes both
2604
+ // bundled and user entries. The merge result is also used by list_pipelines
2605
+ // and get_pipeline_recipe below — every caller must see the same final
2606
+ // objects.
2607
+ {
2608
+ const instructionsDir = path.join(path.dirname(BAPI_PIPELINES_DIR), "instructions");
2609
+ const customResult = await loadCustomPipelines(BAPI_PIPELINES_DIR, instructionsDir, BUNDLED_INSTRUCTIONS);
2610
+ for (const [key, pipeline] of Object.entries(customResult.pipelines)) {
2611
+ if (key in BUNDLED_PIPELINES) {
2612
+ console.error(`Warning: user pipeline "${key}" overrides bundled pipeline.`);
2613
+ }
2614
+ PIPELINES[key] = pipeline;
2615
+ }
2616
+ Object.assign(INSTRUCTIONS, customResult.instructions);
2617
+ userPipelineKeys = customResult.userPipelineKeys;
2618
+ }
2619
+ // ---------------------------------------------------------------------------
2206
2620
  // Pipeline Recipe Tools
2207
2621
  // ---------------------------------------------------------------------------
2208
- server.registerTool("get_docs_dir", {
2622
+ registerTool("get_docs_dir", {
2209
2623
  description: "Return the locally configured docs directory path (BAPI_DOCS_DIR, default docs/tmp). " +
2210
2624
  "No parameters. Use this instead of reading the BAPI_DOCS_DIR environment variable directly, " +
2211
2625
  "which requires shell access and may be blocked on some AI coding platforms.",
@@ -2213,7 +2627,7 @@ server.registerTool("get_docs_dir", {
2213
2627
  }, async () => {
2214
2628
  return { content: [{ type: "text", text: BAPI_DOCS_DIR }] };
2215
2629
  });
2216
- server.registerTool("list_pipelines", {
2630
+ registerTool("list_pipelines", {
2217
2631
  description: "List all available pipeline recipes with their names, descriptions, and required variables. " +
2218
2632
  "No parameters. Use this to discover available pipelines before calling get_pipeline_recipe.",
2219
2633
  inputSchema: {},
@@ -2228,7 +2642,7 @@ server.registerTool("list_pipelines", {
2228
2642
  content: [{ type: "text", text: JSON.stringify(list, null, 2) }],
2229
2643
  };
2230
2644
  });
2231
- server.registerTool("get_pipeline_recipe", {
2645
+ registerTool("get_pipeline_recipe", {
2232
2646
  description: "Retrieve a fully resolved pipeline recipe by name. Substitutes variables, resolves instruction " +
2233
2647
  "file references to inline content, and returns an ordered array of executable steps. " +
2234
2648
  "Each step is either an mcp_call (with tool name and params) or an agent_task (with instruction text). " +
@@ -2246,8 +2660,16 @@ server.registerTool("get_pipeline_recipe", {
2246
2660
  .array(z.string())
2247
2661
  .optional()
2248
2662
  .describe("Step tool names or descriptions to omit from the recipe"),
2663
+ auto_approve: z
2664
+ .boolean()
2665
+ .optional()
2666
+ .describe("When true, automatically approve all approval-gated steps. " +
2667
+ "For implement-ticket this skips the commit/push approval pause; " +
2668
+ "for review-ticket this skips the HTML decision page and selects " +
2669
+ "each item's recommended option. Pass via this top-level parameter, " +
2670
+ "not via the variables map."),
2249
2671
  },
2250
- }, async ({ pipeline: pipelineName, variables, skip_steps }) => {
2672
+ }, async ({ pipeline: pipelineName, variables, skip_steps, auto_approve }) => {
2251
2673
  const pipelineDef = PIPELINES[pipelineName];
2252
2674
  if (!pipelineDef) {
2253
2675
  const available = Object.keys(PIPELINES).join(", ");
@@ -2262,9 +2684,27 @@ server.registerTool("get_pipeline_recipe", {
2262
2684
  }],
2263
2685
  };
2264
2686
  }
2687
+ if (variables && "auto_approve" in variables) {
2688
+ return {
2689
+ content: [{
2690
+ type: "text",
2691
+ text: JSON.stringify({
2692
+ error: ERROR_CODES[400] ?? "BAD_REQUEST",
2693
+ status: 400,
2694
+ message: "Pass auto_approve via the top-level parameter, not via the variables map.",
2695
+ }),
2696
+ }],
2697
+ };
2698
+ }
2265
2699
  try {
2266
- const mergedVariables = { docs_dir: BAPI_DOCS_DIR, provider: "", second_opinion: "", ...(variables ?? {}) };
2267
- const recipe = resolveRecipe(pipelineDef, INSTRUCTIONS, mergedVariables, skip_steps);
2700
+ const mergedVariables = {
2701
+ docs_dir: BAPI_DOCS_DIR,
2702
+ provider: "",
2703
+ second_opinion: "",
2704
+ auto_approve: auto_approve ? "true" : "",
2705
+ ...(variables ?? {}),
2706
+ };
2707
+ const recipe = resolveRecipe(pipelineDef, INSTRUCTIONS, mergedVariables, skip_steps, !!auto_approve);
2268
2708
  return {
2269
2709
  content: [{
2270
2710
  type: "text",
@@ -2286,40 +2726,158 @@ server.registerTool("get_pipeline_recipe", {
2286
2726
  }
2287
2727
  });
2288
2728
  // ---------------------------------------------------------------------------
2289
- // generate_decision_page
2729
+ // Pipeline Execution Tools (BAPI-275)
2290
2730
  // ---------------------------------------------------------------------------
2291
- server.registerTool("generate_decision_page", {
2292
- description: "Generate a local HTML decision page for capturing user decisions on ticket review findings. " +
2293
- "Renders recommendation-driven review decisions with custom options from resolution guide " +
2294
- "decision trees, plus confirmed improvements. The user opens the HTML file " +
2295
- "in a browser, makes selections, and copies the resulting JSON output back to the agent.",
2731
+ //
2732
+ // run_pipeline / resume_pipeline / list_pipeline_runs promote pipelines from
2733
+ // declarative JSON recipes into first-class callable operations. They share
2734
+ // a unified response envelope (status: completed | needs_agent_task | failed)
2735
+ // with a strict error_code enum (VALIDATION | NOT_FOUND | EXPIRED |
2736
+ // REPO_MISMATCH | TOOL_ERROR). Pipeline state is persisted server-side; the
2737
+ // idle TTL defaults to 24 hours and is auto-extended on every state
2738
+ // transition.
2739
+ function buildPipelineCatalogDescription() {
2740
+ const lines = [];
2741
+ for (const [key, pipeline] of Object.entries(PIPELINES)) {
2742
+ const source = userPipelineKeys.has(key) ? " (user)" : "";
2743
+ const desc = pipeline.description ?? "";
2744
+ lines.push(`- ${key}${source} — ${desc}`);
2745
+ }
2746
+ return lines.join("\n");
2747
+ }
2748
+ function buildPipelineOrchestratorDeps() {
2749
+ return {
2750
+ baseUrl: BASE_URL,
2751
+ apiKey: API_KEY,
2752
+ repoName: REPO_NAME,
2753
+ docsDir: BAPI_DOCS_DIR,
2754
+ pipelines: PIPELINES,
2755
+ instructions: INSTRUCTIONS,
2756
+ toolHandlers: TOOL_HANDLERS,
2757
+ };
2758
+ }
2759
+ registerTool("run_pipeline", {
2760
+ description: "Execute a Bridge API pipeline by name. The orchestrator runs steps sequentially, " +
2761
+ "dispatching mcp_call steps in-process and pausing on agent_task steps with a " +
2762
+ "needs_agent_task envelope. Returns a unified envelope keyed on `status`: " +
2763
+ "`completed` (terminal success with `results`), `needs_agent_task` (pause — read " +
2764
+ "`instruction`, perform the task, then call `resume_pipeline` with the resulting " +
2765
+ "string as `agent_result`), or `failed` (terminal error — check `error_code`: " +
2766
+ "VALIDATION | NOT_FOUND | EXPIRED | REPO_MISMATCH | TOOL_ERROR). " +
2767
+ "Paused runs auto-expire after an idle TTL (default 24 hours; override with " +
2768
+ "`ttl_seconds`). The TTL is reset on every state transition.\n\n" +
2769
+ "Available pipelines:\n" +
2770
+ buildPipelineCatalogDescription(),
2296
2771
  inputSchema: {
2297
- ticket_key: z.string().describe("Jira ticket key, e.g. BAPI-123"),
2298
- actionable_items: z
2299
- .array(z.object({
2300
- id: z.string().min(1),
2301
- question: z.string().min(1),
2302
- context: z.string().min(1),
2303
- source: z.string().min(1).describe("Source reference from the evaluation, e.g. 'Clarifying Q3 (initial round)'"),
2304
- recommendation_index: z.number().int().min(0).describe("0-based index of the recommended option in the options array"),
2305
- options: z.array(z.string().min(1)).min(1).describe("Option labels from resolution guide decision tree branches. Values are auto-generated."),
2306
- }))
2772
+ pipeline: z
2773
+ .string()
2774
+ .describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),
2775
+ variables: z
2776
+ .record(z.string())
2307
2777
  .optional()
2308
- .default([])
2309
- .describe("Actionable review decisions with option labels from resolution guide decision trees. 'None of these' auto-appended."),
2310
- clear_improvements: z
2311
- .array(z.object({
2312
- id: z.string().min(1),
2313
- title: z.string().min(1),
2314
- action: z.string().min(1),
2315
- confidence: z.string().min(1),
2316
- source: z.string().min(1).describe("Source reference from the evaluation"),
2317
- }))
2778
+ .describe("Key-value pairs for variable substitution (e.g. { ticket_key: 'BAPI-123' }). " +
2779
+ "Do NOT pass `auto_approve` here use the top-level parameter."),
2780
+ auto_approve: z
2781
+ .union([z.boolean(), z.literal("true"), z.literal("false")])
2318
2782
  .optional()
2319
- .default([])
2320
- .describe("Confirmed improvements displayed as informational list, not submitted."),
2783
+ .describe("When true, approval-gated mcp_call steps execute directly. When false or " +
2784
+ "omitted, the orchestrator synthesises a needs_agent_task pause so the agent " +
2785
+ "can confirm with the user before resuming. Accepts boolean or 'true'/'false' " +
2786
+ "strings for MCP clients that serialize booleans as strings."),
2787
+ ttl_seconds: z
2788
+ .number()
2789
+ .int()
2790
+ .positive()
2791
+ .optional()
2792
+ .describe("Override the default 24-hour idle TTL for this run. Must be a positive integer."),
2793
+ },
2794
+ }, async (input) => {
2795
+ const result = await runPipeline(buildPipelineOrchestratorDeps(), input);
2796
+ return {
2797
+ content: [
2798
+ { type: "text", text: JSON.stringify(result, null, 2) },
2799
+ ],
2800
+ };
2801
+ });
2802
+ registerTool("resume_pipeline", {
2803
+ description: "Resume a paused pipeline run with the result of the agent_task. Provide the " +
2804
+ "`pipeline_run_id` returned by the prior needs_agent_task envelope, and the string " +
2805
+ "the instruction's `## Return` section asked you to produce as `agent_result`. " +
2806
+ "`agent_result` is always a string — do not wrap it in JSON unless the instruction " +
2807
+ "explicitly asked you to serialize structured output. Returns the same unified " +
2808
+ "envelope shape as `run_pipeline`.",
2809
+ inputSchema: {
2810
+ pipeline_run_id: z
2811
+ .string()
2812
+ .describe("The pipeline_run_id returned by a prior needs_agent_task envelope"),
2813
+ agent_result: z
2814
+ .string()
2815
+ .describe("The string the paused instruction's ## Return section asked you to produce"),
2321
2816
  },
2322
2817
  }, async (input) => {
2818
+ const result = await resumePipeline(buildPipelineOrchestratorDeps(), input);
2819
+ return {
2820
+ content: [
2821
+ { type: "text", text: JSON.stringify(result, null, 2) },
2822
+ ],
2823
+ };
2824
+ });
2825
+ registerTool("list_pipeline_runs", {
2826
+ description: "List recent pipeline runs for the configured repository, newest first. Returns " +
2827
+ "metadata only — `resolved_recipe`, resolved params, instruction text, results, " +
2828
+ "and agent outputs are intentionally excluded. Use this to recover a " +
2829
+ "`pipeline_run_id` when an earlier needs_agent_task envelope is no longer in " +
2830
+ "scope (e.g. after compaction or a client restart). " +
2831
+ "Optionally filter by `status`: running | paused | completed | failed | expired.",
2832
+ inputSchema: {
2833
+ status: z
2834
+ .enum(["running", "paused", "completed", "failed", "expired"])
2835
+ .optional()
2836
+ .describe("Optional status filter"),
2837
+ },
2838
+ }, async (input) => {
2839
+ const result = await listPipelineRuns(buildPipelineOrchestratorDeps(), input);
2840
+ return {
2841
+ content: [
2842
+ { type: "text", text: JSON.stringify(result, null, 2) },
2843
+ ],
2844
+ };
2845
+ });
2846
+ registerTool("delete_pipeline_run", {
2847
+ description: "Delete a pipeline run row (any status). Use this to discard orphaned `running` " +
2848
+ "rows from a previous session that can't be resumed (resume_pipeline only accepts " +
2849
+ "`paused`), to clean up after a failed run, or to remove a no-longer-needed paused " +
2850
+ "session. Returns `{ status: 'completed', deleted: true, pipeline_run_id }` on " +
2851
+ "success, or a `failed` envelope with error_code in (VALIDATION | NOT_FOUND | " +
2852
+ "REPO_MISMATCH | TOOL_ERROR). Repo-scoped: the row's stored repo_name must match " +
2853
+ "the caller's repo.",
2854
+ inputSchema: {
2855
+ pipeline_run_id: z
2856
+ .string()
2857
+ .describe("UUID of the pipeline run to delete."),
2858
+ },
2859
+ }, async (input) => {
2860
+ const result = await deletePipelineRun(buildPipelineOrchestratorDeps(), input);
2861
+ return {
2862
+ content: [
2863
+ { type: "text", text: JSON.stringify(result, null, 2) },
2864
+ ],
2865
+ };
2866
+ });
2867
+ // ---------------------------------------------------------------------------
2868
+ // generate_decision_page
2869
+ // ---------------------------------------------------------------------------
2870
+ registerTool("generate_decision_page", {
2871
+ description: "Generate a local HTML decision page for capturing user decisions on ticket review findings. " +
2872
+ "Renders recommendation-driven review decisions sourced from the combined review-and-resolution " +
2873
+ "document, with per-option consequence lines, a closed-by-default codebase-evidence disclosure, " +
2874
+ "and confirmed improvements. The user opens the HTML file in a browser, makes selections, and " +
2875
+ "copies the resulting JSON output back to the agent.",
2876
+ inputSchema: DecisionPageInputShape,
2877
+ }, async (input) => {
2878
+ // Returned messages travel through JSON.stringify in the MCP envelope below,
2879
+ // never into HTML, so echoing untrusted input back to the caller is safe here.
2880
+ // Do not reuse this helper in any path that renders the message into HTML.
2323
2881
  const validationError = (message) => ({
2324
2882
  content: [{
2325
2883
  type: "text",
@@ -2343,21 +2901,27 @@ server.registerTool("generate_decision_page", {
2343
2901
  }],
2344
2902
  };
2345
2903
  }
2346
- // Validate actionable_items
2904
+ // Validate actionable_items: cross-item invariants Zod cannot express.
2905
+ // Per-item bounds (recommendation_index < options.length, parity, branch
2906
+ // count, etc.) are enforced by the schema's superRefine.
2347
2907
  const seenIds = new Set();
2348
2908
  for (const item of input.actionable_items) {
2349
2909
  if (seenIds.has(item.id)) {
2350
2910
  return validationError(`Duplicate actionable_items id: "${item.id}"`);
2351
2911
  }
2352
2912
  seenIds.add(item.id);
2353
- if (item.recommendation_index >= item.options.length) {
2354
- return validationError(`Item "${item.id}": recommendation_index ${item.recommendation_index} is out of bounds (${item.options.length} options).`);
2355
- }
2356
2913
  const noneLabel = item.options.find((label) => label.toLowerCase() === "none of these");
2357
2914
  if (noneLabel) {
2358
2915
  return validationError(`Item "${item.id}": option label "${noneLabel}" is reserved and auto-appended by the tool.`);
2359
2916
  }
2360
2917
  }
2918
+ const seenCiIds = new Set();
2919
+ for (const ci of input.clear_improvements) {
2920
+ if (seenCiIds.has(ci.id)) {
2921
+ return validationError(`Duplicate clear_improvements id: "${ci.id}"`);
2922
+ }
2923
+ seenCiIds.add(ci.id);
2924
+ }
2361
2925
  // Read design assets and base64-encode for embedding
2362
2926
  const assetsDir = path.join(PROJECT_ROOT, "design-assets");
2363
2927
  const fontsDir = path.join(PROJECT_ROOT, "public", "fonts");
@@ -2399,17 +2963,6 @@ server.registerTool("generate_decision_page", {
2399
2963
  // ---------------------------------------------------------------------------
2400
2964
  // Entry point
2401
2965
  // ---------------------------------------------------------------------------
2402
- // Load custom user pipelines before accepting connections
2403
- const instructionsDir = path.join(path.dirname(BAPI_PIPELINES_DIR), "instructions");
2404
- const customResult = await loadCustomPipelines(BAPI_PIPELINES_DIR, instructionsDir, BUNDLED_INSTRUCTIONS);
2405
- for (const [key, pipeline] of Object.entries(customResult.pipelines)) {
2406
- if (key in BUNDLED_PIPELINES) {
2407
- console.error(`Warning: user pipeline "${key}" overrides bundled pipeline.`);
2408
- }
2409
- PIPELINES[key] = pipeline;
2410
- }
2411
- Object.assign(INSTRUCTIONS, customResult.instructions);
2412
- userPipelineKeys = customResult.userPipelineKeys;
2413
2966
  const transport = new StdioServerTransport();
2414
2967
  await server.connect(transport);
2415
2968
  console.error("Bridge API MCP server running on stdio");