@bridge_gpt/mcp-server 0.2.0 → 0.2.2

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
@@ -22,6 +22,7 @@ import { PIPELINES as BUNDLED_PIPELINES, INSTRUCTIONS as BUNDLED_INSTRUCTIONS, C
22
22
  import { COMMANDS } from "./commands.generated.js";
23
23
  import { AGENTS } from "./agents.generated.js";
24
24
  import { VERSION } from "./version.generated.js";
25
+ import { README } from "./readme.generated.js";
25
26
  import { checkForUpdate } from "./update-check.js";
26
27
  import { reconstructAgentMarkdown, translateAgentToCopilot } from "./agent-utils.js";
27
28
  import { resolveRecipe, loadCustomPipelines } from "./pipeline-utils.js";
@@ -87,11 +88,18 @@ async function getResolvedApiKey() {
87
88
  }
88
89
  /** GET auth headers, with the API key resolved once via {@link getResolvedApiKey}. */
89
90
  async function getGetHeaders() {
90
- return { "X-API-Key": await getResolvedApiKey() };
91
+ return {
92
+ "X-API-Key": await getResolvedApiKey(),
93
+ "X-Bridge-MCP-Version": VERSION,
94
+ };
91
95
  }
92
96
  /** POST auth headers (API key + JSON content type). */
93
97
  async function getPostHeaders() {
94
- return { "X-API-Key": await getResolvedApiKey(), "Content-Type": "application/json" };
98
+ return {
99
+ "X-API-Key": await getResolvedApiKey(),
100
+ "Content-Type": "application/json",
101
+ "X-Bridge-MCP-Version": VERSION,
102
+ };
95
103
  }
96
104
  /** Set true immediately after the server connection (transport) completes. */
97
105
  let serverConnected = false;
@@ -313,8 +321,8 @@ async function saveLocally(dir, filename, content) {
313
321
  // Heavy-read truncate-and-save helpers (BAPI-342)
314
322
  // ---------------------------------------------------------------------------
315
323
  //
316
- // Four heavy read tools (get_tickets, get_ticket, get_comments,
317
- // list_attachments) can return very large JSON payloads. To avoid flooding the
324
+ // Five heavy read tools (get_project_standards, get_tickets, get_ticket,
325
+ // get_comments, list_attachments) can return very large payloads. To avoid flooding the
318
326
  // agent's context, when a successful payload exceeds MAX_INLINE_TEXT_LENGTH the
319
327
  // FULL payload is saved to disk first and only then is a truncated,
320
328
  // markdown-fenced inline preview returned. Nothing is lost: if the save fails
@@ -502,6 +510,18 @@ const TICKET_ARTIFACTS = {
502
510
  `Use get_architecture with ticket_number "${n}" to retrieve the architecture plan once processing completes.`,
503
511
  pollLabel: (n) => `Architecture generation for ${n}`,
504
512
  },
513
+ fsd: {
514
+ kind: "single",
515
+ generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-fsd`,
516
+ getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/fsd`,
517
+ saveSubdir: "fsd",
518
+ filename: (n) => `${n}-fsd-plan.md`,
519
+ requestErrorPrefix: "Failed to request FSD generation: ",
520
+ confirmationText: (n) => `FSD generation requested for ${n}. ` +
521
+ `Processing typically takes 2-4 minutes. ` +
522
+ `Use get_design_doc with ticket_number "${n}" and doc_type "fsd" to retrieve the functional specification document once processing completes.`,
523
+ pollLabel: (n) => `FSD generation for ${n}`,
524
+ },
505
525
  clarifying_questions: {
506
526
  kind: "single",
507
527
  generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-clarifying-questions`,
@@ -576,6 +596,8 @@ async function getTicketArtifactDocsPath(subdir) {
576
596
  return getDocsPath("plans");
577
597
  case "architecture":
578
598
  return getDocsPath("architecture");
599
+ case "fsd":
600
+ return getDocsPath("fsd");
579
601
  case "clarifying-questions":
580
602
  return getDocsPath("clarifying-questions");
581
603
  case "ticket-critiques":
@@ -584,6 +606,14 @@ async function getTicketArtifactDocsPath(subdir) {
584
606
  return getDocsPath("reimplementations");
585
607
  }
586
608
  }
609
+ // Map the public unified design-doc `doc_type` to the internal artifact key.
610
+ // `tdd` reuses the existing "architecture" artifact (the TDD/architecture
611
+ // document) WITHOUT renaming it, preserving the back-compatible
612
+ // request_architecture / get_architecture tools. `fsd` routes to the new "fsd"
613
+ // artifact.
614
+ function resolveDesignDocArtifactType(docType) {
615
+ return docType === "fsd" ? "fsd" : "architecture";
616
+ }
587
617
  // Shared request flow for the five single-artifact request_* tools: POST the
588
618
  // generate endpoint, return the per-tool error prefix on a non-OK POST, and on
589
619
  // wait_for_result poll the get endpoint (900_000 ms) and optionally save.
@@ -1306,6 +1336,28 @@ const server = new McpServer({
1306
1336
  version: "1.0.0",
1307
1337
  });
1308
1338
  // ---------------------------------------------------------------------------
1339
+ // README resource
1340
+ // ---------------------------------------------------------------------------
1341
+ //
1342
+ // Expose the package README as a readable MCP resource so a connected agent can
1343
+ // discover the server's feature set without leaving the session. Without this,
1344
+ // `listMcpResources` returns nothing and the README is only visible on npm. The
1345
+ // markdown is bundled at build time (scripts/bundle-readme.js → readme.generated)
1346
+ // so there is no runtime file read.
1347
+ server.registerResource("readme", "bridge-api://readme", {
1348
+ title: "Bridge API MCP — README",
1349
+ description: "Overview and feature reference for the Bridge API MCP server (the same README published with the npm package).",
1350
+ mimeType: "text/markdown",
1351
+ }, async (uri) => ({
1352
+ contents: [
1353
+ {
1354
+ uri: uri.href,
1355
+ mimeType: "text/markdown",
1356
+ text: README,
1357
+ },
1358
+ ],
1359
+ }));
1360
+ // ---------------------------------------------------------------------------
1309
1361
  // Tool registration wrapper (BAPI-275)
1310
1362
  // ---------------------------------------------------------------------------
1311
1363
  //
@@ -1366,15 +1418,6 @@ const registerTool = ((name, config, handler) => {
1366
1418
  }
1367
1419
  return toolHandle;
1368
1420
  });
1369
- // ---------------------------------------------------------------------------
1370
- // Tools
1371
- // ---------------------------------------------------------------------------
1372
- // SECURITY: The `annotations` objects below (readOnlyHint, destructiveHint,
1373
- // idempotentHint, openWorldHint) are UNTRUSTED UX / model-routing hints only.
1374
- // They are advisory metadata forwarded verbatim to the MCP client and MUST
1375
- // NEVER be used for authorization, permission checks, access control, tool
1376
- // enable/disable decisions, or any other security decision. Authorization is
1377
- // enforced server-side via the API key + repo access checks, never here.
1378
1421
  registerTool("ping", {
1379
1422
  annotations: {
1380
1423
  readOnlyHint: true,
@@ -1384,6 +1427,9 @@ registerTool("ping", {
1384
1427
  },
1385
1428
  description: "Test connectivity to Bridge API. Validates that the API key is accepted and the configured repository is accessible. " +
1386
1429
  "Returns JSON with {status: 'ok', repo_name: '<configured repo>'}. " +
1430
+ "The response also reports MCP version metadata: mcp_version (your installed MCP server version), " +
1431
+ "latest_mcp_version (latest published), upgrade_available, upgrade_advice (a short, optional note when a newer version is available), " +
1432
+ "and release_state_stale. These are informational — an available upgrade is not an error. " +
1387
1433
  "Use this as a quick health check before other operations, or to verify your Bridge API configuration is working. " +
1388
1434
  "A 403 response means the API key is invalid or the repo is not authorized. " +
1389
1435
  "If the server is unreachable, check that BAPI_BASE_URL points to a running Bridge API instance.",
@@ -1391,8 +1437,28 @@ registerTool("ping", {
1391
1437
  }, async () => {
1392
1438
  const url = buildGetUrl("/ping", { repo_name: REPO_NAME });
1393
1439
  const resp = await fetch(url, { headers: await getGetHeaders() });
1394
- const text = await handleResponse(resp);
1395
- return { content: [{ type: "text", text }] };
1440
+ // Preserve existing behavior for non-OK responses (structured error relay).
1441
+ if (!resp.ok) {
1442
+ const text = await handleResponse(resp);
1443
+ return { content: [{ type: "text", text }] };
1444
+ }
1445
+ // OK: keep the first content item independently JSON.parse()-able, and emit
1446
+ // any upgrade advice as a SEPARATE text item so prose never pollutes the JSON.
1447
+ const raw = await resp.text();
1448
+ try {
1449
+ const body = JSON.parse(raw);
1450
+ const content = [
1451
+ { type: "text", text: JSON.stringify(body, null, 2) },
1452
+ ];
1453
+ if (typeof body.upgrade_advice === "string" && body.upgrade_advice.length > 0) {
1454
+ content.push({ type: "text", text: body.upgrade_advice });
1455
+ }
1456
+ return { content };
1457
+ }
1458
+ catch {
1459
+ // Unexpected non-JSON 200 — fall back to the raw text in one content item.
1460
+ return { content: [{ type: "text", text: raw }] };
1461
+ }
1396
1462
  });
1397
1463
  registerTool("second_opinion", {
1398
1464
  annotations: {
@@ -1448,6 +1514,7 @@ registerTool("generate_image", {
1448
1514
  "This tool spends provider credits on every call — cost scales with quality (low/medium/high). " +
1449
1515
  "Defaults to low quality to minimize provider spend; increase quality only when fidelity matters. " +
1450
1516
  "Returns native MCP image content (type: 'image') so the caller receives the image directly. " +
1517
+ "The image is always also saved to the local BAPI_DOCS_DIR/images/ directory. " +
1451
1518
  "Google Imagen outputs (provider='gemini') include an invisible SynthID watermark applied server-side by Google.",
1452
1519
  inputSchema: {
1453
1520
  prompt: z
@@ -1470,14 +1537,12 @@ registerTool("generate_image", {
1470
1537
  .optional()
1471
1538
  .default("1024x1024")
1472
1539
  .describe("Image dimensions. Defaults to '1024x1024'."),
1473
- save_locally: z
1474
- .boolean()
1475
- .optional()
1476
- .default(false)
1477
- .describe("When true, save the generated image to the local docs/images directory."),
1478
1540
  },
1479
- }, async ({ prompt, provider, quality, size, save_locally }) => {
1480
- const resp = await fetch(buildApiUrl("/llm/generate-image"), {
1541
+ }, async ({ prompt, provider, quality, size }) => {
1542
+ // The backend is async (submit -> poll -> result) so a slow generation no
1543
+ // longer exceeds the server's 30s request timeout. This tool absorbs the
1544
+ // polling so the agent still gets the image back in a single call.
1545
+ const submitResp = await fetch(buildApiUrl("/llm/generate-image"), {
1481
1546
  method: "POST",
1482
1547
  headers: await getPostHeaders(),
1483
1548
  body: JSON.stringify({
@@ -1488,11 +1553,71 @@ registerTool("generate_image", {
1488
1553
  size,
1489
1554
  }),
1490
1555
  });
1491
- if (!resp.ok) {
1492
- const text = await handleResponse(resp);
1556
+ if (!submitResp.ok) {
1557
+ const text = await handleResponse(submitResp);
1558
+ return { content: [{ type: "text", text }] };
1559
+ }
1560
+ const submitBody = (await submitResp.json());
1561
+ const requestId = submitBody.request_id;
1562
+ if (typeof requestId !== "number") {
1563
+ return {
1564
+ content: [
1565
+ {
1566
+ type: "text",
1567
+ text: JSON.stringify({ error: "Image generation submit response is missing request_id", status: 500 }),
1568
+ },
1569
+ ],
1570
+ };
1571
+ }
1572
+ const repoQuery = `repo_name=${encodeURIComponent(REPO_NAME)}`;
1573
+ const statusUrl = buildApiUrl(`/llm/generate-image/${requestId}/status?${repoQuery}`);
1574
+ const resultUrl = buildApiUrl(`/llm/generate-image/${requestId}/result?${repoQuery}`);
1575
+ // Poll /status until terminal. Low quality is ~13s, but high quality can run
1576
+ // 2-4+ min; start snappy at 2s, back off to 5s after 20s, cap at 300s. The
1577
+ // server-side janitor fails truly-stuck rows only after 15 min, so this cap
1578
+ // always returns the recoverable message well before a live row is touched.
1579
+ const startTime = Date.now();
1580
+ const timeoutMs = 300_000;
1581
+ let pollIntervalMs = 2_000;
1582
+ let finalStatus = "";
1583
+ while (true) {
1584
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
1585
+ if (Date.now() - startTime >= timeoutMs) {
1586
+ return {
1587
+ content: [
1588
+ {
1589
+ type: "text",
1590
+ text: `Image generation is still processing after ${Math.round(timeoutMs / 1000)}s (request_id=${requestId}). The result is recoverable from the server via GET /llm/generate-image/${requestId}/result once it finishes.`,
1591
+ },
1592
+ ],
1593
+ };
1594
+ }
1595
+ if (Date.now() - startTime > 20_000) {
1596
+ pollIntervalMs = 5_000;
1597
+ }
1598
+ const statusResp = await fetch(statusUrl, { headers: await getGetHeaders() });
1599
+ if (!statusResp.ok) {
1600
+ const text = await handleResponse(statusResp);
1601
+ return { content: [{ type: "text", text }] };
1602
+ }
1603
+ const statusBody = (await statusResp.json());
1604
+ finalStatus = typeof statusBody.status === "string" ? statusBody.status : "";
1605
+ if (finalStatus === "completed" || finalStatus === "failed") {
1606
+ break;
1607
+ }
1608
+ }
1609
+ if (finalStatus === "failed") {
1610
+ // /result returns the sanitized 409 failure detail.
1611
+ const failResp = await fetch(resultUrl, { headers: await getGetHeaders() });
1612
+ const text = await handleResponse(failResp);
1493
1613
  return { content: [{ type: "text", text }] };
1494
1614
  }
1495
- const body = (await resp.json());
1615
+ const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
1616
+ if (!resultResp.ok) {
1617
+ const text = await handleResponse(resultResp);
1618
+ return { content: [{ type: "text", text }] };
1619
+ }
1620
+ const body = (await resultResp.json());
1496
1621
  const imageBase64 = body.image_base64;
1497
1622
  if (typeof imageBase64 !== "string" || imageBase64.length === 0) {
1498
1623
  return {
@@ -1510,21 +1635,21 @@ registerTool("generate_image", {
1510
1635
  const content = [
1511
1636
  { type: "image", data: imageBase64, mimeType },
1512
1637
  ];
1513
- if (save_locally) {
1514
- try {
1515
- const imagesDir = await getDocsPath("images");
1516
- const filename = `generated-image-${safeTimestampForFilename()}.png`;
1517
- const filePath = `${imagesDir}/${filename}`;
1518
- await mkdir(imagesDir, { recursive: true });
1519
- await writeFile(filePath, Buffer.from(imageBase64, "base64"));
1520
- content.push({ type: "text", text: `Saved to ${filePath}` });
1521
- }
1522
- catch (saveErr) {
1523
- content.push({
1524
- type: "text",
1525
- text: `Warning: image generated successfully but local save failed: ${saveErr instanceof Error ? saveErr.message : String(saveErr)}`,
1526
- });
1527
- }
1638
+ // Always persist to BAPI_DOCS_DIR/images/. A write failure must not discard
1639
+ // the already-paid-for generation, so warn and still return the inline image.
1640
+ try {
1641
+ const imagesDir = await getDocsPath("images");
1642
+ const filename = `generated-image-${safeTimestampForFilename()}.png`;
1643
+ const filePath = `${imagesDir}/${filename}`;
1644
+ await mkdir(imagesDir, { recursive: true });
1645
+ await writeFile(filePath, Buffer.from(imageBase64, "base64"));
1646
+ content.push({ type: "text", text: `Saved to ${filePath}` });
1647
+ }
1648
+ catch (saveErr) {
1649
+ content.push({
1650
+ type: "text",
1651
+ text: `Warning: image generated successfully but local save failed: ${saveErr instanceof Error ? saveErr.message : String(saveErr)}`,
1652
+ });
1528
1653
  }
1529
1654
  return { content };
1530
1655
  });
@@ -1538,12 +1663,19 @@ registerTool("get_project_standards", {
1538
1663
  description: "Retrieve project-specific coding standards, architecture guidelines, testing standards, code review standards, and project context (platform, version, project description) for the configured repository. " +
1539
1664
  "Returns structured markdown with sections for project context, architecture instructions, code review correctness standards, testing stack information, and build analysis. " +
1540
1665
  "Only sections with configured values are included. Returns 404 if no standards are configured. " +
1541
- "Consult these standards before writing or reviewing code to ensure compliance with project conventions.",
1666
+ "Consult these standards before writing or reviewing code to ensure compliance with project conventions. " +
1667
+ "Successful oversized output is automatically saved under the local docs directory and returned inline as a truncated preview.",
1542
1668
  inputSchema: {},
1543
1669
  }, async () => {
1544
1670
  const url = buildGetUrl("/project-standards", { repo_name: REPO_NAME });
1545
1671
  const resp = await fetch(url, { headers: await getGetHeaders() });
1546
- const text = await handleResponse(resp);
1672
+ const ok = resp.ok;
1673
+ let text = await handleResponse(resp);
1674
+ if (ok) {
1675
+ const safeRepo = safeTicketFileSegment(REPO_NAME || "repo");
1676
+ const filename = `${safeRepo}-project-standards.md`;
1677
+ text = await truncateAndSaveIfNeeded(text, await getDocsPath("project-standards"), filename);
1678
+ }
1547
1679
  return { content: [{ type: "text", text }] };
1548
1680
  });
1549
1681
  registerTool("get_tickets", {
@@ -1647,6 +1779,29 @@ registerTool("get_ticket", {
1647
1779
  }
1648
1780
  return { content: [{ type: "text", text }] };
1649
1781
  });
1782
+ registerTool("get_ticket_model_tier", {
1783
+ annotations: {
1784
+ readOnlyHint: true,
1785
+ destructiveHint: false,
1786
+ idempotentHint: true,
1787
+ openWorldHint: true,
1788
+ },
1789
+ description: "Resolve the coarse implementation-model TIER for a Jira ticket from its difficulty. " +
1790
+ "Returns { difficulty: int|null, tier: \"cheap\"|\"basic\"|\"premium\"|null, source: \"cached\"|\"computed\"|\"fallback\" }. " +
1791
+ "The backend computes difficulty on demand (and caches it) when missing, and never returns a model id — " +
1792
+ "the tier->model mapping is owned by the start-tickets CLI. A null tier (source=\"fallback\") means the " +
1793
+ "model could not be resolved and callers should use the agent's default model.",
1794
+ inputSchema: {
1795
+ ticket_number: z
1796
+ .string()
1797
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
1798
+ },
1799
+ }, async ({ ticket_number }) => {
1800
+ const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}/model-tier`, { repo_name: REPO_NAME });
1801
+ const resp = await fetch(url, { headers: await getGetHeaders() });
1802
+ const text = await handleResponse(resp);
1803
+ return { content: [{ type: "text", text }] };
1804
+ });
1650
1805
  registerTool("get_comments", {
1651
1806
  annotations: {
1652
1807
  readOnlyHint: true,
@@ -2000,7 +2155,7 @@ registerTool("upload_attachment", {
2000
2155
  "Optionally syncs the content to Bridge API's tickets_links table so retrieval endpoints " +
2001
2156
  "(get_clarifying_questions, get_ticket_critique, get_plan) return the updated content without re-generation. " +
2002
2157
  "Use link_type to specify which retrieval endpoint should serve this content. " +
2003
- "Known link_type values: clarifying-questions.md, debugging-guidance.md, ticket-quality-critique.md, architecture-plan.md.",
2158
+ "Known link_type values: clarifying-questions.md, debugging-guidance.md, ticket-quality-critique.md, architecture-plan.md, fsd-plan.md.",
2004
2159
  inputSchema: {
2005
2160
  ticket_number: z
2006
2161
  .string()
@@ -2283,6 +2438,87 @@ registerTool("request_architecture", {
2283
2438
  }, async (args) => {
2284
2439
  return requestTicketArtifact("architecture", args);
2285
2440
  });
2441
+ registerTool("request_design_doc", {
2442
+ annotations: {
2443
+ readOnlyHint: false,
2444
+ destructiveHint: false,
2445
+ idempotentHint: false,
2446
+ openWorldHint: true,
2447
+ },
2448
+ description: "START (or refresh) async generation of a design document for a Jira ticket, routed by doc_type. " +
2449
+ "Use doc_type 'tdd' for a Technical Design Document (architecture-focused, for engineers) or 'fsd' for a " +
2450
+ "Functional Specification Document (product/functional-focused, for PMs, designers, and QA). " +
2451
+ "This triggers an asynchronous background job — results are NOT immediate. " +
2452
+ "Processing typically takes 2-4 minutes depending on ticket complexity. " +
2453
+ "The matching get_design_doc tool retrieves the generated document later (call get_design_doc with the same ticket_number and doc_type) — unless you set wait_for_result, in which case this call blocks and returns it directly. " +
2454
+ "Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, or 403 if the API key is unauthorized.",
2455
+ inputSchema: {
2456
+ ticket_number: z
2457
+ .string()
2458
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123) to generate a design document for"),
2459
+ doc_type: z
2460
+ .enum(["tdd", "fsd"])
2461
+ .describe("Which design document to generate: 'tdd' (Technical Design Document, engineer audience) or " +
2462
+ "'fsd' (Functional Specification Document, product/functional audience)."),
2463
+ wait_for_result: z
2464
+ .boolean()
2465
+ .optional()
2466
+ .default(false)
2467
+ .describe("When true, the tool blocks and polls until the document is ready (typically 2-4 minutes), " +
2468
+ "then returns the full content directly. When false (default), returns immediately " +
2469
+ "with a confirmation message — use get_design_doc later to retrieve results."),
2470
+ save_locally: z
2471
+ .boolean()
2472
+ .optional()
2473
+ .default(true)
2474
+ .describe("When wait_for_result is true, whether to save the generated document to a local file under " +
2475
+ "BAPI_DOCS_DIR. Defaults to true. Only takes effect when wait_for_result is true."),
2476
+ second_opinion: z
2477
+ .string()
2478
+ .optional()
2479
+ .describe("Provider routing override for THIS artifact-generation request " +
2480
+ "(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
2481
+ "generated by the named provider and, where supported, a cross-provider " +
2482
+ "second-opinion pass is applied to this request only. " +
2483
+ "Takes precedence over `provider` when both are set."),
2484
+ provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
2485
+ "triggering second-opinion semantics. If both provider and second_opinion are set, " +
2486
+ "second_opinion takes precedence."),
2487
+ },
2488
+ }, async (args) => {
2489
+ return requestTicketArtifact(resolveDesignDocArtifactType(args.doc_type), args);
2490
+ });
2491
+ registerTool("get_design_doc", {
2492
+ annotations: {
2493
+ readOnlyHint: true,
2494
+ destructiveHint: false,
2495
+ idempotentHint: true,
2496
+ openWorldHint: true,
2497
+ },
2498
+ description: "RETRIEVE an already-generated design document for a Jira ticket, routed by doc_type. " +
2499
+ "Use doc_type 'tdd' for the Technical Design Document or 'fsd' for the Functional Specification Document. " +
2500
+ "This tool only fetches an existing document — it does NOT start or trigger generation. " +
2501
+ "If no document exists yet (or you need a fresh one), call `request_design_doc` first with the same doc_type. " +
2502
+ "Returns the full document as markdown text — present it verbatim without summarizing. " +
2503
+ "Returns a 404 / not-found response when no document is ready yet — that means generation has not run, not that this tool failed.",
2504
+ inputSchema: {
2505
+ ticket_number: z
2506
+ .string()
2507
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123, BAPI-42)"),
2508
+ doc_type: z
2509
+ .enum(["tdd", "fsd"])
2510
+ .describe("Which design document to retrieve: 'tdd' (Technical Design Document) or " +
2511
+ "'fsd' (Functional Specification Document)."),
2512
+ save_locally: z
2513
+ .boolean()
2514
+ .optional()
2515
+ .default(true)
2516
+ .describe("Whether to save the retrieved document to a local file under BAPI_DOCS_DIR. " +
2517
+ "Defaults to true. Set to false to skip saving."),
2518
+ },
2519
+ }, async (args) => {
2520
+ return getTicketArtifact(resolveDesignDocArtifactType(args.doc_type), args);
2521
+ });
2286
2522
  registerTool("request_clarifying_questions", {
2287
2523
  annotations: {
2288
2524
  readOnlyHint: false,
@@ -2705,6 +2941,7 @@ registerTool("resolve_target_status", {
2705
2941
  // ---------------------------------------------------------------------------
2706
2942
  const VALID_CONFIG_FIELDS = [
2707
2943
  "review_instructions", "documentation_instructions", "architecture_instructions",
2944
+ "tdd_document_instructions", "fsd_document_instructions",
2708
2945
  "unit_testing_instructions", "e2e_testing_instructions",
2709
2946
  "unit_testing_stack", "e2e_testing_stack",
2710
2947
  "frontend_correctness_standards", "backend_correctness_standards",
@@ -2714,7 +2951,16 @@ const VALID_CONFIG_FIELDS = [
2714
2951
  "allow_mutating_smoke_ops",
2715
2952
  "selected_mcp_slugs",
2716
2953
  "base_branch",
2717
- "tiered_execution",
2954
+ "difficulty_model_routing_enabled",
2955
+ "difficulty_model_tier_overrides",
2956
+ // BAPI-356 easy-install bootstrap fields (also exposed via the registry).
2957
+ "working_in",
2958
+ "version_control_system",
2959
+ "version",
2960
+ "project_description",
2961
+ "custom_directories",
2962
+ "exclude_directories",
2963
+ "exclude_file_extensions",
2718
2964
  ].join(", ");
2719
2965
  registerTool("list_config_fields", {
2720
2966
  annotations: {
@@ -2761,7 +3007,8 @@ registerTool("get_config_field", {
2761
3007
  },
2762
3008
  description: "Read the current value and metadata for a specific configuration field. " +
2763
3009
  "Returns the field's current database value (or null if not set), along with a description of its purpose and examples of helpful content. " +
2764
- "Use this before update_config_field to understand the current state and build upon it rather than overwriting blindly.",
3010
+ "Use this before update_config_field to understand the current state and build upon it rather than overwriting blindly. " +
3011
+ "For install bootstrap, prefer get_install_manifest (one read of all bootstrap fields) over many individual get_config_field reads.",
2765
3012
  inputSchema: {
2766
3013
  field_name: z.string().describe(`The configuration field to read. Valid options: ${VALID_CONFIG_FIELDS}`),
2767
3014
  },
@@ -2781,19 +3028,30 @@ registerTool("update_config_field", {
2781
3028
  description: "Update a specific configuration field in the Bridge database. " +
2782
3029
  "These fields control LLM behavior during code review, planning, and documentation. " +
2783
3030
  "Always call get_config_field first to read the current value and build upon it. " +
3031
+ "For install bootstrap, prefer apply_install_manifest (one atomic write of all bootstrap fields, " +
3032
+ "with server-owned skip/conflict/confirmation semantics) over many individual update_config_field writes. " +
2784
3033
  "Returns 400 if the field name is invalid, 404 if the repository has no configuration row yet.",
2785
3034
  inputSchema: {
2786
3035
  field_name: z.string().describe(`The configuration field to update. Valid options: ${VALID_CONFIG_FIELDS}`),
2787
- value: z.union([z.string(), z.boolean(), z.array(z.string())]).optional().describe("The new value for the configuration field. Provide either value or file_path, not both. " +
2788
- "Most fields take a string; scalar boolean fields (e.g. allow_mutating_smoke_ops) take true/false. " +
3036
+ value: z.union([
3037
+ z.string(),
3038
+ z.boolean(),
3039
+ z.array(z.string()),
3040
+ z.record(z.string(), z.union([z.string(), z.null()])),
3041
+ ]).optional().describe("The new value for the configuration field. Provide either value or file_path, not both. " +
3042
+ "Most fields take a string; scalar boolean fields (e.g. allow_mutating_smoke_ops, " +
3043
+ "difficulty_model_routing_enabled) take true/false. " +
2789
3044
  "The selected_mcp_slugs field takes a JSON array of supported MCP validation manual slug strings " +
2790
3045
  "(e.g. [\"b2c-commerce-developer\", \"playwright-mcp\", \"pwa-kit-mcp\"]) — pass an array of strings, " +
2791
3046
  "not a comma-delimited string; an empty array clears the selection. " +
3047
+ "The difficulty_model_tier_overrides field takes a JSON object mapping tier names " +
3048
+ "(\"cheap\"/\"basic\"/\"premium\") to per-repo model aliases (e.g. {\"premium\": \"opus\"}) — pass " +
3049
+ "an object, not a string; an empty object clears all overrides. " +
3050
+ "The difficulty_model_routing_enabled field enables difficulty-based /start-tickets model routing " +
3051
+ "(default ON); pass true/false. " +
2792
3052
  "The base_branch field is a string/null field controlling the development base branch used by PR " +
2793
3053
  "creation (/create-pr) and start-tickets worktree creation; an empty/null value clears it and " +
2794
3054
  "automations fall back to 'main'. " +
2795
- "The tiered_execution field is a string enum with valid values 'off', 'claude_code_only', and " +
2796
- "'all_capable'; omitting/clearing the value resolves to the backend default 'claude_code_only'. " +
2797
3055
  "For string fields, omit both value and file_path to set the field to NULL (clearing it). " +
2798
3056
  "Scalar boolean fields are NOT NULL and have no clear/null state: omitting the value writes false " +
2799
3057
  "(matching the API-layer coercion), so pass true/false explicitly."),
@@ -2801,8 +3059,17 @@ registerTool("update_config_field", {
2801
3059
  "Useful for large configuration values like detailed review instructions. " +
2802
3060
  "The file must be UTF-8 encoded and under 1MB. " +
2803
3061
  "Not supported for scalar boolean fields like allow_mutating_smoke_ops."),
3062
+ only_if_null: z.boolean().optional().describe("Secondary conditional-write guard: when true, the field is updated only if its column is " +
3063
+ "currently NULL (returns status 'skipped'/reason 'already_set' otherwise). Legal only for " +
3064
+ "nullable columns (HTTP 422 otherwise). This is NOT the install bootstrap write path — for " +
3065
+ "easy install, prefer apply_install_manifest, which owns skip/conflict/confirmation semantics."),
2804
3066
  },
2805
- }, async ({ field_name, value, file_path }) => {
3067
+ }, async ({ field_name, value, file_path, only_if_null }) => {
3068
+ // Build the PUT body, including only_if_null only when explicitly true so
3069
+ // the default (false) request shape is unchanged for existing callers.
3070
+ const withGuard = (v) => only_if_null === true
3071
+ ? { repo_name: REPO_NAME, value: v, only_if_null: true }
3072
+ : { repo_name: REPO_NAME, value: v };
2806
3073
  // JSONB array config fields (e.g. selected_mcp_slugs): forward the array value
2807
3074
  // as JSON. Never join into a comma-delimited string — the backend expects a
2808
3075
  // real JSON array and validates each slug against the mcp_docs allowlist.
@@ -2825,14 +3092,39 @@ registerTool("update_config_field", {
2825
3092
  const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
2826
3093
  method: "PUT",
2827
3094
  headers: await getPostHeaders(),
2828
- body: JSON.stringify({ repo_name: REPO_NAME, value: arrayValue }),
3095
+ body: JSON.stringify(withGuard(arrayValue)),
3096
+ });
3097
+ const text = await handleResponse(resp);
3098
+ return { content: [{ type: "text", text }] };
3099
+ }
3100
+ // JSONB object config fields (e.g. difficulty_model_tier_overrides): forward the
3101
+ // object value as JSON. Reject file_path; the backend validates/normalizes keys
3102
+ // and alias values. An omitted value clears all overrides (empty object).
3103
+ const JSON_OBJECT_CONFIG_FIELDS = ["difficulty_model_tier_overrides"];
3104
+ if (JSON_OBJECT_CONFIG_FIELDS.includes(field_name)) {
3105
+ if (file_path) {
3106
+ return {
3107
+ isError: true,
3108
+ content: [{
3109
+ type: "text",
3110
+ text: JSON.stringify({
3111
+ error: `'${field_name}' is a JSON object field; file_path updates are not supported. Pass value as an object mapping tier names to model aliases.`,
3112
+ }),
3113
+ }],
3114
+ };
3115
+ }
3116
+ const objectValue = value === undefined ? {} : value;
3117
+ const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
3118
+ method: "PUT",
3119
+ headers: await getPostHeaders(),
3120
+ body: JSON.stringify(withGuard(objectValue)),
2829
3121
  });
2830
3122
  const text = await handleResponse(resp);
2831
3123
  return { content: [{ type: "text", text }] };
2832
3124
  }
2833
3125
  // Scalar boolean config fields: reject file-path updates and normalize boolean
2834
3126
  // true/false and string "true"/"false" to a real boolean before persisting.
2835
- const BOOLEAN_CONFIG_FIELDS = ["allow_mutating_smoke_ops"];
3127
+ const BOOLEAN_CONFIG_FIELDS = ["allow_mutating_smoke_ops", "difficulty_model_routing_enabled"];
2836
3128
  if (BOOLEAN_CONFIG_FIELDS.includes(field_name)) {
2837
3129
  if (file_path) {
2838
3130
  return {
@@ -2874,7 +3166,7 @@ registerTool("update_config_field", {
2874
3166
  const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
2875
3167
  method: "PUT",
2876
3168
  headers: await getPostHeaders(),
2877
- body: JSON.stringify({ repo_name: REPO_NAME, value: boolValue }),
3169
+ body: JSON.stringify(withGuard(boolValue)),
2878
3170
  });
2879
3171
  const text = await handleResponse(resp);
2880
3172
  return { content: [{ type: "text", text }] };
@@ -2892,11 +3184,71 @@ registerTool("update_config_field", {
2892
3184
  const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
2893
3185
  method: "PUT",
2894
3186
  headers: await getPostHeaders(),
2895
- body: JSON.stringify({ repo_name: REPO_NAME, value: finalValue }),
3187
+ body: JSON.stringify(withGuard(finalValue)),
2896
3188
  });
2897
3189
  const text = await handleResponse(resp);
2898
3190
  return { content: [{ type: "text", text: text + note }] };
2899
3191
  });
3192
+ // ---------------------------------------------------------------------------
3193
+ // Easy Install (BAPI-356): read-the-manifest / apply-the-manifest
3194
+ // ---------------------------------------------------------------------------
3195
+ registerTool("get_install_manifest", {
3196
+ annotations: {
3197
+ readOnlyHint: true,
3198
+ destructiveHint: false,
3199
+ idempotentHint: true,
3200
+ openWorldHint: true,
3201
+ },
3202
+ description: "Read the easy-install configuration manifest for the configured repository in one call. " +
3203
+ "Returns ordered field groups (each bootstrap field with its current value, is_set flag, agent " +
3204
+ "guidance, examples, and validation summary), a list of deferred fields (owned by " +
3205
+ "/learn-repository or set deliberately), a next_step pointer, done_criteria, and a signed " +
3206
+ "snapshot_token. Pass that exact snapshot_token to apply_install_manifest. " +
3207
+ "Prefer this over many individual get_config_field reads during install bootstrap.",
3208
+ inputSchema: {
3209
+ save_locally: z.boolean().optional().default(true).describe("When true (default), also save the full JSON manifest under BAPI_DOCS_DIR/install/."),
3210
+ },
3211
+ }, async ({ save_locally }) => {
3212
+ const url = buildGetUrl("/config/install-manifest", { repo_name: REPO_NAME });
3213
+ const resp = await fetch(url, { headers: await getGetHeaders() });
3214
+ const ok = resp.ok;
3215
+ let text = await handleResponse(resp);
3216
+ if (ok && save_locally !== false) {
3217
+ const safeRepo = safeTicketFileSegment(REPO_NAME || "repo");
3218
+ const filename = `${safeRepo}-install-manifest-${safeTimestampForFilename()}.json`;
3219
+ const note = await saveLocally(await getDocsPath("install"), filename, text);
3220
+ text = text + note;
3221
+ }
3222
+ return { content: [{ type: "text", text }] };
3223
+ });
3224
+ registerTool("apply_install_manifest", {
3225
+ annotations: {
3226
+ readOnlyHint: false,
3227
+ destructiveHint: true,
3228
+ idempotentHint: false,
3229
+ openWorldHint: true,
3230
+ },
3231
+ description: "Apply easy-install configuration in one atomic call. Pass the snapshot_token returned by " +
3232
+ "get_install_manifest plus a fields object. Each field value is either a scalar " +
3233
+ "(e.g. \"base_branch\": \"main\") or an object (e.g. \"project_description\": {\"value\": \"...\", " +
3234
+ "\"confirmed\": true}). project_description MUST be passed as {value, confirmed: true} and only " +
3235
+ "after explicit human approval. The server owns skip-if-set, conflict detection, and " +
3236
+ "confirmation semantics and returns six buckets: applied, skipped, conflict, rejected, " +
3237
+ "deferred, needs_confirmation. Only bootstrap-eligible fields are accepted (others are rejected).",
3238
+ inputSchema: {
3239
+ snapshot_token: z.string().describe("The exact snapshot_token returned by get_install_manifest for this repository."),
3240
+ fields: z.record(z.string(), z.any()).describe("Map of field_name to value. A value is either a scalar or an object {value, confirmed}. " +
3241
+ "Pass project_description only as {value: \"...\", confirmed: true} after human approval."),
3242
+ },
3243
+ }, async ({ snapshot_token, fields }) => {
3244
+ const resp = await fetch(buildUrl("/config/apply-install-manifest"), {
3245
+ method: "POST",
3246
+ headers: await getPostHeaders(),
3247
+ body: JSON.stringify({ repo_name: REPO_NAME, snapshot_token, fields }),
3248
+ });
3249
+ const text = await handleResponse(resp);
3250
+ return { content: [{ type: "text", text }] };
3251
+ });
2900
3252
  function formatDeepResearchProviderReason(meta) {
2901
3253
  if (!meta)
2902
3254
  return "";
@@ -4125,72 +4477,6 @@ registerTool("generate_decision_page", {
4125
4477
  };
4126
4478
  });
4127
4479
  // ---------------------------------------------------------------------------
4128
- // Tiered section-executor telemetry (BAPI-346, Ticket 2)
4129
- // ---------------------------------------------------------------------------
4130
- /**
4131
- * POST one per-section execution-telemetry row through the Bridge API HTTP
4132
- * boundary (`/jira/tiered-section-metrics`). Keeps the MCP layer free of any
4133
- * Python DAL import — the server route owns the dormant BAPI-345 DAL seam.
4134
- */
4135
- async function recordTieredSectionMetric(args) {
4136
- const payload = {
4137
- repo_name: REPO_NAME,
4138
- ticket_number: args.ticket_number,
4139
- section_id: args.section_id,
4140
- tier_assigned: args.tier_assigned,
4141
- mode_run: args.mode_run,
4142
- metrics: args.metrics ?? {},
4143
- };
4144
- const resp = await fetch(buildUrl("/tiered-section-metrics"), {
4145
- method: "POST",
4146
- headers: await getPostHeaders(),
4147
- body: JSON.stringify(payload),
4148
- });
4149
- const text = await handleResponse(resp);
4150
- return { content: [{ type: "text", text }] };
4151
- }
4152
- registerTool("record_tiered_section_metric", {
4153
- annotations: {
4154
- readOnlyHint: false,
4155
- destructiveHint: false,
4156
- idempotentHint: false,
4157
- openWorldHint: true,
4158
- },
4159
- description: "Record one per-section execution-telemetry row for the Claude Code tiered " +
4160
- "section executor (BAPI-346). This writes server-side telemetry state only — " +
4161
- "it does NOT mutate Jira, code, or any ticket. It is intended to be called by " +
4162
- "the tiered section executor after each section attempt; the executor Warns and " +
4163
- "continues if recording fails, so telemetry never blocks an implementation. " +
4164
- "Typed columns are ticket_number, section_id, tier_assigned, and mode_run; put " +
4165
- "any additional per-section detail in the flexible `metrics` object.",
4166
- inputSchema: {
4167
- ticket_number: z
4168
- .string()
4169
- .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123, BAPI-42)"),
4170
- section_id: z
4171
- .string()
4172
- .describe("The BAPI-345 section graph `id` of the section this metric is for."),
4173
- tier_assigned: z
4174
- .enum(["cheap", "basic", "premium"])
4175
- .describe("The tier resolved for the section (cheap→haiku, basic→sonnet, premium→opus)."),
4176
- mode_run: z
4177
- .enum(["sub_agent", "inline_tiered", "inline_default"])
4178
- .describe("The execution mode the section actually ran in."),
4179
- metrics: z
4180
- .record(z.string(), z.unknown())
4181
- .optional()
4182
- .describe("Optional flexible JSON detail (e.g. contract_version, mode_intended, " +
4183
- "model_resolved, tokens, cache, verification, escalation_count, " +
4184
- "budget_snapshot, files_changed, handoff, degraded_reason)."),
4185
- },
4186
- }, async ({ ticket_number, section_id, tier_assigned, mode_run, metrics }) => recordTieredSectionMetric({
4187
- ticket_number,
4188
- section_id,
4189
- tier_assigned,
4190
- mode_run,
4191
- metrics,
4192
- }));
4193
- // ---------------------------------------------------------------------------
4194
4480
  // Entry point
4195
4481
  // ---------------------------------------------------------------------------
4196
4482
  const transport = new StdioServerTransport();