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/dist/index.js CHANGED
@@ -7,14 +7,20 @@ import axios from "axios";
7
7
  import dotenv from "dotenv";
8
8
  import fs from "fs";
9
9
  import path from "path";
10
+ import os from "node:os";
11
+ import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
10
12
  import { createServer as createHttpServer } from "node:http";
11
13
  import { randomUUID } from "node:crypto";
12
14
  import * as THREE from 'three';
13
15
  import { PrinterFactory } from "./printers/printer-factory.js";
14
16
  import { STLManipulator } from "./stl/stl-manipulator.js";
17
+ import { SlicerError } from "./slicer/slicer-error.js";
18
+ import { inspectSliceSettings, listTemplateRegistry, resolveTemplatePath, resolveTemplateSlicerProfilePath, saveTemplate, } from "./slicer/templates.js";
15
19
  import { parse3MF } from './3mf_parser.js';
16
20
  import { BambuImplementation } from "./printers/bambu.js";
17
- import { inspectFuluOrcaSetup, invokeFuluBridgeRpc, isFuluPrintRpcMethod, } from "./fulu-orca.js";
21
+ import { inspectFuluOrcaSetup, invokeFuluBridgeRpc, } from "./fulu-orca.js";
22
+ import { CONFIRMATION_UNAVAILABLE, confirmationTimedOut, confirmationTimeoutMs } from "./safety/confirmation.js";
23
+ import { validateExpectedPeaks } from "./safety/expected-peaks.js";
18
24
  // Load environment variables from .env file
19
25
  dotenv.config();
20
26
  // Default values
@@ -22,7 +28,7 @@ const DEFAULT_HOST = process.env.PRINTER_HOST || "localhost";
22
28
  const DEFAULT_PORT = process.env.PRINTER_PORT || "80";
23
29
  const DEFAULT_API_KEY = process.env.API_KEY || "";
24
30
  const DEFAULT_TYPE = process.env.PRINTER_TYPE || "octoprint"; // Default to OctoPrint
25
- const TEMP_DIR = process.env.TEMP_DIR || path.join(process.cwd(), "temp");
31
+ const TEMP_DIR = process.env.TEMP_DIR || fs.mkdtempSync(path.join(os.tmpdir(), "mcp-3d-printer-server-"));
26
32
  // Slicer configuration
