mcp-3d-printer-server 1.2.5 → 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 (87) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/CONTRIBUTORS.md +49 -0
  3. package/README.md +529 -739
  4. package/dist/3mf_parser.js +8 -8
  5. package/dist/blender-mcp-bridge.d.ts +11 -0
  6. package/dist/blender-mcp-bridge.js +661 -0
  7. package/dist/fulu-orca.js +29 -5
  8. package/dist/index.js +752 -212
  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 -120
  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 +30 -14
  58. package/server.json +29 -0
  59. package/src/3mf_parser.ts +8 -8
  60. package/src/blender-mcp-bridge.ts +611 -0
  61. package/src/fulu-orca.ts +36 -6
  62. package/src/index.ts +888 -255
  63. package/src/printers/bambu-node-guard.ts +44 -0
  64. package/src/printers/bambu.ts +458 -158
  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
  87. package/dist/index.js.map +0 -1
package/src/index.ts CHANGED
@@ -15,18 +15,29 @@ import axios from "axios";
15
15
  import dotenv from "dotenv";
16
16
  import fs from "fs";
17
17
  import path from "path";
18
+ import os from "node:os";
19
+ import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
18
20
  import { createServer as createHttpServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from "node:http";
19
21
  import { randomUUID } from "node:crypto";
20
22
  import * as THREE from 'three';
21
23
  import { PrinterFactory } from "./printers/printer-factory.js";
22
- 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";
23
33
  import { parse3MF, ThreeMFData } from './3mf_parser.js';
24
34
  import { BambuImplementation } from "./printers/bambu.js";
25
35
  import {
26
36
  inspectFuluOrcaSetup,
27
37
  invokeFuluBridgeRpc,
28
- isFuluPrintRpcMethod,
29
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";
30
41
 
31
42
  // Load environment variables from .env file
32
43
  dotenv.config();
@@ -36,7 +47,7 @@ const DEFAULT_HOST = process.env.PRINTER_HOST || "localhost";
36
47
  const DEFAULT_PORT = process.env.PRINTER_PORT || "80";
37
48
  const DEFAULT_API_KEY = process.env.API_KEY || "";
38
49
  const DEFAULT_TYPE = process.env.PRINTER_TYPE || "octoprint"; // Default to OctoPrint
39
- const TEMP_DIR = process.env.TEMP_DIR || path.join(process.cwd(), "temp");
50
+ const TEMP_DIR = process.env.TEMP_DIR || fs.mkdtempSync(path.join(os.tmpdir(), "mcp-3d-printer-server-"));
40
51
 
41
52
  // Slicer configuration
42
53
  const CANONICAL_SLICER_TYPES = [
@@ -166,6 +177,211 @@ const BAMBU_MODEL_PRESETS: Record<string, (nozzle: string) => string> = {
166
177
  h2d: (n) => `Bambu Lab H2D ${n} nozzle`,
167
178
  };
168
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
+
169
385
  function validateBambuModel(model: string): BambuModel {
170
386
  const normalized = model.trim().toLowerCase();
171
387
  if (!(VALID_BAMBU_MODELS as readonly string[]).includes(normalized)) {
@@ -186,6 +402,35 @@ function resolveBedType(bedType: string | undefined): string {
186
402
  return resolved;
187
403
  }
188
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
+
189
434
  type RuntimeConfig = {
190
435
  transport: "stdio" | "streamable-http";
191
436
  httpHost: string;
@@ -195,6 +440,8 @@ type RuntimeConfig = {
195
440
  enableJsonResponse: boolean;
196
441
  allowedOrigins: Set<string>;
197
442
  blenderBridgeCommand?: string;
443
+ allowExecutableArg: boolean;
444
+ allowBridgeCommandArg: boolean;
198
445
  };
199
446
 
200
447
  function parseBooleanEnv(rawValue: string | undefined, fallback: boolean): boolean {
@@ -259,6 +506,8 @@ function readRuntimeConfig(): RuntimeConfig {
259
506
  enableJsonResponse: parseBooleanEnv(process.env.MCP_HTTP_JSON_RESPONSE, true),
260
507
  allowedOrigins: parseCsvEnv(process.env.MCP_HTTP_ALLOWED_ORIGINS),
261
508
  blenderBridgeCommand: process.env.BLENDER_MCP_BRIDGE_COMMAND?.trim() || undefined,
509
+ allowExecutableArg: parseBooleanEnv(process.env.MCP_ALLOW_EXECUTABLE_ARG, false),
510
+ allowBridgeCommandArg: parseBooleanEnv(process.env.MCP_ALLOW_BRIDGE_COMMAND_ARG, false),
262
511
  };
263
512
  }
264
513
 
@@ -268,15 +517,32 @@ type StructuredToolError = {
268
517
  suggestion: string;
269
518
  message: string;
270
519
  tool: string;
520
+ slicer?: Record<string, unknown>;
271
521
  };
272
522
 
523
+ const SLICING_TOOLS = new Set(["slice_stl", "slice_with_template", "list_templates", "save_template", "get_slice_settings"]);
524
+
273
525
  // Ensure temp directory exists
274
526
  if (!fs.existsSync(TEMP_DIR)) {
275
527
  fs.mkdirSync(TEMP_DIR, { recursive: true });
276
528
  }
277
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
+
278
543
  class ThreeDPrinterMCPServer {
279
544
  private server: Server;
545
+ private readonly blender = new BlenderMcpBridge();
280
546
  private printerFactory: PrinterFactory;
281
547
  private stlManipulator: STLManipulator;
282
548
  private readonly runtimeConfig: RuntimeConfig;
@@ -287,7 +553,7 @@ class ThreeDPrinterMCPServer {
287
553
  this.server = new Server(
288
554
  {
289
555
  name: "mcp-3d-printer-server",
290
- version: "1.2.0"
556
+ version: JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
291
557
  },
292
558
  {
293
559
  capabilities: {
@@ -297,13 +563,37 @@ class ThreeDPrinterMCPServer {
297
563
  }
298
564
  );
299
565
 
300
- this.printerFactory = new PrinterFactory();
566
+ this.printerFactory = new PrinterFactory((message) => this.confirmHardware(message));
301
567
  this.stlManipulator = new STLManipulator(TEMP_DIR);
302
568
 
303
569
  this.setupHandlers();
304
570
  this.setupErrorHandling();
305
571
  }
306
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
+
307
597
  private async resolveBambuModel(argsModel: string | undefined): Promise<string> {
308
598
  const fromArgs = (argsModel || DEFAULT_BAMBU_MODEL).trim().toLowerCase();
309
599
  if (fromArgs) {
@@ -337,7 +627,7 @@ class ThreeDPrinterMCPServer {
337
627
  },
338
628
  required: ["bambu_model"],
339
629
  },
340
- });
630
+ }, { timeout: confirmationTimeoutMs() });
341
631
 
342
632
  if (result.action === "accept" && result.content?.bambu_model) {
343
633
  return validateBambuModel(String(result.content.bambu_model));
@@ -348,6 +638,12 @@ class ThreeDPrinterMCPServer {
348
638
  );
349
639
  } catch (elicitError: any) {
350
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
+ }
351
647
  if (
352
648
  elicitError?.code === -32601 || elicitError?.code === -32600 ||
353
649
  msg.includes("does not support") || msg.includes("elicitation")
@@ -362,6 +658,79 @@ class ThreeDPrinterMCPServer {
362
658
  }
363
659
  }
364
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
+
365
734
  setupErrorHandling() {
366
735
  this.server.onerror = (error) => {
367
736
  console.error("[MCP Error]", error);
@@ -581,7 +950,7 @@ class ThreeDPrinterMCPServer {
581
950
  },
582
951
  {
583
952
  name: "upload_gcode",
584
- 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.",
585
954
  inputSchema: {
586
955
  type: "object",
587
956
  properties: {
@@ -609,6 +978,11 @@ class ThreeDPrinterMCPServer {
609
978
  type: "string",
610
979
  description: "Access token for Bambu Lab printers (default: value from env)"
611
980
  },
981
+ bambu_model: {
982
+ type: "string",
983
+ enum: [...VALID_BAMBU_MODELS],
984
+ description: "Required for Bambu print operations unless BAMBU_MODEL is configured. Must match the printer and pre-sliced G-code."
985
+ },
612
986
  filename: {
613
987
  type: "string",
614
988
  description: "Filename to use on the printer. Defaults to the basename of gcode_path when omitted."
@@ -623,18 +997,18 @@ class ThreeDPrinterMCPServer {
623
997
  },
624
998
  print: {
625
999
  type: "boolean",
626
- 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."
627
1005
  }
628
- },
629
- anyOf: [
630
- { required: ["filename", "gcode"] },
631
- { required: ["gcode_path"] }
632
- ]
1006
+ }
633
1007
  }
634
1008
  },
635
1009
  {
636
1010
  name: "start_print",
637
- 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.",
638
1012
  inputSchema: {
639
1013
  type: "object",
640
1014
  properties: {
@@ -662,9 +1036,18 @@ class ThreeDPrinterMCPServer {
662
1036
  type: "string",
663
1037
  description: "Access token for Bambu Lab printers (default: value from env)"
664
1038
  },
1039
+ bambu_model: {
1040
+ type: "string",
1041
+ enum: [...VALID_BAMBU_MODELS],
1042
+ description: "Required for Bambu print operations unless BAMBU_MODEL is configured. Must match the printer and pre-sliced G-code."
1043
+ },
665
1044
  filename: {
666
1045
  type: "string",
667
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."
668
1051
  }
669
1052
  },
670
1053
  required: ["filename"]
@@ -705,7 +1088,7 @@ class ThreeDPrinterMCPServer {
705
1088
  },
706
1089
  {
707
1090
  name: "set_printer_temperature",
708
- 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.",
709
1092
  inputSchema: {
710
1093
  type: "object",
711
1094
  properties: {
@@ -739,7 +1122,20 @@ class ThreeDPrinterMCPServer {
739
1122
  },
740
1123
  temperature: {
741
1124
  type: "number",
742
- 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."
743
1139
  }
744
1140
  },
745
1141
  required: ["component", "temperature"]
@@ -766,7 +1162,11 @@ class ThreeDPrinterMCPServer {
766
1162
  },
767
1163
  {
768
1164
  name: "slice_stl",
769
- 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.",
770
1170
  inputSchema: {
771
1171
  type: "object",
772
1172
  properties: {
@@ -774,39 +1174,74 @@ class ThreeDPrinterMCPServer {
774
1174
  type: "string",
775
1175
  description: "Path to the STL or 3MF file to slice"
776
1176
  },
777
- bambu_model: {
778
- type: "string",
779
- enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
780
- description: "Bambu Lab printer model - required when using Bambu project slicers to ensure correct G-code."
781
- },
782
- nozzle_diameter: {
783
- type: "string",
784
- description: "Nozzle diameter in mm (default: 0.4)."
785
- },
786
- slicer_type: {
787
- type: "string",
788
- enum: [...SLICER_TYPE_ENUM],
789
- description: "Type of slicer to use (prusaslicer, cura, slic3r, orcaslicer, orcaslicer-bambulab, bambustudio). Use orcaslicer-bambulab for the FULU fork."
790
- },
791
- slicer_path: {
792
- type: "string",
793
- description: "Path to the slicer executable (default: value from env)"
794
- },
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)." },
795
1193
  slicer_profile: {
796
1194
  type: "string",
797
- 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."
798
1196
  },
799
- 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: {
800
1208
  type: "string",
801
- 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."
802
1225
  }
803
1226
  },
804
- 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
+ }
805
1240
  }
806
1241
  },
807
1242
  {
808
1243
  name: "confirm_temperatures",
809
- 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.",
810
1245
  inputSchema: {
811
1246
  type: "object",
812
1247
  properties: {
@@ -816,11 +1251,11 @@ class ThreeDPrinterMCPServer {
816
1251
  },
817
1252
  extruder_temp: {
818
1253
  type: "number",
819
- description: "Expected extruder temperature"
1254
+ description: "Expected highest nozzle target"
820
1255
  },
821
1256
  bed_temp: {
822
1257
  type: "number",
823
- description: "Expected bed temperature"
1258
+ description: "Expected highest bed target"
824
1259
  }
825
1260
  },
826
1261
  required: ["gcode_path"]
@@ -828,7 +1263,7 @@ class ThreeDPrinterMCPServer {
828
1263
  },
829
1264
  {
830
1265
  name: "process_and_print_stl",
831
- 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.",
832
1267
  inputSchema: {
833
1268
  type: "object",
834
1269
  properties: {
@@ -842,11 +1277,15 @@ class ThreeDPrinterMCPServer {
842
1277
  },
843
1278
  extruder_temp: {
844
1279
  type: "number",
845
- 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."
846
1281
  },
847
1282
  bed_temp: {
848
1283
  type: "number",
849
- 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."
850
1289
  },
851
1290
  host: {
852
1291
  type: "string",
@@ -874,6 +1313,11 @@ class ThreeDPrinterMCPServer {
874
1313
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"],
875
1314
  description: "Bed/plate type installed on the printer (default: textured_plate)."
876
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
+ },
877
1321
  nozzle_diameter: {
878
1322
  type: "string",
879
1323
  description: "Nozzle diameter in mm (default: 0.4)."
@@ -885,7 +1329,7 @@ class ThreeDPrinterMCPServer {
885
1329
  },
886
1330
  slicer_path: {
887
1331
  type: "string",
888
- description: "Path to the slicer executable (default: value from env)."
1332
+ description: "Path to the slicer executable (default: value from env). Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1."
889
1333
  },
890
1334
  slicer_profile: {
891
1335
  type: "string",
@@ -1080,7 +1524,7 @@ class ThreeDPrinterMCPServer {
1080
1524
  },
1081
1525
  {
1082
1526
  name: "print_3mf",
1083
- 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.",
1084
1528
  inputSchema: {
1085
1529
  type: "object",
1086
1530
  properties: {
@@ -1098,6 +1542,11 @@ class ThreeDPrinterMCPServer {
1098
1542
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"],
1099
1543
  description: "Bed/plate type installed on the printer (default: textured_plate)."
1100
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
+ },
1101
1550
  nozzle_diameter: {
1102
1551
  type: "string",
1103
1552
  description: "Nozzle diameter in mm (default: 0.4)."
@@ -1130,7 +1579,7 @@ class ThreeDPrinterMCPServer {
1130
1579
  },
1131
1580
  slicer_path: {
1132
1581
  type: "string",
1133
- description: "Path to the slicer executable if auto-slicing is needed."
1582
+ description: "Path to the slicer executable if auto-slicing is needed. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1."
1134
1583
  },
1135
1584
  slicer_profile: {
1136
1585
  type: "string",
@@ -1177,15 +1626,23 @@ class ThreeDPrinterMCPServer {
1177
1626
  properties: {
1178
1627
  slicer_path: {
1179
1628
  type: "string",
1180
- description: "Path to the FULU OrcaSlicer executable. Defaults from SLICER_PATH/FULU_ORCA_PATH."
1629
+ description:
1630
+ "Path to the FULU OrcaSlicer executable. Defaults from SLICER_PATH/FULU_ORCA_PATH. " +
1631
+ "When run_bridge_probe=true, requires MCP_ALLOW_EXECUTABLE_ARG=1 to be accepted here."
1181
1632
  },
1182
1633
  plugin_dir: {
1183
1634
  type: "string",
1184
- description: "Directory containing the FULU Bambu runtime payload; on macOS this is usually OrcaSlicer.app/Contents/MacOS."
1635
+ description:
1636
+ "Directory containing the FULU Bambu runtime payload; on macOS this is usually " +
1637
+ "OrcaSlicer.app/Contents/MacOS. When run_bridge_probe=true, requires " +
1638
+ "MCP_ALLOW_EXECUTABLE_ARG=1 to be accepted here."
1185
1639
  },
1186
1640
  runtime_dir: {
1187
1641
  type: "string",
1188
- description: "Installed runtime directory. On macOS this defaults to ~/Library/Application Support/OrcaSlicer/macos-bridge/runtime."
1642
+ description:
1643
+ "Installed runtime directory. On macOS this defaults to ~/Library/Application " +
1644
+ "Support/OrcaSlicer/macos-bridge/runtime. When run_bridge_probe=true, requires " +
1645
+ "MCP_ALLOW_EXECUTABLE_ARG=1 to be accepted here."
1189
1646
  },
1190
1647
  platform: {
1191
1648
  type: "string",
@@ -1194,7 +1651,10 @@ class ThreeDPrinterMCPServer {
1194
1651
  },
1195
1652
  bridge_command: {
1196
1653
  type: "string",
1197
- description: "Command that starts the FULU BambuNetwork bridge host for probing."
1654
+ description:
1655
+ "Command that starts the FULU BambuNetwork bridge host for probing. Read from " +
1656
+ "FULU_BAMBU_BRIDGE_COMMAND by default; requires MCP_ALLOW_EXECUTABLE_ARG=1 " +
1657
+ "to be accepted here."
1198
1658
  },
1199
1659
  run_bridge_probe: {
1200
1660
  type: "boolean",
@@ -1210,13 +1670,16 @@ class ThreeDPrinterMCPServer {
1210
1670
  {
1211
1671
  name: "fulu_bambu_network_rpc",
1212
1672
  description:
1213
- "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.",
1214
1674
  inputSchema: {
1215
1675
  type: "object",
1216
1676
  properties: {
1217
1677
  bridge_command: {
1218
1678
  type: "string",
1219
- description: "Command that starts the FULU BambuNetwork bridge host. Defaults to FULU_BAMBU_BRIDGE_COMMAND."
1679
+ description:
1680
+ "Command that starts the FULU BambuNetwork bridge host. Defaults to " +
1681
+ "FULU_BAMBU_BRIDGE_COMMAND; requires MCP_ALLOW_EXECUTABLE_ARG=1 to be " +
1682
+ "accepted here."
1220
1683
  },
1221
1684
  method: {
1222
1685
  type: "string",
@@ -1233,12 +1696,12 @@ class ThreeDPrinterMCPServer {
1233
1696
  },
1234
1697
  allow_mutating_method: {
1235
1698
  type: "boolean",
1236
- 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."
1237
1700
  },
1238
1701
  bambu_model: {
1239
1702
  type: "string",
1240
1703
  enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
1241
- 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."
1242
1705
  }
1243
1706
  },
1244
1707
  required: ["method"]
@@ -1290,31 +1753,73 @@ class ThreeDPrinterMCPServer {
1290
1753
  required: ["stl_path"]
1291
1754
  }
1292
1755
  },
1756
+ {
1757
+ name: "blender_mcp_status",
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.",
1759
+ inputSchema: {
1760
+ type: "object",
1761
+ properties: {
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)." },
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." }
1766
+ },
1767
+ additionalProperties: false
1768
+ }
1769
+ },
1770
+ {
1771
+ name: "blender_mcp_call",
1772
+ description: "Call a discovered tool on the configured Blender MCP server, preserving its full MCP content and errors. Discover tool schemas with blender_mcp_status first; execute_blender_code accepts Python code and user_prompt. Calls can modify the active Blender scene and are never automatically retried.",
1773
+ inputSchema: {
1774
+ type: "object",
1775
+ properties: {
1776
+ tool_name: { type: "string", description: "Exact name advertised by Blender MCP, such as get_scene_info or execute_blender_code." },
1777
+ arguments: { type: "object", description: "Arguments matching the remote tool's discovered input schema. Preserve the user's own words in user_prompt when the remote tool requests it." },
1778
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total connection, discovery, and tool deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." }
1779
+ },
1780
+ required: ["tool_name"],
1781
+ additionalProperties: false
1782
+ }
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
+ },
1293
1801
  {
1294
1802
  name: "blender_mcp_edit_model",
1295
- description: "Optionally send STL-edit instructions to a Blender MCP bridge command. Use for advanced model edits outside built-in STL tools.",
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.",
1296
1804
  inputSchema: {
1297
1805
  type: "object",
1298
1806
  properties: {
1299
- stl_path: {
1300
- type: "string",
1301
- description: "Path to the local STL file."
1302
- },
1807
+ stl_path: { type: "string", description: "Path to the local STL file" },
1303
1808
  operations: {
1304
1809
  type: "array",
1305
- description: "Ordered edit operations for Blender (e.g. remesh, boolean, decimate).",
1810
+ description: "Ordered operations: decimate:<ratio greater than 0 and at most 1>, remesh:<positive voxel size in STL units>, boolean_union:<STL path>. Legacy custom bridges define their own operations.",
1811
+ minItems: 1,
1812
+ maxItems: 64,
1306
1813
  items: { type: "string" }
1307
1814
  },
1308
- bridge_command: {
1309
- type: "string",
1310
- description: "Optional override command for invoking a local Blender MCP bridge."
1311
- },
1312
- execute: {
1313
- type: "boolean",
1314
- description: "When true, attempts to execute bridge command; otherwise returns a prepared payload only."
1315
- }
1815
+ output_path: { type: "string", description: "New local STL output path for standard MCP editing; defaults to a unique model-edited-<id>.stl beside the input. Its parent must exist and existing files are never overwritten. Reuse the preview's output_path when executing that plan." },
1816
+ user_prompt: { type: "string", description: "The user's own words describing the edit, passed unchanged to Blender MCP." },
1817
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total Blender request deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." },
1818
+ bridge_command: { type: "string", description: "Legacy custom bridge executable override, not a standard MCP command. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
1819
+ execute: { type: "boolean", description: "Apply edits and export (true) or validate and return the prepared request without connecting (false, default)." }
1316
1820
  },
1317
- required: ["stl_path", "operations"]
1821
+ required: ["stl_path", "operations"],
1822
+ additionalProperties: false
1318
1823
  }
1319
1824
  }
1320
1825
  ]
@@ -1322,7 +1827,7 @@ class ThreeDPrinterMCPServer {
1322
1827
  });
1323
1828
 
1324
1829
  // Handle tool calls
1325
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
1830
+ this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1326
1831
  const { name, arguments: args } = request.params;
1327
1832
 
1328
1833
  // Set default values for common parameters
@@ -1333,7 +1838,6 @@ class ThreeDPrinterMCPServer {
1333
1838
  const bambuSerial = String(args?.bambu_serial || DEFAULT_BAMBU_SERIAL);
1334
1839
  const bambuToken = String(args?.bambu_token || DEFAULT_BAMBU_TOKEN);
1335
1840
  const slicerType = normalizeSlicerType(String(args?.slicer_type || DEFAULT_SLICER_TYPE));
1336
- const slicerPath = String(args?.slicer_path || DEFAULT_SLICER_PATH);
1337
1841
  const slicerProfile = String(args?.slicer_profile || DEFAULT_SLICER_PROFILE);
1338
1842
  const filamentProfile = String(args?.filament_profile || DEFAULT_FILAMENT_PROFILE);
1339
1843
 
@@ -1359,13 +1863,21 @@ class ThreeDPrinterMCPServer {
1359
1863
  if (!args?.filename && !args?.gcode_path) {
1360
1864
  throw new Error("Missing required parameter: filename");
1361
1865
  }
1866
+ if (args.print !== undefined && typeof args.print !== "boolean") {
1867
+ throw new Error("print must be a boolean.");
1868
+ }
1869
+ const uploadOptions: PrintSafetyOptions = { material: optionalMaterialArg(args?.material) };
1870
+ if (type.toLowerCase() === "bambu" && args.print === true) {
1871
+ uploadOptions.bambuModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1872
+ }
1362
1873
  const uploadFilename = String(args?.filename || path.basename(String(args.gcode_path)));
1363
1874
  const uploadGcodeSource = String(args?.gcode_path || args.gcode);
1364
1875
  result = await this.uploadGcode(
1365
1876
  host, port, type, apiKey, bambuSerial, bambuToken,
1366
1877
  uploadFilename,
1367
1878
  uploadGcodeSource,
1368
- Boolean(args.print || false)
1879
+ Boolean(args.print || false),
1880
+ uploadOptions
1369
1881
  );
1370
1882
  break;
1371
1883
 
@@ -1373,7 +1885,11 @@ class ThreeDPrinterMCPServer {
1373
1885
  if (!args?.filename) {
1374
1886
  throw new Error("Missing required parameter: filename");
1375
1887
  }
1376
- result = await this.startPrint(host, port, type, apiKey, bambuSerial, bambuToken, String(args.filename));
1888
+ const startOptions: PrintSafetyOptions = { material: optionalMaterialArg(args?.material) };
1889
+ if (type.toLowerCase() === "bambu") {
1890
+ startOptions.bambuModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1891
+ }
1892
+ result = await this.startPrint(host, port, type, apiKey, bambuSerial, bambuToken, String(args.filename), startOptions);
1377
1893
  break;
1378
1894
 
1379
1895
  case "cancel_print":
@@ -1384,10 +1900,19 @@ class ThreeDPrinterMCPServer {
1384
1900
  if (!args?.component || args?.temperature === undefined) {
1385
1901
  throw new Error("Missing required parameters: component and temperature");
1386
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
+ }
1387
1911
  result = await this.setPrinterTemperature(
1388
1912
  host, port, type, apiKey, bambuSerial, bambuToken,
1389
- String(args.component),
1390
- Number(args.temperature)
1913
+ String(args.component),
1914
+ requestedTemperature,
1915
+ heatOptions
1391
1916
  );
1392
1917
  break;
1393
1918
 
@@ -1402,29 +1927,47 @@ class ThreeDPrinterMCPServer {
1402
1927
  );
1403
1928
  break;
1404
1929
 
1405
- case "slice_stl": {
1406
- if (!args?.stl_path) {
1407
- 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");
1408
1937
  }
1409
- // Resolve printer model for BambuStudio slicer
1410
- let slicePreset: string | undefined;
1411
- if (isBambuProjectSlicer(slicerType)) {
1412
- const sliceModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1413
- const nozzleDiam = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
1414
- 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");
1415
1948
  }
1416
- result = await this.stlManipulator.sliceSTL(
1417
- String(args.stl_path),
1418
- slicerType,
1419
- slicerPath,
1420
- slicerProfile || undefined,
1421
- undefined, // progressCallback
1422
- slicePreset,
1423
- 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
1424
1953
  );
1425
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;
1426
1969
  }
1427
-
1970
+
1428
1971
  case "confirm_temperatures":
1429
1972
  if (!args?.gcode_path) {
1430
1973
  throw new Error("Missing required parameter: gcode_path");
@@ -1443,35 +1986,56 @@ class ThreeDPrinterMCPServer {
1443
1986
  throw new Error("Missing required parameters: stl_path and extension_inches");
1444
1987
  }
1445
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
+
1446
2003
  // Define progress callback for UI updates
1447
2004
  const processProgressCallback = (progress: number, message?: string) => {
1448
- console.log(`Process progress: ${progress}% - ${message || ''}`);
2005
+ console.error(`Process progress: ${progress}% - ${message || ''}`);
1449
2006
  };
1450
-
2007
+
1451
2008
  // 1. Extend the base of the STL file
1452
2009
  const extendedStlPath = await this.stlManipulator.extendBase(
1453
2010
  String(args.stl_path),
1454
2011
  Number(args.extension_inches),
1455
2012
  processProgressCallback
1456
2013
  );
1457
-
2014
+
1458
2015
  // 2. Slice the extended STL file
1459
2016
  let processPreset: string | undefined;
1460
- if (type.toLowerCase() === 'bambu' || isBambuProjectSlicer(slicerType)) {
1461
- const processModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1462
- const processNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
1463
- if (isBambuProjectSlicer(slicerType)) {
1464
- processPreset = BAMBU_MODEL_PRESETS[processModel]?.(processNozzle);
1465
- }
2017
+ if (processModel && isBambuProjectSlicer(slicerType)) {
2018
+ processPreset = BAMBU_MODEL_PRESETS[processModel]?.(processNozzle);
1466
2019
  }
1467
2020
  const gcodePath = await this.stlManipulator.sliceSTL(
1468
2021
  extendedStlPath,
1469
2022
  slicerType,
1470
- slicerPath,
2023
+ this.resolveExecutableSelectorArg(
2024
+ args?.slicer_path,
2025
+ "process_and_print_stl",
2026
+ "slicer_path"
2027
+ ) ?? DEFAULT_SLICER_PATH,
1471
2028
  slicerProfile || undefined,
1472
2029
  processProgressCallback,
1473
2030
  processPreset,
1474
- 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
1475
2039
  );
1476
2040
 
1477
2041
  if (type.toLowerCase() === 'bambu' && gcodePath.toLowerCase().endsWith(".3mf")) {
@@ -1502,6 +2066,9 @@ class ThreeDPrinterMCPServer {
1502
2066
  result = await factoryImplementation.print3mf(host, bambuSerial, bambuToken, {
1503
2067
  projectName: path.basename(gcodePath).replace(/\.3mf$/i, ''),
1504
2068
  filePath: gcodePath,
2069
+ bambuModel: processModel,
2070
+ nozzleDiameters: [Number(processNozzle)],
2071
+ expectedPeaks,
1505
2072
  plateIndex: 0,
1506
2073
  ...amsOptions,
1507
2074
  bedType: printBedType,
@@ -1524,38 +2091,26 @@ class ThreeDPrinterMCPServer {
1524
2091
  );
1525
2092
  }
1526
2093
 
1527
- // 3. Confirm temperatures if specified
1528
- if (args.extruder_temp !== undefined || args.bed_temp !== undefined) {
1529
- const tempConfirmation = await this.stlManipulator.confirmTemperatures(
1530
- gcodePath,
1531
- {
1532
- extruder: args.extruder_temp !== undefined ? Number(args.extruder_temp) : undefined,
1533
- bed: args.bed_temp !== undefined ? Number(args.bed_temp) : undefined
1534
- },
1535
- processProgressCallback
1536
- );
1537
-
1538
- if (!tempConfirmation.match) {
1539
- console.warn("Temperature mismatch:", tempConfirmation);
1540
- }
1541
- }
1542
-
1543
- // 4. Upload the G-code file to the printer
1544
- 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.
1545
2099
  const filename = path.basename(gcodePath);
1546
-
1547
- await this.uploadGcode(
2100
+ const printResult = await this.uploadGcode(
1548
2101
  host, port, type, apiKey, bambuSerial, bambuToken,
1549
- filename,
1550
- gcodeContent,
1551
- true // Start printing immediately
2102
+ filename,
2103
+ gcodePath,
2104
+ true, // Start printing immediately
2105
+ { bambuModel: processModel, material: processMaterial, expectedPeaks }
1552
2106
  );
1553
-
2107
+
1554
2108
  result = {
1555
2109
  extended_stl_path: extendedStlPath,
1556
2110
  gcode_path: gcodePath,
1557
2111
  filename,
1558
- status: "Print job started"
2112
+ status: "Checked print job started",
2113
+ print: printResult,
1559
2114
  };
1560
2115
  break;
1561
2116
 
@@ -1575,7 +2130,7 @@ class ThreeDPrinterMCPServer {
1575
2130
 
1576
2131
  // Define progress callback for UI updates
1577
2132
  const scaleProgressCallback = (progress: number, message?: string) => {
1578
- console.log(`Scale progress: ${progress}% - ${message || ''}`);
2133
+ console.error(`Scale progress: ${progress}% - ${message || ''}`);
1579
2134
  };
1580
2135
 
1581
2136
  let scaleFactors: number | [number, number, number];
@@ -1607,7 +2162,7 @@ class ThreeDPrinterMCPServer {
1607
2162
 
1608
2163
  // Define progress callback for UI updates
1609
2164
  const rotateProgressCallback = (progress: number, message?: string) => {
1610
- console.log(`Rotate progress: ${progress}% - ${message || ''}`);
2165
+ console.error(`Rotate progress: ${progress}% - ${message || ''}`);
1611
2166
  };
1612
2167
 
1613
2168
  // Get rotation angles, defaulting to 0 for any undefined axis
@@ -1631,7 +2186,7 @@ class ThreeDPrinterMCPServer {
1631
2186
 
1632
2187
  // Define progress callback for UI updates
1633
2188
  const translateProgressCallback = (progress: number, message?: string) => {
1634
- console.log(`Translate progress: ${progress}% - ${message || ''}`);
2189
+ console.error(`Translate progress: ${progress}% - ${message || ''}`);
1635
2190
  };
1636
2191
 
1637
2192
  // Get translation values, defaulting to 0 for any undefined axis
@@ -1655,7 +2210,7 @@ class ThreeDPrinterMCPServer {
1655
2210
 
1656
2211
  // Define progress callback for UI updates
1657
2212
  const modifySectionProgressCallback = (progress: number, message?: string) => {
1658
- console.log(`Modify section progress: ${progress}% - ${message || ''}`);
2213
+ console.error(`Modify section progress: ${progress}% - ${message || ''}`);
1659
2214
  };
1660
2215
 
1661
2216
  // Determine the section to modify
@@ -1732,7 +2287,7 @@ class ThreeDPrinterMCPServer {
1732
2287
 
1733
2288
  // Define progress callback for UI updates
1734
2289
  const visualizationProgressCallback = (progress: number, message?: string) => {
1735
- console.log(`Visualization progress: ${progress}% - ${message || ''}`);
2290
+ console.error(`Visualization progress: ${progress}% - ${message || ''}`);
1736
2291
  };
1737
2292
 
1738
2293
  // Get width and height parameters, with defaults
@@ -1760,35 +2315,66 @@ class ThreeDPrinterMCPServer {
1760
2315
 
1761
2316
  const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1762
2317
  const printBedType = resolveBedType(args?.bed_type as string | undefined);
1763
- const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2318
+ const explicitPrintNozzle = optionalNozzleDiameterArg(args?.nozzle_diameter);
2319
+ const printNozzle = String(explicitPrintNozzle ?? DEFAULT_NOZZLE_DIAMETER);
1764
2320
  const printPreset = BAMBU_MODEL_PRESETS[printModel]?.(printNozzle);
1765
2321
 
1766
2322
  let threeMFPath = String(args.three_mf_path);
1767
2323
 
1768
- // Auto-slice if the 3MF doesn't contain gcode
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";
2327
+ let shouldAutoSlice = false;
1769
2328
  try {
1770
2329
  const JSZip = (await import('jszip')).default;
1771
2330
  const zipData = fs.readFileSync(threeMFPath);
1772
2331
  const zip = await JSZip.loadAsync(zipData);
1773
- const hasGcode = Object.keys(zip.files).some(
1774
- f => f.match(/Metadata\/plate_\d+\.gcode/i) || f.endsWith('.gcode')
2332
+ const plateEntry = zip.file(selectedPlateEntry);
2333
+ shouldAutoSlice = !plateEntry || plateEntry.dir;
2334
+ } catch (sliceCheckErr: any) {
2335
+ throw new Error(
2336
+ `Cannot read 3MF archive ${threeMFPath}; nothing was uploaded or started: ${sliceCheckErr?.message ?? String(sliceCheckErr)}`
1775
2337
  );
1776
- if (!hasGcode) {
1777
- console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
2338
+ }
2339
+
2340
+ // Only treat slicer_path as an executable selector when a slicer
2341
+ // will actually launch. Ready-to-print archives ignore it.
2342
+ if (shouldAutoSlice) {
2343
+ const printSlicerPath =
2344
+ this.resolveExecutableSelectorArg(
2345
+ args?.slicer_path,
2346
+ "print_3mf",
2347
+ "slicer_path"
2348
+ ) ?? DEFAULT_SLICER_PATH;
2349
+ try {
2350
+ console.error(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
1778
2351
  threeMFPath = await this.stlManipulator.sliceSTL(
1779
2352
  threeMFPath,
1780
2353
  slicerType,
1781
- slicerPath,
2354
+ printSlicerPath,
1782
2355
  slicerProfile || undefined,
1783
2356
  undefined, // progressCallback
1784
2357
  printPreset,
1785
- 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) } : {}) }
1786
2361
  );
1787
- console.log("Auto-sliced to: " + threeMFPath);
2362
+ console.error("Auto-sliced to: " + threeMFPath);
2363
+ } catch (sliceErr: any) {
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)}`);
1788
2373
  }
1789
- } catch (sliceCheckErr: any) {
1790
- console.warn("Could not check/slice 3MF, proceeding with original:", sliceCheckErr.message);
1791
2374
  }
2375
+ // Compare the sliced file with an explicit or auto-slice nozzle request.
2376
+ const requestedPrintNozzles =
2377
+ explicitPrintNozzle !== undefined || shouldAutoSlice ? [Number(printNozzle)] : undefined;
1792
2378
 
1793
2379
  // Define variables needed outside the parse try block
1794
2380
  let implementation: BambuImplementation;
@@ -1799,7 +2385,7 @@ class ThreeDPrinterMCPServer {
1799
2385
  try {
1800
2386
  // --- Parse 3MF ---
1801
2387
  const parsed3MFData = await parse3MF(threeMFPath);
1802
- console.log(`Successfully parsed 3MF file: ${threeMFPath}`);
2388
+ console.error(`Successfully parsed 3MF file: ${threeMFPath}`);
1803
2389
  const amsOptions = this.resolveAmsPrintOptions(
1804
2390
  parsed3MFData,
1805
2391
  args as Record<string, unknown> | undefined
@@ -1817,6 +2403,8 @@ class ThreeDPrinterMCPServer {
1817
2403
 
1818
2404
  printOptions = { // Assign to outer scope variable
1819
2405
  ...amsOptions,
2406
+ bambuModel: printModel,
2407
+ nozzleDiameters: requestedPrintNozzles,
1820
2408
  bedType: printBedType,
1821
2409
  bedLeveling: args?.bed_leveling !== undefined ? Boolean(args.bed_leveling) : undefined,
1822
2410
  flowCalibration: args?.flow_calibration !== undefined ? Boolean(args.flow_calibration) : undefined,
@@ -1839,7 +2427,7 @@ class ThreeDPrinterMCPServer {
1839
2427
  plateIndex: 0,
1840
2428
  ...printOptions // Spread the final options
1841
2429
  });
1842
- result = `Print command for ${threeMfFilename} sent successfully.`;
2430
+ // Report what the printer did, not merely that a command was published.
1843
2431
  } catch (printError) {
1844
2432
  console.error(`Error starting 3MF print for ${threeMfFilename}:`, printError);
1845
2433
  throw new Error(`Failed to start print: ${(printError as Error).message}`);
@@ -1849,13 +2437,38 @@ class ThreeDPrinterMCPServer {
1849
2437
  }
1850
2438
 
1851
2439
  case "check_fulu_orca_setup": {
2440
+ const runBridgeProbe = Boolean(args?.run_bridge_probe ?? false);
1852
2441
  result = await inspectFuluOrcaSetup({
1853
- slicerPath: args?.slicer_path !== undefined ? String(args.slicer_path) : undefined,
1854
- pluginDir: args?.plugin_dir !== undefined ? String(args.plugin_dir) : undefined,
1855
- runtimeDir: args?.runtime_dir !== undefined ? String(args.runtime_dir) : undefined,
2442
+ slicerPath: this.resolveExecutableSelectorArg(
2443
+ args?.slicer_path,
2444
+ "check_fulu_orca_setup",
2445
+ "slicer_path",
2446
+ runBridgeProbe,
2447
+ true
2448
+ ),
2449
+ pluginDir: this.resolveExecutableSelectorArg(
2450
+ args?.plugin_dir,
2451
+ "check_fulu_orca_setup",
2452
+ "plugin_dir",
2453
+ runBridgeProbe,
2454
+ true
2455
+ ),
2456
+ runtimeDir: this.resolveExecutableSelectorArg(
2457
+ args?.runtime_dir,
2458
+ "check_fulu_orca_setup",
2459
+ "runtime_dir",
2460
+ runBridgeProbe,
2461
+ true
2462
+ ),
1856
2463
  platform: args?.platform !== undefined ? String(args.platform) : undefined,
1857
- bridgeCommand: args?.bridge_command !== undefined ? String(args.bridge_command) : undefined,
1858
- runBridgeProbe: Boolean(args?.run_bridge_probe ?? false),
2464
+ bridgeCommand: this.resolveExecutableSelectorArg(
2465
+ args?.bridge_command,
2466
+ "check_fulu_orca_setup",
2467
+ "bridge_command",
2468
+ true,
2469
+ true
2470
+ ),
2471
+ runBridgeProbe,
1859
2472
  probeTimeoutMs:
1860
2473
  args?.probe_timeout_ms !== undefined ? Number(args.probe_timeout_ms) : undefined,
1861
2474
  });
@@ -1867,11 +2480,11 @@ class ThreeDPrinterMCPServer {
1867
2480
  throw new Error("Missing required parameter: method");
1868
2481
  }
1869
2482
 
2483
+ // Raw print methods, printer messages and unknown methods are refused
2484
+ // inside invokeFuluBridgeRpc before any bridge process starts.
1870
2485
  const method = String(args.method);
1871
- let rpcBambuModel: string | undefined;
1872
- if (isFuluPrintRpcMethod(method)) {
1873
- rpcBambuModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1874
- }
2486
+ const rpcBambuModel =
2487
+ typeof args?.bambu_model === "string" && args.bambu_model.trim() ? args.bambu_model.trim() : undefined;
1875
2488
 
1876
2489
  const payload =
1877
2490
  typeof args.payload === "object" && args.payload !== null && !Array.isArray(args.payload)
@@ -1879,8 +2492,13 @@ class ThreeDPrinterMCPServer {
1879
2492
  : undefined;
1880
2493
 
1881
2494
  result = await invokeFuluBridgeRpc({
1882
- bridgeCommand:
1883
- args?.bridge_command !== undefined ? String(args.bridge_command) : undefined,
2495
+ bridgeCommand: this.resolveExecutableSelectorArg(
2496
+ args?.bridge_command,
2497
+ "fulu_bambu_network_rpc",
2498
+ "bridge_command",
2499
+ true,
2500
+ true
2501
+ ),
1884
2502
  method,
1885
2503
  payload,
1886
2504
  timeoutMs: args?.timeout_ms !== undefined ? Number(args.timeout_ms) : undefined,
@@ -1914,19 +2532,31 @@ class ThreeDPrinterMCPServer {
1914
2532
  result = await this.stlManipulator.layFlat(String(args.stl_path));
1915
2533
  break;
1916
2534
 
2535
+ case "blender_mcp_status":
2536
+ result = await this.blender.status(args ?? {}, extra.signal);
2537
+ break;
2538
+
2539
+ case "blender_mcp_call":
2540
+ return await this.blender.call(args ?? {}, extra.signal);
2541
+
2542
+ case "blender_mcp_export_stl":
2543
+ result = await this.blender.exportStl(args ?? {}, extra.signal);
2544
+ break;
2545
+
1917
2546
  case "blender_mcp_edit_model":
1918
- if (!args?.stl_path || !Array.isArray(args.operations)) {
1919
- throw new Error("Missing required parameters: stl_path and operations");
1920
- }
1921
- result = await this.invokeBlenderBridge({
1922
- stlPath: String(args.stl_path),
1923
- operations: args.operations.map((entry) => String(entry)),
1924
- execute: Boolean(args.execute ?? false),
1925
- bridgeCommand:
1926
- args.bridge_command !== undefined ? String(args.bridge_command) : undefined,
1927
- });
2547
+ result = await this.blender.edit(
2548
+ args ?? {},
2549
+ this.resolveExecutableSelectorArg(
2550
+ args?.bridge_command,
2551
+ "blender_mcp_edit_model",
2552
+ "bridge_command",
2553
+ true,
2554
+ true
2555
+ ) ?? (!process.env.BLENDER_MCP_COMMAND?.trim() ? this.runtimeConfig.blenderBridgeCommand : undefined),
2556
+ extra.signal
2557
+ );
1928
2558
  break;
1929
-
2559
+
1930
2560
  default:
1931
2561
  throw new Error(`Unknown tool: ${name}`);
1932
2562
  }
@@ -1943,7 +2573,7 @@ class ThreeDPrinterMCPServer {
1943
2573
  console.error(`Error calling tool ${name}:`, error);
1944
2574
 
1945
2575
  const errorMessage = error instanceof Error ? error.message : String(error);
1946
- const structuredError = this.toStructuredToolError(name, errorMessage);
2576
+ const structuredError = this.toStructuredToolError(name, errorMessage, error);
1947
2577
 
1948
2578
  return {
1949
2579
  content: [
@@ -2025,29 +2655,31 @@ class ThreeDPrinterMCPServer {
2025
2655
  bambuToken: string,
2026
2656
  filename: string,
2027
2657
  gcode: string,
2028
- print: boolean
2658
+ print: boolean,
2659
+ options: PrintSafetyOptions = {}
2029
2660
  ) {
2030
2661
  const sourceIsFilePath = fs.existsSync(gcode) && fs.statSync(gcode).isFile();
2031
- const tempFilePath = sourceIsFilePath ? gcode : path.join(TEMP_DIR, filename);
2032
-
2033
- if (!sourceIsFilePath) {
2034
- fs.mkdirSync(TEMP_DIR, { recursive: true });
2035
- fs.writeFileSync(tempFilePath, gcode);
2662
+ // The remote filename must never select a local write/delete target.
2663
+ const localFilename = path.basename(filename);
2664
+ if (!sourceIsFilePath && (!localFilename || localFilename === "." || localFilename === "..")) {
2665
+ throw new Error("filename must include a file basename.");
2036
2666
  }
2667
+ const scratchDir = sourceIsFilePath ? undefined : fs.mkdtempSync(path.join(TEMP_DIR, "upload-"));
2668
+ // Multipart adapters infer their file-part name from this safe basename.
2669
+ const tempFilePath = scratchDir ? path.join(scratchDir, localFilename) : gcode;
2037
2670
 
2038
2671
  try {
2672
+ if (scratchDir) fs.writeFileSync(tempFilePath, gcode);
2039
2673
  const implementation = this.printerFactory.getImplementation(type);
2040
2674
 
2041
2675
  if (type.toLowerCase() === "bambu") {
2042
2676
  const bambuApiKey = `${bambuSerial}:${bambuToken}`;
2043
- return await implementation.uploadFile(host, port, bambuApiKey, tempFilePath, filename, print);
2677
+ return await implementation.uploadFile(host, port, bambuApiKey, tempFilePath, filename, print, options);
2044
2678
  }
2045
2679
 
2046
- return await implementation.uploadFile(host, port, apiKey, tempFilePath, filename, print);
2680
+ return await implementation.uploadFile(host, port, apiKey, tempFilePath, filename, print, options);
2047
2681
  } finally {
2048
- if (!sourceIsFilePath && fs.existsSync(tempFilePath)) {
2049
- fs.unlinkSync(tempFilePath);
2050
- }
2682
+ if (scratchDir) fs.rmSync(scratchDir, { recursive: true, force: true });
2051
2683
  }
2052
2684
  }
2053
2685
 
@@ -2058,16 +2690,17 @@ class ThreeDPrinterMCPServer {
2058
2690
  apiKey: string,
2059
2691
  bambuSerial: string,
2060
2692
  bambuToken: string,
2061
- gcodeFilename: string
2693
+ gcodeFilename: string,
2694
+ options: PrintSafetyOptions = {}
2062
2695
  ) {
2063
2696
  const implementation = this.printerFactory.getImplementation(type);
2064
2697
 
2065
2698
  if (type.toLowerCase() === "bambu") {
2066
2699
  const bambuApiKey = `${bambuSerial}:${bambuToken}`;
2067
- return await implementation.startJob(host, port, bambuApiKey, gcodeFilename);
2700
+ return await implementation.startJob(host, port, bambuApiKey, gcodeFilename, options);
2068
2701
  }
2069
2702
 
2070
- return await implementation.startJob(host, port, apiKey, gcodeFilename);
2703
+ return await implementation.startJob(host, port, apiKey, gcodeFilename, options);
2071
2704
  }
2072
2705
 
2073
2706
  async cancelPrint(
@@ -2096,19 +2729,53 @@ class ThreeDPrinterMCPServer {
2096
2729
  bambuSerial: string,
2097
2730
  bambuToken: string,
2098
2731
  component: string,
2099
- temperature: number
2732
+ temperature: unknown,
2733
+ options: PrintSafetyOptions = {}
2100
2734
  ) {
2101
2735
  const implementation = this.printerFactory.getImplementation(type);
2102
2736
 
2103
2737
  if (type.toLowerCase() === "bambu") {
2104
2738
  const bambuApiKey = `${bambuSerial}:${bambuToken}`;
2105
- return implementation.setTemperature(host, port, bambuApiKey, component, temperature);
2739
+ return implementation.setTemperature(host, port, bambuApiKey, component, temperature, options);
2106
2740
  }
2107
2741
 
2108
- return implementation.setTemperature(host, port, apiKey, component, temperature);
2742
+ return implementation.setTemperature(host, port, apiKey, component, temperature, options);
2109
2743
  }
2110
2744
 
2111
- 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
+ }
2112
2779
  const isInputError =
2113
2780
  message.toLowerCase().includes("missing required") ||
2114
2781
  message.toLowerCase().includes("invalid") ||
@@ -2244,78 +2911,44 @@ class ThreeDPrinterMCPServer {
2244
2911
  );
2245
2912
  }
2246
2913
 
2247
- private async invokeBlenderBridge(params: {
2248
- stlPath: string;
2249
- operations: string[];
2250
- execute: boolean;
2251
- bridgeCommand?: string;
2252
- }): Promise<Record<string, unknown>> {
2253
- const command = params.bridgeCommand ?? this.runtimeConfig.blenderBridgeCommand;
2254
- const payload = {
2255
- modelPath: params.stlPath,
2256
- operations: params.operations,
2257
- source: "mcp-3d-printer-server"
2258
- };
2914
+ /**
2915
+ * Bridge commands and path-derived bridge executables are machine-local
2916
+ * settings. Accepting their selectors as tool arguments means anything that
2917
+ * can steer the model — a model description, a README in a downloaded
2918
+ * archive, 3MF metadata — can choose the program this server runs. They are
2919
+ * read from environment configuration by default and only accepted for
2920
+ * execution when MCP_ALLOW_EXECUTABLE_ARG is set. The older
2921
+ * MCP_ALLOW_BRIDGE_COMMAND_ARG name remains a compatibility alias only for
2922
+ * bridge commands and path selectors used to derive a bridge executable.
2923
+ */
2924
+ private resolveExecutableSelectorArg(
2925
+ rawValue: unknown,
2926
+ toolName: string,
2927
+ argumentName: string,
2928
+ executionRequested = true,
2929
+ allowLegacyBridgeArg = false
2930
+ ): string | undefined {
2931
+ if (rawValue === undefined) {
2932
+ return undefined;
2933
+ }
2259
2934
 
2260
- if (!params.execute) {
2261
- return {
2262
- status: "prepared",
2263
- message: "Prepared Blender MCP payload. Set execute=true to run bridge command.",
2264
- bridgeCommand: command ?? null,
2265
- payload
2266
- };
2935
+ const value = String(rawValue);
2936
+ if (value.length === 0) {
2937
+ return undefined;
2267
2938
  }
2268
2939
 
2269
- if (!command) {
2940
+ const isAllowed =
2941
+ this.runtimeConfig.allowExecutableArg ||
2942
+ (allowLegacyBridgeArg && this.runtimeConfig.allowBridgeCommandArg);
2943
+ if (executionRequested && !isAllowed) {
2270
2944
  throw new Error(
2271
- "execute=true requires bridge_command or BLENDER_MCP_BRIDGE_COMMAND env var."
2945
+ `${toolName}: the ${argumentName} argument cannot select an executable by default. ` +
2946
+ "Executable paths and commands are read from server environment configuration. Set " +
2947
+ `MCP_ALLOW_EXECUTABLE_ARG=1 to accept ${argumentName} for process execution.`
2272
2948
  );
2273
2949
  }
2274
2950
 
2275
- const { spawn } = await import("node:child_process");
2276
- const result = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
2277
- const child = spawn(command, {
2278
- shell: true,
2279
- stdio: ["pipe", "pipe", "pipe"]
2280
- });
2281
-
2282
- let stdout = "";
2283
- let stderr = "";
2284
-
2285
- child.stdout.setEncoding("utf8");
2286
- child.stderr.setEncoding("utf8");
2287
- child.stdout.on("data", (chunk: string) => {
2288
- stdout += chunk;
2289
- });
2290
- child.stderr.on("data", (chunk: string) => {
2291
- stderr += chunk;
2292
- });
2293
- child.on("error", reject);
2294
- child.on("close", (code) => {
2295
- if (code === 0) {
2296
- resolve({ stdout, stderr });
2297
- return;
2298
- }
2299
-
2300
- reject(
2301
- new Error(
2302
- `Blender bridge command exited with code ${code ?? "unknown"}${
2303
- stderr.trim() ? `: ${stderr.trim()}` : ""
2304
- }`
2305
- )
2306
- );
2307
- });
2308
-
2309
- child.stdin.write(JSON.stringify(payload));
2310
- child.stdin.end();
2311
- });
2312
-
2313
- return {
2314
- status: "executed",
2315
- bridgeCommand: command,
2316
- stdout: result.stdout?.toString() ?? "",
2317
- stderr: result.stderr?.toString() ?? ""
2318
- };
2951
+ return value;
2319
2952
  }
2320
2953
 
2321
2954
  async close(): Promise<void> {