bambu-printer-mcp 1.1.27 → 1.1.29

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.
Files changed (38) hide show
  1. package/CONTRIBUTORS.md +7 -1
  2. package/README.md +29 -10
  3. package/dist/ams-mapping.d.ts +18 -2
  4. package/dist/ams-mapping.js +36 -5
  5. package/dist/bambu-native.d.ts +55 -0
  6. package/dist/bambu-native.js +212 -13
  7. package/dist/index.js +154 -71
  8. package/dist/printers/bambu.d.ts +29 -2
  9. package/dist/printers/bambu.js +59 -28
  10. package/dist/safety/hidden-hms.d.ts +32 -0
  11. package/dist/safety/hidden-hms.js +405 -0
  12. package/dist/safety/limits.d.ts +6 -0
  13. package/dist/safety/limits.js +9 -0
  14. package/dist/safety/print-file.d.ts +2 -0
  15. package/dist/safety/print-file.js +83 -7
  16. package/dist/safety/printer-state.d.ts +11 -0
  17. package/dist/safety/printer-state.js +115 -22
  18. package/dist/slicer/profile-flatten.js +25 -0
  19. package/dist/slicer/remote-slicer.d.ts +12 -0
  20. package/dist/slicer/remote-slicer.js +240 -0
  21. package/dist/stl/stl-manipulator.d.ts +2 -1
  22. package/dist/stl/stl-manipulator.js +71 -42
  23. package/native/bambu-native-print.cpp +100 -26
  24. package/package.json +7 -5
  25. package/scripts/build-bambu-native.sh +53 -0
  26. package/scripts/setup-bambu-native.mjs +372 -0
  27. package/src/ams-mapping.ts +38 -5
  28. package/src/bambu-native.ts +229 -14
  29. package/src/index.ts +156 -67
  30. package/src/printers/bambu.ts +65 -25
  31. package/src/safety/hidden-hms.ts +406 -0
  32. package/src/safety/limits.ts +9 -0
  33. package/src/safety/print-file.ts +71 -7
  34. package/src/safety/printer-state.ts +117 -22
  35. package/src/slicer/profile-flatten.ts +32 -0
  36. package/src/slicer/remote-slicer.ts +216 -0
  37. package/src/stl/stl-manipulator.ts +70 -43
  38. package/scripts/build-bambu-native.zsh +0 -33