27
33
  const CANONICAL_SLICER_TYPES = [
28
34
  "prusaslicer",
@@ -125,6 +131,208 @@ const BAMBU_MODEL_PRESETS = {
125
131
  a1mini: (n) => `Bambu Lab A1 mini ${n} nozzle`,
126
132
  h2d: (n) => `Bambu Lab H2D ${n} nozzle`,
127
133
  };
134
+ // Models whose bundled machine presets the Bambu-compatible CLI slicing path
135
+ // can target. The installed slicer must still contain the exact
136
+ // "<model> <nozzle> nozzle" preset; a missing preset stops the slice.
137
+ const SLICE_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c"];
138
+ const SLICE_BAMBU_MODEL_PRESETS = {
139
+ ...BAMBU_MODEL_PRESETS,
140
+ p2s: (n) => `Bambu Lab P2S ${n} nozzle`,
141
+ h2s: (n) => `Bambu Lab H2S ${n} nozzle`,
142
+ h2c: (n) => `Bambu Lab H2C ${n} nozzle`,
143
+ };
144
+ const DEFAULT_TEMPLATE_3MF_PATH = process.env.BAMBU_TEMPLATE_3MF_PATH || "";
145
+ const VALID_NOZZLE_TYPES = ["stainless_steel", "hardened_steel", "tungsten_carbide", "brass"];
146
+ /** The installed hotend material. Bambu machine presets assume the stock nozzle; the print gate compares this with the printer. */
147
+ function resolveNozzleType(value) {
148
+ const raw = value === undefined || value === null || value === "" ? process.env.BAMBU_NOZZLE_TYPE?.trim() : value;
149
+ if (raw === undefined || raw === "")
150
+ return undefined;
151
+ const normalized = String(raw).trim().toLowerCase().replace(/[\s-]+/g, "_");
152
+ if (!VALID_NOZZLE_TYPES.includes(normalized)) {
153
+ throw new Error(`Invalid nozzle type "${raw}". Valid types: ${VALID_NOZZLE_TYPES.join(", ")}`);
154
+ }
155
+ return normalized;
156
+ }
157
+ // Shared slice_stl / slice_with_template argument schema.
158
+ const SLICE_TOOL_PROPERTIES = {
159
+ bambu_model: {
160
+ type: "string",
161
+ enum: [...SLICE_BAMBU_MODELS],
162
+ description: "Bambu Lab printer model. Required for bambustudio and orcaslicer-bambulab (elicited or read from BAMBU_MODEL when omitted); " +
163
+ "passing it with orcaslicer selects the Bambu-compatible path. The installed slicer must contain the exact model/nozzle preset.",
164
+ },
165
+ nozzle_diameter: {
166
+ type: "string",
167
+ description: "Nozzle diameter in mm (default: NOZZLE_DIAMETER or 0.4). Selects the '<model> <diameter> nozzle' machine preset.",
168
+ },
169
+ slicer_type: {
170
+ type: "string",
171
+ enum: [...SLICER_TYPE_ENUM],
172
+ description: "Type of slicer to use (prusaslicer, cura, slic3r, orcaslicer, orcaslicer-bambulab, bambustudio). Use orcaslicer-bambulab for the FULU fork. " +
173
+ "bambustudio and orcaslicer-bambulab (and orcaslicer with bambu_model) export a sliced 3MF.",
174
+ },
175
+ slicer_path: {
176
+ type: "string",
177
+ description: "Path to the slicer executable (default: value from env). Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1.",
178
+ },
179
+ slicer_profile: {
180
+ type: "string",
181
+ description: "Profile to use for slicing (default: SLICER_PROFILE env). Bambu-compatible slicing: one process profile JSON (the machine preset comes from bambu_model). " +
182
+ "Generic OrcaSlicer: machine/process profiles separated with ';', optionally followed by '|filament.json'.",
183
+ },
184
+ filament_profile: {
185
+ type: "string",
186
+ description: "Filament profile path(s), ';'-separated in slot order, loaded with --load-filaments (default: FILAMENT_PROFILE/SLICER_FILAMENT_PROFILE env). Alias of load_filaments.",
187
+ },
188
+ load_filaments: {
189
+ type: "string",
190
+ description: "Bambu-compatible slicing: filament profile JSON paths in slot order, ';'-separated. One profile applies to every project slot; otherwise supply one per slot.",
191
+ },
192
+ load_filament_ids: {
193
+ type: "string",
194
+ description: "Bambu-compatible slicing: comma-separated filament IDs mapping load_filaments to objects, e.g. '1,2,3,1'.",
195
+ },
196
+ filament_colours: {
197
+ type: "string",
198
+ description: "Bambu-compatible slicing: one #RRGGBB per filament slot, ';'-separated. Defaults to the input 3MF's colours, then each profile's colour.",
199
+ },
200
+ bed_type: {
201
+ type: "string",
202
+ enum: [...VALID_BED_TYPES],
203
+ description: "Bambu-compatible slicing: build plate type (default: BED_TYPE or textured_plate).",
204
+ },
205
+ nozzle_type: {
206
+ type: "string",
207
+ enum: [...VALID_NOZZLE_TYPES],
208
+ 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.",
209
+ },
210
+ template_3mf_path: {
211
+ type: "string",
212
+ 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.",
213
+ },
214
+ template_name: {
215
+ type: "string",
216
+ description: "Named template from the local registry (see list_templates); resolves to its file.",
217
+ },
218
+ template_dir: {
219
+ type: "string",
220
+ description: "Template directory override when resolving template_name (default: BAMBU_TEMPLATE_DIR or ~/Sync/bambu/templates).",
221
+ },
222
+ uptodate: { type: "boolean", description: "Bambu-compatible slicing: refresh 3MF preset configs to the installed slicer version (--uptodate)." },
223
+ repetitions: { type: "number", description: "Bambu-compatible slicing: print N identical copies (--repetitions)." },
224
+ orient: { type: "boolean", description: "Bambu-compatible slicing: auto-orient for printability (--orient)." },
225
+ arrange: { type: "boolean", description: "Bambu-compatible slicing: auto-arrange objects on the plate (--arrange). Set false to keep an existing layout." },
226
+ ensure_on_bed: { type: "boolean", description: "Bambu-compatible slicing: lower floating models onto the bed (--ensure-on-bed)." },
227
+ clone_objects: { type: "string", description: "Bambu-compatible slicing: comma-separated clone counts per object index, e.g. '1,3,1,10'." },
228
+ skip_objects: { type: "string", description: "Bambu-compatible slicing: comma-separated object indices to skip, e.g. '3,5,10'." },
229
+ enable_timelapse: { type: "boolean", description: "Bambu-compatible slicing: insert timelapse parking moves (--enable-timelapse)." },
230
+ allow_mix_temp: { type: "boolean", description: "Bambu-compatible slicing: allow filaments with different temperature requirements on one plate." },
231
+ scale: { type: "number", description: "Bambu-compatible slicing: uniform scale factor applied before slicing (1.0 = original size)." },
232
+ rotate: { type: "number", description: "Bambu-compatible slicing: Z-axis rotation in degrees before slicing." },
233
+ rotate_x: { type: "number", description: "Bambu-compatible slicing: X-axis rotation in degrees before slicing." },
234
+ rotate_y: { type: "number", description: "Bambu-compatible slicing: Y-axis rotation in degrees before slicing." },
235
+ min_save: { type: "boolean", description: "Bambu-compatible slicing: write a smaller output 3MF (--min-save)." },
236
+ skip_modified_gcodes: { type: "boolean", description: "Bambu-compatible slicing: ignore custom G-code embedded in an input 3MF (--skip-modified-gcodes)." },
237
+ slice_plate: { type: "number", description: "Bambu-compatible slicing: plate number to slice; 0 slices all plates (default)." },
238
+ };
239
+ function validateSliceBambuModel(model) {
240
+ const normalized = model.trim().toLowerCase();
241
+ if (!SLICE_BAMBU_MODELS.includes(normalized)) {
242
+ throw new Error(`Invalid bambu_model "${model}" for slicing. Valid models: ${SLICE_BAMBU_MODELS.join(", ")}`);
243
+ }
244
+ return normalized;
245
+ }
246
+ function normalizeSliceNozzleDiameter(raw) {
247
+ const text = String(raw ?? "").trim() || DEFAULT_NOZZLE_DIAMETER;
248
+ const value = Number(text);
249
+ if (!/^\d+(\.\d+)?$/.test(text) || !Number.isFinite(value) || value <= 0 || value > 2) {
250
+ throw new Error(`Invalid nozzle_diameter "${text}". Use a diameter in mm such as 0.2, 0.4, 0.6, or 0.8.`);
251
+ }
252
+ return String(value);
253
+ }
254
+ /** Parse "#RRGGBB;#RRGGBB" (leading '#' optional) into positional slot colours. */
255
+ function parseFilamentColours(value) {
256
+ const colours = value.split(";").map((c) => c.trim()).filter(Boolean)
257
+ .map((c) => (c.startsWith("#") ? c : `#${c}`).toUpperCase());
258
+ for (const c of colours) {
259
+ if (!/^#[0-9A-F]{6}([0-9A-F]{2})?$/.test(c)) {
260
+ throw new Error(`Invalid filament colour "${c}"; expected #RRGGBB.`);
261
+ }
262
+ }
263
+ if (colours.length === 0)
264
+ throw new Error("Invalid filament_colours: list at least one colour.");
265
+ return colours;
266
+ }
267
+ function optionalFiniteNumber(args, key) {
268
+ if (args?.[key] === undefined)
269
+ return undefined;
270
+ const value = Number(args[key]);
271
+ if (!Number.isFinite(value))
272
+ throw new Error(`Invalid ${key}: expected a number.`);
273
+ return value;
274
+ }
275
+ function optionalBoolean(args, key) {
276
+ const value = args?.[key];
277
+ if (value === undefined)
278
+ return undefined;
279
+ if (typeof value !== "boolean")
280
+ throw new Error(`Invalid ${key}: expected a boolean.`);
281
+ return value;
282
+ }
283
+ /** Map slice_stl/slice_with_template arguments to Bambu-compatible CLI options. */
284
+ function buildBambuSliceOptions(args) {
285
+ const options = {};
286
+ const text = (key) => (args?.[key] !== undefined && String(args[key]).trim() ? String(args[key]).trim() : undefined);
287
+ options.uptodate = optionalBoolean(args, "uptodate");
288
+ options.orient = optionalBoolean(args, "orient");
289
+ options.arrange = optionalBoolean(args, "arrange");
290
+ options.ensureOnBed = optionalBoolean(args, "ensure_on_bed");
291
+ options.enableTimelapse = optionalBoolean(args, "enable_timelapse");
292
+ options.allowMixTemp = optionalBoolean(args, "allow_mix_temp");
293
+ options.minSave = optionalBoolean(args, "min_save");
294
+ options.skipModifiedGcodes = optionalBoolean(args, "skip_modified_gcodes");
295
+ options.repetitions = optionalFiniteNumber(args, "repetitions");
296
+ options.scale = optionalFiniteNumber(args, "scale");
297
+ options.rotate = optionalFiniteNumber(args, "rotate");
298
+ options.rotateX = optionalFiniteNumber(args, "rotate_x");
299
+ options.rotateY = optionalFiniteNumber(args, "rotate_y");
300
+ options.slicePlate = optionalFiniteNumber(args, "slice_plate");
301
+ if (options.repetitions !== undefined && (!Number.isInteger(options.repetitions) || options.repetitions < 1)) {
302
+ throw new Error("Invalid repetitions: expected a positive integer.");
303
+ }
304
+ if (options.slicePlate !== undefined && (!Number.isInteger(options.slicePlate) || options.slicePlate < 0)) {
305
+ throw new Error("Invalid slice_plate: expected 0 (all plates) or a plate number.");
306
+ }
307
+ if (options.scale !== undefined && options.scale <= 0)
308
+ throw new Error("Invalid scale: expected a positive factor.");
309
+ options.cloneObjects = text("clone_objects");
310
+ options.skipObjects = text("skip_objects");
311
+ options.loadFilamentIds = text("load_filament_ids");
312
+ const loadFilaments = text("load_filaments");
313
+ const filamentAlias = text("filament_profile");
314
+ if (loadFilaments && filamentAlias && loadFilaments !== filamentAlias) {
315
+ throw new Error("Provide either load_filaments or filament_profile, not conflicting values.");
316
+ }
317
+ options.loadFilaments = loadFilaments ?? filamentAlias;
318
+ const colours = text("filament_colours");
319
+ if (colours)
320
+ options.filamentColours = parseFilamentColours(colours);
321
+ options.bedType = resolveBedType(args?.bed_type);
322
+ options.nozzleType = resolveNozzleType(args?.nozzle_type);
323
+ return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined));
324
+ }
325
+ function defaultSlicerPathFor(slicerType) {
326
+ if (process.env.SLICER_PATH)
327
+ return process.env.SLICER_PATH;
328
+ if (slicerType === DEFAULT_SLICER_TYPE)
329
+ return DEFAULT_SLICER_PATH;
330
+ if (slicerType === "bambustudio")
331
+ return defaultBambuStudioPath();
332
+ if (slicerType === "orcaslicer-bambulab")
333
+ return defaultFuluOrcaPath();
334
+ return DEFAULT_SLICER_PATH;
335
+ }
128
336
  function validateBambuModel(model) {
129
337
  const normalized = model.trim().toLowerCase();
130
338
  if (!VALID_BAMBU_MODELS.includes(normalized)) {
@@ -139,6 +347,34 @@ function resolveBedType(bedType) {
139
347
  }
140
348
  return resolved;
141
349
  }
350
+ const SUPPORTED_NOZZLE_DIAMETERS = [0.2, 0.4, 0.6, 0.8];
351
+ /** Optional declared material; blank means "not declared". */
352
+ function optionalMaterialArg(value) {
353
+ if (value === undefined || value === null || value === "")
354
+ return undefined;
355
+ if (typeof value !== "string" || !value.trim()) {
356
+ throw new Error("material must be a non-empty string such as PLA, PETG, ABS, ASA, TPU, PA or PC.");
357
+ }
358
+ return value.trim();
359
+ }
360
+ /** Temperatures are validated as finite numbers before any printer connection. */
361
+ function optionalTemperatureArg(value, name) {
362
+ if (value === undefined)
363
+ return undefined;
364
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
365
+ throw new Error(`${name} must be a finite, non-negative number in °C.`);
366
+ }
367
+ return value;
368
+ }
369
+ function optionalNozzleDiameterArg(value) {
370
+ if (value === undefined || value === null || value === "")
371
+ return undefined;
372
+ const diameter = Number(value);
373
+ if ((typeof value !== "number" && typeof value !== "string") || !SUPPORTED_NOZZLE_DIAMETERS.includes(diameter)) {
374
+ throw new Error(`nozzle_diameter must be one of ${SUPPORTED_NOZZLE_DIAMETERS.join(", ")} mm.`);
375
+ }
376
+ return diameter;
377
+ }
142
378
  function parseBooleanEnv(rawValue, fallback) {
143
379
  if (rawValue === undefined) {
144
380
  return fallback;
@@ -193,29 +429,68 @@ function readRuntimeConfig() {
193
429
  enableJsonResponse: parseBooleanEnv(process.env.MCP_HTTP_JSON_RESPONSE, true),
194
430
  allowedOrigins: parseCsvEnv(process.env.MCP_HTTP_ALLOWED_ORIGINS),
195
431
  blenderBridgeCommand: process.env.BLENDER_MCP_BRIDGE_COMMAND?.trim() || undefined,
432
+ allowExecutableArg: parseBooleanEnv(process.env.MCP_ALLOW_EXECUTABLE_ARG, false),
433
+ allowBridgeCommandArg: parseBooleanEnv(process.env.MCP_ALLOW_BRIDGE_COMMAND_ARG, false),
196
434
  };
197
435
  }
436
+ const SLICING_TOOLS = new Set(["slice_stl", "slice_with_template", "list_templates", "save_template", "get_slice_settings"]);
198
437
  // Ensure temp directory exists
199
438
  if (!fs.existsSync(TEMP_DIR)) {
200
439
  fs.mkdirSync(TEMP_DIR, { recursive: true });
201
440
  }
441
+ /** MQTT "Not authorized", FTP 530, and HTTP 401/403 all mean the printer rejected its credentials. */
442
+ function isCredentialRejection(message) {
443
+ 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);
444
+ }
445
+ function credentialSuggestion() {
446
+ return (process.env.PRINTER_TYPE ?? "").toLowerCase() === "bambu"
447
+ ? "The printer rejected its LAN access code. Read the current Access Code on the printer (Settings > Network/WLAN; it changes " +
448
+ "when LAN mode is toggled and after some resets), update BAMBU_TOKEN, and check BAMBU_SERIAL. Retrying unchanged will not help."
449
+ : "The printer rejected its credentials. Check API_KEY (and PRINTER_HOST/PRINTER_PORT) for this printer system. Retrying unchanged will not help.";
450
+ }
202
451
  class ThreeDPrinterMCPServer {
203
452
  constructor() {
453
+ this.blender = new BlenderMcpBridge();
204
454
  this.runtimeConfig = readRuntimeConfig();
205
455
  this.server = new Server({
206
456
  name: "mcp-3d-printer-server",
207
- version: "1.2.0"
457
+ version: JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
208
458
  }, {
209
459
  capabilities: {
210
460
  resources: {},
211
461
  tools: {}
212
462
  }
213
463
  });
214
- this.printerFactory = new PrinterFactory();
464
+ this.printerFactory = new PrinterFactory((message) => this.confirmHardware(message));
215
465
  this.stlManipulator = new STLManipulator(TEMP_DIR);
216
466
  this.setupHandlers();
217
467
  this.setupErrorHandling();
218
468
  }
469
+ /** Human MCP elicitation before a print start or positive heating command. */
470
+ async confirmHardware(message) {
471
+ let response;
472
+ const timeout = confirmationTimeoutMs();
473
+ try {
474
+ response = await this.server.elicitInput({
475
+ mode: "form",
476
+ message,
477
+ requestedSchema: {
478
+ type: "object",
479
+ properties: {
480
+ confirmed: { type: "boolean", title: "I checked the printer and confirm this operation", default: false },
481
+ },
482
+ required: ["confirmed"],
483
+ },
484
+ }, { timeout });
485
+ }
486
+ catch (error) {
487
+ // A person may take minutes to check the bed; a timeout is not a missing capability.
488
+ if (error?.code === ErrorCode.RequestTimeout)
489
+ throw new Error(confirmationTimedOut(timeout));
490
+ throw new Error(CONFIRMATION_UNAVAILABLE);
491
+ }
492
+ return response.action === "accept" && response.content?.confirmed === true;
493
+ }
219
494
  async resolveBambuModel(argsModel) {
220
495
  const fromArgs = (argsModel || DEFAULT_BAMBU_MODEL).trim().toLowerCase();
221
496
  if (fromArgs) {
@@ -247,7 +522,7 @@ class ThreeDPrinterMCPServer {
247
522
  },
248
523
  required: ["bambu_model"],
249
524
  },
250
- });
525
+ }, { timeout: confirmationTimeoutMs() });
251
526
  if (result.action === "accept" && result.content?.bambu_model) {
252
527
  return validateBambuModel(String(result.content.bambu_model));
253
528
  }
@@ -255,6 +530,10 @@ class ThreeDPrinterMCPServer {
255
530
  }
256
531
  catch (elicitError) {
257
532
  const msg = elicitError?.message || String(elicitError);
533
+ if (elicitError?.code === ErrorCode.RequestTimeout) {
534
+ throw new Error("No printer model was chosen in time; nothing was sent. Set BAMBU_MODEL or pass bambu_model, " +
535
+ `or answer the prompt within PRINT_CONFIRMATION_TIMEOUT_MS. Valid models: ${VALID_BAMBU_MODELS.join(", ")}`);
536
+ }
258
537
  if (elicitError?.code === -32601 || elicitError?.code === -32600 ||
259
538
  msg.includes("does not support") || msg.includes("elicitation")) {
260
539
  throw new Error("bambu_model is required but your MCP client does not support elicitation. " +
@@ -264,6 +543,54 @@ class ThreeDPrinterMCPServer {
264
543
  throw elicitError;
265
544
  }
266
545
  }
546
+ /** Slicing accepts every model with a Bambu-compatible CLI preset mapping. */
547
+ async resolveSliceBambuModel(argsModel) {
548
+ const requested = (argsModel || DEFAULT_BAMBU_MODEL).trim().toLowerCase();
549
+ if (requested) {
550
+ return validateSliceBambuModel(requested);
551
+ }
552
+ return this.resolveBambuModel(undefined);
553
+ }
554
+ /** Shared slice_stl / slice_with_template implementation. */
555
+ async runSliceTool(toolName, args) {
556
+ if (!args?.stl_path) {
557
+ throw new Error("Missing required parameter: stl_path");
558
+ }
559
+ const slicerType = normalizeSlicerType(String(args.slicer_type || DEFAULT_SLICER_TYPE));
560
+ const explicitModel = typeof args.bambu_model === "string" && args.bambu_model.trim() ? args.bambu_model : undefined;
561
+ // Generic OrcaSlicer keeps its user-supplied machine profile unless the
562
+ // call explicitly targets a Bambu model.
563
+ const bambuCli = isBambuProjectSlicer(slicerType) || (slicerType === "orcaslicer" && explicitModel !== undefined);
564
+ let printerPreset;
565
+ let options;
566
+ if (bambuCli) {
567
+ const model = await this.resolveSliceBambuModel(explicitModel);
568
+ printerPreset = SLICE_BAMBU_MODEL_PRESETS[model](normalizeSliceNozzleDiameter(args.nozzle_diameter));
569
+ options = buildBambuSliceOptions(args);
570
+ }
571
+ // Process profile precedence: explicit slicer_profile, then a template, then SLICER_PROFILE.
572
+ const explicitProfile = typeof args.slicer_profile === "string" && args.slicer_profile.trim() ? args.slicer_profile.trim() : undefined;
573
+ const templateDir = typeof args.template_dir === "string" ? args.template_dir : undefined;
574
+ const requestedTemplate = resolveTemplatePath(typeof args.template_name === "string" ? args.template_name : undefined, templateDir, ["json", "config", "3mf"]) ||
575
+ (typeof args.template_3mf_path === "string" && args.template_3mf_path.trim() ? args.template_3mf_path.trim() : undefined);
576
+ if (requestedTemplate && !bambuCli) {
577
+ throw new Error("template_name/template_3mf_path apply to Bambu-compatible slicing (bambustudio, orcaslicer-bambulab, or orcaslicer with bambu_model).");
578
+ }
579
+ let slicerProfile = explicitProfile;
580
+ const templatePath = requestedTemplate || (bambuCli && DEFAULT_TEMPLATE_3MF_PATH ? DEFAULT_TEMPLATE_3MF_PATH : undefined);
581
+ if (!slicerProfile && templatePath) {
582
+ if (!fs.existsSync(templatePath)) {
583
+ throw new Error(`Template not found: ${templatePath}`);
584
+ }
585
+ slicerProfile = await resolveTemplateSlicerProfilePath(templatePath, path.join(TEMP_DIR, "templates"));
586
+ }
587
+ slicerProfile ?? (slicerProfile = DEFAULT_SLICER_PROFILE || undefined);
588
+ const explicitFilament = typeof args.filament_profile === "string" && args.filament_profile.trim() ? args.filament_profile.trim() : undefined;
589
+ const filamentProfile = options?.loadFilaments ?? explicitFilament ?? (DEFAULT_FILAMENT_PROFILE || undefined);
590
+ const slicerPath = this.resolveExecutableSelectorArg(args.slicer_path, toolName, "slicer_path") ?? defaultSlicerPathFor(slicerType);
591
+ return this.stlManipulator.sliceSTL(String(args.stl_path), slicerType, slicerPath, slicerProfile, undefined, // progressCallback
592
+ printerPreset, filamentProfile, options);
593
+ }
267
594
  setupErrorHandling() {
268
595
  this.server.onerror = (error) => {
269
596
  console.error("[MCP Error]", error);
@@ -461,7 +788,7 @@ class ThreeDPrinterMCPServer {
461
788
  },
462
789
  {
463
790
  name: "upload_gcode",
464
- description: "Upload G-code content or a local G-code file path to the printer",
791
+ 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.",
465
792
  inputSchema: {
466
793
  type: "object",
467
794
  properties: {
@@ -489,6 +816,11 @@ class ThreeDPrinterMCPServer {
489
816
  type: "string",
490
817
  description: "Access token for Bambu Lab printers (default: value from env)"
491
818
  },
819
+ bambu_model: {
820
+ type: "string",
821
+ enum: [...VALID_BAMBU_MODELS],
822
+ description: "Required for Bambu print operations unless BAMBU_MODEL is configured. Must match the printer and pre-sliced G-code."
823
+ },
492
824
  filename: {
493
825
  type: "string",
494
826
  description: "Filename to use on the printer. Defaults to the basename of gcode_path when omitted."
@@ -503,18 +835,18 @@ class ThreeDPrinterMCPServer {
503
835
  },
504
836
  print: {
505
837
  type: "boolean",
506
- description: "Start printing after upload when the printer backend supports it."
838
+ 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."
839
+ },
840
+ material: {
841
+ type: "string",
842
+ 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."
507
843
  }
508
- },
509
- anyOf: [
510
- { required: ["filename", "gcode"] },
511
- { required: ["gcode_path"] }
512
- ]
844
+ }
513
845
  }
514
846
  },
515
847
  {
516
848
  name: "start_print",
517
- description: "Start printing a file that is already available on the printer",
849
+ 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.",
518
850
  inputSchema: {
519
851
  type: "object",
520
852
  properties: {
@@ -542,9 +874,18 @@ class ThreeDPrinterMCPServer {
542
874
  type: "string",
543
875
  description: "Access token for Bambu Lab printers (default: value from env)"
544
876
  },
877
+ bambu_model: {
878
+ type: "string",
879
+ enum: [...VALID_BAMBU_MODELS],
880
+ description: "Required for Bambu print operations unless BAMBU_MODEL is configured. Must match the printer and pre-sliced G-code."
881
+ },
545
882
  filename: {
546
883
  type: "string",
547
884
  description: "Name/path of the printer-side G-code file to start."
885
+ },
886
+ material: {
887
+ type: "string",
888
+ description: "Declared filament material when the G-code has no slicer filament_type metadata (non-Bambu printers). Must not contradict the file."
548
889
  }
549
890
  },
550
891
  required: ["filename"]
@@ -585,7 +926,7 @@ class ThreeDPrinterMCPServer {
585
926
  },
586
927
  {
587
928
  name: "set_printer_temperature",
588
- description: "Set the temperature of a printer component",
929
+ 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.",
589
930
  inputSchema: {
590
931
  type: "object",
591
932
  properties: {
@@ -619,7 +960,20 @@ class ThreeDPrinterMCPServer {
619
960
  },
620
961
  temperature: {
621
962
  type: "number",
622
- description: "Target temperature in Celsius."
963
+ description: "Target temperature in Celsius: a finite number >= 0. 0 switches the heater off."
964
+ },
965
+ material: {
966
+ type: "string",
967
+ description: "Declared material at the nozzle (for example PLA, PETG, ABS). Required for positive nozzle heating, including non-RFID spools."
968
+ },
969
+ bambu_model: {
970
+ type: "string",
971
+ enum: [...VALID_BAMBU_MODELS],
972
+ description: "Bambu printer model; required for positive Bambu heating unless BAMBU_MODEL is configured. Checked against the live printer."
973
+ },
974
+ nozzle_diameter: {
975
+ type: "string",
976
+ description: "Installed Bambu nozzle diameter in mm for nozzle heating (default: NOZZLE_DIAMETER or 0.4). Checked against the live printer."
623
977
  }
624
978
  },
625
979
  required: ["component", "temperature"]
@@ -646,7 +1000,10 @@ class ThreeDPrinterMCPServer {
646
1000
  },
647
1001
  {
648
1002
  name: "slice_stl",
649
- description: "Slice an STL or 3MF file to generate G-code or sliced 3MF",
1003
+ description: "Slice an STL or 3MF file to generate G-code or a sliced 3MF. For Bambu-compatible CLI slicing " +
1004
+ "(bambustudio, orcaslicer-bambulab, or orcaslicer with bambu_model), the exact bambu_model/nozzle machine " +
1005
+ "preset from the selected slicer installation is required, profile inheritance is resolved before the CLI " +
1006
+ "runs, and the result must contain plate G-code. Failures stop with the slicer's exit status and output.",
650
1007
  inputSchema: {
651
1008
  type: "object",
652
1009
  properties: {
@@ -654,39 +1011,73 @@ class ThreeDPrinterMCPServer {
654
1011
  type: "string",
655
1012
  description: "Path to the STL or 3MF file to slice"
656
1013
  },
657
- bambu_model: {
658
- type: "string",
659
- enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
660
- description: "Bambu Lab printer model - required when using Bambu project slicers to ensure correct G-code."
661
- },
662
- nozzle_diameter: {
663
- type: "string",
664
- description: "Nozzle diameter in mm (default: 0.4)."
665
- },
666
- slicer_type: {
667
- type: "string",
668
- enum: [...SLICER_TYPE_ENUM],
669
- description: "Type of slicer to use (prusaslicer, cura, slic3r, orcaslicer, orcaslicer-bambulab, bambustudio). Use orcaslicer-bambulab for the FULU fork."
670
- },
671
- slicer_path: {
672
- type: "string",
673
- description: "Path to the slicer executable (default: value from env)"
674
- },
1014
+ ...SLICE_TOOL_PROPERTIES,
1015
+ },
1016
+ required: ["stl_path"]
1017
+ }
1018
+ },
1019
+ {
1020
+ name: "slice_with_template",
1021
+ description: "Slice an STL or 3MF with a named template from the local template registry (BAMBU_TEMPLATE_DIR). " +
1022
+ "The template supplies process settings; the machine preset still comes from bambu_model and nozzle_diameter.",
1023
+ inputSchema: {
1024
+ type: "object",
1025
+ properties: {
1026
+ stl_path: { type: "string", description: "Path to the STL or 3MF file to slice" },
1027
+ ...SLICE_TOOL_PROPERTIES,
1028
+ template_name: { type: "string", description: "Named template from the local registry (required)." },
675
1029
  slicer_profile: {
676
1030
  type: "string",
677
- description: "Profile to use for slicing (default: value from env). OrcaSlicer also accepts machine/process profiles separated with ';', optionally followed by '|filament.json'."
1031
+ description: "Explicit process profile that overrides the named template only when provided in this call."
678
1032
  },
679
- filament_profile: {
1033
+ },
1034
+ required: ["stl_path", "template_name"]
1035
+ }
1036
+ },
1037
+ {
1038
+ name: "list_templates",
1039
+ description: "List saved slicing templates (.3mf, .json, .config) in the local template registry directory.",
1040
+ inputSchema: {
1041
+ type: "object",
1042
+ properties: {
1043
+ template_dir: {
680
1044
  type: "string",
681
- description: "OrcaSlicer filament profile path loaded with --load-filaments (default: FILAMENT_PROFILE/SLICER_FILAMENT_PROFILE env)."
1045
+ description: "Template directory override. Defaults to BAMBU_TEMPLATE_DIR or ~/Sync/bambu/templates."
1046
+ }
1047
+ }
1048
+ }
1049
+ },
1050
+ {
1051
+ name: "save_template",
1052
+ description: "Copy a .3mf, .json, or .config file into the local template registry under a template name.",
1053
+ inputSchema: {
1054
+ type: "object",
1055
+ properties: {
1056
+ source_path: { type: "string", description: "Local .3mf, .json, or .config file to save as a template." },
1057
+ template_name: { type: "string", description: "Template name. Defaults to the source filename without extension." },
1058
+ template_dir: {
1059
+ type: "string",
1060
+ description: "Template directory override. Defaults to BAMBU_TEMPLATE_DIR or ~/Sync/bambu/templates."
682
1061
  }
683
1062
  },
684
- required: ["stl_path"]
1063
+ required: ["source_path"]
1064
+ }
1065
+ },
1066
+ {
1067
+ name: "get_slice_settings",
1068
+ description: "Inspect slicer settings in a 3MF template or JSON/config profile without slicing (layer height, infill, walls, supports, brim, bed, printer, filaments).",
1069
+ inputSchema: {
1070
+ type: "object",
1071
+ properties: {
1072
+ source_path: { type: "string", description: "Path to a 3MF, extracted project_settings.config, or slicer profile JSON." },
1073
+ template_name: { type: "string", description: "Named template from the local registry; used when source_path is omitted." },
1074
+ template_dir: { type: "string", description: "Template directory override when resolving template_name." }
1075
+ }
685
1076
  }
686
1077
  },
687
1078
  {
688
1079
  name: "confirm_temperatures",
689
- description: "Confirm temperature settings in a G-code file",
1080
+ 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.",
690
1081
  inputSchema: {
691
1082
  type: "object",
692
1083
  properties: {
@@ -696,11 +1087,11 @@ class ThreeDPrinterMCPServer {
696
1087
  },
697
1088
  extruder_temp: {
698
1089
  type: "number",
699
- description: "Expected extruder temperature"
1090
+ description: "Expected highest nozzle target"
700
1091
  },
701
1092
  bed_temp: {
702
1093
  type: "number",
703
- description: "Expected bed temperature"
1094
+ description: "Expected highest bed target"
704
1095
  }
705
1096
  },
706
1097
  required: ["gcode_path"]
@@ -708,7 +1099,7 @@ class ThreeDPrinterMCPServer {
708
1099
  },
709
1100
  {
710
1101
  name: "process_and_print_stl",
711
- description: "Process an STL file (extend base), slice it, confirm temperatures, and start printing",
1102
+ 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.",
712
1103
  inputSchema: {
713
1104
  type: "object",
714
1105
  properties: {
@@ -722,11 +1113,15 @@ class ThreeDPrinterMCPServer {
722
1113
  },
723
1114
  extruder_temp: {
724
1115
  type: "number",
725
- description: "Expected extruder temperature"
1116
+ description: "Expected highest nozzle target in the sliced G-code (S and R forms, every tool). Printing stops before upload if it differs."
726
1117
  },
727
1118
  bed_temp: {
728
1119
  type: "number",
729
- description: "Expected bed temperature"
1120
+ description: "Expected highest bed target in the sliced G-code (S and R forms). Printing stops before upload if it differs."
1121
+ },
1122
+ material: {
1123
+ type: "string",
1124
+ description: "Declared filament material when the sliced G-code has no filament_type metadata. Must not contradict the file."
730
1125
  },
731
1126
  host: {
732
1127
  type: "string",
@@ -754,6 +1149,11 @@ class ThreeDPrinterMCPServer {
754
1149
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"],
755
1150
  description: "Bed/plate type installed on the printer (default: textured_plate)."
756
1151
  },
1152
+ nozzle_type: {
1153
+ type: "string",
1154
+ enum: [...VALID_NOZZLE_TYPES],
1155
+ 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."
1156
+ },
757
1157
  nozzle_diameter: {
758
1158
  type: "string",
759
1159
  description: "Nozzle diameter in mm (default: 0.4)."
@@ -765,7 +1165,7 @@ class ThreeDPrinterMCPServer {
765
1165
  },
766
1166
  slicer_path: {
767
1167
  type: "string",
768
- description: "Path to the slicer executable (default: value from env)."
1168
+ description: "Path to the slicer executable (default: value from env). Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1."
769
1169
  },
770
1170
  slicer_profile: {
771
1171
  type: "string",
@@ -960,7 +1360,7 @@ class ThreeDPrinterMCPServer {
960
1360
  },
961
1361
  {
962
1362
  name: "print_3mf",
963
- description: "Print a 3MF file on a Bambu Lab printer, potentially overriding settings.",
1363
+ 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.",
964
1364
  inputSchema: {
965
1365
  type: "object",
966
1366
  properties: {
@@ -978,6 +1378,11 @@ class ThreeDPrinterMCPServer {
978
1378
  enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"],
979
1379
  description: "Bed/plate type installed on the printer (default: textured_plate)."
980
1380
  },
1381
+ nozzle_type: {
1382
+ type: "string",
1383
+ enum: [...VALID_NOZZLE_TYPES],
1384
+ description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)."
1385
+ },
981
1386
  nozzle_diameter: {
982
1387
  type: "string",
983
1388
  description: "Nozzle diameter in mm (default: 0.4)."
@@ -1010,7 +1415,7 @@ class ThreeDPrinterMCPServer {
1010
1415
  },
1011
1416
  slicer_path: {
1012
1417
  type: "string",
1013
- description: "Path to the slicer executable if auto-slicing is needed."
1418
+ description: "Path to the slicer executable if auto-slicing is needed. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1."
1014
1419
  },
1015
1420
  slicer_profile: {
1016
1421
  type: "string",
@@ -1056,15 +1461,20 @@ class ThreeDPrinterMCPServer {
1056
1461
  properties: {
1057
1462
  slicer_path: {
1058
1463
  type: "string",
1059
- description: "Path to the FULU OrcaSlicer executable. Defaults from SLICER_PATH/FULU_ORCA_PATH."
1464
+ description: "Path to the FULU OrcaSlicer executable. Defaults from SLICER_PATH/FULU_ORCA_PATH. " +
1465
+ "When run_bridge_probe=true, requires MCP_ALLOW_EXECUTABLE_ARG=1 to be accepted here."
1060
1466
  },
1061
1467
  plugin_dir: {
1062
1468
  type: "string",
1063
- description: "Directory containing the FULU Bambu runtime payload; on macOS this is usually OrcaSlicer.app/Contents/MacOS."
1469
+ description: "Directory containing the FULU Bambu runtime payload; on macOS this is usually " +
1470
+ "OrcaSlicer.app/Contents/MacOS. When run_bridge_probe=true, requires " +
1471
+ "MCP_ALLOW_EXECUTABLE_ARG=1 to be accepted here."
1064
1472
  },
1065
1473
  runtime_dir: {
1066
1474
  type: "string",
1067
- description: "Installed runtime directory. On macOS this defaults to ~/Library/Application Support/OrcaSlicer/macos-bridge/runtime."
1475
+ description: "Installed runtime directory. On macOS this defaults to ~/Library/Application " +
1476
+ "Support/OrcaSlicer/macos-bridge/runtime. When run_bridge_probe=true, requires " +
1477
+ "MCP_ALLOW_EXECUTABLE_ARG=1 to be accepted here."
1068
1478
  },
1069
1479
  platform: {
1070
1480
  type: "string",
@@ -1073,7 +1483,9 @@ class ThreeDPrinterMCPServer {
1073
1483
  },
1074
1484
  bridge_command: {
1075
1485
  type: "string",
1076
- description: "Command that starts the FULU BambuNetwork bridge host for probing."
1486
+ description: "Command that starts the FULU BambuNetwork bridge host for probing. Read from " +
1487
+ "FULU_BAMBU_BRIDGE_COMMAND by default; requires MCP_ALLOW_EXECUTABLE_ARG=1 " +
1488
+ "to be accepted here."
1077
1489
  },
1078
1490
  run_bridge_probe: {
1079
1491
  type: "boolean",
@@ -1088,13 +1500,15 @@ class ThreeDPrinterMCPServer {
1088
1500
  },
1089
1501
  {
1090
1502
  name: "fulu_bambu_network_rpc",
1091
- description: "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.",
1503
+ description: "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.",
1092
1504
  inputSchema: {
1093
1505
  type: "object",
1094
1506
  properties: {
1095
1507
  bridge_command: {
1096
1508
  type: "string",
1097
- description: "Command that starts the FULU BambuNetwork bridge host. Defaults to FULU_BAMBU_BRIDGE_COMMAND."
1509
+ description: "Command that starts the FULU BambuNetwork bridge host. Defaults to " +
1510
+ "FULU_BAMBU_BRIDGE_COMMAND; requires MCP_ALLOW_EXECUTABLE_ARG=1 to be " +
1511
+ "accepted here."
1098
1512
  },
1099
1513
  method: {
1100
1514
  type: "string",
@@ -1111,12 +1525,12 @@ class ThreeDPrinterMCPServer {
1111
1525
  },
1112
1526
  allow_mutating_method: {
1113
1527
  type: "boolean",
1114
- description: "Required for methods outside the read-only bridge/net allowlist."
1528
+ description: "Required for the allowlisted agent/session setup methods. It never enables print, printer-message, or unknown methods."
1115
1529
  },
1116
1530
  bambu_model: {
1117
1531
  type: "string",
1118
1532
  enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
1119
- description: "Required when calling FULU print RPC methods; preserves the Bambu model safety gate."
1533
+ description: "Informational only. Raw FULU print RPC methods are disabled; use print_3mf."
1120
1534
  }
1121
1535
  },
1122
1536
  required: ["method"]
@@ -1168,38 +1582,80 @@ class ThreeDPrinterMCPServer {
1168
1582
  required: ["stl_path"]
1169
1583
  }
1170
1584
  },
1585
+ {
1586
+ name: "blender_mcp_status",
1587
+ 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.",
1588
+ inputSchema: {
1589
+ type: "object",
1590
+ properties: {
1591
+ connect: { type: "boolean", description: "Initialize the configured stdio MCP server and discover its tools (default false)." },
1592
+ tool_names: { type: "array", items: { type: "string" }, maxItems: 64, description: "Return full input schemas for these discovered tools, such as [\"execute_blender_code\"]." },
1593
+ include_schemas: { type: "boolean", description: "Return every discovered tool's full definition (large; default false)." },
1594
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total connection and discovery deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." }
1595
+ },
1596
+ additionalProperties: false
1597
+ }
1598
+ },
1599
+ {
1600
+ name: "blender_mcp_call",
1601
+ 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.",
1602
+ inputSchema: {
1603
+ type: "object",
1604
+ properties: {
1605
+ tool_name: { type: "string", description: "Exact name advertised by Blender MCP, such as get_scene_info or execute_blender_code." },
1606
+ 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." },
1607
+ 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." }
1608
+ },
1609
+ required: ["tool_name"],
1610
+ additionalProperties: false
1611
+ }
1612
+ },
1613
+ {
1614
+ name: "blender_mcp_export_stl",
1615
+ 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.",
1616
+ inputSchema: {
1617
+ type: "object",
1618
+ properties: {
1619
+ 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)." },
1620
+ output_path: { type: "string", description: "New local .stl path. Its parent must exist; existing files are never overwritten." },
1621
+ apply_modifiers: { type: "boolean", description: "Export the evaluated geometry with modifiers applied (default true)." },
1622
+ 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." },
1623
+ user_prompt: { type: "string", description: "The user's own words, passed unchanged to Blender MCP." },
1624
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total Blender request deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." }
1625
+ },
1626
+ required: ["object_names", "output_path"],
1627
+ additionalProperties: false
1628
+ }
1629
+ },
1171
1630
  {
1172
1631
  name: "blender_mcp_edit_model",
1173
- description: "Optionally send STL-edit instructions to a Blender MCP bridge command. Use for advanced model edits outside built-in STL tools.",
1632
+ 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.",
1174
1633
  inputSchema: {
1175
1634
  type: "object",
1176
1635
  properties: {
1177
- stl_path: {
1178
- type: "string",
1179
- description: "Path to the local STL file."
1180
- },
1636
+ stl_path: { type: "string", description: "Path to the local STL file" },
1181
1637
  operations: {
1182
1638
  type: "array",
1183
- description: "Ordered edit operations for Blender (e.g. remesh, boolean, decimate).",
1639
+ 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.",
1640
+ minItems: 1,
1641
+ maxItems: 64,
1184
1642
  items: { type: "string" }
1185
1643
  },
1186
- bridge_command: {
1187
- type: "string",
1188
- description: "Optional override command for invoking a local Blender MCP bridge."
1189
- },
1190
- execute: {
1191
- type: "boolean",
1192
- description: "When true, attempts to execute bridge command; otherwise returns a prepared payload only."
1193
- }
1644
+ 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." },
1645
+ user_prompt: { type: "string", description: "The user's own words describing the edit, passed unchanged to Blender MCP." },
1646
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total Blender request deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." },
1647
+ bridge_command: { type: "string", description: "Legacy custom bridge executable override, not a standard MCP command. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
1648
+ execute: { type: "boolean", description: "Apply edits and export (true) or validate and return the prepared request without connecting (false, default)." }
1194
1649
  },
1195
- required: ["stl_path", "operations"]
1650
+ required: ["stl_path", "operations"],
1651
+ additionalProperties: false
1196
1652
  }
1197
1653
  }
1198
1654
  ]
1199
1655
  };
1200
1656
  });
1201
1657
  // Handle tool calls
1202
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
1658
+ this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1203
1659
  const { name, arguments: args } = request.params;
1204
1660
  // Set default values for common parameters
1205
1661
  const host = String(args?.host || DEFAULT_HOST);
@@ -1209,7 +1665,6 @@ class ThreeDPrinterMCPServer {
1209
1665
  const bambuSerial = String(args?.bambu_serial || DEFAULT_BAMBU_SERIAL);
1210
1666
  const bambuToken = String(args?.bambu_token || DEFAULT_BAMBU_TOKEN);
1211
1667
  const slicerType = normalizeSlicerType(String(args?.slicer_type || DEFAULT_SLICER_TYPE));
1212
- const slicerPath = String(args?.slicer_path || DEFAULT_SLICER_PATH);
1213
1668
  const slicerProfile = String(args?.slicer_profile || DEFAULT_SLICER_PROFILE);
1214
1669
  const filamentProfile = String(args?.filament_profile || DEFAULT_FILAMENT_PROFILE);
1215
1670
  try {
@@ -1231,15 +1686,26 @@ class ThreeDPrinterMCPServer {
1231
1686
  if (!args?.filename && !args?.gcode_path) {
1232
1687
  throw new Error("Missing required parameter: filename");
1233
1688
  }
1689
+ if (args.print !== undefined && typeof args.print !== "boolean") {
1690
+ throw new Error("print must be a boolean.");
1691
+ }
1692
+ const uploadOptions = { material: optionalMaterialArg(args?.material) };
1693
+ if (type.toLowerCase() === "bambu" && args.print === true) {
1694
+ uploadOptions.bambuModel = await this.resolveBambuModel(args?.bambu_model);
1695
+ }
1234
1696
  const uploadFilename = String(args?.filename || path.basename(String(args.gcode_path)));
1235
1697
  const uploadGcodeSource = String(args?.gcode_path || args.gcode);
1236
- result = await this.uploadGcode(host, port, type, apiKey, bambuSerial, bambuToken, uploadFilename, uploadGcodeSource, Boolean(args.print || false));
1698
+ result = await this.uploadGcode(host, port, type, apiKey, bambuSerial, bambuToken, uploadFilename, uploadGcodeSource, Boolean(args.print || false), uploadOptions);
1237
1699
  break;
1238
1700
  case "start_print":
1239
1701
  if (!args?.filename) {
1240
1702
  throw new Error("Missing required parameter: filename");
1241
1703
  }
1242
- result = await this.startPrint(host, port, type, apiKey, bambuSerial, bambuToken, String(args.filename));
1704
+ const startOptions = { material: optionalMaterialArg(args?.material) };
1705
+ if (type.toLowerCase() === "bambu") {
1706
+ startOptions.bambuModel = await this.resolveBambuModel(args?.bambu_model);
1707
+ }
1708
+ result = await this.startPrint(host, port, type, apiKey, bambuSerial, bambuToken, String(args.filename), startOptions);
1243
1709
  break;
1244
1710
  case "cancel_print":
1245
1711
  result = await this.cancelPrint(host, port, type, apiKey, bambuSerial, bambuToken);
@@ -1248,7 +1714,15 @@ class ThreeDPrinterMCPServer {
1248
1714
  if (!args?.component || args?.temperature === undefined) {
1249
1715
  throw new Error("Missing required parameters: component and temperature");
1250
1716
  }
1251
- result = await this.setPrinterTemperature(host, port, type, apiKey, bambuSerial, bambuToken, String(args.component), Number(args.temperature));
1717
+ // Never coerce: "not-a-number" must not become NaN in a heater command.
1718
+ const requestedTemperature = optionalTemperatureArg(args.temperature, "temperature");
1719
+ const heatOptions = { material: optionalMaterialArg(args.material) };
1720
+ if (type.toLowerCase() === "bambu" && requestedTemperature > 0) {
1721
+ heatOptions.bambuModel = await this.resolveBambuModel(args?.bambu_model);
1722
+ heatOptions.nozzleDiameter =
1723
+ optionalNozzleDiameterArg(args.nozzle_diameter) ?? Number(DEFAULT_NOZZLE_DIAMETER);
1724
+ }
1725
+ result = await this.setPrinterTemperature(host, port, type, apiKey, bambuSerial, bambuToken, String(args.component), requestedTemperature, heatOptions);
1252
1726
  break;
1253
1727
  // New STL manipulation tools
1254
1728
  case "extend_stl_base":
@@ -1257,19 +1731,32 @@ class ThreeDPrinterMCPServer {
1257
1731
  }
1258
1732
  result = await this.stlManipulator.extendBase(String(args.stl_path), Number(args.extension_inches));
1259
1733
  break;
1260
- case "slice_stl": {
1261
- if (!args?.stl_path) {
1262
- throw new Error("Missing required parameter: stl_path");
1734
+ case "slice_stl":
1735
+ result = await this.runSliceTool("slice_stl", args);
1736
+ break;
1737
+ case "slice_with_template":
1738
+ if (!args?.template_name || !String(args.template_name).trim()) {
1739
+ throw new Error("Missing required parameter: template_name");
1263
1740
  }
1264
- // Resolve printer model for BambuStudio slicer
1265
- let slicePreset;
1266
- if (isBambuProjectSlicer(slicerType)) {
1267
- const sliceModel = await this.resolveBambuModel(args?.bambu_model);
1268
- const nozzleDiam = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
1269
- slicePreset = BAMBU_MODEL_PRESETS[sliceModel]?.(nozzleDiam);
1741
+ result = await this.runSliceTool("slice_with_template", args);
1742
+ break;
1743
+ case "list_templates":
1744
+ result = listTemplateRegistry(typeof args?.template_dir === "string" ? args.template_dir : undefined);
1745
+ break;
1746
+ case "save_template":
1747
+ if (!args?.source_path) {
1748
+ throw new Error("Missing required parameter: source_path");
1270
1749
  }
1271
- result = await this.stlManipulator.sliceSTL(String(args.stl_path), slicerType, slicerPath, slicerProfile || undefined, undefined, // progressCallback
1272
- slicePreset, filamentProfile || undefined);
1750
+ result = saveTemplate(String(args.source_path), typeof args?.template_name === "string" ? args.template_name : undefined, typeof args?.template_dir === "string" ? args.template_dir : undefined);
1751
+ break;
1752
+ case "get_slice_settings": {
1753
+ if (!args?.source_path && !args?.template_name) {
1754
+ throw new Error("Missing required parameter: source_path or template_name");
1755
+ }
1756
+ const settingsSource = args?.source_path
1757
+ ? String(args.source_path)
1758
+ : resolveTemplatePath(String(args.template_name), typeof args?.template_dir === "string" ? args.template_dir : undefined, ["3mf", "json", "config"]);
1759
+ result = await inspectSliceSettings(settingsSource, TEMP_DIR);
1273
1760
  break;
1274
1761
  }
1275
1762
  case "confirm_temperatures":
@@ -1285,22 +1772,38 @@ class ThreeDPrinterMCPServer {
1285
1772
  if (!args?.stl_path || args?.extension_inches === undefined) {
1286
1773
  throw new Error("Missing required parameters: stl_path and extension_inches");
1287
1774
  }
1775
+ // Validate every safety input before extending, slicing or connecting.
1776
+ const expectedPeaks = {
1777
+ nozzle: optionalTemperatureArg(args.extruder_temp, "extruder_temp"),
1778
+ bed: optionalTemperatureArg(args.bed_temp, "bed_temp"),
1779
+ };
1780
+ validateExpectedPeaks(expectedPeaks);
1781
+ const processMaterial = optionalMaterialArg(args?.material);
1782
+ let processModel;
1783
+ let processNozzle = String(DEFAULT_NOZZLE_DIAMETER);
1784
+ if (type.toLowerCase() === 'bambu' || isBambuProjectSlicer(slicerType)) {
1785
+ processModel = await this.resolveBambuModel(args?.bambu_model);
1786
+ processNozzle = String(optionalNozzleDiameterArg(args?.nozzle_diameter) ?? DEFAULT_NOZZLE_DIAMETER);
1787
+ }
1288
1788
  // Define progress callback for UI updates
1289
1789
  const processProgressCallback = (progress, message) => {
1290
- console.log(`Process progress: ${progress}% - ${message || ''}`);
1790
+ console.error(`Process progress: ${progress}% - ${message || ''}`);
1291
1791
  };
1292
1792
  // 1. Extend the base of the STL file
1293
1793
  const extendedStlPath = await this.stlManipulator.extendBase(String(args.stl_path), Number(args.extension_inches), processProgressCallback);
1294
1794
  // 2. Slice the extended STL file
1295
1795
  let processPreset;
1296
- if (type.toLowerCase() === 'bambu' || isBambuProjectSlicer(slicerType)) {
1297
- const processModel = await this.resolveBambuModel(args?.bambu_model);
1298
- const processNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
1299
- if (isBambuProjectSlicer(slicerType)) {
1300
- processPreset = BAMBU_MODEL_PRESETS[processModel]?.(processNozzle);
1796
+ if (processModel && isBambuProjectSlicer(slicerType)) {
1797
+ processPreset = BAMBU_MODEL_PRESETS[processModel]?.(processNozzle);
1798
+ }
1799
+ const gcodePath = await this.stlManipulator.sliceSTL(extendedStlPath, slicerType, this.resolveExecutableSelectorArg(args?.slicer_path, "process_and_print_stl", "slicer_path") ?? DEFAULT_SLICER_PATH, slicerProfile || undefined, processProgressCallback, processPreset, filamentProfile || undefined,
1800
+ // Slice for the plate and nozzle the print gate will check against.
1801
+ processPreset
1802
+ ? {
1803
+ bedType: resolveBedType(args?.bed_type),
1804
+ ...(resolveNozzleType(args?.nozzle_type) ? { nozzleType: resolveNozzleType(args?.nozzle_type) } : {}),
1301
1805
  }
1302
- }
1303
- const gcodePath = await this.stlManipulator.sliceSTL(extendedStlPath, slicerType, slicerPath, slicerProfile || undefined, processProgressCallback, processPreset, filamentProfile || undefined);
1806
+ : undefined);
1304
1807
  if (type.toLowerCase() === 'bambu' && gcodePath.toLowerCase().endsWith(".3mf")) {
1305
1808
  if (!bambuSerial || !bambuToken) {
1306
1809
  throw new Error("Bambu serial number and access token are required for Bambu 3MF printing.");
@@ -1321,6 +1824,9 @@ class ThreeDPrinterMCPServer {
1321
1824
  result = await factoryImplementation.print3mf(host, bambuSerial, bambuToken, {
1322
1825
  projectName: path.basename(gcodePath).replace(/\.3mf$/i, ''),
1323
1826
  filePath: gcodePath,
1827
+ bambuModel: processModel,
1828
+ nozzleDiameters: [Number(processNozzle)],
1829
+ expectedPeaks,
1324
1830
  plateIndex: 0,
1325
1831
  ...amsOptions,
1326
1832
  bedType: printBedType,
@@ -1337,26 +1843,20 @@ class ThreeDPrinterMCPServer {
1337
1843
  if (gcodePath.toLowerCase().endsWith(".3mf")) {
1338
1844
  throw new Error("process_and_print_stl produced a sliced 3MF. Direct process-and-print for sliced 3MF is only supported for Bambu printers via print_3mf.");
1339
1845
  }
1340
- // 3. Confirm temperatures if specified
1341
- if (args.extruder_temp !== undefined || args.bed_temp !== undefined) {
1342
- const tempConfirmation = await this.stlManipulator.confirmTemperatures(gcodePath, {
1343
- extruder: args.extruder_temp !== undefined ? Number(args.extruder_temp) : undefined,
1344
- bed: args.bed_temp !== undefined ? Number(args.bed_temp) : undefined
1345
- }, processProgressCallback);
1346
- if (!tempConfirmation.match) {
1347
- console.warn("Temperature mismatch:", tempConfirmation);
1348
- }
1349
- }
1350
- // 4. Upload the G-code file to the printer
1351
- const gcodeContent = await fs.promises.readFile(gcodePath, 'utf8');
1846
+ // 3. Upload and print through the adapter's hard safety gate. It
1847
+ // inspects the exact bytes sent (S and R targets, tool changes,
1848
+ // hardware and material ceilings), requires the expected peaks to
1849
+ // match, reads fresh printer state and asks for human confirmation.
1850
+ // A mismatch refuses before upload; nothing is only logged.
1352
1851
  const filename = path.basename(gcodePath);
1353
- await this.uploadGcode(host, port, type, apiKey, bambuSerial, bambuToken, filename, gcodeContent, true // Start printing immediately
1354
- );
1852
+ const printResult = await this.uploadGcode(host, port, type, apiKey, bambuSerial, bambuToken, filename, gcodePath, true, // Start printing immediately
1853
+ { bambuModel: processModel, material: processMaterial, expectedPeaks });
1355
1854
  result = {
1356
1855
  extended_stl_path: extendedStlPath,
1357
1856
  gcode_path: gcodePath,
1358
1857
  filename,
1359
- status: "Print job started"
1858
+ status: "Checked print job started",
1859
+ print: printResult,
1360
1860
  };
1361
1861
  break;
1362
1862
  // New STL manipulation tool handlers
@@ -1372,7 +1872,7 @@ class ThreeDPrinterMCPServer {
1372
1872
  }
1373
1873
  // Define progress callback for UI updates
1374
1874
  const scaleProgressCallback = (progress, message) => {
1375
- console.log(`Scale progress: ${progress}% - ${message || ''}`);
1875
+ console.error(`Scale progress: ${progress}% - ${message || ''}`);
1376
1876
  };
1377
1877
  let scaleFactors;
1378
1878
  // Check if we have individual axis scaling factors
@@ -1396,7 +1896,7 @@ class ThreeDPrinterMCPServer {
1396
1896
  }
1397
1897
  // Define progress callback for UI updates
1398
1898
  const rotateProgressCallback = (progress, message) => {
1399
- console.log(`Rotate progress: ${progress}% - ${message || ''}`);
1899
+ console.error(`Rotate progress: ${progress}% - ${message || ''}`);
1400
1900
  };
1401
1901
  // Get rotation angles, defaulting to 0 for any undefined axis
1402
1902
  const rotationAngles = [
@@ -1412,7 +1912,7 @@ class ThreeDPrinterMCPServer {
1412
1912
  }
1413
1913
  // Define progress callback for UI updates
1414
1914
  const translateProgressCallback = (progress, message) => {
1415
- console.log(`Translate progress: ${progress}% - ${message || ''}`);
1915
+ console.error(`Translate progress: ${progress}% - ${message || ''}`);
1416
1916
  };
1417
1917
  // Get translation values, defaulting to 0 for any undefined axis
1418
1918
  const translationValues = [
@@ -1428,7 +1928,7 @@ class ThreeDPrinterMCPServer {
1428
1928
  }
1429
1929
  // Define progress callback for UI updates
1430
1930
  const modifySectionProgressCallback = (progress, message) => {
1431
- console.log(`Modify section progress: ${progress}% - ${message || ''}`);
1931
+ console.error(`Modify section progress: ${progress}% - ${message || ''}`);
1432
1932
  };
1433
1933
  // Determine the section to modify
1434
1934
  let sectionBox;
@@ -1485,7 +1985,7 @@ class ThreeDPrinterMCPServer {
1485
1985
  }
1486
1986
  // Define progress callback for UI updates
1487
1987
  const visualizationProgressCallback = (progress, message) => {
1488
- console.log(`Visualization progress: ${progress}% - ${message || ''}`);
1988
+ console.error(`Visualization progress: ${progress}% - ${message || ''}`);
1489
1989
  };
1490
1990
  // Get width and height parameters, with defaults
1491
1991
  const width = args.width !== undefined ? Number(args.width) : 300;
@@ -1504,25 +2004,51 @@ class ThreeDPrinterMCPServer {
1504
2004
  }
1505
2005
  const printModel = await this.resolveBambuModel(args?.bambu_model);
1506
2006
  const printBedType = resolveBedType(args?.bed_type);
1507
- const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2007
+ const explicitPrintNozzle = optionalNozzleDiameterArg(args?.nozzle_diameter);
2008
+ const printNozzle = String(explicitPrintNozzle ?? DEFAULT_NOZZLE_DIAMETER);
1508
2009
  const printPreset = BAMBU_MODEL_PRESETS[printModel]?.(printNozzle);
1509
2010
  let threeMFPath = String(args.three_mf_path);
1510
- // Auto-slice if the 3MF doesn't contain gcode
2011
+ // Only the selected plate's exact G-code entry counts as sliced output;
2012
+ // a stray .gcode entry elsewhere in the archive is not printable.
2013
+ const selectedPlateEntry = "Metadata/plate_1.gcode";
2014
+ let shouldAutoSlice = false;
1511
2015
  try {
1512
2016
  const JSZip = (await import('jszip')).default;
1513
2017
  const zipData = fs.readFileSync(threeMFPath);
1514
2018
  const zip = await JSZip.loadAsync(zipData);
1515
- const hasGcode = Object.keys(zip.files).some(f => f.match(/Metadata\/plate_\d+\.gcode/i) || f.endsWith('.gcode'));
1516
- if (!hasGcode) {
1517
- console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
1518
- threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, slicerPath, slicerProfile || undefined, undefined, // progressCallback
1519
- printPreset, filamentProfile || undefined);
1520
- console.log("Auto-sliced to: " + threeMFPath);
1521
- }
2019
+ const plateEntry = zip.file(selectedPlateEntry);
2020
+ shouldAutoSlice = !plateEntry || plateEntry.dir;
1522
2021
  }
1523
2022
  catch (sliceCheckErr) {
1524
- console.warn("Could not check/slice 3MF, proceeding with original:", sliceCheckErr.message);
2023
+ throw new Error(`Cannot read 3MF archive ${threeMFPath}; nothing was uploaded or started: ${sliceCheckErr?.message ?? String(sliceCheckErr)}`);
1525
2024
  }
2025
+ // Only treat slicer_path as an executable selector when a slicer
2026
+ // will actually launch. Ready-to-print archives ignore it.
2027
+ if (shouldAutoSlice) {
2028
+ const printSlicerPath = this.resolveExecutableSelectorArg(args?.slicer_path, "print_3mf", "slicer_path") ?? DEFAULT_SLICER_PATH;
2029
+ try {
2030
+ console.error(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
2031
+ threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, printSlicerPath, slicerProfile || undefined, undefined, // progressCallback
2032
+ printPreset, filamentProfile || undefined,
2033
+ // Slice for the plate and nozzle the print will be checked against.
2034
+ { bedType: printBedType, ...(resolveNozzleType(args?.nozzle_type) ? { nozzleType: resolveNozzleType(args?.nozzle_type) } : {}) });
2035
+ console.error("Auto-sliced to: " + threeMFPath);
2036
+ }
2037
+ catch (sliceErr) {
2038
+ // Never fall back to uploading the original unsliced project.
2039
+ // Rethrow the original instance (for example a SlicerError) so
2040
+ // structured slicer suggestions survive.
2041
+ const prefix = "Auto-slicing failed; nothing was uploaded or started: ";
2042
+ if (sliceErr instanceof Error) {
2043
+ if (!sliceErr.message.startsWith(prefix))
2044
+ sliceErr.message = `${prefix}${sliceErr.message}`;
2045
+ throw sliceErr;
2046
+ }
2047
+ throw new Error(`${prefix}${String(sliceErr)}`);
2048
+ }
2049
+ }
2050
+ // Compare the sliced file with an explicit or auto-slice nozzle request.
2051
+ const requestedPrintNozzles = explicitPrintNozzle !== undefined || shouldAutoSlice ? [Number(printNozzle)] : undefined;
1526
2052
  // Define variables needed outside the parse try block
1527
2053
  let implementation;
1528
2054
  let threeMfFilename;
@@ -1531,7 +2057,7 @@ class ThreeDPrinterMCPServer {
1531
2057
  try {
1532
2058
  // --- Parse 3MF ---
1533
2059
  const parsed3MFData = await parse3MF(threeMFPath);
1534
- console.log(`Successfully parsed 3MF file: ${threeMFPath}`);
2060
+ console.error(`Successfully parsed 3MF file: ${threeMFPath}`);
1535
2061
  const amsOptions = this.resolveAmsPrintOptions(parsed3MFData, args);
1536
2062
  // --- Prepare Implementation and Print Options ---
1537
2063
  const factoryImplementation = this.printerFactory.getImplementation('bambu');
@@ -1543,6 +2069,8 @@ class ThreeDPrinterMCPServer {
1543
2069
  projectName = threeMfFilename.replace(/\.3mf$/i, ''); // Assign to outer scope variable
1544
2070
  printOptions = {
1545
2071
  ...amsOptions,
2072
+ bambuModel: printModel,
2073
+ nozzleDiameters: requestedPrintNozzles,
1546
2074
  bedType: printBedType,
1547
2075
  bedLeveling: args?.bed_leveling !== undefined ? Boolean(args.bed_leveling) : undefined,
1548
2076
  flowCalibration: args?.flow_calibration !== undefined ? Boolean(args.flow_calibration) : undefined,
@@ -1564,7 +2092,7 @@ class ThreeDPrinterMCPServer {
1564
2092
  plateIndex: 0,
1565
2093
  ...printOptions // Spread the final options
1566
2094
  });
1567
- result = `Print command for ${threeMfFilename} sent successfully.`;
2095
+ // Report what the printer did, not merely that a command was published.
1568
2096
  }
1569
2097
  catch (printError) {
1570
2098
  console.error(`Error starting 3MF print for ${threeMfFilename}:`, printError);
@@ -1573,13 +2101,14 @@ class ThreeDPrinterMCPServer {
1573
2101
  break;
1574
2102
  }
1575
2103
  case "check_fulu_orca_setup": {
2104
+ const runBridgeProbe = Boolean(args?.run_bridge_probe ?? false);
1576
2105
  result = await inspectFuluOrcaSetup({
1577
- slicerPath: args?.slicer_path !== undefined ? String(args.slicer_path) : undefined,
1578
- pluginDir: args?.plugin_dir !== undefined ? String(args.plugin_dir) : undefined,
1579
- runtimeDir: args?.runtime_dir !== undefined ? String(args.runtime_dir) : undefined,
2106
+ slicerPath: this.resolveExecutableSelectorArg(args?.slicer_path, "check_fulu_orca_setup", "slicer_path", runBridgeProbe, true),
2107
+ pluginDir: this.resolveExecutableSelectorArg(args?.plugin_dir, "check_fulu_orca_setup", "plugin_dir", runBridgeProbe, true),
2108
+ runtimeDir: this.resolveExecutableSelectorArg(args?.runtime_dir, "check_fulu_orca_setup", "runtime_dir", runBridgeProbe, true),
1580
2109
  platform: args?.platform !== undefined ? String(args.platform) : undefined,
1581
- bridgeCommand: args?.bridge_command !== undefined ? String(args.bridge_command) : undefined,
1582
- runBridgeProbe: Boolean(args?.run_bridge_probe ?? false),
2110
+ bridgeCommand: this.resolveExecutableSelectorArg(args?.bridge_command, "check_fulu_orca_setup", "bridge_command", true, true),
2111
+ runBridgeProbe,
1583
2112
  probeTimeoutMs: args?.probe_timeout_ms !== undefined ? Number(args.probe_timeout_ms) : undefined,
1584
2113
  });
1585
2114
  break;
@@ -1588,16 +2117,15 @@ class ThreeDPrinterMCPServer {
1588
2117
  if (!args?.method) {
1589
2118
  throw new Error("Missing required parameter: method");
1590
2119
  }
2120
+ // Raw print methods, printer messages and unknown methods are refused
2121
+ // inside invokeFuluBridgeRpc before any bridge process starts.
1591
2122
  const method = String(args.method);
1592
- let rpcBambuModel;
1593
- if (isFuluPrintRpcMethod(method)) {
1594
- rpcBambuModel = await this.resolveBambuModel(args?.bambu_model);
1595
- }
2123
+ const rpcBambuModel = typeof args?.bambu_model === "string" && args.bambu_model.trim() ? args.bambu_model.trim() : undefined;
1596
2124
  const payload = typeof args.payload === "object" && args.payload !== null && !Array.isArray(args.payload)
1597
2125
  ? args.payload
1598
2126
  : undefined;
1599
2127
  result = await invokeFuluBridgeRpc({
1600
- bridgeCommand: args?.bridge_command !== undefined ? String(args.bridge_command) : undefined,
2128
+ bridgeCommand: this.resolveExecutableSelectorArg(args?.bridge_command, "fulu_bambu_network_rpc", "bridge_command", true, true),
1601
2129
  method,
1602
2130
  payload,
1603
2131
  timeoutMs: args?.timeout_ms !== undefined ? Number(args.timeout_ms) : undefined,
@@ -1625,16 +2153,16 @@ class ThreeDPrinterMCPServer {
1625
2153
  }
1626
2154
  result = await this.stlManipulator.layFlat(String(args.stl_path));
1627
2155
  break;
2156
+ case "blender_mcp_status":
2157
+ result = await this.blender.status(args ?? {}, extra.signal);
2158
+ break;
2159
+ case "blender_mcp_call":
2160
+ return await this.blender.call(args ?? {}, extra.signal);
2161
+ case "blender_mcp_export_stl":
2162
+ result = await this.blender.exportStl(args ?? {}, extra.signal);
2163
+ break;
1628
2164
  case "blender_mcp_edit_model":
1629
- if (!args?.stl_path || !Array.isArray(args.operations)) {
1630
- throw new Error("Missing required parameters: stl_path and operations");
1631
- }
1632
- result = await this.invokeBlenderBridge({
1633
- stlPath: String(args.stl_path),
1634
- operations: args.operations.map((entry) => String(entry)),
1635
- execute: Boolean(args.execute ?? false),
1636
- bridgeCommand: args.bridge_command !== undefined ? String(args.bridge_command) : undefined,
1637
- });
2165
+ result = await this.blender.edit(args ?? {}, this.resolveExecutableSelectorArg(args?.bridge_command, "blender_mcp_edit_model", "bridge_command", true, true) ?? (!process.env.BLENDER_MCP_COMMAND?.trim() ? this.runtimeConfig.blenderBridgeCommand : undefined), extra.signal);
1638
2166
  break;
1639
2167
  default:
1640
2168
  throw new Error(`Unknown tool: ${name}`);
@@ -1651,7 +2179,7 @@ class ThreeDPrinterMCPServer {
1651
2179
  catch (error) {
1652
2180
  console.error(`Error calling tool ${name}:`, error);
1653
2181
  const errorMessage = error instanceof Error ? error.message : String(error);
1654
- const structuredError = this.toStructuredToolError(name, errorMessage);
2182
+ const structuredError = this.toStructuredToolError(name, errorMessage, error);
1655
2183
  return {
1656
2184
  content: [
1657
2185
  {
@@ -1690,34 +2218,38 @@ class ThreeDPrinterMCPServer {
1690
2218
  }
1691
2219
  return implementation.getFile(host, port, apiKey, filename);
1692
2220
  }
1693
- async uploadGcode(host, port, type, apiKey, bambuSerial, bambuToken, filename, gcode, print) {
2221
+ async uploadGcode(host, port, type, apiKey, bambuSerial, bambuToken, filename, gcode, print, options = {}) {
1694
2222
  const sourceIsFilePath = fs.existsSync(gcode) && fs.statSync(gcode).isFile();
1695
- const tempFilePath = sourceIsFilePath ? gcode : path.join(TEMP_DIR, filename);
1696
- if (!sourceIsFilePath) {
1697
- fs.mkdirSync(TEMP_DIR, { recursive: true });
1698
- fs.writeFileSync(tempFilePath, gcode);
2223
+ // The remote filename must never select a local write/delete target.
2224
+ const localFilename = path.basename(filename);
2225
+ if (!sourceIsFilePath && (!localFilename || localFilename === "." || localFilename === "..")) {
2226
+ throw new Error("filename must include a file basename.");
1699
2227
  }
2228
+ const scratchDir = sourceIsFilePath ? undefined : fs.mkdtempSync(path.join(TEMP_DIR, "upload-"));
2229
+ // Multipart adapters infer their file-part name from this safe basename.
2230
+ const tempFilePath = scratchDir ? path.join(scratchDir, localFilename) : gcode;
1700
2231
  try {
2232
+ if (scratchDir)
2233
+ fs.writeFileSync(tempFilePath, gcode);
1701
2234
  const implementation = this.printerFactory.getImplementation(type);
1702
2235
  if (type.toLowerCase() === "bambu") {
1703
2236
  const bambuApiKey = `${bambuSerial}:${bambuToken}`;
1704
- return await implementation.uploadFile(host, port, bambuApiKey, tempFilePath, filename, print);
2237
+ return await implementation.uploadFile(host, port, bambuApiKey, tempFilePath, filename, print, options);
1705
2238
  }
1706
- return await implementation.uploadFile(host, port, apiKey, tempFilePath, filename, print);
2239
+ return await implementation.uploadFile(host, port, apiKey, tempFilePath, filename, print, options);
1707
2240
  }
1708
2241
  finally {
1709
- if (!sourceIsFilePath && fs.existsSync(tempFilePath)) {
1710
- fs.unlinkSync(tempFilePath);
1711
- }
2242
+ if (scratchDir)
2243
+ fs.rmSync(scratchDir, { recursive: true, force: true });
1712
2244
  }
1713
2245
  }
1714
- async startPrint(host, port, type, apiKey, bambuSerial, bambuToken, gcodeFilename) {
2246
+ async startPrint(host, port, type, apiKey, bambuSerial, bambuToken, gcodeFilename, options = {}) {
1715
2247
  const implementation = this.printerFactory.getImplementation(type);
1716
2248
  if (type.toLowerCase() === "bambu") {
1717
2249
  const bambuApiKey = `${bambuSerial}:${bambuToken}`;
1718
- return await implementation.startJob(host, port, bambuApiKey, gcodeFilename);
2250
+ return await implementation.startJob(host, port, bambuApiKey, gcodeFilename, options);
1719
2251
  }
1720
- return await implementation.startJob(host, port, apiKey, gcodeFilename);
2252
+ return await implementation.startJob(host, port, apiKey, gcodeFilename, options);
1721
2253
  }
1722
2254
  async cancelPrint(host, port, type, apiKey, bambuSerial, bambuToken) {
1723
2255
  const implementation = this.printerFactory.getImplementation(type);
@@ -1727,15 +2259,48 @@ class ThreeDPrinterMCPServer {
1727
2259
  }
1728
2260
  return await implementation.cancelJob(host, port, apiKey);
1729
2261
  }
1730
- async setPrinterTemperature(host, port, type, apiKey, bambuSerial, bambuToken, component, temperature) {
2262
+ async setPrinterTemperature(host, port, type, apiKey, bambuSerial, bambuToken, component, temperature, options = {}) {
1731
2263
  const implementation = this.printerFactory.getImplementation(type);
1732
2264
  if (type.toLowerCase() === "bambu") {
1733
2265
  const bambuApiKey = `${bambuSerial}:${bambuToken}`;
1734
- return implementation.setTemperature(host, port, bambuApiKey, component, temperature);
2266
+ return implementation.setTemperature(host, port, bambuApiKey, component, temperature, options);
1735
2267
  }
1736
- return implementation.setTemperature(host, port, apiKey, component, temperature);
2268
+ return implementation.setTemperature(host, port, apiKey, component, temperature, options);
1737
2269
  }
1738
- toStructuredToolError(tool, message) {
2270
+ toStructuredToolError(tool, message, error) {
2271
+ // Slicer failures are local profile/slicer problems, never printer connectivity.
2272
+ if (error instanceof SlicerError) {
2273
+ return {
2274
+ status: "error",
2275
+ retryable: false,
2276
+ suggestion: error.suggestion,
2277
+ message,
2278
+ tool,
2279
+ slicer: { phase: error.phase, ...error.details },
2280
+ };
2281
+ }
2282
+ if (SLICING_TOOLS.has(tool)) {
2283
+ return {
2284
+ status: "error",
2285
+ retryable: false,
2286
+ suggestion: "Check the slicing arguments, template, and profile paths named in the error, then retry.",
2287
+ message,
2288
+ tool,
2289
+ };
2290
+ }
2291
+ if (isCredentialRejection(message)) {
2292
+ return { status: "error", retryable: false, suggestion: credentialSuggestion(), message, tool };
2293
+ }
2294
+ // Safety refusals stop before the printer is touched; retrying unchanged repeats the refusal.
2295
+ 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)) {
2296
+ return {
2297
+ status: "error",
2298
+ retryable: false,
2299
+ suggestion: "A safety check stopped this before anything reached the printer. Read the reason, fix the file, arguments, or printer state, and ask again.",
2300
+ message,
2301
+ tool,
2302
+ };
2303
+ }
1739
2304
  const isInputError = message.toLowerCase().includes("missing required") ||
1740
2305
  message.toLowerCase().includes("invalid") ||
1741
2306
  message.toLowerCase().includes("unsupported");
@@ -1847,57 +2412,32 @@ class ThreeDPrinterMCPServer {
1847
2412
  this.httpRuntime = { transport, httpServer };
1848
2413
  console.error(`3D Printer MCP server running on streamable-http at http://${this.runtimeConfig.httpHost}:${this.runtimeConfig.httpPort}${this.runtimeConfig.httpPath}`);
1849
2414
  }
1850
- async invokeBlenderBridge(params) {
1851
- const command = params.bridgeCommand ?? this.runtimeConfig.blenderBridgeCommand;
1852
- const payload = {
1853
- modelPath: params.stlPath,
1854
- operations: params.operations,
1855
- source: "mcp-3d-printer-server"
1856
- };
1857
- if (!params.execute) {
1858
- return {
1859
- status: "prepared",
1860
- message: "Prepared Blender MCP payload. Set execute=true to run bridge command.",
1861
- bridgeCommand: command ?? null,
1862
- payload
1863
- };
2415
+ /**
2416
+ * Bridge commands and path-derived bridge executables are machine-local
2417
+ * settings. Accepting their selectors as tool arguments means anything that
2418
+ * can steer the model — a model description, a README in a downloaded
2419
+ * archive, 3MF metadata — can choose the program this server runs. They are
2420
+ * read from environment configuration by default and only accepted for
2421
+ * execution when MCP_ALLOW_EXECUTABLE_ARG is set. The older
2422
+ * MCP_ALLOW_BRIDGE_COMMAND_ARG name remains a compatibility alias only for
2423
+ * bridge commands and path selectors used to derive a bridge executable.
2424
+ */
2425
+ resolveExecutableSelectorArg(rawValue, toolName, argumentName, executionRequested = true, allowLegacyBridgeArg = false) {
2426
+ if (rawValue === undefined) {
2427
+ return undefined;
1864
2428
  }
1865
- if (!command) {
1866
- throw new Error("execute=true requires bridge_command or BLENDER_MCP_BRIDGE_COMMAND env var.");
2429
+ const value = String(rawValue);
2430
+ if (value.length === 0) {
2431
+ return undefined;
1867
2432
  }
1868
- const { spawn } = await import("node:child_process");
1869
- const result = await new Promise((resolve, reject) => {
1870
- const child = spawn(command, {
1871
- shell: true,
1872
- stdio: ["pipe", "pipe", "pipe"]
1873
- });
1874
- let stdout = "";
1875
- let stderr = "";
1876
- child.stdout.setEncoding("utf8");
1877
- child.stderr.setEncoding("utf8");
1878
- child.stdout.on("data", (chunk) => {
1879
- stdout += chunk;
1880
- });
1881
- child.stderr.on("data", (chunk) => {
1882
- stderr += chunk;
1883
- });
1884
- child.on("error", reject);
1885
- child.on("close", (code) => {
1886
- if (code === 0) {
1887
- resolve({ stdout, stderr });
1888
- return;
1889
- }
1890
- reject(new Error(`Blender bridge command exited with code ${code ?? "unknown"}${stderr.trim() ? `: ${stderr.trim()}` : ""}`));
1891
- });
1892
- child.stdin.write(JSON.stringify(payload));
1893
- child.stdin.end();
1894
- });
1895
- return {
1896
- status: "executed",
1897
- bridgeCommand: command,
1898
- stdout: result.stdout?.toString() ?? "",
1899
- stderr: result.stderr?.toString() ?? ""
1900
- };
2433
+ const isAllowed = this.runtimeConfig.allowExecutableArg ||
2434
+ (allowLegacyBridgeArg && this.runtimeConfig.allowBridgeCommandArg);
2435
+ if (executionRequested && !isAllowed) {
2436
+ throw new Error(`${toolName}: the ${argumentName} argument cannot select an executable by default. ` +
2437
+ "Executable paths and commands are read from server environment configuration. Set " +
2438
+ `MCP_ALLOW_EXECUTABLE_ARG=1 to accept ${argumentName} for process execution.`);
2439
+ }
2440
+ return value;
1901
2441
  }
1902
2442
  async close() {
1903
2443
  await this.printerFactory.disconnectAll();