@bridge_gpt/mcp-server 0.1.13 → 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
@@ -25,6 +25,7 @@ import { reconstructAgentMarkdown, translateAgentToCopilot } from "./agent-utils
25
25
  import { resolveRecipe, loadCustomPipelines } from "./pipeline-utils.js";
26
26
  import { generateDecisionPageHtml } from "./decision-page-template.js";
27
27
  import { DecisionPageInputShape } from "./decision-page-schema.js";
28
+ import { runPipeline, resumePipeline, listPipelineRuns, deletePipelineRun, } from "./pipeline-orchestrator.js";
28
29
  // Mutable pipeline/instruction state — starts with bundled, merged with user at startup
29
30
  const PIPELINES = { ...BUNDLED_PIPELINES };
30
31
  const INSTRUCTIONS = { ...BUNDLED_INSTRUCTIONS };
@@ -49,6 +50,9 @@ const POST_HEADERS = {
49
50
  function buildUrl(path) {
50
51
  return `${BASE_URL.replace(/\/+$/, "")}/jira${path}`;
51
52
  }
53
+ function buildApiUrl(path) {
54
+ return `${BASE_URL.replace(/\/+$/, "")}${path}`;
55
+ }
52
56
  function buildGetUrl(path, params) {
53
57
  const url = new URL(buildUrl(path));
54
58
  for (const [key, value] of Object.entries(params)) {
@@ -518,6 +522,32 @@ automatically provided by the server.
518
522
 
519
523
  - \`"halt"\` (default) — stop the pipeline immediately on failure
520
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.
521
551
  `;
522
552
  const exampleContent = JSON.stringify({
523
553
  name: "Example Pipeline",
@@ -646,9 +676,70 @@ const server = new McpServer({
646
676
  version: "1.0.0",
647
677
  });
648
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
+ // ---------------------------------------------------------------------------
649
740
  // Tools
650
741
  // ---------------------------------------------------------------------------
651
- server.registerTool("ping", {
742
+ registerTool("ping", {
652
743
  description: "Test connectivity to Bridge API. Validates that the API key is accepted and the configured repository is accessible. " +
653
744
  "Returns JSON with {status: 'ok', repo_name: '<configured repo>'}. " +
654
745
  "Use this as a quick health check before other operations, or to verify your Bridge API configuration is working. " +
@@ -661,7 +752,42 @@ server.registerTool("ping", {
661
752
  const text = await handleResponse(resp);
662
753
  return { content: [{ type: "text", text }] };
663
754
  });
664
- 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", {
665
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. " +
666
792
  "Returns structured markdown with sections for project context, architecture instructions, code review correctness standards, testing stack information, and build analysis. " +
667
793
  "Only sections with configured values are included. Returns 404 if no standards are configured. " +
@@ -673,7 +799,7 @@ server.registerTool("get_project_standards", {
673
799
  const text = await handleResponse(resp);
674
800
  return { content: [{ type: "text", text }] };
675
801
  });
676
- server.registerTool("get_tickets", {
802
+ registerTool("get_tickets", {
677
803
  description: "Search for and list Jira tickets from the configured project. " +
678
804
  "Filters by query text, status name, or date. Returns up to 'limit' tickets ordered by most recently updated. " +
679
805
  "All data is fetched live from Jira. Use get_ticket to retrieve full details for a specific ticket.",
@@ -720,7 +846,7 @@ server.registerTool("get_tickets", {
720
846
  const text = await handleResponse(resp);
721
847
  return { content: [{ type: "text", text }] };
722
848
  });
723
- server.registerTool("get_ticket", {
849
+ registerTool("get_ticket", {
724
850
  description: "Retrieve full details for a single Jira ticket by its key. " +
725
851
  "Returns summary, status, type, assignee, reporter, description, and timestamps. " +
726
852
  "All data is fetched live from Jira. Use get_tickets to search/list multiple tickets.",
@@ -735,7 +861,7 @@ server.registerTool("get_ticket", {
735
861
  const text = await handleResponse(resp);
736
862
  return { content: [{ type: "text", text }] };
737
863
  });
738
- server.registerTool("create_ticket", {
864
+ registerTool("create_ticket", {
739
865
  description: "Create a new Jira ticket in the configured project. Requires either description or file_path (or both — file_path takes precedence). " +
740
866
  "Returns JSON with {ticket_key: 'PROJ-123', url: 'https://...'}. " +
741
867
  "The ticket is created immediately in Jira — confirm details with the user before calling. " +
@@ -777,7 +903,7 @@ server.registerTool("create_ticket", {
777
903
  const text = await createTicketRequest({ summary, description: resolved.text, issue_type, priority, labels, assignee });
778
904
  return { content: [{ type: "text", text: text + resolved.note }] };
779
905
  });
780
- server.registerTool("get_plan", {
906
+ registerTool("get_plan", {
781
907
  description: "Retrieve the AI-generated implementation plan for a Jira ticket. " +
782
908
  "Returns the full plan as markdown text — present it verbatim without summarizing. " +
783
909
  "The plan includes step-by-step implementation guidance with code file references. " +
@@ -805,7 +931,7 @@ server.registerTool("get_plan", {
805
931
  }
806
932
  return { content: [{ type: "text", text }] };
807
933
  });
808
- server.registerTool("get_architecture", {
934
+ registerTool("get_architecture", {
809
935
  description: "Retrieve the AI-generated architecture plan for a Jira ticket. " +
810
936
  "Returns the full architecture plan as markdown text — present it verbatim without summarizing. " +
811
937
  "The plan includes high-level architectural decisions, component design, and integration guidance. " +
@@ -833,7 +959,7 @@ server.registerTool("get_architecture", {
833
959
  }
834
960
  return { content: [{ type: "text", text }] };
835
961
  });
836
- server.registerTool("get_clarifying_questions", {
962
+ registerTool("get_clarifying_questions", {
837
963
  description: "Retrieve AI-generated clarifying questions (for feature/task tickets) or debugging guidance (for bug tickets) for a Jira ticket. " +
838
964
  "Returns markdown text with questions that should be resolved before implementation begins. " +
839
965
  "Returns 404 if no questions have been generated yet. " +
@@ -860,7 +986,7 @@ server.registerTool("get_clarifying_questions", {
860
986
  }
861
987
  return { content: [{ type: "text", text }] };
862
988
  });
863
- server.registerTool("parse_repository", {
989
+ registerTool("parse_repository", {
864
990
  description: "Queue a background job to parse and index the repository for Bridge API's AI agents. " +
865
991
  "This should be run after major codebase changes so that plans and questions reflect the latest code. " +
866
992
  "Returns 202 with {message: 'Repository parsing queued'} on success, " +
@@ -886,7 +1012,7 @@ server.registerTool("parse_repository", {
886
1012
  const text = await handleResponse(resp);
887
1013
  return { content: [{ type: "text", text }] };
888
1014
  });
889
- server.registerTool("regenerate_directory_map", {
1015
+ registerTool("regenerate_directory_map", {
890
1016
  description: "Regenerate the repository directory map and return the result. " +
891
1017
  "Unlike parse_repository (which is async), this tool is synchronous — it blocks until " +
892
1018
  "the directory map is generated and returns the full map text directly. " +
@@ -910,7 +1036,7 @@ server.registerTool("regenerate_directory_map", {
910
1036
  clearTimeout(timeout);
911
1037
  }
912
1038
  });
913
- server.registerTool("get_parse_status", {
1039
+ registerTool("get_parse_status", {
914
1040
  description: "Check whether a repository parse job is currently running. " +
915
1041
  "Returns {status: 'in_progress', started_at: '<ISO timestamp>'} if a parse is active, " +
916
1042
  "or {status: 'idle'} if no parse is running. " +
@@ -923,7 +1049,7 @@ server.registerTool("get_parse_status", {
923
1049
  const text = await handleResponse(resp);
924
1050
  return { content: [{ type: "text", text }] };
925
1051
  });
926
- server.registerTool("add_comment", {
1052
+ registerTool("add_comment", {
927
1053
  description: "Post a comment on a Jira ticket. The comment appears immediately in Jira. " +
928
1054
  "Supports markdown formatting. " +
929
1055
  "For long comments (over ~2000 characters), set attach_as_file to true — " +
@@ -977,7 +1103,7 @@ server.registerTool("add_comment", {
977
1103
  const text = await handleResponse(resp);
978
1104
  return { content: [{ type: "text", text: text + resolved.note }] };
979
1105
  });
980
- server.registerTool("update_ticket_description", {
1106
+ registerTool("update_ticket_description", {
981
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. " +
982
1108
  "The description should be in markdown format — it will be automatically converted to Jira wiki markup. " +
983
1109
  "This does NOT create a new ticket. Use create_ticket for that. " +
@@ -1009,7 +1135,7 @@ server.registerTool("update_ticket_description", {
1009
1135
  const text = await handleResponse(resp);
1010
1136
  return { content: [{ type: "text", text: text + resolved.note }] };
1011
1137
  });
1012
- server.registerTool("upload_attachment", {
1138
+ registerTool("upload_attachment", {
1013
1139
  description: "Upload a local file as an attachment to a Jira ticket. " +
1014
1140
  "Supports text/UTF-8 files only (markdown, plain text, etc.). " +
1015
1141
  "Optionally syncs the content to Bridge API's tickets_links table so retrieval endpoints " +
@@ -1072,7 +1198,7 @@ server.registerTool("upload_attachment", {
1072
1198
  const text = await handleResponse(resp);
1073
1199
  return { content: [{ type: "text", text: text + resolved.note }] };
1074
1200
  });
1075
- server.registerTool("list_attachments", {
1201
+ registerTool("list_attachments", {
1076
1202
  description: "List attachments on a Jira ticket. " +
1077
1203
  "Returns metadata (ID, filename, MIME type, size, created date) for each attachment. " +
1078
1204
  "By default, AI-generated attachments are excluded. " +
@@ -1097,7 +1223,7 @@ server.registerTool("list_attachments", {
1097
1223
  return { content: [{ type: "text", text }] };
1098
1224
  });
1099
1225
  const MAX_INLINE_TEXT_LENGTH = 50_000;
1100
- server.registerTool("download_attachment", {
1226
+ registerTool("download_attachment", {
1101
1227
  description: "Download an attachment from a Jira ticket and save it to disk. " +
1102
1228
  "Specify either attachment_id or filename (not both). " +
1103
1229
  "For text files, the content is returned inline (truncated at ~50KB) and also saved to disk. " +
@@ -1185,7 +1311,7 @@ server.registerTool("download_attachment", {
1185
1311
  }
1186
1312
  return { content: [{ type: "text", text: resultText }] };
1187
1313
  });
1188
- server.registerTool("request_plan_generation", {
1314
+ registerTool("request_plan_generation", {
1189
1315
  description: "Request AI-generated implementation plan for a Jira ticket. " +
1190
1316
  "This triggers an asynchronous background job — results are NOT immediate. " +
1191
1317
  "Processing typically takes 1-5 minutes depending on ticket complexity and number of attachments. " +
@@ -1254,7 +1380,7 @@ server.registerTool("request_plan_generation", {
1254
1380
  `Use get_plan with ticket_number "${ticket_number}" to retrieve the plan once processing completes.`;
1255
1381
  return { content: [{ type: "text", text: confirmationText }] };
1256
1382
  });
1257
- server.registerTool("request_architecture", {
1383
+ registerTool("request_architecture", {
1258
1384
  description: "Request AI-generated architecture plan for a Jira ticket. " +
1259
1385
  "This triggers an asynchronous background job — results are NOT immediate. " +
1260
1386
  "Processing typically takes 2-4 minutes depending on ticket complexity. " +
@@ -1323,7 +1449,7 @@ server.registerTool("request_architecture", {
1323
1449
  `Use get_architecture with ticket_number "${ticket_number}" to retrieve the architecture plan once processing completes.`;
1324
1450
  return { content: [{ type: "text", text: confirmationText }] };
1325
1451
  });
1326
- server.registerTool("request_clarifying_questions", {
1452
+ registerTool("request_clarifying_questions", {
1327
1453
  description: "Request AI-generated clarifying questions or debugging guidance for a Jira ticket. " +
1328
1454
  "This triggers an asynchronous background job — results are NOT immediate. " +
1329
1455
  "Processing typically takes 1-5 minutes. " +
@@ -1396,7 +1522,7 @@ server.registerTool("request_clarifying_questions", {
1396
1522
  // ---------------------------------------------------------------------------
1397
1523
  // Ticket Quality Critique
1398
1524
  // ---------------------------------------------------------------------------
1399
- server.registerTool("get_ticket_critique", {
1525
+ registerTool("get_ticket_critique", {
1400
1526
  description: "Retrieve AI-generated ticket quality critique for a Jira ticket. " +
1401
1527
  "Returns markdown text with a structured critique covering Standards Conformance Analysis, " +
1402
1528
  "Standards Deviations, and Suggested Improvements. " +
@@ -1424,7 +1550,7 @@ server.registerTool("get_ticket_critique", {
1424
1550
  }
1425
1551
  return { content: [{ type: "text", text }] };
1426
1552
  });
1427
- server.registerTool("request_ticket_critique", {
1553
+ registerTool("request_ticket_critique", {
1428
1554
  description: "Request AI-generated ticket critique for a Jira ticket. " +
1429
1555
  "This triggers an asynchronous background job — results are NOT immediate. " +
1430
1556
  "Processing typically takes 1-5 minutes. " +
@@ -1496,7 +1622,7 @@ server.registerTool("request_ticket_critique", {
1496
1622
  // ---------------------------------------------------------------------------
1497
1623
  // Combined Ticket Review (clarify + critique)
1498
1624
  // ---------------------------------------------------------------------------
1499
- server.registerTool("request_ticket_review", {
1625
+ registerTool("request_ticket_review", {
1500
1626
  description: "Request a combined ticket review that generates BOTH clarifying questions (or debugging guidance for bug tickets) " +
1501
1627
  "AND a ticket quality critique in parallel on the server, halving wall-clock latency vs. running the two " +
1502
1628
  "requests sequentially. This triggers an asynchronous background job — results are NOT immediate. " +
@@ -1603,7 +1729,7 @@ server.registerTool("request_ticket_review", {
1603
1729
  // ---------------------------------------------------------------------------
1604
1730
  // Reimplement Context
1605
1731
  // ---------------------------------------------------------------------------
1606
- server.registerTool("request_reimplement_context", {
1732
+ registerTool("request_reimplement_context", {
1607
1733
  description: "Request processing of new attachments and context assembly for a previously-implemented Jira ticket. " +
1608
1734
  "Use this for follow-up requests on tickets that have already been through the plan+implement cycle. " +
1609
1735
  "This triggers an asynchronous background job to process new attachments/images. " +
@@ -1669,7 +1795,7 @@ server.registerTool("request_reimplement_context", {
1669
1795
  `Use get_reimplement_context with ticket_number "${ticket_number}" to retrieve the results once processing completes.`;
1670
1796
  return { content: [{ type: "text", text: confirmationText }] };
1671
1797
  });
1672
- server.registerTool("get_reimplement_context", {
1798
+ registerTool("get_reimplement_context", {
1673
1799
  description: "Retrieve the assembled reimplement context for a Jira ticket. " +
1674
1800
  "Returns a markdown document with new/changed information diffed against stored state, " +
1675
1801
  "the original ticket description, and the existing implementation plan. " +
@@ -1710,7 +1836,7 @@ server.registerTool("get_reimplement_context", {
1710
1836
  // ---------------------------------------------------------------------------
1711
1837
  // Ticket Lifecycle Tracking
1712
1838
  // ---------------------------------------------------------------------------
1713
- server.registerTool("track_ticket", {
1839
+ registerTool("track_ticket", {
1714
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. " +
1715
1841
  "If the ticket is already tracked, this is a safe no-op — it upserts the description and repo_name without error. " +
1716
1842
  "Call this after creating a ticket with create_ticket to enable state tracking. " +
@@ -1731,7 +1857,7 @@ server.registerTool("track_ticket", {
1731
1857
  const text = await handleResponse(resp);
1732
1858
  return { content: [{ type: "text", text }] };
1733
1859
  });
1734
- server.registerTool("update_ticket_state", {
1860
+ registerTool("update_ticket_state", {
1735
1861
  description: "Update workflow state timestamps on a tracked ticket. Each specified field is set to the current UTC timestamp on the server. " +
1736
1862
  "Valid field names: 'critique_called', 'critique_answered', 'clarify_called', 'clarify_answered', 'plan_generated', 'implemented', 'reimplement_called'. " +
1737
1863
  "The ticket must already be tracked (via track_ticket) or a 404 error is returned. " +
@@ -1751,7 +1877,7 @@ server.registerTool("update_ticket_state", {
1751
1877
  const text = await handleResponse(resp);
1752
1878
  return { content: [{ type: "text", text }] };
1753
1879
  });
1754
- server.registerTool("get_ticket_state", {
1880
+ registerTool("get_ticket_state", {
1755
1881
  description: "Retrieve workflow state timestamps and artifact existence flags for a tracked ticket. " +
1756
1882
  "Returns timestamps for each state field (critique_called, critique_answered, clarify_called, clarify_answered, plan_generated, implemented, reimplement_called) " +
1757
1883
  "and boolean flags indicating whether artifacts exist (has_clarifying_questions, has_critique, has_plan). " +
@@ -1769,7 +1895,7 @@ server.registerTool("get_ticket_state", {
1769
1895
  // ---------------------------------------------------------------------------
1770
1896
  // Jira Transitions
1771
1897
  // ---------------------------------------------------------------------------
1772
- server.registerTool("get_jira_transitions", {
1898
+ registerTool("get_jira_transitions", {
1773
1899
  description: "List available Jira workflow transitions for a ticket. Returns each transition's id, name, and target status. " +
1774
1900
  "Use this to discover what status changes are possible for a given ticket. " +
1775
1901
  "The repo_name is automatically injected from the configured environment.",
@@ -1782,7 +1908,7 @@ server.registerTool("get_jira_transitions", {
1782
1908
  const text = await handleResponse(resp);
1783
1909
  return { content: [{ type: "text", text }] };
1784
1910
  });
1785
- server.registerTool("update_jira_status", {
1911
+ registerTool("update_jira_status", {
1786
1912
  description: "Transition a Jira ticket to a specified target status by executing a workflow transition. " +
1787
1913
  "Provide either target_status (matched case-insensitively against available transitions) or transition_id (used directly). " +
1788
1914
  "If transition_id is provided, it takes precedence over target_status. " +
@@ -1809,7 +1935,7 @@ server.registerTool("update_jira_status", {
1809
1935
  const text = await handleResponse(resp);
1810
1936
  return { content: [{ type: "text", text }] };
1811
1937
  });
1812
- server.registerTool("resolve_target_status", {
1938
+ registerTool("resolve_target_status", {
1813
1939
  description: "Resolve the post-PR target Jira status for the configured repository using an LLM agent. " +
1814
1940
  "The agent selects the workflow status that best represents 'code committed via PR but not yet tested.' " +
1815
1941
  "Results are cached per-project — subsequent calls return the cached value unless force_rerun is true. " +
@@ -1840,12 +1966,13 @@ server.registerTool("resolve_target_status", {
1840
1966
  const VALID_CONFIG_FIELDS = [
1841
1967
  "review_instructions", "documentation_instructions", "architecture_instructions",
1842
1968
  "unit_testing_instructions", "e2e_testing_instructions",
1969
+ "unit_testing_stack", "e2e_testing_stack",
1843
1970
  "frontend_correctness_standards", "backend_correctness_standards",
1844
1971
  "template_correctness_standards", "style_correctness_standards",
1845
1972
  "design_principles",
1846
- "post_pr_target_status", "ci_check_config",
1973
+ "post_pr_target_status", "ci_check_config", "ci_followup_config",
1847
1974
  ].join(", ");
1848
- server.registerTool("list_config_fields", {
1975
+ registerTool("list_config_fields", {
1849
1976
  description: "List all configurable fields available for reading and updating via the Bridge API. " +
1850
1977
  "Returns each field's name and a description of its purpose. No database values are returned — " +
1851
1978
  "use get_config_field to read a specific field's current value. " +
@@ -1857,7 +1984,7 @@ server.registerTool("list_config_fields", {
1857
1984
  const text = await handleResponse(resp);
1858
1985
  return { content: [{ type: "text", text }] };
1859
1986
  });
1860
- server.registerTool("get_my_role", {
1987
+ registerTool("get_my_role", {
1861
1988
  description: "Check the role and auth source for the current API key. " +
1862
1989
  "Returns JSON with {role: \"admin\" | \"member\" | null, source: \"user_access\" | \"legacy\"}. " +
1863
1990
  "Use this to check if the current key has admin permissions before attempting configuration updates " +
@@ -1869,7 +1996,7 @@ server.registerTool("get_my_role", {
1869
1996
  const text = await handleResponse(resp);
1870
1997
  return { content: [{ type: "text", text }] };
1871
1998
  });
1872
- server.registerTool("get_config_field", {
1999
+ registerTool("get_config_field", {
1873
2000
  description: "Read the current value and metadata for a specific configuration field. " +
1874
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. " +
1875
2002
  "Use this before update_config_field to understand the current state and build upon it rather than overwriting blindly.",
@@ -1882,7 +2009,7 @@ server.registerTool("get_config_field", {
1882
2009
  const text = await handleResponse(resp);
1883
2010
  return { content: [{ type: "text", text }] };
1884
2011
  });
1885
- server.registerTool("update_config_field", {
2012
+ registerTool("update_config_field", {
1886
2013
  description: "Update a specific configuration field in the Bridge database. " +
1887
2014
  "These fields control LLM behavior during code review, planning, and documentation. " +
1888
2015
  "Always call get_config_field first to read the current value and build upon it. " +
@@ -1914,10 +2041,63 @@ server.registerTool("update_config_field", {
1914
2041
  const text = await handleResponse(resp);
1915
2042
  return { content: [{ type: "text", text: text + note }] };
1916
2043
  });
1917
- // ---------------------------------------------------------------------------
1918
- // Deep Research
1919
- // ---------------------------------------------------------------------------
1920
- 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", {
1921
2101
  description: "Submit a deep research request on a technical topic using AI-powered web search. " +
1922
2102
  "Returns a task_id for tracking the research progress. " +
1923
2103
  "\n\n" +
@@ -1981,6 +2161,7 @@ server.registerTool("request_deep_research", {
1981
2161
  const MAX_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
1982
2162
  let pollIntervalMs = 15_000; // start at 15 seconds
1983
2163
  let lastStatus = "queued";
2164
+ let latestStatusBody = null;
1984
2165
  while (Date.now() - startTime < MAX_TIMEOUT_MS) {
1985
2166
  await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
1986
2167
  const elapsed = Math.round((Date.now() - startTime) / 1000);
@@ -1993,15 +2174,15 @@ server.registerTool("request_deep_research", {
1993
2174
  }
1994
2175
  const statusBody = (await statusResp.json());
1995
2176
  lastStatus = statusBody.status;
2177
+ latestStatusBody = statusBody;
1996
2178
  if (lastStatus === "completed") {
1997
2179
  break;
1998
2180
  }
1999
2181
  if (lastStatus === "failed") {
2000
- const errorMsg = statusBody.error_message || "Unknown error";
2001
2182
  return {
2002
2183
  content: [{
2003
2184
  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.` }),
2185
+ text: formatDeepResearchFailure(statusBody),
2005
2186
  }],
2006
2187
  };
2007
2188
  }
@@ -2011,10 +2192,13 @@ server.registerTool("request_deep_research", {
2011
2192
  }
2012
2193
  }
2013
2194
  if (lastStatus !== "completed") {
2195
+ const statusSuffix = latestStatusBody
2196
+ ? ` ${formatDeepResearchStatus(latestStatusBody, taskId)}`
2197
+ : "";
2014
2198
  return {
2015
2199
  content: [{
2016
2200
  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.` }),
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.`,
2018
2202
  }],
2019
2203
  };
2020
2204
  }
@@ -2034,7 +2218,7 @@ server.registerTool("request_deep_research", {
2034
2218
  }
2035
2219
  return { content: [{ type: "text", text: resultText }] };
2036
2220
  });
2037
- server.registerTool("get_deep_research", {
2221
+ registerTool("get_deep_research", {
2038
2222
  description: "Retrieve the result of a previously submitted deep research request. " +
2039
2223
  "Returns the full markdown research report if the task is completed, " +
2040
2224
  "or a structured status response if still processing or failed. " +
@@ -2055,11 +2239,10 @@ server.registerTool("get_deep_research", {
2055
2239
  }
2056
2240
  const statusBody = (await statusResp.json());
2057
2241
  if (statusBody.status === "failed") {
2058
- const errorMsg = statusBody.error_message || "Unknown error";
2059
2242
  return {
2060
2243
  content: [{
2061
2244
  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.` }),
2245
+ text: formatDeepResearchFailure(statusBody),
2063
2246
  }],
2064
2247
  };
2065
2248
  }
@@ -2067,7 +2250,7 @@ server.registerTool("get_deep_research", {
2067
2250
  return {
2068
2251
  content: [{
2069
2252
  type: "text",
2070
- 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),
2071
2254
  }],
2072
2255
  };
2073
2256
  }
@@ -2087,10 +2270,219 @@ server.registerTool("get_deep_research", {
2087
2270
  }
2088
2271
  return { content: [{ type: "text", text: resultText }] };
2089
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
+ });
2090
2482
  // ---------------------------------------------------------------------------
2091
2483
  // VCS & CI Tools
2092
2484
  // ---------------------------------------------------------------------------
2093
- server.registerTool("create_pull_request", {
2485
+ registerTool("create_pull_request", {
2094
2486
  description: "Create a pull request on the configured VCS provider (GitHub or Bitbucket). " +
2095
2487
  "Returns a structured response with {available, reason, action, detail}. " +
2096
2488
  "If a PR already exists for the head branch, returns it with created=false. " +
@@ -2119,7 +2511,7 @@ server.registerTool("create_pull_request", {
2119
2511
  const text = await handleResponse(resp);
2120
2512
  return { content: [{ type: "text", text }] };
2121
2513
  });
2122
- const resolveCiChecksTool = server.registerTool("resolve_ci_checks", {
2514
+ const resolveCiChecksTool = registerTool("resolve_ci_checks", {
2123
2515
  description: "Discover and classify CI checks for the configured repository. " +
2124
2516
  "Queries GitHub Check Runs + Commit Statuses APIs (or Bitbucket Build Statuses), " +
2125
2517
  "then uses Branch Protection API or LLM to determine which checks are required for merging. " +
@@ -2155,7 +2547,7 @@ const resolveCiChecksTool = server.registerTool("resolve_ci_checks", {
2155
2547
  }
2156
2548
  return { content: [{ type: "text", text }] };
2157
2549
  });
2158
- const pollCiChecksTool = server.registerTool("poll_ci_checks", {
2550
+ const pollCiChecksTool = registerTool("poll_ci_checks", {
2159
2551
  description: "Poll the current status of CI checks for a specific commit. " +
2160
2552
  "Requires that resolve_ci_checks has been called first to populate the check configuration. " +
2161
2553
  "Returns per-check status, all_complete, all_passed, and unknown_checks fields. " +
@@ -2204,9 +2596,30 @@ async function checkCiConfigAndDisablePoll() {
2204
2596
  // Check config before connecting
2205
2597
  await checkCiConfigAndDisablePoll();
2206
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
+ // ---------------------------------------------------------------------------
2207
2620
  // Pipeline Recipe Tools
2208
2621
  // ---------------------------------------------------------------------------
2209
- server.registerTool("get_docs_dir", {
2622
+ registerTool("get_docs_dir", {
2210
2623
  description: "Return the locally configured docs directory path (BAPI_DOCS_DIR, default docs/tmp). " +
2211
2624
  "No parameters. Use this instead of reading the BAPI_DOCS_DIR environment variable directly, " +
2212
2625
  "which requires shell access and may be blocked on some AI coding platforms.",
@@ -2214,7 +2627,7 @@ server.registerTool("get_docs_dir", {
2214
2627
  }, async () => {
2215
2628
  return { content: [{ type: "text", text: BAPI_DOCS_DIR }] };
2216
2629
  });
2217
- server.registerTool("list_pipelines", {
2630
+ registerTool("list_pipelines", {
2218
2631
  description: "List all available pipeline recipes with their names, descriptions, and required variables. " +
2219
2632
  "No parameters. Use this to discover available pipelines before calling get_pipeline_recipe.",
2220
2633
  inputSchema: {},
@@ -2229,7 +2642,7 @@ server.registerTool("list_pipelines", {
2229
2642
  content: [{ type: "text", text: JSON.stringify(list, null, 2) }],
2230
2643
  };
2231
2644
  });
2232
- server.registerTool("get_pipeline_recipe", {
2645
+ registerTool("get_pipeline_recipe", {
2233
2646
  description: "Retrieve a fully resolved pipeline recipe by name. Substitutes variables, resolves instruction " +
2234
2647
  "file references to inline content, and returns an ordered array of executable steps. " +
2235
2648
  "Each step is either an mcp_call (with tool name and params) or an agent_task (with instruction text). " +
@@ -2247,8 +2660,16 @@ server.registerTool("get_pipeline_recipe", {
2247
2660
  .array(z.string())
2248
2661
  .optional()
2249
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."),
2250
2671
  },
2251
- }, async ({ pipeline: pipelineName, variables, skip_steps }) => {
2672
+ }, async ({ pipeline: pipelineName, variables, skip_steps, auto_approve }) => {
2252
2673
  const pipelineDef = PIPELINES[pipelineName];
2253
2674
  if (!pipelineDef) {
2254
2675
  const available = Object.keys(PIPELINES).join(", ");
@@ -2263,9 +2684,27 @@ server.registerTool("get_pipeline_recipe", {
2263
2684
  }],
2264
2685
  };
2265
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
+ }
2266
2699
  try {
2267
- const mergedVariables = { docs_dir: BAPI_DOCS_DIR, provider: "", second_opinion: "", ...(variables ?? {}) };
2268
- 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);
2269
2708
  return {
2270
2709
  content: [{
2271
2710
  type: "text",
@@ -2287,9 +2726,148 @@ server.registerTool("get_pipeline_recipe", {
2287
2726
  }
2288
2727
  });
2289
2728
  // ---------------------------------------------------------------------------
2729
+ // Pipeline Execution Tools (BAPI-275)
2730
+ // ---------------------------------------------------------------------------
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(),
2771
+ inputSchema: {
2772
+ pipeline: z
2773
+ .string()
2774
+ .describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),
2775
+ variables: z
2776
+ .record(z.string())
2777
+ .optional()
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")])
2782
+ .optional()
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"),
2816
+ },
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
+ // ---------------------------------------------------------------------------
2290
2868
  // generate_decision_page
2291
2869
  // ---------------------------------------------------------------------------
2292
- server.registerTool("generate_decision_page", {
2870
+ registerTool("generate_decision_page", {
2293
2871
  description: "Generate a local HTML decision page for capturing user decisions on ticket review findings. " +
2294
2872
  "Renders recommendation-driven review decisions sourced from the combined review-and-resolution " +
2295
2873
  "document, with per-option consequence lines, a closed-by-default codebase-evidence disclosure, " +
@@ -2385,17 +2963,6 @@ server.registerTool("generate_decision_page", {
2385
2963
  // ---------------------------------------------------------------------------
2386
2964
  // Entry point
2387
2965
  // ---------------------------------------------------------------------------
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
2966
  const transport = new StdioServerTransport();
2400
2967
  await server.connect(transport);
2401
2968
  console.error("Bridge API MCP server running on stdio");