mcp-3d-printer-server 1.2.9 → 1.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/CONTRIBUTORS.md +49 -0
  3. package/README.md +528 -811
  4. package/dist/3mf_parser.js +8 -8
  5. package/dist/blender-mcp-bridge.d.ts +1 -0
  6. package/dist/blender-mcp-bridge.js +209 -21
  7. package/dist/fulu-orca.js +29 -5
  8. package/dist/index.js +614 -110
  9. package/dist/printers/bambu-node-guard.d.ts +2 -0
  10. package/dist/printers/bambu-node-guard.js +38 -0
  11. package/dist/printers/bambu.d.ts +53 -7
  12. package/dist/printers/bambu.js +420 -126
  13. package/dist/printers/creality.d.ts +15 -6
  14. package/dist/printers/creality.js +42 -9
  15. package/dist/printers/duet.d.ts +11 -6
  16. package/dist/printers/duet.js +57 -34
  17. package/dist/printers/generic-base.d.ts +69 -0
  18. package/dist/printers/generic-base.js +209 -0
  19. package/dist/printers/klipper.d.ts +17 -6
  20. package/dist/printers/klipper.js +63 -16
  21. package/dist/printers/octoprint.d.ts +18 -6
  22. package/dist/printers/octoprint.js +64 -9
  23. package/dist/printers/printer-factory.d.ts +11 -2
  24. package/dist/printers/printer-factory.js +9 -8
  25. package/dist/printers/prusa.d.ts +14 -6
  26. package/dist/printers/prusa.js +56 -9
  27. package/dist/printers/repetier.d.ts +18 -6
  28. package/dist/printers/repetier.js +48 -6
  29. package/dist/safety/archive.d.ts +11 -0
  30. package/dist/safety/archive.js +214 -0
  31. package/dist/safety/artifact.d.ts +6 -0
  32. package/dist/safety/artifact.js +66 -0
  33. package/dist/safety/confirmation.d.ts +22 -0
  34. package/dist/safety/confirmation.js +43 -0
  35. package/dist/safety/expected-peaks.d.ts +24 -0
  36. package/dist/safety/expected-peaks.js +24 -0
  37. package/dist/safety/gcode-thermal.d.ts +64 -0
  38. package/dist/safety/gcode-thermal.js +382 -0
  39. package/dist/safety/limits.d.ts +31 -0
  40. package/dist/safety/limits.js +105 -0
  41. package/dist/safety/print-file.d.ts +25 -0
  42. package/dist/safety/print-file.js +517 -0
  43. package/dist/safety/printer-state.d.ts +51 -0
  44. package/dist/safety/printer-state.js +365 -0
  45. package/dist/slicer/bambu-cli.d.ts +110 -0
  46. package/dist/slicer/bambu-cli.js +547 -0
  47. package/dist/slicer/profile-flatten.d.ts +104 -0
  48. package/dist/slicer/profile-flatten.js +676 -0
  49. package/dist/slicer/run-slicer.d.ts +18 -0
  50. package/dist/slicer/run-slicer.js +102 -0
  51. package/dist/slicer/slicer-error.d.ts +27 -0
  52. package/dist/slicer/slicer-error.js +53 -0
  53. package/dist/slicer/templates.d.ts +126 -0
  54. package/dist/slicer/templates.js +255 -0
  55. package/dist/stl/stl-manipulator.d.ts +25 -3
  56. package/dist/stl/stl-manipulator.js +214 -232
  57. package/package.json +16 -9
  58. package/server.json +29 -0
  59. package/src/3mf_parser.ts +8 -8
  60. package/src/blender-mcp-bridge.ts +200 -19
  61. package/src/fulu-orca.ts +36 -6
  62. package/src/index.ts +686 -137
  63. package/src/printers/bambu-node-guard.ts +44 -0
  64. package/src/printers/bambu.ts +458 -164
  65. package/src/printers/creality.ts +40 -9
  66. package/src/printers/duet.ts +64 -53
  67. package/src/printers/generic-base.ts +286 -0
  68. package/src/printers/klipper.ts +83 -28
  69. package/src/printers/octoprint.ts +74 -21
  70. package/src/printers/printer-factory.ts +21 -11
  71. package/src/printers/prusa.ts +53 -10
  72. package/src/printers/repetier.ts +55 -17
  73. package/src/safety/archive.ts +181 -0
  74. package/src/safety/artifact.ts +58 -0
  75. package/src/safety/confirmation.ts +56 -0
  76. package/src/safety/expected-peaks.ts +47 -0
  77. package/src/safety/gcode-thermal.ts +404 -0
  78. package/src/safety/limits.ts +98 -0
  79. package/src/safety/print-file.ts +354 -0
  80. package/src/safety/printer-state.ts +339 -0
  81. package/src/slicer/bambu-cli.ts +633 -0
  82. package/src/slicer/profile-flatten.ts +830 -0
  83. package/src/slicer/run-slicer.ts +126 -0
  84. package/src/slicer/slicer-error.ts +72 -0
  85. package/src/slicer/templates.ts +288 -0
  86. package/src/stl/stl-manipulator.ts +247 -275
