bambu-printer-mcp 1.1.8 → 1.1.10

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/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { BambuNetworkBridge } from "./bambu-network-bridge.js";
14
14
  import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
15
15
  import { hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
16
16
  import { analyze3MFAmsRequirements, analyze3MFPlateObjects, analyzeCollarCharm3MF, extractBambuTemplateSettings, getCollarCharmRolePolicy, parse3MF } from './3mf_parser.js';
17
- import { BambuImplementation } from "./printers/bambu.js";
17
+ import { BambuImplementation, assertDirectPrintSupported } from "./printers/bambu.js";
18
18
  dotenv.config();
19
19
  const DEFAULT_HOST = process.env.BAMBU_PRINTER_HOST || process.env.PRINTER_HOST || "localhost";
20
20
  const DEFAULT_BAMBU_SERIAL = process.env.BAMBU_PRINTER_SERIAL || process.env.BAMBU_SERIAL || "";
@@ -44,7 +44,7 @@ const DEFAULT_BAMBU_MODEL = process.env.BAMBU_PRINTER_MODEL?.trim().toLowerCase(
44
44
  "";
45
45
  const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
46
46
  const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
47
- const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c"];
47
+ const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c", "x2d"];
48
48
  const H2_BAMBU_MODELS = new Set(["h2d", "h2s", "h2c"]);
49
49
  const VALID_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"];
50
50
  const VALID_BAMBUSTUDIO_CLI_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"];
@@ -60,6 +60,7 @@ const BAMBU_MODEL_PRESETS = {
60
60
  h2d: (n) => `Bambu Lab H2D ${n} nozzle`,
61
61
  h2s: (n) => `Bambu Lab H2S ${n} nozzle`,
62
62
  h2c: (n) => `Bambu Lab H2C ${n} nozzle`,
63
+ x2d: (n) => `Bambu Lab X2D ${n} nozzle`,
63
64
  };
64
65
  const FILAMENT_PROFILE_DIR = "/Applications/BambuStudio.app/Contents/Resources/profiles/BBL/filament";
65
66
  const FILAMENT_MODEL_CODES = {
@@ -73,6 +74,7 @@ const FILAMENT_MODEL_CODES = {
73
74
  h2d: "H2D",
74
75
  h2s: "H2S",
75
76
  h2c: "H2C",
77
+ x2d: "X2D",
76
78
  };
77
79
  const COLLAR_CHARM_POLICY = getCollarCharmRolePolicy();
78
80
  let filamentProfileIndexCache = null;
@@ -371,6 +373,19 @@ function extractPrinterDiagnostics(status) {
371
373
  diagnostic_fields: diagnosticFields,
372
374
  };
373
375
  }
376
+ /** Parse "#RRGGBB;#RRGGBB" (leading '#' optional) into positional slot colours. */
377
+ function parseFilamentColours(value) {
378
+ const colours = value.split(";").map((c) => c.trim()).filter(Boolean)
379
+ .map((c) => (c.startsWith("#") ? c : `#${c}`).toUpperCase());
380
+ for (const c of colours) {
381
+ if (!/^#[0-9A-F]{6}([0-9A-F]{2})?$/.test(c)) {
382
+ throw new Error(`Invalid filament colour "${c}"; expected #RRGGBB.`);
383
+ }
384
+ }
385
+ if (colours.length === 0)
386
+ throw new Error("filament_colours must list at least one colour.");
387
+ return colours;
388
+ }
374
389
  function validateBambuModel(model) {
375
390
  const normalized = model.trim().toLowerCase();
376
391
  if (!VALID_BAMBU_MODELS.includes(normalized)) {
@@ -822,6 +837,7 @@ class BambuPrinterMCPServer {
822
837
  { const: "h2d", title: "H2D" },
823
838
  { const: "h2s", title: "H2S" },
824
839
  { const: "h2c", title: "H2C" },
840
+ { const: "x2d", title: "X2D" },
825
841
  ],
826
842
  },
827
843
  },
@@ -1549,6 +1565,7 @@ class BambuPrinterMCPServer {
1549
1565
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
1550
1566
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs." },
1551
1567
  load_filament_ids: { type: "string", description: "Optional filament-to-object mapping string." },
1568
+ filament_colours: { type: "string", description: "Optional slot colours, one #RRGGBB per filament slot in order, separated by ';' (e.g. '#161616;#FFFFFF'). Defaults to the input 3MF's project colours, else the BambuStudio default." },
1552
1569
  ensure_on_bed: { type: "boolean", description: "Lift floating models onto the bed." },
1553
1570
  arrange: { type: "boolean", description: "Auto-arrange objects on the build plate." },
1554
1571
  orient: { type: "boolean", description: "Auto-orient model for optimal printability." },
@@ -1603,6 +1620,7 @@ class BambuPrinterMCPServer {
1603
1620
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs, e.g. 'pla_basic.json;petg_cf.json'." },
1604
1621
  filament_profile: { type: "string", description: "Compatibility alias for load_filaments. Semicolon-separated Orca/Bambu filament profile JSON paths." },
1605
1622
  load_filament_ids: { type: "string", description: "Map filaments to objects/parts. Comma-separated IDs matching load_filaments order, e.g. '1,2,3,1' assigns filament 1 to objects 0 and 3." },
1623
+ filament_colours: { type: "string", description: "Optional slot colours, one #RRGGBB per filament slot in order, separated by ';' (e.g. '#161616;#FFFFFF'). Defaults to the input 3MF's project colours, else the BambuStudio default." },
1606
1624
  enable_timelapse: { type: "boolean", description: "Insert timelapse parking moves into gcode. The toolhead parks at a fixed position each layer for camera capture. Adds ~10% print time." },
1607
1625
  allow_mix_temp: { type: "boolean", description: "Allow filaments with different temperature requirements on the same plate. Required for multi-material prints mixing e.g. PLA and PETG." },
1608
1626
  scale: { type: "number", description: "Uniform scale factor applied to all axes. 1.0 = original size, 2.0 = double, 0.5 = half. Applied before slicing." },
@@ -2107,7 +2125,7 @@ class BambuPrinterMCPServer {
2107
2125
  bambu_model: {
2108
2126
  type: "string",
2109
2127
  enum: [...VALID_BAMBU_MODELS],
2110
- description: "REQUIRED: Bambu Lab printer model. H2D, H2S, and H2C are the primary intended paths."
2128
+ description: "REQUIRED: Bambu Lab printer model. H2D, H2S, and H2C are the primary intended paths. X2D direct printing is not supported."
2111
2129
  },
2112
2130
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2113
2131
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
@@ -2343,23 +2361,27 @@ class BambuPrinterMCPServer {
2343
2361
  }
2344
2362
  break;
2345
2363
  }
2346
- case "upload_file":
2364
+ case "upload_file": {
2347
2365
  if (!args?.file_path || !args?.filename) {
2348
2366
  throw new Error("Missing required parameters: file_path and filename");
2349
2367
  }
2350
- if (Boolean(args.print ?? false)) {
2351
- await this.resolveBambuModel(args?.bambu_model);
2352
- }
2353
- result = await this.bambu.uploadFile(host, bambuSerial, bambuToken, String(args.file_path), String(args.filename), Boolean(args.print ?? false));
2368
+ const print = Boolean(args.print ?? false);
2369
+ const uploadModel = print ? await this.resolveBambuModel(args?.bambu_model) : undefined;
2370
+ if (print)
2371
+ assertDirectPrintSupported(uploadModel, bambuSerial);
2372
+ result = await this.bambu.uploadFile(host, bambuSerial, bambuToken, String(args.file_path), String(args.filename), print, uploadModel);
2354
2373
  break;
2374
+ }
2355
2375
  case "start_print":
2356
- case "start_print_job":
2376
+ case "start_print_job": {
2357
2377
  if (!args?.filename) {
2358
2378
  throw new Error("Missing required parameter: filename");
2359
2379
  }
2360
- await this.resolveBambuModel(args?.bambu_model);
2361
- result = await this.bambu.startJob(host, bambuSerial, bambuToken, String(args.filename));
2380
+ const startModel = await this.resolveBambuModel(args?.bambu_model);
2381
+ assertDirectPrintSupported(startModel, bambuSerial);
2382
+ result = await this.bambu.startJob(host, bambuSerial, bambuToken, String(args.filename), startModel);
2362
2383
  break;
2384
+ }
2363
2385
  case "cancel_print":
2364
2386
  result = await this.bambu.cancelJob(host, bambuSerial, bambuToken);
2365
2387
  break;
@@ -2499,6 +2521,8 @@ class BambuPrinterMCPServer {
2499
2521
  }
2500
2522
  if (args?.load_filament_ids !== undefined)
2501
2523
  sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
2524
+ if (args?.filament_colours !== undefined)
2525
+ sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
2502
2526
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type);
2503
2527
  if (args?.enable_timelapse !== undefined)
2504
2528
  sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
@@ -2576,6 +2600,8 @@ class BambuPrinterMCPServer {
2576
2600
  }
2577
2601
  if (args?.load_filament_ids !== undefined)
2578
2602
  sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
2603
+ if (args?.filament_colours !== undefined)
2604
+ sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
2579
2605
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type);
2580
2606
  if (args?.enable_timelapse !== undefined)
2581
2607
  sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
@@ -2628,6 +2654,7 @@ class BambuPrinterMCPServer {
2628
2654
  }
2629
2655
  const { slicerType, slicerPath, slicerProfile } = this.resolveSlicerConfigFromArgs(args, "print_3mf");
2630
2656
  const printModel = await this.resolveBambuModel(args?.bambu_model);
2657
+ assertDirectPrintSupported(printModel, bambuSerial);
2631
2658
  const printBedType = resolveBedType(args?.bed_type);
2632
2659
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2633
2660
  const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(args, slicerProfile || undefined, resolveTemplatePathFromName(["json", "config", "3mf"]) || explicitTemplatePath || undefined, TEMP_DIR);
@@ -2768,6 +2795,7 @@ class BambuPrinterMCPServer {
2768
2795
  }
2769
2796
  const { slicerType, slicerPath, slicerProfile } = this.resolveSlicerConfigFromArgs(args, "print_collar_charm");
2770
2797
  const printModel = await this.resolveBambuModel(args?.bambu_model);
2798
+ assertDirectPrintSupported(printModel, bambuSerial);
2771
2799
  const printBedType = resolveBedType(args?.bed_type);
2772
2800
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2773
2801
  const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(args, slicerProfile || undefined, resolvedTemplateSourcePath || explicitTemplatePath || undefined, TEMP_DIR);
@@ -1,3 +1,4 @@
1
+ export declare function assertDirectPrintSupported(model: string | undefined, serial?: string): void;
1
2
  interface BambuPrintOptionsInternal {
2
3
  projectName: string;
3
4
  filePath: string;
@@ -67,8 +68,8 @@ export declare class BambuImplementation {
67
68
  name: string;
68
69
  exists: boolean;
69
70
  }>;
70
- uploadFile(host: string, serial: string, token: string, filePath: string, filename: string, print: boolean): Promise<Record<string, unknown>>;
71
- startJob(host: string, serial: string, token: string, filename: string): Promise<{
71
+ uploadFile(host: string, serial: string, token: string, filePath: string, filename: string, print: boolean, bambuModel?: string): Promise<Record<string, unknown>>;
72
+ startJob(host: string, serial: string, token: string, filename: string, bambuModel?: string): Promise<{
72
73
  status: string;
73
74
  message: string;
74
75
  file: string;
@@ -37,6 +37,7 @@ const MODEL_ID_TO_NAME = {
37
37
  O1D: "H2D",
38
38
  O1E: "H2D Pro",
39
39
  O1S: "H2S",
40
+ N6: "X2D",
40
41
  N2S: "A1",
41
42
  A1M: "A1 Mini",
42
43
  C11: "P1P",
@@ -46,6 +47,12 @@ const MODEL_ID_TO_NAME = {
46
47
  C13: "X1E",
47
48
  };
48
49
  const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s"]);
50
+ export function assertDirectPrintSupported(model, serial) {
51
+ if (model?.trim().toLowerCase() === "x2d" || serial?.trim().toUpperCase().startsWith("20P")) {
52
+ throw new Error("X2D direct printing is not supported pending native eMMC transport support. " +
53
+ "X2D status and slicing remain available; use a supported slicer to print.");
54
+ }
55
+ }
49
56
  function isH2ModelName(model) {
50
57
  return H2_MODEL_NAMES.has(String(model ?? "").trim().toLowerCase().replace(/\s+/g, " "));
51
58
  }
@@ -95,6 +102,8 @@ class TolerantBambuClient extends BambuClient {
95
102
  return "H2C";
96
103
  if (sn.startsWith("31B"))
97
104
  return "H2DPRO";
105
+ if (sn.startsWith("20P"))
106
+ return "X2D";
98
107
  if (sn.startsWith("00M"))
99
108
  return "X1C";
100
109
  if (sn.startsWith("00W"))
@@ -457,6 +466,7 @@ export class BambuImplementation {
457
466
  }
458
467
  }
459
468
  async print3mf(host, serial, token, options) {
469
+ assertDirectPrintSupported(options.bambuModel, serial);
460
470
  if (!options.filePath.toLowerCase().endsWith(".3mf")) {
461
471
  throw new Error("print3mf requires a .3mf input file.");
462
472
  }
@@ -929,7 +939,9 @@ export class BambuImplementation {
929
939
  exists,
930
940
  };
931
941
  }
932
- async uploadFile(host, serial, token, filePath, filename, print) {
942
+ async uploadFile(host, serial, token, filePath, filename, print, bambuModel) {
943
+ if (print)
944
+ assertDirectPrintSupported(bambuModel, serial);
933
945
  await fs.access(filePath);
934
946
  const normalizedFileName = filename.replace(/^\/+/, "");
935
947
  const remotePath = normalizedFileName.includes("/")
@@ -945,7 +957,7 @@ export class BambuImplementation {
945
957
  };
946
958
  if (print) {
947
959
  if (remotePath.toLowerCase().endsWith(".gcode")) {
948
- response.startResult = await this.startJob(host, serial, token, remotePath);
960
+ response.startResult = await this.startJob(host, serial, token, remotePath, bambuModel);
949
961
  }
950
962
  else if (remotePath.toLowerCase().endsWith(".3mf")) {
951
963
  response.note =
@@ -957,7 +969,8 @@ export class BambuImplementation {
957
969
  }
958
970
  return response;
959
971
  }
960
- async startJob(host, serial, token, filename) {
972
+ async startJob(host, serial, token, filename, bambuModel) {
973
+ assertDirectPrintSupported(bambuModel, serial);
961
974
  const lower = filename.toLowerCase();
962
975
  if (lower.endsWith(".3mf") && !lower.endsWith(".gcode.3mf")) {
963
976
  throw new Error("Use print_3mf for .3mf project files.");
@@ -1007,7 +1020,7 @@ export class BambuImplementation {
1007
1020
  // P1/A1 series still use the proprietary TCP-on-6000 framed JPEG path
1008
1021
  // (per https://github.com/Doridian/OpenBambuAPI/blob/main/video.md).
1009
1022
  const TCP_CAMERA_MODELS = new Set(["a1", "a1mini", "p1s", "p1p"]);
1010
- // X1, P2S, AND H2 (H2S/H2D/H2C) all use RTSP on port 322. The
1023
+ // X1, P2S, H2 (H2S/H2D/H2C), AND X2D all use RTSP on port 322. The
1011
1024
  // OpenBambuAPI doc only mentions X1/P2S, but the HA bambulab
1012
1025
  // integration's models.py shows the printer reports its own
1013
1026
  // `ipcam.rtsp_url` for these models, and Parker (H2S) rejects the
@@ -1015,7 +1028,7 @@ export class BambuImplementation {
1015
1028
  // confirmed by local H2 camera transport probes).
1016
1029
  const RTSP_MODELS = new Set([
1017
1030
  "x1", "x1c", "x1carbon", "x1e", "p2s",
1018
- "h2", "h2s", "h2d", "h2c", "h2dpro",
1031
+ "h2", "h2s", "h2d", "h2c", "h2dpro", "x2d",
1019
1032
  ]);
1020
1033
  if (!model) {
1021
1034
  throw new Error("camera_snapshot requires bambu_model or BAMBU_MODEL so it can choose the correct Bambu camera protocol.");
@@ -70,7 +70,21 @@ export interface FlattenOptions {
70
70
  process?: Record<string, unknown>;
71
71
  filaments?: (Record<string, unknown> | undefined)[];
72
72
  };
73
+ /**
74
+ * Positional `#RRGGBB` colour per filament slot (e.g. from the input 3MF
75
+ * project or the caller). Missing entries keep the profile's own colour or
76
+ * fall back to DEFAULT_FILAMENT_COLOUR.
77
+ */
78
+ filamentColours?: (string | undefined)[];
79
+ /** Saved per-plate positions, used only when the process sets no position. */
80
+ projectTowerPosition?: {
81
+ wipe_tower_x?: unknown;
82
+ wipe_tower_y?: unknown;
83
+ };
73
84
  }
85
+ /** Best-effort extruder count for fallback nozzle_volume_type sizing. */
86
+ /** BambuStudio's built-in filament_colour default. */
87
+ export declare const DEFAULT_FILAMENT_COLOUR = "#00AE42";
74
88
  /**
75
89
  * Flatten the leaf profiles, post-process for CLI, and write to temp files.
76
90
  *
@@ -219,6 +219,86 @@ function enforceMatchingNozzles(arr) {
219
219
  }
220
220
  }
221
221
  /** Best-effort extruder count for fallback nozzle_volume_type sizing. */
222
+ /** BambuStudio's built-in filament_colour default. */
223
+ export const DEFAULT_FILAMENT_COLOUR = "#00AE42";
224
+ const FILAMENT_COLOUR_RE = /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/;
225
+ /**
226
+ * No BBL filament profile defines `filament_colour`; the GUI fills it from
227
+ * the AMS. Without it the CLI keeps a single-entry colour vector however many
228
+ * filaments are loaded and crashes (access violation) as soon as a slice uses
229
+ * any filament after the first. Give every slot exactly one colour.
230
+ */
231
+ function ensureFilamentColour(flat, colour) {
232
+ if (colour !== undefined) {
233
+ if (!FILAMENT_COLOUR_RE.test(colour)) {
234
+ throw new Error(`Invalid filament colour "${colour}"; expected #RRGGBB.`);
235
+ }
236
+ flat["filament_colour"] = [colour];
237
+ return;
238
+ }
239
+ const own = flat["filament_colour"];
240
+ if (Array.isArray(own) && typeof own[0] === "string" && FILAMENT_COLOUR_RE.test(own[0])) {
241
+ flat["filament_colour"] = [own[0]];
242
+ return;
243
+ }
244
+ flat["filament_colour"] = [DEFAULT_FILAMENT_COLOUR];
245
+ }
246
+ // BambuStudio's defaults when a process sets no prime tower position/width.
247
+ const DEFAULT_WIPE_TOWER_X = 15;
248
+ const DEFAULT_WIPE_TOWER_Y = 220;
249
+ const DEFAULT_PRIME_TOWER_WIDTH = 35;
250
+ // Clearance inside the shared nozzle area for the auto-sized tower brim and
251
+ // the CLI's own safety margin (X2D fails at 5 mm from the edge, passes at 6).
252
+ const WIPE_TOWER_MARGIN = 15;
253
+ function parseArea(area) {
254
+ const points = area.split(",").map((p) => p.trim().split("x").map(Number));
255
+ if (points.length < 3 || points.some((p) => p.length !== 2 || p.some((n) => !Number.isFinite(n)))) {
256
+ throw new Error(`Malformed extruder_printable_area entry "${area}".`);
257
+ }
258
+ const xs = points.map((p) => p[0]);
259
+ const ys = points.map((p) => p[1]);
260
+ return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
261
+ }
262
+ /**
263
+ * On multi-nozzle machines (H2D, X2D) each nozzle reaches only part of the
264
+ * bed (`extruder_printable_area`), and the prime tower must be reachable by
265
+ * every nozzle. The CLI's default tower position (x=15) lies outside the
266
+ * X2D/H2D right-nozzle area, so slicing fails with "G-code outside of the
267
+ * printable area". When the process sets no position and the default does
268
+ * not fit, move the tower inside the area all nozzles share.
269
+ */
270
+ function placePrimeTowerForAllNozzles(machineFlat, processFlat) {
271
+ if (processFlat["wipe_tower_x"] !== undefined || processFlat["wipe_tower_y"] !== undefined)
272
+ return;
273
+ if (["0", "false"].includes(String(processFlat["enable_prime_tower"]).toLowerCase()))
274
+ return;
275
+ const areas = machineFlat["extruder_printable_area"];
276
+ if (!Array.isArray(areas) || areas.length < 2)
277
+ return;
278
+ const rects = areas.map((a) => {
279
+ if (typeof a !== "string")
280
+ throw new Error("Malformed extruder_printable_area in machine profile.");
281
+ return parseArea(a);
282
+ });
283
+ const shared = {
284
+ minX: Math.max(...rects.map((r) => r.minX)) + WIPE_TOWER_MARGIN,
285
+ maxX: Math.min(...rects.map((r) => r.maxX)) - WIPE_TOWER_MARGIN,
286
+ minY: Math.max(...rects.map((r) => r.minY)) + WIPE_TOWER_MARGIN,
287
+ maxY: Math.min(...rects.map((r) => r.maxY)) - WIPE_TOWER_MARGIN,
288
+ };
289
+ const width = Number(processFlat["prime_tower_width"] ?? DEFAULT_PRIME_TOWER_WIDTH);
290
+ const size = Number.isFinite(width) && width > 0 ? width : DEFAULT_PRIME_TOWER_WIDTH;
291
+ if (shared.maxX - shared.minX < size || shared.maxY - shared.minY < size) {
292
+ throw new Error("No bed area is reachable by every nozzle for the prime tower.");
293
+ }
294
+ const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
295
+ const x = clamp(DEFAULT_WIPE_TOWER_X, shared.minX, shared.maxX - size);
296
+ const y = clamp(DEFAULT_WIPE_TOWER_Y, shared.minY, shared.maxY - size);
297
+ if (x === DEFAULT_WIPE_TOWER_X && y === DEFAULT_WIPE_TOWER_Y)
298
+ return;
299
+ processFlat["wipe_tower_x"] = [String(x)];
300
+ processFlat["wipe_tower_y"] = [String(y)];
301
+ }
222
302
  function inferExtruderCount(flat) {
223
303
  for (const key of ["nozzle_diameter", "extruder_type", "extruder_variant_list"]) {
224
304
  const v = flat[key];
@@ -466,7 +546,15 @@ export async function flattenForCli(opts) {
466
546
  normalizeForCli(machineFlat, "machine", opts.machineLeaf);
467
547
  normalizeForCli(processFlat, "process", opts.processLeaf);
468
548
  filamentFlats.forEach((f, i) => normalizeForCli(f, "filament", opts.filamentLeaves[i]));
549
+ if (opts.filamentColours && opts.filamentColours.length !== filamentFlats.length) {
550
+ throw new Error(`${opts.filamentColours.length} filament colours were supplied for ${filamentFlats.length} filament slots.`);
551
+ }
552
+ filamentFlats.forEach((f, i) => ensureFilamentColour(f, opts.filamentColours?.[i]));
469
553
  applyBedType(processFlat, opts.bedType);
554
+ if (processFlat["wipe_tower_x"] === undefined && processFlat["wipe_tower_y"] === undefined) {
555
+ Object.assign(processFlat, opts.projectTowerPosition);
556
+ }
557
+ placePrimeTowerForAllNozzles(machineFlat, processFlat);
470
558
  // Mirror the GUI's auto-extend behavior: when the caller explicitly
471
559
  // chose a process or filament that wasn't pre-declared compatible with
472
560
  // the chosen machine (e.g. "0.20mm Standard @BBL P1P" used on a P1S),
@@ -36,6 +36,7 @@ export interface BambuSliceOptions {
36
36
  skipObjects?: string;
37
37
  loadFilaments?: string;
38
38
  loadFilamentIds?: string;
39
+ filamentColours?: string[];
39
40
  bedType?: string;
40
41
  enableTimelapse?: boolean;
41
42
  allowMixTemp?: boolean;
@@ -382,20 +382,32 @@ export class STLManipulator extends EventEmitter {
382
382
  profilesRoot,
383
383
  tempDir: this.tempDir,
384
384
  bedType: this.resolveBambuStudioBedType(bambuOptions?.bedType),
385
+ filamentColours: bambuOptions?.filamentColours ?? bundle.filamentColours,
386
+ projectTowerPosition: bundle.projectTowerPosition,
385
387
  sourceProfiles: {
386
388
  machine: isBundledFile(machinePath) ? undefined : machine,
387
389
  process: isBundledFile(processPath) ? undefined : processProfile,
388
390
  filaments: filaments.map((profile, i) => isBundledFile(bundle.filamentPaths[i]) ? undefined : profile),
389
391
  },
390
392
  });
391
- // Standalone custom files have no BBL dependencies; preserve their identity
392
- // and content. Other profiles retain user values through sourceProfiles.
393
+ // Standalone files retain their own identity and settings, but still need
394
+ // the CLI colour/tower fields. Do not discard those required overlays when
395
+ // choosing the original profile instead of the normalized BBL output.
396
+ const withCliFields = (filePath, profile, flatPath, keys) => {
397
+ const flattened = readProfile(flatPath);
398
+ const fields = Object.fromEntries(keys.filter(key => flattened[key] !== undefined).map(key => [key, flattened[key]]));
399
+ if (Object.entries(fields).every(([key, value]) => JSON.stringify(profile[key]) === JSON.stringify(value)))
400
+ return filePath;
401
+ return this.writeTempJson(path.basename(filePath, '.json'), 'cli', { ...profile, ...fields });
402
+ };
393
403
  return {
394
404
  settingsArg: [
395
405
  needsResolution(machinePath, machine) ? flat.machinePath : machinePath,
396
- needsResolution(processPath, processProfile) ? flat.processPath : processPath,
406
+ needsResolution(processPath, processProfile) ? flat.processPath
407
+ : withCliFields(processPath, processProfile, flat.processPath, ['wipe_tower_x', 'wipe_tower_y']),
397
408
  ].join(';'),
398
- filamentPaths: filaments.map((profile, i) => needsResolution(bundle.filamentPaths[i], profile) ? flat.filamentPaths[i] : bundle.filamentPaths[i]),
409
+ filamentPaths: filaments.map((profile, i) => needsResolution(bundle.filamentPaths[i], profile) ? flat.filamentPaths[i]
410
+ : withCliFields(bundle.filamentPaths[i], profile, flat.filamentPaths[i], ['filament_colour'])),
399
411
  };
400
412
  }
401
413
  /** --load-filaments is positional; a single override must cover every project slot. */
@@ -417,13 +429,20 @@ export class STLManipulator extends EventEmitter {
417
429
  .map(key => Array.isArray(data[key]) ? data[key].length : 0));
418
430
  if (count === 0)
419
431
  throw new Error('Cannot determine project filament slots from project_settings.config.');
432
+ // Keep the project's slot colours; loaded profiles carry none of their own.
433
+ const colours = Array.isArray(data.filament_colour) && data.filament_colour.length === count &&
434
+ data.filament_colour.every(c => typeof c === 'string' && /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(c))
435
+ ? data.filament_colour
436
+ : undefined;
437
+ const projectTowerPosition = Object.fromEntries(['wipe_tower_x', 'wipe_tower_y']
438
+ .filter(key => data[key] !== undefined).map(key => [key, data[key]]));
420
439
  if (bundle.filamentPaths.length === 1) {
421
- return { ...bundle, filamentPaths: Array(count).fill(bundle.filamentPaths[0]) };
440
+ return { ...bundle, filamentPaths: Array(count).fill(bundle.filamentPaths[0]), filamentColours: colours, projectTowerPosition };
422
441
  }
423
442
  if (bundle.filamentPaths.length !== count) {
424
443
  throw new Error(`Project declares ${count} filament slots, but ${bundle.filamentPaths.length} profiles were supplied. Provide one profile for all slots or one per slot.`);
425
444
  }
426
- return bundle;
445
+ return { ...bundle, filamentColours: colours, projectTowerPosition };
427
446
  }
428
447
  resolveBambuStudioBedType(bedType) {
429
448
  if (!bedType)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bambu-printer-mcp",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "description": "MCP server for Bambu Lab 3D printers with STL manipulation, slicing, and print management",
5
5
  "main": "dist/index.js",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -29,7 +29,7 @@ import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
29
29
  import { hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
30
30
  import { analyze3MFAmsRequirements, analyze3MFPlateObjects, analyzeCollarCharm3MF, extractBambuTemplateSettings, getCollarCharmRolePolicy, parse3MF } from './3mf_parser.js';
31
31
  import type { ThreeMFAmsRequirements } from "./types.js";
32
- import { BambuImplementation } from "./printers/bambu.js";
32
+ import { BambuImplementation, assertDirectPrintSupported } from "./printers/bambu.js";
33
33
 
34
34
  dotenv.config();
35
35
 
@@ -64,7 +64,7 @@ const DEFAULT_BAMBU_MODEL =
64
64
  const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
65
65
  const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
66
66
 
67
- const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c"] as const;
67
+ const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c", "x2d"] as const;
68
68
  type BambuModel = typeof VALID_BAMBU_MODELS[number];
69
69
  const H2_BAMBU_MODELS = new Set<string>(["h2d", "h2s", "h2c"]);
70
70
 
@@ -83,6 +83,7 @@ const BAMBU_MODEL_PRESETS: Record<string, (nozzle: string) => string> = {
83
83
  h2d: (n) => `Bambu Lab H2D ${n} nozzle`,
84
84
  h2s: (n) => `Bambu Lab H2S ${n} nozzle`,
85
85
  h2c: (n) => `Bambu Lab H2C ${n} nozzle`,
86
+ x2d: (n) => `Bambu Lab X2D ${n} nozzle`,
86
87
  };
87
88
 
88
89
  const FILAMENT_PROFILE_DIR =
@@ -98,6 +99,7 @@ const FILAMENT_MODEL_CODES: Record<string, string> = {
98
99
  h2d: "H2D",
99
100
  h2s: "H2S",
100
101
  h2c: "H2C",
102
+ x2d: "X2D",
101
103
  };
102
104
 
103
105
  type FilamentProfileIndex = {
@@ -516,6 +518,19 @@ function extractPrinterDiagnostics(status: any): Record<string, unknown> {
516
518
  };
517
519
  }
518
520
 
521
+ /** Parse "#RRGGBB;#RRGGBB" (leading '#' optional) into positional slot colours. */
522
+ function parseFilamentColours(value: string): string[] {
523
+ const colours = value.split(";").map((c) => c.trim()).filter(Boolean)
524
+ .map((c) => (c.startsWith("#") ? c : `#${c}`).toUpperCase());
525
+ for (const c of colours) {
526
+ if (!/^#[0-9A-F]{6}([0-9A-F]{2})?$/.test(c)) {
527
+ throw new Error(`Invalid filament colour "${c}"; expected #RRGGBB.`);
528
+ }
529
+ }
530
+ if (colours.length === 0) throw new Error("filament_colours must list at least one colour.");
531
+ return colours;
532
+ }
533
+
519
534
  function validateBambuModel(model: string): string {
520
535
  const normalized = model.trim().toLowerCase();
521
536
  if (!VALID_BAMBU_MODELS.includes(normalized as BambuModel)) {
@@ -1108,6 +1123,7 @@ class BambuPrinterMCPServer {
1108
1123
  { const: "h2d", title: "H2D" },
1109
1124
  { const: "h2s", title: "H2S" },
1110
1125
  { const: "h2c", title: "H2C" },
1126
+ { const: "x2d", title: "X2D" },
1111
1127
  ],
1112
1128
  },
1113
1129
  },
@@ -2045,6 +2061,7 @@ class BambuPrinterMCPServer {
2045
2061
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
2046
2062
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs." },
2047
2063
  load_filament_ids: { type: "string", description: "Optional filament-to-object mapping string." },
2064
+ filament_colours: { type: "string", description: "Optional slot colours, one #RRGGBB per filament slot in order, separated by ';' (e.g. '#161616;#FFFFFF'). Defaults to the input 3MF's project colours, else the BambuStudio default." },
2048
2065
  ensure_on_bed: { type: "boolean", description: "Lift floating models onto the bed." },
2049
2066
  arrange: { type: "boolean", description: "Auto-arrange objects on the build plate." },
2050
2067
  orient: { type: "boolean", description: "Auto-orient model for optimal printability." },
@@ -2099,6 +2116,7 @@ class BambuPrinterMCPServer {
2099
2116
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs, e.g. 'pla_basic.json;petg_cf.json'." },
2100
2117
  filament_profile: { type: "string", description: "Compatibility alias for load_filaments. Semicolon-separated Orca/Bambu filament profile JSON paths." },
2101
2118
  load_filament_ids: { type: "string", description: "Map filaments to objects/parts. Comma-separated IDs matching load_filaments order, e.g. '1,2,3,1' assigns filament 1 to objects 0 and 3." },
2119
+ filament_colours: { type: "string", description: "Optional slot colours, one #RRGGBB per filament slot in order, separated by ';' (e.g. '#161616;#FFFFFF'). Defaults to the input 3MF's project colours, else the BambuStudio default." },
2102
2120
  enable_timelapse: { type: "boolean", description: "Insert timelapse parking moves into gcode. The toolhead parks at a fixed position each layer for camera capture. Adds ~10% print time." },
2103
2121
  allow_mix_temp: { type: "boolean", description: "Allow filaments with different temperature requirements on the same plate. Required for multi-material prints mixing e.g. PLA and PETG." },
2104
2122
  scale: { type: "number", description: "Uniform scale factor applied to all axes. 1.0 = original size, 2.0 = double, 0.5 = half. Applied before slicing." },
@@ -2603,7 +2621,7 @@ class BambuPrinterMCPServer {
2603
2621
  bambu_model: {
2604
2622
  type: "string",
2605
2623
  enum: [...VALID_BAMBU_MODELS],
2606
- description: "REQUIRED: Bambu Lab printer model. H2D, H2S, and H2C are the primary intended paths."
2624
+ description: "REQUIRED: Bambu Lab printer model. H2D, H2S, and H2C are the primary intended paths. X2D direct printing is not supported."
2607
2625
  },
2608
2626
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2609
2627
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
@@ -2895,28 +2913,31 @@ class BambuPrinterMCPServer {
2895
2913
  break;
2896
2914
  }
2897
2915
 
2898
- case "upload_file":
2916
+ case "upload_file": {
2899
2917
  if (!args?.file_path || !args?.filename) {
2900
2918
  throw new Error("Missing required parameters: file_path and filename");
2901
2919
  }
2902
- if (Boolean(args.print ?? false)) {
2903
- await this.resolveBambuModel(args?.bambu_model as string | undefined);
2904
- }
2920
+ const print = Boolean(args.print ?? false);
2921
+ const uploadModel = print ? await this.resolveBambuModel(args?.bambu_model as string | undefined) : undefined;
2922
+ if (print) assertDirectPrintSupported(uploadModel, bambuSerial);
2905
2923
  result = await this.bambu.uploadFile(
2906
2924
  host, bambuSerial, bambuToken,
2907
2925
  String(args.file_path), String(args.filename),
2908
- Boolean(args.print ?? false)
2926
+ print, uploadModel
2909
2927
  );
2910
2928
  break;
2929
+ }
2911
2930
 
2912
2931
  case "start_print":
2913
- case "start_print_job":
2932
+ case "start_print_job": {
2914
2933
  if (!args?.filename) {
2915
2934
  throw new Error("Missing required parameter: filename");
2916
2935
  }
2917
- await this.resolveBambuModel(args?.bambu_model as string | undefined);
2918
- result = await this.bambu.startJob(host, bambuSerial, bambuToken, String(args.filename));
2936
+ const startModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
2937
+ assertDirectPrintSupported(startModel, bambuSerial);
2938
+ result = await this.bambu.startJob(host, bambuSerial, bambuToken, String(args.filename), startModel);
2919
2939
  break;
2940
+ }
2920
2941
 
2921
2942
  case "cancel_print":
2922
2943
  result = await this.bambu.cancelJob(host, bambuSerial, bambuToken);
@@ -3118,6 +3139,7 @@ class BambuPrinterMCPServer {
3118
3139
  sliceBambuOptions.loadFilaments = String(args.filament_profile);
3119
3140
  }
3120
3141
  if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
3142
+ if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3121
3143
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3122
3144
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3123
3145
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
@@ -3203,6 +3225,7 @@ class BambuPrinterMCPServer {
3203
3225
  sliceBambuOptions.loadFilaments = String(args.filament_profile);
3204
3226
  }
3205
3227
  if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
3228
+ if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3206
3229
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3207
3230
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3208
3231
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
@@ -3265,6 +3288,7 @@ class BambuPrinterMCPServer {
3265
3288
  "print_3mf"
3266
3289
  );
3267
3290
  const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
3291
+ assertDirectPrintSupported(printModel, bambuSerial);
3268
3292
  const printBedType = resolveBedType(args?.bed_type as string | undefined);
3269
3293
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
3270
3294
  const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(
@@ -3450,6 +3474,7 @@ class BambuPrinterMCPServer {
3450
3474
  "print_collar_charm"
3451
3475
  );
3452
3476
  const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
3477
+ assertDirectPrintSupported(printModel, bambuSerial);
3453
3478
  const printBedType = resolveBedType(args?.bed_type as string | undefined);
3454
3479
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
3455
3480
  const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(