bambu-printer-mcp 1.0.8 → 1.1.4

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/src/index.ts CHANGED
@@ -24,38 +24,480 @@ import {
24
24
  type SlicerType,
25
25
  } from "./stl/stl-manipulator.js";
26
26
  import { BambuNetworkBridge, type BambuNetworkBridgeOptions } from "./bambu-network-bridge.js";
27
- import { parse3MF } from './3mf_parser.js';
27
+ import { hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
28
+ import { analyze3MFAmsRequirements, analyze3MFPlateObjects, analyzeCollarCharm3MF, extractBambuTemplateSettings, getCollarCharmRolePolicy, parse3MF } from './3mf_parser.js';
29
+ import type { ThreeMFAmsRequirements } from "./types.js";
28
30
  import { BambuImplementation } from "./printers/bambu.js";
29
31
 
30
32
  dotenv.config();
31
33
 
32
- const DEFAULT_HOST = process.env.PRINTER_HOST || "localhost";
33
- const DEFAULT_BAMBU_SERIAL = process.env.BAMBU_SERIAL || "";
34
- const DEFAULT_BAMBU_TOKEN = process.env.BAMBU_TOKEN || "";
34
+ const DEFAULT_HOST = process.env.BAMBU_PRINTER_HOST || process.env.PRINTER_HOST || "localhost";
35
+ const DEFAULT_BAMBU_SERIAL = process.env.BAMBU_PRINTER_SERIAL || process.env.BAMBU_SERIAL || "";
36
+ const DEFAULT_BAMBU_TOKEN =
37
+ process.env.BAMBU_PRINTER_ACCESS_TOKEN || process.env.BAMBU_TOKEN || "";
35
38
  const DEFAULT_BAMBU_DEV_ID = process.env.BAMBU_DEV_ID || DEFAULT_BAMBU_SERIAL;
36
39
  const TEMP_DIR = process.env.TEMP_DIR || path.join(process.cwd(), "temp");
37
40
 
38
41
  // Printer model and bed type
39
- const DEFAULT_BAMBU_MODEL = process.env.BAMBU_MODEL?.trim().toLowerCase() || "";
42
+ const DEFAULT_BAMBU_MODEL =
43
+ process.env.BAMBU_PRINTER_MODEL?.trim().toLowerCase() ||
44
+ process.env.BAMBU_MODEL?.trim().toLowerCase() ||
45
+ "";
40
46
  const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
41
47
  const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
42
48
 
43
- const VALID_BAMBU_MODELS = ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"] as const;
49
+ const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c"] as const;
44
50
  type BambuModel = typeof VALID_BAMBU_MODELS[number];
51
+ const H2_BAMBU_MODELS = new Set<string>(["h2d", "h2s", "h2c"]);
45
52
 
46
- const VALID_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"] as const;
53
+ const VALID_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"] as const;
54
+ const VALID_BAMBUSTUDIO_CLI_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"] as const;
47
55
 
48
56
  // Map model IDs to BambuStudio --load-machine preset names
49
57
  const BAMBU_MODEL_PRESETS: Record<string, (nozzle: string) => string> = {
50
58
  p1s: (n) => `Bambu Lab P1S ${n} nozzle`,
51
59
  p1p: (n) => `Bambu Lab P1P ${n} nozzle`,
60
+ p2s: (n) => `Bambu Lab P2S ${n} nozzle`,
52
61
  x1c: (n) => `Bambu Lab X1 Carbon ${n} nozzle`,
53
62
  x1e: (n) => `Bambu Lab X1E ${n} nozzle`,
54
63
  a1: (n) => `Bambu Lab A1 ${n} nozzle`,
55
64
  a1mini: (n) => `Bambu Lab A1 mini ${n} nozzle`,
56
65
  h2d: (n) => `Bambu Lab H2D ${n} nozzle`,
66
+ h2s: (n) => `Bambu Lab H2S ${n} nozzle`,
67
+ h2c: (n) => `Bambu Lab H2C ${n} nozzle`,
57
68
  };
58
69
 
70
+ const FILAMENT_PROFILE_DIR =
71
+ "/Applications/BambuStudio.app/Contents/Resources/profiles/BBL/filament";
72
+ const FILAMENT_MODEL_CODES: Record<string, string> = {
73
+ p1s: "P1S",
74
+ p1p: "P1P",
75
+ p2s: "P2S",
76
+ x1c: "X1C",
77
+ x1e: "X1E",
78
+ a1: "A1",
79
+ a1mini: "A1M",
80
+ h2d: "H2D",
81
+ h2s: "H2S",
82
+ h2c: "H2C",
83
+ };
84
+
85
+ type FilamentProfileIndex = {
86
+ byName: Map<string, string>;
87
+ baseNameByFilamentId: Map<string, string>;
88
+ };
89
+
90
+ type PrinterFilamentInventory = {
91
+ current_slot: number | null;
92
+ current_source: "ams" | "external" | null;
93
+ summary: {
94
+ loaded_slots: number;
95
+ resolved_profile_slots: number;
96
+ unresolved_loaded_slots: number;
97
+ empty_slots: number;
98
+ current_slot: number | null;
99
+ current_source: "ams" | "external" | null;
100
+ recommended_slot: number | null;
101
+ recommended_reason: string | null;
102
+ };
103
+ trays: Array<{
104
+ ams_id: number | null;
105
+ tray_id: number | null;
106
+ slot: number | null;
107
+ state: number;
108
+ loaded: boolean;
109
+ tray_info_idx: string | null;
110
+ tray_type: string | null;
111
+ tray_sub_brands: string | null;
112
+ tray_color: string | null;
113
+ remain_percent: number | null;
114
+ nozzle_temp_min: number | null;
115
+ nozzle_temp_max: number | null;
116
+ resolved_base_profile_name: string | null;
117
+ resolved_profile_path: string | null;
118
+ profile_resolution: "exact-model-nozzle" | "model" | "generic" | "unresolved";
119
+ match_confidence: "high" | "medium" | "low" | "none";
120
+ display_name: string;
121
+ profile_candidates: string[];
122
+ }>;
123
+ recommended: {
124
+ slot: number | null;
125
+ tray_info_idx: string | null;
126
+ tray_type: string | null;
127
+ resolved_profile_path: string | null;
128
+ load_filaments: string | null;
129
+ reason: string;
130
+ } | null;
131
+ load_filaments_all: string | null;
132
+ };
133
+
134
+ const COLLAR_CHARM_POLICY = getCollarCharmRolePolicy();
135
+
136
+ let filamentProfileIndexCache: FilamentProfileIndex | null = null;
137
+
138
+ function buildFilamentProfileIndex(): FilamentProfileIndex {
139
+ const byName = new Map<string, string>();
140
+ const baseNameByFilamentId = new Map<string, string>();
141
+
142
+ if (!fs.existsSync(FILAMENT_PROFILE_DIR)) {
143
+ return { byName, baseNameByFilamentId };
144
+ }
145
+
146
+ for (const entry of fs.readdirSync(FILAMENT_PROFILE_DIR)) {
147
+ if (!entry.endsWith(".json")) continue;
148
+
149
+ const filePath = path.join(FILAMENT_PROFILE_DIR, entry);
150
+
151
+ try {
152
+ const raw = fs.readFileSync(filePath, "utf8");
153
+ const parsed = JSON.parse(raw);
154
+ const name = typeof parsed?.name === "string" ? parsed.name.trim() : "";
155
+ const filamentId = typeof parsed?.filament_id === "string" ? parsed.filament_id.trim() : "";
156
+
157
+ if (name) {
158
+ byName.set(name, filePath);
159
+ }
160
+ if (name && filamentId) {
161
+ baseNameByFilamentId.set(filamentId, name);
162
+ }
163
+ } catch {
164
+ // Ignore malformed or non-profile JSON files.
165
+ }
166
+ }
167
+
168
+ return { byName, baseNameByFilamentId };
169
+ }
170
+
171
+ function getFilamentProfileIndex(): FilamentProfileIndex {
172
+ if (!filamentProfileIndexCache) {
173
+ filamentProfileIndexCache = buildFilamentProfileIndex();
174
+ }
175
+
176
+ return filamentProfileIndexCache;
177
+ }
178
+
179
+ function resolveFilamentProfileCandidates(
180
+ trayInfoIdx: string,
181
+ bambuModel?: string,
182
+ nozzleDiameter?: string
183
+ ): {
184
+ baseName: string | null;
185
+ paths: string[];
186
+ resolution: "exact-model-nozzle" | "model" | "generic" | "unresolved";
187
+ } {
188
+ const index = getFilamentProfileIndex();
189
+ const baseName = index.baseNameByFilamentId.get(trayInfoIdx) || null;
190
+ if (!baseName) {
191
+ return { baseName: null, paths: [], resolution: "unresolved" };
192
+ }
193
+
194
+ const bareName = baseName.replace(/\s*@base$/, "");
195
+ const modelCode = bambuModel ? FILAMENT_MODEL_CODES[bambuModel] : undefined;
196
+ const candidateNames: string[] = [];
197
+
198
+ if (modelCode && nozzleDiameter) {
199
+ candidateNames.push(`${bareName} @BBL ${modelCode} ${nozzleDiameter} nozzle`);
200
+ }
201
+ if (modelCode) {
202
+ candidateNames.push(`${bareName} @BBL ${modelCode}`);
203
+ }
204
+ candidateNames.push(bareName, baseName);
205
+
206
+ const resolvedPaths: string[] = [];
207
+ let resolution: "exact-model-nozzle" | "model" | "generic" | "unresolved" = "unresolved";
208
+
209
+ for (const [candidateIndex, candidateName] of candidateNames.entries()) {
210
+ const candidatePath = index.byName.get(candidateName);
211
+ if (!candidatePath || resolvedPaths.includes(candidatePath)) continue;
212
+ resolvedPaths.push(candidatePath);
213
+ if (resolution === "unresolved") {
214
+ if (candidateIndex === 0 && modelCode && nozzleDiameter) {
215
+ resolution = "exact-model-nozzle";
216
+ } else if (
217
+ (candidateIndex === 0 && modelCode && !nozzleDiameter) ||
218
+ (candidateIndex === 1 && modelCode && nozzleDiameter)
219
+ ) {
220
+ resolution = "model";
221
+ } else {
222
+ resolution = "generic";
223
+ }
224
+ }
225
+ }
226
+
227
+ return { baseName, paths: resolvedPaths, resolution };
228
+ }
229
+
230
+ function parseIntegerOrNull(value: unknown): number | null {
231
+ if (value === undefined || value === null || value === "") return null;
232
+ const parsed = Number.parseInt(String(value), 10);
233
+ return Number.isFinite(parsed) ? parsed : null;
234
+ }
235
+
236
+ function normalizePrinterFilamentInventory(
237
+ status: any,
238
+ bambuModel?: string,
239
+ nozzleDiameter?: string
240
+ ): PrinterFilamentInventory {
241
+ const rawAms = status?.raw?.ams || status?.ams || {};
242
+ const trayNow = parseIntegerOrNull(rawAms?.tray_now);
243
+ const trays: PrinterFilamentInventory["trays"] = [];
244
+
245
+ const amsArray = Array.isArray(rawAms?.ams) ? rawAms.ams : [];
246
+ for (const [amsIndex, amsUnit] of amsArray.entries()) {
247
+ const trayArray = Array.isArray(amsUnit?.tray) ? amsUnit.tray : [];
248
+ for (const [trayIndex, tray] of trayArray.entries()) {
249
+ const amsId = parseIntegerOrNull(amsUnit?.id) ?? amsIndex;
250
+ const trayId = parseIntegerOrNull(tray?.id) ?? trayIndex;
251
+ const slot = amsId !== null && trayId !== null ? amsId * 4 + trayId : null;
252
+ const state = parseIntegerOrNull(tray?.state) ?? 0;
253
+ const trayInfoIdx = typeof tray?.tray_info_idx === "string" ? tray.tray_info_idx : null;
254
+ const profileResolution =
255
+ trayInfoIdx
256
+ ? resolveFilamentProfileCandidates(trayInfoIdx, bambuModel, nozzleDiameter)
257
+ : { baseName: null, paths: [], resolution: "unresolved" as const };
258
+ const resolvedProfilePath = profileResolution.paths[0] || null;
259
+ const matchConfidence =
260
+ profileResolution.resolution === "exact-model-nozzle" ? "high" :
261
+ profileResolution.resolution === "model" ? "medium" :
262
+ profileResolution.resolution === "generic" ? "low" :
263
+ "none";
264
+ const trayType = typeof tray?.tray_type === "string" ? tray.tray_type : null;
265
+ const traySubBrands = typeof tray?.tray_sub_brands === "string" ? tray.tray_sub_brands : null;
266
+ const trayColor = typeof tray?.tray_color === "string" ? tray.tray_color : null;
267
+ const displayParts = [
268
+ traySubBrands && traySubBrands !== trayType ? traySubBrands : null,
269
+ trayType,
270
+ trayColor ? `#${normalizeRgbColor(trayColor) ?? trayColor.replace(/^#/, "").slice(0, 6)}` : null,
271
+ ].filter(Boolean);
272
+
273
+ trays.push({
274
+ ams_id: amsId,
275
+ tray_id: trayId,
276
+ slot,
277
+ state,
278
+ loaded: state !== 0 && Boolean(trayInfoIdx),
279
+ tray_info_idx: trayInfoIdx,
280
+ tray_type: trayType,
281
+ tray_sub_brands: traySubBrands,
282
+ tray_color: trayColor,
283
+ remain_percent:
284
+ typeof tray?.remain === "number" && tray.remain >= 0 ? tray.remain : null,
285
+ nozzle_temp_min: parseIntegerOrNull(tray?.nozzle_temp_min),
286
+ nozzle_temp_max: parseIntegerOrNull(tray?.nozzle_temp_max),
287
+ resolved_base_profile_name: profileResolution.baseName,
288
+ resolved_profile_path: resolvedProfilePath,
289
+ profile_resolution: profileResolution.resolution,
290
+ match_confidence: matchConfidence,
291
+ display_name: displayParts.length > 0 ? displayParts.join(" ") : "empty/unknown",
292
+ profile_candidates: profileResolution.paths,
293
+ });
294
+ }
295
+ }
296
+
297
+ const loadedTrays = trays.filter((tray) => tray.loaded);
298
+ const recommendedTray =
299
+ loadedTrays.find((tray) => tray.slot === trayNow && tray.resolved_profile_path) ||
300
+ loadedTrays.find((tray) => tray.resolved_profile_path) ||
301
+ null;
302
+ const recommendedReason =
303
+ recommendedTray?.slot === trayNow
304
+ ? "current AMS slot with resolved slicer profile"
305
+ : recommendedTray
306
+ ? "first loaded AMS slot with resolved slicer profile"
307
+ : null;
308
+
309
+ const allProfilePaths = Array.from(
310
+ new Set(
311
+ loadedTrays
312
+ .map((tray) => tray.resolved_profile_path)
313
+ .filter((candidate): candidate is string => Boolean(candidate))
314
+ )
315
+ );
316
+ const emptySlots = trays.filter((tray) => !tray.loaded).length;
317
+ const resolvedProfileSlots = loadedTrays.filter((tray) => tray.resolved_profile_path).length;
318
+
319
+ return {
320
+ current_slot: trayNow !== null && trayNow >= 0 && trayNow < 254 ? trayNow : null,
321
+ current_source:
322
+ trayNow === 254 ? "external" : trayNow !== null && trayNow >= 0 && trayNow < 254 ? "ams" : null,
323
+ summary: {
324
+ loaded_slots: loadedTrays.length,
325
+ resolved_profile_slots: resolvedProfileSlots,
326
+ unresolved_loaded_slots: loadedTrays.length - resolvedProfileSlots,
327
+ empty_slots: emptySlots,
328
+ current_slot: trayNow !== null && trayNow >= 0 && trayNow < 254 ? trayNow : null,
329
+ current_source:
330
+ trayNow === 254 ? "external" : trayNow !== null && trayNow >= 0 && trayNow < 254 ? "ams" : null,
331
+ recommended_slot: recommendedTray?.slot ?? null,
332
+ recommended_reason: recommendedReason,
333
+ },
334
+ trays,
335
+ recommended: recommendedTray
336
+ ? {
337
+ slot: recommendedTray.slot,
338
+ tray_info_idx: recommendedTray.tray_info_idx,
339
+ tray_type: recommendedTray.tray_type,
340
+ resolved_profile_path: recommendedTray.resolved_profile_path,
341
+ load_filaments: recommendedTray.resolved_profile_path,
342
+ reason: recommendedReason ?? "resolved slicer profile",
343
+ }
344
+ : null,
345
+ load_filaments_all: allProfilePaths.length > 0 ? allProfilePaths.join(";") : null,
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Normalize a hex color string to lowercase 6-char RGB (drop leading "#"
351
+ * and any trailing alpha bytes). Returns null if the input doesn't look
352
+ * like a hex color.
353
+ *
354
+ * Bambu mixes formats: 3MF project_settings stores `#FFFFFF` or
355
+ * `#FF911A80` (RGB or RGBA with leading #). AMS push_status reports
356
+ * `FFFFFFFF` (RGBA without #). Comparing alpha is risky -- the same
357
+ * filament can show up as `#000000` in one place and `000000FF` in
358
+ * another. RGB-only is the right key.
359
+ */
360
+ function normalizeRgbColor(color: string | null | undefined): string | null {
361
+ if (!color) return null;
362
+ const hex = color.replace(/^#/, "").toLowerCase();
363
+ if (!/^[0-9a-f]{6}([0-9a-f]{2})?$/.test(hex)) return null;
364
+ return hex.slice(0, 6);
365
+ }
366
+
367
+ function resolveAmsSlotsFromRequirements(
368
+ requirements: ThreeMFAmsRequirements,
369
+ inventory: PrinterFilamentInventory
370
+ ): {
371
+ ams_slots: number[];
372
+ matches: Array<{ filament_position: number; tray_info_idx: string; slot: number; matched_color?: string | null; match_strategy: "color" | "sku-only" }>;
373
+ missing: Array<{ filament_position: number; tray_info_idx: string | null; required_color?: string | null; reason: "no_sku" | "no_loaded_match" | "color_mismatch" | "exhausted" }>;
374
+ } {
375
+ const matches: Array<{ filament_position: number; tray_info_idx: string; slot: number; matched_color?: string | null; match_strategy: "color" | "sku-only" }> = [];
376
+ const missing: Array<{ filament_position: number; tray_info_idx: string | null; required_color?: string | null; reason: "no_sku" | "no_loaded_match" | "color_mismatch" | "exhausted" }> = [];
377
+ const amsSlots: number[] = [];
378
+
379
+ // Track which AMS slots have already been claimed by an earlier
380
+ // requirement so two filaments with the same SKU don't collapse onto
381
+ // the same physical slot.
382
+ const claimedSlots = new Set<number>();
383
+
384
+ for (const filament of requirements.filaments) {
385
+ if (!filament.tray_info_idx) {
386
+ missing.push({
387
+ filament_position: filament.filamentPosition,
388
+ tray_info_idx: null,
389
+ required_color: filament.color ?? null,
390
+ reason: "no_sku",
391
+ });
392
+ continue;
393
+ }
394
+
395
+ const requiredColor = normalizeRgbColor(filament.color);
396
+
397
+ // Pool of trays loaded with the required SKU that aren't already claimed.
398
+ const skuPool = inventory.trays.filter(
399
+ (candidate) =>
400
+ candidate.loaded &&
401
+ candidate.slot !== null &&
402
+ candidate.tray_info_idx === filament.tray_info_idx &&
403
+ !claimedSlots.has(candidate.slot)
404
+ );
405
+
406
+ if (skuPool.length === 0) {
407
+ // No trays of this SKU loaded at all (or all already claimed).
408
+ const reason: "no_loaded_match" | "exhausted" = inventory.trays.some(
409
+ (t) => t.tray_info_idx === filament.tray_info_idx
410
+ )
411
+ ? "exhausted"
412
+ : "no_loaded_match";
413
+ missing.push({
414
+ filament_position: filament.filamentPosition,
415
+ tray_info_idx: filament.tray_info_idx,
416
+ required_color: filament.color ?? null,
417
+ reason,
418
+ });
419
+ continue;
420
+ }
421
+
422
+ // Prefer an exact RGB color match. This is what disambiguates
423
+ // same-SKU different-color cases (e.g. two GFG02 PETG HF trays
424
+ // loaded -- one black, one white).
425
+ let chosen = skuPool.find(
426
+ (candidate) => normalizeRgbColor(candidate.tray_color) === requiredColor && requiredColor !== null
427
+ );
428
+ let strategy: "color" | "sku-only" = "color";
429
+
430
+ // Fall back to SKU-only when the requirement has no color (legacy
431
+ // 3MF) or when only one tray of this SKU is loaded (no ambiguity).
432
+ if (!chosen) {
433
+ if (!requiredColor || skuPool.length === 1) {
434
+ chosen = skuPool[0];
435
+ strategy = "sku-only";
436
+ } else {
437
+ // Color was specified, multiple SKU candidates loaded, none
438
+ // match the requested color. Surface clearly rather than pick
439
+ // a wrong color silently.
440
+ missing.push({
441
+ filament_position: filament.filamentPosition,
442
+ tray_info_idx: filament.tray_info_idx,
443
+ required_color: filament.color ?? null,
444
+ reason: "color_mismatch",
445
+ });
446
+ continue;
447
+ }
448
+ }
449
+
450
+ if (chosen.slot === null) {
451
+ // Defensive: shouldn't happen because of the filter above, but
452
+ // keep the type contract honest.
453
+ missing.push({
454
+ filament_position: filament.filamentPosition,
455
+ tray_info_idx: filament.tray_info_idx,
456
+ required_color: filament.color ?? null,
457
+ reason: "no_loaded_match",
458
+ });
459
+ continue;
460
+ }
461
+
462
+ claimedSlots.add(chosen.slot);
463
+ amsSlots.push(chosen.slot);
464
+ matches.push({
465
+ filament_position: filament.filamentPosition,
466
+ tray_info_idx: filament.tray_info_idx,
467
+ slot: chosen.slot,
468
+ matched_color: chosen.tray_color ?? null,
469
+ match_strategy: strategy,
470
+ });
471
+ }
472
+
473
+ return { ams_slots: amsSlots, matches, missing };
474
+ }
475
+
476
+ function extractPrinterDiagnostics(status: any): Record<string, unknown> {
477
+ const raw = status?.raw && typeof status.raw === "object" ? status.raw : {};
478
+ const diagnosticFields: Record<string, unknown> = {};
479
+
480
+ for (const [key, value] of Object.entries(raw)) {
481
+ if (/(hms|error|fail|warn)/i.test(key)) {
482
+ diagnosticFields[key] = value;
483
+ }
484
+ }
485
+
486
+ return {
487
+ connected: Boolean(status?.connected),
488
+ serial: status?.serial ?? null,
489
+ printer_status: status?.status ?? null,
490
+ active_error:
491
+ raw.print_error ??
492
+ raw.print_error_code ??
493
+ raw.mc_print_error_code ??
494
+ raw.fail_reason ??
495
+ null,
496
+ hms: raw.hms ?? raw.hms_info ?? raw.hms_list ?? null,
497
+ diagnostic_fields: diagnosticFields,
498
+ };
499
+ }
500
+
59
501
  function validateBambuModel(model: string): string {
60
502
  const normalized = model.trim().toLowerCase();
61
503
  if (!VALID_BAMBU_MODELS.includes(normalized as BambuModel)) {
@@ -76,9 +518,23 @@ function resolveBedType(argsBedType: string | undefined): string {
76
518
  return bedType;
77
519
  }
78
520
 
521
+ function resolveBambuStudioCliBedType(argsBedType: string | undefined): string {
522
+ const bedType = resolveBedType(argsBedType);
523
+ if (!(VALID_BAMBUSTUDIO_CLI_BED_TYPES as readonly string[]).includes(bedType)) {
524
+ throw new Error(
525
+ `BambuStudio CLI bed_type "${bedType}" is not verified. Use a pre-sliced 3MF for SuperTack, or choose one of: ${VALID_BAMBUSTUDIO_CLI_BED_TYPES.join(", ")}`
526
+ );
527
+ }
528
+ return bedType;
529
+ }
530
+
79
531
  // Slicer configuration (defaults to bambustudio)
80
532
  const DEFAULT_SLICER_TYPE = process.env.SLICER_TYPE || "bambustudio";
81
- const DEFAULT_SLICER_PROFILE = process.env.SLICER_PROFILE || "";
533
+ const DEFAULT_SLICER_PROFILE = process.env.BAMBU_SLICER_PROFILE || process.env.SLICER_PROFILE || "";
534
+ const DEFAULT_TEMPLATE_3MF_PATH = process.env.BAMBU_TEMPLATE_3MF_PATH || "";
535
+ const DEFAULT_TEMPLATE_DIR =
536
+ process.env.BAMBU_TEMPLATE_DIR ||
537
+ path.join(process.env.HOME || process.cwd(), "Sync", "bambu", "templates");
82
538
  const SLICER_SCHEMA_VALUES = [
83
539
  ...SLICER_TYPES,
84
540
  "fulu-orca",
@@ -110,6 +566,9 @@ function defaultSlicerPathFor(slicerType: SlicerType): string {
110
566
  }
111
567
 
112
568
  if (slicerType === "bambustudio") {
569
+ if (process.env.BAMBU_STUDIO_PATH) {
570
+ return process.env.BAMBU_STUDIO_PATH;
571
+ }
113
572
  if (process.platform === "darwin") {
114
573
  return "/Applications/BambuStudio.app/Contents/MacOS/BambuStudio";
115
574
  }
@@ -172,6 +631,57 @@ function parseCsvEnv(value: string | undefined): Set<string> {
172
631
  return new Set(value.split(",").map((e) => e.trim()).filter((e) => e.length > 0));
173
632
  }
174
633
 
634
+ async function resolveSlicerProfilePath(
635
+ requestedProfile: string | undefined,
636
+ templatePath: string | undefined,
637
+ tempDir: string
638
+ ): Promise<string | undefined> {
639
+ if (requestedProfile) {
640
+ return requestedProfile;
641
+ }
642
+
643
+ if (templatePath) {
644
+ return resolveTemplateSlicerProfilePath(templatePath, tempDir);
645
+ }
646
+
647
+ return undefined;
648
+ }
649
+
650
+ async function resolveTemplateSlicerProfilePath(
651
+ templatePath: string,
652
+ tempDir: string
653
+ ): Promise<string> {
654
+ const extension = path.extname(templatePath).toLowerCase();
655
+ if (extension === ".3mf") {
656
+ return extractBambuTemplateSettings(templatePath, tempDir);
657
+ }
658
+ if (extension === ".json" || extension === ".config") {
659
+ return templatePath;
660
+ }
661
+ throw new Error(`Template profile must be a .3mf, .json, or .config file: ${templatePath}`);
662
+ }
663
+
664
+ function hasExplicitSlicerProfile(args: any): boolean {
665
+ return typeof args?.slicer_profile === "string" && args.slicer_profile.trim().length > 0;
666
+ }
667
+
668
+ async function resolveTemplateFirstSlicerProfilePath(
669
+ args: any,
670
+ configuredProfile: string | undefined,
671
+ template3mfPath: string | undefined,
672
+ tempDir: string
673
+ ): Promise<string | undefined> {
674
+ if (hasExplicitSlicerProfile(args)) {
675
+ return configuredProfile || undefined;
676
+ }
677
+
678
+ if (template3mfPath) {
679
+ return resolveTemplateSlicerProfilePath(template3mfPath, tempDir);
680
+ }
681
+
682
+ return configuredProfile || undefined;
683
+ }
684
+
175
685
  function readRuntimeConfig(): RuntimeConfig {
176
686
  const rawTransport = process.env.MCP_TRANSPORT?.trim().toLowerCase();
177
687
  const transport =
@@ -340,6 +850,157 @@ function redactPrintParams(params: Record<string, unknown>): Record<string, unkn
340
850
  };
341
851
  }
342
852
 
853
+ function parseLooseSlicerConfig(content: string): Record<string, any> {
854
+ try {
855
+ return JSON.parse(content);
856
+ } catch {
857
+ const config: Record<string, any> = {};
858
+ for (const line of content.split(/\r?\n/)) {
859
+ const trimmed = line.trim();
860
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
861
+ const separatorIndex = trimmed.indexOf("=");
862
+ if (separatorIndex === -1) continue;
863
+ const key = trimmed.slice(0, separatorIndex).trim();
864
+ const value = trimmed.slice(separatorIndex + 1).trim();
865
+ if (key) {
866
+ config[key] = value;
867
+ }
868
+ }
869
+ return config;
870
+ }
871
+ }
872
+
873
+ function summarizeSliceSettings(config: Record<string, any>) {
874
+ return {
875
+ printer_settings_id:
876
+ typeof config.printer_settings_id === "string" ? config.printer_settings_id : null,
877
+ default_print_profile:
878
+ typeof config.default_print_profile === "string" ? config.default_print_profile : null,
879
+ default_filament_profile: Array.isArray(config.default_filament_profile)
880
+ ? config.default_filament_profile
881
+ : typeof config.default_filament_profile === "string"
882
+ ? [config.default_filament_profile]
883
+ : [],
884
+ filament_settings_id: Array.isArray(config.filament_settings_id)
885
+ ? config.filament_settings_id.filter((value) => typeof value === "string" && value.length > 0)
886
+ : [],
887
+ filament_type: Array.isArray(config.filament_type)
888
+ ? config.filament_type.filter((value) => typeof value === "string" && value.length > 0)
889
+ : [],
890
+ inherits: typeof config.inherits === "string" ? config.inherits : null,
891
+ print_settings_id: typeof config.print_settings_id === "string" ? config.print_settings_id : null,
892
+ compatible_printers: Array.isArray(config.compatible_printers)
893
+ ? config.compatible_printers.filter((value) => typeof value === "string" && value.length > 0)
894
+ : [],
895
+ layer_height:
896
+ config.layer_height !== undefined && config.layer_height !== null
897
+ ? Number(config.layer_height)
898
+ : null,
899
+ first_layer_height:
900
+ config.initial_layer_print_height !== undefined && config.initial_layer_print_height !== null
901
+ ? Number(config.initial_layer_print_height)
902
+ : config.first_layer_height !== undefined && config.first_layer_height !== null
903
+ ? Number(config.first_layer_height)
904
+ : null,
905
+ sparse_infill_density:
906
+ config.sparse_infill_density !== undefined && config.sparse_infill_density !== null
907
+ ? String(config.sparse_infill_density)
908
+ : null,
909
+ wall_loops:
910
+ config.wall_loops !== undefined && config.wall_loops !== null
911
+ ? Number(config.wall_loops)
912
+ : null,
913
+ top_shell_layers:
914
+ config.top_shell_layers !== undefined && config.top_shell_layers !== null
915
+ ? Number(config.top_shell_layers)
916
+ : null,
917
+ bottom_shell_layers:
918
+ config.bottom_shell_layers !== undefined && config.bottom_shell_layers !== null
919
+ ? Number(config.bottom_shell_layers)
920
+ : null,
921
+ brim_width:
922
+ config.brim_width !== undefined && config.brim_width !== null
923
+ ? Number(config.brim_width)
924
+ : null,
925
+ support_enabled:
926
+ config.enable_support !== undefined
927
+ ? String(config.enable_support) === "1" || String(config.enable_support).toLowerCase() === "true"
928
+ : config.support_enabled !== undefined
929
+ ? Boolean(config.support_enabled)
930
+ : null,
931
+ support_type: typeof config.support_type === "string" ? config.support_type : null,
932
+ bed_type:
933
+ typeof config.curr_bed_type === "string"
934
+ ? config.curr_bed_type
935
+ : typeof config.bed_type === "string"
936
+ ? config.bed_type
937
+ : null,
938
+ nozzle_temperature: Array.isArray(config.nozzle_temperature)
939
+ ? config.nozzle_temperature
940
+ : config.nozzle_temperature !== undefined
941
+ ? [config.nozzle_temperature]
942
+ : [],
943
+ };
944
+ }
945
+
946
+ type TemplateEntry = {
947
+ name: string;
948
+ path: string;
949
+ source_type: "3mf" | "json" | "config";
950
+ relative_path: string;
951
+ };
952
+ type TemplateSourceType = TemplateEntry["source_type"];
953
+
954
+ function sanitizeTemplateName(templateName: string): string {
955
+ return templateName
956
+ .trim()
957
+ .replace(/\\/g, "/")
958
+ .replace(/^\/+|\/+$/g, "")
959
+ .replace(/\s+/g, "_")
960
+ .replace(/[^A-Za-z0-9/_-]/g, "_");
961
+ }
962
+
963
+ function scanTemplateRegistry(templateDir: string): TemplateEntry[] {
964
+ if (!fs.existsSync(templateDir)) {
965
+ return [];
966
+ }
967
+
968
+ const allowedExtensions = new Set([".3mf", ".json", ".config"]);
969
+ const entries: TemplateEntry[] = [];
970
+ const stack = [templateDir];
971
+
972
+ while (stack.length > 0) {
973
+ const currentDir = stack.pop()!;
974
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
975
+ const fullPath = path.join(currentDir, entry.name);
976
+ if (entry.isDirectory()) {
977
+ stack.push(fullPath);
978
+ continue;
979
+ }
980
+
981
+ const extension = path.extname(entry.name).toLowerCase();
982
+ if (!allowedExtensions.has(extension)) {
983
+ continue;
984
+ }
985
+
986
+ const relativePath = path.relative(templateDir, fullPath);
987
+ const templateName = relativePath
988
+ .replace(/\\/g, "/")
989
+ .replace(/(\.gcode)?\.3mf$/i, "")
990
+ .replace(/\.(json|config)$/i, "");
991
+
992
+ entries.push({
993
+ name: templateName,
994
+ path: fullPath,
995
+ source_type: extension === ".3mf" ? "3mf" : extension === ".json" ? "json" : "config",
996
+ relative_path: relativePath,
997
+ });
998
+ }
999
+ }
1000
+
1001
+ return entries.sort((a, b) => a.name.localeCompare(b.name));
1002
+ }
1003
+
343
1004
  if (!fs.existsSync(TEMP_DIR)) {
344
1005
  fs.mkdirSync(TEMP_DIR, { recursive: true });
345
1006
  }
@@ -396,7 +1057,7 @@ class BambuPrinterMCPServer {
396
1057
  return validateBambuModel(fromArgs);
397
1058
  }
398
1059
 
399
- // No model from args or env - ask the user via elicitation
1060
+ // No model from args or env — ask the user via elicitation
400
1061
  try {
401
1062
  const result = await this.server.elicitInput({
402
1063
  mode: "form" as const,
@@ -413,11 +1074,14 @@ class BambuPrinterMCPServer {
413
1074
  oneOf: [
414
1075
  { const: "p1s", title: "P1S" },
415
1076
  { const: "p1p", title: "P1P" },
1077
+ { const: "p2s", title: "P2S" },
416
1078
  { const: "x1c", title: "X1 Carbon" },
417
1079
  { const: "x1e", title: "X1E" },
418
1080
  { const: "a1", title: "A1" },
419
1081
  { const: "a1mini", title: "A1 Mini" },
420
1082
  { const: "h2d", title: "H2D" },
1083
+ { const: "h2s", title: "H2S" },
1084
+ { const: "h2c", title: "H2C" },
421
1085
  ],
422
1086
  },
423
1087
  },
@@ -433,7 +1097,7 @@ class BambuPrinterMCPServer {
433
1097
  "Printer model selection was cancelled. Cannot proceed without knowing the printer model."
434
1098
  );
435
1099
  } catch (elicitError: any) {
436
- // Elicitation not supported by this client - fall back to a clear error
1100
+ // Elicitation not supported by this client — fall back to a clear error
437
1101
  const msg = elicitError?.message || String(elicitError);
438
1102
  if (
439
1103
  elicitError?.code === -32601 || elicitError?.code === -32600 ||
@@ -462,7 +1126,8 @@ class BambuPrinterMCPServer {
462
1126
  private async ensurePrintableThreeMFPath(
463
1127
  args: Record<string, any>,
464
1128
  printModel: string,
465
- printPreset: string | undefined
1129
+ printPreset: string | undefined,
1130
+ bedType?: string
466
1131
  ): Promise<{ threeMFPath: string; autoSliced: boolean }> {
467
1132
  const { slicerType, slicerPath, slicerProfile } = resolveSlicerConfig(args);
468
1133
  let threeMFPath = String(args.three_mf_path);
@@ -478,12 +1143,19 @@ class BambuPrinterMCPServer {
478
1143
  return { threeMFPath, autoSliced: false };
479
1144
  }
480
1145
 
1146
+ if (bedType === "supertack_plate") {
1147
+ throw new Error(
1148
+ 'BambuStudio CLI SuperTack bed type is not verified; use a pre-sliced 3MF for SuperTack or choose textured_plate, cool_plate, engineering_plate, or hot_plate.'
1149
+ );
1150
+ }
1151
+
481
1152
  console.log(`3MF has no gcode - auto-slicing with ${slicerType} for ${printModel}`);
482
1153
  const autoSliceOptions: BambuSliceOptions = {
483
1154
  uptodate: true,
484
1155
  ensureOnBed: true,
485
1156
  minSave: true,
486
1157
  skipModifiedGcodes: true,
1158
+ bedType: bedType ? resolveBambuStudioCliBedType(bedType) : undefined,
487
1159
  };
488
1160
  threeMFPath = await this.stlManipulator.sliceSTL(
489
1161
  threeMFPath, slicerType, slicerPath, slicerProfile || undefined,
@@ -497,47 +1169,85 @@ class BambuPrinterMCPServer {
497
1169
 
498
1170
  private async resolveAmsPrintSettings(
499
1171
  threeMFPath: string,
500
- args: Record<string, any>
501
- ): Promise<{ useAMS: boolean; finalAmsMapping?: number[] }> {
1172
+ args: Record<string, any>,
1173
+ host: string,
1174
+ bambuSerial: string,
1175
+ bambuToken: string,
1176
+ printModel?: string,
1177
+ printNozzle?: string
1178
+ ): Promise<{ useAMS: boolean; finalAmsMapping?: number[]; finalAmsSlots?: number[] }> {
502
1179
  const parsed3MFData = await parse3MF(threeMFPath);
503
1180
  let parsedAmsMapping: number[] | undefined;
504
1181
 
505
1182
  if (parsed3MFData.slicerConfig?.ams_mapping) {
506
- const slots = Object.values(parsed3MFData.slicerConfig.ams_mapping)
507
- .filter(v => typeof v === 'number') as number[];
1183
+ const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>);
508
1184
  if (slots.length > 0) {
509
- parsedAmsMapping = slots.sort((a, b) => a - b);
1185
+ parsedAmsMapping = slots;
510
1186
  }
511
1187
  }
512
1188
 
513
1189
  let finalAmsMapping = parsedAmsMapping;
514
- const explicitUseAMS = args?.use_ams !== undefined;
515
- let useAMS = explicitUseAMS ? Boolean(args.use_ams) : (!!finalAmsMapping && finalAmsMapping.length > 0);
1190
+ let finalAmsSlots: number[] | undefined;
1191
+ let useAMS = args?.use_ams !== undefined ? Boolean(args.use_ams) : (!!finalAmsMapping && finalAmsMapping.length > 0);
1192
+ const hasUserAmsMapping = hasAmsMappingInput(args?.ams_mapping);
1193
+ const hasUserAmsSlots = Array.isArray(args?.ams_slots);
516
1194
 
517
- if (args?.ams_mapping) {
1195
+ if (hasUserAmsMapping) {
518
1196
  let userMappingOverride: number[] | undefined;
519
1197
  if (Array.isArray(args.ams_mapping)) {
520
- userMappingOverride = args.ams_mapping.filter((v: any) => typeof v === 'number');
521
- } else if (typeof args.ams_mapping === 'object') {
522
- userMappingOverride = Object.values(args.ams_mapping)
523
- .filter((v: any) => typeof v === 'number')
524
- .sort((a: any, b: any) => a - b) as number[];
1198
+ userMappingOverride = args.ams_mapping.map((v: unknown, i: number) =>
1199
+ normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`)
1200
+ );
1201
+ } else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
1202
+ userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>);
525
1203
  }
526
1204
  if (userMappingOverride && userMappingOverride.length > 0) {
527
1205
  finalAmsMapping = userMappingOverride;
1206
+ finalAmsSlots = undefined;
528
1207
  useAMS = true;
529
1208
  }
530
1209
  }
531
1210
 
1211
+ if (!hasUserAmsMapping && hasUserAmsSlots) {
1212
+ const userSlots = args.ams_slots.map((slot: unknown, i: number) =>
1213
+ normalizeBridgeAmsTrayValue(slot, `ams_slots[${i}]`)
1214
+ );
1215
+ finalAmsSlots = userSlots;
1216
+ finalAmsMapping = undefined;
1217
+ useAMS = userSlots.length > 0;
1218
+ }
1219
+
1220
+ if (!hasUserAmsMapping && !hasUserAmsSlots && args?.auto_match_ams) {
1221
+ const requirements = await analyze3MFAmsRequirements(
1222
+ threeMFPath,
1223
+ args?.plate_index !== undefined ? Number(args.plate_index) : 0
1224
+ );
1225
+ const inventory = await this.getResolvedPrinterFilamentInventory(
1226
+ host,
1227
+ bambuSerial,
1228
+ bambuToken,
1229
+ printModel,
1230
+ printNozzle
1231
+ );
1232
+ const resolved = resolveAmsSlotsFromRequirements(requirements, inventory);
1233
+ if (resolved.missing.length > 0) {
1234
+ throw new Error(`Could not auto-match AMS slots: ${JSON.stringify(resolved.missing)}`);
1235
+ }
1236
+ finalAmsSlots = resolved.ams_slots;
1237
+ finalAmsMapping = undefined;
1238
+ useAMS = true;
1239
+ }
1240
+
532
1241
  if (args?.use_ams === false) {
533
1242
  finalAmsMapping = undefined;
1243
+ finalAmsSlots = undefined;
534
1244
  useAMS = false;
535
1245
  }
536
- if ((!finalAmsMapping || finalAmsMapping.length === 0) && !explicitUseAMS) {
1246
+ if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
537
1247
  useAMS = false;
538
1248
  }
539
1249
 
540
- return { useAMS, finalAmsMapping };
1250
+ return { useAMS, finalAmsMapping, finalAmsSlots };
541
1251
  }
542
1252
 
543
1253
  private async print3mfViaBambuNetwork(
@@ -587,14 +1297,27 @@ class BambuPrinterMCPServer {
587
1297
  throw new Error("bambu_token/access code is required for FULU BambuNetwork LAN/local print methods.");
588
1298
  }
589
1299
 
590
- const { threeMFPath, autoSliced } = await this.ensurePrintableThreeMFPath(args, printModel, printPreset);
591
- const { useAMS, finalAmsMapping } = await this.resolveAmsPrintSettings(threeMFPath, args);
1300
+ const { threeMFPath, autoSliced } = await this.ensurePrintableThreeMFPath(
1301
+ args,
1302
+ printModel,
1303
+ printPreset,
1304
+ printBedType
1305
+ );
1306
+ const { useAMS, finalAmsMapping, finalAmsSlots } = await this.resolveAmsPrintSettings(
1307
+ threeMFPath,
1308
+ args,
1309
+ host,
1310
+ bambuSerial,
1311
+ bambuToken,
1312
+ printModel,
1313
+ printNozzle
1314
+ );
592
1315
  const threeMfFilename = path.basename(threeMFPath);
593
1316
  const projectName = String(args?.project_name || threeMfFilename.replace(/\.3mf$/i, ''));
594
1317
  const presetName = String(args?.preset_name || `${projectName}_plate_${plateIndex + 1}`);
595
1318
  const clientJobId = args?.client_job_id !== undefined ? Number(args.client_job_id) : Date.now();
596
1319
 
597
- const amsMapping = stringifyBridgeJson(args?.ams_mapping_bridge ?? finalAmsMapping);
1320
+ const amsMapping = stringifyBridgeJson(args?.ams_mapping_bridge ?? finalAmsMapping ?? finalAmsSlots);
598
1321
  const params: Record<string, unknown> = {
599
1322
  dev_id: devId,
600
1323
  task_name: String(args?.task_name || projectName),
@@ -670,11 +1393,259 @@ class BambuPrinterMCPServer {
670
1393
  plateIndex,
671
1394
  bridgePlateIndex: plateIndex + 1,
672
1395
  useAMS,
673
- amsMapping: finalAmsMapping,
1396
+ amsMapping: finalAmsMapping ?? finalAmsSlots,
674
1397
  params: redactPrintParams(params),
675
1398
  };
676
1399
  }
677
1400
 
1401
+ private async getResolvedPrinterFilamentInventory(
1402
+ host: string,
1403
+ bambuSerial: string,
1404
+ bambuToken: string,
1405
+ bambuModel?: string,
1406
+ nozzleDiameter?: string
1407
+ ): Promise<PrinterFilamentInventory> {
1408
+ const status = await this.bambu.getStatus(host, bambuSerial, bambuToken);
1409
+ let inventory = normalizePrinterFilamentInventory(status, bambuModel, nozzleDiameter);
1410
+ // The first MQTT push from an idle printer is sparse (model/modules
1411
+ // only). AMS data arrives on a second push a short while later. If
1412
+ // we have a live connection but no loaded trays, wait for the follow-
1413
+ // up push and retry.
1414
+ if (inventory.trays.length === 0 && status.connected) {
1415
+ await new Promise((r) => setTimeout(r, 1500));
1416
+ const retryStatus = await this.bambu.getStatus(host, bambuSerial, bambuToken);
1417
+ inventory = normalizePrinterFilamentInventory(retryStatus, bambuModel, nozzleDiameter);
1418
+ }
1419
+ return inventory;
1420
+ }
1421
+
1422
+ private async inspectSliceSettings(sourcePath: string) {
1423
+ if (!fs.existsSync(sourcePath)) {
1424
+ throw new Error(`Slice settings source not found: ${sourcePath}`);
1425
+ }
1426
+
1427
+ const extension = path.extname(sourcePath).toLowerCase();
1428
+ const tempDir = path.join(TEMP_DIR, "slice-settings");
1429
+ fs.mkdirSync(tempDir, { recursive: true });
1430
+
1431
+ if (extension === ".3mf") {
1432
+ const parsed = await parse3MF(sourcePath);
1433
+ const extractedSettingsPath = await extractBambuTemplateSettings(sourcePath, tempDir);
1434
+ const config = (parsed.slicerConfig || {}) as Record<string, any>;
1435
+ return {
1436
+ source_path: sourcePath,
1437
+ source_type: "3mf",
1438
+ extracted_settings_path: extractedSettingsPath,
1439
+ object_count: parsed.objects.length,
1440
+ build_item_count: parsed.build.items.length,
1441
+ metadata_keys: Object.keys(parsed.metadata),
1442
+ summary: summarizeSliceSettings(config),
1443
+ raw_key_count: Object.keys(config).length,
1444
+ };
1445
+ }
1446
+
1447
+ const content = fs.readFileSync(sourcePath, "utf8");
1448
+ const config = parseLooseSlicerConfig(content);
1449
+ return {
1450
+ source_path: sourcePath,
1451
+ source_type: extension === ".json" ? "json" : extension === ".config" ? "config" : "text",
1452
+ extracted_settings_path: sourcePath,
1453
+ summary: summarizeSliceSettings(config),
1454
+ raw_key_count: Object.keys(config).length,
1455
+ };
1456
+ }
1457
+
1458
+ private async resolveCollarCharmPrepared3MF(
1459
+ sourcePath: string,
1460
+ template3mfPath: string | undefined,
1461
+ slicerType: SlicerType,
1462
+ slicerPath: string,
1463
+ slicerProfile: string | undefined,
1464
+ printModel: string,
1465
+ printNozzle: string,
1466
+ bedType: string,
1467
+ host: string,
1468
+ bambuSerial: string,
1469
+ bambuToken: string
1470
+ ): Promise<string> {
1471
+ if (!sourcePath.toLowerCase().endsWith(".3mf")) {
1472
+ throw new Error("print_collar_charm requires a prepared .3mf project or sliced 3MF.");
1473
+ }
1474
+
1475
+ try {
1476
+ const JSZip = (await import('jszip')).default;
1477
+ const zipData = fs.readFileSync(sourcePath);
1478
+ const zip = await JSZip.loadAsync(zipData);
1479
+ const hasGcode = Object.keys(zip.files).some(
1480
+ (fileName) => fileName.match(/Metadata\/plate_\d+\.gcode/i) || fileName.endsWith('.gcode')
1481
+ );
1482
+ if (hasGcode) {
1483
+ return sourcePath;
1484
+ }
1485
+ } catch (error: any) {
1486
+ throw new Error(`Failed to inspect collar charm 3MF before slicing: ${error.message}`);
1487
+ }
1488
+
1489
+ const printPreset = BAMBU_MODEL_PRESETS[printModel]?.(printNozzle);
1490
+ if (bedType === "supertack_plate") {
1491
+ throw new Error(
1492
+ 'BambuStudio CLI SuperTack bed type is not verified; use a pre-sliced 3MF for SuperTack or choose textured_plate, cool_plate, engineering_plate, or hot_plate.'
1493
+ );
1494
+ }
1495
+ const autoSliceOptions: BambuSliceOptions = {
1496
+ uptodate: true,
1497
+ ensureOnBed: true,
1498
+ minSave: true,
1499
+ skipModifiedGcodes: true,
1500
+ bedType,
1501
+ };
1502
+
1503
+ try {
1504
+ const liveFilaments = await this.getResolvedPrinterFilamentInventory(
1505
+ host,
1506
+ bambuSerial,
1507
+ bambuToken,
1508
+ printModel,
1509
+ printNozzle
1510
+ );
1511
+ if (liveFilaments.recommended?.load_filaments) {
1512
+ autoSliceOptions.loadFilaments = liveFilaments.recommended.load_filaments;
1513
+ }
1514
+ } catch (filamentError) {
1515
+ console.warn("Could not resolve live printer filaments for collar charm auto-slicing:", filamentError);
1516
+ }
1517
+
1518
+ return this.stlManipulator.sliceSTL(
1519
+ sourcePath,
1520
+ slicerType,
1521
+ slicerPath,
1522
+ slicerProfile || template3mfPath || undefined,
1523
+ undefined,
1524
+ printPreset,
1525
+ autoSliceOptions
1526
+ );
1527
+ }
1528
+
1529
+ private async preflightCollarCharmPolicy(
1530
+ host: string,
1531
+ bambuSerial: string,
1532
+ bambuToken: string,
1533
+ bambuModel: string,
1534
+ nozzleDiameter: string
1535
+ ): Promise<PrinterFilamentInventory> {
1536
+ const inventory = await this.getResolvedPrinterFilamentInventory(
1537
+ host,
1538
+ bambuSerial,
1539
+ bambuToken,
1540
+ bambuModel,
1541
+ nozzleDiameter
1542
+ );
1543
+
1544
+ const requiredSlots = [COLLAR_CHARM_POLICY.amsSlots.inner, COLLAR_CHARM_POLICY.amsSlots.outer];
1545
+ for (const slot of requiredSlots) {
1546
+ const tray = inventory.trays.find((candidate) => candidate.slot === slot);
1547
+ if (!tray) {
1548
+ throw new Error(`Collar charm wrapper requires AMS tray ${slot}, but that tray is not reported by the printer.`);
1549
+ }
1550
+ if (!tray.loaded) {
1551
+ throw new Error(`Collar charm wrapper requires AMS tray ${slot} to be loaded, but it is currently empty or unavailable.`);
1552
+ }
1553
+ }
1554
+
1555
+ return inventory;
1556
+ }
1557
+
1558
+ private listTemplateRegistry(templateDir?: string): {
1559
+ template_dir: string;
1560
+ templates: TemplateEntry[];
1561
+ } {
1562
+ const resolvedTemplateDir = templateDir && templateDir.trim().length > 0
1563
+ ? templateDir
1564
+ : DEFAULT_TEMPLATE_DIR;
1565
+ return {
1566
+ template_dir: resolvedTemplateDir,
1567
+ templates: scanTemplateRegistry(resolvedTemplateDir),
1568
+ };
1569
+ }
1570
+
1571
+ private resolveTemplatePath(
1572
+ templateName?: string,
1573
+ templateDir?: string,
1574
+ sourceTypes?: TemplateSourceType[]
1575
+ ): string | undefined {
1576
+ if (!templateName || templateName.trim().length === 0) {
1577
+ return undefined;
1578
+ }
1579
+
1580
+ const registry = this.listTemplateRegistry(templateDir);
1581
+ const normalizedName = sanitizeTemplateName(templateName).toLowerCase();
1582
+ const requestedName = templateName.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").toLowerCase();
1583
+ const nameMatches = registry.templates.filter((entry) =>
1584
+ entry.name.toLowerCase() === requestedName ||
1585
+ sanitizeTemplateName(entry.name).toLowerCase() === normalizedName
1586
+ );
1587
+ const searchTypes = sourceTypes && sourceTypes.length > 0
1588
+ ? sourceTypes
1589
+ : (["3mf", "json", "config"] as TemplateSourceType[]);
1590
+ const match = searchTypes
1591
+ .map((sourceType) => nameMatches.find((entry) => entry.source_type === sourceType))
1592
+ .find((entry): entry is TemplateEntry => Boolean(entry));
1593
+ if (!match) {
1594
+ const typeHint = sourceTypes && sourceTypes.length > 0
1595
+ ? ` with source type ${sourceTypes.join("/")}`
1596
+ : "";
1597
+ const availableTypes = nameMatches.length > 0
1598
+ ? ` Available source types: ${Array.from(new Set(nameMatches.map((entry) => entry.source_type))).join(", ")}.`
1599
+ : "";
1600
+ throw new Error(
1601
+ `Template "${templateName}"${typeHint} not found in ${registry.template_dir}.${availableTypes}`
1602
+ );
1603
+ }
1604
+
1605
+ return match.path;
1606
+ }
1607
+
1608
+ private saveTemplate(sourcePath: string, templateName?: string, templateDir?: string) {
1609
+ if (!fs.existsSync(sourcePath)) {
1610
+ throw new Error(`Template source not found: ${sourcePath}`);
1611
+ }
1612
+
1613
+ const resolvedTemplateDir = templateDir && templateDir.trim().length > 0
1614
+ ? templateDir
1615
+ : DEFAULT_TEMPLATE_DIR;
1616
+ fs.mkdirSync(resolvedTemplateDir, { recursive: true });
1617
+
1618
+ const sourceBaseName = path.basename(sourcePath);
1619
+ const extension = path.extname(sourceBaseName).toLowerCase();
1620
+ if (![".3mf", ".json", ".config"].includes(extension)) {
1621
+ throw new Error("Templates must be .3mf, .json, or .config files.");
1622
+ }
1623
+
1624
+ const baseName = templateName && templateName.trim().length > 0
1625
+ ? sanitizeTemplateName(templateName)
1626
+ : sanitizeTemplateName(
1627
+ sourceBaseName
1628
+ .replace(/(\.gcode)?\.3mf$/i, "")
1629
+ .replace(/\.(json|config)$/i, "")
1630
+ );
1631
+
1632
+ const destinationPath = path.join(resolvedTemplateDir, `${baseName}${extension}`);
1633
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
1634
+ fs.copyFileSync(sourcePath, destinationPath);
1635
+
1636
+ const sourceType: TemplateSourceType =
1637
+ extension === ".3mf" ? "3mf" : extension === ".json" ? "json" : "config";
1638
+ const registryEntry = this.resolveTemplatePath(baseName, resolvedTemplateDir, [sourceType]);
1639
+ return {
1640
+ saved: true,
1641
+ template_name: baseName,
1642
+ source_path: sourcePath,
1643
+ destination_path: destinationPath,
1644
+ template_dir: resolvedTemplateDir,
1645
+ resolved_path: registryEntry,
1646
+ };
1647
+ }
1648
+
678
1649
  setupResourceHandlers() {
679
1650
  this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
680
1651
  return {
@@ -690,6 +1661,12 @@ class BambuPrinterMCPServer {
690
1661
  name: "Bambu Printer Files",
691
1662
  mimeType: "application/json",
692
1663
  description: "List of files on the Bambu Lab printer"
1664
+ },
1665
+ {
1666
+ uri: `printer://${DEFAULT_HOST}/hms`,
1667
+ name: "Bambu Printer HMS Diagnostics",
1668
+ mimeType: "application/json",
1669
+ description: "HMS and error-related fields from the latest printer status"
693
1670
  }
694
1671
  ],
695
1672
  templates: [
@@ -702,6 +1679,11 @@ class BambuPrinterMCPServer {
702
1679
  uriTemplate: "printer://{host}/files",
703
1680
  name: "Bambu Printer Files",
704
1681
  mimeType: "application/json"
1682
+ },
1683
+ {
1684
+ uriTemplate: "printer://{host}/hms",
1685
+ name: "Bambu Printer HMS Diagnostics",
1686
+ mimeType: "application/json"
705
1687
  }
706
1688
  ]
707
1689
  };
@@ -724,6 +1706,19 @@ class BambuPrinterMCPServer {
724
1706
  content = await this.bambu.getStatus(host || DEFAULT_HOST, bambuSerial, bambuToken);
725
1707
  } else if (resource === "files") {
726
1708
  content = await this.bambu.getFiles(host || DEFAULT_HOST, bambuSerial, bambuToken);
1709
+ } else if (resource === "hms") {
1710
+ const status = await this.bambu.getStatus(host || DEFAULT_HOST, bambuSerial, bambuToken);
1711
+ let diagnostics = extractPrinterDiagnostics(status);
1712
+ // The first MQTT status push may not include HMS data — the printer
1713
+ // sends basic state first, then pushes HMS/error fields on a subsequent
1714
+ // report. If we got a connection but no HMS data, wait briefly and
1715
+ // retry so the incremental merge has time to arrive.
1716
+ if (!diagnostics.hms && status.connected) {
1717
+ await new Promise((r) => setTimeout(r, 1500));
1718
+ const retryStatus = await this.bambu.getStatus(host || DEFAULT_HOST, bambuSerial, bambuToken);
1719
+ diagnostics = extractPrinterDiagnostics(retryStatus);
1720
+ }
1721
+ content = diagnostics;
727
1722
  } else {
728
1723
  throw new McpError(ErrorCode.InvalidRequest, `Unknown resource: ${resource}`);
729
1724
  }
@@ -766,15 +1761,78 @@ class BambuPrinterMCPServer {
766
1761
  }
767
1762
  },
768
1763
  {
769
- name: "extend_stl_base",
770
- description: "Extend the base of an STL file by a specified amount",
1764
+ name: "get_printer_filaments",
1765
+ description: "Get the live AMS/external filament inventory from the printer over MQTT, including loaded/empty slot summary, resolved slicer profile paths, match confidence, and recommended load_filaments when the printer model is known.",
771
1766
  inputSchema: {
772
1767
  type: "object",
773
1768
  properties: {
774
- stl_path: { type: "string", description: "Path to the STL file to modify" },
775
- extension_height: { type: "number", description: "Height in mm to extend the base by" }
776
- },
777
- required: ["stl_path", "extension_height"]
1769
+ host: {
1770
+ type: "string",
1771
+ description: "Hostname or IP address of the printer (default: value from env)"
1772
+ },
1773
+ bambu_serial: {
1774
+ type: "string",
1775
+ description: "Serial number for the Bambu Lab printer (default: value from env)"
1776
+ },
1777
+ bambu_token: {
1778
+ type: "string",
1779
+ description: "Access token for the Bambu Lab printer (default: value from env)"
1780
+ },
1781
+ bambu_model: {
1782
+ type: "string",
1783
+ enum: [...VALID_BAMBU_MODELS],
1784
+ description: "Optional model hint used to resolve Bambu/Orca filament profile JSONs for each tray."
1785
+ },
1786
+ nozzle_diameter: {
1787
+ type: "string",
1788
+ description: "Optional nozzle diameter used when resolving model-specific filament profile JSONs (default: 0.4)."
1789
+ }
1790
+ }
1791
+ }
1792
+ },
1793
+ {
1794
+ name: "resolve_3mf_ams_slots",
1795
+ description: "Inspect a sliced 3MF and match its tray_info_idx filament requirements against the live AMS inventory. Does not upload or start a print.",
1796
+ inputSchema: {
1797
+ type: "object",
1798
+ properties: {
1799
+ three_mf_path: { type: "string", description: "Path to a sliced 3MF/.gcode.3mf file" },
1800
+ plate_index: { type: "number", description: "0-based plate index to inspect (default: 0)" },
1801
+ bambu_model: {
1802
+ type: "string",
1803
+ enum: [...VALID_BAMBU_MODELS],
1804
+ description: "Optional model hint used to resolve Bambu/Orca filament profile JSONs for each tray."
1805
+ },
1806
+ nozzle_diameter: { type: "string", description: "Nozzle diameter in mm (default: 0.4)" },
1807
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
1808
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
1809
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
1810
+ },
1811
+ required: ["three_mf_path"]
1812
+ }
1813
+ },
1814
+ {
1815
+ name: "list_3mf_plate_objects",
1816
+ description: "List object IDs from a sliced 3MF plate. Use these IDs with skip_objects during a running print.",
1817
+ inputSchema: {
1818
+ type: "object",
1819
+ properties: {
1820
+ three_mf_path: { type: "string", description: "Path to a sliced 3MF/.gcode.3mf file" },
1821
+ plate_index: { type: "number", description: "0-based plate index to inspect (default: 0)" }
1822
+ },
1823
+ required: ["three_mf_path"]
1824
+ }
1825
+ },
1826
+ {
1827
+ name: "extend_stl_base",
1828
+ description: "Extend the base of an STL file by a specified amount",
1829
+ inputSchema: {
1830
+ type: "object",
1831
+ properties: {
1832
+ stl_path: { type: "string", description: "Path to the STL file to modify" },
1833
+ extension_height: { type: "number", description: "Height in mm to extend the base by" }
1834
+ },
1835
+ required: ["stl_path", "extension_height"]
778
1836
  }
779
1837
  },
780
1838
  {
@@ -816,6 +1874,110 @@ class BambuPrinterMCPServer {
816
1874
  required: ["stl_path"]
817
1875
  }
818
1876
  },
1877
+ {
1878
+ name: "list_templates",
1879
+ description: "List saved slicing templates from the local template registry directory.",
1880
+ inputSchema: {
1881
+ type: "object",
1882
+ properties: {
1883
+ template_dir: {
1884
+ type: "string",
1885
+ description: "Optional template directory override. Defaults to BAMBU_TEMPLATE_DIR or the server's configured local template registry."
1886
+ }
1887
+ }
1888
+ }
1889
+ },
1890
+ {
1891
+ name: "save_template",
1892
+ description: "Copy a 3MF, JSON, or config file into the local template registry and register it under a template name.",
1893
+ inputSchema: {
1894
+ type: "object",
1895
+ properties: {
1896
+ source_path: {
1897
+ type: "string",
1898
+ description: "Path to a local .3mf, .json, or .config file to save into the template registry."
1899
+ },
1900
+ template_name: {
1901
+ type: "string",
1902
+ description: "Optional template name. Defaults to the source filename without extension."
1903
+ },
1904
+ template_dir: {
1905
+ type: "string",
1906
+ description: "Optional template directory override. Defaults to BAMBU_TEMPLATE_DIR or the server's configured local template registry."
1907
+ }
1908
+ },
1909
+ required: ["source_path"]
1910
+ }
1911
+ },
1912
+ {
1913
+ name: "get_slice_settings",
1914
+ description: "Inspect slicer settings from a saved 3MF template or a JSON/config slicer profile without slicing anything.",
1915
+ inputSchema: {
1916
+ type: "object",
1917
+ properties: {
1918
+ source_path: {
1919
+ type: "string",
1920
+ description: "Path to a 3MF template, extracted project_settings.config, or slicer profile JSON."
1921
+ },
1922
+ template_name: {
1923
+ type: "string",
1924
+ description: "Optional named template from the local registry. If provided, resolves source_path automatically."
1925
+ },
1926
+ template_dir: {
1927
+ type: "string",
1928
+ description: "Optional template directory override when resolving template_name."
1929
+ }
1930
+ }
1931
+ }
1932
+ },
1933
+ {
1934
+ name: "slice_with_template",
1935
+ description: "Slice an STL or 3MF using a named template from the local registry. This is a higher-level wrapper around slice_stl for template-based workflows.",
1936
+ inputSchema: {
1937
+ type: "object",
1938
+ properties: {
1939
+ stl_path: { type: "string", description: "Path to the STL or 3MF file to slice" },
1940
+ template_name: { type: "string", description: "Named template from the local registry." },
1941
+ template_dir: { type: "string", description: "Optional template directory override when resolving template_name." },
1942
+ bambu_model: {
1943
+ type: "string",
1944
+ enum: [...VALID_BAMBU_MODELS],
1945
+ description: "REQUIRED: Bambu Lab printer model. Ask the user if not known. Using the wrong model can damage the printer."
1946
+ },
1947
+ slicer_type: {
1948
+ type: "string",
1949
+ enum: SLICER_SCHEMA_VALUES,
1950
+ description: "Type of slicer to use. Bambu-compatible choices (bambustudio, orcaslicer, orcaslicer-bambulab) export sliced 3MF; aliases such as fulu-orca and orca-studio are accepted."
1951
+ },
1952
+ slicer_path: { type: "string", description: "Path to the slicer executable (default: value from env)" },
1953
+ slicer_profile: { type: "string", description: "Explicit slicer profile/config file. Overrides the named template only when provided in the tool call." },
1954
+ nozzle_diameter: { type: "string", description: "Nozzle diameter in mm (default: 0.4)" },
1955
+ bed_type: {
1956
+ type: "string",
1957
+ enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
1958
+ description: "Bed plate type for slicing (default: textured_plate). SuperTack is accepted only for pre-sliced print jobs until the BambuStudio CLI identifier is verified."
1959
+ },
1960
+ 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." },
1961
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
1962
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
1963
+ bambu_token: { type: "string", description: "Access token (default: value from env)" },
1964
+ load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs." },
1965
+ load_filament_ids: { type: "string", description: "Optional filament-to-object mapping string." },
1966
+ ensure_on_bed: { type: "boolean", description: "Lift floating models onto the bed." },
1967
+ arrange: { type: "boolean", description: "Auto-arrange objects on the build plate." },
1968
+ orient: { type: "boolean", description: "Auto-orient model for optimal printability." },
1969
+ repetitions: { type: "number", description: "Number of copies to print." },
1970
+ scale: { type: "number", description: "Uniform scale factor." },
1971
+ rotate: { type: "number", description: "Z-axis rotation in degrees." },
1972
+ rotate_x: { type: "number", description: "X-axis rotation in degrees." },
1973
+ rotate_y: { type: "number", description: "Y-axis rotation in degrees." },
1974
+ min_save: { type: "boolean", description: "Produce smaller output 3MF." },
1975
+ skip_modified_gcodes: { type: "boolean", description: "Ignore stale custom gcodes in the 3MF." },
1976
+ slice_plate: { type: "number", description: "Which plate index to slice. 0 = all plates." }
1977
+ },
1978
+ required: ["stl_path", "template_name", "bambu_model"]
1979
+ }
1980
+ },
819
1981
  {
820
1982
  name: "slice_stl",
821
1983
  description: "Slice an STL or 3MF file using a slicer to generate printable G-code or sliced 3MF. IMPORTANT: bambu_model must be specified to ensure the slicer generates safe G-code for the correct printer.",
@@ -825,7 +1987,7 @@ class BambuPrinterMCPServer {
825
1987
  stl_path: { type: "string", description: "Path to the STL or 3MF file to slice" },
826
1988
  bambu_model: {
827
1989
  type: "string",
828
- enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
1990
+ enum: [...VALID_BAMBU_MODELS],
829
1991
  description: "REQUIRED: Bambu Lab printer model. Ask the user if not known. Using the wrong model can damage the printer."
830
1992
  },
831
1993
  slicer_type: {
@@ -833,10 +1995,19 @@ class BambuPrinterMCPServer {
833
1995
  enum: SLICER_SCHEMA_VALUES,
834
1996
  description: "Type of slicer to use. Bambu-compatible choices (bambustudio, orcaslicer, orcaslicer-bambulab) export sliced 3MF; aliases such as fulu-orca and orca-studio are accepted."
835
1997
  },
836
- slicer_path: { type: "string", description: "Path to the slicer executable (default: value from env or a platform default)" },
1998
+ slicer_path: { type: "string", description: "Path to the slicer executable (default: value from env)" },
837
1999
  slicer_profile: { type: "string", description: "Path to the slicer profile/config file (optional, overrides bambu_model preset)" },
2000
+ template_3mf_path: { type: "string", description: "Optional template 3MF whose embedded Bambu slicer settings should be reused when slicing a new STL or 3MF." },
2001
+ template_name: { type: "string", description: "Optional named template from the local registry. Resolves to template_3mf_path automatically." },
2002
+ template_dir: { type: "string", description: "Optional template directory override when resolving template_name." },
838
2003
  nozzle_diameter: { type: "string", description: "Nozzle diameter in mm (default: 0.4)" },
839
- uptodate: { type: "boolean", description: "Refresh 3MF preset configs to match the latest Bambu-compatible slicer presets. Use when slicing downloaded or older 3MF files to prevent stale-config failures." },
2004
+ bed_type: {
2005
+ type: "string",
2006
+ enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
2007
+ description: "Bed plate type for slicing (default: textured_plate). SuperTack is accepted only for pre-sliced print jobs until the BambuStudio CLI identifier is verified."
2008
+ },
2009
+ 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." },
2010
+ 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." },
840
2011
  repetitions: { type: "number", description: "Print N identical copies of the model. Each copy gets its own plate placement. Example: 3 prints three copies." },
841
2012
  orient: { type: "boolean", description: "Auto-orient the model for optimal printability (minimize supports, maximize bed adhesion). Recommended for raw STL imports that lack a pre-set orientation." },
842
2013
  arrange: { type: "boolean", description: "Auto-arrange all objects on the build plate with optimal spacing. Recommended when importing STLs or adding multiple objects. Set false to preserve existing plate layout." },
@@ -844,7 +2015,7 @@ class BambuPrinterMCPServer {
844
2015
  clone_objects: { type: "string", description: "Duplicate specific objects on the plate. Comma-separated clone counts per object index, e.g. '1,3,1,10' clones object 0 once, object 1 three times, etc." },
845
2016
  skip_objects: { type: "string", description: "Skip specific objects during slicing by index. Comma-separated, e.g. '3,5,10'. Useful for multi-object 3MFs where you only want to print some parts." },
846
2017
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs, e.g. 'pla_basic.json;petg_cf.json'." },
847
- filament_profile: { type: "string", description: "Compatibility alias for load_filaments. Semicolon-separated Orca/Bambu filament profile JSON paths; flat self-contained profiles are safest because Orca does not resolve every inherited system filament setting from arbitrary file paths." },
2018
+ filament_profile: { type: "string", description: "Compatibility alias for load_filaments. Semicolon-separated Orca/Bambu filament profile JSON paths." },
848
2019
  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." },
849
2020
  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." },
850
2021
  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." },
@@ -913,7 +2084,7 @@ class BambuPrinterMCPServer {
913
2084
  three_mf_path: { type: "string", description: "Path to the 3MF file to print; unsliced 3MFs are auto-sliced before sending." },
914
2085
  bambu_model: {
915
2086
  type: "string",
916
- enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
2087
+ enum: [...VALID_BAMBU_MODELS],
917
2088
  description: "REQUIRED: Bambu Lab printer model. Ask the user if not known. Using the wrong model can damage the printer."
918
2089
  },
919
2090
  connection_type: { type: "string", enum: ["cloud", "lan"], description: "BambuNetwork connection type to put in FULU PrintParams; cloud uses restored internet printing, lan uses local bridge printing." },
@@ -925,7 +2096,7 @@ class BambuPrinterMCPServer {
925
2096
  bambu_token: { type: "string", description: "Printer access code/password for LAN/local bridge methods." },
926
2097
  username: { type: "string", description: "Printer username for LAN/local bridge methods; defaults to bblp." },
927
2098
  password: { type: "string", description: "Printer password/access code override for LAN/local bridge methods." },
928
- bed_type: { type: "string", enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"], description: "Bed plate type currently installed (default: textured_plate)." },
2099
+ bed_type: { type: "string", enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"], description: "Bed plate type currently installed (default: textured_plate)." },
929
2100
  plate_index: { type: "number", description: "Zero-based plate index to print from the sliced 3MF; converted to FULU's one-based PrintParams plate_index." },
930
2101
  project_name: { type: "string", description: "Optional project name sent in FULU PrintParams; defaults to the 3MF filename without extension." },
931
2102
  preset_name: { type: "string", description: "Optional preset name sent in FULU PrintParams; defaults to project plus one-based plate index." },
@@ -942,6 +2113,7 @@ class BambuPrinterMCPServer {
942
2113
  nozzle_diameter: { type: "string", description: "Nozzle diameter in mm for auto-slicing (default: 0.4)." },
943
2114
  use_ams: { type: "boolean", description: "Whether to use the AMS; defaults to auto-detect from the 3MF mapping." },
944
2115
  ams_mapping: { type: "array", description: "AMS slot mapping array used by both local MCP printing and FULU PrintParams.", items: { type: "number" } },
2116
+ ams_slots: { type: "array", description: "Per-used-filament AMS slot list, matching the local LAN print path.", items: { type: "number" } },
945
2117
  ams_mapping_bridge: { type: "string", description: "Raw JSON string override for FULU PrintParams ams_mapping when the automatic array is not enough." },
946
2118
  ams_mapping2: { type: "string", description: "Raw JSON string for FULU PrintParams ams_mapping2, matching OrcaSlicer-bambulab's v1 AMS mapping field." },
947
2119
  ams_mapping_info: { type: "string", description: "Raw JSON string for FULU PrintParams ams_mapping_info, matching OrcaSlicer-bambulab's detailed AMS mapping field." },
@@ -962,6 +2134,44 @@ class BambuPrinterMCPServer {
962
2134
  required: ["three_mf_path", "bambu_model"]
963
2135
  }
964
2136
  },
2137
+ {
2138
+ name: "camera_snapshot",
2139
+ description: "Capture a single JPEG frame from the printer's chamber camera. A1/P1 use TCP-on-6000; X1/P2S/H2 use RTSP via ffmpeg. Returns JPEG as base64; pass save_path to also write the bytes to disk.",
2140
+ inputSchema: {
2141
+ type: "object",
2142
+ properties: {
2143
+ save_path: { type: "string", description: "Optional absolute path to write the JPEG to disk. If omitted, only the base64 payload is returned." },
2144
+ timeout_ms: { type: "number", description: "Max ms to wait for a full frame (default 8000). Camera may take a few seconds on cold start." },
2145
+ bambu_model: { type: "string", description: "Printer model. Used to route to the correct protocol or fail fast on unsupported models. Defaults to BAMBU_MODEL." },
2146
+ experimental: { type: "boolean", description: "Deprecated and ignored. Earlier this flag let callers probe H2 series via the A1/P1 TCP-on-6000 path; live testing on an H2S confirmed H2 uses RTSP instead, so the flag has no effect now." },
2147
+ ffmpeg_path: { type: "string", description: "Override path to the ffmpeg binary used by the RTSP path. Defaults to ffmpeg via $PATH. Required only for the RTSP transport (X1, P2S, H2 series)." },
2148
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2149
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2150
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
2151
+ }
2152
+ }
2153
+ },
2154
+ {
2155
+ name: "delete_printer_file",
2156
+ description: "Delete a file from the Bambu Lab printer's SD card via FTPS. Destructive: requires confirm:true. Restricted to cache/, timelapse/, and logs/ directories. Path traversal segments (..) are rejected.",
2157
+ inputSchema: {
2158
+ type: "object",
2159
+ properties: {
2160
+ filename: {
2161
+ type: "string",
2162
+ description: "File to delete. Bare names default to cache/<name>; pass a relative path like timelapse/foo.mp4 to target other allowed directories."
2163
+ },
2164
+ confirm: {
2165
+ type: "boolean",
2166
+ description: "Must be true to actually delete. When false or omitted the call returns without sending an FTP request."
2167
+ },
2168
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2169
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2170
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
2171
+ },
2172
+ required: ["filename"]
2173
+ }
2174
+ },
965
2175
  {
966
2176
  name: "upload_gcode",
967
2177
  description: "Upload a G-code file to the Bambu Lab printer",
@@ -969,17 +2179,13 @@ class BambuPrinterMCPServer {
969
2179
  type: "object",
970
2180
  properties: {
971
2181
  filename: { type: "string", description: "Name for the file on the printer" },
972
- gcode: { type: "string", description: "G-code content to upload, or a readable local .gcode path. For large files, prefer gcode_path." },
973
- gcode_path: { type: "string", description: "Local path to a .gcode file to upload. This avoids sending large G-code bodies through the MCP request." },
2182
+ gcode: { type: "string", description: "G-code content to upload, or a readable local .gcode path. Required unless gcode_path is provided. For large files, prefer gcode_path." },
2183
+ gcode_path: { type: "string", description: "Local path to a .gcode file to upload. Required unless gcode is provided. This avoids sending large G-code bodies through the MCP request." },
974
2184
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
975
2185
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
976
2186
  bambu_token: { type: "string", description: "Access token (default: value from env)" }
977
2187
  },
978
- required: ["filename"],
979
- anyOf: [
980
- { required: ["gcode"] },
981
- { required: ["gcode_path"] }
982
- ]
2188
+ required: ["filename"]
983
2189
  }
984
2190
  },
985
2191
  {
@@ -993,7 +2199,7 @@ class BambuPrinterMCPServer {
993
2199
  print: { type: "boolean", description: "Start printing after upload (default: false)" },
994
2200
  bambu_model: {
995
2201
  type: "string",
996
- enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
2202
+ enum: [...VALID_BAMBU_MODELS],
997
2203
  description: "Required when print is true. Bambu Lab printer model used as a safety confirmation before starting the uploaded file."
998
2204
  },
999
2205
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
@@ -1012,7 +2218,7 @@ class BambuPrinterMCPServer {
1012
2218
  filename: { type: "string", description: "Name of the file to print" },
1013
2219
  bambu_model: {
1014
2220
  type: "string",
1015
- enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
2221
+ enum: [...VALID_BAMBU_MODELS],
1016
2222
  description: "REQUIRED: Bambu Lab printer model. Ask the user if not known. Starting G-code for the wrong model can damage the printer."
1017
2223
  },
1018
2224
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
@@ -1031,7 +2237,7 @@ class BambuPrinterMCPServer {
1031
2237
  filename: { type: "string", description: "Name of the file to print" },
1032
2238
  bambu_model: {
1033
2239
  type: "string",
1034
- enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
2240
+ enum: [...VALID_BAMBU_MODELS],
1035
2241
  description: "REQUIRED: Bambu Lab printer model. Ask the user if not known. Starting G-code for the wrong model can damage the printer."
1036
2242
  },
1037
2243
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
@@ -1053,6 +2259,93 @@ class BambuPrinterMCPServer {
1053
2259
  }
1054
2260
  }
1055
2261
  },
2262
+ {
2263
+ name: "pause_print",
2264
+ description: "Pause the current print job on the Bambu Lab printer (resumable via resume_print)",
2265
+ inputSchema: {
2266
+ type: "object",
2267
+ properties: {
2268
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2269
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2270
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
2271
+ }
2272
+ }
2273
+ },
2274
+ {
2275
+ name: "resume_print",
2276
+ description: "Resume a paused print job on the Bambu Lab printer",
2277
+ inputSchema: {
2278
+ type: "object",
2279
+ properties: {
2280
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2281
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2282
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
2283
+ }
2284
+ }
2285
+ },
2286
+ {
2287
+ name: "clear_hms_errors",
2288
+ description: "Clear HMS or print error state on the Bambu Lab printer using the clean_print_error MQTT command.",
2289
+ inputSchema: {
2290
+ type: "object",
2291
+ properties: {
2292
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2293
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2294
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
2295
+ }
2296
+ }
2297
+ },
2298
+ {
2299
+ name: "set_print_speed",
2300
+ description: "Set the active print speed mode: silent, standard, sport, or ludicrous.",
2301
+ inputSchema: {
2302
+ type: "object",
2303
+ properties: {
2304
+ mode: {
2305
+ type: "string",
2306
+ enum: ["silent", "standard", "sport", "ludicrous", "1", "2", "3", "4"],
2307
+ description: "Speed mode to apply: silent/1, standard/2, sport/3, or ludicrous/4"
2308
+ },
2309
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2310
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2311
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
2312
+ },
2313
+ required: ["mode"]
2314
+ }
2315
+ },
2316
+ {
2317
+ name: "set_airduct_mode",
2318
+ description: "Set H2/P2 airduct mode to cooling or heating.",
2319
+ inputSchema: {
2320
+ type: "object",
2321
+ properties: {
2322
+ mode: {
2323
+ type: "string",
2324
+ enum: ["cooling", "heating"],
2325
+ description: "Airduct mode to apply"
2326
+ },
2327
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2328
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2329
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
2330
+ },
2331
+ required: ["mode"]
2332
+ }
2333
+ },
2334
+ {
2335
+ name: "reread_ams_rfid",
2336
+ description: "Trigger a Bambu AMS RFID re-read for one AMS slot. This can move AMS filament; use only when the printer is idle and unloaded.",
2337
+ inputSchema: {
2338
+ type: "object",
2339
+ properties: {
2340
+ ams_id: { type: "number", description: "AMS unit index from 0 to 3" },
2341
+ slot_id: { type: "number", description: "Slot index within that AMS, from 0 to 3" },
2342
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2343
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2344
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
2345
+ },
2346
+ required: ["ams_id", "slot_id"]
2347
+ }
2348
+ },
1056
2349
  {
1057
2350
  name: "set_temperature",
1058
2351
  description: "Set the temperature of a printer component (bed, nozzle)",
@@ -1068,6 +2361,86 @@ class BambuPrinterMCPServer {
1068
2361
  required: ["component", "temperature"]
1069
2362
  }
1070
2363
  },
2364
+ {
2365
+ name: "set_fan_speed",
2366
+ description: "Set a Bambu printer fan speed percentage using the printer's MQTT fan command",
2367
+ inputSchema: {
2368
+ type: "object",
2369
+ properties: {
2370
+ fan: {
2371
+ type: "string",
2372
+ description: "Fan to control: part, auxiliary, chamber, 1, 2, or 3"
2373
+ },
2374
+ speed: { type: "number", description: "Fan speed percentage from 0 to 100" },
2375
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2376
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2377
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
2378
+ },
2379
+ required: ["fan", "speed"]
2380
+ }
2381
+ },
2382
+ {
2383
+ name: "set_light",
2384
+ description: "Set a Bambu printer light node mode using the printer's MQTT LED command",
2385
+ inputSchema: {
2386
+ type: "object",
2387
+ properties: {
2388
+ light: {
2389
+ type: "string",
2390
+ description: "Light node to control, for example chamber_light"
2391
+ },
2392
+ mode: {
2393
+ type: "string",
2394
+ enum: ["on", "off", "flashing"],
2395
+ description: "Light mode to apply"
2396
+ },
2397
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2398
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2399
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
2400
+ },
2401
+ required: ["light", "mode"]
2402
+ }
2403
+ },
2404
+ {
2405
+ name: "skip_objects",
2406
+ description: "Skip specific object IDs during a running multi-object print using the printer's MQTT skip_objects command",
2407
+ inputSchema: {
2408
+ type: "object",
2409
+ properties: {
2410
+ object_ids: {
2411
+ type: "array",
2412
+ description: "Object IDs to skip. Use list_3mf_plate_objects on the sliced 3MF to find IDs.",
2413
+ items: { type: "number" }
2414
+ },
2415
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2416
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2417
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
2418
+ },
2419
+ required: ["object_ids"]
2420
+ }
2421
+ },
2422
+ {
2423
+ name: "set_ams_drying",
2424
+ description: "Start or stop the AMS filament drying cycle. Available on AMS units with heating capability (AMS Pro / AMS-HT). Sends an ams_control MQTT command to the printer.",
2425
+ inputSchema: {
2426
+ type: "object",
2427
+ properties: {
2428
+ action: {
2429
+ type: "string",
2430
+ enum: ["start", "stop"],
2431
+ description: "Whether to start or stop the drying cycle"
2432
+ },
2433
+ ams_id: {
2434
+ type: "number",
2435
+ description: "AMS unit index from 0 to 3"
2436
+ },
2437
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2438
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2439
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
2440
+ },
2441
+ required: ["action", "ams_id"]
2442
+ }
2443
+ },
1071
2444
  {
1072
2445
  name: "print_3mf",
1073
2446
  description: "Print a 3MF file on a Bambu Lab printer. Auto-slices if the 3MF has no gcode. IMPORTANT: bambu_model must be specified to ensure safe printer operation.",
@@ -1077,13 +2450,13 @@ class BambuPrinterMCPServer {
1077
2450
  three_mf_path: { type: "string", description: "Path to the 3MF file to print" },
1078
2451
  bambu_model: {
1079
2452
  type: "string",
1080
- enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
2453
+ enum: [...VALID_BAMBU_MODELS],
1081
2454
  description: "REQUIRED: Bambu Lab printer model. Ask the user if not known. Using the wrong model can damage the printer."
1082
2455
  },
1083
2456
  connection_mode: {
1084
2457
  type: "string",
1085
2458
  enum: ["lan_mqtt_ftps", "bambu_network"],
1086
- description: "Print path to use: lan_mqtt_ftps uses this MCP's direct local MQTT/FTPS path; bambu_network uses FULU OrcaSlicer-bambulab's restored BambuNetwork bridge."
2459
+ description: "Print path to use: lan_mqtt_ftps uses this MCP's direct local MQTT/FTPS path; bambu_network uses the restored FULU BambuNetwork bridge."
1087
2460
  },
1088
2461
  connection_type: { type: "string", enum: ["cloud", "lan"], description: "BambuNetwork connection type when connection_mode is bambu_network; cloud uses restored internet printing, lan uses local bridge printing." },
1089
2462
  bambu_network_method: { type: "string", enum: BAMBU_NETWORK_PRINT_METHODS, description: "FULU print method when connection_mode is bambu_network; defaults to start_print for cloud and start_local_print for lan." },
@@ -1095,34 +2468,77 @@ class BambuPrinterMCPServer {
1095
2468
  user_info: { type: "string", description: "Optional BambuNetwork user_info JSON string passed to net.change_user for the FULU bridge." },
1096
2469
  bed_type: {
1097
2470
  type: "string",
1098
- enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"],
2471
+ enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
1099
2472
  description: "Bed plate type currently installed (default: textured_plate)"
1100
2473
  },
1101
- plate_index: { type: "number", description: "Zero-based plate index to print from the sliced 3MF (default: 0)" },
1102
2474
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
1103
2475
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
1104
2476
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
2477
+ plate_index: { type: "number", description: "Zero-based plate index to print from the sliced 3MF (default: 0)" },
1105
2478
  slicer_type: {
1106
2479
  type: "string",
1107
2480
  enum: SLICER_SCHEMA_VALUES,
1108
- description: "Slicer to use only if auto-slicing an unsliced 3MF. Use orcaslicer-bambulab for FULU's OrcaSlicer-bambulab fork; aliases such as fulu-orca and orca-studio are accepted."
2481
+ description: "Slicer to use only if auto-slicing an unsliced 3MF. Bambu-compatible slicer aliases such as fulu-orca and orca-studio are accepted."
1109
2482
  },
1110
2483
  slicer_path: { type: "string", description: "Path to the slicer executable for auto-slicing (default: value from env or a platform default)" },
1111
- slicer_profile: { type: "string", description: "Path to an optional slicer profile/config file for auto-slicing" },
1112
2484
  use_ams: { type: "boolean", description: "Whether to use the AMS (default: auto-detect from 3MF)" },
1113
2485
  ams_mapping: {
1114
2486
  type: "array",
1115
- description: "AMS slot mapping array, e.g. [0, 2] maps filaments to AMS slots 0 and 2",
2487
+ 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.",
1116
2488
  items: { type: "number" }
1117
2489
  },
2490
+ ams_slots: {
2491
+ type: "array",
2492
+ 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.",
2493
+ items: { type: "number" }
2494
+ },
2495
+ auto_match_ams: {
2496
+ type: "boolean",
2497
+ description: "When true, match the sliced 3MF's tray_info_idx requirements against live AMS inventory and use the resulting ams_slots. Ignored when ams_mapping or ams_slots is provided."
2498
+ },
2499
+ bed_leveling: { type: "boolean", description: "Enable auto bed leveling (default: true)" },
2500
+ flow_calibration: { type: "boolean", description: "Enable flow calibration (default: true)" },
2501
+ vibration_calibration: { type: "boolean", description: "Enable vibration calibration (default: true)" },
2502
+ timelapse: { type: "boolean", description: "Enable timelapse recording (default: false)" },
2503
+ slicer_profile: { type: "string", description: "Path to the slicer profile/config file for auto-slicing (optional)." },
2504
+ template_3mf_path: { type: "string", description: "Optional template 3MF whose embedded Bambu slicer settings should be reused when auto-slicing this print job." },
2505
+ template_name: { type: "string", description: "Optional named template from the local registry. Resolves to template_3mf_path automatically." },
2506
+ template_dir: { type: "string", description: "Optional template directory override when resolving template_name." },
2507
+ nozzle_diameter: { type: "string", description: "Nozzle diameter in mm for auto-slicing (default: 0.4)" }
2508
+ },
2509
+ required: ["three_mf_path", "bambu_model"]
2510
+ }
2511
+ },
2512
+ {
2513
+ name: "print_collar_charm",
2514
+ description: "Print a prepared two-part dog collar charm project using the fixed tray policy: inner/smaller object -> black on AMS 1 slot 1, outer/larger object -> white on AMS 2 slot 1.",
2515
+ inputSchema: {
2516
+ type: "object",
2517
+ properties: {
2518
+ source_path: { type: "string", description: "Path to a prepared collar charm .3mf project or sliced 3MF. Required unless template_name is provided." },
2519
+ template_name: { type: "string", description: "Named collar charm template from the local registry. Required unless source_path is provided; resolves source_path automatically." },
2520
+ template_dir: { type: "string", description: "Optional template directory override when resolving template_name." },
2521
+ bambu_model: {
2522
+ type: "string",
2523
+ enum: [...VALID_BAMBU_MODELS],
2524
+ description: "REQUIRED: Bambu Lab printer model. H2D, H2S, and H2C are the primary intended paths."
2525
+ },
2526
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2527
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
2528
+ bambu_token: { type: "string", description: "Access token (default: value from env)" },
2529
+ bed_type: {
2530
+ type: "string",
2531
+ enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"],
2532
+ description: "Bed plate type currently installed (default: textured_plate)"
2533
+ },
1118
2534
  bed_leveling: { type: "boolean", description: "Enable auto bed leveling (default: true)" },
1119
2535
  flow_calibration: { type: "boolean", description: "Enable flow calibration (default: true)" },
1120
2536
  vibration_calibration: { type: "boolean", description: "Enable vibration calibration (default: true)" },
1121
- layer_inspect: { type: "boolean", description: "Enable first-layer inspection where supported (default: printer/profile behavior)" },
1122
2537
  timelapse: { type: "boolean", description: "Enable timelapse recording (default: false)" },
2538
+ slicer_profile: { type: "string", description: "Path to the slicer profile/config file for auto-slicing (optional)." },
1123
2539
  nozzle_diameter: { type: "string", description: "Nozzle diameter in mm for auto-slicing (default: 0.4)" }
1124
2540
  },
1125
- required: ["three_mf_path", "bambu_model"]
2541
+ required: ["bambu_model"]
1126
2542
  }
1127
2543
  },
1128
2544
  {
@@ -1187,6 +2603,16 @@ class BambuPrinterMCPServer {
1187
2603
  const host = String(args?.host || DEFAULT_HOST);
1188
2604
  const bambuSerial = String(args?.bambu_serial || DEFAULT_BAMBU_SERIAL);
1189
2605
  const bambuToken = String(args?.bambu_token || DEFAULT_BAMBU_TOKEN);
2606
+ const requestedTemplateDir =
2607
+ typeof args?.template_dir === "string" && args.template_dir.trim().length > 0
2608
+ ? String(args.template_dir)
2609
+ : undefined;
2610
+ const requestedTemplateName =
2611
+ typeof args?.template_name === "string" ? String(args.template_name) : undefined;
2612
+ const resolveTemplatePathFromName = (sourceTypes?: TemplateSourceType[]) =>
2613
+ this.resolveTemplatePath(requestedTemplateName, requestedTemplateDir, sourceTypes);
2614
+ const explicitTemplatePath = String(args?.template_3mf_path || DEFAULT_TEMPLATE_3MF_PATH);
2615
+
1190
2616
  try {
1191
2617
  let result;
1192
2618
 
@@ -1195,6 +2621,75 @@ class BambuPrinterMCPServer {
1195
2621
  result = await this.bambu.getStatus(host, bambuSerial, bambuToken);
1196
2622
  break;
1197
2623
 
2624
+ case "get_printer_filaments": {
2625
+ const requestedModel = (String(args?.bambu_model ?? DEFAULT_BAMBU_MODEL ?? "")).trim().toLowerCase();
2626
+ const normalizedModel = requestedModel ? validateBambuModel(requestedModel) : undefined;
2627
+ const nozzleDiam = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2628
+ result = await this.getResolvedPrinterFilamentInventory(
2629
+ host,
2630
+ bambuSerial,
2631
+ bambuToken,
2632
+ normalizedModel,
2633
+ nozzleDiam
2634
+ );
2635
+ break;
2636
+ }
2637
+
2638
+ case "resolve_3mf_ams_slots": {
2639
+ if (!args?.three_mf_path) {
2640
+ throw new Error("Missing required parameter: three_mf_path");
2641
+ }
2642
+ const requestedModel = (String(args?.bambu_model ?? DEFAULT_BAMBU_MODEL ?? "")).trim().toLowerCase();
2643
+ const normalizedModel = requestedModel ? validateBambuModel(requestedModel) : undefined;
2644
+ const nozzleDiam = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2645
+ const requirements = await analyze3MFAmsRequirements(
2646
+ String(args.three_mf_path),
2647
+ args?.plate_index !== undefined ? Number(args.plate_index) : 0
2648
+ );
2649
+ const inventory = await this.getResolvedPrinterFilamentInventory(
2650
+ host,
2651
+ bambuSerial,
2652
+ bambuToken,
2653
+ normalizedModel,
2654
+ nozzleDiam
2655
+ );
2656
+ const resolved = resolveAmsSlotsFromRequirements(requirements, inventory);
2657
+ result = {
2658
+ status: resolved.missing.length === 0 ? "matched" : "missing",
2659
+ requirements,
2660
+ ams_slots: resolved.ams_slots,
2661
+ matches: resolved.matches,
2662
+ missing: resolved.missing,
2663
+ };
2664
+ break;
2665
+ }
2666
+
2667
+ case "list_3mf_plate_objects": {
2668
+ if (!args?.three_mf_path) {
2669
+ throw new Error("Missing required parameter: three_mf_path");
2670
+ }
2671
+ result = await analyze3MFPlateObjects(
2672
+ String(args.three_mf_path),
2673
+ args?.plate_index !== undefined ? Number(args.plate_index) : 0
2674
+ );
2675
+ break;
2676
+ }
2677
+
2678
+ case "list_templates":
2679
+ result = this.listTemplateRegistry(requestedTemplateDir);
2680
+ break;
2681
+
2682
+ case "save_template":
2683
+ if (!args?.source_path) {
2684
+ throw new Error("Missing required parameter: source_path");
2685
+ }
2686
+ result = this.saveTemplate(
2687
+ String(args.source_path),
2688
+ typeof args?.template_name === "string" ? String(args.template_name) : undefined,
2689
+ requestedTemplateDir
2690
+ );
2691
+ break;
2692
+
1198
2693
  case "list_printer_files":
1199
2694
  result = await this.bambu.getFiles(host, bambuSerial, bambuToken);
1200
2695
  break;
@@ -1235,6 +2730,31 @@ class BambuPrinterMCPServer {
1235
2730
  break;
1236
2731
  }
1237
2732
 
2733
+ case "camera_snapshot": {
2734
+ const snapshotModel = (args?.bambu_model as string | undefined) ?? process.env.BAMBU_MODEL ?? process.env.BAMBU_PRINTER_MODEL;
2735
+ result = await this.bambu.cameraSnapshot(host, bambuSerial, bambuToken, {
2736
+ savePath: args?.save_path ? String(args.save_path) : undefined,
2737
+ timeoutMs: args?.timeout_ms !== undefined ? Number(args.timeout_ms) : undefined,
2738
+ bambuModel: snapshotModel ? String(snapshotModel) : undefined,
2739
+ experimental: Boolean(args?.experimental),
2740
+ ffmpegPath: args?.ffmpeg_path ? String(args.ffmpeg_path) : undefined,
2741
+ });
2742
+ break;
2743
+ }
2744
+
2745
+ case "delete_printer_file":
2746
+ if (!args?.filename) {
2747
+ throw new Error("Missing required parameter: filename");
2748
+ }
2749
+ result = await this.bambu.deleteFile(
2750
+ host,
2751
+ bambuSerial,
2752
+ bambuToken,
2753
+ String(args.filename),
2754
+ args.confirm
2755
+ );
2756
+ break;
2757
+
1238
2758
  case "upload_gcode": {
1239
2759
  if (!args?.filename) {
1240
2760
  throw new Error("Missing required parameter: filename");
@@ -1279,6 +2799,45 @@ class BambuPrinterMCPServer {
1279
2799
  result = await this.bambu.cancelJob(host, bambuSerial, bambuToken);
1280
2800
  break;
1281
2801
 
2802
+ case "pause_print":
2803
+ result = await this.bambu.pauseJob(host, bambuSerial, bambuToken);
2804
+ break;
2805
+
2806
+ case "resume_print":
2807
+ result = await this.bambu.resumeJob(host, bambuSerial, bambuToken);
2808
+ break;
2809
+
2810
+ case "clear_hms_errors":
2811
+ result = await this.bambu.clearHmsErrors(host, bambuSerial, bambuToken);
2812
+ break;
2813
+
2814
+ case "set_print_speed":
2815
+ if (!args?.mode) {
2816
+ throw new Error("Missing required parameter: mode");
2817
+ }
2818
+ result = await this.bambu.setPrintSpeed(host, bambuSerial, bambuToken, String(args.mode));
2819
+ break;
2820
+
2821
+ case "set_airduct_mode":
2822
+ if (!args?.mode) {
2823
+ throw new Error("Missing required parameter: mode");
2824
+ }
2825
+ result = await this.bambu.setAirductMode(host, bambuSerial, bambuToken, String(args.mode));
2826
+ break;
2827
+
2828
+ case "reread_ams_rfid":
2829
+ if (args?.ams_id === undefined || args?.slot_id === undefined) {
2830
+ throw new Error("Missing required parameters: ams_id and slot_id");
2831
+ }
2832
+ result = await this.bambu.rereadAmsRfid(
2833
+ host,
2834
+ bambuSerial,
2835
+ bambuToken,
2836
+ Number(args.ams_id),
2837
+ Number(args.slot_id)
2838
+ );
2839
+ break;
2840
+
1282
2841
  case "set_temperature":
1283
2842
  if (!args?.component || args?.temperature === undefined) {
1284
2843
  throw new Error("Missing required parameters: component and temperature");
@@ -1289,6 +2848,57 @@ class BambuPrinterMCPServer {
1289
2848
  );
1290
2849
  break;
1291
2850
 
2851
+ case "set_fan_speed":
2852
+ if (!args?.fan || args?.speed === undefined) {
2853
+ throw new Error("Missing required parameters: fan and speed");
2854
+ }
2855
+ result = await this.bambu.setFanSpeed(
2856
+ host,
2857
+ bambuSerial,
2858
+ bambuToken,
2859
+ String(args.fan),
2860
+ Number(args.speed)
2861
+ );
2862
+ break;
2863
+
2864
+ case "set_light":
2865
+ if (!args?.light || !args?.mode) {
2866
+ throw new Error("Missing required parameters: light and mode");
2867
+ }
2868
+ result = await this.bambu.setLight(
2869
+ host,
2870
+ bambuSerial,
2871
+ bambuToken,
2872
+ String(args.light),
2873
+ String(args.mode)
2874
+ );
2875
+ break;
2876
+
2877
+ case "skip_objects":
2878
+ if (!Array.isArray(args?.object_ids)) {
2879
+ throw new Error("Missing required parameter: object_ids");
2880
+ }
2881
+ result = await this.bambu.skipObjects(
2882
+ host,
2883
+ bambuSerial,
2884
+ bambuToken,
2885
+ args.object_ids.map((id: unknown) => Number(id))
2886
+ );
2887
+ break;
2888
+
2889
+ case "set_ams_drying":
2890
+ if (!args?.action || args?.ams_id === undefined) {
2891
+ throw new Error("Missing required parameters: action and ams_id");
2892
+ }
2893
+ result = await this.bambu.setAmsDrying(
2894
+ host,
2895
+ bambuSerial,
2896
+ bambuToken,
2897
+ String(args.action),
2898
+ Number(args.ams_id)
2899
+ );
2900
+ break;
2901
+
1292
2902
  case "extend_stl_base":
1293
2903
  if (!args?.stl_path || args?.extension_height === undefined) {
1294
2904
  throw new Error("Missing required parameters: stl_path and extension_height");
@@ -1333,13 +2943,113 @@ class BambuPrinterMCPServer {
1333
2943
  result = await this.stlManipulator.getSTLInfo(String(args.stl_path));
1334
2944
  break;
1335
2945
 
2946
+ case "get_slice_settings":
2947
+ if (!args?.source_path && !args?.template_name) {
2948
+ throw new Error("Missing required parameter: source_path or template_name");
2949
+ }
2950
+ result = await this.inspectSliceSettings(
2951
+ String(args?.source_path || resolveTemplatePathFromName(["3mf", "json", "config"]))
2952
+ );
2953
+ break;
2954
+
2955
+ case "slice_with_template": {
2956
+ if (!args?.stl_path) {
2957
+ throw new Error("Missing required parameter: stl_path");
2958
+ }
2959
+ if (!args?.template_name) {
2960
+ throw new Error("Missing required parameter: template_name");
2961
+ }
2962
+ const { slicerType, slicerPath, slicerProfile } = resolveSlicerConfig(args as Record<string, unknown> | undefined);
2963
+ const sliceModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
2964
+ const nozzleDiam = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2965
+ const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(
2966
+ args,
2967
+ slicerProfile || undefined,
2968
+ resolveTemplatePathFromName(["json", "config", "3mf"]) || explicitTemplatePath || undefined,
2969
+ TEMP_DIR
2970
+ );
2971
+ const explicitSlicerProfile = hasExplicitSlicerProfile(args);
2972
+ const printerPreset = BAMBU_MODEL_PRESETS[sliceModel]?.(nozzleDiam);
2973
+
2974
+ const sliceBambuOptions: BambuSliceOptions = {};
2975
+ if (args?.uptodate !== undefined) sliceBambuOptions.uptodate = Boolean(args.uptodate);
2976
+ if (args?.repetitions !== undefined) sliceBambuOptions.repetitions = Number(args.repetitions);
2977
+ if (args?.orient !== undefined) sliceBambuOptions.orient = Boolean(args.orient);
2978
+ if (args?.arrange !== undefined) sliceBambuOptions.arrange = Boolean(args.arrange);
2979
+ if (args?.ensure_on_bed !== undefined) sliceBambuOptions.ensureOnBed = Boolean(args.ensure_on_bed);
2980
+ if (args?.clone_objects !== undefined) sliceBambuOptions.cloneObjects = String(args.clone_objects);
2981
+ if (args?.skip_objects !== undefined) sliceBambuOptions.skipObjects = String(args.skip_objects);
2982
+ if (
2983
+ args?.load_filaments !== undefined &&
2984
+ args?.filament_profile !== undefined &&
2985
+ String(args.load_filaments) !== String(args.filament_profile)
2986
+ ) {
2987
+ throw new Error("Provide either load_filaments or filament_profile, not conflicting values.");
2988
+ }
2989
+ if (args?.load_filaments !== undefined) {
2990
+ sliceBambuOptions.loadFilaments = String(args.load_filaments);
2991
+ } else if (args?.filament_profile !== undefined) {
2992
+ sliceBambuOptions.loadFilaments = String(args.filament_profile);
2993
+ }
2994
+ if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
2995
+ sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
2996
+ if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
2997
+ if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
2998
+ if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
2999
+ if (args?.rotate !== undefined) sliceBambuOptions.rotate = Number(args.rotate);
3000
+ if (args?.rotate_x !== undefined) sliceBambuOptions.rotateX = Number(args.rotate_x);
3001
+ if (args?.rotate_y !== undefined) sliceBambuOptions.rotateY = Number(args.rotate_y);
3002
+ if (args?.min_save !== undefined) sliceBambuOptions.minSave = Boolean(args.min_save);
3003
+ if (args?.skip_modified_gcodes !== undefined) sliceBambuOptions.skipModifiedGcodes = Boolean(args.skip_modified_gcodes);
3004
+ if (args?.slice_plate !== undefined) sliceBambuOptions.slicePlate = Number(args.slice_plate);
3005
+ const usePrinterFilaments =
3006
+ args?.use_printer_filaments !== undefined ? Boolean(args.use_printer_filaments) : true;
3007
+ if (
3008
+ usePrinterFilaments &&
3009
+ !explicitSlicerProfile &&
3010
+ !sliceBambuOptions.loadFilaments &&
3011
+ bambuSerial &&
3012
+ bambuToken
3013
+ ) {
3014
+ try {
3015
+ const liveFilaments = await this.getResolvedPrinterFilamentInventory(
3016
+ host,
3017
+ bambuSerial,
3018
+ bambuToken,
3019
+ sliceModel,
3020
+ nozzleDiam
3021
+ );
3022
+ if (liveFilaments.recommended?.load_filaments) {
3023
+ sliceBambuOptions.loadFilaments = liveFilaments.recommended.load_filaments;
3024
+ }
3025
+ } catch (filamentError) {
3026
+ console.warn("Could not resolve live printer filaments for slicing:", filamentError);
3027
+ }
3028
+ }
3029
+
3030
+ result = await this.stlManipulator.sliceSTL(
3031
+ String(args.stl_path), slicerType, slicerPath,
3032
+ activeSlicerProfile,
3033
+ undefined,
3034
+ printerPreset,
3035
+ sliceBambuOptions
3036
+ );
3037
+ break;
3038
+ }
3039
+
1336
3040
  case "slice_stl": {
1337
3041
  if (!args?.stl_path) {
1338
3042
  throw new Error("Missing required parameter: stl_path");
1339
3043
  }
1340
- const { slicerType, slicerPath, slicerProfile } = resolveSlicerConfig(args);
3044
+ const { slicerType, slicerPath, slicerProfile } = resolveSlicerConfig(args as Record<string, unknown> | undefined);
1341
3045
  const sliceModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1342
3046
  const nozzleDiam = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
3047
+ const activeSlicerProfile = await resolveSlicerProfilePath(
3048
+ slicerProfile || undefined,
3049
+ resolveTemplatePathFromName(["json", "config", "3mf"]) || explicitTemplatePath || undefined,
3050
+ TEMP_DIR
3051
+ );
3052
+ const explicitSlicerProfile = hasExplicitSlicerProfile(args);
1343
3053
  // Resolve printer preset for BambuStudio slicer
1344
3054
  const printerPreset = BAMBU_MODEL_PRESETS[sliceModel]?.(nozzleDiam);
1345
3055
 
@@ -1364,6 +3074,7 @@ class BambuPrinterMCPServer {
1364
3074
  sliceBambuOptions.loadFilaments = String(args.filament_profile);
1365
3075
  }
1366
3076
  if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
3077
+ sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
1367
3078
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
1368
3079
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
1369
3080
  if (args?.scale !== undefined) sliceBambuOptions.scale = Number(args.scale);
@@ -1373,10 +3084,34 @@ class BambuPrinterMCPServer {
1373
3084
  if (args?.min_save !== undefined) sliceBambuOptions.minSave = Boolean(args.min_save);
1374
3085
  if (args?.skip_modified_gcodes !== undefined) sliceBambuOptions.skipModifiedGcodes = Boolean(args.skip_modified_gcodes);
1375
3086
  if (args?.slice_plate !== undefined) sliceBambuOptions.slicePlate = Number(args.slice_plate);
3087
+ const usePrinterFilaments =
3088
+ args?.use_printer_filaments !== undefined ? Boolean(args.use_printer_filaments) : true;
3089
+ if (
3090
+ usePrinterFilaments &&
3091
+ !explicitSlicerProfile &&
3092
+ !sliceBambuOptions.loadFilaments &&
3093
+ bambuSerial &&
3094
+ bambuToken
3095
+ ) {
3096
+ try {
3097
+ const liveFilaments = await this.getResolvedPrinterFilamentInventory(
3098
+ host,
3099
+ bambuSerial,
3100
+ bambuToken,
3101
+ sliceModel,
3102
+ nozzleDiam
3103
+ );
3104
+ if (liveFilaments.recommended?.load_filaments) {
3105
+ sliceBambuOptions.loadFilaments = liveFilaments.recommended.load_filaments;
3106
+ }
3107
+ } catch (filamentError) {
3108
+ console.warn("Could not resolve live printer filaments for slicing:", filamentError);
3109
+ }
3110
+ }
1376
3111
 
1377
3112
  result = await this.stlManipulator.sliceSTL(
1378
3113
  String(args.stl_path), slicerType, slicerPath,
1379
- slicerProfile || undefined,
3114
+ activeSlicerProfile,
1380
3115
  undefined, // progressCallback
1381
3116
  printerPreset,
1382
3117
  sliceBambuOptions
@@ -1388,20 +3123,25 @@ class BambuPrinterMCPServer {
1388
3123
  if (!args?.three_mf_path) {
1389
3124
  throw new Error("Missing required parameter: three_mf_path");
1390
3125
  }
1391
-
1392
3126
  if (String(args?.connection_mode || "lan_mqtt_ftps") === "bambu_network") {
1393
3127
  result = await this.print3mfViaBambuNetwork(args as Record<string, any>, host, bambuSerial, bambuToken);
1394
3128
  break;
1395
3129
  }
1396
-
1397
3130
  if (!bambuSerial || !bambuToken) {
1398
3131
  throw new Error("Bambu serial number and access token are required for print_3mf.");
1399
3132
  }
1400
3133
 
1401
- const { slicerType, slicerPath, slicerProfile } = resolveSlicerConfig(args);
3134
+ const { slicerType, slicerPath, slicerProfile } = resolveSlicerConfig(args as Record<string, unknown> | undefined);
1402
3135
  const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1403
3136
  const printBedType = resolveBedType(args?.bed_type as string | undefined);
1404
3137
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
3138
+ const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(
3139
+ args,
3140
+ slicerProfile || undefined,
3141
+ resolveTemplatePathFromName(["json", "config", "3mf"]) || explicitTemplatePath || undefined,
3142
+ TEMP_DIR
3143
+ );
3144
+ const explicitSlicerProfile = hasExplicitSlicerProfile(args);
1405
3145
  const printPreset = BAMBU_MODEL_PRESETS[printModel]?.(printNozzle);
1406
3146
  const plateIndex = args?.plate_index !== undefined ? Number(args.plate_index) : 0;
1407
3147
 
@@ -1420,15 +3160,37 @@ class BambuPrinterMCPServer {
1420
3160
  f => f.match(/Metadata\/plate_\d+\.gcode/i) || f.endsWith('.gcode')
1421
3161
  );
1422
3162
  if (!hasGcode) {
1423
- console.log(`3MF has no gcode - auto-slicing with ${slicerType} for ${printModel}`);
3163
+ if (printBedType === "supertack_plate") {
3164
+ throw new Error(
3165
+ 'BambuStudio CLI SuperTack bed type is not verified; use a pre-sliced 3MF for SuperTack or choose textured_plate, cool_plate, engineering_plate, or hot_plate.'
3166
+ );
3167
+ }
3168
+ console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
1424
3169
  const autoSliceOptions: BambuSliceOptions = {
1425
3170
  uptodate: true,
1426
3171
  ensureOnBed: true,
1427
3172
  minSave: true,
1428
3173
  skipModifiedGcodes: true,
3174
+ bedType: printBedType,
1429
3175
  };
3176
+ if (!explicitSlicerProfile) {
3177
+ try {
3178
+ const liveFilaments = await this.getResolvedPrinterFilamentInventory(
3179
+ host,
3180
+ bambuSerial,
3181
+ bambuToken,
3182
+ printModel,
3183
+ printNozzle
3184
+ );
3185
+ if (liveFilaments.recommended?.load_filaments) {
3186
+ autoSliceOptions.loadFilaments = liveFilaments.recommended.load_filaments;
3187
+ }
3188
+ } catch (filamentError) {
3189
+ console.warn("Could not resolve live printer filaments for auto-slicing:", filamentError);
3190
+ }
3191
+ }
1430
3192
  threeMFPath = await this.stlManipulator.sliceSTL(
1431
- threeMFPath, slicerType, slicerPath, slicerProfile || undefined,
3193
+ threeMFPath, slicerType, slicerPath, activeSlicerProfile,
1432
3194
  undefined, // progressCallback
1433
3195
  printPreset,
1434
3196
  autoSliceOptions
@@ -1436,43 +3198,93 @@ class BambuPrinterMCPServer {
1436
3198
  console.log("Auto-sliced to: " + threeMFPath);
1437
3199
  }
1438
3200
  } catch (sliceCheckErr: any) {
3201
+ if (String(sliceCheckErr?.message || "").includes("SuperTack")) {
3202
+ throw sliceCheckErr;
3203
+ }
1439
3204
  console.warn("Could not check/slice 3MF, proceeding with original:", sliceCheckErr.message);
1440
3205
  }
1441
3206
 
1442
3207
  const parsed3MFData = await parse3MF(threeMFPath);
3208
+ const isH2Print = H2_BAMBU_MODELS.has(printModel);
1443
3209
  let parsedAmsMapping: number[] | undefined;
1444
- if (parsed3MFData.slicerConfig?.ams_mapping) {
1445
- const slots = Object.values(parsed3MFData.slicerConfig.ams_mapping)
1446
- .filter(v => typeof v === 'number') as number[];
3210
+ if (!isH2Print && parsed3MFData.slicerConfig?.ams_mapping) {
3211
+ const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>);
1447
3212
  if (slots.length > 0) {
1448
- parsedAmsMapping = slots.sort((a, b) => a - b);
3213
+ parsedAmsMapping = slots;
1449
3214
  }
1450
3215
  }
1451
3216
 
1452
3217
  let finalAmsMapping = parsedAmsMapping;
1453
- const explicitUseAMS = args?.use_ams !== undefined;
1454
- let useAMS = explicitUseAMS ? Boolean(args.use_ams) : (!!finalAmsMapping && finalAmsMapping.length > 0);
3218
+ let finalAmsSlots: number[] | undefined;
3219
+ let useAMS = args?.use_ams !== undefined ? Boolean(args.use_ams) : (!!finalAmsMapping && finalAmsMapping.length > 0);
3220
+ const hasUserAmsMapping = hasAmsMappingInput(args?.ams_mapping);
3221
+ const hasUserAmsSlots = Array.isArray(args?.ams_slots);
1455
3222
 
1456
- if (args?.ams_mapping) {
3223
+ if (hasUserAmsMapping) {
1457
3224
  let userMappingOverride: number[] | undefined;
1458
3225
  if (Array.isArray(args.ams_mapping)) {
1459
- userMappingOverride = args.ams_mapping.filter((v: any) => typeof v === 'number');
1460
- } else if (typeof args.ams_mapping === 'object') {
1461
- userMappingOverride = Object.values(args.ams_mapping)
1462
- .filter((v: any) => typeof v === 'number')
1463
- .sort((a: any, b: any) => a - b) as number[];
3226
+ userMappingOverride = args.ams_mapping.map((v: unknown, i: number) =>
3227
+ normalizeBridgeAmsTrayValue(v, `ams_mapping[${i}]`)
3228
+ );
3229
+ } else if (args.ams_mapping && typeof args.ams_mapping === 'object') {
3230
+ userMappingOverride = normalizeAmsMappingObject(args.ams_mapping as Record<string, unknown>);
1464
3231
  }
1465
3232
  if (userMappingOverride && userMappingOverride.length > 0) {
1466
3233
  finalAmsMapping = userMappingOverride;
1467
3234
  useAMS = true;
1468
3235
  }
1469
3236
  }
3237
+ if (hasUserAmsSlots) {
3238
+ const userSlots = (args.ams_slots as unknown[]).filter((v: any) => typeof v === 'number');
3239
+ if (userSlots.length > 0) {
3240
+ finalAmsSlots = userSlots;
3241
+ finalAmsMapping = undefined;
3242
+ useAMS = true;
3243
+ }
3244
+ }
3245
+ if (!hasUserAmsMapping && !hasUserAmsSlots && args?.auto_match_ams === true && args?.use_ams !== false) {
3246
+ const requirements = await analyze3MFAmsRequirements(threeMFPath, plateIndex);
3247
+ const inventory = await this.getResolvedPrinterFilamentInventory(
3248
+ host,
3249
+ bambuSerial,
3250
+ bambuToken,
3251
+ printModel,
3252
+ printNozzle
3253
+ );
3254
+ const resolved = resolveAmsSlotsFromRequirements(requirements, inventory);
3255
+ if (resolved.missing.length > 0) {
3256
+ throw new Error(
3257
+ `auto_match_ams could not find loaded AMS trays for: ${resolved.missing
3258
+ .map((missing) => missing.tray_info_idx || `filament position ${missing.filament_position}`)
3259
+ .join(", ")}`
3260
+ );
3261
+ }
3262
+ finalAmsMapping = undefined;
3263
+ finalAmsSlots = resolved.ams_slots;
3264
+ useAMS = finalAmsSlots.length > 0;
3265
+ }
3266
+ if (
3267
+ isH2Print &&
3268
+ !hasUserAmsMapping &&
3269
+ !hasUserAmsSlots &&
3270
+ args?.auto_match_ams !== true &&
3271
+ (!finalAmsMapping || finalAmsMapping.length === 0) &&
3272
+ (!finalAmsSlots || finalAmsSlots.length === 0)
3273
+ ) {
3274
+ const requirements = await analyze3MFAmsRequirements(threeMFPath, plateIndex);
3275
+ if (requirements.usedFilamentPositions.length > 0) {
3276
+ throw new Error(
3277
+ `H2 ${printModel.toUpperCase()} pre-sliced jobs with declared filaments require ams_slots, ams_mapping, or auto_match_ams: true. Plate uses project filament positions ${JSON.stringify(requirements.usedFilamentPositions)}; pass one physical tray per used filament, for example ams_slots: [0] for AMS 0 slot 0 or [1] for AMS 0 slot 1.`
3278
+ );
3279
+ }
3280
+ }
1470
3281
 
1471
3282
  if (args?.use_ams === false) {
1472
3283
  finalAmsMapping = undefined;
3284
+ finalAmsSlots = undefined;
1473
3285
  useAMS = false;
1474
3286
  }
1475
- if ((!finalAmsMapping || finalAmsMapping.length === 0) && !explicitUseAMS) {
3287
+ if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
1476
3288
  useAMS = false;
1477
3289
  }
1478
3290
 
@@ -1482,9 +3294,11 @@ class BambuPrinterMCPServer {
1482
3294
  result = await this.bambu.print3mf(host, bambuSerial, bambuToken, {
1483
3295
  projectName,
1484
3296
  filePath: threeMFPath,
3297
+ bambuModel: printModel,
1485
3298
  plateIndex,
1486
3299
  useAMS: useAMS,
1487
3300
  amsMapping: finalAmsMapping,
3301
+ amsSlots: finalAmsSlots,
1488
3302
  bedType: printBedType,
1489
3303
  bedLeveling: args?.bed_leveling !== undefined ? Boolean(args.bed_leveling) : undefined,
1490
3304
  flowCalibration: args?.flow_calibration !== undefined ? Boolean(args.flow_calibration) : undefined,
@@ -1496,6 +3310,93 @@ class BambuPrinterMCPServer {
1496
3310
  break;
1497
3311
  }
1498
3312
 
3313
+ case "print_collar_charm": {
3314
+ const resolvedTemplateSourcePath = resolveTemplatePathFromName(["3mf"]);
3315
+ const resolvedSourcePath = String(resolvedTemplateSourcePath || args?.source_path || "");
3316
+ if (!resolvedSourcePath) {
3317
+ throw new Error("Missing required parameter: source_path or template_name");
3318
+ }
3319
+ if (!bambuSerial || !bambuToken) {
3320
+ throw new Error("Bambu serial number and access token are required for print_collar_charm.");
3321
+ }
3322
+
3323
+ const { slicerType, slicerPath, slicerProfile } = resolveSlicerConfig(args as Record<string, unknown> | undefined);
3324
+ const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
3325
+ const printBedType = resolveBedType(args?.bed_type as string | undefined);
3326
+ const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
3327
+ const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(
3328
+ args,
3329
+ slicerProfile || undefined,
3330
+ resolvedTemplateSourcePath || explicitTemplatePath || undefined,
3331
+ TEMP_DIR
3332
+ );
3333
+
3334
+ const preparedThreeMFPath = await this.resolveCollarCharmPrepared3MF(
3335
+ resolvedSourcePath,
3336
+ resolvedTemplateSourcePath || explicitTemplatePath || undefined,
3337
+ slicerType,
3338
+ slicerPath,
3339
+ activeSlicerProfile || undefined,
3340
+ printModel,
3341
+ printNozzle,
3342
+ printBedType,
3343
+ host,
3344
+ bambuSerial,
3345
+ bambuToken
3346
+ );
3347
+
3348
+ const collarAnalysis = await analyzeCollarCharm3MF(preparedThreeMFPath, 0);
3349
+ const inventory = await this.preflightCollarCharmPolicy(
3350
+ host,
3351
+ bambuSerial,
3352
+ bambuToken,
3353
+ printModel,
3354
+ printNozzle
3355
+ );
3356
+
3357
+ const projectName = path.basename(preparedThreeMFPath).replace(/\.3mf$/i, '');
3358
+ result = await this.bambu.print3mf(host, bambuSerial, bambuToken, {
3359
+ projectName,
3360
+ filePath: preparedThreeMFPath,
3361
+ bambuModel: printModel,
3362
+ plateIndex: collarAnalysis.plateIndex,
3363
+ useAMS: true,
3364
+ amsSlots: collarAnalysis.amsSlots,
3365
+ bedType: printBedType,
3366
+ bedLeveling: args?.bed_leveling !== undefined ? Boolean(args.bed_leveling) : true,
3367
+ flowCalibration: args?.flow_calibration !== undefined ? Boolean(args.flow_calibration) : true,
3368
+ vibrationCalibration: args?.vibration_calibration !== undefined ? Boolean(args.vibration_calibration) : true,
3369
+ timelapse: args?.timelapse !== undefined ? Boolean(args.timelapse) : false,
3370
+ });
3371
+
3372
+ result = {
3373
+ ...result,
3374
+ source_path: resolvedSourcePath,
3375
+ prepared_three_mf_path: preparedThreeMFPath,
3376
+ tray_policy: {
3377
+ inner: {
3378
+ color: COLLAR_CHARM_POLICY.colors.inner,
3379
+ absolute_tray: COLLAR_CHARM_POLICY.amsSlots.inner,
3380
+ },
3381
+ outer: {
3382
+ color: COLLAR_CHARM_POLICY.colors.outer,
3383
+ absolute_tray: COLLAR_CHARM_POLICY.amsSlots.outer,
3384
+ },
3385
+ },
3386
+ collar_roles: collarAnalysis.roles,
3387
+ inventory_slots_checked: inventory.trays
3388
+ .filter((tray) => tray.slot === COLLAR_CHARM_POLICY.amsSlots.inner || tray.slot === COLLAR_CHARM_POLICY.amsSlots.outer)
3389
+ .map((tray) => ({
3390
+ slot: tray.slot,
3391
+ loaded: tray.loaded,
3392
+ tray_color: tray.tray_color,
3393
+ tray_type: tray.tray_type,
3394
+ tray_info_idx: tray.tray_info_idx,
3395
+ })),
3396
+ };
3397
+ break;
3398
+ }
3399
+
1499
3400
  case "merge_vertices":
1500
3401
  if (!args?.stl_path) throw new Error("Missing required parameter: stl_path");
1501
3402
  result = await this.stlManipulator.mergeVertices(