@bridge_gpt/mcp-server 0.2.0 → 0.2.1

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
@@ -87,11 +87,18 @@ async function getResolvedApiKey() {
87
87
  }
88
88
  /** GET auth headers, with the API key resolved once via {@link getResolvedApiKey}. */
89
89
  async function getGetHeaders() {
90
- return { "X-API-Key": await getResolvedApiKey() };
90
+ return {
91
+ "X-API-Key": await getResolvedApiKey(),
92
+ "X-Bridge-MCP-Version": VERSION,
93
+ };
91
94
  }
92
95
  /** POST auth headers (API key + JSON content type). */
93
96
  async function getPostHeaders() {
94
- return { "X-API-Key": await getResolvedApiKey(), "Content-Type": "application/json" };
97
+ return {
98
+ "X-API-Key": await getResolvedApiKey(),
99
+ "Content-Type": "application/json",
100
+ "X-Bridge-MCP-Version": VERSION,
101
+ };
95
102
  }
96
103
  /** Set true immediately after the server connection (transport) completes. */
97
104
  let serverConnected = false;
@@ -313,8 +320,8 @@ async function saveLocally(dir, filename, content) {
313
320
  // Heavy-read truncate-and-save helpers (BAPI-342)
314
321
  // ---------------------------------------------------------------------------
315
322
  //
316
- // Four heavy read tools (get_tickets, get_ticket, get_comments,
317
- // list_attachments) can return very large JSON payloads. To avoid flooding the
323
+ // Five heavy read tools (get_project_standards, get_tickets, get_ticket,
324
+ // get_comments, list_attachments) can return very large payloads. To avoid flooding the
318
325
  // agent's context, when a successful payload exceeds MAX_INLINE_TEXT_LENGTH the
319
326
  // FULL payload is saved to disk first and only then is a truncated,
320
327
  // markdown-fenced inline preview returned. Nothing is lost: if the save fails
@@ -502,6 +509,18 @@ const TICKET_ARTIFACTS = {
502
509
  `Use get_architecture with ticket_number "${n}" to retrieve the architecture plan once processing completes.`,
503
510
  pollLabel: (n) => `Architecture generation for ${n}`,
504
511
  },
512
+ fsd: {
513
+ kind: "single",
514
+ generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-fsd`,
515
+ getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/fsd`,
516
+ saveSubdir: "fsd",
517
+ filename: (n) => `${n}-fsd-plan.md`,
518
+ requestErrorPrefix: "Failed to request FSD generation: ",
519
+ confirmationText: (n) => `FSD generation requested for ${n}. ` +
520
+ `Processing typically takes 2-4 minutes. ` +
521
+ `Use get_design_doc with ticket_number "${n}" and doc_type "fsd" to retrieve the functional specification document once processing completes.`,
522
+ pollLabel: (n) => `FSD generation for ${n}`,
523
+ },
505
524
  clarifying_questions: {
506
525
  kind: "single",
507
526
  generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-clarifying-questions`,
@@ -576,6 +595,8 @@ async function getTicketArtifactDocsPath(subdir) {
576
595
  return getDocsPath("plans");
577
596
  case "architecture":
578
597
  return getDocsPath("architecture");
598
+ case "fsd":
599
+ return getDocsPath("fsd");
579
600
  case "clarifying-questions":
580
601
  return getDocsPath("clarifying-questions");
581
602
  case "ticket-critiques":
@@ -584,6 +605,14 @@ async function getTicketArtifactDocsPath(subdir) {
584
605
  return getDocsPath("reimplementations");
585
606
  }
586
607
  }
608
+ // Map the public unified design-doc `doc_type` to the internal artifact key.
609
+ // `tdd` reuses the existing "architecture" artifact (the TDD/architecture
610
+ // document) WITHOUT renaming it, preserving the back-compatible
611
+ // request_architecture / get_architecture tools. `fsd` routes to the new "fsd"
612
+ // artifact.
613
+ function resolveDesignDocArtifactType(docType) {
614
+ return docType === "fsd" ? "fsd" : "architecture";
615
+ }
587
616
  // Shared request flow for the five single-artifact request_* tools: POST the
588
617
  // generate endpoint, return the per-tool error prefix on a non-OK POST, and on
589
618
  // wait_for_result poll the get endpoint (900_000 ms) and optionally save.
@@ -1366,15 +1395,6 @@ const registerTool = ((name, config, handler) => {
1366
1395
  }
1367
1396
  return toolHandle;
1368
1397
  });
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
1398
  registerTool("ping", {
1379
1399
  annotations: {
1380
1400
  readOnlyHint: true,
@@ -1384,6 +1404,9 @@ registerTool("ping", {
1384
1404
  },
1385
1405
  description: "Test connectivity to Bridge API. Validates that the API key is accepted and the configured repository is accessible. " +
1386
1406
  "Returns JSON with {status: 'ok', repo_name: '<configured repo>'}. " +
1407
+ "The response also reports MCP version metadata: mcp_version (your installed MCP server version), " +
1408
+ "latest_mcp_version (latest published), upgrade_available, upgrade_advice (a short, optional note when a newer version is available), " +
1409
+ "and release_state_stale. These are informational — an available upgrade is not an error. " +
1387
1410
  "Use this as a quick health check before other operations, or to verify your Bridge API configuration is working. " +
1388
1411
  "A 403 response means the API key is invalid or the repo is not authorized. " +
1389
1412
  "If the server is unreachable, check that BAPI_BASE_URL points to a running Bridge API instance.",
@@ -1391,8 +1414,28 @@ registerTool("ping", {
1391
1414
  }, async () => {
1392
1415
  const url = buildGetUrl("/ping", { repo_name: REPO_NAME });
1393
1416
  const resp = await fetch(url, { headers: await getGetHeaders() });
1394
- const text = await handleResponse(resp);
1395
- return { content: [{ type: "text", text }] };
1417
+ // Preserve existing behavior for non-OK responses (structured error relay).
1418
+ if (!resp.ok) {
1419
+ const text = await handleResponse(resp);
1420
+ return { content: [{ type: "text", text }] };
1421
+ }
1422
+ // OK: keep the first content item independently JSON.parse()-able, and emit
1423
+ // any upgrade advice as a SEPARATE text item so prose never pollutes the JSON.
1424
+ const raw = await resp.text();
1425
+ try {
1426
+ const body = JSON.parse(raw);
1427
+ const content = [
1428
+ { type: "text", text: JSON.stringify(body, null, 2) },
1429
+ ];
1430
+ if (typeof body.upgrade_advice === "string" && body.upgrade_advice.length > 0) {
1431
+ content.push({ type: "text", text: body.upgrade_advice });
1432
+ }
1433
+ return { content };
1434
+ }
1435
+ catch {
1436
+ // Unexpected non-JSON 200 — fall back to the raw text in one content item.
1437
+ return { content: [{ type: "text", text: raw }] };
1438
+ }
1396
1439
  });
1397
1440
  registerTool("second_opinion", {
1398
1441
  annotations: {
@@ -1448,6 +1491,7 @@ registerTool("generate_image", {
1448
1491
  "This tool spends provider credits on every call — cost scales with quality (low/medium/high). " +
1449
1492
  "Defaults to low quality to minimize provider spend; increase quality only when fidelity matters. " +
1450
1493
  "Returns native MCP image content (type: 'image') so the caller receives the image directly. " +
1494
+ "The image is always also saved to the local BAPI_DOCS_DIR/images/ directory. " +
1451
1495
  "Google Imagen outputs (provider='gemini') include an invisible SynthID watermark applied server-side by Google.",
1452
1496
  inputSchema: {
1453
1497
  prompt: z
@@ -1470,13 +1514,8 @@ registerTool("generate_image", {
1470
1514
  .optional()
1471
1515
  .default("1024x1024")
1472
1516
  .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
1517
  },
1479
- }, async ({ prompt, provider, quality, size, save_locally }) => {
1518
+ }, async ({ prompt, provider, quality, size }) => {
1480
1519
  const resp = await fetch(buildApiUrl("/llm/generate-image"), {
1481
1520
  method: "POST",
1482
1521
  headers: await getPostHeaders(),
@@ -1510,21 +1549,21 @@ registerTool("generate_image", {
1510
1549
  const content = [
1511
1550
  { type: "image", data: imageBase64, mimeType },
1512
1551
  ];
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
- }
1552
+ // Always persist to BAPI_DOCS_DIR/images/. A write failure must not discard
1553
+ // the already-paid-for generation, so warn and still return the inline image.
1554
+ try {
1555
+ const imagesDir = await getDocsPath("images");
1556
+ const filename = `generated-image-${safeTimestampForFilename()}.png`;
1557
+ const filePath = `${imagesDir}/${filename}`;
1558
+ await mkdir(imagesDir, { recursive: true });
1559
+ await writeFile(filePath, Buffer.from(imageBase64, "base64"));
1560
+ content.push({ type: "text", text: `Saved to ${filePath}` });
1561
+ }
1562
+ catch (saveErr) {
1563
+ content.push({
1564
+ type: "text",
1565
+ text: `Warning: image generated successfully but local save failed: ${saveErr instanceof Error ? saveErr.message : String(saveErr)}`,
1566
+ });
1528
1567
  }
1529
1568
  return { content };
1530
1569
  });
@@ -1538,12 +1577,19 @@ registerTool("get_project_standards", {
1538
1577
  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
1578
  "Returns structured markdown with sections for project context, architecture instructions, code review correctness standards, testing stack information, and build analysis. " +
1540
1579
  "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.",
1580
+ "Consult these standards before writing or reviewing code to ensure compliance with project conventions. " +
1581
+ "Successful oversized output is automatically saved under the local docs directory and returned inline as a truncated preview.",
1542
1582
  inputSchema: {},
1543
1583
  }, async () => {
1544
1584
  const url = buildGetUrl("/project-standards", { repo_name: REPO_NAME });
1545
1585
  const resp = await fetch(url, { headers: await getGetHeaders() });
1546
- const text = await handleResponse(resp);
1586
+ const ok = resp.ok;
1587
+ let text = await handleResponse(resp);
1588
+ if (ok) {
1589
+ const safeRepo = safeTicketFileSegment(REPO_NAME || "repo");
1590
+ const filename = `${safeRepo}-project-standards.md`;
1591
+ text = await truncateAndSaveIfNeeded(text, await getDocsPath("project-standards"), filename);
1592
+ }
1547
1593
  return { content: [{ type: "text", text }] };
1548
1594
  });
1549
1595
  registerTool("get_tickets", {
@@ -1647,6 +1693,29 @@ registerTool("get_ticket", {
1647
1693
  }
1648
1694
  return { content: [{ type: "text", text }] };
1649
1695
  });
1696
+ registerTool("get_ticket_model_tier", {
1697
+ annotations: {
1698
+ readOnlyHint: true,
1699
+ destructiveHint: false,
1700
+ idempotentHint: true,
1701
+ openWorldHint: true,
1702
+ },
1703
+ description: "Resolve the coarse implementation-model TIER for a Jira ticket from its difficulty. " +
1704
+ "Returns { difficulty: int|null, tier: \"cheap\"|\"basic\"|\"premium\"|null, source: \"cached\"|\"computed\"|\"fallback\" }. " +
1705
+ "The backend computes difficulty on demand (and caches it) when missing, and never returns a model id — " +
1706
+ "the tier->model mapping is owned by the start-tickets CLI. A null tier (source=\"fallback\") means the " +
1707
+ "model could not be resolved and callers should use the agent's default model.",
1708
+ inputSchema: {
1709
+ ticket_number: z
1710
+ .string()
1711
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
1712
+ },
1713
+ }, async ({ ticket_number }) => {
1714
+ const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}/model-tier`, { repo_name: REPO_NAME });
1715
+ const resp = await fetch(url, { headers: await getGetHeaders() });
1716
+ const text = await handleResponse(resp);
1717
+ return { content: [{ type: "text", text }] };
1718
+ });
1650
1719
  registerTool("get_comments", {
1651
1720
  annotations: {
1652
1721
  readOnlyHint: true,
@@ -2000,7 +2069,7 @@ registerTool("upload_attachment", {
2000
2069
  "Optionally syncs the content to Bridge API's tickets_links table so retrieval endpoints " +
2001
2070
  "(get_clarifying_questions, get_ticket_critique, get_plan) return the updated content without re-generation. " +
2002
2071
  "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.",
2072
+ "Known link_type values: clarifying-questions.md, debugging-guidance.md, ticket-quality-critique.md, architecture-plan.md, fsd-plan.md.",
2004
2073
  inputSchema: {
2005
2074
  ticket_number: z
2006
2075
  .string()
@@ -2283,6 +2352,87 @@ registerTool("request_architecture", {
2283
2352
  }, async (args) => {
2284
2353
  return requestTicketArtifact("architecture", args);
2285
2354
  });
2355
+ registerTool("request_design_doc", {
2356
+ annotations: {
2357
+ readOnlyHint: false,
2358
+ destructiveHint: false,
2359
+ idempotentHint: false,
2360
+ openWorldHint: true,
2361
+ },
2362
+ description: "START (or refresh) async generation of a design document for a Jira ticket, routed by doc_type. " +
2363
+ "Use doc_type 'tdd' for a Technical Design Document (architecture-focused, for engineers) or 'fsd' for a " +
2364
+ "Functional Specification Document (product/functional-focused, for PMs, designers, and QA). " +
2365
+ "This triggers an asynchronous background job — results are NOT immediate. " +
2366
+ "Processing typically takes 2-4 minutes depending on ticket complexity. " +
2367
+ "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. " +
2368
+ "Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, or 403 if the API key is unauthorized.",
2369
+ inputSchema: {
2370
+ ticket_number: z
2371
+ .string()
2372
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123) to generate a design document for"),
2373
+ doc_type: z
2374
+ .enum(["tdd", "fsd"])
2375
+ .describe("Which design document to generate: 'tdd' (Technical Design Document, engineer audience) or " +
2376
+ "'fsd' (Functional Specification Document, product/functional audience)."),
2377
+ wait_for_result: z
2378
+ .boolean()
2379
+ .optional()
2380
+ .default(false)
2381
+ .describe("When true, the tool blocks and polls until the document is ready (typically 2-4 minutes), " +
2382
+ "then returns the full content directly. When false (default), returns immediately " +
2383
+ "with a confirmation message — use get_design_doc later to retrieve results."),
2384
+ save_locally: z
2385
+ .boolean()
2386
+ .optional()
2387
+ .default(true)
2388
+ .describe("When wait_for_result is true, whether to save the generated document to a local file under " +
2389
+ "BAPI_DOCS_DIR. Defaults to true. Only takes effect when wait_for_result is true."),
2390
+ second_opinion: z
2391
+ .string()
2392
+ .optional()
2393
+ .describe("Provider routing override for THIS artifact-generation request " +
2394
+ "(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
2395
+ "generated by the named provider and, where supported, a cross-provider " +
2396
+ "second-opinion pass is applied to this request only. " +
2397
+ "Takes precedence over `provider` when both are set."),
2398
+ provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
2399
+ "triggering second-opinion semantics. If both provider and second_opinion are set, " +
2400
+ "second_opinion takes precedence."),
2401
+ },
2402
+ }, async (args) => {
2403
+ return requestTicketArtifact(resolveDesignDocArtifactType(args.doc_type), args);
2404
+ });
2405
+ registerTool("get_design_doc", {
2406
+ annotations: {
2407
+ readOnlyHint: true,
2408
+ destructiveHint: false,
2409
+ idempotentHint: true,
2410
+ openWorldHint: true,
2411
+ },
2412
+ description: "RETRIEVE an already-generated design document for a Jira ticket, routed by doc_type. " +
2413
+ "Use doc_type 'tdd' for the Technical Design Document or 'fsd' for the Functional Specification Document. " +
2414
+ "This tool only fetches an existing document — it does NOT start or trigger generation. " +
2415
+ "If no document exists yet (or you need a fresh one), call `request_design_doc` first with the same doc_type. " +
2416
+ "Returns the full document as markdown text — present it verbatim without summarizing. " +
2417
+ "Returns a 404 / not-found response when no document is ready yet — that means generation has not run, not that this tool failed.",
2418
+ inputSchema: {
2419
+ ticket_number: z
2420
+ .string()
2421
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123, BAPI-42)"),
2422
+ doc_type: z
2423
+ .enum(["tdd", "fsd"])
2424
+ .describe("Which design document to retrieve: 'tdd' (Technical Design Document) or " +
2425
+ "'fsd' (Functional Specification Document)."),
2426
+ save_locally: z
2427
+ .boolean()
2428
+ .optional()
2429
+ .default(true)
2430
+ .describe("Whether to save the retrieved document to a local file under BAPI_DOCS_DIR. " +
2431
+ "Defaults to true. Set to false to skip saving."),
2432
+ },
2433
+ }, async (args) => {
2434
+ return getTicketArtifact(resolveDesignDocArtifactType(args.doc_type), args);
2435
+ });
2286
2436
  registerTool("request_clarifying_questions", {
2287
2437
  annotations: {
2288
2438
  readOnlyHint: false,
@@ -2705,6 +2855,7 @@ registerTool("resolve_target_status", {
2705
2855
  // ---------------------------------------------------------------------------
2706
2856
  const VALID_CONFIG_FIELDS = [
2707
2857
  "review_instructions", "documentation_instructions", "architecture_instructions",
2858
+ "tdd_document_instructions", "fsd_document_instructions",
2708
2859
  "unit_testing_instructions", "e2e_testing_instructions",
2709
2860
  "unit_testing_stack", "e2e_testing_stack",
2710
2861
  "frontend_correctness_standards", "backend_correctness_standards",
@@ -2714,7 +2865,16 @@ const VALID_CONFIG_FIELDS = [
2714
2865
  "allow_mutating_smoke_ops",
2715
2866
  "selected_mcp_slugs",
2716
2867
  "base_branch",
2717
- "tiered_execution",
2868
+ "difficulty_model_routing_enabled",
2869
+ "difficulty_model_tier_overrides",
2870
+ // BAPI-356 easy-install bootstrap fields (also exposed via the registry).
2871
+ "working_in",
2872
+ "version_control_system",
2873
+ "version",
2874
+ "project_description",
2875
+ "custom_directories",
2876
+ "exclude_directories",
2877
+ "exclude_file_extensions",
2718
2878
  ].join(", ");
2719
2879
  registerTool("list_config_fields", {
2720
2880
  annotations: {
@@ -2761,7 +2921,8 @@ registerTool("get_config_field", {
2761
2921
  },
2762
2922
  description: "Read the current value and metadata for a specific configuration field. " +
2763
2923
  "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.",
2924
+ "Use this before update_config_field to understand the current state and build upon it rather than overwriting blindly. " +
2925
+ "For install bootstrap, prefer get_install_manifest (one read of all bootstrap fields) over many individual get_config_field reads.",
2765
2926
  inputSchema: {
2766
2927
  field_name: z.string().describe(`The configuration field to read. Valid options: ${VALID_CONFIG_FIELDS}`),
2767
2928
  },
@@ -2781,19 +2942,30 @@ registerTool("update_config_field", {
2781
2942
  description: "Update a specific configuration field in the Bridge database. " +
2782
2943
  "These fields control LLM behavior during code review, planning, and documentation. " +
2783
2944
  "Always call get_config_field first to read the current value and build upon it. " +
2945
+ "For install bootstrap, prefer apply_install_manifest (one atomic write of all bootstrap fields, " +
2946
+ "with server-owned skip/conflict/confirmation semantics) over many individual update_config_field writes. " +
2784
2947
  "Returns 400 if the field name is invalid, 404 if the repository has no configuration row yet.",
2785
2948
  inputSchema: {
2786
2949
  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. " +
2950
+ value: z.union([
2951
+ z.string(),
2952
+ z.boolean(),
2953
+ z.array(z.string()),
2954
+ z.record(z.string(), z.union([z.string(), z.null()])),
2955
+ ]).optional().describe("The new value for the configuration field. Provide either value or file_path, not both. " +
2956
+ "Most fields take a string; scalar boolean fields (e.g. allow_mutating_smoke_ops, " +
2957
+ "difficulty_model_routing_enabled) take true/false. " +
2789
2958
  "The selected_mcp_slugs field takes a JSON array of supported MCP validation manual slug strings " +
2790
2959
  "(e.g. [\"b2c-commerce-developer\", \"playwright-mcp\", \"pwa-kit-mcp\"]) — pass an array of strings, " +
2791
2960
  "not a comma-delimited string; an empty array clears the selection. " +
2961
+ "The difficulty_model_tier_overrides field takes a JSON object mapping tier names " +
2962
+ "(\"cheap\"/\"basic\"/\"premium\") to per-repo model aliases (e.g. {\"premium\": \"opus\"}) — pass " +
2963
+ "an object, not a string; an empty object clears all overrides. " +
2964
+ "The difficulty_model_routing_enabled field enables difficulty-based /start-tickets model routing " +
2965
+ "(default ON); pass true/false. " +
2792
2966
  "The base_branch field is a string/null field controlling the development base branch used by PR " +
2793
2967
  "creation (/create-pr) and start-tickets worktree creation; an empty/null value clears it and " +
2794
2968
  "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
2969
  "For string fields, omit both value and file_path to set the field to NULL (clearing it). " +
2798
2970
  "Scalar boolean fields are NOT NULL and have no clear/null state: omitting the value writes false " +
2799
2971
  "(matching the API-layer coercion), so pass true/false explicitly."),
@@ -2801,8 +2973,17 @@ registerTool("update_config_field", {
2801
2973
  "Useful for large configuration values like detailed review instructions. " +
2802
2974
  "The file must be UTF-8 encoded and under 1MB. " +
2803
2975
  "Not supported for scalar boolean fields like allow_mutating_smoke_ops."),
2976
+ only_if_null: z.boolean().optional().describe("Secondary conditional-write guard: when true, the field is updated only if its column is " +
2977
+ "currently NULL (returns status 'skipped'/reason 'already_set' otherwise). Legal only for " +
2978
+ "nullable columns (HTTP 422 otherwise). This is NOT the install bootstrap write path — for " +
2979
+ "easy install, prefer apply_install_manifest, which owns skip/conflict/confirmation semantics."),
2804
2980
  },
2805
- }, async ({ field_name, value, file_path }) => {
2981
+ }, async ({ field_name, value, file_path, only_if_null }) => {
2982
+ // Build the PUT body, including only_if_null only when explicitly true so
2983
+ // the default (false) request shape is unchanged for existing callers.
2984
+ const withGuard = (v) => only_if_null === true
2985
+ ? { repo_name: REPO_NAME, value: v, only_if_null: true }
2986
+ : { repo_name: REPO_NAME, value: v };
2806
2987
  // JSONB array config fields (e.g. selected_mcp_slugs): forward the array value
2807
2988
  // as JSON. Never join into a comma-delimited string — the backend expects a
2808
2989
  // real JSON array and validates each slug against the mcp_docs allowlist.
@@ -2825,14 +3006,39 @@ registerTool("update_config_field", {
2825
3006
  const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
2826
3007
  method: "PUT",
2827
3008
  headers: await getPostHeaders(),
2828
- body: JSON.stringify({ repo_name: REPO_NAME, value: arrayValue }),
3009
+ body: JSON.stringify(withGuard(arrayValue)),
3010
+ });
3011
+ const text = await handleResponse(resp);
3012
+ return { content: [{ type: "text", text }] };
3013
+ }
3014
+ // JSONB object config fields (e.g. difficulty_model_tier_overrides): forward the
3015
+ // object value as JSON. Reject file_path; the backend validates/normalizes keys
3016
+ // and alias values. An omitted value clears all overrides (empty object).
3017
+ const JSON_OBJECT_CONFIG_FIELDS = ["difficulty_model_tier_overrides"];
3018
+ if (JSON_OBJECT_CONFIG_FIELDS.includes(field_name)) {
3019
+ if (file_path) {
3020
+ return {
3021
+ isError: true,
3022
+ content: [{
3023
+ type: "text",
3024
+ text: JSON.stringify({
3025
+ 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.`,
3026
+ }),
3027
+ }],
3028
+ };
3029
+ }
3030
+ const objectValue = value === undefined ? {} : value;
3031
+ const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
3032
+ method: "PUT",
3033
+ headers: await getPostHeaders(),
3034
+ body: JSON.stringify(withGuard(objectValue)),
2829
3035
  });
2830
3036
  const text = await handleResponse(resp);
2831
3037
  return { content: [{ type: "text", text }] };
2832
3038
  }
2833
3039
  // Scalar boolean config fields: reject file-path updates and normalize boolean
2834
3040
  // true/false and string "true"/"false" to a real boolean before persisting.
2835
- const BOOLEAN_CONFIG_FIELDS = ["allow_mutating_smoke_ops"];
3041
+ const BOOLEAN_CONFIG_FIELDS = ["allow_mutating_smoke_ops", "difficulty_model_routing_enabled"];
2836
3042
  if (BOOLEAN_CONFIG_FIELDS.includes(field_name)) {
2837
3043
  if (file_path) {
2838
3044
  return {
@@ -2874,7 +3080,7 @@ registerTool("update_config_field", {
2874
3080
  const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
2875
3081
  method: "PUT",
2876
3082
  headers: await getPostHeaders(),
2877
- body: JSON.stringify({ repo_name: REPO_NAME, value: boolValue }),
3083
+ body: JSON.stringify(withGuard(boolValue)),
2878
3084
  });
2879
3085
  const text = await handleResponse(resp);
2880
3086
  return { content: [{ type: "text", text }] };
@@ -2892,11 +3098,71 @@ registerTool("update_config_field", {
2892
3098
  const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
2893
3099
  method: "PUT",
2894
3100
  headers: await getPostHeaders(),
2895
- body: JSON.stringify({ repo_name: REPO_NAME, value: finalValue }),
3101
+ body: JSON.stringify(withGuard(finalValue)),
2896
3102
  });
2897
3103
  const text = await handleResponse(resp);
2898
3104
  return { content: [{ type: "text", text: text + note }] };
2899
3105
  });
3106
+ // ---------------------------------------------------------------------------
3107
+ // Easy Install (BAPI-356): read-the-manifest / apply-the-manifest
3108
+ // ---------------------------------------------------------------------------
3109
+ registerTool("get_install_manifest", {
3110
+ annotations: {
3111
+ readOnlyHint: true,
3112
+ destructiveHint: false,
3113
+ idempotentHint: true,
3114
+ openWorldHint: true,
3115
+ },
3116
+ description: "Read the easy-install configuration manifest for the configured repository in one call. " +
3117
+ "Returns ordered field groups (each bootstrap field with its current value, is_set flag, agent " +
3118
+ "guidance, examples, and validation summary), a list of deferred fields (owned by " +
3119
+ "/learn-repository or set deliberately), a next_step pointer, done_criteria, and a signed " +
3120
+ "snapshot_token. Pass that exact snapshot_token to apply_install_manifest. " +
3121
+ "Prefer this over many individual get_config_field reads during install bootstrap.",
3122
+ inputSchema: {
3123
+ save_locally: z.boolean().optional().default(true).describe("When true (default), also save the full JSON manifest under BAPI_DOCS_DIR/install/."),
3124
+ },
3125
+ }, async ({ save_locally }) => {
3126
+ const url = buildGetUrl("/config/install-manifest", { repo_name: REPO_NAME });
3127
+ const resp = await fetch(url, { headers: await getGetHeaders() });
3128
+ const ok = resp.ok;
3129
+ let text = await handleResponse(resp);
3130
+ if (ok && save_locally !== false) {
3131
+ const safeRepo = safeTicketFileSegment(REPO_NAME || "repo");
3132
+ const filename = `${safeRepo}-install-manifest-${safeTimestampForFilename()}.json`;
3133
+ const note = await saveLocally(await getDocsPath("install"), filename, text);
3134
+ text = text + note;
3135
+ }
3136
+ return { content: [{ type: "text", text }] };
3137
+ });
3138
+ registerTool("apply_install_manifest", {
3139
+ annotations: {
3140
+ readOnlyHint: false,
3141
+ destructiveHint: true,
3142
+ idempotentHint: false,
3143
+ openWorldHint: true,
3144
+ },
3145
+ description: "Apply easy-install configuration in one atomic call. Pass the snapshot_token returned by " +
3146
+ "get_install_manifest plus a fields object. Each field value is either a scalar " +
3147
+ "(e.g. \"base_branch\": \"main\") or an object (e.g. \"project_description\": {\"value\": \"...\", " +
3148
+ "\"confirmed\": true}). project_description MUST be passed as {value, confirmed: true} and only " +
3149
+ "after explicit human approval. The server owns skip-if-set, conflict detection, and " +
3150
+ "confirmation semantics and returns six buckets: applied, skipped, conflict, rejected, " +
3151
+ "deferred, needs_confirmation. Only bootstrap-eligible fields are accepted (others are rejected).",
3152
+ inputSchema: {
3153
+ snapshot_token: z.string().describe("The exact snapshot_token returned by get_install_manifest for this repository."),
3154
+ 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}. " +
3155
+ "Pass project_description only as {value: \"...\", confirmed: true} after human approval."),
3156
+ },
3157
+ }, async ({ snapshot_token, fields }) => {
3158
+ const resp = await fetch(buildUrl("/config/apply-install-manifest"), {
3159
+ method: "POST",
3160
+ headers: await getPostHeaders(),
3161
+ body: JSON.stringify({ repo_name: REPO_NAME, snapshot_token, fields }),
3162
+ });
3163
+ const text = await handleResponse(resp);
3164
+ return { content: [{ type: "text", text }] };
3165
+ });
2900
3166
  function formatDeepResearchProviderReason(meta) {
2901
3167
  if (!meta)
2902
3168
  return "";
@@ -4125,72 +4391,6 @@ registerTool("generate_decision_page", {
4125
4391
  };
4126
4392
  });
4127
4393
  // ---------------------------------------------------------------------------
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
4394
  // Entry point
4195
4395
  // ---------------------------------------------------------------------------
4196
4396
  const transport = new StdioServerTransport();