mcp-3d-printer-server 1.2.24 → 1.2.26

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 (96) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/CONTRIBUTORS.md +5 -1
  3. package/README.md +64 -29
  4. package/dist/ams-mapping.d.ts +38 -0
  5. package/dist/ams-mapping.js +125 -0
  6. package/dist/bambu-network-bridge.d.ts +200 -0
  7. package/dist/bambu-network-bridge.js +881 -0
  8. package/dist/bambu-network-certs.d.ts +13 -0
  9. package/dist/bambu-network-certs.js +152 -0
  10. package/dist/control-reply.d.ts +21 -0
  11. package/dist/control-reply.js +44 -0
  12. package/dist/counterpart/camera-sources.d.ts +16 -0
  13. package/dist/counterpart/camera-sources.js +138 -0
  14. package/dist/counterpart/camera-stream.d.ts +66 -0
  15. package/dist/counterpart/camera-stream.js +246 -0
  16. package/dist/counterpart/camera-tools.d.ts +247 -0
  17. package/dist/counterpart/camera-tools.js +35 -0
  18. package/dist/counterpart/contract.d.ts +21 -5
  19. package/dist/counterpart/contract.js +8 -2
  20. package/dist/counterpart/route.d.ts +31 -0
  21. package/dist/counterpart/route.js +70 -0
  22. package/dist/counterpart/service.d.ts +60 -4
  23. package/dist/counterpart/service.js +142 -16
  24. package/dist/design-guidance.d.ts +26 -0
  25. package/dist/design-guidance.js +177 -0
  26. package/dist/fulu-orca.d.ts +7 -0
  27. package/dist/fulu-orca.js +18 -2
  28. package/dist/index.js +528 -72
  29. package/dist/printers/bambu-filaments.d.ts +68 -0
  30. package/dist/printers/bambu-filaments.js +262 -0
  31. package/dist/printers/bambu.d.ts +99 -6
  32. package/dist/printers/bambu.js +478 -72
  33. package/dist/printers/creality.d.ts +2 -1
  34. package/dist/printers/creality.js +5 -1
  35. package/dist/printers/generic-base.d.ts +11 -6
  36. package/dist/printers/generic-base.js +21 -25
  37. package/dist/printers/klipper.d.ts +2 -1
  38. package/dist/printers/klipper.js +10 -7
  39. package/dist/printers/octoprint.d.ts +2 -1
  40. package/dist/printers/octoprint.js +15 -8
  41. package/dist/printers/printer-factory.d.ts +1 -1
  42. package/dist/printers/printer-factory.js +3 -3
  43. package/dist/printers/prusa.js +0 -1
  44. package/dist/safety/confirmation.d.ts +11 -16
  45. package/dist/safety/confirmation.js +8 -32
  46. package/dist/safety/hidden-hms.d.ts +32 -0
  47. package/dist/safety/hidden-hms.js +405 -0
  48. package/dist/safety/limits.d.ts +8 -2
  49. package/dist/safety/limits.js +14 -5
  50. package/dist/safety/print-file.d.ts +5 -0
  51. package/dist/safety/print-file.js +156 -19
  52. package/dist/safety/printer-state.d.ts +59 -8
  53. package/dist/safety/printer-state.js +174 -39
  54. package/dist/slicer/bambu-cli.d.ts +6 -2
  55. package/dist/slicer/bambu-cli.js +19 -12
  56. package/dist/slicer/layer-pauses.d.ts +47 -0
  57. package/dist/slicer/layer-pauses.js +130 -0
  58. package/dist/slicer/slicer-error.d.ts +11 -1
  59. package/dist/slicer/slicer-error.js +16 -2
  60. package/dist/stl/stl-manipulator.d.ts +27 -1
  61. package/dist/stl/stl-manipulator.js +216 -76
  62. package/dist/studio-print-options.d.ts +57 -0
  63. package/dist/studio-print-options.js +179 -0
  64. package/package.json +25 -4
  65. package/server.json +2 -2
  66. package/src/ams-mapping.ts +132 -0
  67. package/src/bambu-network-bridge.ts +1075 -0
  68. package/src/bambu-network-certs.ts +154 -0
  69. package/src/control-reply.ts +41 -0
  70. package/src/counterpart/camera-sources.ts +102 -0
  71. package/src/counterpart/camera-stream.ts +195 -0
  72. package/src/counterpart/camera-tools.ts +35 -0
  73. package/src/counterpart/contract.ts +12 -4
  74. package/src/counterpart/route.ts +82 -0
  75. package/src/counterpart/service.ts +150 -16
  76. package/src/design-guidance.ts +188 -0
  77. package/src/fulu-orca.ts +18 -2
  78. package/src/index.ts +546 -87
  79. package/src/printers/bambu-filaments.ts +346 -0
  80. package/src/printers/bambu.ts +506 -81
  81. package/src/printers/creality.ts +8 -3
  82. package/src/printers/generic-base.ts +33 -40
  83. package/src/printers/klipper.ts +12 -8
  84. package/src/printers/octoprint.ts +16 -9
  85. package/src/printers/printer-factory.ts +3 -3
  86. package/src/printers/prusa.ts +0 -1
  87. package/src/safety/confirmation.ts +15 -38
  88. package/src/safety/hidden-hms.ts +406 -0
  89. package/src/safety/limits.ts +15 -5
  90. package/src/safety/print-file.ts +139 -17
  91. package/src/safety/printer-state.ts +179 -40
  92. package/src/slicer/bambu-cli.ts +22 -13
  93. package/src/slicer/layer-pauses.ts +143 -0
  94. package/src/slicer/slicer-error.ts +21 -2
  95. package/src/stl/stl-manipulator.ts +254 -84
  96. package/src/studio-print-options.ts +201 -0
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import "./ensure-web-crypto.js";
4
4
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
- import { ListResourcesRequestSchema, ReadResourceRequestSchema, ListToolsRequestSchema, CallToolRequestSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
7
+ import { ListResourcesRequestSchema, ReadResourceRequestSchema, ListToolsRequestSchema, CallToolRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
8
8
  import axios from "axios";
9
9
  import dotenv from "dotenv";
10
10
  import fs from "fs";
@@ -18,15 +18,27 @@ import { randomUUID } from "node:crypto";
18
18
  import * as THREE from 'three';
19
19
  import { PrinterFactory } from "./printers/printer-factory.js";
20
20
  import { STLManipulator } from "./stl/stl-manipulator.js";
21
+ import { parsePauseLayers, readPlateGcode, pausedLayers, machinePauseGcode } from "./slicer/layer-pauses.js";
21
22
  import { CounterpartService } from "./counterpart/service.js";
23
+ import { CAMERA_STREAM_TOOLS } from "./counterpart/camera-tools.js";
22
24
  import { CAMERA_SNAPSHOT_OUTPUT_SCHEMA, CONTROL_OUTPUT_SCHEMA, FILAMENTS_OUTPUT_SCHEMA, PRINT_PROGRESS_OUTPUT_SCHEMA, PRINTER_INFO_OUTPUT_SCHEMA, } from "./counterpart/contract.js";
23
25
  import { SlicerError } from "./slicer/slicer-error.js";
24
26
  import { inspectSliceSettings, listTemplateRegistry, resolveTemplatePath, resolveTemplateSlicerProfilePath, saveTemplate, } from "./slicer/templates.js";
25
27
  import { parse3MF } from './3mf_parser.js';
26
28
  import { BambuImplementation } from "./printers/bambu.js";
27
29
  import { inspectFuluOrcaSetup, invokeFuluBridgeRpc, } from "./fulu-orca.js";
30
+ import { bridgeAmsMapping2Entry, bridgeAmsMappingValue, externalTrayForNozzle, hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue, studioAmsMappingInfo, trayColour, } from "./ams-mapping.js";
31
+ import { isDualNozzleModel } from "./safety/limits.js";
32
+ import { BambuNetworkBridge, describeBambuNetworkPrintError } from "./bambu-network-bridge.js";
33
+ import { PRINT_ROUTES, connectionForRoute, routeOf, routeOptions, writeEnvValues } from "./counterpart/route.js";
34
+ import { resolveStudioPrintOptions, usedNozzleCount } from "./studio-print-options.js";
35
+ import { inspectPrintFile } from "./safety/print-file.js";
36
+ import { validatePrinterState } from "./safety/printer-state.js";
37
+ import { uniquePrintName, withPrinterOperation, withPrintSnapshot } from "./safety/artifact.js";
38
+ import { checkedFile } from "./printers/bambu.js";
28
39
  import { CONFIRMATION_UNAVAILABLE, confirmationTimedOut, confirmationTimeoutMs } from "./safety/confirmation.js";
29
40
  import { validateExpectedPeaks } from "./safety/expected-peaks.js";
41
+ import { DESIGN_PROMPTS, FDM_CAPABILITY_GUIDANCE, FDM_CAPABILITY_RESOURCE, FDM_CAPABILITY_URI, SERVER_INSTRUCTIONS } from "./design-guidance.js";
30
42
  // Load environment variables from .env file
31
43
  dotenv.config();
32
44
  const SERVER_NAME = "mcp-3d-printer-server";
@@ -36,7 +48,10 @@ const DEFAULT_HOST = process.env.PRINTER_HOST || "localhost";
36
48
  const DEFAULT_PORT = process.env.PRINTER_PORT || "80";
37
49
  const DEFAULT_API_KEY = process.env.API_KEY || "";
38
50
  const DEFAULT_TYPE = process.env.PRINTER_TYPE || "octoprint"; // Default to OctoPrint
39
- const TEMP_DIR = process.env.TEMP_DIR || fs.mkdtempSync(path.join(os.tmpdir(), "mcp-3d-printer-server-"));
51
+ // Resolve an explicit TEMP_DIR (e.g. ./temp) against the server's working directory, as
52
+ // dotenv's own .env lookup is: slicer CLIs receive paths under it, and Bambu Studio fails
53
+ // every 3MF export to a relative --outputdir (bambu-printer-mcp 1.1.44 fixed the same).
54
+ const TEMP_DIR = process.env.TEMP_DIR ? path.resolve(process.env.TEMP_DIR) : fs.mkdtempSync(path.join(os.tmpdir(), "mcp-3d-printer-server-"));
40
55
  // Slicer configuration
41
56
  const CANONICAL_SLICER_TYPES = [
42
57
  "prusaslicer",
@@ -111,7 +126,10 @@ function defaultBambuStudioPath() {
111
126
  ]) ||
112
127
  "bambustudio");
113
128
  }