package/src/index.ts CHANGED
@@ -21,14 +21,23 @@ import { createServer as createHttpServer, type IncomingMessage, type Server as
21
21
  import { randomUUID } from "node:crypto";
22
22
  import * as THREE from 'three';
23
23
  import { PrinterFactory } from "./printers/printer-factory.js";
24
- import { STLManipulator } from "./stl/stl-manipulator.js";
24
+ import { STLManipulator, type BambuSliceOptions } from "./stl/stl-manipulator.js";
25
+ import { SlicerError } from "./slicer/slicer-error.js";
26
+ import {
27
+ inspectSliceSettings,
28
+ listTemplateRegistry,
29
+ resolveTemplatePath,
30
+ resolveTemplateSlicerProfilePath,
31
+ saveTemplate,
32
+ } from "./slicer/templates.js";
25
33
  import { parse3MF, ThreeMFData } from './3mf_parser.js';
26
34
  import { BambuImplementation } from "./printers/bambu.js";
27
35
  import {
28
36
  inspectFuluOrcaSetup,
29
37
  invokeFuluBridgeRpc,
30
- isFuluPrintRpcMethod,
31
38
  } from "./fulu-orca.js";
39
+ import { CONFIRMATION_UNAVAILABLE, confirmationTimedOut, confirmationTimeoutMs } from "./safety/confirmation.js";
40
+ import { validateExpectedPeaks, type ExpectedPeakTemperatures, type PrintSafetyOptions } from "./safety/expected-peaks.js";
32
41
 
33
42
  // Load environment variables from .env file
34
43
  dotenv.config();
@@ -168,6 +177,211 @@ const BAMBU_MODEL_PRESETS: Record<string, (nozzle: string) => string> = {
168
177
  h2d: (n) => `Bambu Lab H2D ${n} nozzle`,
169
178
  };
170
179
 
180
+ // Models whose bundled machine presets the Bambu-compatible CLI slicing path
181
+ // can target. The installed slicer must still contain the exact
182
+ // "<model> <nozzle> nozzle" preset; a missing preset stops the slice.
183
+ const SLICE_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c"] as const;
184
+ const SLICE_BAMBU_MODEL_PRESETS: Record<string, (nozzle: string) => string> = {
185
+ ...BAMBU_MODEL_PRESETS,
186
+ p2s: (n) => `Bambu Lab P2S ${n} nozzle`,
187
+ h2s: (n) => `Bambu Lab H2S ${n} nozzle`,
188
+ h2c: (n) => `Bambu Lab H2C ${n} nozzle`,
189
+ };
190
+ const DEFAULT_TEMPLATE_3MF_PATH = process.env.BAMBU_TEMPLATE_3MF_PATH || "";
191
+ const VALID_NOZZLE_TYPES = ["stainless_steel", "hardened_steel", "tungsten_carbide", "brass"] as const;
192
+
193
+ /** The installed hotend material. Bambu machine presets assume the stock nozzle; the print gate compares this with the printer. */
194
+ function resolveNozzleType(value: unknown): string | undefined {
195
+ const raw = value === undefined || value === null || value === "" ? process.env.BAMBU_NOZZLE_TYPE?.trim() : value;
196
+ if (raw === undefined || raw === "") return undefined;
197
+ const normalized = String(raw).trim().toLowerCase().replace(/[\s-]+/g, "_");
198
+ if (!(VALID_NOZZLE_TYPES as readonly string[]).includes(normalized)) {
199
+ throw new Error(`Invalid nozzle type "${raw}". Valid types: ${VALID_NOZZLE_TYPES.join(", ")}`);
200
+ }
201
+ return normalized;
202
+ }
203
+
204
+ // Shared slice_stl / slice_with_template argument schema.
205
+ const SLICE_TOOL_PROPERTIES = {
206
+ bambu_model: {
207
+ type: "string",
208
+ enum: [...SLICE_BAMBU_MODELS],
209
+ description:
210
+ "Bambu Lab printer model. Required for bambustudio and orcaslicer-bambulab (elicited or read from BAMBU_MODEL when omitted); " +
211
+ "passing it with orcaslicer selects the Bambu-compatible path. The installed slicer must contain the exact model/nozzle preset.",
212
+ },
213
+ nozzle_diameter: {
214
+ type: "string",
215
+ description: "Nozzle diameter in mm (default: NOZZLE_DIAMETER or 0.4). Selects the '<model> <diameter> nozzle' machine preset.",
216
+ },
217
+ slicer_type: {
218
+ type: "string",
219
+ enum: [...SLICER_TYPE_ENUM],
220
+ description:
221
+ "Type of slicer to use (prusaslicer, cura, slic3r, orcaslicer, orcaslicer-bambulab, bambustudio). Use orcaslicer-bambulab for the FULU fork. " +
222
+ "bambustudio and orcaslicer-bambulab (and orcaslicer with bambu_model) export a sliced 3MF.",
223
+ },
224
+ slicer_path: {
225
+ type: "string",
226
+ description: "Path to the slicer executable (default: value from env). Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1.",
227
+ },
228
+ slicer_profile: {
229
+ type: "string",
230
+ description:
231
+ "Profile to use for slicing (default: SLICER_PROFILE env). Bambu-compatible slicing: one process profile JSON (the machine preset comes from bambu_model). " +
232
+ "Generic OrcaSlicer: machine/process profiles separated with ';', optionally followed by '|filament.json'.",
233
+ },
234
+ filament_profile: {
235
+ type: "string",
236
+ description: "Filament profile path(s), ';'-separated in slot order, loaded with --load-filaments (default: FILAMENT_PROFILE/SLICER_FILAMENT_PROFILE env). Alias of load_filaments.",
237
+ },
238
+ load_filaments: {
239
+ type: "string",
240
+ description: "Bambu-compatible slicing: filament profile JSON paths in slot order, ';'-separated. One profile applies to every project slot; otherwise supply one per slot.",
241
+ },
242
+ load_filament_ids: {
243
+ type: "string",
244
+ description: "Bambu-compatible slicing: comma-separated filament IDs mapping load_filaments to objects, e.g. '1,2,3,1'.",
245
+ },
246
+ filament_colours: {
247
+ type: "string",
248
+ description: "Bambu-compatible slicing: one #RRGGBB per filament slot, ';'-separated. Defaults to the input 3MF's colours, then each profile's colour.",
249
+ },
250
+ bed_type: {
251
+ type: "string",
252
+ enum: [...VALID_BED_TYPES],
253
+ description: "Bambu-compatible slicing: build plate type (default: BED_TYPE or textured_plate).",
254
+ },
255
+ nozzle_type: {
256
+ type: "string",
257
+ enum: [...VALID_NOZZLE_TYPES],
258
+ description: "Bambu-compatible slicing: the hotend nozzle material installed on the printer (default: BAMBU_NOZZLE_TYPE, else the model preset's stock nozzle, usually stainless_steel; X1C/X1E presets use hardened_steel). Printing compares it with the printer's reported nozzle.",
259
+ },
260
+ template_3mf_path: {
261
+ type: "string",
262
+ description: "Bambu-compatible slicing: 3MF or profile whose embedded slicer settings are reused as the process profile (default: BAMBU_TEMPLATE_3MF_PATH). An explicit slicer_profile takes precedence.",
263
+ },
264
+ template_name: {
265
+ type: "string",
266
+ description: "Named template from the local registry (see list_templates); resolves to its file.",
267
+ },
268
+ template_dir: {
269
+ type: "string",
270
+ description: "Template directory override when resolving template_name (default: BAMBU_TEMPLATE_DIR or ~/Sync/bambu/templates).",
271
+ },
272
+ uptodate: { type: "boolean", description: "Bambu-compatible slicing: refresh 3MF preset configs to the installed slicer version (--uptodate)." },
273
+ repetitions: { type: "number", description: "Bambu-compatible slicing: print N identical copies (--repetitions)." },
274
+ orient: { type: "boolean", description: "Bambu-compatible slicing: auto-orient for printability (--orient)." },
275
+ arrange: { type: "boolean", description: "Bambu-compatible slicing: auto-arrange objects on the plate (--arrange). Set false to keep an existing layout." },
276
+ ensure_on_bed: { type: "boolean", description: "Bambu-compatible slicing: lower floating models onto the bed (--ensure-on-bed)." },
277
+ clone_objects: { type: "string", description: "Bambu-compatible slicing: comma-separated clone counts per object index, e.g. '1,3,1,10'." },
278
+ skip_objects: { type: "string", description: "Bambu-compatible slicing: comma-separated object indices to skip, e.g. '3,5,10'." },
279
+ enable_timelapse: { type: "boolean", description: "Bambu-compatible slicing: insert timelapse parking moves (--enable-timelapse)." },
280
+ allow_mix_temp: { type: "boolean", description: "Bambu-compatible slicing: allow filaments with different temperature requirements on one plate." },
281
+ scale: { type: "number", description: "Bambu-compatible slicing: uniform scale factor applied before slicing (1.0 = original size)." },
282
+ rotate: { type: "number", description: "Bambu-compatible slicing: Z-axis rotation in degrees before slicing." },
283
+ rotate_x: { type: "number", description: "Bambu-compatible slicing: X-axis rotation in degrees before slicing." },
284
+ rotate_y: { type: "number", description: "Bambu-compatible slicing: Y-axis rotation in degrees before slicing." },
285
+ min_save: { type: "boolean", description: "Bambu-compatible slicing: write a smaller output 3MF (--min-save)." },
286
+ skip_modified_gcodes: { type: "boolean", description: "Bambu-compatible slicing: ignore custom G-code embedded in an input 3MF (--skip-modified-gcodes)." },
287
+ slice_plate: { type: "number", description: "Bambu-compatible slicing: plate number to slice; 0 slices all plates (default)." },
288
+ };
289
+
290
+ function validateSliceBambuModel(model: string): string {
291
+ const normalized = model.trim().toLowerCase();
292
+ if (!(SLICE_BAMBU_MODELS as readonly string[]).includes(normalized)) {
293
+ throw new Error(
294
+ `Invalid bambu_model "${model}" for slicing. Valid models: ${SLICE_BAMBU_MODELS.join(", ")}`
295
+ );
296
+ }
297
+ return normalized;
298
+ }
299
+
300
+ function normalizeSliceNozzleDiameter(raw: unknown): string {
301
+ const text = String(raw ?? "").trim() || DEFAULT_NOZZLE_DIAMETER;
302
+ const value = Number(text);
303
+ if (!/^\d+(\.\d+)?$/.test(text) || !Number.isFinite(value) || value <= 0 || value > 2) {
304
+ throw new Error(`Invalid nozzle_diameter "${text}". Use a diameter in mm such as 0.2, 0.4, 0.6, or 0.8.`);
305
+ }
306
+ return String(value);
307
+ }
308
+
309
+ /** Parse "#RRGGBB;#RRGGBB" (leading '#' optional) into positional slot colours. */
310
+ function parseFilamentColours(value: string): string[] {
311
+ const colours = value.split(";").map((c) => c.trim()).filter(Boolean)
312
+ .map((c) => (c.startsWith("#") ? c : `#${c}`).toUpperCase());
313
+ for (const c of colours) {
314
+ if (!/^#[0-9A-F]{6}([0-9A-F]{2})?$/.test(c)) {
315
+ throw new Error(`Invalid filament colour "${c}"; expected #RRGGBB.`);
316
+ }
317
+ }
318
+ if (colours.length === 0) throw new Error("Invalid filament_colours: list at least one colour.");
319
+ return colours;
320
+ }
321
+
322
+ function optionalFiniteNumber(args: Record<string, unknown> | undefined, key: string): number | undefined {
323
+ if (args?.[key] === undefined) return undefined;
324
+ const value = Number(args[key]);
325
+ if (!Number.isFinite(value)) throw new Error(`Invalid ${key}: expected a number.`);
326
+ return value;
327
+ }
328
+
329
+ function optionalBoolean(args: Record<string, unknown> | undefined, key: string): boolean | undefined {
330
+ const value = args?.[key];
331
+ if (value === undefined) return undefined;
332
+ if (typeof value !== "boolean") throw new Error(`Invalid ${key}: expected a boolean.`);
333
+ return value;
334
+ }
335
+
336
+ /** Map slice_stl/slice_with_template arguments to Bambu-compatible CLI options. */
337
+ function buildBambuSliceOptions(args: Record<string, unknown> | undefined): BambuSliceOptions {
338
+ const options: BambuSliceOptions = {};
339
+ const text = (key: string) => (args?.[key] !== undefined && String(args[key]).trim() ? String(args[key]).trim() : undefined);
340
+ options.uptodate = optionalBoolean(args, "uptodate");
341
+ options.orient = optionalBoolean(args, "orient");
342
+ options.arrange = optionalBoolean(args, "arrange");
343
+ options.ensureOnBed = optionalBoolean(args, "ensure_on_bed");
344
+ options.enableTimelapse = optionalBoolean(args, "enable_timelapse");
345
+ options.allowMixTemp = optionalBoolean(args, "allow_mix_temp");
346
+ options.minSave = optionalBoolean(args, "min_save");
347
+ options.skipModifiedGcodes = optionalBoolean(args, "skip_modified_gcodes");
348
+ options.repetitions = optionalFiniteNumber(args, "repetitions");
349
+ options.scale = optionalFiniteNumber(args, "scale");
350
+ options.rotate = optionalFiniteNumber(args, "rotate");
351
+ options.rotateX = optionalFiniteNumber(args, "rotate_x");
352
+ options.rotateY = optionalFiniteNumber(args, "rotate_y");
353
+ options.slicePlate = optionalFiniteNumber(args, "slice_plate");
354
+ if (options.repetitions !== undefined && (!Number.isInteger(options.repetitions) || options.repetitions < 1)) {
355
+ throw new Error("Invalid repetitions: expected a positive integer.");
356
+ }
357
+ if (options.slicePlate !== undefined && (!Number.isInteger(options.slicePlate) || options.slicePlate < 0)) {
358
+ throw new Error("Invalid slice_plate: expected 0 (all plates) or a plate number.");
359
+ }
360
+ if (options.scale !== undefined && options.scale <= 0) throw new Error("Invalid scale: expected a positive factor.");
361
+ options.cloneObjects = text("clone_objects");
362
+ options.skipObjects = text("skip_objects");
363
+ options.loadFilamentIds = text("load_filament_ids");
364
+ const loadFilaments = text("load_filaments");
365
+ const filamentAlias = text("filament_profile");
366
+ if (loadFilaments && filamentAlias && loadFilaments !== filamentAlias) {
367
+ throw new Error("Provide either load_filaments or filament_profile, not conflicting values.");
368
+ }
369
+ options.loadFilaments = loadFilaments ?? filamentAlias;
370
+ const colours = text("filament_colours");
371
+ if (colours) options.filamentColours = parseFilamentColours(colours);
372
+ options.bedType = resolveBedType(args?.bed_type as string | undefined);
373
+ options.nozzleType = resolveNozzleType(args?.nozzle_type);
374
+ return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) as BambuSliceOptions;
375
+ }
376
+
377
+ function defaultSlicerPathFor(slicerType: SlicerType): string {
378
+ if (process.env.SLICER_PATH) return process.env.SLICER_PATH;
379
+ if (slicerType === DEFAULT_SLICER_TYPE) return DEFAULT_SLICER_PATH;
380
+ if (slicerType === "bambustudio") return defaultBambuStudioPath();
381
+ if (slicerType === "orcaslicer-bambulab") return defaultFuluOrcaPath();
382
+ return DEFAULT_SLICER_PATH;
383
+ }
384
+
171
385
  function validateBambuModel(model: string): BambuModel {
172
386
  const normalized = model.trim().toLowerCase();
173
387
  if (!(VALID_BAMBU_MODELS as readonly string[]).includes(normalized)) {
@@ -188,6 +402,35 @@ function resolveBedType(bedType: string | undefined): string {
188
402
  return resolved;
189
403
  }
190
404
 
405
+ const SUPPORTED_NOZZLE_DIAMETERS = [0.2, 0.4, 0.6, 0.8];
406
+
407
+ /** Optional declared material; blank means "not declared". */
408
+ function optionalMaterialArg(value: unknown): string | undefined {
409
+ if (value === undefined || value === null || value === "") return undefined;
410
+ if (typeof value !== "string" || !value.trim()) {
411
+ throw new Error("material must be a non-empty string such as PLA, PETG, ABS, ASA, TPU, PA or PC.");
412
+ }
413
+ return value.trim();
414
+ }
415
+
416
+ /** Temperatures are validated as finite numbers before any printer connection. */
417
+ function optionalTemperatureArg(value: unknown, name: string): number | undefined {
418
+ if (value === undefined) return undefined;
419
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
420
+ throw new Error(`${name} must be a finite, non-negative number in °C.`);
421
+ }
422
+ return value;
423
+ }
424
+
425
+ function optionalNozzleDiameterArg(value: unknown): number | undefined {
426
+ if (value === undefined || value === null || value === "") return undefined;
427
+ const diameter = Number(value);
428
+ if ((typeof value !== "number" && typeof value !== "string") || !SUPPORTED_NOZZLE_DIAMETERS.includes(diameter)) {
429
+ throw new Error(`nozzle_diameter must be one of ${SUPPORTED_NOZZLE_DIAMETERS.join(", ")} mm.`);
430
+ }
431
+ return diameter;
432
+ }
433
+
191
434
  type RuntimeConfig = {
192
435
  transport: "stdio" | "streamable-http";
193
436
  httpHost: string;
@@ -274,13 +517,29 @@ type StructuredToolError = {
274
517
  suggestion: string;
275
518
  message: string;
276
519
  tool: string;
520
+ slicer?: Record<string, unknown>;
277
521
  };
278
522
 
523
+ const SLICING_TOOLS = new Set(["slice_stl", "slice_with_template", "list_templates", "save_template", "get_slice_settings"]);
524
+
279
525
  // Ensure temp directory exists
280
526
  if (!fs.existsSync(TEMP_DIR)) {
281
527
  fs.mkdirSync(TEMP_DIR, { recursive: true });
282
528
  }
283
529
 
530
+
531
+ /** MQTT "Not authorized", FTP 530, and HTTP 401/403 all mean the printer rejected its credentials. */
532
+ function isCredentialRejection(message: string): boolean {
533
+ return /not authori[sz]ed|\b530\b|login incorrect|\b401\b|\b403\b|unauthori[sz]ed|forbidden|bad user name or password/i.test(message);
534
+ }
535
+
536
+ function credentialSuggestion(): string {
537
+ return (process.env.PRINTER_TYPE ?? "").toLowerCase() === "bambu"
538
+ ? "The printer rejected its LAN access code. Read the current Access Code on the printer (Settings > Network/WLAN; it changes " +
539
+ "when LAN mode is toggled and after some resets), update BAMBU_TOKEN, and check BAMBU_SERIAL. Retrying unchanged will not help."
540
+ : "The printer rejected its credentials. Check API_KEY (and PRINTER_HOST/PRINTER_PORT) for this printer system. Retrying unchanged will not help.";
541
+ }
542
+
284
543
  class ThreeDPrinterMCPServer {
285
544
  private server: Server;
286
545
  private readonly blender = new BlenderMcpBridge();
@@ -304,13 +563,37 @@ class ThreeDPrinterMCPServer {
304
563
  }
305
564
  );
306
565
 
307
- this.printerFactory = new PrinterFactory();
566
+ this.printerFactory = new PrinterFactory((message) => this.confirmHardware(message));
308
567
  this.stlManipulator = new STLManipulator(TEMP_DIR);
309
568
 
310
569
  this.setupHandlers();
311
570
  this.setupErrorHandling();
312
571
  }
313
572
 
573
+ /** Human MCP elicitation before a print start or positive heating command. */
574
+ private async confirmHardware(message: string): Promise<boolean> {
575
+ let response;
576
+ const timeout = confirmationTimeoutMs();
577
+ try {
578
+ response = await this.server.elicitInput({
579
+ mode: "form",
580
+ message,
581
+ requestedSchema: {
582
+ type: "object",
583
+ properties: {
584
+ confirmed: { type: "boolean", title: "I checked the printer and confirm this operation", default: false },
585
+ },
586
+ required: ["confirmed"],
587
+ },
588
+ }, { timeout });
589
+ } catch (error) {
590
+ // A person may take minutes to check the bed; a timeout is not a missing capability.
591
+ if ((error as { code?: unknown })?.code === ErrorCode.RequestTimeout) throw new Error(confirmationTimedOut(timeout));
592
+ throw new Error(CONFIRMATION_UNAVAILABLE);
593
+ }
594
+ return response.action === "accept" && response.content?.confirmed === true;
595
+ }
596
+
314
597
  private async resolveBambuModel(argsModel: string | undefined): Promise<string> {
315
598
  const fromArgs = (argsModel || DEFAULT_BAMBU_MODEL).trim().toLowerCase();
316
599
  if (fromArgs) {
@@ -344,7 +627,7 @@ class ThreeDPrinterMCPServer {
344
627
  },
345
628
  required: ["bambu_model"],
346
629
  },
347
- });
630
+ }, { timeout: confirmationTimeoutMs() });
348
631
 
349
632
  if (result.action === "accept" && result.content?.bambu_model) {
350
633
  return validateBambuModel(String(result.content.bambu_model));
@@ -355,6 +638,12 @@ class ThreeDPrinterMCPServer {
355
638
  );
356
639
  } catch (elicitError: any) {
357
640
  const msg = elicitError?.message || String(elicitError);
641
+ if (elicitError?.code === ErrorCode.RequestTimeout) {
642
+ throw new Error(
643
+ "No printer model was chosen in time; nothing was sent. Set BAMBU_MODEL or pass bambu_model, " +
644
+ `or answer the prompt within PRINT_CONFIRMATION_TIMEOUT_MS. Valid models: ${VALID_BAMBU_MODELS.join(", ")}`
645
+ );
646
+ }
358
647
  if (
359
648
  elicitError?.code === -32601 || elicitError?.code === -32600 ||
360
649
  msg.includes("does not support") || msg.includes("elicitation")
@@ -369,6 +658,79 @@ class ThreeDPrinterMCPServer {
369
658
  }
370
659
  }
371
660
 
661
+ /** Slicing accepts every model with a Bambu-compatible CLI preset mapping. */
662
+ private async resolveSliceBambuModel(argsModel: string | undefined): Promise<string> {
663
+ const requested = (argsModel || DEFAULT_BAMBU_MODEL).trim().toLowerCase();
664
+ if (requested) {
665
+ return validateSliceBambuModel(requested);
666
+ }
667
+ return this.resolveBambuModel(undefined);
668
+ }
669
+
670
+ /** Shared slice_stl / slice_with_template implementation. */
671
+ private async runSliceTool(toolName: string, args: Record<string, unknown> | undefined): Promise<string> {
672
+ if (!args?.stl_path) {
673
+ throw new Error("Missing required parameter: stl_path");
674
+ }
675
+ const slicerType = normalizeSlicerType(String(args.slicer_type || DEFAULT_SLICER_TYPE));
676
+ const explicitModel =
677
+ typeof args.bambu_model === "string" && args.bambu_model.trim() ? args.bambu_model : undefined;
678
+ // Generic OrcaSlicer keeps its user-supplied machine profile unless the
679
+ // call explicitly targets a Bambu model.
680
+ const bambuCli = isBambuProjectSlicer(slicerType) || (slicerType === "orcaslicer" && explicitModel !== undefined);
681
+
682
+ let printerPreset: string | undefined;
683
+ let options: BambuSliceOptions | undefined;
684
+ if (bambuCli) {
685
+ const model = await this.resolveSliceBambuModel(explicitModel);
686
+ printerPreset = SLICE_BAMBU_MODEL_PRESETS[model](normalizeSliceNozzleDiameter(args.nozzle_diameter));
687
+ options = buildBambuSliceOptions(args);
688
+ }
689
+
690
+ // Process profile precedence: explicit slicer_profile, then a template, then SLICER_PROFILE.
691
+ const explicitProfile =
692
+ typeof args.slicer_profile === "string" && args.slicer_profile.trim() ? args.slicer_profile.trim() : undefined;
693
+ const templateDir = typeof args.template_dir === "string" ? args.template_dir : undefined;
694
+ const requestedTemplate =
695
+ resolveTemplatePath(
696
+ typeof args.template_name === "string" ? args.template_name : undefined,
697
+ templateDir,
698
+ ["json", "config", "3mf"]
699
+ ) ||
700
+ (typeof args.template_3mf_path === "string" && args.template_3mf_path.trim() ? args.template_3mf_path.trim() : undefined);
701
+ if (requestedTemplate && !bambuCli) {
702
+ throw new Error(
703
+ "template_name/template_3mf_path apply to Bambu-compatible slicing (bambustudio, orcaslicer-bambulab, or orcaslicer with bambu_model)."
704
+ );
705
+ }
706
+ let slicerProfile = explicitProfile;
707
+ const templatePath = requestedTemplate || (bambuCli && DEFAULT_TEMPLATE_3MF_PATH ? DEFAULT_TEMPLATE_3MF_PATH : undefined);
708
+ if (!slicerProfile && templatePath) {
709
+ if (!fs.existsSync(templatePath)) {
710
+ throw new Error(`Template not found: ${templatePath}`);
711
+ }
712
+ slicerProfile = await resolveTemplateSlicerProfilePath(templatePath, path.join(TEMP_DIR, "templates"));
713
+ }
714
+ slicerProfile ??= DEFAULT_SLICER_PROFILE || undefined;
715
+
716
+ const explicitFilament =
717
+ typeof args.filament_profile === "string" && args.filament_profile.trim() ? args.filament_profile.trim() : undefined;
718
+ const filamentProfile = options?.loadFilaments ?? explicitFilament ?? (DEFAULT_FILAMENT_PROFILE || undefined);
719
+ const slicerPath =
720
+ this.resolveExecutableSelectorArg(args.slicer_path, toolName, "slicer_path") ?? defaultSlicerPathFor(slicerType);
721
+
722
+ return this.stlManipulator.sliceSTL(
723
+ String(args.stl_path),
724
+ slicerType,
725
+ slicerPath,
726
+ slicerProfile,
727
+ undefined, // progressCallback
728
+ printerPreset,
729
+ filamentProfile,
730
+ options
731
+ );
732
+ }
733
+
372
734
  setupErrorHandling() {
373
735
  this.server.onerror = (error) => {
374
736
  console.error("[MCP Error]", error);
@@ -588,7 +950,7 @@ class ThreeDPrinterMCPServer {
588
950
  },
589
951
  {
590
952
  name: "upload_gcode",
591
- description: "Upload G-code content or a local G-code file path to the printer",
953
+ description: "Upload G-code content or a local G-code file path to the printer. With print=true the exact uploaded bytes are inspected first (every S/R heater target, tool changes, hardware and material ceilings), printer state is checked, and a human confirmation is requested before the print starts.",
592
954
  inputSchema: {
593
955
  type: "object",
594
956
  properties: {
@@ -635,14 +997,18 @@ class ThreeDPrinterMCPServer {
635
997
  },
636
998
  print: {
637
999
  type: "boolean",
638
- description: "Start printing after upload when the printer backend supports it."
1000
+ description: "Start printing after upload when the printer backend supports it. Printing requires a declared material from slicer metadata (; filament_type = PLA) or the material argument."
1001
+ },
1002
+ material: {
1003
+ type: "string",
1004
+ description: "Declared filament material (for example PLA, PETG, ABS, ASA, TPU, PA, PC) when the G-code has no slicer filament_type metadata. Must not contradict the file. Material ceilings limit nozzle targets."
639
1005
  }
640
1006
  }
641
1007
  }
642
1008
  },
643
1009
  {
644
1010
  name: "start_print",
645
- description: "Start printing a file that is already available on the printer",
1011
+ description: "Start printing a G-code file already stored on the printer. The server downloads and inspects the exact file, then starts a uniquely named checked copy after printer-state checks and human confirmation. Printers whose API cannot download files (Repetier, Prusa, Creality) refuse; use upload_gcode with print=true instead.",
646
1012
  inputSchema: {
647
1013
  type: "object",
648
1014
  properties: {
@@ -678,6 +1044,10 @@ class ThreeDPrinterMCPServer {
678
1044
  filename: {
679
1045
  type: "string",
680
1046
  description: "Name/path of the printer-side G-code file to start."
1047
+ },
1048
+ material: {
1049
+ type: "string",
1050
+ description: "Declared filament material when the G-code has no slicer filament_type metadata (non-Bambu printers). Must not contradict the file."
681
1051
  }
682
1052
  },
683
1053
  required: ["filename"]
@@ -718,7 +1088,7 @@ class ThreeDPrinterMCPServer {
718
1088
  },
719
1089
  {
720
1090
  name: "set_printer_temperature",
721
- description: "Set the temperature of a printer component",
1091
+ description: "Set the temperature of a printer component. Temperature 0 switches a heater off and is never gated. Positive targets are validated before connecting, limited by independent hardware and material ceilings, require a ready printer and a human confirmation.",
722
1092
  inputSchema: {
723
1093
  type: "object",
724
1094
  properties: {
@@ -752,7 +1122,20 @@ class ThreeDPrinterMCPServer {
752
1122
  },
753
1123
  temperature: {
754
1124
  type: "number",
755
- description: "Target temperature in Celsius."
1125
+ description: "Target temperature in Celsius: a finite number >= 0. 0 switches the heater off."
1126
+ },
1127
+ material: {
1128
+ type: "string",
1129
+ description: "Declared material at the nozzle (for example PLA, PETG, ABS). Required for positive nozzle heating, including non-RFID spools."
1130
+ },
1131
+ bambu_model: {
1132
+ type: "string",
1133
+ enum: [...VALID_BAMBU_MODELS],
1134
+ description: "Bambu printer model; required for positive Bambu heating unless BAMBU_MODEL is configured. Checked against the live printer."
1135
+ },
1136
+ nozzle_diameter: {
1137
+ type: "string",
1138
+ description: "Installed Bambu nozzle diameter in mm for nozzle heating (default: NOZZLE_DIAMETER or 0.4). Checked against the live printer."
756
1139
  }
757
1140
  },
758
1141
  required: ["component", "temperature"]
@@ -779,7 +1162,11 @@ class ThreeDPrinterMCPServer {
779
1162
  },
780
1163
  {
781
1164
  name: "slice_stl",
782
- description: "Slice an STL or 3MF file to generate G-code or sliced 3MF",
1165
+ description:
1166
+ "Slice an STL or 3MF file to generate G-code or a sliced 3MF. For Bambu-compatible CLI slicing " +
1167
+ "(bambustudio, orcaslicer-bambulab, or orcaslicer with bambu_model), the exact bambu_model/nozzle machine " +
1168
+ "preset from the selected slicer installation is required, profile inheritance is resolved before the CLI " +
1169
+ "runs, and the result must contain plate G-code. Failures stop with the slicer's exit status and output.",
783
1170
  inputSchema: {
784
1171
  type: "object",
785
1172
  properties: {
@@ -787,39 +1174,74 @@ class ThreeDPrinterMCPServer {
787
1174
  type: "string",
788
1175
  description: "Path to the STL or 3MF file to slice"
789
1176
  },
790
- bambu_model: {
791
- type: "string",
792
- enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
793
- description: "Bambu Lab printer model - required when using Bambu project slicers to ensure correct G-code."
794
- },
795
- nozzle_diameter: {
796
- type: "string",
797
- description: "Nozzle diameter in mm (default: 0.4)."
798
- },
799
- slicer_type: {
800
- type: "string",
801
- enum: [...SLICER_TYPE_ENUM],
802
- description: "Type of slicer to use (prusaslicer, cura, slic3r, orcaslicer, orcaslicer-bambulab, bambustudio). Use orcaslicer-bambulab for the FULU fork."
803
- },
804
- slicer_path: {
805
- type: "string",
806
- description: "Path to the slicer executable (default: value from env). Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1."
807
- },
1177
+ ...SLICE_TOOL_PROPERTIES,
1178
+ },
1179
+ required: ["stl_path"]
1180
+ }
1181
+ },
1182
+ {
1183
+ name: "slice_with_template",
1184
+ description:
1185
+ "Slice an STL or 3MF with a named template from the local template registry (BAMBU_TEMPLATE_DIR). " +
1186
+ "The template supplies process settings; the machine preset still comes from bambu_model and nozzle_diameter.",
1187
+ inputSchema: {
1188
+ type: "object",
1189
+ properties: {
1190
+ stl_path: { type: "string", description: "Path to the STL or 3MF file to slice" },
1191
+ ...SLICE_TOOL_PROPERTIES,
1192
+ template_name: { type: "string", description: "Named template from the local registry (required)." },
808
1193
  slicer_profile: {
809
1194
  type: "string",
810
- description: "Profile to use for slicing (default: value from env). OrcaSlicer also accepts machine/process profiles separated with ';', optionally followed by '|filament.json'."
1195
+ description: "Explicit process profile that overrides the named template only when provided in this call."
811
1196
  },
812
- filament_profile: {
1197
+ },
1198
+ required: ["stl_path", "template_name"]
1199
+ }
1200
+ },
1201
+ {
1202
+ name: "list_templates",
1203
+ description: "List saved slicing templates (.3mf, .json, .config) in the local template registry directory.",
1204
+ inputSchema: {
1205
+ type: "object",
1206
+ properties: {
1207
+ template_dir: {
813
1208
  type: "string",
814
- description: "OrcaSlicer filament profile path loaded with --load-filaments (default: FILAMENT_PROFILE/SLICER_FILAMENT_PROFILE env)."
1209
+ description: "Template directory override. Defaults to BAMBU_TEMPLATE_DIR or ~/Sync/bambu/templates."
1210
+ }
1211
+ }
1212
+ }
1213
+ },
1214
+ {
1215
+ name: "save_template",
1216
+ description: "Copy a .3mf, .json, or .config file into the local template registry under a template name.",
1217
+ inputSchema: {
1218
+ type: "object",
1219
+ properties: {
1220
+ source_path: { type: "string", description: "Local .3mf, .json, or .config file to save as a template." },
1221
+ template_name: { type: "string", description: "Template name. Defaults to the source filename without extension." },
1222
+ template_dir: {
1223
+ type: "string",
1224
+ description: "Template directory override. Defaults to BAMBU_TEMPLATE_DIR or ~/Sync/bambu/templates."
815
1225
  }
816
1226
  },
817
- required: ["stl_path"]
1227
+ required: ["source_path"]
1228
+ }
1229
+ },
1230
+ {
1231
+ name: "get_slice_settings",
1232
+ description: "Inspect slicer settings in a 3MF template or JSON/config profile without slicing (layer height, infill, walls, supports, brim, bed, printer, filaments).",
1233
+ inputSchema: {
1234
+ type: "object",
1235
+ properties: {
1236
+ source_path: { type: "string", description: "Path to a 3MF, extracted project_settings.config, or slicer profile JSON." },
1237
+ template_name: { type: "string", description: "Named template from the local registry; used when source_path is omitted." },
1238
+ template_dir: { type: "string", description: "Template directory override when resolving template_name." }
1239
+ }
818
1240
  }
819
1241
  },
820
1242
  {
821
1243
  name: "confirm_temperatures",
822
- description: "Confirm temperature settings in a G-code file",
1244
+ description: "Report every heater target in a G-code file (S and R forms, tool-addressed, RepRapFirmware G10/M568 and Klipper SET_HEATER_TEMPERATURE). An expected temperature matches only when it equals the file's highest target. Read-only; printing tools enforce their own safety gate.",
823
1245
  inputSchema: {
824
1246
  type: "object",
825
1247
  properties: {
@@ -829,11 +1251,11 @@ class ThreeDPrinterMCPServer {
829
1251
  },
830
1252
  extruder_temp: {
831
1253
  type: "number",
832
- description: "Expected extruder temperature"
1254
+ description: "Expected highest nozzle target"
833
1255
  },
834
1256
  bed_temp: {
835
1257
  type: "number",
836
- description: "Expected bed temperature"
1258
+ description: "Expected highest bed target"
837
1259
  }
838
1260
  },
839
1261
  required: ["gcode_path"]
@@ -841,7 +1263,7 @@ class ThreeDPrinterMCPServer {
841
1263
  },
842
1264
  {
843
1265
  name: "process_and_print_stl",
844
- description: "Process an STL file (extend base), slice it, confirm temperatures, and start printing",
1266
+ description: "Process an STL file (extend base), slice it, and start printing through the same checked print gate as upload_gcode/print_3mf. Expected temperatures are enforced: a mismatch refuses before upload.",
845
1267
  inputSchema: {
846
1268
  type: "object",
847
1269
  properties: {
@@ -855,11 +1277,15 @@ class ThreeDPrinterMCPServer {
855
1277
  },
856
1278
  extruder_temp: {
857
1279
  type: "number",
858
- description: "Expected extruder temperature"
1280
+ description: "Expected highest nozzle target in the sliced G-code (S and R forms, every tool). Printing stops before upload if it differs."
859
1281
  },
860
1282
  bed_temp: {
861
1283
  type: "number",
862
- description: "Expected bed temperature"
1284
+ description: "Expected highest bed target in the sliced G-code (S and R forms). Printing stops before upload if it differs."
1285
+ },
1286
+ material: {
1287
+ type: "string",
1288
+ description: "Declared filament material when the sliced G-code has no filament_type metadata. Must not contradict the file."
863
1289
  },
864
1290
  host: {
865
1291
  type: "string",
@@ -887,6 +1313,11 @@ class ThreeDPrinterMCPServer {
887
1313
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"],
888
1314
  description: "Bed/plate type installed on the printer (default: textured_plate)."
889
1315
  },
1316
+ nozzle_type: {
1317
+ type: "string",
1318
+ enum: [...VALID_NOZZLE_TYPES],
1319
+ description: "Installed Bambu nozzle material used when slicing (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle). The print gate compares it with the printer's report."
1320
+ },
890
1321
  nozzle_diameter: {
891
1322
  type: "string",
892
1323
  description: "Nozzle diameter in mm (default: 0.4)."
@@ -1093,7 +1524,7 @@ class ThreeDPrinterMCPServer {
1093
1524
  },
1094
1525
  {
1095
1526
  name: "print_3mf",
1096
- description: "Print a 3MF file on a Bambu Lab printer, potentially overriding settings.",
1527
+ description: "Print a 3MF file on a Bambu Lab printer. The exact selected plate is inspected (model, nozzle, bed type, materials, every heater target) and checked against a fresh MQTT report of the printer's identity, nozzle, state, errors and loaded filament, then a human confirmation is requested before upload and start.",
1097
1528
  inputSchema: {
1098
1529
  type: "object",
1099
1530
  properties: {
@@ -1111,6 +1542,11 @@ class ThreeDPrinterMCPServer {
1111
1542
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"],
1112
1543
  description: "Bed/plate type installed on the printer (default: textured_plate)."
1113
1544
  },
1545
+ nozzle_type: {
1546
+ type: "string",
1547
+ enum: [...VALID_NOZZLE_TYPES],
1548
+ description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)."
1549
+ },
1114
1550
  nozzle_diameter: {
1115
1551
  type: "string",
1116
1552
  description: "Nozzle diameter in mm (default: 0.4)."
@@ -1234,7 +1670,7 @@ class ThreeDPrinterMCPServer {
1234
1670
  {
1235
1671
  name: "fulu_bambu_network_rpc",
1236
1672
  description:
1237
- "Advanced FULU bridge RPC for BambuNetwork diagnostics and development. Read-only methods are allowed by default; mutating methods require allow_mutating_method=true, and print methods require bambu_model.",
1673
+ "Advanced FULU bridge RPC for BambuNetwork diagnostics and development. Read-only methods are allowed by default. Agent/session setup methods require allow_mutating_method=true. Raw print methods, printer messages, file transfers and unknown methods are refused because they would bypass the print safety gate; use print_3mf for checked printing.",
1238
1674
  inputSchema: {
1239
1675
  type: "object",
1240
1676
  properties: {
@@ -1260,12 +1696,12 @@ class ThreeDPrinterMCPServer {
1260
1696
  },
1261
1697
  allow_mutating_method: {
1262
1698
  type: "boolean",
1263
- description: "Required for methods outside the read-only bridge/net allowlist."
1699
+ description: "Required for the allowlisted agent/session setup methods. It never enables print, printer-message, or unknown methods."
1264
1700
  },
1265
1701
  bambu_model: {
1266
1702
  type: "string",
1267
1703
  enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
1268
- description: "Required when calling FULU print RPC methods; preserves the Bambu model safety gate."
1704
+ description: "Informational only. Raw FULU print RPC methods are disabled; use print_3mf."
1269
1705
  }
1270
1706
  },
1271
1707
  required: ["method"]
@@ -1319,11 +1755,13 @@ class ThreeDPrinterMCPServer {
1319
1755
  },
1320
1756
  {
1321
1757
  name: "blender_mcp_status",
1322
- description: "Inspect Blender MCP configuration or connect and discover the remote server's tools and schemas. Connecting does not edit the scene; use get_scene_info through blender_mcp_call to check the Blender addon.",
1758
+ description: "Inspect Blender MCP configuration or connect and discover the remote server's tools. Lists tool names and summaries; pass tool_names for the full input schemas of the tools you will call. Connecting does not edit the scene; use get_scene_info through blender_mcp_call to check the Blender addon.",
1323
1759
  inputSchema: {
1324
1760
  type: "object",
1325
1761
  properties: {
1326
1762
  connect: { type: "boolean", description: "Initialize the configured stdio MCP server and discover its tools (default false)." },
1763
+ tool_names: { type: "array", items: { type: "string" }, maxItems: 64, description: "Return full input schemas for these discovered tools, such as [\"execute_blender_code\"]." },
1764
+ include_schemas: { type: "boolean", description: "Return every discovered tool's full definition (large; default false)." },
1327
1765
  timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total connection and discovery deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." }
1328
1766
  },
1329
1767
  additionalProperties: false
@@ -1343,6 +1781,23 @@ class ThreeDPrinterMCPServer {
1343
1781
  additionalProperties: false
1344
1782
  }
1345
1783
  },
1784
+ {
1785
+ name: "blender_mcp_export_stl",
1786
+ description: "Export named objects from the live Blender scene to a new, verified STL for slicing. Writes world-space geometry with modifiers applied, without changing the scene, selection, or mode, and reports triangle count and bounding-box dimensions from the written file. Use this after editing or modelling through blender_mcp_call; Blender MCP's own export_scene writes GLB/FBX only. Requires standard Blender MCP and a shared local filesystem.",
1787
+ inputSchema: {
1788
+ type: "object",
1789
+ properties: {
1790
+ object_names: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 64, description: "Blender object names to export together as one STL (mesh, curve, surface, metaball, or text objects)." },
1791
+ output_path: { type: "string", description: "New local .stl path. Its parent must exist; existing files are never overwritten." },
1792
+ apply_modifiers: { type: "boolean", description: "Export the evaluated geometry with modifiers applied (default true)." },
1793
+ scale: { type: "number", exclusiveMinimum: 0, description: "Multiply coordinates before writing (default 1). Slicers read STL units as millimetres, so use 1000 for a scene modelled in metres." },
1794
+ user_prompt: { type: "string", description: "The user's own words, passed unchanged to Blender MCP." },
1795
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total Blender request deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." }
1796
+ },
1797
+ required: ["object_names", "output_path"],
1798
+ additionalProperties: false
1799
+ }
1800
+ },
1346
1801
  {
1347
1802
  name: "blender_mcp_edit_model",
1348
1803
  description: "Import, edit, and export a local STL through standard Blender MCP with verified output and existing scene objects preserved. Requires a shared local filesystem and Blender Object Mode. Also supports a separately configured legacy executable bridge.",
@@ -1411,8 +1866,9 @@ class ThreeDPrinterMCPServer {
1411
1866
  if (args.print !== undefined && typeof args.print !== "boolean") {
1412
1867
  throw new Error("print must be a boolean.");
1413
1868
  }
1869
+ const uploadOptions: PrintSafetyOptions = { material: optionalMaterialArg(args?.material) };
1414
1870
  if (type.toLowerCase() === "bambu" && args.print === true) {
1415
- await this.resolveBambuModel(args?.bambu_model as string | undefined);
1871
+ uploadOptions.bambuModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1416
1872
  }
1417
1873
  const uploadFilename = String(args?.filename || path.basename(String(args.gcode_path)));
1418
1874
  const uploadGcodeSource = String(args?.gcode_path || args.gcode);
@@ -1420,7 +1876,8 @@ class ThreeDPrinterMCPServer {
1420
1876
  host, port, type, apiKey, bambuSerial, bambuToken,
1421
1877
  uploadFilename,
1422
1878
  uploadGcodeSource,
1423
- Boolean(args.print || false)
1879
+ Boolean(args.print || false),
1880
+ uploadOptions
1424
1881
  );
1425
1882
  break;
1426
1883
 
@@ -1428,10 +1885,11 @@ class ThreeDPrinterMCPServer {
1428
1885
  if (!args?.filename) {
1429
1886
  throw new Error("Missing required parameter: filename");
1430
1887
  }
1888
+ const startOptions: PrintSafetyOptions = { material: optionalMaterialArg(args?.material) };
1431
1889
  if (type.toLowerCase() === "bambu") {
1432
- await this.resolveBambuModel(args?.bambu_model as string | undefined);
1890
+ startOptions.bambuModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1433
1891
  }
1434
- result = await this.startPrint(host, port, type, apiKey, bambuSerial, bambuToken, String(args.filename));
1892
+ result = await this.startPrint(host, port, type, apiKey, bambuSerial, bambuToken, String(args.filename), startOptions);
1435
1893
  break;
1436
1894
 
1437
1895
  case "cancel_print":
@@ -1442,10 +1900,19 @@ class ThreeDPrinterMCPServer {
1442
1900
  if (!args?.component || args?.temperature === undefined) {
1443
1901
  throw new Error("Missing required parameters: component and temperature");
1444
1902
  }
1903
+ // Never coerce: "not-a-number" must not become NaN in a heater command.
1904
+ const requestedTemperature = optionalTemperatureArg(args.temperature, "temperature")!;
1905
+ const heatOptions: PrintSafetyOptions = { material: optionalMaterialArg(args.material) };
1906
+ if (type.toLowerCase() === "bambu" && requestedTemperature > 0) {
1907
+ heatOptions.bambuModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1908
+ heatOptions.nozzleDiameter =
1909
+ optionalNozzleDiameterArg(args.nozzle_diameter) ?? Number(DEFAULT_NOZZLE_DIAMETER);
1910
+ }
1445
1911
  result = await this.setPrinterTemperature(
1446
1912
  host, port, type, apiKey, bambuSerial, bambuToken,
1447
- String(args.component),
1448
- Number(args.temperature)
1913
+ String(args.component),
1914
+ requestedTemperature,
1915
+ heatOptions
1449
1916
  );
1450
1917
  break;
1451
1918
 
@@ -1460,33 +1927,47 @@ class ThreeDPrinterMCPServer {
1460
1927
  );
1461
1928
  break;
1462
1929
 
1463
- case "slice_stl": {
1464
- if (!args?.stl_path) {
1465
- throw new Error("Missing required parameter: stl_path");
1930
+ case "slice_stl":
1931
+ result = await this.runSliceTool("slice_stl", args as Record<string, unknown> | undefined);
1932
+ break;
1933
+
1934
+ case "slice_with_template":
1935
+ if (!args?.template_name || !String(args.template_name).trim()) {
1936
+ throw new Error("Missing required parameter: template_name");
1466
1937
  }
1467
- // Resolve printer model for BambuStudio slicer
1468
- let slicePreset: string | undefined;
1469
- if (isBambuProjectSlicer(slicerType)) {
1470
- const sliceModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1471
- const nozzleDiam = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
1472
- slicePreset = BAMBU_MODEL_PRESETS[sliceModel]?.(nozzleDiam);
1938
+ result = await this.runSliceTool("slice_with_template", args as Record<string, unknown> | undefined);
1939
+ break;
1940
+
1941
+ case "list_templates":
1942
+ result = listTemplateRegistry(typeof args?.template_dir === "string" ? args.template_dir : undefined);
1943
+ break;
1944
+
1945
+ case "save_template":
1946
+ if (!args?.source_path) {
1947
+ throw new Error("Missing required parameter: source_path");
1473
1948
  }
1474
- result = await this.stlManipulator.sliceSTL(
1475
- String(args.stl_path),
1476
- slicerType,
1477
- this.resolveExecutableSelectorArg(
1478
- args?.slicer_path,
1479
- "slice_stl",
1480
- "slicer_path"
1481
- ) ?? DEFAULT_SLICER_PATH,
1482
- slicerProfile || undefined,
1483
- undefined, // progressCallback
1484
- slicePreset,
1485
- filamentProfile || undefined
1949
+ result = saveTemplate(
1950
+ String(args.source_path),
1951
+ typeof args?.template_name === "string" ? args.template_name : undefined,
1952
+ typeof args?.template_dir === "string" ? args.template_dir : undefined
1486
1953
  );
1487
1954
  break;
1955
+
1956
+ case "get_slice_settings": {
1957
+ if (!args?.source_path && !args?.template_name) {
1958
+ throw new Error("Missing required parameter: source_path or template_name");
1959
+ }
1960
+ const settingsSource = args?.source_path
1961
+ ? String(args.source_path)
1962
+ : resolveTemplatePath(
1963
+ String(args.template_name),
1964
+ typeof args?.template_dir === "string" ? args.template_dir : undefined,
1965
+ ["3mf", "json", "config"]
1966
+ )!;
1967
+ result = await inspectSliceSettings(settingsSource, TEMP_DIR);
1968
+ break;
1488
1969
  }
1489
-
1970
+
1490
1971
  case "confirm_temperatures":
1491
1972
  if (!args?.gcode_path) {
1492
1973
  throw new Error("Missing required parameter: gcode_path");
@@ -1505,26 +1986,36 @@ class ThreeDPrinterMCPServer {
1505
1986
  throw new Error("Missing required parameters: stl_path and extension_inches");
1506
1987
  }
1507
1988
 
1989
+ // Validate every safety input before extending, slicing or connecting.
1990
+ const expectedPeaks: ExpectedPeakTemperatures = {
1991
+ nozzle: optionalTemperatureArg(args.extruder_temp, "extruder_temp"),
1992
+ bed: optionalTemperatureArg(args.bed_temp, "bed_temp"),
1993
+ };
1994
+ validateExpectedPeaks(expectedPeaks);
1995
+ const processMaterial = optionalMaterialArg(args?.material);
1996
+ let processModel: string | undefined;
1997
+ let processNozzle = String(DEFAULT_NOZZLE_DIAMETER);
1998
+ if (type.toLowerCase() === 'bambu' || isBambuProjectSlicer(slicerType)) {
1999
+ processModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
2000
+ processNozzle = String(optionalNozzleDiameterArg(args?.nozzle_diameter) ?? DEFAULT_NOZZLE_DIAMETER);
2001
+ }
2002
+
1508
2003
  // Define progress callback for UI updates
1509
2004
  const processProgressCallback = (progress: number, message?: string) => {
1510
- console.log(`Process progress: ${progress}% - ${message || ''}`);
2005
+ console.error(`Process progress: ${progress}% - ${message || ''}`);
1511
2006
  };
1512
-
2007
+
1513
2008
  // 1. Extend the base of the STL file
1514
2009
  const extendedStlPath = await this.stlManipulator.extendBase(
1515
2010
  String(args.stl_path),
1516
2011
  Number(args.extension_inches),
1517
2012
  processProgressCallback
1518
2013
  );
1519
-
2014
+
1520
2015
  // 2. Slice the extended STL file
1521
2016
  let processPreset: string | undefined;
1522
- if (type.toLowerCase() === 'bambu' || isBambuProjectSlicer(slicerType)) {
1523
- const processModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1524
- const processNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
1525
- if (isBambuProjectSlicer(slicerType)) {
1526
- processPreset = BAMBU_MODEL_PRESETS[processModel]?.(processNozzle);
1527
- }
2017
+ if (processModel && isBambuProjectSlicer(slicerType)) {
2018
+ processPreset = BAMBU_MODEL_PRESETS[processModel]?.(processNozzle);
1528
2019
  }
1529
2020
  const gcodePath = await this.stlManipulator.sliceSTL(
1530
2021
  extendedStlPath,
@@ -1537,7 +2028,14 @@ class ThreeDPrinterMCPServer {
1537
2028
  slicerProfile || undefined,
1538
2029
  processProgressCallback,
1539
2030
  processPreset,
1540
- filamentProfile || undefined
2031
+ filamentProfile || undefined,
2032
+ // Slice for the plate and nozzle the print gate will check against.
2033
+ processPreset
2034
+ ? {
2035
+ bedType: resolveBedType(args?.bed_type as string | undefined),
2036
+ ...(resolveNozzleType(args?.nozzle_type) ? { nozzleType: resolveNozzleType(args?.nozzle_type) } : {}),
2037
+ }
2038
+ : undefined
1541
2039
  );
1542
2040
 
1543
2041
  if (type.toLowerCase() === 'bambu' && gcodePath.toLowerCase().endsWith(".3mf")) {
@@ -1568,6 +2066,9 @@ class ThreeDPrinterMCPServer {
1568
2066
  result = await factoryImplementation.print3mf(host, bambuSerial, bambuToken, {
1569
2067
  projectName: path.basename(gcodePath).replace(/\.3mf$/i, ''),
1570
2068
  filePath: gcodePath,
2069
+ bambuModel: processModel,
2070
+ nozzleDiameters: [Number(processNozzle)],
2071
+ expectedPeaks,
1571
2072
  plateIndex: 0,
1572
2073
  ...amsOptions,
1573
2074
  bedType: printBedType,
@@ -1590,38 +2091,26 @@ class ThreeDPrinterMCPServer {
1590
2091
  );
1591
2092
  }
1592
2093
 
1593
- // 3. Confirm temperatures if specified
1594
- if (args.extruder_temp !== undefined || args.bed_temp !== undefined) {
1595
- const tempConfirmation = await this.stlManipulator.confirmTemperatures(
1596
- gcodePath,
1597
- {
1598
- extruder: args.extruder_temp !== undefined ? Number(args.extruder_temp) : undefined,
1599
- bed: args.bed_temp !== undefined ? Number(args.bed_temp) : undefined
1600
- },
1601
- processProgressCallback
1602
- );
1603
-
1604
- if (!tempConfirmation.match) {
1605
- console.warn("Temperature mismatch:", tempConfirmation);
1606
- }
1607
- }
1608
-
1609
- // 4. Upload the G-code file to the printer
1610
- const gcodeContent = await fs.promises.readFile(gcodePath, 'utf8');
2094
+ // 3. Upload and print through the adapter's hard safety gate. It
2095
+ // inspects the exact bytes sent (S and R targets, tool changes,
2096
+ // hardware and material ceilings), requires the expected peaks to
2097
+ // match, reads fresh printer state and asks for human confirmation.
2098
+ // A mismatch refuses before upload; nothing is only logged.
1611
2099
  const filename = path.basename(gcodePath);
1612
-
1613
- await this.uploadGcode(
2100
+ const printResult = await this.uploadGcode(
1614
2101
  host, port, type, apiKey, bambuSerial, bambuToken,
1615
- filename,
1616
- gcodeContent,
1617
- true // Start printing immediately
2102
+ filename,
2103
+ gcodePath,
2104
+ true, // Start printing immediately
2105
+ { bambuModel: processModel, material: processMaterial, expectedPeaks }
1618
2106
  );
1619
-
2107
+
1620
2108
  result = {
1621
2109
  extended_stl_path: extendedStlPath,
1622
2110
  gcode_path: gcodePath,
1623
2111
  filename,
1624
- status: "Print job started"
2112
+ status: "Checked print job started",
2113
+ print: printResult,
1625
2114
  };
1626
2115
  break;
1627
2116
 
@@ -1641,7 +2130,7 @@ class ThreeDPrinterMCPServer {
1641
2130
 
1642
2131
  // Define progress callback for UI updates
1643
2132
  const scaleProgressCallback = (progress: number, message?: string) => {
1644
- console.log(`Scale progress: ${progress}% - ${message || ''}`);
2133
+ console.error(`Scale progress: ${progress}% - ${message || ''}`);
1645
2134
  };
1646
2135
 
1647
2136
  let scaleFactors: number | [number, number, number];
@@ -1673,7 +2162,7 @@ class ThreeDPrinterMCPServer {
1673
2162
 
1674
2163
  // Define progress callback for UI updates
1675
2164
  const rotateProgressCallback = (progress: number, message?: string) => {
1676
- console.log(`Rotate progress: ${progress}% - ${message || ''}`);
2165
+ console.error(`Rotate progress: ${progress}% - ${message || ''}`);
1677
2166
  };
1678
2167
 
1679
2168
  // Get rotation angles, defaulting to 0 for any undefined axis
@@ -1697,7 +2186,7 @@ class ThreeDPrinterMCPServer {
1697
2186
 
1698
2187
  // Define progress callback for UI updates
1699
2188
  const translateProgressCallback = (progress: number, message?: string) => {
1700
- console.log(`Translate progress: ${progress}% - ${message || ''}`);
2189
+ console.error(`Translate progress: ${progress}% - ${message || ''}`);
1701
2190
  };
1702
2191
 
1703
2192
  // Get translation values, defaulting to 0 for any undefined axis
@@ -1721,7 +2210,7 @@ class ThreeDPrinterMCPServer {
1721
2210
 
1722
2211
  // Define progress callback for UI updates
1723
2212
  const modifySectionProgressCallback = (progress: number, message?: string) => {
1724
- console.log(`Modify section progress: ${progress}% - ${message || ''}`);
2213
+ console.error(`Modify section progress: ${progress}% - ${message || ''}`);
1725
2214
  };
1726
2215
 
1727
2216
  // Determine the section to modify
@@ -1798,7 +2287,7 @@ class ThreeDPrinterMCPServer {
1798
2287
 
1799
2288
  // Define progress callback for UI updates
1800
2289
  const visualizationProgressCallback = (progress: number, message?: string) => {
1801
- console.log(`Visualization progress: ${progress}% - ${message || ''}`);
2290
+ console.error(`Visualization progress: ${progress}% - ${message || ''}`);
1802
2291
  };
1803
2292
 
1804
2293
  // Get width and height parameters, with defaults
@@ -1826,21 +2315,26 @@ class ThreeDPrinterMCPServer {
1826
2315
 
1827
2316
  const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1828
2317
  const printBedType = resolveBedType(args?.bed_type as string | undefined);
1829
- const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2318
+ const explicitPrintNozzle = optionalNozzleDiameterArg(args?.nozzle_diameter);
2319
+ const printNozzle = String(explicitPrintNozzle ?? DEFAULT_NOZZLE_DIAMETER);
1830
2320
  const printPreset = BAMBU_MODEL_PRESETS[printModel]?.(printNozzle);
1831
2321
 
1832
2322
  let threeMFPath = String(args.three_mf_path);
1833
2323
 
2324
+ // Only the selected plate's exact G-code entry counts as sliced output;
2325
+ // a stray .gcode entry elsewhere in the archive is not printable.
2326
+ const selectedPlateEntry = "Metadata/plate_1.gcode";
1834
2327
  let shouldAutoSlice = false;
1835
2328
  try {
1836
2329
  const JSZip = (await import('jszip')).default;
1837
2330
  const zipData = fs.readFileSync(threeMFPath);
1838
2331
  const zip = await JSZip.loadAsync(zipData);
1839
- shouldAutoSlice = !Object.keys(zip.files).some(
1840
- f => f.match(/Metadata\/plate_\d+\.gcode/i) || f.endsWith('.gcode')
1841
- );
2332
+ const plateEntry = zip.file(selectedPlateEntry);
2333
+ shouldAutoSlice = !plateEntry || plateEntry.dir;
1842
2334
  } catch (sliceCheckErr: any) {
1843
- console.warn("Could not check 3MF for embedded gcode, proceeding with original:", sliceCheckErr.message);
2335
+ throw new Error(
2336
+ `Cannot read 3MF archive ${threeMFPath}; nothing was uploaded or started: ${sliceCheckErr?.message ?? String(sliceCheckErr)}`
2337
+ );
1844
2338
  }
1845
2339
 
1846
2340
  // Only treat slicer_path as an executable selector when a slicer
@@ -1853,7 +2347,7 @@ class ThreeDPrinterMCPServer {
1853
2347
  "slicer_path"
1854
2348
  ) ?? DEFAULT_SLICER_PATH;
1855
2349
  try {
1856
- console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
2350
+ console.error(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
1857
2351
  threeMFPath = await this.stlManipulator.sliceSTL(
1858
2352
  threeMFPath,
1859
2353
  slicerType,
@@ -1861,13 +2355,26 @@ class ThreeDPrinterMCPServer {
1861
2355
  slicerProfile || undefined,
1862
2356
  undefined, // progressCallback
1863
2357
  printPreset,
1864
- filamentProfile || undefined
2358
+ filamentProfile || undefined,
2359
+ // Slice for the plate and nozzle the print will be checked against.
2360
+ { bedType: printBedType, ...(resolveNozzleType(args?.nozzle_type) ? { nozzleType: resolveNozzleType(args?.nozzle_type) } : {}) }
1865
2361
  );
1866
- console.log("Auto-sliced to: " + threeMFPath);
2362
+ console.error("Auto-sliced to: " + threeMFPath);
1867
2363
  } catch (sliceErr: any) {
1868
- console.warn("Could not auto-slice 3MF, proceeding with original:", sliceErr.message);
2364
+ // Never fall back to uploading the original unsliced project.
2365
+ // Rethrow the original instance (for example a SlicerError) so
2366
+ // structured slicer suggestions survive.
2367
+ const prefix = "Auto-slicing failed; nothing was uploaded or started: ";
2368
+ if (sliceErr instanceof Error) {
2369
+ if (!sliceErr.message.startsWith(prefix)) sliceErr.message = `${prefix}${sliceErr.message}`;
2370
+ throw sliceErr;
2371
+ }
2372
+ throw new Error(`${prefix}${String(sliceErr)}`);
1869
2373
  }
1870
2374
  }
2375
+ // Compare the sliced file with an explicit or auto-slice nozzle request.
2376
+ const requestedPrintNozzles =
2377
+ explicitPrintNozzle !== undefined || shouldAutoSlice ? [Number(printNozzle)] : undefined;
1871
2378
 
1872
2379
  // Define variables needed outside the parse try block
1873
2380
  let implementation: BambuImplementation;
@@ -1878,7 +2385,7 @@ class ThreeDPrinterMCPServer {
1878
2385
  try {
1879
2386
  // --- Parse 3MF ---
1880
2387
  const parsed3MFData = await parse3MF(threeMFPath);
1881
- console.log(`Successfully parsed 3MF file: ${threeMFPath}`);
2388
+ console.error(`Successfully parsed 3MF file: ${threeMFPath}`);
1882
2389
  const amsOptions = this.resolveAmsPrintOptions(
1883
2390
  parsed3MFData,
1884
2391
  args as Record<string, unknown> | undefined
@@ -1896,6 +2403,8 @@ class ThreeDPrinterMCPServer {
1896
2403
 
1897
2404
  printOptions = { // Assign to outer scope variable
1898
2405
  ...amsOptions,
2406
+ bambuModel: printModel,
2407
+ nozzleDiameters: requestedPrintNozzles,
1899
2408
  bedType: printBedType,
1900
2409
  bedLeveling: args?.bed_leveling !== undefined ? Boolean(args.bed_leveling) : undefined,
1901
2410
  flowCalibration: args?.flow_calibration !== undefined ? Boolean(args.flow_calibration) : undefined,
@@ -1918,7 +2427,7 @@ class ThreeDPrinterMCPServer {
1918
2427
  plateIndex: 0,
1919
2428
  ...printOptions // Spread the final options
1920
2429
  });
1921
- result = `Print command for ${threeMfFilename} sent successfully.`;
2430
+ // Report what the printer did, not merely that a command was published.
1922
2431
  } catch (printError) {
1923
2432
  console.error(`Error starting 3MF print for ${threeMfFilename}:`, printError);
1924
2433
  throw new Error(`Failed to start print: ${(printError as Error).message}`);
@@ -1971,11 +2480,11 @@ class ThreeDPrinterMCPServer {
1971
2480
  throw new Error("Missing required parameter: method");
1972
2481
  }
1973
2482
 
2483
+ // Raw print methods, printer messages and unknown methods are refused
2484
+ // inside invokeFuluBridgeRpc before any bridge process starts.
1974
2485
  const method = String(args.method);
1975
- let rpcBambuModel: string | undefined;
1976
- if (isFuluPrintRpcMethod(method)) {
1977
- rpcBambuModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1978
- }
2486
+ const rpcBambuModel =
2487
+ typeof args?.bambu_model === "string" && args.bambu_model.trim() ? args.bambu_model.trim() : undefined;
1979
2488
 
1980
2489
  const payload =
1981
2490
  typeof args.payload === "object" && args.payload !== null && !Array.isArray(args.payload)
@@ -2030,6 +2539,10 @@ class ThreeDPrinterMCPServer {
2030
2539
  case "blender_mcp_call":
2031
2540
  return await this.blender.call(args ?? {}, extra.signal);
2032
2541
 
2542
+ case "blender_mcp_export_stl":
2543
+ result = await this.blender.exportStl(args ?? {}, extra.signal);
2544
+ break;
2545
+
2033
2546
  case "blender_mcp_edit_model":
2034
2547
  result = await this.blender.edit(
2035
2548
  args ?? {},
@@ -2060,7 +2573,7 @@ class ThreeDPrinterMCPServer {
2060
2573
  console.error(`Error calling tool ${name}:`, error);
2061
2574
 
2062
2575
  const errorMessage = error instanceof Error ? error.message : String(error);
2063
- const structuredError = this.toStructuredToolError(name, errorMessage);
2576
+ const structuredError = this.toStructuredToolError(name, errorMessage, error);
2064
2577
 
2065
2578
  return {
2066
2579
  content: [
@@ -2142,7 +2655,8 @@ class ThreeDPrinterMCPServer {
2142
2655
  bambuToken: string,
2143
2656
  filename: string,
2144
2657
  gcode: string,
2145
- print: boolean
2658
+ print: boolean,
2659
+ options: PrintSafetyOptions = {}
2146
2660
  ) {
2147
2661
  const sourceIsFilePath = fs.existsSync(gcode) && fs.statSync(gcode).isFile();
2148
2662
  // The remote filename must never select a local write/delete target.
@@ -2160,10 +2674,10 @@ class ThreeDPrinterMCPServer {
2160
2674
 
2161
2675
  if (type.toLowerCase() === "bambu") {
2162
2676
  const bambuApiKey = `${bambuSerial}:${bambuToken}`;
2163
- return await implementation.uploadFile(host, port, bambuApiKey, tempFilePath, filename, print);
2677
+ return await implementation.uploadFile(host, port, bambuApiKey, tempFilePath, filename, print, options);
2164
2678
  }
2165
2679
 
2166
- return await implementation.uploadFile(host, port, apiKey, tempFilePath, filename, print);
2680
+ return await implementation.uploadFile(host, port, apiKey, tempFilePath, filename, print, options);
2167
2681
  } finally {
2168
2682
  if (scratchDir) fs.rmSync(scratchDir, { recursive: true, force: true });
2169
2683
  }
@@ -2176,16 +2690,17 @@ class ThreeDPrinterMCPServer {
2176
2690
  apiKey: string,
2177
2691
  bambuSerial: string,
2178
2692
  bambuToken: string,
2179
- gcodeFilename: string
2693
+ gcodeFilename: string,
2694
+ options: PrintSafetyOptions = {}
2180
2695
  ) {
2181
2696
  const implementation = this.printerFactory.getImplementation(type);
2182
2697
 
2183
2698
  if (type.toLowerCase() === "bambu") {
2184
2699
  const bambuApiKey = `${bambuSerial}:${bambuToken}`;
2185
- return await implementation.startJob(host, port, bambuApiKey, gcodeFilename);
2700
+ return await implementation.startJob(host, port, bambuApiKey, gcodeFilename, options);
2186
2701
  }
2187
2702
 
2188
- return await implementation.startJob(host, port, apiKey, gcodeFilename);
2703
+ return await implementation.startJob(host, port, apiKey, gcodeFilename, options);
2189
2704
  }
2190
2705
 
2191
2706
  async cancelPrint(
@@ -2214,19 +2729,53 @@ class ThreeDPrinterMCPServer {
2214
2729
  bambuSerial: string,
2215
2730
  bambuToken: string,
2216
2731
  component: string,
2217
- temperature: number
2732
+ temperature: unknown,
2733
+ options: PrintSafetyOptions = {}
2218
2734
  ) {
2219
2735
  const implementation = this.printerFactory.getImplementation(type);
2220
2736
 
2221
2737
  if (type.toLowerCase() === "bambu") {
2222
2738
  const bambuApiKey = `${bambuSerial}:${bambuToken}`;
2223
- return implementation.setTemperature(host, port, bambuApiKey, component, temperature);
2739
+ return implementation.setTemperature(host, port, bambuApiKey, component, temperature, options);
2224
2740
  }
2225
2741
 
2226
- return implementation.setTemperature(host, port, apiKey, component, temperature);
2742
+ return implementation.setTemperature(host, port, apiKey, component, temperature, options);
2227
2743
  }
2228
2744
 
2229
- private toStructuredToolError(tool: string, message: string): StructuredToolError {
2745
+ private toStructuredToolError(tool: string, message: string, error?: unknown): StructuredToolError {
2746
+ // Slicer failures are local profile/slicer problems, never printer connectivity.
2747
+ if (error instanceof SlicerError) {
2748
+ return {
2749
+ status: "error",
2750
+ retryable: false,
2751
+ suggestion: error.suggestion,
2752
+ message,
2753
+ tool,
2754
+ slicer: { phase: error.phase, ...error.details },
2755
+ };
2756
+ }
2757
+ if (SLICING_TOOLS.has(tool)) {
2758
+ return {
2759
+ status: "error",
2760
+ retryable: false,
2761
+ suggestion: "Check the slicing arguments, template, and profile paths named in the error, then retry.",
2762
+ message,
2763
+ tool,
2764
+ };
2765
+ }
2766
+ if (isCredentialRejection(message)) {
2767
+ return { status: "error", retryable: false, suggestion: credentialSuggestion(), message, tool };
2768
+ }
2769
+ // Safety refusals stop before the printer is touched; retrying unchanged repeats the refusal.
2770
+ if (/nothing was (?:sent|uploaded)|no command was sent|print safety|hardware limit|material policy|declared material|confirmation was declined|are refused|is refused|refused because|refused until|refused before|does not match the job|is ambiguous|is missing or ambiguous|rejected the print command/i.test(message)) {
2771
+ return {
2772
+ status: "error",
2773
+ retryable: false,
2774
+ suggestion: "A safety check stopped this before anything reached the printer. Read the reason, fix the file, arguments, or printer state, and ask again.",
2775
+ message,
2776
+ tool,
2777
+ };
2778
+ }
2230
2779
  const isInputError =
2231
2780
  message.toLowerCase().includes("missing required") ||
2232
2781
  message.toLowerCase().includes("invalid") ||