package/src/index.ts CHANGED
@@ -27,6 +27,9 @@ import {
27
27
  import { BambuNetworkBridge, type BambuNetworkBridgeOptions } from "./bambu-network-bridge.js";
28
28
  import {
29
29
  assertBambuNativeAvailable,
30
+ bambuNativeControlsAvailable,
31
+ bambuNativeUnavailableReason,
32
+ interruptNativeOperations,
30
33
  validateBambuNativeMetadata,
31
34
  buildBambuNativeFanCommand,
32
35
  buildBambuNativeTemperatureCommand,
@@ -37,12 +40,13 @@ import {
37
40
  } from "./bambu-native.js";
38
41
  import { importFileViaBambuConnect } from "./bambu-connect.js";
39
42
  import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
40
- import { hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
43
+ import { bridgeAmsMapping2Entry, bridgeAmsMappingValue, externalTrayForNozzle, hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
41
44
  import { analyze3MFAmsRequirements, analyze3MFPlateObjects, analyzeCollarCharm3MF, extractBambuTemplateSettings, getCollarCharmRolePolicy, parse3MF } from './3mf_parser.js';
42
45
  import type { ThreeMFAmsRequirements } from "./types.js";
43
46
  import { BambuImplementation, assertDirectPrintSupported } from "./printers/bambu.js";
44
47
  import { inspectPrintFile } from "./safety/print-file.js";
45
48
  import { validatePrinterState } from "./safety/printer-state.js";
49
+ import { isDualNozzleModel } from "./safety/limits.js";
46
50
  import { withPrinterOperation, withPrintSnapshot, uniquePrintName } from "./safety/artifact.js";
47
51
  import { detectProfilesRoot } from "./slicer/profile-flatten.js";
48
52
 
@@ -124,6 +128,21 @@ function resolveNozzleType(value: unknown): string | undefined {
124
128
  }
125
129
  return normalized;
126
130
  }
131
+
132
+ /** BambuStudio's nozzle_volume_type value for each tool argument value. */
133
+ const NOZZLE_VOLUME_TYPES = { standard: "Standard", high_flow: "High Flow" } as const;
134
+ const VALID_NOZZLE_VOLUME_TYPES = Object.keys(NOZZLE_VOLUME_TYPES) as (keyof typeof NOZZLE_VOLUME_TYPES)[];
135
+
136
+ /** The installed nozzle's flow type. Bambu machine presets assume Standard; the print gate compares this with the printer. */
137
+ function resolveNozzleVolumeType(value: unknown): "Standard" | "High Flow" | undefined {
138
+ const raw = value === undefined || value === null || value === "" ? process.env.BAMBU_NOZZLE_VOLUME_TYPE?.trim() : value;
139
+ if (raw === undefined || raw === "") return undefined;
140
+ const normalized = String(raw).trim().toLowerCase().replace(/[\s-]+/g, "_");
141
+ if (!(VALID_NOZZLE_VOLUME_TYPES as readonly string[]).includes(normalized)) {
142
+ throw new Error(`Invalid nozzle volume type "${raw}". Valid types: ${VALID_NOZZLE_VOLUME_TYPES.join(", ")}`);
143
+ }
144
+ return NOZZLE_VOLUME_TYPES[normalized as keyof typeof NOZZLE_VOLUME_TYPES];
145
+ }
127
146
  const VALID_BAMBUSTUDIO_CLI_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"] as const;
128
147
 
129
148
  // Map model IDs to BambuStudio --load-machine preset names
@@ -685,7 +704,7 @@ function resolveSlicerConfig(args: Record<string, unknown> | undefined): {
685
704
  slicerProfile: string;
686
705
  } {
687
706
  const slicerType = normalizeSlicerType(String(args?.slicer_type || DEFAULT_SLICER_TYPE));
688
- const slicerPath = String(args?.slicer_path || process.env.SLICER_PATH || defaultSlicerPathFor(slicerType));
707
+ const slicerPath = slicerType === "orca-slicer-api" ? "" : String(args?.slicer_path || process.env.SLICER_PATH || defaultSlicerPathFor(slicerType));
689
708
  const slicerProfile = String(args?.slicer_profile || DEFAULT_SLICER_PROFILE);
690
709
  return { slicerType, slicerPath, slicerProfile };
691
710
  }
@@ -976,14 +995,9 @@ function stringifyBridgeJson(value: unknown): string | undefined {
976
995
  return JSON.stringify(value);
977
996
  }
978
997
 
979
- function nativeAmsMapping2(mapping: number[] | undefined): string | undefined {
998
+ function nativeAmsMapping2(mapping: number[] | undefined, dualNozzle = false): string | undefined {
980
999
  if (!mapping || mapping.length === 0) return undefined;
981
- return JSON.stringify(mapping.map((value) => {
982
- if (value < 0 || value === 255) return { ams_id: 255, slot_id: 255 };
983
- if (value === 254) return { ams_id: 254, slot_id: 254 };
984
- if (value >= 128) return { ams_id: 128, slot_id: value - 128 };
985
- return { ams_id: Math.floor(value / 4), slot_id: value % 4 };
986
- }));
1000
+ return JSON.stringify(mapping.map((value) => bridgeAmsMapping2Entry(value, dualNozzle)));
987
1001
  }
988
1002
 
989
1003
  function redactPrintParams(params: Record<string, unknown>): Record<string, unknown> {
@@ -1339,6 +1353,9 @@ class BambuPrinterMCPServer {
1339
1353
  ): ReturnType<typeof resolveSlicerConfig> {
1340
1354
  const config = resolveSlicerConfig(args);
1341
1355
  if (args?.slicer_path !== undefined) {
1356
+ if (config.slicerType === "orca-slicer-api") {
1357
+ throw new Error(`${toolName}: remote slicing uses trusted SLICER_API_URL and BAMBU_PROFILES_ROOT configuration; slicer_path is unsupported.`);
1358
+ }
1342
1359
  const resolvedSlicerPath = this.resolveExecutableSelectorArg(
1343
1360
  args.slicer_path,
1344
1361
  toolName,
@@ -1379,12 +1396,13 @@ class BambuPrinterMCPServer {
1379
1396
 
1380
1397
  console.error(`3MF has no gcode - auto-slicing with ${slicerType} for ${printModel}`);
1381
1398
  const autoSliceOptions: BambuSliceOptions = {
1382
- uptodate: true,
1383
- ensureOnBed: true,
1384
- minSave: true,
1385
- skipModifiedGcodes: true,
1399
+ ...(slicerType === "orca-slicer-api" ? {} : {
1400
+ uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
1401
+ }),
1402
+ ...(slicerType === "orca-slicer-api" ? { slicePlate: Number(args.plate_index ?? 0) + 1 } : {}),
1386
1403
  bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
1387
1404
  nozzleType: resolveNozzleType(args?.nozzle_type),
1405
+ nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
1388
1406
  };
1389
1407
  threeMFPath = await this.stlManipulator.sliceSTL(
1390
1408
  threeMFPath, slicerType, slicerPath, slicerProfile || undefined,
@@ -1407,9 +1425,10 @@ class BambuPrinterMCPServer {
1407
1425
  ): Promise<{ useAMS: boolean; finalAmsMapping?: number[]; finalAmsSlots?: number[] }> {
1408
1426
  const parsed3MFData = await parse3MF(threeMFPath);
1409
1427
  let parsedAmsMapping: number[] | undefined;
1428
+ const trayOptions = { dualNozzle: isDualNozzleModel(printModel) };
1410
1429
 
1411
1430
  if (parsed3MFData.slicerConfig?.ams_mapping) {
1412
- const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>);
1431
+ const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
1413
1432
  if (slots.length > 0) {
1414
1433
  parsedAmsMapping = slots;
1415
1434
  }
@@ -1425,10 +1444,10 @@ class BambuPrinterMCPServer {
1425
1444
  let userMappingOverride: number[] | undefined;
1426
1445
  if (Array.isArray(args.ams_mapping)) {
1427
1446
  userMappingOverride = args.ams_mapping.map((v: unknown, i: number) =>
1428
- normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`)
1447
+ normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`, trayOptions)
1429
1448
  );
1430
1449
  } else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
1431
- userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>);
1450
+ userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
1432
1451
  }
1433
1452
  if (userMappingOverride && userMappingOverride.length > 0) {
1434
1453
  finalAmsMapping = userMappingOverride;
@@ -1439,7 +1458,7 @@ class BambuPrinterMCPServer {
1439
1458
 
1440
1459
  if (!hasUserAmsMapping && hasUserAmsSlots) {
1441
1460
  const userSlots = args.ams_slots.map((slot: unknown, i: number) =>
1442
- normalizeBridgeAmsTrayValue(slot, `ams_slots[${i}]`)
1461
+ normalizeBridgeAmsTrayValue(slot, `ams_slots[${i}]`, trayOptions)
1443
1462
  );
1444
1463
  finalAmsSlots = userSlots;
1445
1464
  finalAmsMapping = undefined;
@@ -1468,8 +1487,8 @@ class BambuPrinterMCPServer {
1468
1487
  }
1469
1488
 
1470
1489
  if (args?.use_ams === false) {
1471
- finalAmsMapping = undefined;
1472
- finalAmsSlots = undefined;
1490
+ if (!hasUserAmsMapping) finalAmsMapping = undefined;
1491
+ if (!hasUserAmsSlots) finalAmsSlots = undefined;
1473
1492
  useAMS = false;
1474
1493
  }
1475
1494
  if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
@@ -1570,8 +1589,16 @@ class BambuPrinterMCPServer {
1570
1589
  inspection.usedFilamentPositions.forEach((position, index) => { mapping![position] = finalAmsSlots[index]; });
1571
1590
  }
1572
1591
  if (!useAMS) {
1573
- mapping = Array(inspection.materials.length).fill(-1);
1574
- inspection.usedFilamentPositions.forEach(position => { mapping![position] = 254; });
1592
+ mapping ??= Array(inspection.materials.length).fill(-1);
1593
+ const dualNozzle = isDualNozzleModel(printModel);
1594
+ for (const position of inspection.usedFilamentPositions) {
1595
+ if (mapping[position] === undefined || mapping[position] < 0) {
1596
+ mapping[position] = externalTrayForNozzle(inspection.filamentNozzleIndices?.[position], dualNozzle);
1597
+ }
1598
+ if (mapping[position] !== 254 && !(dualNozzle && mapping[position] === 255)) {
1599
+ throw new Error("use_ams:false requires an external-spool mapping for every used filament.");
1600
+ }
1601
+ }
1575
1602
  }
1576
1603
  const requirements = { ...inspection, amsMapping: mapping, useAMS };
1577
1604
  const initialStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
@@ -1580,7 +1607,7 @@ class BambuPrinterMCPServer {
1580
1607
  const confirmedStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
1581
1608
  validatePrinterState(confirmedStatus, requirements);
1582
1609
  this.bambu.assertBedClearance(confirmedStatus, bedClearance);
1583
- const amsMapping = stringifyBridgeJson(mapping);
1610
+ const amsMapping = stringifyBridgeJson(mapping?.map(value => bridgeAmsMappingValue(value, isDualNozzleModel(printModel))));
1584
1611
  const remoteFileName = uniquePrintName(threeMFPath);
1585
1612
  const params: Record<string, unknown> = {
1586
1613
  dev_id: devId,
@@ -1597,7 +1624,7 @@ class BambuPrinterMCPServer {
1597
1624
  ftp_file_md5: String(args?.ftp_file_md5 || ""),
1598
1625
  nozzle_mapping: stringifyBridgeJson(args?.nozzle_mapping) || "",
1599
1626
  ams_mapping: amsMapping || "",
1600
- ams_mapping2: stringifyBridgeJson(args?.ams_mapping2) || "",
1627
+ ams_mapping2: nativeAmsMapping2(mapping, isDualNozzleModel(printModel)) || "",
1601
1628
  ams_mapping_info: stringifyBridgeJson(args?.ams_mapping_info) || "",
1602
1629
  nozzles_info: stringifyBridgeJson(args?.nozzles_info) || "",
1603
1630
  connection_type: connectionType,
@@ -1638,7 +1665,7 @@ class BambuPrinterMCPServer {
1638
1665
  host: devIp, serial: devId, token: password || bambuToken, filePath: snapshot,
1639
1666
  projectName: String(params.task_name), presetName, remoteName: remoteFileName,
1640
1667
  plateIndex, bedType: printBedType, useAMS,
1641
- amsMapping, amsMapping2: nativeAmsMapping2(mapping),
1668
+ amsMapping, amsMapping2: nativeAmsMapping2(mapping, isDualNozzleModel(printModel)),
1642
1669
  bedLeveling: params.task_bed_leveling as boolean,
1643
1670
  flowCalibration: params.task_flow_cali as boolean,
1644
1671
  vibrationCalibration: params.task_vibration_cali as boolean,
@@ -1765,10 +1792,12 @@ class BambuPrinterMCPServer {
1765
1792
  args: Record<string, any>, host: string, bambuSerial: string, bambuToken: string,
1766
1793
  onUpdate?: BambuNativeUpdateCallback, signal?: AbortSignal
1767
1794
  ): Promise<Record<string, unknown>> {
1768
- assertBambuNativeAvailable();
1769
1795
  if (args.print) throw new Error("Native upload never starts printing; use print_3mf.");
1796
+ // Resolve and enforce the model first, as the print path does, so a
1797
+ // non-X2D request is never told to install the native helper.
1770
1798
  const model = await this.resolveBambuModel(args.bambu_model);
1771
1799
  if (model !== "x2d") throw new Error("connection_mode=bambu_native is restricted to X2D.");
1800
+ assertBambuNativeAvailable();
1772
1801
  const unsupported = ["use_ams", "ams_mapping", "ams_mapping2", "ams_mapping_info", "nozzle_mapping", "nozzles_info",
1773
1802
  "bed_leveling", "flow_calibration", "vibration_calibration", "layer_inspect", "timelapse"];
1774
1803
  for (const key of unsupported) {
@@ -1895,10 +1924,9 @@ class BambuPrinterMCPServer {
1895
1924
  );
1896
1925
  }
1897
1926
  const autoSliceOptions: BambuSliceOptions = {
1898
- uptodate: true,
1899
- ensureOnBed: true,
1900
- minSave: true,
1901
- skipModifiedGcodes: true,
1927
+ ...(slicerType === "orca-slicer-api" ? {} : {
1928
+ uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
1929
+ }),
1902
1930
  bedType,
1903
1931
  };
1904
1932
 
@@ -2364,6 +2392,11 @@ class BambuPrinterMCPServer {
2364
2392
  enum: [...VALID_NOZZLE_TYPES],
2365
2393
  description: "Bambu-compatible slicing: the hotend nozzle material installed on the printer (default: BAMBU_NOZZLE_TYPE, else the model preset's stock nozzle, usually stainless_steel). Printing compares it with the printer's reported nozzle."
2366
2394
  },
2395
+ nozzle_volume_type: {
2396
+ type: "string",
2397
+ enum: [...VALID_NOZZLE_VOLUME_TYPES],
2398
+ description: "Bambu-compatible slicing: the flow type of the nozzle installed on the printer (default: BAMBU_NOZZLE_VOLUME_TYPE, else standard). high_flow uses the presets' High Flow values, such as the filament's High Flow max volumetric speed, and is refused if the machine preset has no High Flow variant. Printing compares it with the printer's reported nozzle flow."
2399
+ },
2367
2400
  use_printer_filaments: { type: "boolean", description: "When true, and no explicit slicer profile or load_filaments override is provided, use the printer's current or first loaded AMS filament as the slicer filament profile." },
2368
2401
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2369
2402
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
@@ -2419,6 +2452,11 @@ class BambuPrinterMCPServer {
2419
2452
  enum: [...VALID_NOZZLE_TYPES],
2420
2453
  description: "Bambu-compatible slicing: the hotend nozzle material installed on the printer (default: BAMBU_NOZZLE_TYPE, else the model preset's stock nozzle, usually stainless_steel). Printing compares it with the printer's reported nozzle."
2421
2454
  },
2455
+ nozzle_volume_type: {
2456
+ type: "string",
2457
+ enum: [...VALID_NOZZLE_VOLUME_TYPES],
2458
+ description: "Bambu-compatible slicing: the flow type of the nozzle installed on the printer (default: BAMBU_NOZZLE_VOLUME_TYPE, else standard). high_flow uses the presets' High Flow values, such as the filament's High Flow max volumetric speed, and is refused if the machine preset has no High Flow variant. Printing compares it with the printer's reported nozzle flow."
2459
+ },
2422
2460
  use_printer_filaments: { type: "boolean", description: "When true, and no explicit slicer profile or load_filaments override is provided, use the printer's current or first loaded AMS filament as the slicer filament profile. Template 3MF process settings can still be used at the same time." },
2423
2461
  uptodate: { type: "boolean", description: "Refresh 3MF preset configs to match the latest BambuStudio version. Use when slicing downloaded or older 3MF files to prevent stale-config failures." },
2424
2462
  repetitions: { type: "number", description: "Print N identical copies of the model. Each copy gets its own plate placement. Example: 3 prints three copies." },
@@ -2439,7 +2477,7 @@ class BambuPrinterMCPServer {
2439
2477
  rotate_y: { type: "number", description: "Rotate the model around the Y-axis by this many degrees before slicing. Useful for reorienting prints for better layer adhesion." },
2440
2478
  min_save: { type: "boolean", description: "Write a smaller output 3MF by omitting non-essential metadata. Reduces file size for faster FTP upload to the printer." },
2441
2479
  skip_modified_gcodes: { type: "boolean", description: "Strip custom start/end gcodes embedded in the 3MF. Recommended for downloaded 3MFs since custom gcodes from other users' profiles may be unsafe for your printer." },
2442
- slice_plate: { type: "number", description: "Which plate index to slice. 0 = all plates (default). Use 1, 2, etc. to slice only a specific plate in multi-plate 3MF projects." }
2480
+ slice_plate: { type: "number", description: "Which plate index to slice. 0 = all plates for local slicers; remote slicing defaults to plate 1 and rejects 0. Use 1, 2, etc. to slice only a specific plate in multi-plate 3MF projects." }
2443
2481
  },
2444
2482
  required: ["stl_path", "bambu_model"]
2445
2483
  }
@@ -2525,6 +2563,7 @@ class BambuPrinterMCPServer {
2525
2563
  password: { type: "string", description: "Printer password/access code override for LAN/local bridge methods." },
2526
2564
  bed_type: { type: "string", enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"], description: "Bed plate type currently installed (default: textured_plate)." },
2527
2565
  nozzle_type: { type: "string", enum: [...VALID_NOZZLE_TYPES], description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)." },
2566
+ nozzle_volume_type: { type: "string", enum: [...VALID_NOZZLE_VOLUME_TYPES], description: "Installed nozzle flow type, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_VOLUME_TYPE, else standard)." },
2528
2567
  plate_index: { type: "number", description: "Zero-based plate index to print from the sliced 3MF; converted to FULU's one-based PrintParams plate_index." },
2529
2568
  project_name: { type: "string", description: "Optional project name sent in FULU PrintParams; defaults to the 3MF filename without extension." },
2530
2569
  preset_name: { type: "string", description: "Optional preset name sent in FULU PrintParams; defaults to project plus one-based plate index." },
@@ -2541,8 +2580,8 @@ class BambuPrinterMCPServer {
2541
2580
  nozzle_diameter: { type: "string", description: "Nozzle diameter in mm for auto-slicing (default: 0.4)." },
2542
2581
  nozzle_diameters: { type: "array", minItems: 1, maxItems: 2, items: { type: "number", enum: [0.2, 0.4, 0.6, 0.8] }, description: "Complete per-nozzle diameters for pre-sliced jobs, e.g. [0.4, 0.6]. Omit to verify file metadata against every reported nozzle; cannot combine with nozzle_diameter." },
2543
2582
  use_ams: { type: "boolean", description: "Whether to use the AMS; defaults to auto-detect from the 3MF mapping." },
2544
- ams_mapping: { type: "array", description: "AMS slot mapping array used by both local MCP printing and FULU PrintParams.", items: { type: "number" } },
2545
- ams_slots: { type: "array", description: "Per-used-filament AMS slot list, matching the local LAN print path.", items: { type: "number" } },
2583
+ ams_mapping: { type: "array", description: "AMS slot mapping array used by both local MCP printing and FULU PrintParams. Same tray values as print_3mf: 254 = external spool; on dual-nozzle printers 254 = left/deputy nozzle external and 255 = right/main nozzle external.", items: { type: "number" } },
2584
+ ams_slots: { type: "array", description: "Per-used-filament AMS slot list, matching the local LAN print path (254/255 external spool rules as in print_3mf).", items: { type: "number" } },
2546
2585
  ams_mapping_bridge: { type: "string", description: "Raw JSON string override for FULU PrintParams ams_mapping when the automatic array is not enough." },
2547
2586
  ams_mapping2: { type: "string", description: "Raw JSON string for FULU PrintParams ams_mapping2, matching OrcaSlicer-bambulab's v1 AMS mapping field." },
2548
2587
  ams_mapping_info: { type: "string", description: "Raw JSON string for FULU PrintParams ams_mapping_info, matching OrcaSlicer-bambulab's detailed AMS mapping field." },
@@ -2788,7 +2827,7 @@ class BambuPrinterMCPServer {
2788
2827
  },
2789
2828
  {
2790
2829
  name: "reread_ams_rfid",
2791
- description: "Trigger a Bambu AMS RFID re-read for one AMS slot. X2D on macOS uses the native official plug-in route. This can move AMS filament; use only when the printer is idle and unloaded.",
2830
+ description: "Trigger a Bambu AMS RFID re-read for one AMS slot. X2D on macOS, or Linux with the native helper installed, uses the native official plug-in route. This can move AMS filament; use only when the printer is idle and unloaded.",
2792
2831
  inputSchema: {
2793
2832
  type: "object",
2794
2833
  properties: {
@@ -2821,7 +2860,7 @@ class BambuPrinterMCPServer {
2821
2860
  },
2822
2861
  {
2823
2862
  name: "set_fan_speed",
2824
- description: "Set a Bambu printer fan speed percentage. X2D on macOS uses the installed Bambu networking plug-in without UI automation; other models use MQTT.",
2863
+ description: "Set a Bambu printer fan speed percentage. X2D on macOS, or Linux with the native helper installed, uses Bambu's networking plug-in without UI automation; other models use MQTT.",
2825
2864
  inputSchema: {
2826
2865
  type: "object",
2827
2866
  properties: {
@@ -2880,7 +2919,7 @@ class BambuPrinterMCPServer {
2880
2919
  },
2881
2920
  {
2882
2921
  name: "set_ams_drying",
2883
- description: "Start or stop the AMS filament drying cycle. X2D on macOS uses the native official plug-in route and supports AMS-HT id 128.",
2922
+ description: "Start or stop the AMS filament drying cycle. X2D on macOS, or Linux with the native helper installed, uses the native official plug-in route and supports AMS-HT id 128.",
2884
2923
  inputSchema: {
2885
2924
  type: "object",
2886
2925
  properties: {
@@ -2952,6 +2991,11 @@ class BambuPrinterMCPServer {
2952
2991
  enum: [...VALID_NOZZLE_TYPES],
2953
2992
  description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)."
2954
2993
  },
2994
+ nozzle_volume_type: {
2995
+ type: "string",
2996
+ enum: [...VALID_NOZZLE_VOLUME_TYPES],
2997
+ description: "Installed nozzle flow type, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_VOLUME_TYPE, else standard)."
2998
+ },
2955
2999
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2956
3000
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2957
3001
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
@@ -2965,12 +3009,12 @@ class BambuPrinterMCPServer {
2965
3009
  use_ams: { type: "boolean", description: "Whether to use the AMS (default: auto-detect from 3MF)" },
2966
3010
  ams_mapping: {
2967
3011
  type: "array",
2968
- description: "Project-level AMS mapping array. Position = project filament index, value = absolute AMS tray (0-3=AMS 0, 4-7=AMS 1, 8-11=AMS 2, 128+=AMS-HT, 254=external, -1=unused). Prefer ams_slots unless you know the project-level layout.",
3012
+ description: "Project-level AMS mapping array. Position = project filament index, value = absolute AMS tray (0-3=AMS 0, 4-7=AMS 1, 8-11=AMS 2, 128+=AMS-HT, 254=external, -1=unused). On dual-nozzle printers (H2D, H2D Pro, H2C, X2D) 254 is the LEFT/deputy nozzle external spool and 255 the RIGHT/main nozzle external spool, matching Bambu Studio's virtual tray ids; the gcode header's filament_map (1=left, 2=right) says which nozzle each filament was sliced for. Prefer ams_slots unless you know the project-level layout.",
2969
3013
  items: { type: "number" }
2970
3014
  },
2971
3015
  ams_slots: {
2972
3016
  type: "array",
2973
- description: "Preferred AMS input: one absolute tray index per USED filament in plate order, e.g. [1] for a single-filament print pulling from AMS 0 slot 1. Expanded to project-level ams_mapping automatically from the 3MF's plate_N.json and gcode header.",
3017
+ description: "Preferred AMS input: one absolute tray index per USED filament in plate order, e.g. [1] for a single-filament print pulling from AMS 0 slot 1. Use 254 for the external spool (on dual-nozzle printers: 254 = left/deputy nozzle external, 255 = right/main nozzle external). Expanded to project-level ams_mapping automatically from the 3MF's plate_N.json and gcode header.",
2974
3018
  items: { type: "number" }
2975
3019
  },
2976
3020
  auto_match_ams: {
@@ -3147,9 +3191,43 @@ class BambuPrinterMCPServer {
3147
3191
  const host = String(args?.host || DEFAULT_HOST);
3148
3192
  const bambuSerial = String(args?.bambu_serial || DEFAULT_BAMBU_SERIAL);
3149
3193
  const bambuToken = String(args?.bambu_token || DEFAULT_BAMBU_TOKEN);
3150
- const nativeControls = process.platform === "darwin" &&
3151
- (bambuSerial.trim().toUpperCase().startsWith("20P") ||
3152
- (DEFAULT_BAMBU_MODEL === "x2d" && bambuSerial === DEFAULT_BAMBU_SERIAL));
3194
+ const x2dTarget = bambuSerial.trim().toUpperCase().startsWith("20P") ||
3195
+ (DEFAULT_BAMBU_MODEL === "x2d" && bambuSerial === DEFAULT_BAMBU_SERIAL);
3196
+ // Resolved lazily: the Linux availability check reads files, and most
3197
+ // tools (status, slicing, STL) never need it.
3198
+ // One resolution per request; null means native X2D controls are usable.
3199
+ let nativeUnavailable: string | null | undefined;
3200
+ const nativeUnavailableReason = () =>
3201
+ (nativeUnavailable ??= x2dTarget ? bambuNativeUnavailableReason() ?? null : "The selected printer is not an X2D.");
3202
+ const nativeControls = () => nativeUnavailableReason() === null;
3203
+ // Linux sent X2D stop and heater-off over MQTT before the native route
3204
+ // existed. Send them over both routes there: a stuck helper must not
3205
+ // delay an emergency control, and a repeated stop or heater-off is
3206
+ // harmless. The immediate MQTT send can precede the exit of an older
3207
+ // print or heating helper that then still dispatches, so MQTT is sent
3208
+ // again once every older helper for this printer has exited, and only
3209
+ // that send counts. Either counted route succeeding is success. macOS
3210
+ // stays native-only.
3211
+ const linuxEmergency = (native: () => Promise<any>, mqtt: () => Promise<any>) => async () => {
3212
+ if (process.platform !== "linux") return native();
3213
+ const [nativeResult, immediateResult, mqttResult] = await Promise.allSettled([
3214
+ native(),
3215
+ mqtt(),
3216
+ interruptNativeOperations(bambuSerial).then(() => mqtt()),
3217
+ ]);
3218
+ const reason = (result: PromiseSettledResult<any>) =>
3219
+ result.status === "rejected" ? (result.reason instanceof Error ? result.reason.message : String(result.reason)) : undefined;
3220
+ if (nativeResult.status === "rejected" && mqttResult.status === "rejected") {
3221
+ throw new Error(`native route: ${reason(nativeResult)}; MQTT route: ${reason(mqttResult)}`);
3222
+ }
3223
+ const state = (result: PromiseSettledResult<any>) => result.status === "fulfilled" ? "sent" : "failed";
3224
+ return {
3225
+ status: "success",
3226
+ routes: { native: state(nativeResult), mqtt: state(mqttResult), mqtt_immediate: state(immediateResult) },
3227
+ ...(reason(nativeResult) ? { native_error: reason(nativeResult) } : {}),
3228
+ ...(reason(mqttResult) ? { mqtt_error: reason(mqttResult) } : {}),
3229
+ };
3230
+ };
3153
3231
  const requestedTemplateDir =
3154
3232
  typeof args?.template_dir === "string" && args.template_dir.trim().length > 0
3155
3233
  ? String(args.template_dir)
@@ -3405,15 +3483,17 @@ class BambuPrinterMCPServer {
3405
3483
 
3406
3484
  case "cancel_print":
3407
3485
  result = await this.bambu.cancelJob(host, bambuSerial, bambuToken,
3408
- nativeControls
3409
- ? () => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3410
- messageJson: JSON.stringify({ print: { command: "stop", param: "", sequence_id: String(Date.now()) } }), qos: 1 })
3486
+ nativeControls()
3487
+ ? linuxEmergency(
3488
+ () => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3489
+ messageJson: JSON.stringify({ print: { command: "stop", param: "", sequence_id: String(Date.now()) } }), qos: 1 }),
3490
+ () => this.bambu.sendStopOverMqtt(host, bambuSerial, bambuToken))
3411
3491
  : undefined);
3412
3492
  break;
3413
3493
 
3414
3494
  case "pause_print":
3415
3495
  result = await this.bambu.pauseJob(host, bambuSerial, bambuToken,
3416
- nativeControls
3496
+ nativeControls()
3417
3497
  ? () => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3418
3498
  messageJson: JSON.stringify({ print: { command: "pause", param: "", sequence_id: String(Date.now()) } }), qos: 1 })
3419
3499
  : undefined);
@@ -3421,7 +3501,7 @@ class BambuPrinterMCPServer {
3421
3501
 
3422
3502
  case "resume_print":
3423
3503
  result = await this.bambu.resumeJob(host, bambuSerial, bambuToken,
3424
- nativeControls
3504
+ nativeControls()
3425
3505
  ? (assertActive, beforeDispatch) => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3426
3506
  messageJson: JSON.stringify({ print: { command: "resume", param: "", sequence_id: String(Date.now()) } }), qos: 1 }, { signal: extra.signal, assertActive, beforeDispatch })
3427
3507
  : undefined);
@@ -3431,8 +3511,9 @@ class BambuPrinterMCPServer {
3431
3511
  if (!args?.message_json) {
3432
3512
  throw new Error("Missing required parameter: message_json");
3433
3513
  }
3434
- if (!nativeControls) {
3435
- throw new Error("x2d_native_control is restricted to a selected X2D printer on macOS.");
3514
+ if (!nativeControls()) {
3515
+ throw new Error("x2d_native_control is restricted to a selected X2D printer on macOS, or on Linux with the native helper installed. " +
3516
+ nativeUnavailableReason());
3436
3517
  }
3437
3518
  const messageJson = validateBambuNativeMetadata(String(args.message_json));
3438
3519
  result = await sendNativeCommand({
@@ -3448,7 +3529,7 @@ class BambuPrinterMCPServer {
3448
3529
 
3449
3530
  case "clear_hms_errors":
3450
3531
  result = await this.bambu.clearHmsErrors(host, bambuSerial, bambuToken,
3451
- nativeControls
3532
+ nativeControls()
3452
3533
  ? (assertActive, beforeDispatch) => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3453
3534
  messageJson: JSON.stringify({ print: { command: "clean_print_error", param: "", sequence_id: String(Date.now()) } }), qos: 1 }, { signal: extra.signal, assertActive, beforeDispatch })
3454
3535
  : undefined);
@@ -3458,7 +3539,7 @@ class BambuPrinterMCPServer {
3458
3539
  if (!args?.mode) {
3459
3540
  throw new Error("Missing required parameter: mode");
3460
3541
  }
3461
- if (nativeControls) {
3542
+ if (nativeControls()) {
3462
3543
  const normalized = String(args.mode).trim().toLowerCase();
3463
3544
  const mode = normalized === "silent" ? 1
3464
3545
  : normalized === "standard" ? 2
@@ -3485,7 +3566,7 @@ class BambuPrinterMCPServer {
3485
3566
  if (!args?.mode) {
3486
3567
  throw new Error("Missing required parameter: mode");
3487
3568
  }
3488
- if (nativeControls) {
3569
+ if (nativeControls()) {
3489
3570
  const mode = String(args.mode).trim().toLowerCase();
3490
3571
  if (mode !== "cooling" && mode !== "heating") {
3491
3572
  throw new Error("Airduct mode must be one of: cooling, heating.");
@@ -3512,7 +3593,7 @@ class BambuPrinterMCPServer {
3512
3593
  if (args?.ams_id === undefined || args?.slot_id === undefined) {
3513
3594
  throw new Error("Missing required parameters: ams_id and slot_id");
3514
3595
  }
3515
- if (nativeControls) {
3596
+ if (nativeControls()) {
3516
3597
  const amsId = Number(args.ams_id);
3517
3598
  const slotId = Number(args.slot_id);
3518
3599
  if ((!Number.isInteger(amsId) || (amsId < 0 || amsId > 3)) && amsId !== 128) {
@@ -3561,9 +3642,14 @@ class BambuPrinterMCPServer {
3561
3642
  heaterModel || undefined,
3562
3643
  args?.material !== undefined ? String(args.material) : undefined,
3563
3644
  Number(args?.nozzle_diameter ?? DEFAULT_NOZZLE_DIAMETER),
3564
- (args.temperature === 0 ? nativeControls : heaterModel === "x2d" && process.platform === "darwin")
3565
- ? (heater, target, assertActive, beforeDispatch) => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3566
- messageJson: buildBambuNativeTemperatureCommand(heater, target).messageJson }, { signal: extra.signal, assertActive, beforeDispatch })
3645
+ (args.temperature === 0 ? nativeControls() : heaterModel === "x2d" && bambuNativeControlsAvailable())
3646
+ ? (heater, target, assertActive, beforeDispatch) => {
3647
+ const native = () => sendNativeCommand({ host, serial: bambuSerial, token: bambuToken,
3648
+ messageJson: buildBambuNativeTemperatureCommand(heater, target).messageJson }, { signal: extra.signal, assertActive, beforeDispatch });
3649
+ return target === 0
3650
+ ? linuxEmergency(native, () => this.bambu.sendHeaterOffOverMqtt(host, bambuSerial, bambuToken, heater))()
3651
+ : native();
3652
+ }
3567
3653
  : undefined
3568
3654
  );
3569
3655
  break;
@@ -3573,7 +3659,7 @@ class BambuPrinterMCPServer {
3573
3659
  if (!args?.fan || args?.speed === undefined) {
3574
3660
  throw new Error("Missing required parameters: fan and speed");
3575
3661
  }
3576
- if (nativeControls) {
3662
+ if (nativeControls()) {
3577
3663
  const status = await this.bambu.getStatus(host, bambuSerial, bambuToken);
3578
3664
  const printerState = String(status?.raw?.gcode_state ?? status?.raw?.print?.gcode_state ?? status?.gcode_state ?? status?.status ?? "").toUpperCase();
3579
3665
  if (printerState === "RUNNING" && args?.confirm_during_print !== true) {
@@ -3607,7 +3693,7 @@ class BambuPrinterMCPServer {
3607
3693
  if (!args?.light || !args?.mode) {
3608
3694
  throw new Error("Missing required parameters: light and mode");
3609
3695
  }
3610
- if (nativeControls) {
3696
+ if (nativeControls()) {
3611
3697
  const light = String(args.light).trim();
3612
3698
  const mode = String(args.mode).trim().toLowerCase();
3613
3699
  if (light !== "chamber_light" && light !== "chamber_light2") {
@@ -3648,7 +3734,7 @@ class BambuPrinterMCPServer {
3648
3734
  if (!Array.isArray(args?.object_ids)) {
3649
3735
  throw new Error("Missing required parameter: object_ids");
3650
3736
  }
3651
- if (nativeControls) {
3737
+ if (nativeControls()) {
3652
3738
  const objectIds = args.object_ids.map((id: unknown) => Number(id));
3653
3739
  if (objectIds.length === 0 || objectIds.some((id: number) => !Number.isInteger(id) || id < 0)) {
3654
3740
  throw new Error("object_ids must contain one or more non-negative integers.");
@@ -3676,7 +3762,7 @@ class BambuPrinterMCPServer {
3676
3762
  if (!args?.action || args?.ams_id === undefined) {
3677
3763
  throw new Error("Missing required parameters: action and ams_id");
3678
3764
  }
3679
- if (nativeControls) {
3765
+ if (nativeControls()) {
3680
3766
  const action = String(args.action).trim().toLowerCase();
3681
3767
  const amsId = Number(args.ams_id);
3682
3768
  if (action !== "start" && action !== "stop") {
@@ -3808,6 +3894,7 @@ class BambuPrinterMCPServer {
3808
3894
  if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3809
3895
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3810
3896
  sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3897
+ sliceBambuOptions.nozzleVolumeType = resolveNozzleVolumeType(args?.nozzle_volume_type);
3811
3898
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3812
3899
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
3813
3900
  if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
@@ -3895,6 +3982,7 @@ class BambuPrinterMCPServer {
3895
3982
  if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3896
3983
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3897
3984
  sliceBambuOptions.nozzleType = resolveNozzleType(args?.nozzle_type);
3985
+ sliceBambuOptions.nozzleVolumeType = resolveNozzleVolumeType(args?.nozzle_volume_type);
3898
3986
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3899
3987
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
3900
3988
  if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
@@ -4017,12 +4105,12 @@ class BambuPrinterMCPServer {
4017
4105
  }
4018
4106
  console.error(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
4019
4107
  const autoSliceOptions: BambuSliceOptions = {
4020
- uptodate: true,
4021
- ensureOnBed: true,
4022
- minSave: true,
4023
- skipModifiedGcodes: true,
4108
+ ...(slicerType === "orca-slicer-api" ? { slicePlate: plateIndex + 1 } : {
4109
+ uptodate: true, ensureOnBed: true, minSave: true, skipModifiedGcodes: true,
4110
+ }),
4024
4111
  bedType: printBedType,
4025
4112
  nozzleType: resolveNozzleType(args?.nozzle_type),
4113
+ nozzleVolumeType: resolveNozzleVolumeType(args?.nozzle_volume_type),
4026
4114
  };
4027
4115
  if (!explicitSlicerProfile) {
4028
4116
  try {
@@ -4051,9 +4139,10 @@ class BambuPrinterMCPServer {
4051
4139
 
4052
4140
  const parsed3MFData = await parse3MF(threeMFPath);
4053
4141
  const isH2Print = H2_BAMBU_MODELS.has(printModel);
4142
+ const trayOptions = { dualNozzle: isDualNozzleModel(printModel) };
4054
4143
  let parsedAmsMapping: number[] | undefined;
4055
4144
  if (!isH2Print && parsed3MFData.slicerConfig?.ams_mapping) {
4056
- const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>);
4145
+ const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
4057
4146
  if (slots.length > 0) {
4058
4147
  parsedAmsMapping = slots;
4059
4148
  }
@@ -4069,10 +4158,10 @@ class BambuPrinterMCPServer {
4069
4158
  let userMappingOverride: number[] | undefined;
4070
4159
  if (Array.isArray(args.ams_mapping)) {
4071
4160
  userMappingOverride = args.ams_mapping.map((v: unknown, i: number) =>
4072
- normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`)
4161
+ normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`, trayOptions)
4073
4162
  );
4074
4163
  } else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
4075
- userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>);
4164
+ userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
4076
4165
  }
4077
4166
  if (userMappingOverride && userMappingOverride.length > 0) {
4078
4167
  finalAmsMapping = userMappingOverride;
@@ -4126,8 +4215,8 @@ class BambuPrinterMCPServer {
4126
4215
  }
4127
4216
 
4128
4217
  if (args?.use_ams === false) {
4129
- finalAmsMapping = undefined;
4130
- finalAmsSlots = undefined;
4218
+ if (!hasUserAmsMapping) finalAmsMapping = undefined;
4219
+ if (!hasUserAmsSlots) finalAmsSlots = undefined;
4131
4220
  useAMS = false;
4132
4221
  }
4133
4222
  if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {