mcp-3d-printer-server 1.2.25 → 1.2.27

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 (95) hide show
  1. package/CHANGELOG.md +67 -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 +211 -0
  7. package/dist/bambu-network-bridge.js +918 -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 +514 -70
  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 +68 -8
  53. package/dist/safety/printer-state.js +187 -40
  54. package/dist/slicer/bambu-cli.d.ts +2 -0
  55. package/dist/slicer/bambu-cli.js +7 -5
  56. package/dist/slicer/layer-pauses.d.ts +47 -0
  57. package/dist/slicer/layer-pauses.js +130 -0
  58. package/dist/stl/stl-manipulator.d.ts +1 -0
  59. package/dist/stl/stl-manipulator.js +40 -0
  60. package/dist/studio-print-options.d.ts +57 -0
  61. package/dist/studio-print-options.js +179 -0
  62. package/dist/tool-annotations.d.ts +25 -0
  63. package/dist/tool-annotations.js +58 -0
  64. package/package.json +27 -4
  65. package/server.json +2 -2
  66. package/src/ams-mapping.ts +132 -0
  67. package/src/bambu-network-bridge.ts +1106 -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 +528 -83
  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 +192 -41
  92. package/src/slicer/bambu-cli.ts +8 -5
  93. package/src/slicer/layer-pauses.ts +143 -0
  94. package/src/stl/stl-manipulator.ts +45 -0
  95. 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";
@@ -33,7 +35,10 @@ import { randomUUID } from "node:crypto";
33
35
  import * as THREE from 'three';
34
36
  import { PrinterFactory } from "./printers/printer-factory.js";
35
37
  import { STLManipulator, type BambuSliceOptions, type ProcessOverrides, type SliceOutput } from "./stl/stl-manipulator.js";
