@bridge_gpt/mcp-server 0.1.0

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 ADDED
@@ -0,0 +1,1630 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Bridge API MCP Server
4
+ *
5
+ * Exposes Bridge API Jira endpoints as MCP tools for Claude Code agents.
6
+ * Reads configuration from environment variables:
7
+ * BAPI_BASE_URL - Base URL of the Bridge API (e.g. http://localhost:8000)
8
+ * BAPI_REPO_NAME - Default repository name injected into every request
9
+ * BAPI_API_KEY - API key for X-API-Key authentication
10
+ * BAPI_DOCS_DIR - Base directory for local file output (default: docs/tmp)
11
+ */
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
+ import { z } from "zod";
15
+ import { writeFile, mkdir, readFile, stat } from "fs/promises";
16
+ import path from "path";
17
+ import { PIPELINES, INSTRUCTIONS } from "./pipelines.generated.js";
18
+ import { resolveRecipe } from "./pipeline-utils.js";
19
+ // ---------------------------------------------------------------------------
20
+ // Configuration
21
+ // ---------------------------------------------------------------------------
22
+ const BASE_URL = process.env.BAPI_BASE_URL ?? "http://localhost:8000";
23
+ const REPO_NAME = process.env.BAPI_REPO_NAME ?? "";
24
+ const API_KEY = process.env.BAPI_API_KEY ?? "";
25
+ const BAPI_DOCS_DIR = process.env.BAPI_DOCS_DIR ?? "docs/tmp";
26
+ const GET_HEADERS = { "X-API-Key": API_KEY };
27
+ const POST_HEADERS = {
28
+ "X-API-Key": API_KEY,
29
+ "Content-Type": "application/json",
30
+ };
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+ function buildUrl(path) {
35
+ return `${BASE_URL.replace(/\/+$/, "")}/jira${path}`;
36
+ }
37
+ function buildGetUrl(path, params) {
38
+ const url = new URL(buildUrl(path));
39
+ for (const [key, value] of Object.entries(params)) {
40
+ url.searchParams.set(key, value);
41
+ }
42
+ return url.toString();
43
+ }
44
+ function getDocsPath(subdir) {
45
+ return path.join(BAPI_DOCS_DIR, subdir);
46
+ }
47
+ function slugify(text, maxLength = 60) {
48
+ return text
49
+ .toLowerCase()
50
+ .replace(/[^a-z0-9\s-]/g, "")
51
+ .trim()
52
+ .replace(/\s+/g, "-")
53
+ .replace(/-+/g, "-")
54
+ .slice(0, maxLength)
55
+ .replace(/-$/, "");
56
+ }
57
+ const ERROR_CODES = {
58
+ 400: "BAD_REQUEST",
59
+ 401: "UNAUTHORIZED",
60
+ 403: "FORBIDDEN",
61
+ 404: "NOT_FOUND",
62
+ 409: "CONFLICT",
63
+ 422: "VALIDATION_ERROR",
64
+ 429: "RATE_LIMITED",
65
+ 500: "INTERNAL_ERROR",
66
+ 502: "BAD_GATEWAY",
67
+ 503: "SERVICE_UNAVAILABLE",
68
+ 504: "GATEWAY_TIMEOUT",
69
+ };
70
+ async function handleResponse(resp) {
71
+ if (resp.ok) {
72
+ const contentType = resp.headers.get("content-type") ?? "";
73
+ if (contentType.includes("application/json")) {
74
+ const body = await resp.json();
75
+ return JSON.stringify(body, null, 2);
76
+ }
77
+ return await resp.text();
78
+ }
79
+ const rawText = await resp.text();
80
+ const errorCode = ERROR_CODES[resp.status] ?? "UNKNOWN_ERROR";
81
+ let message = rawText;
82
+ try {
83
+ const parsed = JSON.parse(rawText);
84
+ if (parsed.detail) {
85
+ message = typeof parsed.detail === "string" ? parsed.detail : JSON.stringify(parsed.detail);
86
+ }
87
+ }
88
+ catch {
89
+ // Response body is not JSON; use raw text as message
90
+ }
91
+ return JSON.stringify({ error: errorCode, status: resp.status, message });
92
+ }
93
+ // ---------------------------------------------------------------------------
94
+ // Shared Helpers
95
+ // ---------------------------------------------------------------------------
96
+ async function createTicketRequest(params) {
97
+ const payload = {
98
+ repo_name: REPO_NAME,
99
+ summary: params.summary,
100
+ description: params.description,
101
+ issue_type: params.issue_type,
102
+ };
103
+ if (params.priority)
104
+ payload.priority = params.priority;
105
+ if (params.labels)
106
+ payload.labels = params.labels;
107
+ if (params.assignee)
108
+ payload.assignee = params.assignee;
109
+ const resp = await fetch(buildUrl("/create-ticket"), {
110
+ method: "POST",
111
+ headers: POST_HEADERS,
112
+ body: JSON.stringify(payload),
113
+ });
114
+ return handleResponse(resp);
115
+ }
116
+ async function saveLocally(dir, filename, content) {
117
+ const filePath = path.join(dir, filename);
118
+ try {
119
+ await mkdir(dir, { recursive: true });
120
+ await writeFile(filePath, content, "utf-8");
121
+ return `\n\n---\nSaved to ${filePath}`;
122
+ }
123
+ catch (writeErr) {
124
+ return `\n\n---\nNote: Failed to save file to ${filePath}: ${writeErr}`;
125
+ }
126
+ }
127
+ async function resolveTextOrFile(textValue, filePath, textLabel) {
128
+ if (!filePath && !textValue) {
129
+ return {
130
+ ok: false,
131
+ errorResponse: {
132
+ content: [{
133
+ type: "text",
134
+ text: JSON.stringify({ error: "BAD_REQUEST", status: 400, message: `Either ${textLabel} or file_path must be provided.` }),
135
+ }],
136
+ },
137
+ };
138
+ }
139
+ let resolvedText = textValue || "";
140
+ let note = "";
141
+ if (filePath) {
142
+ try {
143
+ const fileStat = await stat(filePath);
144
+ if (fileStat.size > 1_048_576) {
145
+ return {
146
+ ok: false,
147
+ errorResponse: {
148
+ content: [{
149
+ type: "text",
150
+ text: JSON.stringify({ error: "BAD_REQUEST", status: 400, message: `File at ${filePath} exceeds 1MB size limit. Please provide the ${textLabel} directly or use a smaller file.` }),
151
+ }],
152
+ },
153
+ };
154
+ }
155
+ resolvedText = await readFile(filePath, "utf-8");
156
+ if (textValue) {
157
+ note = `\n\nNote: Both file_path and ${textLabel} were provided. file_path content was used.`;
158
+ }
159
+ }
160
+ catch (err) {
161
+ return {
162
+ ok: false,
163
+ errorResponse: {
164
+ content: [{
165
+ type: "text",
166
+ text: JSON.stringify({ error: "BAD_REQUEST", status: 400, message: `Error reading file at ${filePath}: ${err instanceof Error ? err.message : String(err)}` }),
167
+ }],
168
+ },
169
+ };
170
+ }
171
+ }
172
+ return { ok: true, text: resolvedText, note };
173
+ }
174
+ async function pollForResult(getUrl, timeoutMs, label) {
175
+ const startTime = Date.now();
176
+ let pollIntervalMs = 15_000;
177
+ while (true) {
178
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
179
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
180
+ if (Date.now() - startTime >= timeoutMs) {
181
+ return {
182
+ ok: false,
183
+ text: `${label} timed out after ${Math.round(timeoutMs / 1000)} seconds. The task may still be processing on the server.`,
184
+ };
185
+ }
186
+ console.error(`${label} in progress... (elapsed: ${elapsed}s)`);
187
+ const resp = await fetch(getUrl, { headers: GET_HEADERS });
188
+ if (resp.status === 404) {
189
+ await resp.text();
190
+ }
191
+ else {
192
+ const isOk = resp.ok;
193
+ const text = await handleResponse(resp);
194
+ return { ok: isOk, text };
195
+ }
196
+ if (Date.now() - startTime > 60_000) {
197
+ pollIntervalMs = 30_000;
198
+ }
199
+ }
200
+ }
201
+ // ---------------------------------------------------------------------------
202
+ // Server
203
+ // ---------------------------------------------------------------------------
204
+ const server = new McpServer({
205
+ name: "bridge-api",
206
+ version: "1.0.0",
207
+ });
208
+ // ---------------------------------------------------------------------------
209
+ // Tools
210
+ // ---------------------------------------------------------------------------
211
+ server.registerTool("ping", {
212
+ description: "Test connectivity to Bridge API. Validates that the API key is accepted and the configured repository is accessible. " +
213
+ "Returns JSON with {status: 'ok', repo_name: '<configured repo>'}. " +
214
+ "Use this as a quick health check before other operations, or to verify your Bridge API configuration is working. " +
215
+ "A 403 response means the API key is invalid or the repo is not authorized. " +
216
+ "If the server is unreachable, check that BAPI_BASE_URL points to a running Bridge API instance.",
217
+ inputSchema: {},
218
+ }, async () => {
219
+ const url = buildGetUrl("/ping", { repo_name: REPO_NAME });
220
+ const resp = await fetch(url, { headers: GET_HEADERS });
221
+ const text = await handleResponse(resp);
222
+ return { content: [{ type: "text", text }] };
223
+ });
224
+ server.registerTool("get_project_standards", {
225
+ description: "Retrieve project-specific coding standards, architecture guidelines, testing standards, and code review standards for the configured repository. " +
226
+ "Returns structured markdown with sections for architecture instructions, code review correctness standards, testing stack information, and build analysis. " +
227
+ "Only sections with configured values are included. Returns 404 if no standards are configured. " +
228
+ "Consult these standards before writing or reviewing code to ensure compliance with project conventions.",
229
+ inputSchema: {},
230
+ }, async () => {
231
+ const url = buildGetUrl("/project-standards", { repo_name: REPO_NAME });
232
+ const resp = await fetch(url, { headers: GET_HEADERS });
233
+ const text = await handleResponse(resp);
234
+ return { content: [{ type: "text", text }] };
235
+ });
236
+ server.registerTool("get_tickets", {
237
+ description: "Search for and list Jira tickets from the configured project. " +
238
+ "Filters by query text, status name, or date. Returns up to 'limit' tickets ordered by most recently updated. " +
239
+ "All data is fetched live from Jira. Use get_ticket to retrieve full details for a specific ticket.",
240
+ inputSchema: {
241
+ query: z
242
+ .string()
243
+ .optional()
244
+ .describe("Free-text search string. Filters tickets via JQL text ~ '...' " +
245
+ "(searches summary, description, comments). " +
246
+ "Examples: \"authentication error\", \"login page crash\", \"payment timeout\""),
247
+ status: z
248
+ .string()
249
+ .optional()
250
+ .describe("Filter by Jira status name (e.g. 'To Do', 'In Progress', 'Done')"),
251
+ limit: z
252
+ .number()
253
+ .optional()
254
+ .default(20)
255
+ .describe("Maximum number of tickets to return (1-100, default 20)"),
256
+ offset: z
257
+ .number()
258
+ .optional()
259
+ .default(0)
260
+ .describe("Number of results to skip for pagination (default 0)"),
261
+ updated_since: z
262
+ .string()
263
+ .optional()
264
+ .describe("ISO date string (YYYY-MM-DD). Only return tickets updated on or after this date"),
265
+ },
266
+ }, async ({ query, status, limit, offset, updated_since }) => {
267
+ const params = { repo_name: REPO_NAME };
268
+ if (query)
269
+ params.query = query;
270
+ if (status)
271
+ params.status = status;
272
+ if (limit !== undefined)
273
+ params.limit = String(limit);
274
+ if (offset !== undefined && offset > 0)
275
+ params.offset = String(offset);
276
+ if (updated_since)
277
+ params.updated_since = updated_since;
278
+ const url = buildGetUrl("/tickets", params);
279
+ const resp = await fetch(url, { headers: GET_HEADERS });
280
+ const text = await handleResponse(resp);
281
+ return { content: [{ type: "text", text }] };
282
+ });
283
+ server.registerTool("get_ticket", {
284
+ description: "Retrieve full details for a single Jira ticket by its key. " +
285
+ "Returns summary, status, type, assignee, reporter, description, and timestamps. " +
286
+ "All data is fetched live from Jira. Use get_tickets to search/list multiple tickets.",
287
+ inputSchema: {
288
+ ticket_number: z
289
+ .string()
290
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
291
+ },
292
+ }, async ({ ticket_number }) => {
293
+ const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}`, { repo_name: REPO_NAME });
294
+ const resp = await fetch(url, { headers: GET_HEADERS });
295
+ const text = await handleResponse(resp);
296
+ return { content: [{ type: "text", text }] };
297
+ });
298
+ server.registerTool("create_ticket", {
299
+ description: "Create a new Jira ticket in the configured project. Requires either description or file_path (or both — file_path takes precedence). " +
300
+ "Returns JSON with {ticket_key: 'PROJ-123', url: 'https://...'}. " +
301
+ "The ticket is created immediately in Jira — confirm details with the user before calling. " +
302
+ "The description field supports Jira markdown formatting.",
303
+ inputSchema: {
304
+ summary: z.string().describe("Ticket title — keep under 100 characters"),
305
+ description: z
306
+ .string()
307
+ .optional()
308
+ .describe("Required unless file_path is provided. Detailed description in markdown. " +
309
+ "Recommended structure: Summary (2-4 sentences), Requirements (bullet list with code file references), " +
310
+ "Acceptance Criteria (testable 'Done when...' statements)"),
311
+ file_path: z
312
+ .string()
313
+ .optional()
314
+ .describe("Path to a local markdown file whose contents will be used as the ticket description. " +
315
+ "If both file_path and description are provided, file_path takes precedence. " +
316
+ "The file must be UTF-8 encoded and under 1MB."),
317
+ issue_type: z
318
+ .string()
319
+ .describe("One of: 'Bug' (defect), 'Story' (user-facing feature), 'Task' (technical/infrastructure work)"),
320
+ priority: z
321
+ .string()
322
+ .optional()
323
+ .describe("One of: 'Highest', 'High', 'Medium', 'Low', 'Lowest'. Omit to use Jira project default"),
324
+ labels: z
325
+ .array(z.string())
326
+ .optional()
327
+ .describe("List of Jira labels to apply (e.g. ['frontend', 'tech-debt'])"),
328
+ assignee: z
329
+ .string()
330
+ .optional()
331
+ .describe("Jira username or account ID of the assignee. Omit to leave unassigned"),
332
+ },
333
+ }, async ({ summary, description, file_path, issue_type, priority, labels, assignee }) => {
334
+ const resolved = await resolveTextOrFile(description, file_path, "description");
335
+ if (!resolved.ok)
336
+ return resolved.errorResponse;
337
+ const text = await createTicketRequest({ summary, description: resolved.text, issue_type, priority, labels, assignee });
338
+ return { content: [{ type: "text", text: text + resolved.note }] };
339
+ });
340
+ server.registerTool("get_plan", {
341
+ description: "Retrieve the AI-generated implementation plan for a Jira ticket. " +
342
+ "Returns the full plan as markdown text — present it verbatim without summarizing. " +
343
+ "The plan includes step-by-step implementation guidance with code file references. " +
344
+ "Returns 404 if no plan has been generated yet (the ticket may not have been processed by Bridge API). " +
345
+ "Tip: call get_clarifying_questions for the same ticket to get the full context for implementation.",
346
+ inputSchema: {
347
+ ticket_number: z
348
+ .string()
349
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123, BAPI-42)"),
350
+ save_locally: z
351
+ .boolean()
352
+ .optional()
353
+ .default(true)
354
+ .describe("Whether to save the plan to a local file in the BAPI_DOCS_DIR/plans/ directory. " +
355
+ "Defaults to true. Set to false to skip saving."),
356
+ },
357
+ }, async ({ ticket_number, save_locally }) => {
358
+ const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/plan`, { repo_name: REPO_NAME });
359
+ const resp = await fetch(url, { headers: GET_HEADERS });
360
+ const ok = resp.ok;
361
+ let text = await handleResponse(resp);
362
+ if (ok && save_locally) {
363
+ const note = await saveLocally(getDocsPath("plans"), `${ticket_number}-plan.md`, text);
364
+ text += note;
365
+ }
366
+ return { content: [{ type: "text", text }] };
367
+ });
368
+ server.registerTool("get_architecture", {
369
+ description: "Retrieve the AI-generated architecture plan for a Jira ticket. " +
370
+ "Returns the full architecture plan as markdown text — present it verbatim without summarizing. " +
371
+ "The plan includes high-level architectural decisions, component design, and integration guidance. " +
372
+ "Returns 404 if no architecture plan has been generated yet. " +
373
+ "Tip: use request_architecture to trigger architecture plan generation first.",
374
+ inputSchema: {
375
+ ticket_number: z
376
+ .string()
377
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123, BAPI-42)"),
378
+ save_locally: z
379
+ .boolean()
380
+ .optional()
381
+ .default(true)
382
+ .describe("Whether to save the architecture plan to a local file in the BAPI_DOCS_DIR/architecture/ directory. " +
383
+ "Defaults to true. Set to false to skip saving."),
384
+ },
385
+ }, async ({ ticket_number, save_locally }) => {
386
+ const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/architecture-plan`, { repo_name: REPO_NAME });
387
+ const resp = await fetch(url, { headers: GET_HEADERS });
388
+ const ok = resp.ok;
389
+ let text = await handleResponse(resp);
390
+ if (ok && save_locally) {
391
+ const note = await saveLocally(getDocsPath("architecture"), `${ticket_number}-architecture-plan.md`, text);
392
+ text += note;
393
+ }
394
+ return { content: [{ type: "text", text }] };
395
+ });
396
+ server.registerTool("get_clarifying_questions", {
397
+ description: "Retrieve AI-generated clarifying questions (for feature/task tickets) or debugging guidance (for bug tickets) for a Jira ticket. " +
398
+ "Returns markdown text with questions that should be resolved before implementation begins. " +
399
+ "Returns 404 if no questions have been generated yet. " +
400
+ "Tip: call get_plan for the same ticket to get the implementation plan alongside these questions.",
401
+ inputSchema: {
402
+ ticket_number: z
403
+ .string()
404
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123, BAPI-42)"),
405
+ save_locally: z
406
+ .boolean()
407
+ .optional()
408
+ .default(true)
409
+ .describe("Whether to save the clarifying questions to a local file in the BAPI_DOCS_DIR/clarifying-questions/ directory. " +
410
+ "Defaults to true. Set to false to skip saving."),
411
+ },
412
+ }, async ({ ticket_number, save_locally }) => {
413
+ const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/clarifying-questions`, { repo_name: REPO_NAME });
414
+ const resp = await fetch(url, { headers: GET_HEADERS });
415
+ const ok = resp.ok;
416
+ let text = await handleResponse(resp);
417
+ if (ok && save_locally) {
418
+ const note = await saveLocally(getDocsPath("clarifying-questions"), `${ticket_number}-clarifying-questions.md`, text);
419
+ text += note;
420
+ }
421
+ return { content: [{ type: "text", text }] };
422
+ });
423
+ server.registerTool("parse_repository", {
424
+ description: "Queue a background job to parse and index the repository for Bridge API's AI agents. " +
425
+ "This should be run after major codebase changes so that plans and questions reflect the latest code. " +
426
+ "Returns 202 with {message: 'Repository parsing queued'} on success, " +
427
+ "or {message: 'Repository parsing already in progress'} if a job is already running. " +
428
+ "The job runs asynchronously — there is no completion callback. " +
429
+ "For large repositories this may take several minutes. Confirm with the user before triggering.",
430
+ inputSchema: {
431
+ directory_path: z
432
+ .string()
433
+ .optional()
434
+ .describe("Subdirectory to scope the parse to (e.g. 'src/python'). " +
435
+ "Omit to parse the entire repository"),
436
+ },
437
+ }, async ({ directory_path }) => {
438
+ const payload = { repo_name: REPO_NAME };
439
+ if (directory_path)
440
+ payload.directory_path = directory_path;
441
+ const resp = await fetch(buildUrl("/parse-repository"), {
442
+ method: "POST",
443
+ headers: POST_HEADERS,
444
+ body: JSON.stringify(payload),
445
+ });
446
+ const text = await handleResponse(resp);
447
+ return { content: [{ type: "text", text }] };
448
+ });
449
+ server.registerTool("regenerate_directory_map", {
450
+ description: "Regenerate the repository directory map and return the result. " +
451
+ "Unlike parse_repository (which is async), this tool is synchronous — it blocks until " +
452
+ "the directory map is generated and returns the full map text directly. " +
453
+ "Use this when you need the directory map immediately (e.g. for architecture analysis). " +
454
+ "May take 30-120 seconds for large repositories.",
455
+ inputSchema: {},
456
+ }, async () => {
457
+ const controller = new AbortController();
458
+ const timeout = setTimeout(() => controller.abort(), 120_000);
459
+ try {
460
+ const resp = await fetch(buildUrl("/regenerate-directory-map"), {
461
+ method: "POST",
462
+ headers: POST_HEADERS,
463
+ body: JSON.stringify({ repo_name: REPO_NAME }),
464
+ signal: controller.signal,
465
+ });
466
+ const text = await handleResponse(resp);
467
+ return { content: [{ type: "text", text }] };
468
+ }
469
+ finally {
470
+ clearTimeout(timeout);
471
+ }
472
+ });
473
+ server.registerTool("get_parse_status", {
474
+ description: "Check whether a repository parse job is currently running. " +
475
+ "Returns {status: 'in_progress', started_at: '<ISO timestamp>'} if a parse is active, " +
476
+ "or {status: 'idle'} if no parse is running. " +
477
+ "Use this after calling parse_repository to monitor whether the job has finished. " +
478
+ "The repo_name is automatically injected from the configured environment.",
479
+ inputSchema: {},
480
+ }, async () => {
481
+ const url = buildGetUrl("/parse-status", { repo_name: REPO_NAME });
482
+ const resp = await fetch(url, { headers: GET_HEADERS });
483
+ const text = await handleResponse(resp);
484
+ return { content: [{ type: "text", text }] };
485
+ });
486
+ server.registerTool("add_comment", {
487
+ description: "Post a comment on a Jira ticket. The comment appears immediately in Jira. " +
488
+ "Supports markdown formatting. " +
489
+ "For long comments (over ~2000 characters), set attach_as_file to true — " +
490
+ "this attaches the comment as a .md file instead of posting inline, which avoids Jira's comment length limitations.\n\n" +
491
+ "Tip: To generate plans, clarifying questions, or ticket critiques, use the dedicated " +
492
+ "request_plan_generation, request_clarifying_questions, or request_ticket_critique tools.",
493
+ inputSchema: {
494
+ ticket_number: z
495
+ .string()
496
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123, BAPI-42)"),
497
+ comment: z
498
+ .string()
499
+ .optional()
500
+ .describe("Comment text in markdown format. Can include code blocks, lists, headings, etc. Optional if file_path is provided."),
501
+ file_path: z
502
+ .string()
503
+ .optional()
504
+ .describe("Path to a local markdown file whose contents will be used as the comment. " +
505
+ "If both file_path and comment are provided, file_path takes precedence. " +
506
+ "The file must be UTF-8 encoded and under 1MB."),
507
+ attach_as_file: z
508
+ .boolean()
509
+ .optional()
510
+ .default(false)
511
+ .describe("Set to true to attach the comment as a .md file instead of posting inline. " +
512
+ "Recommended for comments over 2000 characters"),
513
+ file_name: z
514
+ .string()
515
+ .optional()
516
+ .describe("Custom filename for the attached .md file (only used when attach_as_file is true). " +
517
+ "Defaults to {ticket_number}-comment.md if not provided. " +
518
+ "Example: 'PROJ-123-clarifying-questions.md'"),
519
+ },
520
+ }, async ({ ticket_number, comment, file_path, attach_as_file, file_name }) => {
521
+ const resolved = await resolveTextOrFile(comment, file_path, "comment");
522
+ if (!resolved.ok)
523
+ return resolved.errorResponse;
524
+ const payload = {
525
+ repo_name: REPO_NAME,
526
+ comment: resolved.text,
527
+ attach_as_file,
528
+ };
529
+ if (file_name) {
530
+ payload.file_name = file_name;
531
+ }
532
+ const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/comment`), {
533
+ method: "POST",
534
+ headers: POST_HEADERS,
535
+ body: JSON.stringify(payload),
536
+ });
537
+ const text = await handleResponse(resp);
538
+ return { content: [{ type: "text", text: text + resolved.note }] };
539
+ });
540
+ server.registerTool("update_ticket_description", {
541
+ description: "Update the description of an existing Jira ticket. This is a direct, synchronous update that overwrites the existing description with the provided text. " +
542
+ "The description should be in markdown format — it will be automatically converted to Jira wiki markup. " +
543
+ "This does NOT create a new ticket. Use create_ticket for that. " +
544
+ "Returns a success message with the ticket number, or an error if the update fails.",
545
+ inputSchema: {
546
+ ticket_number: z
547
+ .string()
548
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
549
+ description: z
550
+ .string()
551
+ .optional()
552
+ .describe("New description text in markdown format. Optional if file_path is provided. This will completely replace the existing description."),
553
+ file_path: z
554
+ .string()
555
+ .optional()
556
+ .describe("Path to a local markdown file whose contents will be used as the new description. " +
557
+ "If both file_path and description are provided, file_path takes precedence. " +
558
+ "The file must be UTF-8 encoded and under 1MB."),
559
+ },
560
+ }, async ({ ticket_number, description, file_path }) => {
561
+ const resolved = await resolveTextOrFile(description, file_path, "description");
562
+ if (!resolved.ok)
563
+ return resolved.errorResponse;
564
+ const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/description`), {
565
+ method: "PUT",
566
+ headers: POST_HEADERS,
567
+ body: JSON.stringify({ repo_name: REPO_NAME, description: resolved.text }),
568
+ });
569
+ const text = await handleResponse(resp);
570
+ return { content: [{ type: "text", text: text + resolved.note }] };
571
+ });
572
+ server.registerTool("upload_attachment", {
573
+ description: "Upload a local file as an attachment to a Jira ticket. " +
574
+ "Supports text/UTF-8 files only (markdown, plain text, etc.). " +
575
+ "Optionally syncs the content to Bridge API's tickets_links table so retrieval endpoints " +
576
+ "(get_clarifying_questions, get_ticket_critique, get_plan) return the updated content without re-generation. " +
577
+ "Use link_type to specify which retrieval endpoint should serve this content. " +
578
+ "Known link_type values: clarifying-questions.md, debugging-guidance.md, ticket-quality-critique.md, architecture-plan.md.",
579
+ inputSchema: {
580
+ ticket_number: z
581
+ .string()
582
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123, BAPI-42)"),
583
+ file_path: z
584
+ .string()
585
+ .optional()
586
+ .describe("Path to a local file to upload as an attachment. " +
587
+ "The file must be UTF-8 encoded and under 1MB. " +
588
+ "If both file_path and content are provided, file_path takes precedence."),
589
+ content: z
590
+ .string()
591
+ .max(1_048_576)
592
+ .optional()
593
+ .describe("Inline text content to upload (max 1MB). Optional if file_path is provided."),
594
+ file_name: z
595
+ .string()
596
+ .optional()
597
+ .describe("Filename for the attachment in Jira. " +
598
+ "Defaults to the basename of file_path if provided, or {ticket_number}-attachment.md otherwise."),
599
+ link_type: z
600
+ .string()
601
+ .optional()
602
+ .describe("When provided, also syncs the content to Bridge API's tickets_links table. " +
603
+ "Known values: clarifying-questions.md, debugging-guidance.md, ticket-quality-critique.md, architecture-plan.md. " +
604
+ "Unknown values are accepted with a warning."),
605
+ replace_existing: z
606
+ .boolean()
607
+ .optional()
608
+ .default(true)
609
+ .describe("When true (default), deletes any existing Jira attachment with the same filename before uploading. " +
610
+ "Set to false to allow duplicate filenames."),
611
+ },
612
+ }, async ({ ticket_number, file_path, content, file_name, link_type, replace_existing }) => {
613
+ const resolved = await resolveTextOrFile(content, file_path, "content");
614
+ if (!resolved.ok)
615
+ return resolved.errorResponse;
616
+ const derivedFileName = file_name
617
+ || (file_path ? path.basename(file_path) : `${ticket_number}-attachment.md`);
618
+ const payload = {
619
+ repo_name: REPO_NAME,
620
+ content: resolved.text,
621
+ file_name: derivedFileName,
622
+ replace_existing,
623
+ };
624
+ if (link_type) {
625
+ payload.link_type = link_type;
626
+ }
627
+ const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/attachment`), {
628
+ method: "POST",
629
+ headers: POST_HEADERS,
630
+ body: JSON.stringify(payload),
631
+ });
632
+ const text = await handleResponse(resp);
633
+ return { content: [{ type: "text", text: text + resolved.note }] };
634
+ });
635
+ server.registerTool("request_plan_generation", {
636
+ description: "Request AI-generated implementation plan for a Jira ticket. " +
637
+ "This triggers an asynchronous background job — results are NOT immediate. " +
638
+ "Processing typically takes 1-5 minutes depending on ticket complexity and number of attachments. " +
639
+ "After submitting, use get_plan with the same ticket_number to check if results are available. " +
640
+ "Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
641
+ "or 403 if the API key is unauthorized. " +
642
+ "Set wait_for_result to true to block until the result is ready (typically 1-5 minutes) instead of returning immediately.",
643
+ inputSchema: {
644
+ ticket_number: z
645
+ .string()
646
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123) to generate a plan for"),
647
+ wait_for_result: z
648
+ .boolean()
649
+ .optional()
650
+ .default(false)
651
+ .describe("When true, the tool blocks and polls until the plan is ready (typically 1-5 minutes), " +
652
+ "then returns the full plan content directly. When false (default), returns immediately " +
653
+ "with a confirmation message — use get_plan later to retrieve results."),
654
+ save_locally: z
655
+ .boolean()
656
+ .optional()
657
+ .default(true)
658
+ .describe("When wait_for_result is true, whether to save the plan to a local file in the " +
659
+ "BAPI_DOCS_DIR/plans/ directory. Defaults to true. Ignored when wait_for_result is false."),
660
+ second_opinion: z.string().optional(),
661
+ provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
662
+ "triggering second-opinion semantics. If both provider and second_opinion are set, " +
663
+ "second_opinion takes precedence."),
664
+ },
665
+ }, async ({ ticket_number, wait_for_result, save_locally, second_opinion, provider }) => {
666
+ const body = { repo_name: REPO_NAME };
667
+ const trimmedSecondOpinion = second_opinion?.trim();
668
+ if (trimmedSecondOpinion) {
669
+ body.provider_override = trimmedSecondOpinion;
670
+ }
671
+ const trimmedProvider = provider?.trim();
672
+ if (trimmedProvider && !trimmedSecondOpinion) {
673
+ body.provider = trimmedProvider;
674
+ }
675
+ const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-plan`), {
676
+ method: "POST",
677
+ headers: POST_HEADERS,
678
+ body: JSON.stringify(body),
679
+ });
680
+ if (!resp.ok) {
681
+ const errorText = await handleResponse(resp);
682
+ return {
683
+ content: [{ type: "text", text: `Failed to request plan generation: ${errorText}` }],
684
+ };
685
+ }
686
+ if (wait_for_result) {
687
+ const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/plan`, { repo_name: REPO_NAME });
688
+ const result = await pollForResult(getUrl, 900_000, `Plan generation for ${ticket_number}`);
689
+ if (!result.ok) {
690
+ return { content: [{ type: "text", text: result.text }] };
691
+ }
692
+ let text = result.text;
693
+ if (save_locally) {
694
+ const note = await saveLocally(getDocsPath("plans"), `${ticket_number}-plan.md`, text);
695
+ text += note;
696
+ }
697
+ return { content: [{ type: "text", text }] };
698
+ }
699
+ const confirmationText = `Plan generation requested for ${ticket_number}. ` +
700
+ `Processing typically takes 1-5 minutes. ` +
701
+ `Use get_plan with ticket_number "${ticket_number}" to retrieve the plan once processing completes.`;
702
+ return { content: [{ type: "text", text: confirmationText }] };
703
+ });
704
+ server.registerTool("request_architecture", {
705
+ description: "Request AI-generated architecture plan for a Jira ticket. " +
706
+ "This triggers an asynchronous background job — results are NOT immediate. " +
707
+ "Processing typically takes 2-4 minutes depending on ticket complexity. " +
708
+ "After submitting, use get_architecture with the same ticket_number to check if results are available. " +
709
+ "Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
710
+ "or 403 if the API key is unauthorized. " +
711
+ "Set wait_for_result to true to block until the result is ready (typically 2-4 minutes) instead of returning immediately.",
712
+ inputSchema: {
713
+ ticket_number: z
714
+ .string()
715
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123) to generate an architecture plan for"),
716
+ wait_for_result: z
717
+ .boolean()
718
+ .optional()
719
+ .default(false)
720
+ .describe("When true, the tool blocks and polls until the architecture plan is ready (typically 2-4 minutes), " +
721
+ "then returns the full plan content directly. When false (default), returns immediately " +
722
+ "with a confirmation message — use get_architecture later to retrieve results."),
723
+ save_locally: z
724
+ .boolean()
725
+ .optional()
726
+ .default(true)
727
+ .describe("When wait_for_result is true, whether to save the architecture plan to a local file in the " +
728
+ "BAPI_DOCS_DIR/architecture/ directory. Defaults to true. Only takes effect when wait_for_result is true."),
729
+ second_opinion: z.string().optional(),
730
+ provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
731
+ "triggering second-opinion semantics. If both provider and second_opinion are set, " +
732
+ "second_opinion takes precedence."),
733
+ },
734
+ }, async ({ ticket_number, wait_for_result, save_locally, second_opinion, provider }) => {
735
+ const body = { repo_name: REPO_NAME };
736
+ const trimmedSecondOpinion = second_opinion?.trim();
737
+ if (trimmedSecondOpinion) {
738
+ body.provider_override = trimmedSecondOpinion;
739
+ }
740
+ const trimmedProvider = provider?.trim();
741
+ if (trimmedProvider && !trimmedSecondOpinion) {
742
+ body.provider = trimmedProvider;
743
+ }
744
+ const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-architecture`), {
745
+ method: "POST",
746
+ headers: POST_HEADERS,
747
+ body: JSON.stringify(body),
748
+ });
749
+ if (!resp.ok) {
750
+ const errorText = await handleResponse(resp);
751
+ return {
752
+ content: [{ type: "text", text: `Failed to request architecture generation: ${errorText}` }],
753
+ };
754
+ }
755
+ if (wait_for_result) {
756
+ const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/architecture-plan`, { repo_name: REPO_NAME });
757
+ const result = await pollForResult(getUrl, 900_000, `Architecture generation for ${ticket_number}`);
758
+ if (!result.ok) {
759
+ return { content: [{ type: "text", text: result.text }] };
760
+ }
761
+ let text = result.text;
762
+ if (save_locally) {
763
+ const note = await saveLocally(getDocsPath("architecture"), `${ticket_number}-architecture-plan.md`, text);
764
+ text += note;
765
+ }
766
+ return { content: [{ type: "text", text }] };
767
+ }
768
+ const confirmationText = `Architecture generation requested for ${ticket_number}. ` +
769
+ `Processing typically takes 2-4 minutes. ` +
770
+ `Use get_architecture with ticket_number "${ticket_number}" to retrieve the architecture plan once processing completes.`;
771
+ return { content: [{ type: "text", text: confirmationText }] };
772
+ });
773
+ server.registerTool("request_clarifying_questions", {
774
+ description: "Request AI-generated clarifying questions or debugging guidance for a Jira ticket. " +
775
+ "This triggers an asynchronous background job — results are NOT immediate. " +
776
+ "Processing typically takes 1-5 minutes. " +
777
+ "After submitting, use get_clarifying_questions with the same ticket_number to check if results are available. " +
778
+ "For bug tickets, the result may be debugging guidance instead of clarifying questions. " +
779
+ "Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
780
+ "or 403 if the API key is unauthorized. " +
781
+ "Set wait_for_result to true to block until the result is ready (typically 1-5 minutes) instead of returning immediately.",
782
+ inputSchema: {
783
+ ticket_number: z
784
+ .string()
785
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123) to generate clarifying questions for"),
786
+ wait_for_result: z
787
+ .boolean()
788
+ .optional()
789
+ .default(false)
790
+ .describe("When true, the tool blocks and polls until the clarifying questions are ready (typically 1-5 minutes), " +
791
+ "then returns the full content directly. When false (default), returns immediately " +
792
+ "with a confirmation message — use get_clarifying_questions later to retrieve results."),
793
+ save_locally: z
794
+ .boolean()
795
+ .optional()
796
+ .default(true)
797
+ .describe("When wait_for_result is true, whether to save the clarifying questions to a local file in the " +
798
+ "BAPI_DOCS_DIR/clarifying-questions/ directory. Defaults to true. Ignored when wait_for_result is false."),
799
+ second_opinion: z.string().optional(),
800
+ provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
801
+ "triggering second-opinion semantics. If both provider and second_opinion are set, " +
802
+ "second_opinion takes precedence."),
803
+ },
804
+ }, async ({ ticket_number, wait_for_result, save_locally, second_opinion, provider }) => {
805
+ const body = { repo_name: REPO_NAME };
806
+ const trimmedSecondOpinion = second_opinion?.trim();
807
+ if (trimmedSecondOpinion) {
808
+ body.provider_override = trimmedSecondOpinion;
809
+ }
810
+ const trimmedProvider = provider?.trim();
811
+ if (trimmedProvider && !trimmedSecondOpinion) {
812
+ body.provider = trimmedProvider;
813
+ }
814
+ const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-clarifying-questions`), {
815
+ method: "POST",
816
+ headers: POST_HEADERS,
817
+ body: JSON.stringify(body),
818
+ });
819
+ if (!resp.ok) {
820
+ const errorText = await handleResponse(resp);
821
+ return {
822
+ content: [{ type: "text", text: `Failed to request clarifying questions: ${errorText}` }],
823
+ };
824
+ }
825
+ if (wait_for_result) {
826
+ const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/clarifying-questions`, { repo_name: REPO_NAME });
827
+ const result = await pollForResult(getUrl, 900_000, `Clarifying questions for ${ticket_number}`);
828
+ if (!result.ok) {
829
+ return { content: [{ type: "text", text: result.text }] };
830
+ }
831
+ let text = result.text;
832
+ if (save_locally) {
833
+ const note = await saveLocally(getDocsPath("clarifying-questions"), `${ticket_number}-clarifying-questions.md`, text);
834
+ text += note;
835
+ }
836
+ return { content: [{ type: "text", text }] };
837
+ }
838
+ const confirmationText = `Clarifying questions requested for ${ticket_number}. ` +
839
+ `Processing typically takes 1-5 minutes. ` +
840
+ `Use get_clarifying_questions with ticket_number "${ticket_number}" to retrieve the results once processing completes.`;
841
+ return { content: [{ type: "text", text: confirmationText }] };
842
+ });
843
+ // ---------------------------------------------------------------------------
844
+ // Ticket Quality Critique
845
+ // ---------------------------------------------------------------------------
846
+ server.registerTool("get_ticket_critique", {
847
+ description: "Retrieve AI-generated ticket quality critique for a Jira ticket. " +
848
+ "Returns markdown text with a structured critique covering Standards Conformance Analysis, " +
849
+ "Standards Deviations, and Suggested Improvements. " +
850
+ "Returns 404 if no critique has been generated yet. " +
851
+ "Tip: use request_ticket_critique to trigger critique generation first.",
852
+ inputSchema: {
853
+ ticket_number: z
854
+ .string()
855
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123, BAPI-42)"),
856
+ save_locally: z
857
+ .boolean()
858
+ .optional()
859
+ .default(true)
860
+ .describe("Whether to save the critique to a local file in the BAPI_DOCS_DIR/ticket-critiques/ directory. " +
861
+ "Defaults to true. Set to false to skip saving."),
862
+ },
863
+ }, async ({ ticket_number, save_locally }) => {
864
+ const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/ticket-critique`, { repo_name: REPO_NAME });
865
+ const resp = await fetch(url, { headers: GET_HEADERS });
866
+ const ok = resp.ok;
867
+ let text = await handleResponse(resp);
868
+ if (ok && save_locally) {
869
+ const note = await saveLocally(getDocsPath("ticket-critiques"), `${ticket_number}-ticket-quality-critique.md`, text);
870
+ text += note;
871
+ }
872
+ return { content: [{ type: "text", text }] };
873
+ });
874
+ server.registerTool("request_ticket_critique", {
875
+ description: "Request AI-generated ticket critique for a Jira ticket. " +
876
+ "This triggers an asynchronous background job — results are NOT immediate. " +
877
+ "Processing typically takes 1-5 minutes. " +
878
+ "After submitting, use get_ticket_critique with the same ticket_number to check if results are available. " +
879
+ "Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
880
+ "or 403 if the API key is unauthorized. " +
881
+ "Set wait_for_result to true to block until the result is ready (typically 1-5 minutes) instead of returning immediately.",
882
+ inputSchema: {
883
+ ticket_number: z
884
+ .string()
885
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123) to generate a ticket critique for"),
886
+ wait_for_result: z
887
+ .boolean()
888
+ .optional()
889
+ .default(false)
890
+ .describe("When true, the tool blocks and polls until the ticket critique is ready (typically 1-5 minutes), " +
891
+ "then returns the full content directly. When false (default), returns immediately " +
892
+ "with a confirmation message — use get_ticket_critique later to retrieve results."),
893
+ save_locally: z
894
+ .boolean()
895
+ .optional()
896
+ .default(true)
897
+ .describe("When wait_for_result is true, whether to save the ticket critique to a local file in the " +
898
+ "BAPI_DOCS_DIR/ticket-critiques/ directory. Defaults to true. Ignored when wait_for_result is false."),
899
+ second_opinion: z.string().optional(),
900
+ provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
901
+ "triggering second-opinion semantics. If both provider and second_opinion are set, " +
902
+ "second_opinion takes precedence."),
903
+ },
904
+ }, async ({ ticket_number, wait_for_result, save_locally, second_opinion, provider }) => {
905
+ const body = { repo_name: REPO_NAME };
906
+ const trimmedSecondOpinion = second_opinion?.trim();
907
+ if (trimmedSecondOpinion) {
908
+ body.provider_override = trimmedSecondOpinion;
909
+ }
910
+ const trimmedProvider = provider?.trim();
911
+ if (trimmedProvider && !trimmedSecondOpinion) {
912
+ body.provider = trimmedProvider;
913
+ }
914
+ const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-ticket-critique`), {
915
+ method: "POST",
916
+ headers: POST_HEADERS,
917
+ body: JSON.stringify(body),
918
+ });
919
+ if (!resp.ok) {
920
+ const errorText = await handleResponse(resp);
921
+ return {
922
+ content: [{ type: "text", text: `Failed to request ticket critique: ${errorText}` }],
923
+ };
924
+ }
925
+ if (wait_for_result) {
926
+ const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/ticket-critique`, { repo_name: REPO_NAME });
927
+ const result = await pollForResult(getUrl, 900_000, `Ticket critique for ${ticket_number}`);
928
+ if (!result.ok) {
929
+ return { content: [{ type: "text", text: result.text }] };
930
+ }
931
+ let text = result.text;
932
+ if (save_locally) {
933
+ const note = await saveLocally(getDocsPath("ticket-critiques"), `${ticket_number}-ticket-quality-critique.md`, text);
934
+ text += note;
935
+ }
936
+ return { content: [{ type: "text", text }] };
937
+ }
938
+ const confirmationText = `Ticket critique requested for ${ticket_number}. ` +
939
+ `Processing typically takes 1-5 minutes. ` +
940
+ `Use get_critique with ticket_number "${ticket_number}" to retrieve the results once processing completes.`;
941
+ return { content: [{ type: "text", text: confirmationText }] };
942
+ });
943
+ // ---------------------------------------------------------------------------
944
+ // Reimplement Context
945
+ // ---------------------------------------------------------------------------
946
+ server.registerTool("request_reimplement_context", {
947
+ description: "Request processing of new attachments and context assembly for a previously-implemented Jira ticket. " +
948
+ "Use this for follow-up requests on tickets that have already been through the plan+implement cycle. " +
949
+ "This triggers an asynchronous background job to process new attachments/images. " +
950
+ "After submitting, use get_reimplement_context with the same ticket_number to retrieve the assembled context. " +
951
+ "Set wait_for_result to true to block until the context is ready instead of returning immediately.",
952
+ inputSchema: {
953
+ ticket_number: z
954
+ .string()
955
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
956
+ wait_for_result: z
957
+ .boolean()
958
+ .optional()
959
+ .default(false)
960
+ .describe("When true, POST then poll the GET endpoint until the context is ready. " +
961
+ "When false (default), return immediately with a confirmation — use get_reimplement_context later."),
962
+ save_locally: z
963
+ .boolean()
964
+ .optional()
965
+ .default(true)
966
+ .describe("When wait_for_result is true, save the context markdown to " +
967
+ "BAPI_DOCS_DIR/reimplementations/{ticket_number}-context.md. Defaults to true."),
968
+ second_opinion: z.string().optional(),
969
+ provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
970
+ "triggering second-opinion semantics. If both provider and second_opinion are set, " +
971
+ "second_opinion takes precedence."),
972
+ },
973
+ }, async ({ ticket_number, wait_for_result, save_locally, second_opinion, provider }) => {
974
+ const body = { repo_name: REPO_NAME };
975
+ const trimmedSecondOpinion = second_opinion?.trim();
976
+ if (trimmedSecondOpinion) {
977
+ body.provider_override = trimmedSecondOpinion;
978
+ }
979
+ const trimmedProvider = provider?.trim();
980
+ if (trimmedProvider && !trimmedSecondOpinion) {
981
+ body.provider = trimmedProvider;
982
+ }
983
+ const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/request-reimplement-context`), {
984
+ method: "POST",
985
+ headers: POST_HEADERS,
986
+ body: JSON.stringify(body),
987
+ });
988
+ if (!resp.ok) {
989
+ const errorText = await handleResponse(resp);
990
+ return {
991
+ content: [{ type: "text", text: `Failed to request reimplement context: ${errorText}` }],
992
+ };
993
+ }
994
+ if (wait_for_result) {
995
+ const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/reimplement-context`, { repo_name: REPO_NAME });
996
+ const result = await pollForResult(getUrl, 900_000, `Reimplement context for ${ticket_number}`);
997
+ if (!result.ok) {
998
+ return { content: [{ type: "text", text: result.text }] };
999
+ }
1000
+ let text = result.text;
1001
+ if (save_locally) {
1002
+ const note = await saveLocally(getDocsPath("reimplementations"), `${ticket_number}-context.md`, text);
1003
+ text += note;
1004
+ }
1005
+ return { content: [{ type: "text", text }] };
1006
+ }
1007
+ const confirmationText = `Reimplement context processing requested for ${ticket_number}. ` +
1008
+ `Processing typically takes 1-2 minutes. ` +
1009
+ `Use get_reimplement_context with ticket_number "${ticket_number}" to retrieve the results once processing completes.`;
1010
+ return { content: [{ type: "text", text: confirmationText }] };
1011
+ });
1012
+ server.registerTool("get_reimplement_context", {
1013
+ description: "Retrieve the assembled reimplement context for a Jira ticket. " +
1014
+ "Returns a markdown document with new/changed information diffed against stored state, " +
1015
+ "the original ticket description, and the existing implementation plan. " +
1016
+ "Returns 404 if processing is not yet complete. " +
1017
+ "Tip: use request_reimplement_context to trigger processing first.",
1018
+ inputSchema: {
1019
+ ticket_number: z
1020
+ .string()
1021
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
1022
+ save_locally: z
1023
+ .boolean()
1024
+ .optional()
1025
+ .default(true)
1026
+ .describe("Save the context to BAPI_DOCS_DIR/reimplementations/{ticket_number}-context.md. Defaults to true."),
1027
+ },
1028
+ }, async ({ ticket_number, save_locally }) => {
1029
+ const resp = await fetch(buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/reimplement-context`, { repo_name: REPO_NAME }), { headers: GET_HEADERS });
1030
+ if (resp.status === 404) {
1031
+ return {
1032
+ content: [{
1033
+ type: "text",
1034
+ text: JSON.stringify({
1035
+ error: "NOT_FOUND",
1036
+ message: `Reimplement context for ${ticket_number} is not yet available. ` +
1037
+ `Processing may still be in progress. Try again in a moment, ` +
1038
+ `or call request_reimplement_context to trigger processing.`,
1039
+ }),
1040
+ }],
1041
+ };
1042
+ }
1043
+ const text = await handleResponse(resp);
1044
+ if (save_locally) {
1045
+ const note = await saveLocally(getDocsPath("reimplementations"), `${ticket_number}-context.md`, text);
1046
+ return { content: [{ type: "text", text: text + note }] };
1047
+ }
1048
+ return { content: [{ type: "text", text }] };
1049
+ });
1050
+ // ---------------------------------------------------------------------------
1051
+ // Ticket Lifecycle Tracking
1052
+ // ---------------------------------------------------------------------------
1053
+ server.registerTool("track_ticket", {
1054
+ 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. " +
1055
+ "If the ticket is already tracked, this is a safe no-op — it upserts the description and repo_name without error. " +
1056
+ "Call this after creating a ticket with create_ticket to enable state tracking. " +
1057
+ "The repo_name is automatically injected from the configured environment.",
1058
+ inputSchema: {
1059
+ ticket_number: z.string().describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
1060
+ description: z.string().optional().describe("Ticket description text. Optional — used to store a local copy of the description for reference."),
1061
+ },
1062
+ }, async ({ ticket_number, description }) => {
1063
+ const payload = { repo_name: REPO_NAME };
1064
+ if (description !== undefined)
1065
+ payload.description = description;
1066
+ const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/track`), {
1067
+ method: "POST",
1068
+ headers: POST_HEADERS,
1069
+ body: JSON.stringify(payload),
1070
+ });
1071
+ const text = await handleResponse(resp);
1072
+ return { content: [{ type: "text", text }] };
1073
+ });
1074
+ server.registerTool("update_ticket_state", {
1075
+ description: "Update workflow state timestamps on a tracked ticket. Each specified field is set to the current UTC timestamp on the server. " +
1076
+ "Valid field names: 'critique_called', 'critique_answered', 'clarify_called', 'clarify_answered', 'plan_generated', 'implemented', 'reimplement_called'. " +
1077
+ "The ticket must already be tracked (via track_ticket) or a 404 error is returned. " +
1078
+ "Returns 400 if any field name is invalid. " +
1079
+ "The repo_name is automatically injected from the configured environment.",
1080
+ inputSchema: {
1081
+ ticket_number: z.string().describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
1082
+ fields: z.array(z.string()).describe("List of state field names to set to the current UTC timestamp. Valid values: 'critique_called', 'critique_answered', 'clarify_called', 'clarify_answered', 'plan_generated', 'implemented', 'reimplement_called'"),
1083
+ },
1084
+ }, async ({ ticket_number, fields }) => {
1085
+ const payload = { repo_name: REPO_NAME, fields };
1086
+ const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/state`), {
1087
+ method: "PUT",
1088
+ headers: POST_HEADERS,
1089
+ body: JSON.stringify(payload),
1090
+ });
1091
+ const text = await handleResponse(resp);
1092
+ return { content: [{ type: "text", text }] };
1093
+ });
1094
+ server.registerTool("get_ticket_state", {
1095
+ description: "Retrieve workflow state timestamps and artifact existence flags for a tracked ticket. " +
1096
+ "Returns timestamps for each state field (critique_called, critique_answered, clarify_called, clarify_answered, plan_generated, implemented, reimplement_called) " +
1097
+ "and boolean flags indicating whether artifacts exist (has_clarifying_questions, has_critique, has_plan). " +
1098
+ "The ticket must be tracked via track_ticket first, or a 404 is returned. " +
1099
+ "The repo_name is automatically injected from the configured environment.",
1100
+ inputSchema: {
1101
+ ticket_number: z.string().describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
1102
+ },
1103
+ }, async ({ ticket_number }) => {
1104
+ const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/state`, { repo_name: REPO_NAME });
1105
+ const resp = await fetch(url, { headers: GET_HEADERS });
1106
+ const text = await handleResponse(resp);
1107
+ return { content: [{ type: "text", text }] };
1108
+ });
1109
+ // ---------------------------------------------------------------------------
1110
+ // Jira Transitions
1111
+ // ---------------------------------------------------------------------------
1112
+ server.registerTool("get_jira_transitions", {
1113
+ description: "List available Jira workflow transitions for a ticket. Returns each transition's id, name, and target status. " +
1114
+ "Use this to discover what status changes are possible for a given ticket. " +
1115
+ "The repo_name is automatically injected from the configured environment.",
1116
+ inputSchema: {
1117
+ ticket_number: z.string().describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
1118
+ },
1119
+ }, async ({ ticket_number }) => {
1120
+ const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}/jira-transitions`, { repo_name: REPO_NAME });
1121
+ const resp = await fetch(url, { headers: GET_HEADERS });
1122
+ const text = await handleResponse(resp);
1123
+ return { content: [{ type: "text", text }] };
1124
+ });
1125
+ server.registerTool("update_jira_status", {
1126
+ description: "Transition a Jira ticket to a specified target status by executing a workflow transition. " +
1127
+ "Provide either target_status (matched case-insensitively against available transitions) or transition_id (used directly). " +
1128
+ "If transition_id is provided, it takes precedence over target_status. " +
1129
+ "Returns the from/to status on success, or an error listing available transitions if no match is found. " +
1130
+ "The repo_name is automatically injected from the configured environment.",
1131
+ inputSchema: {
1132
+ ticket_number: z.string().describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
1133
+ target_status: z.string().optional().describe("Target status name to transition to (case-insensitive match)"),
1134
+ transition_id: z.string().optional().describe("Specific transition ID to execute (takes precedence over target_status)"),
1135
+ },
1136
+ }, async ({ ticket_number, target_status, transition_id }) => {
1137
+ const payload = { repo_name: REPO_NAME };
1138
+ if (target_status !== undefined)
1139
+ payload.target_status = target_status;
1140
+ if (transition_id !== undefined)
1141
+ payload.transition_id = transition_id;
1142
+ const resp = await fetch(buildUrl(`/tickets/${encodeURIComponent(ticket_number)}/jira-status`), {
1143
+ method: "PUT",
1144
+ headers: POST_HEADERS,
1145
+ body: JSON.stringify(payload),
1146
+ });
1147
+ const text = await handleResponse(resp);
1148
+ return { content: [{ type: "text", text }] };
1149
+ });
1150
+ server.registerTool("resolve_target_status", {
1151
+ description: "Resolve the post-PR target Jira status for the configured repository using an LLM agent. " +
1152
+ "The agent selects the workflow status that best represents 'code committed via PR but not yet tested.' " +
1153
+ "Results are cached per-project — subsequent calls return the cached value unless force_rerun is true. " +
1154
+ "Requires a ticket_number to fetch available transitions from Jira. " +
1155
+ "The repo_name is automatically injected from the configured environment.",
1156
+ inputSchema: {
1157
+ ticket_number: z.string().describe("Jira ticket key to fetch available transitions from (e.g. PROJ-123)"),
1158
+ force_rerun: z.boolean().optional().describe("Set to true to bypass the cache and re-resolve the target status via LLM"),
1159
+ },
1160
+ }, async ({ ticket_number, force_rerun }) => {
1161
+ const payload = {
1162
+ repo_name: REPO_NAME,
1163
+ ticket_number,
1164
+ };
1165
+ if (force_rerun !== undefined)
1166
+ payload.force_rerun = force_rerun;
1167
+ const resp = await fetch(buildUrl("/resolve-target-status"), {
1168
+ method: "POST",
1169
+ headers: POST_HEADERS,
1170
+ body: JSON.stringify(payload),
1171
+ });
1172
+ const text = await handleResponse(resp);
1173
+ return { content: [{ type: "text", text }] };
1174
+ });
1175
+ // ---------------------------------------------------------------------------
1176
+ // Config Fields
1177
+ // ---------------------------------------------------------------------------
1178
+ const VALID_CONFIG_FIELDS = [
1179
+ "review_instructions", "documentation_instructions", "architecture_instructions",
1180
+ "unit_testing_instructions", "e2e_testing_instructions",
1181
+ "frontend_correctness_standards", "backend_correctness_standards",
1182
+ "template_correctness_standards", "style_correctness_standards",
1183
+ "post_pr_target_status", "ci_check_config",
1184
+ ].join(", ");
1185
+ server.registerTool("list_config_fields", {
1186
+ description: "List all configurable fields available for reading and updating via the Bridge API. " +
1187
+ "Returns each field's name and a description of its purpose. No database values are returned — " +
1188
+ "use get_config_field to read a specific field's current value. " +
1189
+ "Use this tool to discover what configuration options are available before reading or updating them.",
1190
+ inputSchema: {},
1191
+ }, async () => {
1192
+ const url = buildGetUrl("/config-fields", { repo_name: REPO_NAME });
1193
+ const resp = await fetch(url, { headers: GET_HEADERS });
1194
+ const text = await handleResponse(resp);
1195
+ return { content: [{ type: "text", text }] };
1196
+ });
1197
+ server.registerTool("get_my_role", {
1198
+ description: "Check the role and auth source for the current API key. " +
1199
+ "Returns JSON with {role: \"admin\" | \"member\" | null, source: \"user_access\" | \"legacy\"}. " +
1200
+ "Use this to check if the current key has admin permissions before attempting configuration updates " +
1201
+ "via update_config_field. Non-admin user_access keys will be blocked from config updates.",
1202
+ inputSchema: {},
1203
+ }, async () => {
1204
+ const url = buildGetUrl("/my-role", { repo_name: REPO_NAME });
1205
+ const resp = await fetch(url, { headers: GET_HEADERS });
1206
+ const text = await handleResponse(resp);
1207
+ return { content: [{ type: "text", text }] };
1208
+ });
1209
+ server.registerTool("get_config_field", {
1210
+ description: "Read the current value and metadata for a specific configuration field. " +
1211
+ "Returns the field's current database value (or null if not set), along with a description of its purpose and examples of helpful content. " +
1212
+ "Use this before update_config_field to understand the current state and build upon it rather than overwriting blindly.",
1213
+ inputSchema: {
1214
+ field_name: z.string().describe(`The configuration field to read. Valid options: ${VALID_CONFIG_FIELDS}`),
1215
+ },
1216
+ }, async ({ field_name }) => {
1217
+ const url = buildGetUrl(`/config-field/${encodeURIComponent(field_name)}`, { repo_name: REPO_NAME });
1218
+ const resp = await fetch(url, { headers: GET_HEADERS });
1219
+ const text = await handleResponse(resp);
1220
+ return { content: [{ type: "text", text }] };
1221
+ });
1222
+ server.registerTool("update_config_field", {
1223
+ description: "Update a specific configuration field in the Bridge database. " +
1224
+ "These fields control LLM behavior during code review, planning, and documentation. " +
1225
+ "Always call get_config_field first to read the current value and build upon it. " +
1226
+ "Returns 400 if the field name is invalid, 404 if the repository has no configuration row yet.",
1227
+ inputSchema: {
1228
+ field_name: z.string().describe(`The configuration field to update. Valid options: ${VALID_CONFIG_FIELDS}`),
1229
+ value: z.string().optional().describe("The new value for the configuration field. Provide either value or file_path, not both. " +
1230
+ "Omit both value and file_path to set the field to NULL (clearing it)."),
1231
+ file_path: z.string().optional().describe("Path to a local file whose contents will be used as the new value. " +
1232
+ "Useful for large configuration values like detailed review instructions. " +
1233
+ "The file must be UTF-8 encoded and under 1MB."),
1234
+ },
1235
+ }, async ({ field_name, value, file_path }) => {
1236
+ // Allow omitting both value and file_path to set NULL
1237
+ let finalValue = null;
1238
+ let note = "";
1239
+ if (value || file_path) {
1240
+ const resolved = await resolveTextOrFile(value, file_path, "value");
1241
+ if (!resolved.ok)
1242
+ return resolved.errorResponse;
1243
+ finalValue = resolved.text;
1244
+ note = resolved.note;
1245
+ }
1246
+ const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
1247
+ method: "PUT",
1248
+ headers: POST_HEADERS,
1249
+ body: JSON.stringify({ repo_name: REPO_NAME, value: finalValue }),
1250
+ });
1251
+ const text = await handleResponse(resp);
1252
+ return { content: [{ type: "text", text: text + note }] };
1253
+ });
1254
+ // ---------------------------------------------------------------------------
1255
+ // Deep Research
1256
+ // ---------------------------------------------------------------------------
1257
+ server.registerTool("request_deep_research", {
1258
+ description: "Submit a deep research request on a technical topic using AI-powered web search. " +
1259
+ "Returns a task_id for tracking the research progress. " +
1260
+ "\n\n" +
1261
+ "WHEN TO USE: Use this tool when you need to investigate unfamiliar technologies, " +
1262
+ "gather implementation guidance across multiple sources, research best practices for a complex topic, " +
1263
+ "or when the depth of information needed exceeds what a single web search can provide. " +
1264
+ "Example: 'What are the best practices for implementing WebSocket connection pooling in Python asyncio, " +
1265
+ "including error handling, reconnection strategies, and load balancing across multiple servers?' " +
1266
+ "\n\n" +
1267
+ "WHEN NOT TO USE: Do NOT use this for quick factual lookups, single-question searches, " +
1268
+ "or anything a standard web search could answer in seconds. " +
1269
+ "Bad example: 'websocket python' — use a web search instead. " +
1270
+ "\n\n" +
1271
+ "BEHAVIOR: By default, returns immediately with a task_id. Use get_deep_research to retrieve the result " +
1272
+ "once processing completes (typically 2-10 minutes). " +
1273
+ "Set wait_for_result to true to block until the report is ready (up to 15 minutes). " +
1274
+ "On failure, do not retry automatically — the underlying issue (provider outage, rate limit) " +
1275
+ "is unlikely to resolve immediately. Fall back to standard web searches to gather the information incrementally.",
1276
+ inputSchema: {
1277
+ query: z.string().describe("The research query. Be specific and detailed about what you need to learn. " +
1278
+ "Good: 'What are the tradeoffs between Redis, Memcached, and DynamoDB DAX for caching in a Python FastAPI " +
1279
+ "application serving 10k RPM, including connection pooling, serialization overhead, and failure modes?' " +
1280
+ "Bad: 'caching options' (too vague — use a web search instead)"),
1281
+ context: z.string().optional().describe("Optional context to focus the research scope. Describe your current task, tech stack, and constraints. " +
1282
+ "Example: 'I am building a FastAPI application that uses PostgreSQL and needs to implement real-time " +
1283
+ "notifications. Focus on Python-specific solutions compatible with async frameworks.'"),
1284
+ ticket_number: z.string().optional().describe("Optional Jira ticket key (e.g. PROJ-123) to associate this research with a specific ticket. " +
1285
+ "Helps trace research results back to the ticket that prompted the investigation."),
1286
+ wait_for_result: z.boolean().optional().default(false).describe("When true, blocks and polls until the research is ready (up to 15 minutes), " +
1287
+ "then returns the full report directly. When false (default), returns immediately " +
1288
+ "with a task_id — use get_deep_research later to retrieve results."),
1289
+ save_locally: z.boolean().optional().default(true).describe("Whether to save the research report to the BAPI_DOCS_DIR/deep-research/ directory. " +
1290
+ "Defaults to true. When wait_for_result is false, saving happens when you call get_deep_research instead."),
1291
+ },
1292
+ }, async ({ query, context, ticket_number, wait_for_result, save_locally }) => {
1293
+ // 1. Submit the deep research request
1294
+ const submitPayload = { repo_name: REPO_NAME, query };
1295
+ if (context)
1296
+ submitPayload.context = context;
1297
+ if (ticket_number)
1298
+ submitPayload.ticket_number = ticket_number;
1299
+ const submitResp = await fetch(buildUrl("/deep-research"), {
1300
+ method: "POST",
1301
+ headers: POST_HEADERS,
1302
+ body: JSON.stringify(submitPayload),
1303
+ });
1304
+ if (!submitResp.ok) {
1305
+ const errorText = await handleResponse(submitResp);
1306
+ return { content: [{ type: "text", text: errorText }] };
1307
+ }
1308
+ const submitBody = (await submitResp.json());
1309
+ const taskId = submitBody.task_id;
1310
+ if (!wait_for_result) {
1311
+ const confirmationText = `Deep research submitted (task_id: ${taskId}). ` +
1312
+ `Processing typically takes 2-10 minutes. ` +
1313
+ `Use get_deep_research with task_id ${taskId} to retrieve the result once processing completes.`;
1314
+ return { content: [{ type: "text", text: confirmationText }] };
1315
+ }
1316
+ // 2. Poll until completion or timeout
1317
+ const startTime = Date.now();
1318
+ const MAX_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
1319
+ let pollIntervalMs = 15_000; // start at 15 seconds
1320
+ let lastStatus = "queued";
1321
+ while (Date.now() - startTime < MAX_TIMEOUT_MS) {
1322
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
1323
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
1324
+ console.error(`Deep research in progress... (elapsed: ${elapsed}s, status: ${lastStatus})`);
1325
+ const statusUrl = buildGetUrl(`/deep-research/${taskId}/status`, { repo_name: REPO_NAME });
1326
+ const statusResp = await fetch(statusUrl, { headers: GET_HEADERS });
1327
+ if (!statusResp.ok) {
1328
+ const errorText = await handleResponse(statusResp);
1329
+ return { content: [{ type: "text", text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Error polling deep research status: ${errorText}` }) }] };
1330
+ }
1331
+ const statusBody = (await statusResp.json());
1332
+ lastStatus = statusBody.status;
1333
+ if (lastStatus === "completed") {
1334
+ break;
1335
+ }
1336
+ if (lastStatus === "failed") {
1337
+ const errorMsg = statusBody.error_message || "Unknown error";
1338
+ return {
1339
+ content: [{
1340
+ type: "text",
1341
+ text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Deep research failed: ${errorMsg}. Consider using standard web searches to gather the information incrementally.` }),
1342
+ }],
1343
+ };
1344
+ }
1345
+ // Increase poll interval after first minute
1346
+ if (Date.now() - startTime > 60_000) {
1347
+ pollIntervalMs = 30_000;
1348
+ }
1349
+ }
1350
+ if (lastStatus !== "completed") {
1351
+ return {
1352
+ content: [{
1353
+ type: "text",
1354
+ 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.` }),
1355
+ }],
1356
+ };
1357
+ }
1358
+ // 3. Retrieve the result
1359
+ const resultUrl = buildGetUrl(`/deep-research/${taskId}/result`, { repo_name: REPO_NAME });
1360
+ const resultResp = await fetch(resultUrl, { headers: GET_HEADERS });
1361
+ if (!resultResp.ok) {
1362
+ const errorText = await handleResponse(resultResp);
1363
+ return { content: [{ type: "text", text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Error retrieving deep research result: ${errorText}` }) }] };
1364
+ }
1365
+ let resultText = await resultResp.text();
1366
+ // 4. Optionally save to file
1367
+ if (save_locally) {
1368
+ const slug = slugify(query);
1369
+ const note = await saveLocally(getDocsPath("deep-research"), `${slug}-${taskId}.md`, resultText);
1370
+ resultText += note;
1371
+ }
1372
+ return { content: [{ type: "text", text: resultText }] };
1373
+ });
1374
+ server.registerTool("get_deep_research", {
1375
+ description: "Retrieve the result of a previously submitted deep research request. " +
1376
+ "Returns the full markdown research report if the task is completed, " +
1377
+ "or a structured status response if still processing or failed. " +
1378
+ "Use this after calling request_deep_research with wait_for_result=false.",
1379
+ inputSchema: {
1380
+ task_id: z.number().describe("The task ID returned by request_deep_research."),
1381
+ query_slug: z.string().optional().describe("Optional slug derived from the original query, used for the saved filename. " +
1382
+ "If omitted, the file is saved as 'research-{task_id}.md'."),
1383
+ save_locally: z.boolean().optional().default(true).describe("Whether to save the research report to the BAPI_DOCS_DIR/deep-research/ directory. Defaults to true."),
1384
+ },
1385
+ }, async ({ task_id, query_slug, save_locally }) => {
1386
+ // 1. Check status
1387
+ const statusUrl = buildGetUrl(`/deep-research/${task_id}/status`, { repo_name: REPO_NAME });
1388
+ const statusResp = await fetch(statusUrl, { headers: GET_HEADERS });
1389
+ if (!statusResp.ok) {
1390
+ const errorText = await handleResponse(statusResp);
1391
+ return { content: [{ type: "text", text: errorText }] };
1392
+ }
1393
+ const statusBody = (await statusResp.json());
1394
+ if (statusBody.status === "failed") {
1395
+ const errorMsg = statusBody.error_message || "Unknown error";
1396
+ return {
1397
+ content: [{
1398
+ type: "text",
1399
+ text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Deep research failed: ${errorMsg}. Consider using standard web searches to gather the information incrementally.` }),
1400
+ }],
1401
+ };
1402
+ }
1403
+ if (statusBody.status !== "completed") {
1404
+ return {
1405
+ content: [{
1406
+ type: "text",
1407
+ text: JSON.stringify({ status: statusBody.status, task_id, message: `Deep research is still ${statusBody.status}. Try again in a minute.` }),
1408
+ }],
1409
+ };
1410
+ }
1411
+ // 2. Retrieve the result
1412
+ const resultUrl = buildGetUrl(`/deep-research/${task_id}/result`, { repo_name: REPO_NAME });
1413
+ const resultResp = await fetch(resultUrl, { headers: GET_HEADERS });
1414
+ if (!resultResp.ok) {
1415
+ const errorText = await handleResponse(resultResp);
1416
+ return { content: [{ type: "text", text: errorText }] };
1417
+ }
1418
+ let resultText = await resultResp.text();
1419
+ // 3. Optionally save to file
1420
+ if (save_locally) {
1421
+ const slug = query_slug || "research";
1422
+ const note = await saveLocally(getDocsPath("deep-research"), `${slug}-${task_id}.md`, resultText);
1423
+ resultText += note;
1424
+ }
1425
+ return { content: [{ type: "text", text: resultText }] };
1426
+ });
1427
+ // ---------------------------------------------------------------------------
1428
+ // VCS & CI Tools
1429
+ // ---------------------------------------------------------------------------
1430
+ server.registerTool("create_pull_request", {
1431
+ description: "Create a pull request on the configured VCS provider (GitHub or Bitbucket). " +
1432
+ "Returns a structured response with {available, reason, action, detail}. " +
1433
+ "If a PR already exists for the head branch, returns it with created=false. " +
1434
+ "Capability issues (missing VCS config, API errors) return available=false, not errors. " +
1435
+ "The repo_name is automatically injected from the configured environment.",
1436
+ inputSchema: {
1437
+ head_branch: z.string().describe("The source branch name for the pull request"),
1438
+ base_branch: z.string().describe("The target/destination branch name for the pull request"),
1439
+ title: z.string().describe("The title of the pull request"),
1440
+ body: z.string().optional().describe("The description/body of the pull request"),
1441
+ },
1442
+ }, async ({ head_branch, base_branch, title, body }) => {
1443
+ const payload = {
1444
+ repo_name: REPO_NAME,
1445
+ head_branch,
1446
+ base_branch,
1447
+ title,
1448
+ };
1449
+ if (body !== undefined)
1450
+ payload.body = body;
1451
+ const resp = await fetch(buildUrl("/vcs/pull-requests"), {
1452
+ method: "POST",
1453
+ headers: POST_HEADERS,
1454
+ body: JSON.stringify(payload),
1455
+ });
1456
+ const text = await handleResponse(resp);
1457
+ return { content: [{ type: "text", text }] };
1458
+ });
1459
+ const resolveCiChecksTool = server.registerTool("resolve_ci_checks", {
1460
+ description: "Discover and classify CI checks for the configured repository. " +
1461
+ "Queries GitHub Check Runs + Commit Statuses APIs (or Bitbucket Build Statuses), " +
1462
+ "then uses Branch Protection API or LLM to determine which checks are required for merging. " +
1463
+ "Results are cached per-project — subsequent calls return cached config unless force_rerun is true. " +
1464
+ "Returns {available, reason, action, detail} envelope. " +
1465
+ "The repo_name is automatically injected from the configured environment.",
1466
+ inputSchema: {
1467
+ commit_ref: z.string().describe("Git commit SHA to discover checks for"),
1468
+ force_rerun: z.boolean().optional().describe("Set to true to bypass cache and re-resolve CI checks"),
1469
+ },
1470
+ }, async ({ commit_ref, force_rerun }) => {
1471
+ const payload = {
1472
+ repo_name: REPO_NAME,
1473
+ commit_ref,
1474
+ };
1475
+ if (force_rerun !== undefined)
1476
+ payload.force_rerun = force_rerun;
1477
+ const resp = await fetch(buildUrl("/resolve-ci-checks"), {
1478
+ method: "POST",
1479
+ headers: POST_HEADERS,
1480
+ body: JSON.stringify(payload),
1481
+ });
1482
+ const text = await handleResponse(resp);
1483
+ // Re-enable poll_ci_checks after successful resolution
1484
+ try {
1485
+ const result = JSON.parse(text);
1486
+ if (result.available === true) {
1487
+ pollCiChecksTool.enable();
1488
+ }
1489
+ }
1490
+ catch {
1491
+ // Not JSON — leave poll tool state unchanged
1492
+ }
1493
+ return { content: [{ type: "text", text }] };
1494
+ });
1495
+ const pollCiChecksTool = server.registerTool("poll_ci_checks", {
1496
+ description: "Poll the current status of CI checks for a specific commit. " +
1497
+ "Requires that resolve_ci_checks has been called first to populate the check configuration. " +
1498
+ "Returns per-check status, all_complete, all_passed, and unknown_checks fields. " +
1499
+ "For failed checks with detail_level 'full', includes annotations and/or log tails. " +
1500
+ "Returns {available, reason, action, detail} envelope. " +
1501
+ "The repo_name is automatically injected from the configured environment.",
1502
+ inputSchema: {
1503
+ commit_ref: z.string().describe("Git commit SHA to poll CI checks for"),
1504
+ },
1505
+ }, async ({ commit_ref }) => {
1506
+ const url = buildGetUrl("/poll-ci-checks", {
1507
+ repo_name: REPO_NAME,
1508
+ commit_ref,
1509
+ });
1510
+ const resp = await fetch(url, { headers: GET_HEADERS });
1511
+ const text = await handleResponse(resp);
1512
+ return { content: [{ type: "text", text }] };
1513
+ });
1514
+ // ---------------------------------------------------------------------------
1515
+ // Conditional CI tool visibility
1516
+ // ---------------------------------------------------------------------------
1517
+ async function checkCiConfigAndDisablePoll() {
1518
+ try {
1519
+ const url = buildGetUrl("/config-field/ci_check_config", { repo_name: REPO_NAME });
1520
+ const resp = await fetch(url, { headers: GET_HEADERS });
1521
+ if (resp.ok) {
1522
+ const body = await resp.json();
1523
+ const value = body.value;
1524
+ if (value === null || value === undefined) {
1525
+ pollCiChecksTool.disable();
1526
+ console.error("poll_ci_checks disabled: ci_check_config is null");
1527
+ }
1528
+ }
1529
+ else {
1530
+ // Config field not found or error — disable poll tool
1531
+ pollCiChecksTool.disable();
1532
+ console.error("poll_ci_checks disabled: could not read ci_check_config");
1533
+ }
1534
+ }
1535
+ catch (err) {
1536
+ // Network error or server not reachable — disable poll tool
1537
+ pollCiChecksTool.disable();
1538
+ console.error(`poll_ci_checks disabled: ${err}`);
1539
+ }
1540
+ }
1541
+ // Check config before connecting
1542
+ await checkCiConfigAndDisablePoll();
1543
+ // ---------------------------------------------------------------------------
1544
+ // Pipeline Recipe Tools
1545
+ // ---------------------------------------------------------------------------
1546
+ server.registerTool("get_docs_dir", {
1547
+ description: "Return the locally configured docs directory path (BAPI_DOCS_DIR, default docs/tmp). " +
1548
+ "No parameters. Use this instead of reading the BAPI_DOCS_DIR environment variable directly, " +
1549
+ "which requires shell access and may be blocked on some AI coding platforms.",
1550
+ inputSchema: {},
1551
+ }, async () => {
1552
+ return { content: [{ type: "text", text: BAPI_DOCS_DIR }] };
1553
+ });
1554
+ server.registerTool("list_pipelines", {
1555
+ description: "List all available pipeline recipes with their names, descriptions, and required variables. " +
1556
+ "No parameters. Use this to discover available pipelines before calling get_pipeline_recipe.",
1557
+ inputSchema: {},
1558
+ }, async () => {
1559
+ const list = Object.entries(PIPELINES).map(([key, pipeline]) => ({
1560
+ name: key,
1561
+ description: pipeline.description ?? "",
1562
+ variables: (pipeline.variables ?? []).filter((v) => v !== "docs_dir"),
1563
+ }));
1564
+ return {
1565
+ content: [{ type: "text", text: JSON.stringify(list, null, 2) }],
1566
+ };
1567
+ });
1568
+ server.registerTool("get_pipeline_recipe", {
1569
+ description: "Retrieve a fully resolved pipeline recipe by name. Substitutes variables, resolves instruction " +
1570
+ "file references to inline content, and returns an ordered array of executable steps. " +
1571
+ "Each step is either an mcp_call (with tool name and params) or an agent_task (with instruction text). " +
1572
+ "Use list_pipelines to discover available pipeline names first. " +
1573
+ "Note: the 'docs_dir' variable is automatically set from BAPI_DOCS_DIR — callers should omit it.",
1574
+ inputSchema: {
1575
+ pipeline: z
1576
+ .string()
1577
+ .describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),
1578
+ variables: z
1579
+ .record(z.string())
1580
+ .optional()
1581
+ .describe("Key-value pairs for variable substitution (e.g. { ticket_key: 'BAPI-123' })"),
1582
+ skip_steps: z
1583
+ .array(z.string())
1584
+ .optional()
1585
+ .describe("Step tool names or descriptions to omit from the recipe"),
1586
+ },
1587
+ }, async ({ pipeline: pipelineName, variables, skip_steps }) => {
1588
+ const pipelineDef = PIPELINES[pipelineName];
1589
+ if (!pipelineDef) {
1590
+ const available = Object.keys(PIPELINES).join(", ");
1591
+ return {
1592
+ content: [{
1593
+ type: "text",
1594
+ text: JSON.stringify({
1595
+ error: ERROR_CODES[404] ?? "NOT_FOUND",
1596
+ status: 404,
1597
+ message: `Pipeline "${pipelineName}" not found. Available pipelines: ${available || "(none)"}`,
1598
+ }),
1599
+ }],
1600
+ };
1601
+ }
1602
+ try {
1603
+ const mergedVariables = { docs_dir: BAPI_DOCS_DIR, provider: "", second_opinion: "", ...(variables ?? {}) };
1604
+ const recipe = resolveRecipe(pipelineDef, INSTRUCTIONS, mergedVariables, skip_steps);
1605
+ return {
1606
+ content: [{
1607
+ type: "text",
1608
+ text: JSON.stringify(recipe, null, 2),
1609
+ }],
1610
+ };
1611
+ }
1612
+ catch (err) {
1613
+ const message = err instanceof Error ? err.message : String(err);
1614
+ const isServerError = message.includes("not found in bundled instructions");
1615
+ const status = isServerError ? 500 : 400;
1616
+ const code = isServerError ? "PIPELINE_DATA_ERROR" : (ERROR_CODES[400] ?? "BAD_REQUEST");
1617
+ return {
1618
+ content: [{
1619
+ type: "text",
1620
+ text: JSON.stringify({ error: code, status, message }),
1621
+ }],
1622
+ };
1623
+ }
1624
+ });
1625
+ // ---------------------------------------------------------------------------
1626
+ // Entry point
1627
+ // ---------------------------------------------------------------------------
1628
+ const transport = new StdioServerTransport();
1629
+ await server.connect(transport);
1630
+ console.error("Bridge API MCP server running on stdio");