114
- const DEFAULT_SLICER_TYPE = normalizeSlicerType(process.env.SLICER_TYPE || (DEFAULT_TYPE.toLowerCase() === "bambu" ? "orcaslicer-bambulab" : "prusaslicer"));
129
+ const DEFAULT_SLICER_TYPE = normalizeSlicerType(
130
+ // Bambu Studio for Bambu printers, as in bambu-printer-mcp: FULU OrcaSlicer-bambulab v1.0.0's CLI
131
+ // segfaults on every slice (FULU #56/#57 open), so it is chosen explicitly, not by default.
132
+ process.env.SLICER_TYPE || (DEFAULT_TYPE.toLowerCase() === "bambu" ? "bambustudio" : "prusaslicer"));
115
133
  const DEFAULT_SLICER_PATH = process.env.SLICER_PATH ||
116
134
  (DEFAULT_SLICER_TYPE === "orcaslicer-bambulab"
117
135
  ? defaultFuluOrcaPath()
@@ -127,6 +145,49 @@ const DEFAULT_BAMBU_TOKEN = process.env.BAMBU_TOKEN || "";
127
145
  const DEFAULT_BAMBU_MODEL = process.env.BAMBU_MODEL?.trim().toLowerCase() || "";
128
146
  const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
129
147
  const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
148
+ // counterpart_print_route changes these at run time (and in the .env file they came from).
149
+ let DEFAULT_BAMBU_CONNECTION_MODE = process.env.BAMBU_DEFAULT_CONNECTION_MODE?.trim().toLowerCase() || "";
150
+ let DEFAULT_BAMBU_CONNECTION_TYPE = process.env.BAMBU_DEFAULT_CONNECTION_TYPE?.trim().toLowerCase() || "";
151
+ const CONNECTION_MODES = ["lan_mqtt_ftps", "bambu_network", "bambu_native", "bambu_connect"];
152
+ /**
153
+ * The one route decision for a Bambu printer, as in bambu-printer-mcp:
154
+ * print_3mf and every print control (cancel, pause, resume) resolve it here,
155
+ * so a job is never controlled over a different connection than the one that
156
+ * started it. Arguments override BAMBU_DEFAULT_CONNECTION_MODE/_TYPE; without
157
+ * either, direct LAN MQTT/FTPS. The type (lan or cloud) applies to FULU's
158
+ * BambuNetwork route and defaults to cloud, as bambu-printer-mcp's does.
159
+ */
160
+ function resolveConnection(args) {
161
+ const mode = String(args?.connection_mode || DEFAULT_BAMBU_CONNECTION_MODE || "lan_mqtt_ftps").trim().toLowerCase();
162
+ if (!CONNECTION_MODES.includes(mode))
163
+ throw new Error("Unsupported connection_mode.");
164
+ const type = String(args?.connection_type || DEFAULT_BAMBU_CONNECTION_TYPE || "cloud").trim().toLowerCase();
165
+ if (!["cloud", "lan"].includes(type))
166
+ throw new Error('connection_type must be "cloud" or "lan".');
167
+ return { mode, type };
168
+ }
169
+ /** bambu-printer-mcp's installed-plug-in (X2D native) and Bambu Connect routes are not part of this server. */
170
+ function assertConnectionAvailable(mode) {
171
+ if (mode === "bambu_native" || mode === "bambu_connect") {
172
+ throw new Error(`connection_mode ${mode} is not available in mcp-3D-printer-server. Use lan_mqtt_ftps or bambu_network (FULU), ` +
173
+ `or bambu-printer-mcp for ${mode === "bambu_native" ? "the X2D native route" : "Bambu Connect"}. Nothing was sent.`);
174
+ }
175
+ }
176
+ const CONNECTION_PROPERTIES = {
177
+ connection_mode: { type: "string", enum: ["lan_mqtt_ftps", "bambu_network"], description: "Bambu only: the connection the print uses (print_3mf) or used (controls). lan_mqtt_ftps is direct LAN MQTT/FTPS (Developer Mode); bambu_network is FULU's authenticated BambuNetwork bridge. Defaults to BAMBU_DEFAULT_CONNECTION_MODE, else lan_mqtt_ftps." },
178
+ connection_type: { type: "string", enum: ["lan", "cloud"], description: "Bambu only: FULU BambuNetwork connection type. lan is the authenticated local network session; cloud is Bambu Cloud. Defaults to BAMBU_DEFAULT_CONNECTION_TYPE, else cloud." },
179
+ };
180
+ /** The Send dialog options a caller set explicitly; the rest resolve as Bambu Studio does (studio-print-options.ts). */
181
+ function callerPrintOptions(args) {
182
+ const flag = (value) => value === undefined ? undefined : Boolean(value);
183
+ const options = {
184
+ bedLeveling: flag(args?.bed_leveling),
185
+ flowCalibration: flag(args?.flow_calibration),
186
+ vibrationCalibration: flag(args?.vibration_calibration),
187
+ layerInspect: flag(args?.layer_inspect),
188
+ };
189
+ return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined));
190
+ }
130
191
  const VALID_BAMBU_MODELS = ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"];
131
192
  const VALID_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"];
132
193
  // Map model IDs to BambuStudio --load-machine preset names
@@ -164,6 +225,7 @@ function resolveNozzleType(value) {
164
225
  }
165
226
  // Shared slice_stl / slice_with_template argument schema.
