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/src/index.ts CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  ReadResourceRequestSchema,
11
11
  ListToolsRequestSchema,
12
12
  CallToolRequestSchema,
13
+ ListPromptsRequestSchema,
14
+ GetPromptRequestSchema,
13
15
  ErrorCode,
14
16
  McpError
15
17
  } from "@modelcontextprotocol/sdk/types.js";
@@ -32,8 +34,10 @@ import { createServer as createHttpServer, type IncomingMessage, type Server as
32
34
  import { randomUUID } from "node:crypto";
33
35
  import * as THREE from 'three';
34
36
  import { PrinterFactory } from "./printers/printer-factory.js";
35
- import { STLManipulator, type BambuSliceOptions, type ProcessOverrides } from "./stl/stl-manipulator.js";
36
- import { CounterpartService, type PrinterTarget } from "./counterpart/service.js";
37
+ import { STLManipulator, type BambuSliceOptions, type ProcessOverrides, type SliceOutput } from "./stl/stl-manipulator.js";
38
+ import { parsePauseLayers, readPlateGcode, pausedLayers, machinePauseGcode } from "./slicer/layer-pauses.js";
39
+ import { CounterpartService, type BambuControlRoute, type PrinterTarget } from "./counterpart/service.js";
40
+ import {CAMERA_STREAM_TOOLS} from "./counterpart/camera-tools.js";
37
41
  import {
38
42
  CAMERA_SNAPSHOT_OUTPUT_SCHEMA,
39
43
  CONTROL_OUTPUT_SCHEMA,
@@ -55,8 +59,27 @@ import {
55
59
  inspectFuluOrcaSetup,
56
60
  invokeFuluBridgeRpc,
57
61
  } from "./fulu-orca.js";
62
+ import {
63
+ bridgeAmsMapping2Entry,
64
+ bridgeAmsMappingValue,
65
+ externalTrayForNozzle,
66
+ hasAmsMappingInput,
67
+ normalizeAmsMappingObject,
68
+ normalizeBridgeAmsTrayValue,
69
+ studioAmsMappingInfo,
70
+ trayColour,
71
+ } from "./ams-mapping.js";
72
+ import { isDualNozzleModel } from "./safety/limits.js";
73
+ import { BambuNetworkBridge, describeBambuNetworkPrintError, type BridgeJobUpdate } from "./bambu-network-bridge.js";
74
+ import { PRINT_ROUTES, connectionForRoute, routeOf, routeOptions, writeEnvValues, type PrintRoute } from "./counterpart/route.js";
75
+ import { resolveStudioPrintOptions, usedNozzleCount, type CallerPrintOptions } from "./studio-print-options.js";
76
+ import { inspectPrintFile } from "./safety/print-file.js";
77
+ import { validatePrinterState } from "./safety/printer-state.js";
78
+ import { uniquePrintName, withPrinterOperation, withPrintSnapshot } from "./safety/artifact.js";
79
+ import { checkedFile } from "./printers/bambu.js";
58
80
  import { CONFIRMATION_UNAVAILABLE, confirmationTimedOut, confirmationTimeoutMs } from "./safety/confirmation.js";
59
81
  import { validateExpectedPeaks, type ExpectedPeakTemperatures, type PrintSafetyOptions } from "./safety/expected-peaks.js";
82
+ import { DESIGN_PROMPTS, FDM_CAPABILITY_GUIDANCE, FDM_CAPABILITY_RESOURCE, FDM_CAPABILITY_URI, SERVER_INSTRUCTIONS } from "./design-guidance.js";
60
83
 
61
84
  // Load environment variables from .env file
62
85
  dotenv.config();
@@ -69,7 +92,10 @@ const DEFAULT_HOST = process.env.PRINTER_HOST || "localhost";
69
92
  const DEFAULT_PORT = process.env.PRINTER_PORT || "80";
70
93
  const DEFAULT_API_KEY = process.env.API_KEY || "";
71
94
  const DEFAULT_TYPE = process.env.PRINTER_TYPE || "octoprint"; // Default to OctoPrint
72
- const TEMP_DIR = process.env.TEMP_DIR || fs.mkdtempSync(path.join(os.tmpdir(), "mcp-3d-printer-server-"));
95
+ // Resolve an explicit TEMP_DIR (e.g. ./temp) against the server's working directory, as
96
+ // dotenv's own .env lookup is: slicer CLIs receive paths under it, and Bambu Studio fails
97
+ // every 3MF export to a relative --outputdir (bambu-printer-mcp 1.1.44 fixed the same).
98
+ const TEMP_DIR = process.env.TEMP_DIR ? path.resolve(process.env.TEMP_DIR) : fs.mkdtempSync(path.join(os.tmpdir(), "mcp-3d-printer-server-"));
73
99
 
74
100
  // Slicer configuration
75
101
  const CANONICAL_SLICER_TYPES = [
@@ -161,7 +187,9 @@ function defaultBambuStudioPath(): string {
161
187
  }
162
188
 
163
189
  const DEFAULT_SLICER_TYPE = normalizeSlicerType(
164
- process.env.SLICER_TYPE || (DEFAULT_TYPE.toLowerCase() === "bambu" ? "orcaslicer-bambulab" : "prusaslicer")
190
+ // Bambu Studio for Bambu printers, as in bambu-printer-mcp: FULU OrcaSlicer-bambulab v1.0.0's CLI
191
+ // segfaults on every slice (FULU #56/#57 open), so it is chosen explicitly, not by default.
192
+ process.env.SLICER_TYPE || (DEFAULT_TYPE.toLowerCase() === "bambu" ? "bambustudio" : "prusaslicer")
165
193
  );
166
194
  const DEFAULT_SLICER_PATH =
167
195
  process.env.SLICER_PATH ||
@@ -182,6 +210,53 @@ const DEFAULT_BAMBU_TOKEN = process.env.BAMBU_TOKEN || "";
182
210
  const DEFAULT_BAMBU_MODEL = process.env.BAMBU_MODEL?.trim().toLowerCase() || "";
183
211
  const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
184
212
  const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
213
+ // counterpart_print_route changes these at run time (and in the .env file they came from).
214
+ let DEFAULT_BAMBU_CONNECTION_MODE = process.env.BAMBU_DEFAULT_CONNECTION_MODE?.trim().toLowerCase() || "";
215
+ let DEFAULT_BAMBU_CONNECTION_TYPE = process.env.BAMBU_DEFAULT_CONNECTION_TYPE?.trim().toLowerCase() || "";
216
+ const CONNECTION_MODES = ["lan_mqtt_ftps", "bambu_network", "bambu_native", "bambu_connect"];
217
+
218
+ /**
219
+ * The one route decision for a Bambu printer, as in bambu-printer-mcp:
220
+ * print_3mf and every print control (cancel, pause, resume) resolve it here,
221
+ * so a job is never controlled over a different connection than the one that
222
+ * started it. Arguments override BAMBU_DEFAULT_CONNECTION_MODE/_TYPE; without
223
+ * either, direct LAN MQTT/FTPS. The type (lan or cloud) applies to FULU's
224
+ * BambuNetwork route and defaults to cloud, as bambu-printer-mcp's does.
225
+ */
226
+ function resolveConnection(args: Record<string, unknown> | undefined): { mode: string; type: string } {
227
+ const mode = String(args?.connection_mode || DEFAULT_BAMBU_CONNECTION_MODE || "lan_mqtt_ftps").trim().toLowerCase();
228
+ if (!CONNECTION_MODES.includes(mode)) throw new Error("Unsupported connection_mode.");
229
+ const type = String(args?.connection_type || DEFAULT_BAMBU_CONNECTION_TYPE || "cloud").trim().toLowerCase();
230
+ if (!["cloud", "lan"].includes(type)) throw new Error('connection_type must be "cloud" or "lan".');
231
+ return { mode, type };
232
+ }
233
+
234
+ /** bambu-printer-mcp's installed-plug-in (X2D native) and Bambu Connect routes are not part of this server. */
235
+ function assertConnectionAvailable(mode: string): void {
236
+ if (mode === "bambu_native" || mode === "bambu_connect") {
237
+ throw new Error(
238
+ `connection_mode ${mode} is not available in mcp-3D-printer-server. Use lan_mqtt_ftps or bambu_network (FULU), ` +
239
+ `or bambu-printer-mcp for ${mode === "bambu_native" ? "the X2D native route" : "Bambu Connect"}. Nothing was sent.`
240
+ );
241
+ }
242
+ }
243
+
244
+ const CONNECTION_PROPERTIES = {
245
+ 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." },
246
+ 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." },
247
+ };
248
+
249
+ /** The Send dialog options a caller set explicitly; the rest resolve as Bambu Studio does (studio-print-options.ts). */
250
+ function callerPrintOptions(args: Record<string, any> | undefined): CallerPrintOptions {
251
+ const flag = (value: unknown) => value === undefined ? undefined : Boolean(value);
252
+ const options: CallerPrintOptions = {
253
+ bedLeveling: flag(args?.bed_leveling),
254
+ flowCalibration: flag(args?.flow_calibration),
255
+ vibrationCalibration: flag(args?.vibration_calibration),
256
+ layerInspect: flag(args?.layer_inspect),
257
+ };
258
+ return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) as CallerPrintOptions;
259
+ }
185
260
 
186
261
  const VALID_BAMBU_MODELS = ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"] as const;
187
262
  type BambuModel = typeof VALID_BAMBU_MODELS[number];
@@ -225,6 +300,7 @@ function resolveNozzleType(value: unknown): string | undefined {
225
300
 
226
301
  // Shared slice_stl / slice_with_template argument schema.
227
302
  const SLICE_TOOL_PROPERTIES = {
303
+ 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." },
228
304
  bambu_model: {
229
305
  type: "string",
230
306
  enum: [...SLICE_BAMBU_MODELS],
@@ -292,7 +368,7 @@ const SLICE_TOOL_PROPERTIES = {
292
368
  description: "Template directory override when resolving template_name (default: BAMBU_TEMPLATE_DIR or ~/Sync/bambu/templates).",
293
369
  },
294
370
  uptodate: { type: "boolean", description: "Bambu-compatible slicing: refresh 3MF preset configs to the installed slicer version (--uptodate)." },
295
- 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." },
371
+ 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." },
296
372
  orient: { type: "boolean", description: "Bambu-compatible slicing: auto-orient for printability (--orient)." },
297
373
  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." },
298
374
  ensure_on_bed: { type: "boolean", description: "Bambu-compatible slicing: lower floating models onto the bed (--ensure-on-bed)." },
@@ -347,6 +423,7 @@ const PRINTER_TARGET_PROPERTIES = {
347
423
  /** Tools that declare an outputSchema: their errors carry no structuredContent, which clients would validate. */
348
424
  const OUTPUT_SCHEMA_TOOLS = new Set([
349
425
  "counterpart_printer_info", "counterpart_print_progress", "camera_snapshot", "get_printer_filaments", "pause_print", "resume_print",
426
+ "camera_stream_start", "camera_frame", "camera_stream_stop",
350
427
  "fusion_mcp_status", "fusion_mcp_call", "fusion_mcp_new_design", "fusion_mcp_export",
351
428
  ]);
352
429
 
@@ -444,6 +521,7 @@ function buildBambuSliceOptions(args: Record<string, unknown> | undefined): Bamb
444
521
  options.rotateX = optionalFiniteNumber(args, "rotate_x");
445
522
  options.rotateY = optionalFiniteNumber(args, "rotate_y");
446
523
  options.slicePlate = optionalFiniteNumber(args, "slice_plate");
524
+ options.pauseAtLayers = parsePauseLayers(args?.pause_at_layers);
447
525
  if (options.repetitions !== undefined && (!Number.isInteger(options.repetitions) || options.repetitions < 1)) {
448
526
  throw new Error("Invalid repetitions: expected a positive integer.");
449
527
  }
@@ -500,6 +578,12 @@ function validateBambuModel(model: string): BambuModel {
500
578
  return normalized as BambuModel;
501
579
  }
502
580
 
581
+ /** An inventory model hint: the argument, else BAMBU_MODEL; validated when present, never required. */
582
+ function optionalBambuModelHint(value: unknown): string | undefined {
583
+ const requested = String(value ?? DEFAULT_BAMBU_MODEL ?? "").trim().toLowerCase();
584
+ return requested ? validateBambuModel(requested) : undefined;
585
+ }
586
+
503
587
  function resolveBedType(bedType: string | undefined): string {
504
588
  const resolved = (bedType || DEFAULT_BED_TYPE).trim().toLowerCase();
505
589
  if (!(VALID_BED_TYPES as readonly string[]).includes(resolved)) {
@@ -626,6 +710,8 @@ type StructuredToolError = {
626
710
  message: string;
627
711
  tool: string;
628
712
  slicer?: Record<string, unknown>;
713
+ /** The slicer's own result.json, verbatim. */
714
+ slicer_result?: Record<string, unknown>;
629
715
  };
630
716
 
631
717
  const SLICING_TOOLS = new Set(["slice_stl", "slice_with_template", "list_templates", "save_template", "get_slice_settings"]);
@@ -655,6 +741,8 @@ class ThreeDPrinterMCPServer {
655
741
  private printerFactory: PrinterFactory;
656
742
  private readonly counterpart: CounterpartService;
657
743
  private stlManipulator: STLManipulator;
744
+ /** FULU's BambuNetwork bridge host, started on first use by the bambu_network route. */
745
+ private bambuNetwork = new BambuNetworkBridge();
658
746
  private readonly runtimeConfig: RuntimeConfig;
659
747
  private httpRuntime?: { transport: StreamableHTTPServerTransport; httpServer: HttpServer };
660
748
 
@@ -667,9 +755,11 @@ class ThreeDPrinterMCPServer {
667
755
  },
668
756
  {
669
757
  capabilities: {
758
+ prompts: {},
670
759
  resources: {},
671
760
  tools: {}
672
- }
761
+ },
762
+ instructions: SERVER_INSTRUCTIONS
673
763
  }
674
764
  );
675
765
 
@@ -678,7 +768,8 @@ class ThreeDPrinterMCPServer {
678
768
  (printerType) => this.printerFactory.getImplementation(printerType),
679
769
  axios.create({ timeout: 10000 }),
680
770
  { name: SERVER_NAME, version: SERVER_VERSION },
681
- (material) => resolveFilamentProfile(material, defaultFilamentProfileContext())
771
+ (material) => resolveFilamentProfile(material, defaultFilamentProfileContext()),
772
+ () => ({ slicerType: DEFAULT_SLICER_TYPE, slicerPath: DEFAULT_SLICER_PATH || undefined })
682
773
  );
683
774
  this.stlManipulator = new STLManipulator(TEMP_DIR);
684
775
 
@@ -686,7 +777,7 @@ class ThreeDPrinterMCPServer {
686
777
  this.setupErrorHandling();
687
778
  }
688
779
 
689
- /** Human MCP elicitation before a print start or positive heating command. */
780
+ /** Human MCP elicitation for a physical check software cannot make (resuming a job that cannot be re-read). */
690
781
  private async confirmHardware(message: string): Promise<boolean> {
691
782
  let response;
692
783
  const timeout = confirmationTimeoutMs();
@@ -703,7 +794,7 @@ class ThreeDPrinterMCPServer {
703
794
  },
704
795
  }, { timeout });
705
796
  } catch (error) {
706
- // A person may take minutes to check the bed; a timeout is not a missing capability.
797
+ // A person may take minutes to check the printer; a timeout is not a missing capability.
707
798
  if ((error as { code?: unknown })?.code === ErrorCode.RequestTimeout) throw new Error(confirmationTimedOut(timeout));
708
799
  throw new Error(CONFIRMATION_UNAVAILABLE);
709
800
  }
@@ -784,7 +875,7 @@ class ThreeDPrinterMCPServer {
784
875
  }
785
876
 
786
877
  /** Shared slice_stl / slice_with_template implementation. */
787
- private async runSliceTool(toolName: string, args: Record<string, unknown> | undefined): Promise<string> {
878
+ private async runSliceTool(toolName: string, args: Record<string, unknown> | undefined): Promise<SliceOutput> {
788
879
  if (!args?.stl_path) {
789
880
  throw new Error("Missing required parameter: stl_path");
790
881
  }
@@ -795,6 +886,9 @@ class ThreeDPrinterMCPServer {
795
886
  // Generic OrcaSlicer keeps its user-supplied machine profile unless the
796
887
  // call explicitly targets a Bambu model.
797
888
  const bambuCli = isBambuProjectSlicer(slicerType) || (slicerType === "orcaslicer" && explicitModel !== undefined);
889
+ if (!bambuCli && parsePauseLayers(args.pause_at_layers)) {
890
+ throw new Error(`Pausing at a layer needs the Bambu Studio or OrcaSlicer CLI, not ${slicerType}.`);
891
+ }
798
892
 
799
893
  let printerPreset: string | undefined;
800
894
  let options: BambuSliceOptions | undefined;
@@ -847,7 +941,7 @@ class ThreeDPrinterMCPServer {
847
941
  const slicerPath =
848
942
  this.resolveExecutableSelectorArg(args.slicer_path, toolName, "slicer_path") ?? defaultSlicerPathFor(slicerType);
849
943
 
850
- return this.stlManipulator.sliceSTL(
944
+ return this.stlManipulator.sliceSTLWithResult(
851
945
  String(args.stl_path),
852
946
  slicerType,
853
947
  slicerPath,
@@ -868,59 +962,254 @@ class ThreeDPrinterMCPServer {
868
962
 
869
963
  setupHandlers() {
870
964
  this.setupResourceHandlers();
965
+ this.setupPromptHandlers();
871
966
  this.setupToolHandlers();
872
967
  }
873
968
 
874
- private extractParsedAmsMapping(parsed3MFData: ThreeMFData | undefined): number[] | undefined {
875
- if (!parsed3MFData?.slicerConfig?.ams_mapping) {
876
- return undefined;
877
- }
878
-
879
- const slots = Object.values(parsed3MFData.slicerConfig.ams_mapping).filter(
880
- (value): value is number => typeof value === "number"
881
- );
882
-
883
- return slots.length > 0 ? slots.sort((a, b) => a - b) : undefined;
884
- }
885
-
969
+ /**
970
+ * The AMS choice for a Bambu print, as bambu-printer-mcp's print_3mf resolves
971
+ * it: the 3MF's own mapping (ordered by project filament index), overridden by
972
+ * an explicit ams_mapping, which ams_slots (one tray per used filament)
973
+ * replaces. use_ams:false prints from the external spool. Tray values are
974
+ * checked here; the adapter expands ams_slots against the inspected plate.
975
+ */
886
976
  private resolveAmsPrintOptions(
887
977
  parsed3MFData: ThreeMFData | undefined,
888
- args: Record<string, unknown> | undefined
889
- ): { useAMS: boolean; amsMapping?: number[] } {
890
- let finalAmsMapping = this.extractParsedAmsMapping(parsed3MFData);
891
- let useAMS =
892
- args?.use_ams !== undefined
893
- ? Boolean(args.use_ams)
894
- : !!finalAmsMapping && finalAmsMapping.length > 0;
895
-
896
- const override = args?.ams_mapping;
897
- if (override !== undefined) {
898
- let userMappingOverride: number[] | undefined;
899
-
900
- if (Array.isArray(override)) {
901
- userMappingOverride = override.filter((value): value is number => typeof value === "number");
902
- } else if (typeof override === "object" && override !== null) {
903
- userMappingOverride = Object.values(override as Record<string, unknown>)
904
- .filter((value): value is number => typeof value === "number")
905
- .sort((a, b) => a - b);
978
+ args: Record<string, unknown> | undefined,
979
+ model?: string
980
+ ): { useAMS: boolean; amsMapping?: number[]; amsSlots?: number[] } {
981
+ const trayOptions = { dualNozzle: isDualNozzleModel(model) };
982
+ let finalAmsMapping: number[] | undefined;
983
+ let finalAmsSlots: number[] | undefined;
984
+ if (parsed3MFData?.slicerConfig?.ams_mapping) {
985
+ const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
986
+ if (slots.length > 0) finalAmsMapping = slots;
987
+ }
988
+ let useAMS = args?.use_ams !== undefined ? Boolean(args.use_ams) : !!finalAmsMapping && finalAmsMapping.length > 0;
989
+ const hasUserAmsMapping = hasAmsMappingInput(args?.ams_mapping);
990
+ const hasUserAmsSlots = Array.isArray(args?.ams_slots);
991
+
992
+ if (hasUserAmsMapping) {
993
+ let userMapping: number[] | undefined;
994
+ if (Array.isArray(args!.ams_mapping)) {
995
+ userMapping = (args!.ams_mapping as unknown[]).map((value, index) =>
996
+ normalizeBridgeAmsTrayValue(value, `ams_mapping[${index}]`, trayOptions));
997
+ } else if (args!.ams_mapping && typeof args!.ams_mapping === "object") {
998
+ userMapping = normalizeAmsMappingObject(args!.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
999
+ } else {
1000
+ throw new Error("ams_mapping must be an array of trays or an object of trays keyed by project filament index.");
906
1001
  }
907
-
908
- if (userMappingOverride && userMappingOverride.length > 0) {
909
- finalAmsMapping = userMappingOverride;
1002
+ if (userMapping.length > 0) {
1003
+ finalAmsMapping = userMapping;
1004
+ useAMS = true;
1005
+ }
1006
+ }
1007
+ if (hasUserAmsSlots) {
1008
+ const userSlots = (args!.ams_slots as unknown[]).map((value, index) =>
1009
+ normalizeBridgeAmsTrayValue(value, `ams_slots[${index}]`, trayOptions));
1010
+ if (userSlots.length > 0) {
1011
+ finalAmsSlots = userSlots;
1012
+ finalAmsMapping = undefined;
910
1013
  useAMS = true;
911
1014
  }
912
1015
  }
913
-
914
1016
  if (args?.use_ams === false) {
915
- finalAmsMapping = undefined;
1017
+ if (!hasUserAmsMapping) finalAmsMapping = undefined;
1018
+ if (!hasUserAmsSlots) finalAmsSlots = undefined;
916
1019
  useAMS = false;
917
1020
  }
918
-
919
- if (!finalAmsMapping || finalAmsMapping.length === 0) {
1021
+ if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
920
1022
  useAMS = false;
921
1023
  }
1024
+ return { useAMS, amsMapping: finalAmsMapping, amsSlots: finalAmsSlots };
1025
+ }
1026
+
1027
+ /**
1028
+ * print_3mf over FULU's BambuNetwork bridge (connection_mode bambu_network),
1029
+ * ported from bambu-printer-mcp: the route a P1S on authorization firmware
1030
+ * without Developer Mode prints through. connection_type lan opens the
1031
+ * plug-in's authenticated local session (start_local_print); cloud sends
1032
+ * through Bambu Cloud (start_print). The shared gates run first: the exact
1033
+ * snapshot bytes are inspected, the AMS mapping checked, and fresh LAN MQTT
1034
+ * state validated before the bridge starts, again immediately before the
1035
+ * print frame, and the job is recorded for a checked resume.
1036
+ */
1037
+ private async print3mfViaBambuNetwork(options: {
1038
+ args: Record<string, any>; host: string; serial: string; token: string; threeMFPath: string;
1039
+ model: string; nozzleDiameters?: number[]; bedType: string; connectionType: string;
1040
+ ams: { useAMS: boolean; amsMapping?: number[]; amsSlots?: number[] };
1041
+ /** The zero-based plate print_3mf validated: inspected and dispatched as the same plate. */
1042
+ plateIndex: number;
1043
+ }): Promise<Record<string, unknown>> {
1044
+ const { args, model, bedType, connectionType } = options;
1045
+ const devId = options.serial.trim();
1046
+ const devIp = options.host.trim();
1047
+ const password = options.token.trim();
1048
+ if (!devId || !devIp || devIp === "localhost" || !password) {
1049
+ 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.");
1050
+ }
1051
+ const isLocalBridgePrint = connectionType === "lan";
1052
+ const bridgePrintMethod = isLocalBridgePrint ? "start_local_print" : "start_print";
1053
+ const bridgeMethod = `net.${bridgePrintMethod}`;
1054
+ const { plateIndex } = options;
1055
+ const threeMfFilename = path.basename(options.threeMFPath);
1056
+ const projectName = threeMfFilename.replace(/\.3mf$/i, "");
1057
+ const presetName = `${projectName}_plate_${plateIndex + 1}`;
1058
+ const clientJobId = Date.now();
1059
+ const bambu = this.printerFactory.getImplementation("bambu") as unknown as BambuImplementation;
1060
+ const dualNozzle = isDualNozzleModel(model);
1061
+
1062
+ return withPrinterOperation(devIp, devId, (assertActive) => withPrintSnapshot(options.threeMFPath, async (snapshot, retainForDispatchedUpload) => {
1063
+ const inspection = await inspectPrintFile(snapshot, { model, nozzleDiameters: options.nozzleDiameters, plateIndex, bedType });
1064
+ const resumeFile = await checkedFile(inspection, snapshot);
1065
+ // The options Bambu Studio's Send dialog would send; explicit ones win.
1066
+ const studio = resolveStudioPrintOptions(model, callerPrintOptions(args), { usedNozzles: usedNozzleCount(inspection) });
1067
+ const { useAMS, amsSlots } = options.ams;
1068
+ let mapping = options.ams.amsMapping?.slice();
1069
+ if (amsSlots) {
1070
+ if (amsSlots.length !== inspection.usedFilamentPositions.length) throw new Error("ams_slots must supply exactly one physical tray per used filament.");
1071
+ mapping = Array<number>(inspection.materials.length).fill(-1);
1072
+ inspection.usedFilamentPositions.forEach((position, index) => { mapping![position] = amsSlots[index]; });
1073
+ }
1074
+ if (!useAMS) {
1075
+ mapping ??= Array<number>(inspection.materials.length).fill(-1);
1076
+ for (const position of inspection.usedFilamentPositions) {
1077
+ if (mapping[position] === undefined || mapping[position] < 0) {
1078
+ mapping[position] = externalTrayForNozzle(inspection.filamentNozzleIndices?.[position], dualNozzle);
1079
+ }
1080
+ if (mapping[position] !== 254 && !(dualNozzle && mapping[position] === 255)) {
1081
+ throw new Error("use_ams:false requires an external-spool mapping for every used filament.");
1082
+ }
1083
+ }
1084
+ }
1085
+ for (const position of inspection.usedFilamentPositions) {
1086
+ if (mapping?.[position] === undefined || mapping[position] < 0) {
1087
+ 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.`);
1088
+ }
1089
+ }
1090
+ const requirements = { ...inspection, amsMapping: mapping, useAMS };
1091
+ validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
1092
+ const confirmedStatus = await bambu.getSafetyStatus(devIp, devId, password);
1093
+ validatePrinterState(confirmedStatus, requirements);
1094
+ const wireMapping = mapping!.map((value) => bridgeAmsMappingValue(value, dualNozzle));
1095
+ const remoteFileName = uniquePrintName(options.threeMFPath);
1096
+ const checkedName = remoteFileName.replace(/(?:\.gcode)?\.3mf$/i, "");
1097
+ const params: Record<string, unknown> = {
1098
+ dev_id: devId,
1099
+ // The printer reports the task or project name back; both carry the unique
1100
+ // checked name, which binds the job to this inspected artifact for a later resume.
1101
+ task_name: checkedName,
1102
+ project_name: checkedName,
1103
+ preset_name: presetName,
1104
+ filename: snapshot,
1105
+ config_filename: snapshot,
1106
+ plate_index: plateIndex + 1,
1107
+ ftp_folder: "",
1108
+ ftp_file: "",
1109
+ ftp_file_md5: "",
1110
+ nozzle_mapping: "",
1111
+ ams_mapping: JSON.stringify(wireMapping),
1112
+ ams_mapping2: JSON.stringify(mapping!.map((value) => bridgeAmsMapping2Entry(value, dualNozzle))),
1113
+ // Bambu Studio and FULU's slicer send this with every job (get_ams_mapping_result).
1114
+ ams_mapping_info: inspection.projectFilaments?.length
1115
+ ? JSON.stringify(studioAmsMappingInfo(inspection.projectFilaments, mapping!, (tray) => trayColour(confirmedStatus.raw, tray)))
1116
+ : "",
1117
+ nozzles_info: "",
1118
+ connection_type: connectionType,
1119
+ comments: "",
1120
+ origin_profile_id: 0,
1121
+ stl_design_id: 0,
1122
+ origin_model_id: "",
1123
+ print_type: "from_normal",
1124
+ dst_file: remoteFileName,
1125
+ dev_name: "",
1126
+ dev_ip: devIp,
1127
+ use_ssl_for_ftp: true,
1128
+ use_ssl_for_mqtt: true,
1129
+ username: "bblp",
1130
+ password,
1131
+ task_bed_leveling: studio.task_bed_leveling,
1132
+ task_flow_cali: studio.task_flow_cali,
1133
+ task_vibration_cali: studio.task_vibration_cali,
1134
+ task_layer_inspect: studio.task_layer_inspect,
1135
+ task_record_timelapse: args?.timelapse !== undefined ? Boolean(args.timelapse) : false,
1136
+ task_use_ams: useAMS,
1137
+ task_bed_type: bedType,
1138
+ extra_options: "",
1139
+ auto_bed_leveling: studio.auto_bed_leveling,
1140
+ auto_flow_cali: studio.auto_flow_cali,
1141
+ auto_offset_cali: studio.auto_offset_cali,
1142
+ extruder_cali_manual_mode: studio.extruder_cali_manual_mode,
1143
+ task_ext_change_assist: false,
1144
+ try_emmc_print: false,
1145
+ };
1146
+ assertActive();
1147
+ const bridgeOptions = {
1148
+ // Trusted in-process preflight immediately before the print frame.
1149
+ beforeDispatch: async (method: string) => {
1150
+ if (method !== bridgeMethod) return;
1151
+ assertActive();
1152
+ validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
1153
+ },
1154
+ assertDispatchAllowed: (method: string) => { if (method === bridgeMethod) assertActive(); },
1155
+ onDispatched: (method: string) => { if (method === bridgeMethod) retainForDispatchedUpload(); },
1156
+ };
1157
+ let jobUpdates: BridgeJobUpdate[] = [];
1158
+ let bridgeResult: unknown;
1159
+ if (isLocalBridgePrint) {
1160
+ ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startLocalPrint(
1161
+ { devId, devIp, username: "bblp", password, useSsl: true },
1162
+ bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive
1163
+ ));
1164
+ } else {
1165
+ ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startCloudPrint(devId, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive));
1166
+ }
1167
+ if (typeof bridgeResult === "object" && bridgeResult !== null && (bridgeResult as { ok?: unknown }).ok === false) {
1168
+ throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} failed: ${String((bridgeResult as { error?: unknown }).error || "unknown bridge error")}`);
1169
+ }
1170
+ const value = typeof bridgeResult === "object" && bridgeResult !== null ? (bridgeResult as { value?: unknown }).value : undefined;
1171
+ if (typeof value === "number" && value !== 0) {
1172
+ const reported = jobUpdates.filter((update) => update.code !== 0 || update.msg).slice(-3)
1173
+ .map((update) => `status ${update.status}, code ${update.code}${update.msg ? `: ${update.msg}` : ""}`).join("; ");
1174
+ throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} returned non-zero result ${value}${describeBambuNetworkPrintError(value)}.` +
1175
+ (reported ? ` Plug-in reported: ${reported}.` : ""));
1176
+ }
1177
+ assertActive();
1178
+ // Bambu's plug-in placed the file, so a resume re-reads it if it can and otherwise asks a person.
1179
+ bambu.recordCheckedJob(devIp, devId, remoteFileName, requirements, resumeFile, false);
1180
+ return {
1181
+ status: "success",
1182
+ message: `FULU BambuNetwork ${bridgePrintMethod} command for ${threeMfFilename} sent successfully.`,
1183
+ connectionMode: "bambu_network",
1184
+ connectionType,
1185
+ bridgeMethod,
1186
+ bridgeResult,
1187
+ clientJobId,
1188
+ projectName,
1189
+ plateIndex,
1190
+ bridgePlateIndex: plateIndex + 1,
1191
+ printerJobName: checkedName,
1192
+ useAMS,
1193
+ amsMapping: mapping,
1194
+ sha256: inspection.sha256,
1195
+ params: { ...params, password: "[redacted]" },
1196
+ };
1197
+ }));
1198
+ }
922
1199
 
923
- return { useAMS, amsMapping: finalAmsMapping };
1200
+ /** The FDM design guidance as MCP prompts: the client decides when to attach each one. */
1201
+ setupPromptHandlers() {
1202
+ this.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
1203
+ prompts: DESIGN_PROMPTS.map(({ name, title, description }) => ({ name, title, description })),
1204
+ }));
1205
+ this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
1206
+ const prompt = DESIGN_PROMPTS.find(item => item.name === request.params.name);
1207
+ if (!prompt) throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${request.params.name}`);
1208
+ return {
1209
+ description: prompt.description,
1210
+ messages: [{ role: "user" as const, content: { type: "text" as const, text: prompt.text } }],
1211
+ };
1212
+ });
924
1213
  }
925
1214
 
926
1215
  setupResourceHandlers() {
@@ -939,7 +1228,8 @@ class ThreeDPrinterMCPServer {
939
1228
  name: "3D Printer Files",
940
1229
  mimeType: "application/json",
941
1230
  description: "List of files available on the 3D printer"
942
- }
1231
+ },
1232
+ FDM_CAPABILITY_RESOURCE
943
1233
  ],
944
1234
  templates: [
945
1235
  {
@@ -964,6 +1254,9 @@ class ThreeDPrinterMCPServer {
964
1254
  // Read resource
965
1255
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
966
1256
  const uri = request.params.uri;
1257
+ if (uri === FDM_CAPABILITY_URI) {
1258
+ return { contents: [{ uri, mimeType: FDM_CAPABILITY_RESOURCE.mimeType, text: FDM_CAPABILITY_GUIDANCE }] };
1259
+ }
967
1260
  const match = uri.match(/^printer:\/\/([^\/]+)\/(.+)$/);
968
1261
 
969
1262
  if (!match) {
@@ -1079,7 +1372,7 @@ class ThreeDPrinterMCPServer {
1079
1372
  },
1080
1373
  {
1081
1374
  name: "upload_gcode",
1082
- 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.",
1375
+ 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.",
1083
1376
  inputSchema: {
1084
1377
  type: "object",
1085
1378
  properties: {
@@ -1137,7 +1430,7 @@ class ThreeDPrinterMCPServer {
1137
1430
  },
1138
1431
  {
1139
1432
  name: "start_print",
1140
- 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.",
1433
+ 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.",
1141
1434
  inputSchema: {
1142
1435
  type: "object",
1143
1436
  properties: {
@@ -1211,10 +1504,23 @@ class ThreeDPrinterMCPServer {
1211
1504
  bambu_token: {
1212
1505
  type: "string",
1213
1506
  description: "Access token for Bambu Lab printers (default: value from env)"
1214
- }
1507
+ },
1508
+ ...CONNECTION_PROPERTIES,
1215
1509
  }
1216
1510
  }
1217
1511
  },
1512
+ ...CAMERA_STREAM_TOOLS,
1513
+ {
1514
+ name: "counterpart_print_route",
1515
+ 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.",
1516
+ inputSchema: {
1517
+ type: "object",
1518
+ properties: {
1519
+ route: { type: "string", enum: ["lan", "cloud", "developer"], description: "Change the route. Omit to read it." }
1520
+ },
1521
+ additionalProperties: false
1522
+ }
1523
+ },
1218
1524
  {
1219
1525
  name: "counterpart_printer_info",
1220
1526
  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.",
@@ -1245,28 +1551,35 @@ class ThreeDPrinterMCPServer {
1245
1551
  },
1246
1552
  {
1247
1553
  name: "get_printer_filaments",
1248
- 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.",
1249
- inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1554
+ 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.",
1555
+ inputSchema: {
1556
+ type: "object",
1557
+ properties: {
1558
+ ...PRINTER_TARGET_PROPERTIES,
1559
+ bambu_model: { type: "string", description: "Bambu only: optional model hint used to resolve Bambu/Orca filament profile JSONs for each tray (default: BAMBU_MODEL)." },
1560
+ nozzle_diameter: { type: "string", description: "Bambu only: optional nozzle diameter used when resolving model-specific filament profile JSONs (default: 0.4)." },
1561
+ },
1562
+ },
1250
1563
  outputSchema: FILAMENTS_OUTPUT_SCHEMA,
1251
1564
  annotations: { readOnlyHint: true, openWorldHint: true },
1252
1565
  },
1253
1566
  {
1254
1567
  name: "pause_print",
1255
- description: "Pause the current print (OctoPrint, Klipper/Moonraker, PrusaLink, Duet M25, Repetier, and Creality printers that run Moonraker). Pause is never gated.",
1256
- inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1568
+ 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.",
1569
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES, ...CONNECTION_PROPERTIES } },
1257
1570
  outputSchema: CONTROL_OUTPUT_SCHEMA,
1258
1571
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1259
1572
  },
1260
1573
  {
1261
1574
  name: "resume_print",
1262
- 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.",
1263
- inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1575
+ 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.",
1576
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES, ...CONNECTION_PROPERTIES } },
1264
1577
  outputSchema: CONTROL_OUTPUT_SCHEMA,
1265
1578
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1266
1579
  },
1267
1580
  {
1268
1581
  name: "set_printer_temperature",
1269
- 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.",
1582
+ 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.",
1270
1583
  inputSchema: {
1271
1584
  type: "object",
1272
1585
  properties: {
@@ -1700,16 +2013,35 @@ class ThreeDPrinterMCPServer {
1700
2013
  required: ["stl_path"]
1701
2014
  }
1702
2015
  },
2016
+ {
2017
+ name: "inspect_print_file",
2018
+ 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.",
2019
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2020
+ inputSchema: {
2021
+ type: "object",
2022
+ additionalProperties: false,
2023
+ properties: {
2024
+ file_path: { type: "string", minLength: 1, description: "Local prepared .3mf or textual .gcode artifact to inspect; checksum-only and unsliced projects reject." },
2025
+ bambu_model: { type: "string", enum: [...VALID_BAMBU_MODELS], description: "Explicit file-inspection target model; never inferred from environment or live state." },
2026
+ plate_index: { type: "integer", minimum: 0, description: "Zero-based selected plate; defaults to 0. Raw G-code only supports 0." },
2027
+ 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." },
2028
+ bed_type: { type: "string", enum: [...VALID_BED_TYPES], description: "Optional declared bed type to compare with file metadata." },
2029
+ },
2030
+ required: ["file_path", "bambu_model"],
2031
+ },
2032
+ },
1703
2033
  {
1704
2034
  name: "print_3mf",
1705
- 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.",
2035
+ 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.",
1706
2036
  inputSchema: {
1707
2037
  type: "object",
1708
2038
  properties: {
2039
+ 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." },
1709
2040
  three_mf_path: {
1710
2041
  type: "string",
1711
2042
  description: "Path to the 3MF file to print."
1712
2043
  },
2044
+ 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." },
1713
2045
  bambu_model: {
1714
2046
  type: "string",
1715
2047
  enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
@@ -1745,10 +2077,18 @@ class ThreeDPrinterMCPServer {
1745
2077
  nozzle_temperature: { type: "number", description: "Override nozzle temperature (°C)." },
1746
2078
  bed_temperature: { type: "number", description: "Override bed temperature (°C)." },
1747
2079
  support_enabled: { type: "boolean", description: "Override support generation." },
2080
+ ...CONNECTION_PROPERTIES,
1748
2081
  ams_mapping: {
1749
- type: "object",
1750
- description: "Override AMS filament mapping (e.g., {\"Generic PLA\": 0, \"Generic PETG\": 1}).",
1751
- additionalProperties: { type: "number" }
2082
+ anyOf: [
2083
+ { type: "array", items: { type: "number" } },
2084
+ { type: "object", additionalProperties: { type: "number" } },
2085
+ ],
2086
+ 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.",
2087
+ },
2088
+ ams_slots: {
2089
+ type: "array",
2090
+ items: { type: "number" },
2091
+ 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.",
1752
2092
  },
1753
2093
  slicer_type: {
1754
2094
  type: "string",
@@ -1769,7 +2109,7 @@ class ThreeDPrinterMCPServer {
1769
2109
  },
1770
2110
  use_ams: {
1771
2111
  type: "boolean",
1772
- description: "Whether to use AMS for the print. Defaults from parsed 3MF mapping when present."
2112
+ 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."
1773
2113
  },
1774
2114
  bed_leveling: {
1775
2115
  type: "boolean",
@@ -1919,7 +2259,7 @@ class ThreeDPrinterMCPServer {
1919
2259
  },
1920
2260
  {
1921
2261
  name: "lay_flat",
1922
- description: "Attempt to rotate the model so its largest flat face lies on the XY plane (Z=0).",
2262
+ 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.",
1923
2263
  inputSchema: {
1924
2264
  type: "object",
1925
2265
  properties: {
@@ -2082,12 +2422,39 @@ class ThreeDPrinterMCPServer {
2082
2422
  type, host, port,
2083
2423
  apiKey: type.toLowerCase() === "bambu" ? `${bambuSerial}:${bambuToken}` : apiKey,
2084
2424
  };
2425
+ // Bambu print controls go over the same connection as the print (resolveConnection);
2426
+ // undefined keeps direct LAN MQTT in BambuImplementation.
2427
+ const bambuControlRoute: BambuControlRoute = (command) => {
2428
+ if (type.toLowerCase() !== "bambu") return undefined;
2429
+ const route = resolveConnection(args);
2430
+ assertConnectionAvailable(route.mode);
2431
+ if (route.mode !== "bambu_network") return undefined;
2432
+ return (assertActive?: () => void, beforeDispatch?: () => Promise<void>) => {
2433
+ const options = { assertDispatchAllowed: () => { extra.signal.throwIfAborted(); assertActive?.(); } };
2434
+ return route.type === "cloud"
2435
+ ? this.bambuNetwork.sendCloudPrintCommand(bambuSerial, command, options, beforeDispatch)
2436
+ : this.bambuNetwork.sendLocalPrintCommand({ devId: bambuSerial, devIp: host, username: "bblp", password: bambuToken, useSsl: true }, command, options, beforeDispatch);
2437
+ };
2438
+ };
2085
2439
  const structured = (value: object) => ({
2086
2440
  content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
2087
2441
  structuredContent: value as Record<string, unknown>,
2088
2442
  });
2089
2443
 
2090
2444
  switch (name) {
2445
+ case "camera_stream_start":
2446
+ return structured(await this.counterpart.cameraStreamStart(target, {fps: args?.fps as number | undefined, idleTimeoutMs: args?.idle_timeout_ms as number | undefined}));
2447
+ case "camera_frame": {
2448
+ const frame = await this.counterpart.cameraStreamFrame(target, {
2449
+ sinceSeq: args?.since_seq as number | undefined, waitMs: args?.wait_ms as number | undefined,
2450
+ maxWidth: args?.max_width as number | undefined, maxBytes: args?.max_bytes as number | undefined,
2451
+ changedOnly: args?.changed_only as boolean | undefined, minChange: args?.min_change as number | undefined, signal: extra.signal,
2452
+ });
2453
+ // The JPEG appears only once in the MCP payload; text is useful metadata.
2454
+ return {content: [{type: "text" as const, text: JSON.stringify(Object.fromEntries(Object.entries(frame).filter(([key]) => key !== "base64")))}], structuredContent: frame};
2455
+ }
2456
+ case "camera_stream_stop":
2457
+ return structured(this.counterpart.cameraStreamStop(target));
2091
2458
  case "counterpart_printer_info":
2092
2459
  return structured(await this.counterpart.printerInfo(target));
2093
2460
 
@@ -2103,15 +2470,75 @@ class ThreeDPrinterMCPServer {
2103
2470
  }));
2104
2471
  }
2105
2472
 
2106
- case "get_printer_filaments":
2107
- return structured(await this.counterpart.filaments(target));
2473
+ case "get_printer_filaments": {
2474
+ const filamentOptions = type.toLowerCase() === "bambu" ? {
2475
+ bambuModel: optionalBambuModelHint(args?.bambu_model),
2476
+ nozzleDiameter: String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER),
2477
+ } : {};
2478
+ return structured(await this.counterpart.filaments(target, filamentOptions));
2479
+ }
2108
2480
 
2109
2481
  case "pause_print":
2110
- return structured(await this.counterpart.pause(target));
2482
+ return structured(await this.counterpart.pause(target, bambuControlRoute));
2111
2483
 
2112
2484
  case "resume_print":
2113
- return structured(await this.counterpart.resume(target));
2485
+ return structured(await this.counterpart.resume(target, bambuControlRoute));
2486
+
2487
+ case "counterpart_print_route": {
2488
+ let savedTo: string | null = null;
2489
+ // FULU's bridge is set up (configured or discovered), so the routes through it can be used.
2490
+ const bridgeConfigured = Boolean(this.bambuNetwork.resolveBridgeCommand());
2491
+ if (args?.route !== undefined) {
2492
+ if (!PRINT_ROUTES.includes(args.route as PrintRoute)) throw new Error('route must be "lan", "cloud" or "developer".');
2493
+ const next = connectionForRoute(args.route as PrintRoute, { mode: DEFAULT_BAMBU_CONNECTION_MODE, type: DEFAULT_BAMBU_CONNECTION_TYPE }, bridgeConfigured);
2494
+ // dotenv loaded .env from the working directory at start; a client's own env block has no file to update.
2495
+ const file = path.resolve(process.cwd(), ".env");
2496
+ if (fs.existsSync(file)) {
2497
+ writeEnvValues(file, {
2498
+ ...(next.mode ? { BAMBU_DEFAULT_CONNECTION_MODE: next.mode } : {}),
2499
+ ...(next.type ? { BAMBU_DEFAULT_CONNECTION_TYPE: next.type } : {}),
2500
+ });
2501
+ savedTo = file;
2502
+ }
2503
+ DEFAULT_BAMBU_CONNECTION_MODE = next.mode;
2504
+ DEFAULT_BAMBU_CONNECTION_TYPE = next.type;
2505
+ }
2506
+ const connection = resolveConnection({});
2507
+ const options = routeOptions(connection, bridgeConfigured);
2508
+ result = { route: routeOf(connection), mode: connection.mode, type: connection.type,
2509
+ choices: options.filter(option => option.available).map(option => option.id), options, savedTo };
2510
+ break;
2511
+ }
2114
2512
 
2513
+ case "inspect_print_file": {
2514
+ const allowed = new Set(["file_path", "bambu_model", "plate_index", "nozzle_diameters", "bed_type"]);
2515
+ if (!args || Object.keys(args).some(key => !allowed.has(key))) throw new Error("inspect_print_file accepts only file/model/plate/nozzle/bed arguments.");
2516
+ if (typeof args.file_path !== "string" || !args.file_path.trim() || args.file_path.includes("\0")) throw new Error("file_path must name a local prepared artifact.");
2517
+ if (typeof args.bambu_model !== "string" || !args.bambu_model.trim()) throw new Error("An explicit bambu_model is required for file inspection.");
2518
+ const model = validateBambuModel(args.bambu_model);
2519
+ const plateIndex = args.plate_index === undefined ? 0 : args.plate_index;
2520
+ if (typeof plateIndex !== "number" || !Number.isSafeInteger(plateIndex) || plateIndex < 0) throw new Error("plate_index must be a nonnegative safe integer.");
2521
+ if (args.nozzle_diameters !== undefined && (!Array.isArray(args.nozzle_diameters) || args.nozzle_diameters.some(value => typeof value !== "number"))) throw new Error("nozzle_diameters must be numeric sizes.");
2522
+ const nozzles = args.nozzle_diameters as number[] | undefined;
2523
+ if (nozzles && (nozzles.length < 1 || nozzles.length > 2 || nozzles.some(value => !SUPPORTED_NOZZLE_DIAMETERS.includes(value)))) throw new Error("nozzle_diameters must contain one or two supported sizes.");
2524
+ if (args.bed_type !== undefined && (typeof args.bed_type !== "string" || !args.bed_type.trim())) throw new Error("bed_type must be a supported plate type.");
2525
+ const bedType = args.bed_type === undefined ? undefined : resolveBedType(args.bed_type);
2526
+ if (extra.signal.aborted) throw new Error("File inspection cancelled.");
2527
+ result = await withPrintSnapshot(args.file_path, async snapshot => {
2528
+ const inspection = await inspectPrintFile(snapshot, { model, nozzleDiameters: nozzles, plateIndex, bedType });
2529
+ if (extra.signal.aborted) throw new Error("File inspection cancelled.");
2530
+ const byteLength = (await fs.promises.stat(snapshot)).size;
2531
+ return {
2532
+ receipt_version: "1",
2533
+ evidence: "file-only",
2534
+ dispatch_authorized: false,
2535
+ artifact: { sha256: inspection.sha256, byte_length: byteLength, file_format: path.extname(snapshot).toLowerCase() === ".3mf" ? "3mf" : "gcode" },
2536
+ selection: { bambu_model: model, plate_index: plateIndex, ...(nozzles === undefined ? {} : { nozzle_diameters: nozzles }), ...(bedType === undefined ? {} : { bed_type: bedType }) },
2537
+ inspection,
2538
+ };
2539
+ });
2540
+ break;
2541
+ }
2115
2542
  case "get_printer_status":
2116
2543
  result = await this.getPrinterStatus(host, port, type, apiKey, bambuSerial, bambuToken);
2117
2544
  break;
@@ -2163,7 +2590,7 @@ class ThreeDPrinterMCPServer {
2163
2590
  break;
2164
2591
 
2165
2592
  case "cancel_print":
2166
- result = await this.cancelPrint(host, port, type, apiKey, bambuSerial, bambuToken);
2593
+ result = await this.cancelPrint(host, port, type, apiKey, bambuSerial, bambuToken, bambuControlRoute("stop"));
2167
2594
  break;
2168
2595
 
2169
2596
  case "set_printer_temperature":
@@ -2197,15 +2624,17 @@ class ThreeDPrinterMCPServer {
2197
2624
  );
2198
2625
  break;
2199
2626
 
2200
- case "slice_stl":
2201
- result = await this.runSliceTool("slice_stl", args as Record<string, unknown> | undefined);
2202
- break;
2627
+ case "slice_stl": {
2628
+ const sliced = await this.runSliceTool("slice_stl", args as Record<string, unknown> | undefined);
2629
+ // The text stays the sliced file's path; slicer_result is the slicer's own result.json, verbatim.
2630
+ return { content: [{ type: "text", text: sliced.path }], structuredContent: sliced };
2631
+ }
2203
2632
 
2204
2633
  case "slice_with_template":
2205
2634
  if (!args?.template_name || !String(args.template_name).trim()) {
2206
2635
  throw new Error("Missing required parameter: template_name");
2207
2636
  }
2208
- result = await this.runSliceTool("slice_with_template", args as Record<string, unknown> | undefined);
2637
+ result = (await this.runSliceTool("slice_with_template", args as Record<string, unknown> | undefined)).path;
2209
2638
  break;
2210
2639
 
2211
2640
  case "list_templates":
@@ -2330,7 +2759,8 @@ class ThreeDPrinterMCPServer {
2330
2759
  }
2331
2760
  const amsOptions = this.resolveAmsPrintOptions(
2332
2761
  parsedProcess3MF,
2333
- args as Record<string, unknown> | undefined
2762
+ args as Record<string, unknown> | undefined,
2763
+ processModel
2334
2764
  );
2335
2765
 
2336
2766
  result = await factoryImplementation.print3mf(host, bambuSerial, bambuToken, {
@@ -2364,7 +2794,7 @@ class ThreeDPrinterMCPServer {
2364
2794
  // 3. Upload and print through the adapter's hard safety gate. It
2365
2795
  // inspects the exact bytes sent (S and R targets, tool changes,
2366
2796
  // hardware and material ceilings), requires the expected peaks to
2367
- // match, reads fresh printer state and asks for human confirmation.
2797
+ // match, and reads fresh printer state.
2368
2798
  // A mismatch refuses before upload; nothing is only logged.
2369
2799
  const filename = path.basename(gcodePath);
2370
2800
  const printResult = await this.uploadGcode(
@@ -2583,17 +3013,21 @@ class ThreeDPrinterMCPServer {
2583
3013
  throw new Error("Bambu serial number and access token are required for print_3mf.");
2584
3014
  }
2585
3015
 
3016
+ const printPlate = args.plate_index === undefined ? 0 : args.plate_index;
3017
+ if (typeof printPlate !== "number" || !Number.isSafeInteger(printPlate) || printPlate < 0 || printPlate >= Number.MAX_SAFE_INTEGER) throw new Error("plate_index must be a nonnegative safe integer with a supported one-based plate number.");
2586
3018
  const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
2587
3019
  const printBedType = resolveBedType(args?.bed_type as string | undefined);
2588
3020
  const explicitPrintNozzle = optionalNozzleDiameterArg(args?.nozzle_diameter);
2589
3021
  const printNozzle = String(explicitPrintNozzle ?? DEFAULT_NOZZLE_DIAMETER);
2590
3022
  const printPreset = BAMBU_MODEL_PRESETS[printModel]?.(printNozzle);
3023
+ const printConnection = resolveConnection(args);
3024
+ assertConnectionAvailable(printConnection.mode);
2591
3025
 
2592
3026
  let threeMFPath = String(args.three_mf_path);
2593
3027
 
2594
3028
  // Only the selected plate's exact G-code entry counts as sliced output;
2595
3029
  // a stray .gcode entry elsewhere in the archive is not printable.
2596
- const selectedPlateEntry = "Metadata/plate_1.gcode";
3030
+ const selectedPlateEntry = `Metadata/plate_${printPlate + 1}.gcode`;
2597
3031
  let shouldAutoSlice = false;
2598
3032
  try {
2599
3033
  const JSZip = (await import('jszip')).default;
@@ -2609,6 +3043,13 @@ class ThreeDPrinterMCPServer {
2609
3043
 
2610
3044
  // Only treat slicer_path as an executable selector when a slicer
2611
3045
  // will actually launch. Ready-to-print archives ignore it.
3046
+ const pauseLayers = parsePauseLayers(args?.pause_at_layers);
3047
+ if (!shouldAutoSlice && pauseLayers) {
3048
+ const gcode = await readPlateGcode(threeMFPath, printPlate + 1);
3049
+ const placed = gcode ? pausedLayers(gcode, machinePauseGcode(gcode)) : [];
3050
+ const missing = pauseLayers.filter(layer => !placed.includes(layer));
3051
+ if (missing.length) 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.`);
3052
+ }
2612
3053
  if (shouldAutoSlice) {
2613
3054
  const printSlicerPath =
2614
3055
  this.resolveExecutableSelectorArg(
@@ -2627,7 +3068,7 @@ class ThreeDPrinterMCPServer {
2627
3068
  printPreset,
2628
3069
  filamentProfile || undefined,
2629
3070
  // Slice for the plate and nozzle the print will be checked against.
2630
- { bedType: printBedType, ...(resolveNozzleType(args?.nozzle_type) ? { nozzleType: resolveNozzleType(args?.nozzle_type) } : {}) }
3071
+ { bedType: printBedType, slicePlate: printPlate + 1, ...(pauseLayers ? { pauseAtLayers: pauseLayers } : {}), ...(resolveNozzleType(args?.nozzle_type) ? { nozzleType: resolveNozzleType(args?.nozzle_type) } : {}) }
2631
3072
  );
2632
3073
  console.error("Auto-sliced to: " + threeMFPath);
2633
3074
  } catch (sliceErr: any) {
@@ -2658,7 +3099,8 @@ class ThreeDPrinterMCPServer {
2658
3099
  console.error(`Successfully parsed 3MF file: ${threeMFPath}`);
2659
3100
  const amsOptions = this.resolveAmsPrintOptions(
2660
3101
  parsed3MFData,
2661
- args as Record<string, unknown> | undefined
3102
+ args as Record<string, unknown> | undefined,
3103
+ printModel
2662
3104
  );
2663
3105
 
2664
3106
  // --- Prepare Implementation and Print Options ---
@@ -2689,12 +3131,22 @@ class ThreeDPrinterMCPServer {
2689
3131
  throw new Error(`Failed during 3MF processing: ${(error as Error).message}`);
2690
3132
  }
2691
3133
 
3134
+ if (printConnection.mode === "bambu_network") {
3135
+ result = await this.print3mfViaBambuNetwork({
3136
+ args: args as Record<string, any>, host, serial: bambuSerial, token: bambuToken, threeMFPath,
3137
+ model: printModel, nozzleDiameters: requestedPrintNozzles, bedType: printBedType,
3138
+ connectionType: printConnection.type, plateIndex: printPlate,
3139
+ ams: { useAMS: printOptions.useAMS, amsMapping: printOptions.amsMapping, amsSlots: printOptions.amsSlots },
3140
+ });
3141
+ break;
3142
+ }
3143
+
2692
3144
  // --- Call Implementation (Now variables are in scope) ---
2693
3145
  try {
2694
3146
  result = await implementation.print3mf(host, bambuSerial, bambuToken, {
2695
3147
  projectName: projectName,
2696
3148
  filePath: threeMFPath,
2697
- plateIndex: 0,
3149
+ plateIndex: printPlate,
2698
3150
  ...printOptions // Spread the final options
2699
3151
  });
2700
3152
  // Report what the printer did, not merely that a command was published.
@@ -2795,12 +3247,14 @@ class ThreeDPrinterMCPServer {
2795
3247
  result = await this.stlManipulator.centerModel(String(args.stl_path));
2796
3248
  break;
2797
3249
 
2798
- case "lay_flat":
3250
+ case "lay_flat": {
2799
3251
  if (!args?.stl_path) {
2800
3252
  throw new Error("Missing required parameter: stl_path");
2801
3253
  }
2802
- result = await this.stlManipulator.layFlat(String(args.stl_path));
2803
- break;
3254
+ const layFlatResult = await this.stlManipulator.layFlatWithResult(String(args.stl_path));
3255
+ // The text stays the STL path clients read; structuredContent says whether it was laid flat.
3256
+ return { content: [{ type: "text", text: layFlatResult.path }], structuredContent: layFlatResult };
3257
+ }
2804
3258
 
2805
3259
  case "fusion_mcp_status":
2806
3260
  return structured(await this.fusion.status(args ?? {}, extra.signal));
@@ -2861,7 +3315,8 @@ class ThreeDPrinterMCPServer {
2861
3315
  content: [
2862
3316
  {
2863
3317
  type: "text",
2864
- text: `Error: ${errorMessage}\nSuggestion: ${structuredError.suggestion}`
3318
+ // A slicer failure leads with the slicer's own words.
3319
+ text: `${error instanceof SlicerError && error.slicerText ? "" : "Error: "}${errorMessage}\nSuggestion: ${structuredError.suggestion}`
2865
3320
  }
2866
3321
  ],
2867
3322
  // Clients validate structuredContent against a declared outputSchema even on errors.
@@ -2992,13 +3447,14 @@ class ThreeDPrinterMCPServer {
2992
3447
  type: string,
2993
3448
  apiKey: string,
2994
3449
  bambuSerial: string,
2995
- bambuToken: string
3450
+ bambuToken: string,
3451
+ bambuDispatch?: () => Promise<any>
2996
3452
  ) {
2997
3453
  const implementation = this.printerFactory.getImplementation(type);
2998
3454
 
2999
3455
  if (type.toLowerCase() === "bambu") {
3000
3456
  const bambuApiKey = `${bambuSerial}:${bambuToken}`;
3001
- return await implementation.cancelJob(host, port, bambuApiKey);
3457
+ return await (implementation as unknown as BambuImplementation).cancelJob(host, port, bambuApiKey, bambuDispatch);
3002
3458
  }
3003
3459
 
3004
3460
  return await implementation.cancelJob(host, port, apiKey);
@@ -3035,6 +3491,7 @@ class ThreeDPrinterMCPServer {
3035
3491
  message,
3036
3492
  tool,
3037
3493
  slicer: { phase: error.phase, ...error.details },
3494
+ ...(error.slicerResult ? { slicer_result: error.slicerResult } : {}),
3038
3495
  };
3039
3496
  }
3040
3497
  if (SLICING_TOOLS.has(tool)) {
@@ -3257,7 +3714,9 @@ class ThreeDPrinterMCPServer {
3257
3714
  }
3258
3715
 
3259
3716
  private async closeOnce(): Promise<void> {
3717
+ this.counterpart.close();
3260
3718
  await this.printerFactory.disconnectAll();
3719
+ await this.bambuNetwork.stop();
3261
3720
  await this.fusion.close();
3262
3721
 
3263
3722
  if (this.httpRuntime) {