36
- import { CounterpartService, type PrinterTarget } from "./counterpart/service.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";
41
+ import { annotateTools } from "./tool-annotations.js";
37
42
  import {
38
43
  CAMERA_SNAPSHOT_OUTPUT_SCHEMA,
39
44
  CONTROL_OUTPUT_SCHEMA,
@@ -55,8 +60,27 @@ import {
55
60
  inspectFuluOrcaSetup,
56
61
  invokeFuluBridgeRpc,
57
62
  } from "./fulu-orca.js";
63
+ import {
64
+ bridgeAmsMapping2Entry,
65
+ bridgeAmsMappingValue,
66
+ externalTrayForNozzle,
67
+ hasAmsMappingInput,
68
+ normalizeAmsMappingObject,
69
+ normalizeBridgeAmsTrayValue,
70
+ studioAmsMappingInfo,
71
+ trayColour,
72
+ } from "./ams-mapping.js";
73
+ import { isDualNozzleModel } from "./safety/limits.js";
74
+ import { BambuNetworkBridge, describeBambuNetworkPrintError, type BridgeJobUpdate } from "./bambu-network-bridge.js";
75
+ import { PRINT_ROUTES, connectionForRoute, routeOf, routeOptions, writeEnvValues, type PrintRoute } from "./counterpart/route.js";
76
+ import { resolveStudioPrintOptions, usedNozzleCount, type CallerPrintOptions } from "./studio-print-options.js";
77
+ import { inspectPrintFile } from "./safety/print-file.js";
78
+ import { validatePrinterState } from "./safety/printer-state.js";
79
+ import { uniquePrintName, withPrinterOperation, withPrintSnapshot } from "./safety/artifact.js";
80
+ import { checkedFile } from "./printers/bambu.js";
58
81
  import { CONFIRMATION_UNAVAILABLE, confirmationTimedOut, confirmationTimeoutMs } from "./safety/confirmation.js";
59
82
  import { validateExpectedPeaks, type ExpectedPeakTemperatures, type PrintSafetyOptions } from "./safety/expected-peaks.js";
83
+ import { DESIGN_PROMPTS, FDM_CAPABILITY_GUIDANCE, FDM_CAPABILITY_RESOURCE, FDM_CAPABILITY_URI, SERVER_INSTRUCTIONS } from "./design-guidance.js";
60
84
 
61
85
  // Load environment variables from .env file
62
86
  dotenv.config();
@@ -69,7 +93,10 @@ const DEFAULT_HOST = process.env.PRINTER_HOST || "localhost";
69
93
  const DEFAULT_PORT = process.env.PRINTER_PORT || "80";
70
94
  const DEFAULT_API_KEY = process.env.API_KEY || "";
71
95
  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-"));
96
+ // Resolve an explicit TEMP_DIR (e.g. ./temp) against the server's working directory, as
97
+ // dotenv's own .env lookup is: slicer CLIs receive paths under it, and Bambu Studio fails
98
+ // every 3MF export to a relative --outputdir (bambu-printer-mcp 1.1.44 fixed the same).
99
+ const TEMP_DIR = process.env.TEMP_DIR ? path.resolve(process.env.TEMP_DIR) : fs.mkdtempSync(path.join(os.tmpdir(), "mcp-3d-printer-server-"));
73
100
 
74
101
  // Slicer configuration
75
102
  const CANONICAL_SLICER_TYPES = [
@@ -161,7 +188,9 @@ function defaultBambuStudioPath(): string {
161
188
  }
162
189
 
163
190
  const DEFAULT_SLICER_TYPE = normalizeSlicerType(
164
- process.env.SLICER_TYPE || (DEFAULT_TYPE.toLowerCase() === "bambu" ? "orcaslicer-bambulab" : "prusaslicer")
191
+ // Bambu Studio for Bambu printers, as in bambu-printer-mcp: FULU OrcaSlicer-bambulab v1.0.0's CLI
192
+ // segfaults on every slice (FULU #56/#57 open), so it is chosen explicitly, not by default.
193
+ process.env.SLICER_TYPE || (DEFAULT_TYPE.toLowerCase() === "bambu" ? "bambustudio" : "prusaslicer")
165
194
  );
166
195
  const DEFAULT_SLICER_PATH =
167
196
  process.env.SLICER_PATH ||
@@ -182,6 +211,53 @@ const DEFAULT_BAMBU_TOKEN = process.env.BAMBU_TOKEN || "";
182
211
  const DEFAULT_BAMBU_MODEL = process.env.BAMBU_MODEL?.trim().toLowerCase() || "";
183
212
  const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
184
213
  const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
214
+ // counterpart_print_route changes these at run time (and in the .env file they came from).
215
+ let DEFAULT_BAMBU_CONNECTION_MODE = process.env.BAMBU_DEFAULT_CONNECTION_MODE?.trim().toLowerCase() || "";
216
+ let DEFAULT_BAMBU_CONNECTION_TYPE = process.env.BAMBU_DEFAULT_CONNECTION_TYPE?.trim().toLowerCase() || "";
217
+ const CONNECTION_MODES = ["lan_mqtt_ftps", "bambu_network", "bambu_native", "bambu_connect"];
218
+
219
+ /**
220
+ * The one route decision for a Bambu printer, as in bambu-printer-mcp:
221
+ * print_3mf and every print control (cancel, pause, resume) resolve it here,
222
+ * so a job is never controlled over a different connection than the one that
223
+ * started it. Arguments override BAMBU_DEFAULT_CONNECTION_MODE/_TYPE; without
224
+ * either, direct LAN MQTT/FTPS. The type (lan or cloud) applies to FULU's
225
+ * BambuNetwork route and defaults to cloud, as bambu-printer-mcp's does.
226
+ */
227
+ function resolveConnection(args: Record<string, unknown> | undefined): { mode: string; type: string } {
228
+ const mode = String(args?.connection_mode || DEFAULT_BAMBU_CONNECTION_MODE || "lan_mqtt_ftps").trim().toLowerCase();
229
+ if (!CONNECTION_MODES.includes(mode)) throw new Error("Unsupported connection_mode.");
230
+ const type = String(args?.connection_type || DEFAULT_BAMBU_CONNECTION_TYPE || "cloud").trim().toLowerCase();
231
+ if (!["cloud", "lan"].includes(type)) throw new Error('connection_type must be "cloud" or "lan".');
232
+ return { mode, type };
233
+ }
234
+
235
+ /** bambu-printer-mcp's installed-plug-in (X2D native) and Bambu Connect routes are not part of this server. */
236
+ function assertConnectionAvailable(mode: string): void {
237
+ if (mode === "bambu_native" || mode === "bambu_connect") {
238
+ throw new Error(
239
+ `connection_mode ${mode} is not available in mcp-3D-printer-server. Use lan_mqtt_ftps or bambu_network (FULU), ` +
240
+ `or bambu-printer-mcp for ${mode === "bambu_native" ? "the X2D native route" : "Bambu Connect"}. Nothing was sent.`
241
+ );
242
+ }
243
+ }
244
+
245
+ const CONNECTION_PROPERTIES = {
246
+ 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." },
247
+ 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." },
248
+ };
249
+
250
+ /** The Send dialog options a caller set explicitly; the rest resolve as Bambu Studio does (studio-print-options.ts). */
251
+ function callerPrintOptions(args: Record<string, any> | undefined): CallerPrintOptions {
252
+ const flag = (value: unknown) => value === undefined ? undefined : Boolean(value);
253
+ const options: CallerPrintOptions = {
254
+ bedLeveling: flag(args?.bed_leveling),
255
+ flowCalibration: flag(args?.flow_calibration),
256
+ vibrationCalibration: flag(args?.vibration_calibration),
257
+ layerInspect: flag(args?.layer_inspect),
258
+ };
259
+ return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) as CallerPrintOptions;
260
+ }
185
261
 
186
262
  const VALID_BAMBU_MODELS = ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"] as const;
187
263
  type BambuModel = typeof VALID_BAMBU_MODELS[number];
@@ -225,6 +301,7 @@ function resolveNozzleType(value: unknown): string | undefined {
225
301
 
226
302
  // Shared slice_stl / slice_with_template argument schema.
227
303
  const SLICE_TOOL_PROPERTIES = {
304
+ 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
305
  bambu_model: {
229
306
  type: "string",
230
307
  enum: [...SLICE_BAMBU_MODELS],
@@ -292,7 +369,7 @@ const SLICE_TOOL_PROPERTIES = {
292
369
  description: "Template directory override when resolving template_name (default: BAMBU_TEMPLATE_DIR or ~/Sync/bambu/templates).",
293
370
  },
294
371
  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." },
372
+ 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
373
  orient: { type: "boolean", description: "Bambu-compatible slicing: auto-orient for printability (--orient)." },
297
374
  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
375
  ensure_on_bed: { type: "boolean", description: "Bambu-compatible slicing: lower floating models onto the bed (--ensure-on-bed)." },
@@ -347,6 +424,7 @@ const PRINTER_TARGET_PROPERTIES = {
347
424
  /** Tools that declare an outputSchema: their errors carry no structuredContent, which clients would validate. */
348
425
  const OUTPUT_SCHEMA_TOOLS = new Set([
349
426
  "counterpart_printer_info", "counterpart_print_progress", "camera_snapshot", "get_printer_filaments", "pause_print", "resume_print",
427
+ "camera_stream_start", "camera_frame", "camera_stream_stop",
350
428
  "fusion_mcp_status", "fusion_mcp_call", "fusion_mcp_new_design", "fusion_mcp_export",
351
429
  ]);
352
430
 
@@ -444,6 +522,7 @@ function buildBambuSliceOptions(args: Record<string, unknown> | undefined): Bamb
444
522
  options.rotateX = optionalFiniteNumber(args, "rotate_x");
445
523
  options.rotateY = optionalFiniteNumber(args, "rotate_y");
446
524
  options.slicePlate = optionalFiniteNumber(args, "slice_plate");
525
+ options.pauseAtLayers = parsePauseLayers(args?.pause_at_layers);
447
526
  if (options.repetitions !== undefined && (!Number.isInteger(options.repetitions) || options.repetitions < 1)) {
448
527
  throw new Error("Invalid repetitions: expected a positive integer.");
449
528
  }
@@ -500,6 +579,12 @@ function validateBambuModel(model: string): BambuModel {
500
579
  return normalized as BambuModel;
501
580
  }
502
581
 
582
+ /** An inventory model hint: the argument, else BAMBU_MODEL; validated when present, never required. */
583
+ function optionalBambuModelHint(value: unknown): string | undefined {
584
+ const requested = String(value ?? DEFAULT_BAMBU_MODEL ?? "").trim().toLowerCase();
585
+ return requested ? validateBambuModel(requested) : undefined;
586
+ }
587
+
503
588
  function resolveBedType(bedType: string | undefined): string {
504
589
  const resolved = (bedType || DEFAULT_BED_TYPE).trim().toLowerCase();
505
590
  if (!(VALID_BED_TYPES as readonly string[]).includes(resolved)) {
@@ -657,6 +742,8 @@ class ThreeDPrinterMCPServer {
657
742
  private printerFactory: PrinterFactory;
658
743
  private readonly counterpart: CounterpartService;
659
744
  private stlManipulator: STLManipulator;
745
+ /** FULU's BambuNetwork bridge host, started on first use by the bambu_network route. */
746
+ private bambuNetwork = new BambuNetworkBridge();
660
747
  private readonly runtimeConfig: RuntimeConfig;
661
748
  private httpRuntime?: { transport: StreamableHTTPServerTransport; httpServer: HttpServer };
662
749
 
@@ -669,9 +756,11 @@ class ThreeDPrinterMCPServer {
669
756
  },
670
757
  {
671
758
  capabilities: {
759
+ prompts: {},
672
760
  resources: {},
673
761
  tools: {}
674
- }
762
+ },
763
+ instructions: SERVER_INSTRUCTIONS
675
764
  }
676
765
  );
677
766
 
@@ -680,7 +769,8 @@ class ThreeDPrinterMCPServer {
680
769
  (printerType) => this.printerFactory.getImplementation(printerType),
681
770
  axios.create({ timeout: 10000 }),
682
771
  { name: SERVER_NAME, version: SERVER_VERSION },
683
- (material) => resolveFilamentProfile(material, defaultFilamentProfileContext())
772
+ (material) => resolveFilamentProfile(material, defaultFilamentProfileContext()),
773
+ () => ({ slicerType: DEFAULT_SLICER_TYPE, slicerPath: DEFAULT_SLICER_PATH || undefined })
684
774
  );
685
775
  this.stlManipulator = new STLManipulator(TEMP_DIR);
686
776
 
@@ -688,7 +778,7 @@ class ThreeDPrinterMCPServer {
688
778
  this.setupErrorHandling();
689
779
  }
690
780
 
691
- /** Human MCP elicitation before a print start or positive heating command. */
781
+ /** Human MCP elicitation for a physical check software cannot make (resuming a job that cannot be re-read). */
692
782
  private async confirmHardware(message: string): Promise<boolean> {
693
783
  let response;
694
784
  const timeout = confirmationTimeoutMs();
@@ -705,7 +795,7 @@ class ThreeDPrinterMCPServer {
705
795
  },
706
796
  }, { timeout });
707
797
  } catch (error) {
708
- // A person may take minutes to check the bed; a timeout is not a missing capability.
798
+ // A person may take minutes to check the printer; a timeout is not a missing capability.
709
799
  if ((error as { code?: unknown })?.code === ErrorCode.RequestTimeout) throw new Error(confirmationTimedOut(timeout));
710
800
  throw new Error(CONFIRMATION_UNAVAILABLE);
711
801
  }
@@ -797,6 +887,9 @@ class ThreeDPrinterMCPServer {
797
887
  // Generic OrcaSlicer keeps its user-supplied machine profile unless the
798
888
  // call explicitly targets a Bambu model.
799
889
  const bambuCli = isBambuProjectSlicer(slicerType) || (slicerType === "orcaslicer" && explicitModel !== undefined);
890
+ if (!bambuCli && parsePauseLayers(args.pause_at_layers)) {
891
+ throw new Error(`Pausing at a layer needs the Bambu Studio or OrcaSlicer CLI, not ${slicerType}.`);
892
+ }
800
893
 
801
894
  let printerPreset: string | undefined;
802
895
  let options: BambuSliceOptions | undefined;
@@ -870,59 +963,254 @@ class ThreeDPrinterMCPServer {
870
963
 
871
964
  setupHandlers() {
872
965
  this.setupResourceHandlers();
966
+ this.setupPromptHandlers();
873
967
  this.setupToolHandlers();
874
968
  }
875
969
 
876
- private extractParsedAmsMapping(parsed3MFData: ThreeMFData | undefined): number[] | undefined {
877
- if (!parsed3MFData?.slicerConfig?.ams_mapping) {
878
- return undefined;
879
- }
880
-
881
- const slots = Object.values(parsed3MFData.slicerConfig.ams_mapping).filter(
882
- (value): value is number => typeof value === "number"
883
- );
884
-
885
- return slots.length > 0 ? slots.sort((a, b) => a - b) : undefined;
886
- }
887
-
970
+ /**
971
+ * The AMS choice for a Bambu print, as bambu-printer-mcp's print_3mf resolves
972
+ * it: the 3MF's own mapping (ordered by project filament index), overridden by
973
+ * an explicit ams_mapping, which ams_slots (one tray per used filament)
974
+ * replaces. use_ams:false prints from the external spool. Tray values are
975
+ * checked here; the adapter expands ams_slots against the inspected plate.
976
+ */
888
977
  private resolveAmsPrintOptions(
889
978
  parsed3MFData: ThreeMFData | undefined,
890
- args: Record<string, unknown> | undefined
891
- ): { useAMS: boolean; amsMapping?: number[] } {
892
- let finalAmsMapping = this.extractParsedAmsMapping(parsed3MFData);
893
- let useAMS =
894
- args?.use_ams !== undefined
895
- ? Boolean(args.use_ams)
896
- : !!finalAmsMapping && finalAmsMapping.length > 0;
897
-
898
- const override = args?.ams_mapping;
899
- if (override !== undefined) {
900
- let userMappingOverride: number[] | undefined;
901
-
902
- if (Array.isArray(override)) {
903
- userMappingOverride = override.filter((value): value is number => typeof value === "number");
904
- } else if (typeof override === "object" && override !== null) {
905
- userMappingOverride = Object.values(override as Record<string, unknown>)
906
- .filter((value): value is number => typeof value === "number")
907
- .sort((a, b) => a - b);
979
+ args: Record<string, unknown> | undefined,
980
+ model?: string
981
+ ): { useAMS: boolean; amsMapping?: number[]; amsSlots?: number[] } {
982
+ const trayOptions = { dualNozzle: isDualNozzleModel(model) };
983
+ let finalAmsMapping: number[] | undefined;
984
+ let finalAmsSlots: number[] | undefined;
985
+ if (parsed3MFData?.slicerConfig?.ams_mapping) {
986
+ const slots = normalizeAmsMappingObject(parsed3MFData.slicerConfig.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
987
+ if (slots.length > 0) finalAmsMapping = slots;
988
+ }
989
+ let useAMS = args?.use_ams !== undefined ? Boolean(args.use_ams) : !!finalAmsMapping && finalAmsMapping.length > 0;
990
+ const hasUserAmsMapping = hasAmsMappingInput(args?.ams_mapping);
991
+ const hasUserAmsSlots = Array.isArray(args?.ams_slots);
992
+
993
+ if (hasUserAmsMapping) {
994
+ let userMapping: number[] | undefined;
995
+ if (Array.isArray(args!.ams_mapping)) {
996
+ userMapping = (args!.ams_mapping as unknown[]).map((value, index) =>
997
+ normalizeBridgeAmsTrayValue(value, `ams_mapping[${index}]`, trayOptions));
998
+ } else if (args!.ams_mapping && typeof args!.ams_mapping === "object") {
999
+ userMapping = normalizeAmsMappingObject(args!.ams_mapping as Record<string, unknown>, "ams_mapping", trayOptions);
1000
+ } else {
1001
+ throw new Error("ams_mapping must be an array of trays or an object of trays keyed by project filament index.");
908
1002
  }
909
-
910
- if (userMappingOverride && userMappingOverride.length > 0) {
911
- finalAmsMapping = userMappingOverride;
1003
+ if (userMapping.length > 0) {
1004
+ finalAmsMapping = userMapping;
1005
+ useAMS = true;
1006
+ }
1007
+ }
1008
+ if (hasUserAmsSlots) {
1009
+ const userSlots = (args!.ams_slots as unknown[]).map((value, index) =>
1010
+ normalizeBridgeAmsTrayValue(value, `ams_slots[${index}]`, trayOptions));
1011
+ if (userSlots.length > 0) {
1012
+ finalAmsSlots = userSlots;
1013
+ finalAmsMapping = undefined;
912
1014
  useAMS = true;
913
1015
  }
914
1016
  }
915
-
916
1017
  if (args?.use_ams === false) {
917
- finalAmsMapping = undefined;
1018
+ if (!hasUserAmsMapping) finalAmsMapping = undefined;
1019
+ if (!hasUserAmsSlots) finalAmsSlots = undefined;
918
1020
  useAMS = false;
919
1021
  }
920
-
921
- if (!finalAmsMapping || finalAmsMapping.length === 0) {
1022
+ if ((!finalAmsMapping || finalAmsMapping.length === 0) && (!finalAmsSlots || finalAmsSlots.length === 0)) {
922
1023
  useAMS = false;
923
1024
  }
1025
+ return { useAMS, amsMapping: finalAmsMapping, amsSlots: finalAmsSlots };
1026
+ }
924
1027
 
925
- return { useAMS, amsMapping: finalAmsMapping };
1028
+ /**
1029
+ * print_3mf over FULU's BambuNetwork bridge (connection_mode bambu_network),
1030
+ * ported from bambu-printer-mcp: the route a P1S on authorization firmware
1031
+ * without Developer Mode prints through. connection_type lan opens the
1032
+ * plug-in's authenticated local session (start_local_print); cloud sends
1033
+ * through Bambu Cloud (start_print). The shared gates run first: the exact
1034
+ * snapshot bytes are inspected, the AMS mapping checked, and fresh LAN MQTT
1035
+ * state validated before the bridge starts, again immediately before the
1036
+ * print frame, and the job is recorded for a checked resume.
1037
+ */
1038
+ private async print3mfViaBambuNetwork(options: {
1039
+ args: Record<string, any>; host: string; serial: string; token: string; threeMFPath: string;
1040
+ model: string; nozzleDiameters?: number[]; bedType: string; connectionType: string;
1041
+ ams: { useAMS: boolean; amsMapping?: number[]; amsSlots?: number[] };
1042
+ /** The zero-based plate print_3mf validated: inspected and dispatched as the same plate. */
1043
+ plateIndex: number;
1044
+ }): Promise<Record<string, unknown>> {
1045
+ const { args, model, bedType, connectionType } = options;
1046
+ const devId = options.serial.trim();
1047
+ const devIp = options.host.trim();
1048
+ const password = options.token.trim();
1049
+ if (!devId || !devIp || devIp === "localhost" || !password) {
1050
+ 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.");
1051
+ }
1052
+ const isLocalBridgePrint = connectionType === "lan";
1053
+ const bridgePrintMethod = isLocalBridgePrint ? "start_local_print" : "start_print";
1054
+ const bridgeMethod = `net.${bridgePrintMethod}`;
1055
+ const { plateIndex } = options;
1056
+ const threeMfFilename = path.basename(options.threeMFPath);
1057
+ const projectName = threeMfFilename.replace(/\.3mf$/i, "");
1058
+ const presetName = `${projectName}_plate_${plateIndex + 1}`;
1059
+ const clientJobId = Date.now();
1060
+ const bambu = this.printerFactory.getImplementation("bambu") as unknown as BambuImplementation;
1061
+ const dualNozzle = isDualNozzleModel(model);
1062
+
1063
+ return withPrinterOperation(devIp, devId, (assertActive) => withPrintSnapshot(options.threeMFPath, async (snapshot, retainForDispatchedUpload) => {
1064
+ const inspection = await inspectPrintFile(snapshot, { model, nozzleDiameters: options.nozzleDiameters, plateIndex, bedType });
1065
+ const resumeFile = await checkedFile(inspection, snapshot);
1066
+ // The options Bambu Studio's Send dialog would send; explicit ones win.
1067
+ const studio = resolveStudioPrintOptions(model, callerPrintOptions(args), { usedNozzles: usedNozzleCount(inspection) });
1068
+ const { useAMS, amsSlots } = options.ams;
1069
+ let mapping = options.ams.amsMapping?.slice();
1070
+ if (amsSlots) {
1071
+ if (amsSlots.length !== inspection.usedFilamentPositions.length) throw new Error("ams_slots must supply exactly one physical tray per used filament.");
1072
+ mapping = Array<number>(inspection.materials.length).fill(-1);
1073
+ inspection.usedFilamentPositions.forEach((position, index) => { mapping![position] = amsSlots[index]; });
1074
+ }
1075
+ if (!useAMS) {
1076
+ mapping ??= Array<number>(inspection.materials.length).fill(-1);
1077
+ for (const position of inspection.usedFilamentPositions) {
1078
+ if (mapping[position] === undefined || mapping[position] < 0) {
1079
+ mapping[position] = externalTrayForNozzle(inspection.filamentNozzleIndices?.[position], dualNozzle);
1080
+ }
1081
+ if (mapping[position] !== 254 && !(dualNozzle && mapping[position] === 255)) {
1082
+ throw new Error("use_ams:false requires an external-spool mapping for every used filament.");
1083
+ }
1084
+ }
1085
+ }
1086
+ for (const position of inspection.usedFilamentPositions) {
1087
+ if (mapping?.[position] === undefined || mapping[position] < 0) {
1088
+ 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.`);
1089
+ }
1090
+ }
1091
+ const requirements = { ...inspection, amsMapping: mapping, useAMS };
1092
+ validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
1093
+ const confirmedStatus = await bambu.getSafetyStatus(devIp, devId, password);
1094
+ validatePrinterState(confirmedStatus, requirements);
1095
+ const wireMapping = mapping!.map((value) => bridgeAmsMappingValue(value, dualNozzle));
1096
+ const remoteFileName = uniquePrintName(options.threeMFPath);
1097
+ const checkedName = remoteFileName.replace(/(?:\.gcode)?\.3mf$/i, "");
1098
+ const params: Record<string, unknown> = {
1099
+ dev_id: devId,
1100
+ // The printer reports the task or project name back; both carry the unique
1101
+ // checked name, which binds the job to this inspected artifact for a later resume.
1102
+ task_name: checkedName,
1103
+ project_name: checkedName,
1104
+ preset_name: presetName,
1105
+ filename: snapshot,
1106
+ config_filename: snapshot,
1107
+ plate_index: plateIndex + 1,
1108
+ ftp_folder: "",
1109
+ ftp_file: "",
1110
+ ftp_file_md5: "",
1111
+ nozzle_mapping: "",
1112
+ ams_mapping: JSON.stringify(wireMapping),
1113
+ ams_mapping2: JSON.stringify(mapping!.map((value) => bridgeAmsMapping2Entry(value, dualNozzle))),
1114
+ // Bambu Studio and FULU's slicer send this with every job (get_ams_mapping_result).
1115
+ ams_mapping_info: inspection.projectFilaments?.length
1116
+ ? JSON.stringify(studioAmsMappingInfo(inspection.projectFilaments, mapping!, (tray) => trayColour(confirmedStatus.raw, tray)))
1117
+ : "",
1118
+ nozzles_info: "",
1119
+ connection_type: connectionType,
1120
+ comments: "",
1121
+ origin_profile_id: 0,
1122
+ stl_design_id: 0,
1123
+ origin_model_id: "",
1124
+ print_type: "from_normal",
1125
+ dst_file: remoteFileName,
1126
+ dev_name: "",
1127
+ dev_ip: devIp,
1128
+ use_ssl_for_ftp: true,
1129
+ use_ssl_for_mqtt: true,
1130
+ username: "bblp",
1131
+ password,
1132
+ task_bed_leveling: studio.task_bed_leveling,
1133
+ task_flow_cali: studio.task_flow_cali,
1134
+ task_vibration_cali: studio.task_vibration_cali,
1135
+ task_layer_inspect: studio.task_layer_inspect,
1136
+ task_record_timelapse: args?.timelapse !== undefined ? Boolean(args.timelapse) : false,
1137
+ task_use_ams: useAMS,
1138
+ task_bed_type: bedType,
1139
+ extra_options: "",
1140
+ auto_bed_leveling: studio.auto_bed_leveling,
1141
+ auto_flow_cali: studio.auto_flow_cali,
1142
+ auto_offset_cali: studio.auto_offset_cali,
1143
+ extruder_cali_manual_mode: studio.extruder_cali_manual_mode,
1144
+ task_ext_change_assist: false,
1145
+ try_emmc_print: false,
1146
+ };
1147
+ assertActive();
1148
+ const bridgeOptions = {
1149
+ // Trusted in-process preflight immediately before the print frame.
1150
+ beforeDispatch: async (method: string) => {
1151
+ if (method !== bridgeMethod) return;
1152
+ assertActive();
1153
+ validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
1154
+ },
1155
+ assertDispatchAllowed: (method: string) => { if (method === bridgeMethod) assertActive(); },
1156
+ onDispatched: (method: string) => { if (method === bridgeMethod) retainForDispatchedUpload(); },
1157
+ };
1158
+ let jobUpdates: BridgeJobUpdate[] = [];
1159
+ let bridgeResult: unknown;
1160
+ if (isLocalBridgePrint) {
1161
+ ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startLocalPrint(
1162
+ { devId, devIp, username: "bblp", password, useSsl: true },
1163
+ bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive
1164
+ ));
1165
+ } else {
1166
+ ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startCloudPrint(devId, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive));
1167
+ }
1168
+ if (typeof bridgeResult === "object" && bridgeResult !== null && (bridgeResult as { ok?: unknown }).ok === false) {
1169
+ throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} failed: ${String((bridgeResult as { error?: unknown }).error || "unknown bridge error")}`);
1170
+ }
1171
+ const value = typeof bridgeResult === "object" && bridgeResult !== null ? (bridgeResult as { value?: unknown }).value : undefined;
1172
+ if (typeof value === "number" && value !== 0) {
1173
+ const reported = jobUpdates.filter((update) => update.code !== 0 || update.msg).slice(-3)
1174
+ .map((update) => `status ${update.status}, code ${update.code}${update.msg ? `: ${update.msg}` : ""}`).join("; ");
1175
+ throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} returned non-zero result ${value}${describeBambuNetworkPrintError(value)}.` +
1176
+ (reported ? ` Plug-in reported: ${reported}.` : ""));
1177
+ }
1178
+ assertActive();
1179
+ // Bambu's plug-in placed the file, so a resume re-reads it if it can and otherwise asks a person.
1180
+ bambu.recordCheckedJob(devIp, devId, remoteFileName, requirements, resumeFile, false);
1181
+ return {
1182
+ status: "success",
1183
+ message: `FULU BambuNetwork ${bridgePrintMethod} command for ${threeMfFilename} sent successfully.`,
1184
+ connectionMode: "bambu_network",
1185
+ connectionType,
1186
+ bridgeMethod,
1187
+ bridgeResult,
1188
+ clientJobId,
1189
+ projectName,
1190
+ plateIndex,
1191
+ bridgePlateIndex: plateIndex + 1,
1192
+ printerJobName: checkedName,
1193
+ useAMS,
1194
+ amsMapping: mapping,
1195
+ sha256: inspection.sha256,
1196
+ params: { ...params, password: "[redacted]" },
1197
+ };
1198
+ }));
1199
+ }
1200
+
1201
+ /** The FDM design guidance as MCP prompts: the client decides when to attach each one. */
1202
+ setupPromptHandlers() {
1203
+ this.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
1204
+ prompts: DESIGN_PROMPTS.map(({ name, title, description }) => ({ name, title, description })),
1205
+ }));
1206
+ this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
1207
+ const prompt = DESIGN_PROMPTS.find(item => item.name === request.params.name);
1208
+ if (!prompt) throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${request.params.name}`);
1209
+ return {
1210
+ description: prompt.description,
1211
+ messages: [{ role: "user" as const, content: { type: "text" as const, text: prompt.text } }],
1212
+ };
1213
+ });
926
1214
  }
927
1215
 
928
1216
  setupResourceHandlers() {
@@ -941,7 +1229,8 @@ class ThreeDPrinterMCPServer {
941
1229
  name: "3D Printer Files",
942
1230
  mimeType: "application/json",
943
1231
  description: "List of files available on the 3D printer"
944
- }
1232
+ },
1233
+ FDM_CAPABILITY_RESOURCE
945
1234
  ],
946
1235
  templates: [
947
1236
  {
@@ -966,6 +1255,9 @@ class ThreeDPrinterMCPServer {
966
1255
  // Read resource
967
1256
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
968
1257
  const uri = request.params.uri;
1258
+ if (uri === FDM_CAPABILITY_URI) {
1259
+ return { contents: [{ uri, mimeType: FDM_CAPABILITY_RESOURCE.mimeType, text: FDM_CAPABILITY_GUIDANCE }] };
1260
+ }
969
1261
  const match = uri.match(/^printer:\/\/([^\/]+)\/(.+)$/);
970
1262
 
971
1263
  if (!match) {
@@ -1012,7 +1304,7 @@ class ThreeDPrinterMCPServer {
1012
1304
  // List available tools
1013
1305
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
1014
1306
  return {
1015
- tools: [
1307
+ tools: annotateTools([
1016
1308
  {
1017
1309
  name: "get_printer_status",
1018
1310
  description: "Get the current status of the 3D printer",
@@ -1081,7 +1373,7 @@ class ThreeDPrinterMCPServer {
1081
1373
  },
1082
1374
  {
1083
1375
  name: "upload_gcode",
1084
- 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.",
1376
+ 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.",
1085
1377
  inputSchema: {
1086
1378
  type: "object",
1087
1379
  properties: {
@@ -1139,7 +1431,7 @@ class ThreeDPrinterMCPServer {
1139
1431
  },
1140
1432
  {
1141
1433
  name: "start_print",
1142
- 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.",
1434
+ 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.",
1143
1435
  inputSchema: {
1144
1436
  type: "object",
1145
1437
  properties: {
@@ -1213,23 +1505,34 @@ class ThreeDPrinterMCPServer {
1213
1505
  bambu_token: {
1214
1506
  type: "string",
1215
1507
  description: "Access token for Bambu Lab printers (default: value from env)"
1216
- }
1508
+ },
1509
+ ...CONNECTION_PROPERTIES,
1217
1510
  }
1218
1511
  }
1219
1512
  },
1513
+ ...CAMERA_STREAM_TOOLS,
1514
+ {
1515
+ name: "counterpart_print_route",
1516
+ 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.",
1517
+ inputSchema: {
1518
+ type: "object",
1519
+ properties: {
1520
+ route: { type: "string", enum: ["lan", "cloud", "developer"], description: "Change the route. Omit to read it." }
1521
+ },
1522
+ additionalProperties: false
1523
+ }
1524
+ },
1220
1525
  {
1221
1526
  name: "counterpart_printer_info",
1222
1527
  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.",
1223
1528
  inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1224
1529
  outputSchema: PRINTER_INFO_OUTPUT_SCHEMA,
1225
- annotations: { readOnlyHint: true, openWorldHint: true },
1226
1530
  },
1227
1531
  {
1228
1532
  name: "counterpart_print_progress",
1229
1533
  description: "Read the printer's state and current job in the counterpart/1 shape: state, job name, progress (0-100), elapsed and remaining seconds, layers when the firmware reports them, temperatures and problems. job.name matches the jobName that upload_gcode and start_print return. Read-only.",
1230
1534
  inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1231
1535
  outputSchema: PRINT_PROGRESS_OUTPUT_SCHEMA,
1232
- annotations: { readOnlyHint: true, openWorldHint: true },
1233
1536
  },
1234
1537
  {
1235
1538
  name: "camera_snapshot",
@@ -1243,32 +1546,35 @@ class ThreeDPrinterMCPServer {
1243
1546
  },
1244
1547
  },
1245
1548
  outputSchema: CAMERA_SNAPSHOT_OUTPUT_SCHEMA,
1246
- annotations: { readOnlyHint: true, openWorldHint: true },
1247
1549
  },
1248
1550
  {
1249
1551
  name: "get_printer_filaments",
1250
- 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.",
1251
- inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1552
+ 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.",
1553
+ inputSchema: {
1554
+ type: "object",
1555
+ properties: {
1556
+ ...PRINTER_TARGET_PROPERTIES,
1557
+ bambu_model: { type: "string", description: "Bambu only: optional model hint used to resolve Bambu/Orca filament profile JSONs for each tray (default: BAMBU_MODEL)." },
1558
+ nozzle_diameter: { type: "string", description: "Bambu only: optional nozzle diameter used when resolving model-specific filament profile JSONs (default: 0.4)." },
1559
+ },
1560
+ },
1252
1561
  outputSchema: FILAMENTS_OUTPUT_SCHEMA,
1253
- annotations: { readOnlyHint: true, openWorldHint: true },
1254
1562
  },
1255
1563
  {
1256
1564
  name: "pause_print",
1257
- description: "Pause the current print (OctoPrint, Klipper/Moonraker, PrusaLink, Duet M25, Repetier, and Creality printers that run Moonraker). Pause is never gated.",
1258
- inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1565
+ 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.",
1566
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES, ...CONNECTION_PROPERTIES } },
1259
1567
  outputSchema: CONTROL_OUTPUT_SCHEMA,
1260
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1261
1568
  },
1262
1569
  {
1263
1570
  name: "resume_print",
1264
- 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.",
1265
- inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1571
+ 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.",
1572
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES, ...CONNECTION_PROPERTIES } },
1266
1573
  outputSchema: CONTROL_OUTPUT_SCHEMA,
1267
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1268
1574
  },
1269
1575
  {
1270
1576
  name: "set_printer_temperature",
1271
- 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.",
1577
+ 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.",
1272
1578
  inputSchema: {
1273
1579
  type: "object",
1274
1580
  properties: {
@@ -1702,16 +2008,34 @@ class ThreeDPrinterMCPServer {
1702
2008
  required: ["stl_path"]
1703
2009
  }
1704
2010
  },
2011
+ {
2012
+ name: "inspect_print_file",
2013
+ 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.",
2014
+ inputSchema: {
2015
+ type: "object",
2016
+ additionalProperties: false,
2017
+ properties: {
2018
+ file_path: { type: "string", minLength: 1, description: "Local prepared .3mf or textual .gcode artifact to inspect; checksum-only and unsliced projects reject." },
2019
+ bambu_model: { type: "string", enum: [...VALID_BAMBU_MODELS], description: "Explicit file-inspection target model; never inferred from environment or live state." },
2020
+ plate_index: { type: "integer", minimum: 0, description: "Zero-based selected plate; defaults to 0. Raw G-code only supports 0." },
2021
+ 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." },
2022
+ bed_type: { type: "string", enum: [...VALID_BED_TYPES], description: "Optional declared bed type to compare with file metadata." },
2023
+ },
2024
+ required: ["file_path", "bambu_model"],
2025
+ },
2026
+ },
1705
2027
  {
1706
2028
  name: "print_3mf",
1707
- 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.",
2029
+ 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.",
1708
2030
  inputSchema: {
1709
2031
  type: "object",
1710
2032
  properties: {
2033
+ 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." },
1711
2034
  three_mf_path: {
1712
2035
  type: "string",
1713
2036
  description: "Path to the 3MF file to print."
1714
2037
  },
2038
+ 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." },
1715
2039
  bambu_model: {
1716
2040
  type: "string",
1717
2041
  enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
@@ -1747,10 +2071,18 @@ class ThreeDPrinterMCPServer {
1747
2071
  nozzle_temperature: { type: "number", description: "Override nozzle temperature (°C)." },
1748
2072
  bed_temperature: { type: "number", description: "Override bed temperature (°C)." },
1749
2073
  support_enabled: { type: "boolean", description: "Override support generation." },
2074
+ ...CONNECTION_PROPERTIES,
1750
2075
  ams_mapping: {
1751
- type: "object",
1752
- description: "Override AMS filament mapping (e.g., {\"Generic PLA\": 0, \"Generic PETG\": 1}).",
1753
- additionalProperties: { type: "number" }
2076
+ anyOf: [
2077
+ { type: "array", items: { type: "number" } },
2078
+ { type: "object", additionalProperties: { type: "number" } },
2079
+ ],
2080
+ 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.",
2081
+ },
2082
+ ams_slots: {
2083
+ type: "array",
2084
+ items: { type: "number" },
2085
+ 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.",
1754
2086
  },
1755
2087
  slicer_type: {
1756
2088
  type: "string",
@@ -1771,7 +2103,7 @@ class ThreeDPrinterMCPServer {
1771
2103
  },
1772
2104
  use_ams: {
1773
2105
  type: "boolean",
1774
- description: "Whether to use AMS for the print. Defaults from parsed 3MF mapping when present."
2106
+ 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."
1775
2107
  },
1776
2108
  bed_leveling: {
1777
2109
  type: "boolean",
@@ -2058,7 +2390,7 @@ class ThreeDPrinterMCPServer {
2058
2390
  },
2059
2391
  outputSchema: FUSION_EXPORT_OUTPUT_SCHEMA,
2060
2392
  }
2061
- ]
2393
+ ])
2062
2394
  };
2063
2395
  });
2064
2396
 
@@ -2084,12 +2416,39 @@ class ThreeDPrinterMCPServer {
2084
2416
  type, host, port,
2085
2417
  apiKey: type.toLowerCase() === "bambu" ? `${bambuSerial}:${bambuToken}` : apiKey,
2086
2418
  };
2419
+ // Bambu print controls go over the same connection as the print (resolveConnection);
2420
+ // undefined keeps direct LAN MQTT in BambuImplementation.
2421
+ const bambuControlRoute: BambuControlRoute = (command) => {
2422
+ if (type.toLowerCase() !== "bambu") return undefined;
2423
+ const route = resolveConnection(args);
2424
+ assertConnectionAvailable(route.mode);
2425
+ if (route.mode !== "bambu_network") return undefined;
2426
+ return (assertActive?: () => void, beforeDispatch?: () => Promise<void>) => {
2427
+ const options = { assertDispatchAllowed: () => { extra.signal.throwIfAborted(); assertActive?.(); } };
2428
+ return route.type === "cloud"
2429
+ ? this.bambuNetwork.sendCloudPrintCommand(bambuSerial, command, options, beforeDispatch)
2430
+ : this.bambuNetwork.sendLocalPrintCommand({ devId: bambuSerial, devIp: host, username: "bblp", password: bambuToken, useSsl: true }, command, options, beforeDispatch);
2431
+ };
2432
+ };
2087
2433
  const structured = (value: object) => ({
2088
2434
  content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
2089
2435
  structuredContent: value as Record<string, unknown>,
2090
2436
  });
2091
2437
 
2092
2438
  switch (name) {
2439
+ case "camera_stream_start":
2440
+ return structured(await this.counterpart.cameraStreamStart(target, {fps: args?.fps as number | undefined, idleTimeoutMs: args?.idle_timeout_ms as number | undefined}));
2441
+ case "camera_frame": {
2442
+ const frame = await this.counterpart.cameraStreamFrame(target, {
2443
+ sinceSeq: args?.since_seq as number | undefined, waitMs: args?.wait_ms as number | undefined,
2444
+ maxWidth: args?.max_width as number | undefined, maxBytes: args?.max_bytes as number | undefined,
2445
+ changedOnly: args?.changed_only as boolean | undefined, minChange: args?.min_change as number | undefined, signal: extra.signal,
2446
+ });
2447
+ // The JPEG appears only once in the MCP payload; text is useful metadata.
2448
+ return {content: [{type: "text" as const, text: JSON.stringify(Object.fromEntries(Object.entries(frame).filter(([key]) => key !== "base64")))}], structuredContent: frame};
2449
+ }
2450
+ case "camera_stream_stop":
2451
+ return structured(this.counterpart.cameraStreamStop(target));
2093
2452
  case "counterpart_printer_info":
2094
2453
  return structured(await this.counterpart.printerInfo(target));
2095
2454
 
@@ -2105,15 +2464,75 @@ class ThreeDPrinterMCPServer {
2105
2464
  }));
2106
2465
  }
2107
2466
 
2108
- case "get_printer_filaments":
2109
- return structured(await this.counterpart.filaments(target));
2467
+ case "get_printer_filaments": {
2468
+ const filamentOptions = type.toLowerCase() === "bambu" ? {
2469
+ bambuModel: optionalBambuModelHint(args?.bambu_model),
2470
+ nozzleDiameter: String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER),
2471
+ } : {};
2472
+ return structured(await this.counterpart.filaments(target, filamentOptions));
2473
+ }
2110
2474
 
2111
2475
  case "pause_print":
2112
- return structured(await this.counterpart.pause(target));
2476
+ return structured(await this.counterpart.pause(target, bambuControlRoute));
2113
2477
 
2114
2478
  case "resume_print":
2115
- return structured(await this.counterpart.resume(target));
2479
+ return structured(await this.counterpart.resume(target, bambuControlRoute));
2480
+
2481
+ case "counterpart_print_route": {
2482
+ let savedTo: string | null = null;
2483
+ // FULU's bridge is set up (configured or discovered), so the routes through it can be used.
2484
+ const bridgeConfigured = Boolean(this.bambuNetwork.resolveBridgeCommand());
2485
+ if (args?.route !== undefined) {
2486
+ if (!PRINT_ROUTES.includes(args.route as PrintRoute)) throw new Error('route must be "lan", "cloud" or "developer".');
2487
+ const next = connectionForRoute(args.route as PrintRoute, { mode: DEFAULT_BAMBU_CONNECTION_MODE, type: DEFAULT_BAMBU_CONNECTION_TYPE }, bridgeConfigured);
2488
+ // dotenv loaded .env from the working directory at start; a client's own env block has no file to update.
2489
+ const file = path.resolve(process.cwd(), ".env");
2490
+ if (fs.existsSync(file)) {
2491
+ writeEnvValues(file, {
2492
+ ...(next.mode ? { BAMBU_DEFAULT_CONNECTION_MODE: next.mode } : {}),
2493
+ ...(next.type ? { BAMBU_DEFAULT_CONNECTION_TYPE: next.type } : {}),
2494
+ });
2495
+ savedTo = file;
2496
+ }
2497
+ DEFAULT_BAMBU_CONNECTION_MODE = next.mode;
2498
+ DEFAULT_BAMBU_CONNECTION_TYPE = next.type;
2499
+ }
2500
+ const connection = resolveConnection({});
2501
+ const options = routeOptions(connection, bridgeConfigured);
2502
+ result = { route: routeOf(connection), mode: connection.mode, type: connection.type,
2503
+ choices: options.filter(option => option.available).map(option => option.id), options, savedTo };
2504
+ break;
2505
+ }
2116
2506
 
2507
+ case "inspect_print_file": {
2508
+ const allowed = new Set(["file_path", "bambu_model", "plate_index", "nozzle_diameters", "bed_type"]);
2509
+ if (!args || Object.keys(args).some(key => !allowed.has(key))) throw new Error("inspect_print_file accepts only file/model/plate/nozzle/bed arguments.");
2510
+ 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.");
2511
+ if (typeof args.bambu_model !== "string" || !args.bambu_model.trim()) throw new Error("An explicit bambu_model is required for file inspection.");
2512
+ const model = validateBambuModel(args.bambu_model);
2513
+ const plateIndex = args.plate_index === undefined ? 0 : args.plate_index;
2514
+ if (typeof plateIndex !== "number" || !Number.isSafeInteger(plateIndex) || plateIndex < 0) throw new Error("plate_index must be a nonnegative safe integer.");
2515
+ 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.");
2516
+ const nozzles = args.nozzle_diameters as number[] | undefined;
2517
+ 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.");
2518
+ 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.");
2519
+ const bedType = args.bed_type === undefined ? undefined : resolveBedType(args.bed_type);
2520
+ if (extra.signal.aborted) throw new Error("File inspection cancelled.");
2521
+ result = await withPrintSnapshot(args.file_path, async snapshot => {
2522
+ const inspection = await inspectPrintFile(snapshot, { model, nozzleDiameters: nozzles, plateIndex, bedType });
2523
+ if (extra.signal.aborted) throw new Error("File inspection cancelled.");
2524
+ const byteLength = (await fs.promises.stat(snapshot)).size;
2525
+ return {
2526
+ receipt_version: "1",
2527
+ evidence: "file-only",
2528
+ dispatch_authorized: false,
2529
+ artifact: { sha256: inspection.sha256, byte_length: byteLength, file_format: path.extname(snapshot).toLowerCase() === ".3mf" ? "3mf" : "gcode" },
2530
+ selection: { bambu_model: model, plate_index: plateIndex, ...(nozzles === undefined ? {} : { nozzle_diameters: nozzles }), ...(bedType === undefined ? {} : { bed_type: bedType }) },
2531
+ inspection,
2532
+ };
2533
+ });
2534
+ break;
2535
+ }
2117
2536
  case "get_printer_status":
2118
2537
  result = await this.getPrinterStatus(host, port, type, apiKey, bambuSerial, bambuToken);
2119
2538
  break;
@@ -2165,7 +2584,7 @@ class ThreeDPrinterMCPServer {
2165
2584
  break;
2166
2585
 
2167
2586
  case "cancel_print":
2168
- result = await this.cancelPrint(host, port, type, apiKey, bambuSerial, bambuToken);
2587
+ result = await this.cancelPrint(host, port, type, apiKey, bambuSerial, bambuToken, bambuControlRoute("stop"));
2169
2588
  break;
2170
2589
 
2171
2590
  case "set_printer_temperature":
@@ -2334,7 +2753,8 @@ class ThreeDPrinterMCPServer {
2334
2753
  }
2335
2754
  const amsOptions = this.resolveAmsPrintOptions(
2336
2755
  parsedProcess3MF,
2337
- args as Record<string, unknown> | undefined
2756
+ args as Record<string, unknown> | undefined,
2757
+ processModel
2338
2758
  );
2339
2759
 
2340
2760
  result = await factoryImplementation.print3mf(host, bambuSerial, bambuToken, {
@@ -2368,7 +2788,7 @@ class ThreeDPrinterMCPServer {
2368
2788
  // 3. Upload and print through the adapter's hard safety gate. It
2369
2789
  // inspects the exact bytes sent (S and R targets, tool changes,
2370
2790
  // hardware and material ceilings), requires the expected peaks to
2371
- // match, reads fresh printer state and asks for human confirmation.
2791
+ // match, and reads fresh printer state.
2372
2792
  // A mismatch refuses before upload; nothing is only logged.
2373
2793
  const filename = path.basename(gcodePath);
2374
2794
  const printResult = await this.uploadGcode(
@@ -2587,17 +3007,21 @@ class ThreeDPrinterMCPServer {
2587
3007
  throw new Error("Bambu serial number and access token are required for print_3mf.");
2588
3008
  }
2589
3009
 
3010
+ const printPlate = args.plate_index === undefined ? 0 : args.plate_index;
3011
+ 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.");
2590
3012
  const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
2591
3013
  const printBedType = resolveBedType(args?.bed_type as string | undefined);
2592
3014
  const explicitPrintNozzle = optionalNozzleDiameterArg(args?.nozzle_diameter);
2593
3015
  const printNozzle = String(explicitPrintNozzle ?? DEFAULT_NOZZLE_DIAMETER);
2594
3016
  const printPreset = BAMBU_MODEL_PRESETS[printModel]?.(printNozzle);
3017
+ const printConnection = resolveConnection(args);
3018
+ assertConnectionAvailable(printConnection.mode);
2595
3019
 
2596
3020
  let threeMFPath = String(args.three_mf_path);
2597
3021
 
2598
3022
  // Only the selected plate's exact G-code entry counts as sliced output;
2599
3023
  // a stray .gcode entry elsewhere in the archive is not printable.
2600
- const selectedPlateEntry = "Metadata/plate_1.gcode";
3024
+ const selectedPlateEntry = `Metadata/plate_${printPlate + 1}.gcode`;
2601
3025
  let shouldAutoSlice = false;
2602
3026
  try {
2603
3027
  const JSZip = (await import('jszip')).default;
@@ -2613,6 +3037,13 @@ class ThreeDPrinterMCPServer {
2613
3037
 
2614
3038
  // Only treat slicer_path as an executable selector when a slicer
2615
3039
  // will actually launch. Ready-to-print archives ignore it.
3040
+ const pauseLayers = parsePauseLayers(args?.pause_at_layers);
3041
+ if (!shouldAutoSlice && pauseLayers) {
3042
+ const gcode = await readPlateGcode(threeMFPath, printPlate + 1);
3043
+ const placed = gcode ? pausedLayers(gcode, machinePauseGcode(gcode)) : [];
3044
+ const missing = pauseLayers.filter(layer => !placed.includes(layer));
3045
+ 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.`);
3046
+ }
2616
3047
  if (shouldAutoSlice) {
2617
3048
  const printSlicerPath =
2618
3049
  this.resolveExecutableSelectorArg(
@@ -2631,7 +3062,7 @@ class ThreeDPrinterMCPServer {
2631
3062
  printPreset,
2632
3063
  filamentProfile || undefined,
2633
3064
  // Slice for the plate and nozzle the print will be checked against.
2634
- { bedType: printBedType, ...(resolveNozzleType(args?.nozzle_type) ? { nozzleType: resolveNozzleType(args?.nozzle_type) } : {}) }
3065
+ { bedType: printBedType, slicePlate: printPlate + 1, ...(pauseLayers ? { pauseAtLayers: pauseLayers } : {}), ...(resolveNozzleType(args?.nozzle_type) ? { nozzleType: resolveNozzleType(args?.nozzle_type) } : {}) }
2635
3066
  );
2636
3067
  console.error("Auto-sliced to: " + threeMFPath);
2637
3068
  } catch (sliceErr: any) {
@@ -2662,7 +3093,8 @@ class ThreeDPrinterMCPServer {
2662
3093
  console.error(`Successfully parsed 3MF file: ${threeMFPath}`);
2663
3094
  const amsOptions = this.resolveAmsPrintOptions(
2664
3095
  parsed3MFData,
2665
- args as Record<string, unknown> | undefined
3096
+ args as Record<string, unknown> | undefined,
3097
+ printModel
2666
3098
  );
2667
3099
 
2668
3100
  // --- Prepare Implementation and Print Options ---
@@ -2693,12 +3125,22 @@ class ThreeDPrinterMCPServer {
2693
3125
  throw new Error(`Failed during 3MF processing: ${(error as Error).message}`);
2694
3126
  }
2695
3127
 
3128
+ if (printConnection.mode === "bambu_network") {
3129
+ result = await this.print3mfViaBambuNetwork({
3130
+ args: args as Record<string, any>, host, serial: bambuSerial, token: bambuToken, threeMFPath,
3131
+ model: printModel, nozzleDiameters: requestedPrintNozzles, bedType: printBedType,
3132
+ connectionType: printConnection.type, plateIndex: printPlate,
3133
+ ams: { useAMS: printOptions.useAMS, amsMapping: printOptions.amsMapping, amsSlots: printOptions.amsSlots },
3134
+ });
3135
+ break;
3136
+ }
3137
+
2696
3138
  // --- Call Implementation (Now variables are in scope) ---
2697
3139
  try {
2698
3140
  result = await implementation.print3mf(host, bambuSerial, bambuToken, {
2699
3141
  projectName: projectName,
2700
3142
  filePath: threeMFPath,
2701
- plateIndex: 0,
3143
+ plateIndex: printPlate,
2702
3144
  ...printOptions // Spread the final options
2703
3145
  });
2704
3146
  // Report what the printer did, not merely that a command was published.
@@ -2999,13 +3441,14 @@ class ThreeDPrinterMCPServer {
2999
3441
  type: string,
3000
3442
  apiKey: string,
3001
3443
  bambuSerial: string,
3002
- bambuToken: string
3444
+ bambuToken: string,
3445
+ bambuDispatch?: () => Promise<any>
3003
3446
  ) {
3004
3447
  const implementation = this.printerFactory.getImplementation(type);
3005
3448
 
3006
3449
  if (type.toLowerCase() === "bambu") {
3007
3450
  const bambuApiKey = `${bambuSerial}:${bambuToken}`;
3008
- return await implementation.cancelJob(host, port, bambuApiKey);
3451
+ return await (implementation as unknown as BambuImplementation).cancelJob(host, port, bambuApiKey, bambuDispatch);
3009
3452
  }
3010
3453
 
3011
3454
  return await implementation.cancelJob(host, port, apiKey);
@@ -3265,7 +3708,9 @@ class ThreeDPrinterMCPServer {
3265
3708
  }
3266
3709
 
3267
3710
  private async closeOnce(): Promise<void> {
3711
+ this.counterpart.close();
3268
3712
  await this.printerFactory.disconnectAll();
3713
+ await this.bambuNetwork.stop();
3269
3714
  await this.fusion.close();
3270
3715
 
3271
3716
  if (this.httpRuntime) {