166
227
  const SLICE_TOOL_PROPERTIES = {
228
+ pause_at_layers: { type: "array", items: { type: "integer", minimum: 2 }, description: "Pause before these 1-based layers, for example to insert magnets. Bambu-compatible CLI only; the slicer writes the printer's own pause G-code. Existing project pauses are preserved." },
167
229
  bambu_model: {
168
230
  type: "string",
169
231
  enum: [...SLICE_BAMBU_MODELS],
@@ -228,7 +290,7 @@ const SLICE_TOOL_PROPERTIES = {
228
290
  description: "Template directory override when resolving template_name (default: BAMBU_TEMPLATE_DIR or ~/Sync/bambu/templates).",
229
291
  },
230
292
  uptodate: { type: "boolean", description: "Bambu-compatible slicing: refresh 3MF preset configs to the installed slicer version (--uptodate)." },
231
- repetitions: { type: "number", description: "Bambu-compatible slicing: print N identical copies (--repetitions). OrcaSlicer copies an STL with --clone-objects; do not combine with clone_objects." },
293
+ repetitions: { type: "number", description: "Bambu-compatible slicing: print N identical copies. STL inputs use --clone-objects; selected 3MF plates retain --repetitions. Do not combine with clone_objects for STL." },
232
294
  orient: { type: "boolean", description: "Bambu-compatible slicing: auto-orient for printability (--orient)." },
233
295
  arrange: { type: "boolean", description: "Bambu-compatible slicing: auto-arrange objects on the plate (--arrange); STL input is arranged by default. Set false to keep an existing layout: an STL that is not already on the plate then fails to slice." },
234
296
  ensure_on_bed: { type: "boolean", description: "Bambu-compatible slicing: lower floating models onto the bed (--ensure-on-bed)." },
@@ -282,6 +344,7 @@ const PRINTER_TARGET_PROPERTIES = {
282
344
  /** Tools that declare an outputSchema: their errors carry no structuredContent, which clients would validate. */
283
345
  const OUTPUT_SCHEMA_TOOLS = new Set([
284
346
  "counterpart_printer_info", "counterpart_print_progress", "camera_snapshot", "get_printer_filaments", "pause_print", "resume_print",
347
+ "camera_stream_start", "camera_frame", "camera_stream_stop",
285
348
  "fusion_mcp_status", "fusion_mcp_call", "fusion_mcp_new_design", "fusion_mcp_export",
286
349
  ]);
287
350
  /** layer_height / wall_loops / sparse_infill_density (PRD §8.4), validated before slicing. */
@@ -375,6 +438,7 @@ function buildBambuSliceOptions(args) {
375
438
  options.rotateX = optionalFiniteNumber(args, "rotate_x");
376
439
  options.rotateY = optionalFiniteNumber(args, "rotate_y");
377
440
  options.slicePlate = optionalFiniteNumber(args, "slice_plate");
441
+ options.pauseAtLayers = parsePauseLayers(args?.pause_at_layers);
378
442
  if (options.repetitions !== undefined && (!Number.isInteger(options.repetitions) || options.repetitions < 1)) {
379
443
  throw new Error("Invalid repetitions: expected a positive integer.");
380
444
  }
@@ -431,6 +495,11 @@ function validateBambuModel(model) {
431
495
  }
432
496
  return normalized;
433
497
  }
498
+ /** An inventory model hint: the argument, else BAMBU_MODEL; validated when present, never required. */
499
+ function optionalBambuModelHint(value) {
500
+ const requested = String(value ?? DEFAULT_BAMBU_MODEL ?? "").trim().toLowerCase();
501
+ return requested ? validateBambuModel(requested) : undefined;
502
+ }
434
503
  function resolveBedType(bedType) {
435
504
  const resolved = (bedType || DEFAULT_BED_TYPE).trim().toLowerCase();
436
505
  if (!VALID_BED_TYPES.includes(resolved)) {
@@ -543,23 +612,27 @@ class ThreeDPrinterMCPServer {
543
612
  constructor() {
544
613
  this.blender = new BlenderMcpBridge();
545
614
  this.fusion = new FusionMcpBridge();
615
+ /** FULU's BambuNetwork bridge host, started on first use by the bambu_network route. */
616
+ this.bambuNetwork = new BambuNetworkBridge();
546
617
  this.runtimeConfig = readRuntimeConfig();
547
618
  this.server = new Server({
548
619
  name: SERVER_NAME,
549
620
  version: SERVER_VERSION
550
621
  }, {
551
622
  capabilities: {
623
+ prompts: {},
552
624
  resources: {},
553
625
  tools: {}
554
- }
626
+ },
627
+ instructions: SERVER_INSTRUCTIONS
555
628
  });
556
629
  this.printerFactory = new PrinterFactory((message) => this.confirmHardware(message));
557
- this.counterpart = new CounterpartService((printerType) => this.printerFactory.getImplementation(printerType), axios.create({ timeout: 10000 }), { name: SERVER_NAME, version: SERVER_VERSION }, (material) => resolveFilamentProfile(material, defaultFilamentProfileContext()));
630
+ this.counterpart = new CounterpartService((printerType) => this.printerFactory.getImplementation(printerType), axios.create({ timeout: 10000 }), { name: SERVER_NAME, version: SERVER_VERSION }, (material) => resolveFilamentProfile(material, defaultFilamentProfileContext()), () => ({ slicerType: DEFAULT_SLICER_TYPE, slicerPath: DEFAULT_SLICER_PATH || undefined }));
558
631
  this.stlManipulator = new STLManipulator(TEMP_DIR);
559
632
  this.setupHandlers();
560
633
  this.setupErrorHandling();
561
634
  }
562
- /** Human MCP elicitation before a print start or positive heating command. */
635
+ /** Human MCP elicitation for a physical check software cannot make (resuming a job that cannot be re-read). */
563
636
  async confirmHardware(message) {
564
637
  let response;
565
638
  const timeout = confirmationTimeoutMs();
@@ -577,7 +650,7 @@ class ThreeDPrinterMCPServer {
577
650
  }, { timeout });
578
651
  }
579
652
  catch (error) {
580
- // A person may take minutes to check the bed; a timeout is not a missing capability.
653
+ // A person may take minutes to check the printer; a timeout is not a missing capability.
581
654
  if (error?.code === ErrorCode.RequestTimeout)
582
655
  throw new Error(confirmationTimedOut(timeout));
583
656
  throw new Error(CONFIRMATION_UNAVAILABLE);
@@ -655,6 +728,9 @@ class ThreeDPrinterMCPServer {
655
728
  // Generic OrcaSlicer keeps its user-supplied machine profile unless the
656
729
  // call explicitly targets a Bambu model.
657
730
  const bambuCli = isBambuProjectSlicer(slicerType) || (slicerType === "orcaslicer" && explicitModel !== undefined);
731
+ if (!bambuCli && parsePauseLayers(args.pause_at_layers)) {
732
+ throw new Error(`Pausing at a layer needs the Bambu Studio or OrcaSlicer CLI, not ${slicerType}.`);
733
+ }
658
734
  let printerPreset;
659
735
  let options;
660
736
  if (bambuCli) {
@@ -692,7 +768,7 @@ class ThreeDPrinterMCPServer {
692
768
  slicerProfile = slicerProfile.split("|", 1)[0];
693
769
  }
694
770
  const slicerPath = this.resolveExecutableSelectorArg(args.slicer_path, toolName, "slicer_path") ?? defaultSlicerPathFor(slicerType);
695
- return this.stlManipulator.sliceSTL(String(args.stl_path), slicerType, slicerPath, slicerProfile, undefined, // progressCallback
771
+ return this.stlManipulator.sliceSTLWithResult(String(args.stl_path), slicerType, slicerPath, slicerProfile, undefined, // progressCallback
696
772
  printerPreset, filamentProfile, options, processOverrides);
697
773
  }
698
774
  setupErrorHandling() {
@@ -702,44 +778,245 @@ class ThreeDPrinterMCPServer {
702
778
  }
703
779
  setupHandlers() {
704
780
  this.setupResourceHandlers();
781
+ this.setupPromptHandlers();
705
782
  this.setupToolHandlers();
706
783
  }
707
- extractParsedAmsMapping(parsed3MFData) {
708
- if (!parsed3MFData?.slicerConfig?.ams_mapping) {
709
- return undefined;
784
+ /**
785
+ * The AMS choice for a Bambu print, as bambu-printer-mcp's print_3mf resolves
786
+ * it: the 3MF's own mapping (ordered by project filament index), overridden by
787
+ * an explicit ams_mapping, which ams_slots (one tray per used filament)
788
+ * replaces. use_ams:false prints from the external spool. Tray values are
789
+ * checked here; the adapter expands ams_slots against the inspected plate.
790
+ */
791
+ resolveAmsPrintOptions(parsed3MFData, args, model) {
792
+ const trayOptions = { dualNozzle: isDualNozzleModel(model) };
793
+ let finalAmsMapping;
794
+ let finalAmsSlots;
795
+ if (parsed3MFData?.slicerConfig?.ams_mapping) {
796
+ const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping, "ams_mapping", trayOptions);
797
+ if (slots.length > 0)
798
+ finalAmsMapping = slots;
710
799
  }
711
- const slots = Object.values(parsed3MFData.slicerConfig.ams_mapping).filter((value) => typeof value === "number");
712
- return slots.length > 0 ? slots.sort((a, b) => a - b) : undefined;
713
- }
714
- resolveAmsPrintOptions(parsed3MFData, args) {
715
- let finalAmsMapping = this.extractParsedAmsMapping(parsed3MFData);
716
- let useAMS = args?.use_ams !== undefined
717
- ? Boolean(args.use_ams)
718
- : !!finalAmsMapping && finalAmsMapping.length > 0;
719
- const override = args?.ams_mapping;
720
- if (override !== undefined) {
721
- let userMappingOverride;
722
- if (Array.isArray(override)) {
723
- userMappingOverride = override.filter((value) => typeof value === "number");
800
+ let useAMS = args?.use_ams !== undefined ? Boolean(args.use_ams) : !!finalAmsMapping && finalAmsMapping.length > 0;
801
+ const hasUserAmsMapping = hasAmsMappingInput(args?.ams_mapping);
802
+ const hasUserAmsSlots = Array.isArray(args?.ams_slots);
803
+ if (hasUserAmsMapping) {
804
+ let userMapping;
805
+ if (Array.isArray(args.ams_mapping)) {
806
+ userMapping = args.ams_mapping.map((value, index) => normalizeBridgeAmsTrayValue(value, `ams_mapping[${index}]`, trayOptions));
724
807
  }
725
- else if (typeof override === "object" && override !== null) {
726
- userMappingOverride = Object.values(override)
727
- .filter((value) => typeof value === "number")
728
- .sort((a, b) => a - b);
808
+ else if (args.ams_mapping && typeof args.ams_mapping === "object") {
809
+ userMapping = normalizeAmsMappingObject(args.ams_mapping, "ams_mapping", trayOptions);
810
+ }
811
+ else {
812
+ throw new Error("ams_mapping must be an array of trays or an object of trays keyed by project filament index.");
813
+ }
814
+ if (userMapping.length > 0) {
815
+ finalAmsMapping = userMapping;
816
+ useAMS = true;
729
817
  }
730
- if (userMappingOverride && userMappingOverride.length > 0) {
731
- finalAmsMapping = userMappingOverride;
818
+ }
819
+ if (hasUserAmsSlots) {
820
+ const userSlots = args.ams_slots.map((value, index) => normalizeBridgeAmsTrayValue(value, `ams_slots[${index}]`, trayOptions));
821
+ if (userSlots.length > 0) {
822
+ finalAmsSlots = userSlots;
823
+ finalAmsMapping = undefined;
732
824
  useAMS = true;
733
825
  }
734
826
  }
735
827
  if (args?.use_ams === false) {
736
- finalAmsMapping = undefined;
828
+ if (!hasUserAmsMapping)
829
+ finalAmsMapping = undefined;
830
+ if (!hasUserAmsSlots)
831
+ finalAmsSlots = undefined;
737
832
  useAMS = false;
738
833
  }
739
- if (!finalAmsMapping || finalAmsMapping.length === 0) {
834
+ if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
740
835
  useAMS = false;
741
836
  }
742
- return { useAMS, amsMapping: finalAmsMapping };
837
+ return { useAMS, amsMapping: finalAmsMapping, amsSlots: finalAmsSlots };
838
+ }
839
+ /**
840
+ * print_3mf over FULU's BambuNetwork bridge (connection_mode bambu_network),
841
+ * ported from bambu-printer-mcp: the route a P1S on authorization firmware
842
+ * without Developer Mode prints through. connection_type lan opens the
843
+ * plug-in's authenticated local session (start_local_print); cloud sends
844
+ * through Bambu Cloud (start_print). The shared gates run first: the exact
845
+ * snapshot bytes are inspected, the AMS mapping checked, and fresh LAN MQTT
846
+ * state validated before the bridge starts, again immediately before the
847
+ * print frame, and the job is recorded for a checked resume.
848
+ */
849
+ async print3mfViaBambuNetwork(options) {
850
+ const { args, model, bedType, connectionType } = options;
851
+ const devId = options.serial.trim();
852
+ const devIp = options.host.trim();
853
+ const password = options.token.trim();
854
+ if (!devId || !devIp || devIp === "localhost" || !password) {
855
+ throw new Error("FULU BambuNetwork printing requires bambu_serial, the printer host and its LAN access code: fresh printer state is checked over MQTT before every bridge print, including cloud dispatch.");
856
+ }
857
+ const isLocalBridgePrint = connectionType === "lan";
858
+ const bridgePrintMethod = isLocalBridgePrint ? "start_local_print" : "start_print";
859
+ const bridgeMethod = `net.${bridgePrintMethod}`;
860
+ const { plateIndex } = options;
861
+ const threeMfFilename = path.basename(options.threeMFPath);
862
+ const projectName = threeMfFilename.replace(/\.3mf$/i, "");
863
+ const presetName = `${projectName}_plate_${plateIndex + 1}`;
864
+ const clientJobId = Date.now();
865
+ const bambu = this.printerFactory.getImplementation("bambu");
866
+ const dualNozzle = isDualNozzleModel(model);
867
+ return withPrinterOperation(devIp, devId, (assertActive) => withPrintSnapshot(options.threeMFPath, async (snapshot, retainForDispatchedUpload) => {
868
+ const inspection = await inspectPrintFile(snapshot, { model, nozzleDiameters: options.nozzleDiameters, plateIndex, bedType });
869
+ const resumeFile = await checkedFile(inspection, snapshot);
870
+ // The options Bambu Studio's Send dialog would send; explicit ones win.
871
+ const studio = resolveStudioPrintOptions(model, callerPrintOptions(args), { usedNozzles: usedNozzleCount(inspection) });
872
+ const { useAMS, amsSlots } = options.ams;
873
+ let mapping = options.ams.amsMapping?.slice();
874
+ if (amsSlots) {
875
+ if (amsSlots.length !== inspection.usedFilamentPositions.length)
876
+ throw new Error("ams_slots must supply exactly one physical tray per used filament.");
877
+ mapping = Array(inspection.materials.length).fill(-1);
878
+ inspection.usedFilamentPositions.forEach((position, index) => { mapping[position] = amsSlots[index]; });
879
+ }
880
+ if (!useAMS) {
881
+ mapping ?? (mapping = Array(inspection.materials.length).fill(-1));
882
+ for (const position of inspection.usedFilamentPositions) {
883
+ if (mapping[position] === undefined || mapping[position] < 0) {
884
+ mapping[position] = externalTrayForNozzle(inspection.filamentNozzleIndices?.[position], dualNozzle);
885
+ }
886
+ if (mapping[position] !== 254 && !(dualNozzle && mapping[position] === 255)) {
887
+ throw new Error("use_ams:false requires an external-spool mapping for every used filament.");
888
+ }
889
+ }
890
+ }
891
+ for (const position of inspection.usedFilamentPositions) {
892
+ if (mapping?.[position] === undefined || mapping[position] < 0) {
893
+ throw new Error(`Missing physical filament mapping for project filament ${position}. Pass ams_slots (one tray per used filament), ams_mapping, or use_ams:false for the external spool.`);
894
+ }
895
+ }
896
+ const requirements = { ...inspection, amsMapping: mapping, useAMS };
897
+ validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
898
+ const confirmedStatus = await bambu.getSafetyStatus(devIp, devId, password);
899
+ validatePrinterState(confirmedStatus, requirements);
900
+ const wireMapping = mapping.map((value) => bridgeAmsMappingValue(value, dualNozzle));
901
+ const remoteFileName = uniquePrintName(options.threeMFPath);
902
+ const checkedName = remoteFileName.replace(/(?:\.gcode)?\.3mf$/i, "");
903
+ const params = {
904
+ dev_id: devId,
905
+ // The printer reports the task or project name back; both carry the unique
906
+ // checked name, which binds the job to this inspected artifact for a later resume.
907
+ task_name: checkedName,
908
+ project_name: checkedName,
909
+ preset_name: presetName,
910
+ filename: snapshot,
911
+ config_filename: snapshot,
912
+ plate_index: plateIndex + 1,
913
+ ftp_folder: "",
914
+ ftp_file: "",
915
+ ftp_file_md5: "",
916
+ nozzle_mapping: "",
917
+ ams_mapping: JSON.stringify(wireMapping),
918
+ ams_mapping2: JSON.stringify(mapping.map((value) => bridgeAmsMapping2Entry(value, dualNozzle))),
919
+ // Bambu Studio and FULU's slicer send this with every job (get_ams_mapping_result).
920
+ ams_mapping_info: inspection.projectFilaments?.length
921
+ ? JSON.stringify(studioAmsMappingInfo(inspection.projectFilaments, mapping, (tray) => trayColour(confirmedStatus.raw, tray)))
922
+ : "",
923
+ nozzles_info: "",
924
+ connection_type: connectionType,
925
+ comments: "",
926
+ origin_profile_id: 0,
927
+ stl_design_id: 0,
928
+ origin_model_id: "",
929
+ print_type: "from_normal",
930
+ dst_file: remoteFileName,
931
+ dev_name: "",
932
+ dev_ip: devIp,
933
+ use_ssl_for_ftp: true,
934
+ use_ssl_for_mqtt: true,
935
+ username: "bblp",
936
+ password,
937
+ task_bed_leveling: studio.task_bed_leveling,
938
+ task_flow_cali: studio.task_flow_cali,
939
+ task_vibration_cali: studio.task_vibration_cali,
940
+ task_layer_inspect: studio.task_layer_inspect,
941
+ task_record_timelapse: args?.timelapse !== undefined ? Boolean(args.timelapse) : false,
942
+ task_use_ams: useAMS,
943
+ task_bed_type: bedType,
944
+ extra_options: "",
945
+ auto_bed_leveling: studio.auto_bed_leveling,
946
+ auto_flow_cali: studio.auto_flow_cali,
947
+ auto_offset_cali: studio.auto_offset_cali,
948
+ extruder_cali_manual_mode: studio.extruder_cali_manual_mode,
949
+ task_ext_change_assist: false,
950
+ try_emmc_print: false,
951
+ };
952
+ assertActive();
953
+ const bridgeOptions = {
954
+ // Trusted in-process preflight immediately before the print frame.
955
+ beforeDispatch: async (method) => {
956
+ if (method !== bridgeMethod)
957
+ return;
958
+ assertActive();
959
+ validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
960
+ },
961
+ assertDispatchAllowed: (method) => { if (method === bridgeMethod)
962
+ assertActive(); },
963
+ onDispatched: (method) => { if (method === bridgeMethod)
964
+ retainForDispatchedUpload(); },
965
+ };
966
+ let jobUpdates = [];
967
+ let bridgeResult;
968
+ if (isLocalBridgePrint) {
969
+ ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startLocalPrint({ devId, devIp, username: "bblp", password, useSsl: true }, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive));
970
+ }
971
+ else {
972
+ ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startCloudPrint(devId, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive));
973
+ }
974
+ if (typeof bridgeResult === "object" && bridgeResult !== null && bridgeResult.ok === false) {
975
+ throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} failed: ${String(bridgeResult.error || "unknown bridge error")}`);
976
+ }
977
+ const value = typeof bridgeResult === "object" && bridgeResult !== null ? bridgeResult.value : undefined;
978
+ if (typeof value === "number" && value !== 0) {
979
+ const reported = jobUpdates.filter((update) => update.code !== 0 || update.msg).slice(-3)
980
+ .map((update) => `status ${update.status}, code ${update.code}${update.msg ? `: ${update.msg}` : ""}`).join("; ");
981
+ throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} returned non-zero result ${value}${describeBambuNetworkPrintError(value)}.` +
982
+ (reported ? ` Plug-in reported: ${reported}.` : ""));
983
+ }
984
+ assertActive();
985
+ // Bambu's plug-in placed the file, so a resume re-reads it if it can and otherwise asks a person.
986
+ bambu.recordCheckedJob(devIp, devId, remoteFileName, requirements, resumeFile, false);
987
+ return {
988
+ status: "success",
989
+ message: `FULU BambuNetwork ${bridgePrintMethod} command for ${threeMfFilename} sent successfully.`,
990
+ connectionMode: "bambu_network",
991
+ connectionType,
992
+ bridgeMethod,
993
+ bridgeResult,
994
+ clientJobId,
995
+ projectName,
996
+ plateIndex,
997
+ bridgePlateIndex: plateIndex + 1,
998
+ printerJobName: checkedName,
999
+ useAMS,
1000
+ amsMapping: mapping,
1001
+ sha256: inspection.sha256,
1002
+ params: { ...params, password: "[redacted]" },
1003
+ };
1004
+ }));
1005
+ }
1006
+ /** The FDM design guidance as MCP prompts: the client decides when to attach each one. */
1007
+ setupPromptHandlers() {
1008
+ this.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
1009
+ prompts: DESIGN_PROMPTS.map(({ name, title, description }) => ({ name, title, description })),
1010
+ }));
1011
+ this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
1012
+ const prompt = DESIGN_PROMPTS.find(item => item.name === request.params.name);
1013
+ if (!prompt)
1014
+ throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${request.params.name}`);
1015
+ return {
1016
+ description: prompt.description,
1017
+ messages: [{ role: "user", content: { type: "text", text: prompt.text } }],
1018
+ };
1019
+ });
743
1020
  }
744
1021
  setupResourceHandlers() {
745
1022
  // List available resources
@@ -757,7 +1034,8 @@ class ThreeDPrinterMCPServer {
757
1034
  name: "3D Printer Files",
758
1035
  mimeType: "application/json",
759
1036
  description: "List of files available on the 3D printer"
760
- }
1037
+ },
1038
+ FDM_CAPABILITY_RESOURCE
761
1039
  ],
762
1040
  templates: [
763
1041
  {
@@ -781,6 +1059,9 @@ class ThreeDPrinterMCPServer {
781
1059
  // Read resource
782
1060
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
783
1061
  const uri = request.params.uri;
1062
+ if (uri === FDM_CAPABILITY_URI) {
1063
+ return { contents: [{ uri, mimeType: FDM_CAPABILITY_RESOURCE.mimeType, text: FDM_CAPABILITY_GUIDANCE }] };
1064
+ }
784
1065
  const match = uri.match(/^printer:\/\/([^\/]+)\/(.+)$/);
785
1066
  if (!match) {
786
1067
  throw new McpError(ErrorCode.InvalidRequest, `Invalid resource URI: ${uri}`);
@@ -892,7 +1173,7 @@ class ThreeDPrinterMCPServer {
892
1173
  },
893
1174
  {
894
1175
  name: "upload_gcode",
895
- 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.",
1176
+ 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) and printer state is checked before the print starts.",
896
1177
  inputSchema: {
897
1178
  type: "object",
898
1179
  properties: {
@@ -950,7 +1231,7 @@ class ThreeDPrinterMCPServer {
950
1231
  },
951
1232
  {
952
1233
  name: "start_print",
953
- 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.",
1234
+ 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. Printers whose API cannot download files (Repetier, Prusa, Creality) refuse; use upload_gcode with print=true instead.",
954
1235
  inputSchema: {
955
1236
  type: "object",
956
1237
  properties: {
@@ -1024,10 +1305,23 @@ class ThreeDPrinterMCPServer {
1024
1305
  bambu_token: {
1025
1306
  type: "string",
1026
1307
  description: "Access token for Bambu Lab printers (default: value from env)"
1027
- }
1308
+ },
1309
+ ...CONNECTION_PROPERTIES,
1028
1310
  }
1029
1311
  }
1030
1312
  },
1313
+ ...CAMERA_STREAM_TOOLS,
1314
+ {
1315
+ name: "counterpart_print_route",
1316
+ description: "Counterpart contract (counterpart/1), Bambu: how this server reaches the printer for prints and for Stop/Pause/Resume. route is \"lan\" (the local network through FULU's BambuNetwork bridge), \"cloud\" (Bambu Cloud through FULU's bridge) or \"developer\" (the local network straight to a printer in LAN Only + Developer Mode, whose Pause, Resume and Stop need no Bambu app). options lists each with its label, whether it can be chosen here (and why not) and what controls do on it; choices lists the ids that can be chosen. mode and type are BAMBU_DEFAULT_CONNECTION_MODE and BAMBU_DEFAULT_CONNECTION_TYPE as resolved. Pass route to change it: the change applies to the next print and control at once, and is saved to the .env file in this server's working directory when that file exists (savedTo; null otherwise). Sends nothing to the printer.",
1317
+ inputSchema: {
1318
+ type: "object",
1319
+ properties: {
1320
+ route: { type: "string", enum: ["lan", "cloud", "developer"], description: "Change the route. Omit to read it." }
1321
+ },
1322
+ additionalProperties: false
1323
+ }
1324
+ },
1031
1325
  {
1032
1326
  name: "counterpart_printer_info",
1033
1327
  description: "Describe this printer for the counterpart/1 contract: server name and version, printer kind and identity, and which features work (progress, layers, camera, filaments, pause, resume, cancel, print_3mf, G-code), detected from the firmware. Read-only.",
@@ -1058,28 +1352,35 @@ class ThreeDPrinterMCPServer {
1058
1352
  },
1059
1353
  {
1060
1354
  name: "get_printer_filaments",
1061
- description: "Report the loaded filament as one external slot (id \"ext\"). Moonraker printers with Spoolman report the active spool's material, colour and name; other printers report source \"unknown\". Read-only.",
1062
- inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1355
+ description: "Report the loaded filament. Bambu: the live AMS and external-spool inventory over MQTT, as bambu-printer-mcp reports it (trays with absolute slot, loaded state, material, colour, tray_info_idx and resolved slicer profile path; summary; recommended load_filaments), plus counterpart/1 slots for every loaded tray and external spool (254, and 255 on dual-nozzle printers). Other printers: one external slot (id \"ext\"); Moonraker printers with Spoolman report the active spool's material, colour and name, others source \"unknown\". Read-only.",
1356
+ inputSchema: {
1357
+ type: "object",
1358
+ properties: {
1359
+ ...PRINTER_TARGET_PROPERTIES,
1360
+ bambu_model: { type: "string", description: "Bambu only: optional model hint used to resolve Bambu/Orca filament profile JSONs for each tray (default: BAMBU_MODEL)." },
1361
+ nozzle_diameter: { type: "string", description: "Bambu only: optional nozzle diameter used when resolving model-specific filament profile JSONs (default: 0.4)." },
1362
+ },
1363
+ },
1063
1364
  outputSchema: FILAMENTS_OUTPUT_SCHEMA,
1064
1365
  annotations: { readOnlyHint: true, openWorldHint: true },
1065
1366
  },
1066
1367
  {
1067
1368
  name: "pause_print",
1068
- description: "Pause the current print (OctoPrint, Klipper/Moonraker, PrusaLink, Duet M25, Repetier, and Creality printers that run Moonraker). Pause is never gated.",
1069
- inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1369
+ description: "Pause the current print (Bambu Lab, OctoPrint, Klipper/Moonraker, PrusaLink, Duet M25, Repetier, and Creality printers that run Moonraker). Bambu pauses over the same connection that prints. Pause is never gated.",
1370
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES, ...CONNECTION_PROPERTIES } },
1070
1371
  outputSchema: CONTROL_OUTPUT_SCHEMA,
1071
1372
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1072
1373
  },
1073
1374
  {
1074
1375
  name: "resume_print",
1075
- description: "Resume a paused print. Resume reheats and moves the printer, so it only resumes a job this server instance inspected and started, and only when the printer freshly reports that job paused. Resume other jobs at the printer.",
1076
- inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1376
+ description: "Resume a paused print. Resume reheats and moves the printer, so it only resumes a job this server instance inspected and started, and only when the printer freshly reports that job paused. Bambu re-reads the job over FTPS (or the plate G-code the printer extracted) and compares its SHA-256, and resumes over the same connection that printed. When the file cannot be re-read, a person confirms through MCP elicitation first (Bambu: only for files Bambu's plug-in placed; a file this server uploaded must re-read). Resume other jobs at the printer.",
1377
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES, ...CONNECTION_PROPERTIES } },
1077
1378
  outputSchema: CONTROL_OUTPUT_SCHEMA,
1078
1379
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1079
1380
  },
1080
1381
  {
1081
1382
  name: "set_printer_temperature",
1082
- 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.",
1383
+ 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, and require a ready printer.",
1083
1384
  inputSchema: {
1084
1385
  type: "object",
1085
1386
  properties: {
@@ -1511,16 +1812,35 @@ class ThreeDPrinterMCPServer {
1511
1812
  required: ["stl_path"]
1512
1813
  }
1513
1814
  },
1815
+ {
1816
+ name: "inspect_print_file",
1817
+ description: "Inspect an exact private snapshot of a prepared sliced 3MF or textual G-code file and selected plate without contacting a printer. Returns versioned file-only evidence; no printer readiness or permission to dispatch is established. Does not auto-slice.",
1818
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1819
+ inputSchema: {
1820
+ type: "object",
1821
+ additionalProperties: false,
1822
+ properties: {
1823
+ file_path: { type: "string", minLength: 1, description: "Local prepared .3mf or textual .gcode artifact to inspect; checksum-only and unsliced projects reject." },
1824
+ bambu_model: { type: "string", enum: [...VALID_BAMBU_MODELS], description: "Explicit file-inspection target model; never inferred from environment or live state." },
1825
+ plate_index: { type: "integer", minimum: 0, description: "Zero-based selected plate; defaults to 0. Raw G-code only supports 0." },
1826
+ nozzle_diameters: { type: "array", minItems: 1, maxItems: 2, items: { type: "number", enum: [0.2, 0.4, 0.6, 0.8] }, description: "Optional requested nozzle diameters to compare with file declarations; this does not observe installed hardware." },
1827
+ bed_type: { type: "string", enum: [...VALID_BED_TYPES], description: "Optional declared bed type to compare with file metadata." },
1828
+ },
1829
+ required: ["file_path", "bambu_model"],
1830
+ },
1831
+ },
1514
1832
  {
1515
1833
  name: "print_3mf",
1516
- 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.",
1834
+ 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 before upload and start.",
1517
1835
  inputSchema: {
1518
1836
  type: "object",
1519
1837
  properties: {
1838
+ pause_at_layers: { type: "array", items: { type: "integer", minimum: 2 }, description: "Pause before these 1-based layers when auto-slicing. An already sliced 3MF must contain the requested pauses or printing is refused." },
1520
1839
  three_mf_path: {
1521
1840
  type: "string",
1522
1841
  description: "Path to the 3MF file to print."
1523
1842
  },
1843
+ plate_index: { type: "integer", minimum: 0, description: "Zero-based selected plate; defaults to 0. The same plate is inspected and printed; auto-slicing uses its one-based CLI plate number." },
1524
1844
  bambu_model: {
1525
1845
  type: "string",
1526
1846
  enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
@@ -1556,10 +1876,18 @@ class ThreeDPrinterMCPServer {
1556
1876
  nozzle_temperature: { type: "number", description: "Override nozzle temperature (°C)." },
1557
1877
  bed_temperature: { type: "number", description: "Override bed temperature (°C)." },
1558
1878
  support_enabled: { type: "boolean", description: "Override support generation." },
1879
+ ...CONNECTION_PROPERTIES,
1559
1880
  ams_mapping: {
1560
- type: "object",
1561
- description: "Override AMS filament mapping (e.g., {\"Generic PLA\": 0, \"Generic PETG\": 1}).",
1562
- additionalProperties: { type: "number" }
1881
+ anyOf: [
1882
+ { type: "array", items: { type: "number" } },
1883
+ { type: "object", additionalProperties: { type: "number" } },
1884
+ ],
1885
+ description: "Project-level AMS mapping. Array: position = project filament index, value = absolute AMS tray (0-3 = AMS 0, 4-7 = AMS 1, 8-11 = AMS 2, 12-15 = AMS 3, 128+ = AMS-HT, 254 = external spool, -1 = unused); on dual-nozzle printers (H2D, H2D Pro, H2C, X2D) 254 is the left/deputy nozzle external spool and 255 the right/main one. Object: trays keyed by project filament index (numeric keys first, in order), e.g. {\"0\": 2}. Prefer ams_slots unless you know the project-level layout.",
1886
+ },
1887
+ ams_slots: {
1888
+ type: "array",
1889
+ items: { type: "number" },
1890
+ description: "Preferred AMS input: one absolute tray per USED filament in plate order, e.g. [1] for a single-filament print from AMS 0 slot 1. Use 254 for the external spool (on dual-nozzle printers 254 = left/deputy, 255 = right/main). Expanded to the project-level ams_mapping from the 3MF's plate_N.json and G-code header. Overrides ams_mapping.",
1563
1891
  },
1564
1892
  slicer_type: {
1565
1893
  type: "string",
@@ -1580,7 +1908,7 @@ class ThreeDPrinterMCPServer {
1580
1908
  },
1581
1909
  use_ams: {
1582
1910
  type: "boolean",
1583
- description: "Whether to use AMS for the print. Defaults from parsed 3MF mapping when present."
1911
+ description: "Whether to use the AMS. Defaults to true when ams_slots, ams_mapping or the 3MF's own mapping is present; false prints every used filament from the external spool."
1584
1912
  },
1585
1913
  bed_leveling: {
1586
1914
  type: "boolean",
@@ -1723,7 +2051,7 @@ class ThreeDPrinterMCPServer {
1723
2051
  },
1724
2052
  {
1725
2053
  name: "lay_flat",
1726
- description: "Attempt to rotate the model so its largest flat face lies on the XY plane (Z=0).",
2054
+ description: "Rotate the model so a face rests on the bed (Z=0), choosing the orientation with the least part and support volume per mm² of bed contact. A face counts only if nothing hangs below it. Returns the new STL path; structuredContent.laid_flat is false, with the input path, when no orientation qualifies.",
1727
2055
  inputSchema: {
1728
2056
  type: "object",
1729
2057
  properties: {
@@ -1882,11 +2210,40 @@ class ThreeDPrinterMCPServer {
1882
2210
  type, host, port,
1883
2211
  apiKey: type.toLowerCase() === "bambu" ? `${bambuSerial}:${bambuToken}` : apiKey,
1884
2212
  };
2213
+ // Bambu print controls go over the same connection as the print (resolveConnection);
2214
+ // undefined keeps direct LAN MQTT in BambuImplementation.
2215
+ const bambuControlRoute = (command) => {
2216
+ if (type.toLowerCase() !== "bambu")
2217
+ return undefined;
2218
+ const route = resolveConnection(args);
2219
+ assertConnectionAvailable(route.mode);
2220
+ if (route.mode !== "bambu_network")
2221
+ return undefined;
2222
+ return (assertActive, beforeDispatch) => {
2223
+ const options = { assertDispatchAllowed: () => { extra.signal.throwIfAborted(); assertActive?.(); } };
2224
+ return route.type === "cloud"
2225
+ ? this.bambuNetwork.sendCloudPrintCommand(bambuSerial, command, options, beforeDispatch)
2226
+ : this.bambuNetwork.sendLocalPrintCommand({ devId: bambuSerial, devIp: host, username: "bblp", password: bambuToken, useSsl: true }, command, options, beforeDispatch);
2227
+ };
2228
+ };
1885
2229
  const structured = (value) => ({
1886
2230
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
1887
2231
  structuredContent: value,
1888
2232
  });
1889
2233
  switch (name) {
2234
+ case "camera_stream_start":
2235
+ return structured(await this.counterpart.cameraStreamStart(target, { fps: args?.fps, idleTimeoutMs: args?.idle_timeout_ms }));
2236
+ case "camera_frame": {
2237
+ const frame = await this.counterpart.cameraStreamFrame(target, {
2238
+ sinceSeq: args?.since_seq, waitMs: args?.wait_ms,
2239
+ maxWidth: args?.max_width, maxBytes: args?.max_bytes,
2240
+ changedOnly: args?.changed_only, minChange: args?.min_change, signal: extra.signal,
2241
+ });
2242
+ // The JPEG appears only once in the MCP payload; text is useful metadata.
2243
+ return { content: [{ type: "text", text: JSON.stringify(Object.fromEntries(Object.entries(frame).filter(([key]) => key !== "base64"))) }], structuredContent: frame };
2244
+ }
2245
+ case "camera_stream_stop":
2246
+ return structured(this.counterpart.cameraStreamStop(target));
1890
2247
  case "counterpart_printer_info":
1891
2248
  return structured(await this.counterpart.printerInfo(target));
1892
2249
  case "counterpart_print_progress":
@@ -1901,12 +2258,81 @@ class ThreeDPrinterMCPServer {
1901
2258
  timeoutMs: args?.timeout_ms,
1902
2259
  }));
1903
2260
  }
1904
- case "get_printer_filaments":
1905
- return structured(await this.counterpart.filaments(target));
2261
+ case "get_printer_filaments": {
2262
+ const filamentOptions = type.toLowerCase() === "bambu" ? {
2263
+ bambuModel: optionalBambuModelHint(args?.bambu_model),
2264
+ nozzleDiameter: String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER),
2265
+ } : {};
2266
+ return structured(await this.counterpart.filaments(target, filamentOptions));
2267
+ }
1906
2268
  case "pause_print":
1907
- return structured(await this.counterpart.pause(target));
2269
+ return structured(await this.counterpart.pause(target, bambuControlRoute));
1908
2270
  case "resume_print":
1909
- return structured(await this.counterpart.resume(target));
2271
+ return structured(await this.counterpart.resume(target, bambuControlRoute));
2272
+ case "counterpart_print_route": {
2273
+ let savedTo = null;
2274
+ // FULU's bridge is set up (configured or discovered), so the routes through it can be used.
2275
+ const bridgeConfigured = Boolean(this.bambuNetwork.resolveBridgeCommand());
2276
+ if (args?.route !== undefined) {
2277
+ if (!PRINT_ROUTES.includes(args.route))
2278
+ throw new Error('route must be "lan", "cloud" or "developer".');
2279
+ const next = connectionForRoute(args.route, { mode: DEFAULT_BAMBU_CONNECTION_MODE, type: DEFAULT_BAMBU_CONNECTION_TYPE }, bridgeConfigured);
2280
+ // dotenv loaded .env from the working directory at start; a client's own env block has no file to update.
2281
+ const file = path.resolve(process.cwd(), ".env");
2282
+ if (fs.existsSync(file)) {
2283
+ writeEnvValues(file, {
2284
+ ...(next.mode ? { BAMBU_DEFAULT_CONNECTION_MODE: next.mode } : {}),
2285
+ ...(next.type ? { BAMBU_DEFAULT_CONNECTION_TYPE: next.type } : {}),
2286
+ });
2287
+ savedTo = file;
2288
+ }
2289
+ DEFAULT_BAMBU_CONNECTION_MODE = next.mode;
2290
+ DEFAULT_BAMBU_CONNECTION_TYPE = next.type;
2291
+ }
2292
+ const connection = resolveConnection({});
2293
+ const options = routeOptions(connection, bridgeConfigured);
2294
+ result = { route: routeOf(connection), mode: connection.mode, type: connection.type,
2295
+ choices: options.filter(option => option.available).map(option => option.id), options, savedTo };
2296
+ break;
2297
+ }
2298
+ case "inspect_print_file": {
2299
+ const allowed = new Set(["file_path", "bambu_model", "plate_index", "nozzle_diameters", "bed_type"]);
2300
+ if (!args || Object.keys(args).some(key => !allowed.has(key)))
2301
+ throw new Error("inspect_print_file accepts only file/model/plate/nozzle/bed arguments.");
2302
+ if (typeof args.file_path !== "string" || !args.file_path.trim() || args.file_path.includes("\0"))
2303
+ throw new Error("file_path must name a local prepared artifact.");
2304
+ if (typeof args.bambu_model !== "string" || !args.bambu_model.trim())
2305
+ throw new Error("An explicit bambu_model is required for file inspection.");
2306
+ const model = validateBambuModel(args.bambu_model);
2307
+ const plateIndex = args.plate_index === undefined ? 0 : args.plate_index;
2308
+ if (typeof plateIndex !== "number" || !Number.isSafeInteger(plateIndex) || plateIndex < 0)
2309
+ throw new Error("plate_index must be a nonnegative safe integer.");
2310
+ if (args.nozzle_diameters !== undefined && (!Array.isArray(args.nozzle_diameters) || args.nozzle_diameters.some(value => typeof value !== "number")))
2311
+ throw new Error("nozzle_diameters must be numeric sizes.");
2312
+ const nozzles = args.nozzle_diameters;
2313
+ if (nozzles && (nozzles.length < 1 || nozzles.length > 2 || nozzles.some(value => !SUPPORTED_NOZZLE_DIAMETERS.includes(value))))
2314
+ throw new Error("nozzle_diameters must contain one or two supported sizes.");
2315
+ if (args.bed_type !== undefined && (typeof args.bed_type !== "string" || !args.bed_type.trim()))
2316
+ throw new Error("bed_type must be a supported plate type.");
2317
+ const bedType = args.bed_type === undefined ? undefined : resolveBedType(args.bed_type);
2318
+ if (extra.signal.aborted)
2319
+ throw new Error("File inspection cancelled.");
2320
+ result = await withPrintSnapshot(args.file_path, async (snapshot) => {
2321
+ const inspection = await inspectPrintFile(snapshot, { model, nozzleDiameters: nozzles, plateIndex, bedType });
2322
+ if (extra.signal.aborted)
2323
+ throw new Error("File inspection cancelled.");
2324
+ const byteLength = (await fs.promises.stat(snapshot)).size;
2325
+ return {
2326
+ receipt_version: "1",
2327
+ evidence: "file-only",
2328
+ dispatch_authorized: false,
2329
+ artifact: { sha256: inspection.sha256, byte_length: byteLength, file_format: path.extname(snapshot).toLowerCase() === ".3mf" ? "3mf" : "gcode" },
2330
+ selection: { bambu_model: model, plate_index: plateIndex, ...(nozzles === undefined ? {} : { nozzle_diameters: nozzles }), ...(bedType === undefined ? {} : { bed_type: bedType }) },
2331
+ inspection,
2332
+ };
2333
+ });
2334
+ break;
2335
+ }
1910
2336
  case "get_printer_status":
1911
2337
  result = await this.getPrinterStatus(host, port, type, apiKey, bambuSerial, bambuToken);
1912
2338
  break;
@@ -1945,7 +2371,7 @@ class ThreeDPrinterMCPServer {
1945
2371
  result = withJobName(await this.startPrint(host, port, type, apiKey, bambuSerial, bambuToken, String(args.filename), startOptions), String(args.filename));
1946
2372
  break;
1947
2373
  case "cancel_print":
1948
- result = await this.cancelPrint(host, port, type, apiKey, bambuSerial, bambuToken);
2374
+ result = await this.cancelPrint(host, port, type, apiKey, bambuSerial, bambuToken, bambuControlRoute("stop"));
1949
2375
  break;
1950
2376
  case "set_printer_temperature":
1951
2377
  if (!args?.component || args?.temperature === undefined) {
@@ -1968,14 +2394,16 @@ class ThreeDPrinterMCPServer {
1968
2394
  }
1969
2395
  result = await this.stlManipulator.extendBase(String(args.stl_path), Number(args.extension_inches));
1970
2396
  break;
1971
- case "slice_stl":
1972
- result = await this.runSliceTool("slice_stl", args);
1973
- break;
2397
+ case "slice_stl": {
2398
+ const sliced = await this.runSliceTool("slice_stl", args);
2399
+ // The text stays the sliced file's path; slicer_result is the slicer's own result.json, verbatim.
2400
+ return { content: [{ type: "text", text: sliced.path }], structuredContent: sliced };
2401
+ }
1974
2402
  case "slice_with_template":
1975
2403
  if (!args?.template_name || !String(args.template_name).trim()) {
1976
2404
  throw new Error("Missing required parameter: template_name");
1977
2405
  }
1978
- result = await this.runSliceTool("slice_with_template", args);
2406
+ result = (await this.runSliceTool("slice_with_template", args)).path;
1979
2407
  break;
1980
2408
  case "list_templates":
1981
2409
  result = listTemplateRegistry(typeof args?.template_dir === "string" ? args.template_dir : undefined);
@@ -2057,7 +2485,7 @@ class ThreeDPrinterMCPServer {
2057
2485
  catch (error) {
2058
2486
  console.warn(`Could not parse AMS metadata from sliced 3MF ${gcodePath}:`, error.message);
2059
2487
  }
2060
- const amsOptions = this.resolveAmsPrintOptions(parsedProcess3MF, args);
2488
+ const amsOptions = this.resolveAmsPrintOptions(parsedProcess3MF, args, processModel);
2061
2489
  result = await factoryImplementation.print3mf(host, bambuSerial, bambuToken, {
2062
2490
  projectName: path.basename(gcodePath).replace(/\.3mf$/i, ''),
2063
2491
  filePath: gcodePath,
@@ -2083,7 +2511,7 @@ class ThreeDPrinterMCPServer {
2083
2511
  // 3. Upload and print through the adapter's hard safety gate. It
2084
2512
  // inspects the exact bytes sent (S and R targets, tool changes,
2085
2513
  // hardware and material ceilings), requires the expected peaks to
2086
- // match, reads fresh printer state and asks for human confirmation.
2514
+ // match, and reads fresh printer state.
2087
2515
  // A mismatch refuses before upload; nothing is only logged.
2088
2516
  const filename = path.basename(gcodePath);
2089
2517
  const printResult = await this.uploadGcode(host, port, type, apiKey, bambuSerial, bambuToken, filename, gcodePath, true, // Start printing immediately
@@ -2239,15 +2667,20 @@ class ThreeDPrinterMCPServer {
2239
2667
  if (!bambuSerial || !bambuToken) {
2240
2668
  throw new Error("Bambu serial number and access token are required for print_3mf.");
2241
2669
  }
2670
+ const printPlate = args.plate_index === undefined ? 0 : args.plate_index;
2671
+ if (typeof printPlate !== "number" || !Number.isSafeInteger(printPlate) || printPlate < 0 || printPlate >= Number.MAX_SAFE_INTEGER)
2672
+ throw new Error("plate_index must be a nonnegative safe integer with a supported one-based plate number.");
2242
2673
  const printModel = await this.resolveBambuModel(args?.bambu_model);
2243
2674
  const printBedType = resolveBedType(args?.bed_type);
2244
2675
  const explicitPrintNozzle = optionalNozzleDiameterArg(args?.nozzle_diameter);
2245
2676
  const printNozzle = String(explicitPrintNozzle ?? DEFAULT_NOZZLE_DIAMETER);
2246
2677
  const printPreset = BAMBU_MODEL_PRESETS[printModel]?.(printNozzle);
2678
+ const printConnection = resolveConnection(args);
2679
+ assertConnectionAvailable(printConnection.mode);
2247
2680
  let threeMFPath = String(args.three_mf_path);
2248
2681
  // Only the selected plate's exact G-code entry counts as sliced output;
2249
2682
  // a stray .gcode entry elsewhere in the archive is not printable.
2250
- const selectedPlateEntry = "Metadata/plate_1.gcode";
2683
+ const selectedPlateEntry = `Metadata/plate_${printPlate + 1}.gcode`;
2251
2684
  let shouldAutoSlice = false;
2252
2685
  try {
2253
2686
  const JSZip = (await import('jszip')).default;
@@ -2261,6 +2694,14 @@ class ThreeDPrinterMCPServer {
2261
2694
  }
2262
2695
  // Only treat slicer_path as an executable selector when a slicer
2263
2696
  // will actually launch. Ready-to-print archives ignore it.
2697
+ const pauseLayers = parsePauseLayers(args?.pause_at_layers);
2698
+ if (!shouldAutoSlice && pauseLayers) {
2699
+ const gcode = await readPlateGcode(threeMFPath, printPlate + 1);
2700
+ const placed = gcode ? pausedLayers(gcode, machinePauseGcode(gcode)) : [];
2701
+ const missing = pauseLayers.filter(layer => !placed.includes(layer));
2702
+ if (missing.length)
2703
+ throw new Error(`This 3MF is already sliced without a pause before layer ${missing.join(", ")}. Slice it again with pause_at_layers (slice_stl), or add the pause in Bambu Studio or OrcaSlicer.`);
2704
+ }
2264
2705
  if (shouldAutoSlice) {
2265
2706
  const printSlicerPath = this.resolveExecutableSelectorArg(args?.slicer_path, "print_3mf", "slicer_path") ?? DEFAULT_SLICER_PATH;
2266
2707
  try {
@@ -2268,7 +2709,7 @@ class ThreeDPrinterMCPServer {
2268
2709
  threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, printSlicerPath, slicerProfile || undefined, undefined, // progressCallback
2269
2710
  printPreset, filamentProfile || undefined,
2270
2711
  // Slice for the plate and nozzle the print will be checked against.
2271
- { bedType: printBedType, ...(resolveNozzleType(args?.nozzle_type) ? { nozzleType: resolveNozzleType(args?.nozzle_type) } : {}) });
2712
+ { bedType: printBedType, slicePlate: printPlate + 1, ...(pauseLayers ? { pauseAtLayers: pauseLayers } : {}), ...(resolveNozzleType(args?.nozzle_type) ? { nozzleType: resolveNozzleType(args?.nozzle_type) } : {}) });
2272
2713
  console.error("Auto-sliced to: " + threeMFPath);
2273
2714
  }
2274
2715
  catch (sliceErr) {
@@ -2295,7 +2736,7 @@ class ThreeDPrinterMCPServer {
2295
2736
  // --- Parse 3MF ---
2296
2737
  const parsed3MFData = await parse3MF(threeMFPath);
2297
2738
  console.error(`Successfully parsed 3MF file: ${threeMFPath}`);
2298
- const amsOptions = this.resolveAmsPrintOptions(parsed3MFData, args);
2739
+ const amsOptions = this.resolveAmsPrintOptions(parsed3MFData, args, printModel);
2299
2740
  // --- Prepare Implementation and Print Options ---
2300
2741
  const factoryImplementation = this.printerFactory.getImplementation('bambu');
2301
2742
  if (!(factoryImplementation instanceof BambuImplementation)) {
@@ -2321,12 +2762,21 @@ class ThreeDPrinterMCPServer {
2321
2762
  console.error(`Error processing 3MF or setting up print:`, error);
2322
2763
  throw new Error(`Failed during 3MF processing: ${error.message}`);
2323
2764
  }
2765
+ if (printConnection.mode === "bambu_network") {
2766
+ result = await this.print3mfViaBambuNetwork({
2767
+ args: args, host, serial: bambuSerial, token: bambuToken, threeMFPath,
2768
+ model: printModel, nozzleDiameters: requestedPrintNozzles, bedType: printBedType,
2769
+ connectionType: printConnection.type, plateIndex: printPlate,
2770
+ ams: { useAMS: printOptions.useAMS, amsMapping: printOptions.amsMapping, amsSlots: printOptions.amsSlots },
2771
+ });
2772
+ break;
2773
+ }
2324
2774
  // --- Call Implementation (Now variables are in scope) ---
2325
2775
  try {
2326
2776
  result = await implementation.print3mf(host, bambuSerial, bambuToken, {
2327
2777
  projectName: projectName,
2328
2778
  filePath: threeMFPath,
2329
- plateIndex: 0,
2779
+ plateIndex: printPlate,
2330
2780
  ...printOptions // Spread the final options
2331
2781
  });
2332
2782
  // Report what the printer did, not merely that a command was published.
@@ -2384,12 +2834,14 @@ class ThreeDPrinterMCPServer {
2384
2834
  }
2385
2835
  result = await this.stlManipulator.centerModel(String(args.stl_path));
2386
2836
  break;
2387
- case "lay_flat":
2837
+ case "lay_flat": {
2388
2838
  if (!args?.stl_path) {
2389
2839
  throw new Error("Missing required parameter: stl_path");
2390
2840
  }
2391
- result = await this.stlManipulator.layFlat(String(args.stl_path));
2392
- break;
2841
+ const layFlatResult = await this.stlManipulator.layFlatWithResult(String(args.stl_path));
2842
+ // The text stays the STL path clients read; structuredContent says whether it was laid flat.
2843
+ return { content: [{ type: "text", text: layFlatResult.path }], structuredContent: layFlatResult };
2844
+ }
2393
2845
  case "fusion_mcp_status":
2394
2846
  return structured(await this.fusion.status(args ?? {}, extra.signal));
2395
2847
  case "fusion_mcp_call":
@@ -2429,7 +2881,8 @@ class ThreeDPrinterMCPServer {
2429
2881
  content: [
2430
2882
  {
2431
2883
  type: "text",
2432
- text: `Error: ${errorMessage}\nSuggestion: ${structuredError.suggestion}`
2884
+ // A slicer failure leads with the slicer's own words.
2885
+ text: `${error instanceof SlicerError && error.slicerText ? "" : "Error: "}${errorMessage}\nSuggestion: ${structuredError.suggestion}`
2433
2886
  }
2434
2887
  ],
2435
2888
  // Clients validate structuredContent against a declared outputSchema even on errors.
@@ -2497,11 +2950,11 @@ class ThreeDPrinterMCPServer {
2497
2950
  }
2498
2951
  return await implementation.startJob(host, port, apiKey, gcodeFilename, options);
2499
2952
  }
2500
- async cancelPrint(host, port, type, apiKey, bambuSerial, bambuToken) {
2953
+ async cancelPrint(host, port, type, apiKey, bambuSerial, bambuToken, bambuDispatch) {
2501
2954
  const implementation = this.printerFactory.getImplementation(type);
2502
2955
  if (type.toLowerCase() === "bambu") {
2503
2956
  const bambuApiKey = `${bambuSerial}:${bambuToken}`;
2504
- return await implementation.cancelJob(host, port, bambuApiKey);
2957
+ return await implementation.cancelJob(host, port, bambuApiKey, bambuDispatch);
2505
2958
  }
2506
2959
  return await implementation.cancelJob(host, port, apiKey);
2507
2960
  }
@@ -2523,6 +2976,7 @@ class ThreeDPrinterMCPServer {
2523
2976
  message,
2524
2977
  tool,
2525
2978
  slicer: { phase: error.phase, ...error.details },
2979
+ ...(error.slicerResult ? { slicer_result: error.slicerResult } : {}),
2526
2980
  };
2527
2981
  }
2528
2982
  if (SLICING_TOOLS.has(tool)) {
@@ -2705,7 +3159,9 @@ class ThreeDPrinterMCPServer {
2705
3159
  return this.closing;
2706
3160
  }
2707
3161
  async closeOnce() {
3162
+ this.counterpart.close();
2708
3163
  await this.printerFactory.disconnectAll();
3164
+ await this.bambuNetwork.stop();
2709
3165
  await this.fusion.close();
2710
3166
  if (this.httpRuntime) {
2711
3167
  await this.httpRuntime.transport.close();