mcp-3d-printer-server 1.2.27 → 1.2.29

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.29] – 2026-11-04
4
+
5
+ ### Changed
6
+
7
+ - With no method given, a FULU print over Bambu Cloud goes the way Bambu Studio sends it to a cloud-bound printer it can reach on the LAN (`PrintJob.cpp`): `start_local_print_with_record`, which uploads to the printer over the LAN and records the task in the account, in the account's cloud session. When that fails before the print command (the plug-in's `-2010` to `-2140` preparation results, other than a missing or oversized file), the same checked job goes once through `start_print`, with the printer checked again first. Any other failure may have reached the printer and is not sent again. A printer that reports no storage goes straight to `start_print`, as in Studio. Same change as bambu-printer-mcp 1.1.55. On a P1S with firmware 01.10.00.00, `start_print` alone returns `-3120`, HTTP 403 "internal blocking", every time. Errors name `-2120`, `-2130` and `-3120`. Checked with the stand-in host and intercepted transports only; not yet on a printer.
8
+
9
+ ## [1.2.28] – 2026-11-04
10
+
11
+ ### Added
12
+
13
+ - `slice_stl` takes `enable_support`, `support_type` (`normal(auto)`, `tree(auto)`, `normal(manual)`, `tree(manual)`) and `support_threshold_angle`, written over the selected process profile like `layer_height` and `wall_loops`. Bambu Studio and OrcaSlicer only; PrusaSlicer and Cura refuse them. Tested with the fake Bambu Studio CLI install; bambu-printer-mcp 1.1.54 checks the same keys against the installed Bambu Studio 02.08.02.61. No printer was involved.
14
+
3
15
  ## [1.2.27] – 2026-11-04
4
16
 
5
17
  ### Added
package/README.md CHANGED
@@ -690,6 +690,8 @@ Options for the Bambu-compatible path:
690
690
 
691
691
  `layer_height` (mm), `wall_loops` (a whole number), and `sparse_infill_density` (such as `"15%"`) are written over the selected profile on every slicer: as the process keys `layer_height`, `wall_loops`, and `sparse_infill_density` for Bambu Studio and OrcaSlicer, and as `--layer-height`, `--perimeters`, and `--fill-density` for PrusaSlicer and Slic3r. They win over a template. Cura refuses them; set them in its definition file. Your profile files are never modified.
692
692
 
693
+ `enable_support` (true or false), `support_type` (`normal(auto)`, `tree(auto)`, `normal(manual)` or `tree(manual)`) and `support_threshold_angle` (0 to 90 degrees; 0 lets the slicer decide) are written over the selected process profile as the same-named Bambu Studio and OrcaSlicer keys. PrusaSlicer, Slic3r and Cura refuse them; set supports in their own profile.
694
+
693
695
  Generic slicers keep their own profile formats: PrusaSlicer and Slic3r load one exported config, plus an optional filament preset `.ini` from `load_filaments`, and generic OrcaSlicer takes `machine.json;process.json`, optionally followed by `|filament.json`. `slicer_type`, `slicer_profile`, and `filament_profile` fall back to `SLICER_TYPE`, `SLICER_PROFILE`, and `FILAMENT_PROFILE`. The slicer executable comes from `SLICER_PATH`; a per-call `slicer_path` requires `MCP_ALLOW_EXECUTABLE_ARG=1`. See the [slicing guide](https://github.com/DMontgomery40/mcp-3D-printer-server/blob/main/docs/SLICING.md#bambu-compatible-slicing).
694
696
 
695
697
  #### slice_with_template
@@ -186,10 +186,16 @@ export declare class BambuNetworkBridge {
186
186
  * Shares the LAN session queue: one reader of the plug-in's events at a time.
187
187
  */
188
188
  withCloudSession<T>(devId: string, options: BambuNetworkBridgeOptions, assertActive: () => void, timing: Readonly<CloudTiming>, run: (session: CloudSession) => Promise<T>): Promise<T>;
189
- /** A cloud print method (start_print) in a cloud session, so the plug-in has the printer's state. */
190
- startCloudPrint(devId: string, method: string, payload: JsonObject, options?: BambuNetworkBridgeOptions, assertActive?: () => void, timing?: Readonly<CloudTiming>): Promise<{
189
+ /**
190
+ * A cloud print in a cloud session, so the plug-in has the printer's state. With a fallback this is
191
+ * Bambu Studio's order for a cloud-bound printer it can also reach on the LAN (PrintJob.cpp,
192
+ * SendAttemptRoute::LanWithRecord, then ::Cloud): start_local_print_with_record, then start_print
193
+ * when the first failed before its print command was sent. `firstFailure` is the first method's result.
194
+ */
195
+ startCloudPrint(devId: string, method: string, payload: JsonObject, options?: BambuNetworkBridgeOptions, assertActive?: () => void, timing?: Readonly<CloudTiming>, fallbackMethod?: string): Promise<{
191
196
  result: unknown;
192
197
  jobUpdates: BridgeJobUpdate[];
198
+ firstFailure?: number;
193
199
  }>;
194
200
  /**
195
201
  * A print control over Bambu Cloud: how Bambu Studio and FULU's slicer control a printer
@@ -21,8 +21,16 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 120000;
21
21
  const LOCAL_CONNECT_STATUS = ["ok", "failed", "lost"];
22
22
  // bambu_networking.hpp BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED.
23
23
  const LOCAL_PRINT_PUBLISH_FAILED = -4030;
24
+ // A with-record send falls back to start_print only on its own preparation failures (the plug-in's
25
+ // PRINT_WR_* results, -2010 to -2140: project, upload, task record, FTPS), which come before the print
26
+ // command, so the printer has no job. Any other result may have reached the printer and is never
27
+ // replayed. Of those, Studio also stops on a file missing or too large (should_stop_without_fallback).
28
+ const WITH_RECORD_PREPARATION_FAILURE = (value) => value <= -2010 && value >= -2140 && value !== -2040 && value !== -2100;
24
29
  // bambu_networking.hpp print errors that name a recoverable cause.
25
30
  const BAMBU_NETWORK_PRINT_ERRORS = {
31
+ [-2120]: "Bambu Cloud refused to create the print task",
32
+ [-2130]: "FTPS upload to the printer failed",
33
+ [-3120]: "Bambu Cloud refused to create the print task",
26
34
  [-4010]: "the file exceeds the 1 GB upload limit",
27
35
  [-4020]: "FTPS upload to the printer failed",
28
36
  [LOCAL_PRINT_PUBLISH_FAILED]: "the networking plug-in could not send the print command to the printer over MQTT",
@@ -703,9 +711,23 @@ export class BambuNetworkBridge {
703
711
  release();
704
712
  }
705
713
  }
706
- /** A cloud print method (start_print) in a cloud session, so the plug-in has the printer's state. */
707
- async startCloudPrint(devId, method, payload, options = {}, assertActive = () => { }, timing = CLOUD_PRINT_TIMING) {
708
- return await this.withCloudSession(devId, options, assertActive, timing, async (session) => ({ result: await this.callWithAgent(method, payload, options), jobUpdates: session.jobUpdates }));
714
+ /**
715
+ * A cloud print in a cloud session, so the plug-in has the printer's state. With a fallback this is
716
+ * Bambu Studio's order for a cloud-bound printer it can also reach on the LAN (PrintJob.cpp,
717
+ * SendAttemptRoute::LanWithRecord, then ::Cloud): start_local_print_with_record, then start_print
718
+ * when the first failed before its print command was sent. `firstFailure` is the first method's result.
719
+ */
720
+ async startCloudPrint(devId, method, payload, options = {}, assertActive = () => { }, timing = CLOUD_PRINT_TIMING, fallbackMethod) {
721
+ return await this.withCloudSession(devId, options, assertActive, timing, async (session) => {
722
+ const result = await this.callWithAgent(method, payload, options);
723
+ const value = bridgeNumericResult(result);
724
+ if (!fallbackMethod || value === undefined || !WITH_RECORD_PREPARATION_FAILURE(value))
725
+ return { result, jobUpdates: session.jobUpdates };
726
+ options.trace?.({ step: "fallback", from: method, to: fallbackMethod, result: value });
727
+ const params = payload.params;
728
+ const fallbackPayload = params ? { ...payload, params: { ...params, comments: `failed(${value})` } } : payload;
729
+ return { result: await this.callWithAgent(fallbackMethod, fallbackPayload, options), jobUpdates: session.jobUpdates, firstFailure: value };
730
+ });
709
731
  }
710
732
  /**
711
733
  * A print control over Bambu Cloud: how Bambu Studio and FULU's slicer control a printer
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import { createServer as createHttpServer } from "node:http";
17
17
  import { randomUUID } from "node:crypto";
18
18
  import * as THREE from 'three';
19
19
  import { PrinterFactory } from "./printers/printer-factory.js";
20
- import { STLManipulator } from "./stl/stl-manipulator.js";
20
+ import { STLManipulator, SUPPORT_TYPES } from "./stl/stl-manipulator.js";
21
21
  import { parsePauseLayers, readPlateGcode, pausedLayers, machinePauseGcode } from "./slicer/layer-pauses.js";
22
22
  import { CounterpartService } from "./counterpart/service.js";
23
23
  import { CAMERA_STREAM_TOOLS } from "./counterpart/camera-tools.js";
@@ -34,7 +34,7 @@ import { BambuNetworkBridge, describeBambuNetworkPrintError } from "./bambu-netw
34
34
  import { PRINT_ROUTES, connectionForRoute, routeOf, routeOptions, writeEnvValues } from "./counterpart/route.js";
35
35
  import { resolveStudioPrintOptions, usedNozzleCount } from "./studio-print-options.js";
36
36
  import { inspectPrintFile } from "./safety/print-file.js";
37
- import { validatePrinterState } from "./safety/printer-state.js";
37
+ import { externalStorageState, validatePrinterState } from "./safety/printer-state.js";
38
38
  import { uniquePrintName, withPrinterOperation, withPrintSnapshot } from "./safety/artifact.js";
39
39
  import { checkedFile } from "./printers/bambu.js";
40
40
  import { CONFIRMATION_UNAVAILABLE, confirmationTimedOut, confirmationTimeoutMs } from "./safety/confirmation.js";
@@ -318,6 +318,12 @@ const SLICE_TOOL_PROPERTIES = {
318
318
  type: "string",
319
319
  description: "Sparse infill density such as \"15%\" (0–100%), written over the selected process profile (Orca/Bambu Studio sparse_infill_density; PrusaSlicer --fill-density). Not mapped for Cura.",
320
320
  },
321
+ enable_support: { type: "boolean", description: "Turn support generation on or off (Orca/Bambu Studio enable_support), written over the selected process profile. Bambu Studio and OrcaSlicer only." },
322
+ support_type: { type: "string", enum: [...SUPPORT_TYPES], description: "Support style (Orca/Bambu Studio support_type), written over the selected process profile. Bambu Studio and OrcaSlicer only." },
323
+ support_threshold_angle: {
324
+ type: "integer", minimum: 0, maximum: 90,
325
+ description: "Overhang threshold in degrees for automatic supports (Orca/Bambu Studio support_threshold_angle; 0 lets the slicer decide). Bambu Studio and OrcaSlicer only.",
326
+ },
321
327
  };
322
328
  /**
323
329
  * upload_gcode / start_print results name the remote file that was stored or
@@ -348,7 +354,7 @@ const OUTPUT_SCHEMA_TOOLS = new Set([
348
354
  "camera_stream_start", "camera_frame", "camera_stream_stop",
349
355
  "fusion_mcp_status", "fusion_mcp_call", "fusion_mcp_new_design", "fusion_mcp_export",
350
356
  ]);
351
- /** layer_height / wall_loops / sparse_infill_density (PRD §8.4), validated before slicing. */
357
+ /** layer_height / wall_loops / sparse_infill_density / support settings (PRD §8.4), validated before slicing. */
352
358
  function parseProcessOverrides(args) {
353
359
  const overrides = {};
354
360
  if (args?.layer_height !== undefined) {
@@ -375,6 +381,25 @@ function parseProcessOverrides(args) {
375
381
  }
376
382
  overrides.sparseInfillDensity = `${value}%`;
377
383
  }
384
+ if (args?.enable_support !== undefined) {
385
+ if (typeof args.enable_support !== "boolean")
386
+ throw new Error("Invalid enable_support: expected true or false.");
387
+ overrides.enableSupport = args.enable_support;
388
+ }
389
+ if (args?.support_type !== undefined) {
390
+ const value = args.support_type;
391
+ if (typeof value !== "string" || !SUPPORT_TYPES.includes(value)) {
392
+ throw new Error(`Invalid support_type: expected one of ${SUPPORT_TYPES.join(", ")}.`);
393
+ }
394
+ overrides.supportType = value;
395
+ }
396
+ if (args?.support_threshold_angle !== undefined) {
397
+ const value = args.support_threshold_angle;
398
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 90) {
399
+ throw new Error("Invalid support_threshold_angle: expected an integer number of degrees from 0 to 90.");
400
+ }
401
+ overrides.supportThresholdAngle = value;
402
+ }
378
403
  return overrides;
379
404
  }
380
405
  function validateSliceBambuModel(model) {
@@ -856,8 +881,10 @@ class ThreeDPrinterMCPServer {
856
881
  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.");
857
882
  }
858
883
  const isLocalBridgePrint = connectionType === "lan";
859
- const bridgePrintMethod = isLocalBridgePrint ? "start_local_print" : "start_print";
860
- const bridgeMethod = `net.${bridgePrintMethod}`;
884
+ // Bambu Studio sends a cloud-bound printer it can reach on the LAN with record first, in the cloud
885
+ // session, then start_print (PrintJob.cpp). This server always has the printer's IP and access code.
886
+ let bridgeMethod = isLocalBridgePrint ? "net.start_local_print" : "net.start_local_print_with_record";
887
+ let cloudFallbackMethod = isLocalBridgePrint ? undefined : "net.start_print";
861
888
  const { plateIndex } = options;
862
889
  const threeMfFilename = path.basename(options.threeMFPath);
863
890
  const projectName = threeMfFilename.replace(/\.3mf$/i, "");
@@ -898,6 +925,11 @@ class ThreeDPrinterMCPServer {
898
925
  validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
899
926
  const confirmedStatus = await bambu.getSafetyStatus(devIp, devId, password);
900
927
  validatePrinterState(confirmedStatus, requirements);
928
+ if (cloudFallbackMethod && externalStorageState(confirmedStatus.raw) !== "normal") {
929
+ // Studio sends straight through the cloud when the printer has no storage for the LAN upload.
930
+ bridgeMethod = cloudFallbackMethod;
931
+ cloudFallbackMethod = undefined;
932
+ }
901
933
  const wireMapping = mapping.map((value) => bridgeAmsMappingValue(value, dualNozzle));
902
934
  const remoteFileName = uniquePrintName(options.threeMFPath);
903
935
  const checkedName = remoteFileName.replace(/(?:\.gcode)?\.3mf$/i, "");
@@ -954,23 +986,29 @@ class ThreeDPrinterMCPServer {
954
986
  const bridgeOptions = {
955
987
  // Trusted in-process preflight immediately before the print frame.
956
988
  beforeDispatch: async (method) => {
957
- if (method !== bridgeMethod)
989
+ if (method !== bridgeMethod && method !== cloudFallbackMethod)
958
990
  return;
959
991
  assertActive();
960
992
  validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
961
993
  },
962
- assertDispatchAllowed: (method) => { if (method === bridgeMethod)
994
+ assertDispatchAllowed: (method) => { if (method === bridgeMethod || method === cloudFallbackMethod)
963
995
  assertActive(); },
964
- onDispatched: (method) => { if (method === bridgeMethod)
996
+ onDispatched: (method) => { if (method === bridgeMethod || method === cloudFallbackMethod)
965
997
  retainForDispatchedUpload(); },
966
998
  };
967
999
  let jobUpdates = [];
968
1000
  let bridgeResult;
1001
+ let firstAttempt = "";
969
1002
  if (isLocalBridgePrint) {
970
1003
  ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startLocalPrint({ devId, devIp, username: "bblp", password, useSsl: true }, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive));
971
1004
  }
972
1005
  else {
973
- ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startCloudPrint(devId, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive));
1006
+ let firstFailure;
1007
+ ({ result: bridgeResult, jobUpdates, firstFailure } = await this.bambuNetwork.startCloudPrint(devId, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive, undefined, cloudFallbackMethod));
1008
+ if (firstFailure !== undefined) {
1009
+ bridgeMethod = cloudFallbackMethod;
1010
+ firstAttempt = ` It first tried net.start_local_print_with_record, as Bambu Studio does, which returned ${firstFailure}${describeBambuNetworkPrintError(firstFailure)}.`;
1011
+ }
974
1012
  }
975
1013
  if (typeof bridgeResult === "object" && bridgeResult !== null && bridgeResult.ok === false) {
976
1014
  throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} failed: ${String(bridgeResult.error || "unknown bridge error")}`);
@@ -980,14 +1018,14 @@ class ThreeDPrinterMCPServer {
980
1018
  const reported = jobUpdates.filter((update) => update.code !== 0 || update.msg).slice(-3)
981
1019
  .map((update) => `status ${update.status}, code ${update.code}${update.msg ? `: ${update.msg}` : ""}`).join("; ");
982
1020
  throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} returned non-zero result ${value}${describeBambuNetworkPrintError(value)}.` +
983
- (reported ? ` Plug-in reported: ${reported}.` : ""));
1021
+ (reported ? ` Plug-in reported: ${reported}.` : "") + firstAttempt);
984
1022
  }
985
1023
  assertActive();
986
1024
  // Bambu's plug-in placed the file, so a resume re-reads it if it can and otherwise asks a person.
987
1025
  bambu.recordCheckedJob(devIp, devId, remoteFileName, requirements, resumeFile, false);
988
1026
  return {
989
1027
  status: "success",
990
- message: `FULU BambuNetwork ${bridgePrintMethod} command for ${threeMfFilename} sent successfully.`,
1028
+ message: `FULU BambuNetwork ${bridgeMethod.replace(/^net\./, "")} command for ${threeMfFilename} sent successfully.${firstAttempt}`,
991
1029
  connectionMode: "bambu_network",
992
1030
  connectionType,
993
1031
  bridgeMethod,
@@ -15,7 +15,14 @@ export interface ProcessOverrides {
15
15
  wallLoops?: number;
16
16
  /** e.g. "15%" */
17
17
  sparseInfillDensity?: string;
18
+ enableSupport?: boolean;
19
+ /** normal(auto), tree(auto), normal(manual) or tree(manual) */
20
+ supportType?: string;
21
+ /** degrees, 0-90 */
22
+ supportThresholdAngle?: number;
18
23
  }
24
+ /** Bambu Studio's and OrcaSlicer's support_type values. */
25
+ export declare const SUPPORT_TYPES: readonly ["normal(auto)", "tree(auto)", "normal(manual)", "tree(manual)"];
19
26
  /** Orca/Bambu Studio process keys; profile JSON values are strings. */
20
27
  export declare function orcaProcessOverrides(overrides: ProcessOverrides | undefined): Record<string, string>;
21
28
  /** PrusaSlicer/Slic3r command-line options, which override the loaded config. */
@@ -13,6 +13,16 @@ import { BambuCliProfilePreparer, assertSlicedProjectOutput, bambuCliFailureReas
13
13
  import { runSlicerProcess } from '../slicer/run-slicer.js';
14
14
  import { SlicerError, asPreparationError } from '../slicer/slicer-error.js';
15
15
  import { customGcodesWithPauses, layerTops, machinePauseGcode, pausedLayers, readPlateGcode, readProjectCustomGcodes } from '../slicer/layer-pauses.js';
16
+ /** Bambu Studio's and OrcaSlicer's support_type values. */
17
+ export const SUPPORT_TYPES = ["normal(auto)", "tree(auto)", "normal(manual)", "tree(manual)"];
18
+ /** Support overrides only Bambu Studio and OrcaSlicer take; PrusaSlicer and Cura refuse them. */
19
+ function supportOverrideNames(overrides) {
20
+ return [
21
+ overrides?.enableSupport !== undefined ? "enable_support" : "",
22
+ overrides?.supportType !== undefined ? "support_type" : "",
23
+ overrides?.supportThresholdAngle !== undefined ? "support_threshold_angle" : "",
24
+ ].filter(Boolean);
25
+ }
16
26
  /** Orca/Bambu Studio process keys; profile JSON values are strings. */
17
27
  export function orcaProcessOverrides(overrides) {
18
28
  const keys = {};
@@ -22,10 +32,19 @@ export function orcaProcessOverrides(overrides) {
22
32
  keys.wall_loops = String(overrides.wallLoops);
23
33
  if (overrides?.sparseInfillDensity !== undefined)
24
34
  keys.sparse_infill_density = overrides.sparseInfillDensity;
35
+ if (overrides?.enableSupport !== undefined)
36
+ keys.enable_support = overrides.enableSupport ? "1" : "0";
37
+ if (overrides?.supportType !== undefined)
38
+ keys.support_type = overrides.supportType;
39
+ if (overrides?.supportThresholdAngle !== undefined)
40
+ keys.support_threshold_angle = String(overrides.supportThresholdAngle);
25
41
  return keys;
26
42
  }
27
43
  /** PrusaSlicer/Slic3r command-line options, which override the loaded config. */
28
44
  export function prusaSlicerOverrideArgs(overrides) {
45
+ const unsupported = supportOverrideNames(overrides);
46
+ if (unsupported.length)
47
+ throw new Error(`${unsupported.join(", ")} apply to Bambu Studio and OrcaSlicer only; set them in the PrusaSlicer config passed as slicer_profile.`);
29
48
  const args = [];
30
49
  if (overrides?.layerHeight !== undefined)
31
50
  args.push('--layer-height', String(overrides.layerHeight));
@@ -1000,7 +1019,7 @@ export class STLManipulator extends EventEmitter {
1000
1019
  break;
1001
1020
  case 'cura':
1002
1021
  if (Object.keys(orcaProcessOverrides(processOverrides)).length) {
1003
- throw new SlicerError('preparation', 'layer_height, wall_loops and sparse_infill_density are not mapped for Cura; set them in the Cura definition passed as slicer_profile.', { slicerType, slicerPath });
1022
+ throw new SlicerError('preparation', 'layer_height, wall_loops, sparse_infill_density and the support settings are not mapped for Cura; set them in the Cura definition passed as slicer_profile.', { slicerType, slicerPath });
1004
1023
  }
1005
1024
  // CuraEngine CLI args are different, often requiring -s for settings
1006
1025
  // Example: curaengine slice -v -j cura_settings.json -s layer_height=0.2 -o output.gcode -l input.stl
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-3d-printer-server",
3
3
  "mcpName": "io.github.DMontgomery40/mcp-3d-printer-server",
4
- "version": "1.2.27",
4
+ "version": "1.2.29",
5
5
  "description": "MCP server for connecting Claude with 3D printer management systems",
6
6
  "main": "dist/index.js",
7
7
  "repository": "https://github.com/DMontgomery40/mcp-3D-printer-server",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/DMontgomery40/mcp-3D-printer-server",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.2.27",
10
+ "version": "1.2.29",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "mcp-3d-printer-server",
15
- "version": "1.2.27",
15
+ "version": "1.2.29",
16
16
  "runtimeHint": "npx",
17
17
  "transport": {
18
18
  "type": "stdio"
@@ -23,8 +23,16 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
23
23
  const LOCAL_CONNECT_STATUS = ["ok", "failed", "lost"];
24
24
  // bambu_networking.hpp BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED.
25
25
  const LOCAL_PRINT_PUBLISH_FAILED = -4030;
26
+ // A with-record send falls back to start_print only on its own preparation failures (the plug-in's
27
+ // PRINT_WR_* results, -2010 to -2140: project, upload, task record, FTPS), which come before the print
28
+ // command, so the printer has no job. Any other result may have reached the printer and is never
29
+ // replayed. Of those, Studio also stops on a file missing or too large (should_stop_without_fallback).
30
+ const WITH_RECORD_PREPARATION_FAILURE = (value: number) => value <= -2010 && value >= -2140 && value !== -2040 && value !== -2100;
26
31
  // bambu_networking.hpp print errors that name a recoverable cause.
27
32
  const BAMBU_NETWORK_PRINT_ERRORS: Record<number, string> = {
33
+ [-2120]: "Bambu Cloud refused to create the print task",
34
+ [-2130]: "FTPS upload to the printer failed",
35
+ [-3120]: "Bambu Cloud refused to create the print task",
28
36
  [-4010]: "the file exceeds the 1 GB upload limit",
29
37
  [-4020]: "FTPS upload to the printer failed",
30
38
  [LOCAL_PRINT_PUBLISH_FAILED]: "the networking plug-in could not send the print command to the printer over MQTT",
@@ -853,10 +861,24 @@ export class BambuNetworkBridge {
853
861
  }
854
862
  }
855
863
 
856
- /** A cloud print method (start_print) in a cloud session, so the plug-in has the printer's state. */
864
+ /**
865
+ * A cloud print in a cloud session, so the plug-in has the printer's state. With a fallback this is
866
+ * Bambu Studio's order for a cloud-bound printer it can also reach on the LAN (PrintJob.cpp,
867
+ * SendAttemptRoute::LanWithRecord, then ::Cloud): start_local_print_with_record, then start_print
868
+ * when the first failed before its print command was sent. `firstFailure` is the first method's result.
869
+ */
857
870
  async startCloudPrint(devId: string, method: string, payload: JsonObject, options: BambuNetworkBridgeOptions = {},
858
- assertActive: () => void = () => {}, timing: Readonly<CloudTiming> = CLOUD_PRINT_TIMING): Promise<{ result: unknown; jobUpdates: BridgeJobUpdate[] }> {
859
- return await this.withCloudSession(devId, options, assertActive, timing, async session => ({ result: await this.callWithAgent(method, payload, options), jobUpdates: session.jobUpdates }));
871
+ assertActive: () => void = () => {}, timing: Readonly<CloudTiming> = CLOUD_PRINT_TIMING, fallbackMethod?: string
872
+ ): Promise<{ result: unknown; jobUpdates: BridgeJobUpdate[]; firstFailure?: number }> {
873
+ return await this.withCloudSession(devId, options, assertActive, timing, async session => {
874
+ const result = await this.callWithAgent(method, payload, options);
875
+ const value = bridgeNumericResult(result);
876
+ if (!fallbackMethod || value === undefined || !WITH_RECORD_PREPARATION_FAILURE(value)) return { result, jobUpdates: session.jobUpdates };
877
+ options.trace?.({ step: "fallback", from: method, to: fallbackMethod, result: value });
878
+ const params = (payload as { params?: JsonObject }).params;
879
+ const fallbackPayload = params ? { ...payload, params: { ...params, comments: `failed(${value})` } } : payload;
880
+ return { result: await this.callWithAgent(fallbackMethod, fallbackPayload, options), jobUpdates: session.jobUpdates, firstFailure: value };
881
+ });
860
882
  }
861
883
 
862
884
  /**
package/src/index.ts CHANGED
@@ -34,7 +34,7 @@ import { createServer as createHttpServer, type IncomingMessage, type Server as
34
34
  import { randomUUID } from "node:crypto";
35
35
  import * as THREE from 'three';
36
36
  import { PrinterFactory } from "./printers/printer-factory.js";
37
- import { STLManipulator, type BambuSliceOptions, type ProcessOverrides, type SliceOutput } from "./stl/stl-manipulator.js";
37
+ import { STLManipulator, SUPPORT_TYPES, type BambuSliceOptions, type ProcessOverrides, type SliceOutput } from "./stl/stl-manipulator.js";
38
38
  import { parsePauseLayers, readPlateGcode, pausedLayers, machinePauseGcode } from "./slicer/layer-pauses.js";
39
39
  import { CounterpartService, type BambuControlRoute, type PrinterTarget } from "./counterpart/service.js";
40
40
  import {CAMERA_STREAM_TOOLS} from "./counterpart/camera-tools.js";
@@ -75,7 +75,7 @@ import { BambuNetworkBridge, describeBambuNetworkPrintError, type BridgeJobUpdat
75
75
  import { PRINT_ROUTES, connectionForRoute, routeOf, routeOptions, writeEnvValues, type PrintRoute } from "./counterpart/route.js";
76
76
  import { resolveStudioPrintOptions, usedNozzleCount, type CallerPrintOptions } from "./studio-print-options.js";
77
77
  import { inspectPrintFile } from "./safety/print-file.js";
78
- import { validatePrinterState } from "./safety/printer-state.js";
78
+ import { externalStorageState, validatePrinterState } from "./safety/printer-state.js";
79
79
  import { uniquePrintName, withPrinterOperation, withPrintSnapshot } from "./safety/artifact.js";
80
80
  import { checkedFile } from "./printers/bambu.js";
81
81
  import { CONFIRMATION_UNAVAILABLE, confirmationTimedOut, confirmationTimeoutMs } from "./safety/confirmation.js";
@@ -396,6 +396,12 @@ const SLICE_TOOL_PROPERTIES = {
396
396
  type: "string",
397
397
  description: "Sparse infill density such as \"15%\" (0–100%), written over the selected process profile (Orca/Bambu Studio sparse_infill_density; PrusaSlicer --fill-density). Not mapped for Cura.",
398
398
  },
399
+ enable_support: { type: "boolean", description: "Turn support generation on or off (Orca/Bambu Studio enable_support), written over the selected process profile. Bambu Studio and OrcaSlicer only." },
400
+ support_type: { type: "string", enum: [...SUPPORT_TYPES], description: "Support style (Orca/Bambu Studio support_type), written over the selected process profile. Bambu Studio and OrcaSlicer only." },
401
+ support_threshold_angle: {
402
+ type: "integer", minimum: 0, maximum: 90,
403
+ description: "Overhang threshold in degrees for automatic supports (Orca/Bambu Studio support_threshold_angle; 0 lets the slicer decide). Bambu Studio and OrcaSlicer only.",
404
+ },
399
405
  };
400
406
 
401
407
  /**
@@ -428,7 +434,7 @@ const OUTPUT_SCHEMA_TOOLS = new Set([
428
434
  "fusion_mcp_status", "fusion_mcp_call", "fusion_mcp_new_design", "fusion_mcp_export",
429
435
  ]);
430
436
 
431
- /** layer_height / wall_loops / sparse_infill_density (PRD §8.4), validated before slicing. */
437
+ /** layer_height / wall_loops / sparse_infill_density / support settings (PRD §8.4), validated before slicing. */
432
438
  function parseProcessOverrides(args: Record<string, unknown> | undefined): ProcessOverrides {
433
439
  const overrides: ProcessOverrides = {};
434
440
  if (args?.layer_height !== undefined) {
@@ -455,6 +461,24 @@ function parseProcessOverrides(args: Record<string, unknown> | undefined): Proce
455
461
  }
456
462
  overrides.sparseInfillDensity = `${value}%`;
457
463
  }
464
+ if (args?.enable_support !== undefined) {
465
+ if (typeof args.enable_support !== "boolean") throw new Error("Invalid enable_support: expected true or false.");
466
+ overrides.enableSupport = args.enable_support;
467
+ }
468
+ if (args?.support_type !== undefined) {
469
+ const value = args.support_type;
470
+ if (typeof value !== "string" || !(SUPPORT_TYPES as readonly string[]).includes(value)) {
471
+ throw new Error(`Invalid support_type: expected one of ${SUPPORT_TYPES.join(", ")}.`);
472
+ }
473
+ overrides.supportType = value;
474
+ }
475
+ if (args?.support_threshold_angle !== undefined) {
476
+ const value = args.support_threshold_angle;
477
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 90) {
478
+ throw new Error("Invalid support_threshold_angle: expected an integer number of degrees from 0 to 90.");
479
+ }
480
+ overrides.supportThresholdAngle = value;
481
+ }
458
482
  return overrides;
459
483
  }
460
484
 
@@ -1050,8 +1074,10 @@ class ThreeDPrinterMCPServer {
1050
1074
  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
1075
  }
1052
1076
  const isLocalBridgePrint = connectionType === "lan";
1053
- const bridgePrintMethod = isLocalBridgePrint ? "start_local_print" : "start_print";
1054
- const bridgeMethod = `net.${bridgePrintMethod}`;
1077
+ // Bambu Studio sends a cloud-bound printer it can reach on the LAN with record first, in the cloud
1078
+ // session, then start_print (PrintJob.cpp). This server always has the printer's IP and access code.
1079
+ let bridgeMethod = isLocalBridgePrint ? "net.start_local_print" : "net.start_local_print_with_record";
1080
+ let cloudFallbackMethod = isLocalBridgePrint ? undefined : "net.start_print";
1055
1081
  const { plateIndex } = options;
1056
1082
  const threeMfFilename = path.basename(options.threeMFPath);
1057
1083
  const projectName = threeMfFilename.replace(/\.3mf$/i, "");
@@ -1092,6 +1118,11 @@ class ThreeDPrinterMCPServer {
1092
1118
  validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
1093
1119
  const confirmedStatus = await bambu.getSafetyStatus(devIp, devId, password);
1094
1120
  validatePrinterState(confirmedStatus, requirements);
1121
+ if (cloudFallbackMethod && externalStorageState(confirmedStatus.raw) !== "normal") {
1122
+ // Studio sends straight through the cloud when the printer has no storage for the LAN upload.
1123
+ bridgeMethod = cloudFallbackMethod;
1124
+ cloudFallbackMethod = undefined;
1125
+ }
1095
1126
  const wireMapping = mapping!.map((value) => bridgeAmsMappingValue(value, dualNozzle));
1096
1127
  const remoteFileName = uniquePrintName(options.threeMFPath);
1097
1128
  const checkedName = remoteFileName.replace(/(?:\.gcode)?\.3mf$/i, "");
@@ -1148,22 +1179,28 @@ class ThreeDPrinterMCPServer {
1148
1179
  const bridgeOptions = {
1149
1180
  // Trusted in-process preflight immediately before the print frame.
1150
1181
  beforeDispatch: async (method: string) => {
1151
- if (method !== bridgeMethod) return;
1182
+ if (method !== bridgeMethod && method !== cloudFallbackMethod) return;
1152
1183
  assertActive();
1153
1184
  validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
1154
1185
  },
1155
- assertDispatchAllowed: (method: string) => { if (method === bridgeMethod) assertActive(); },
1156
- onDispatched: (method: string) => { if (method === bridgeMethod) retainForDispatchedUpload(); },
1186
+ assertDispatchAllowed: (method: string) => { if (method === bridgeMethod || method === cloudFallbackMethod) assertActive(); },
1187
+ onDispatched: (method: string) => { if (method === bridgeMethod || method === cloudFallbackMethod) retainForDispatchedUpload(); },
1157
1188
  };
1158
1189
  let jobUpdates: BridgeJobUpdate[] = [];
1159
1190
  let bridgeResult: unknown;
1191
+ let firstAttempt = "";
1160
1192
  if (isLocalBridgePrint) {
1161
1193
  ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startLocalPrint(
1162
1194
  { devId, devIp, username: "bblp", password, useSsl: true },
1163
1195
  bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive
1164
1196
  ));
1165
1197
  } else {
1166
- ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startCloudPrint(devId, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive));
1198
+ let firstFailure: number | undefined;
1199
+ ({ result: bridgeResult, jobUpdates, firstFailure } = await this.bambuNetwork.startCloudPrint(devId, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive, undefined, cloudFallbackMethod));
1200
+ if (firstFailure !== undefined) {
1201
+ bridgeMethod = cloudFallbackMethod!;
1202
+ firstAttempt = ` It first tried net.start_local_print_with_record, as Bambu Studio does, which returned ${firstFailure}${describeBambuNetworkPrintError(firstFailure)}.`;
1203
+ }
1167
1204
  }
1168
1205
  if (typeof bridgeResult === "object" && bridgeResult !== null && (bridgeResult as { ok?: unknown }).ok === false) {
1169
1206
  throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} failed: ${String((bridgeResult as { error?: unknown }).error || "unknown bridge error")}`);
@@ -1173,14 +1210,14 @@ class ThreeDPrinterMCPServer {
1173
1210
  const reported = jobUpdates.filter((update) => update.code !== 0 || update.msg).slice(-3)
1174
1211
  .map((update) => `status ${update.status}, code ${update.code}${update.msg ? `: ${update.msg}` : ""}`).join("; ");
1175
1212
  throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} returned non-zero result ${value}${describeBambuNetworkPrintError(value)}.` +
1176
- (reported ? ` Plug-in reported: ${reported}.` : ""));
1213
+ (reported ? ` Plug-in reported: ${reported}.` : "") + firstAttempt);
1177
1214
  }
1178
1215
  assertActive();
1179
1216
  // Bambu's plug-in placed the file, so a resume re-reads it if it can and otherwise asks a person.
1180
1217
  bambu.recordCheckedJob(devIp, devId, remoteFileName, requirements, resumeFile, false);
1181
1218
  return {
1182
1219
  status: "success",
1183
- message: `FULU BambuNetwork ${bridgePrintMethod} command for ${threeMfFilename} sent successfully.`,
1220
+ message: `FULU BambuNetwork ${bridgeMethod.replace(/^net\./, "")} command for ${threeMfFilename} sent successfully.${firstAttempt}`,
1184
1221
  connectionMode: "bambu_network",
1185
1222
  connectionType,
1186
1223
  bridgeMethod,
@@ -36,6 +36,23 @@ export interface ProcessOverrides {
36
36
  wallLoops?: number;
37
37
  /** e.g. "15%" */
38
38
  sparseInfillDensity?: string;
39
+ enableSupport?: boolean;
40
+ /** normal(auto), tree(auto), normal(manual) or tree(manual) */
41
+ supportType?: string;
42
+ /** degrees, 0-90 */
43
+ supportThresholdAngle?: number;
44
+ }
45
+
46
+ /** Bambu Studio's and OrcaSlicer's support_type values. */
47
+ export const SUPPORT_TYPES = ["normal(auto)", "tree(auto)", "normal(manual)", "tree(manual)"] as const;
48
+
49
+ /** Support overrides only Bambu Studio and OrcaSlicer take; PrusaSlicer and Cura refuse them. */
50
+ function supportOverrideNames(overrides: ProcessOverrides | undefined): string[] {
51
+ return [
52
+ overrides?.enableSupport !== undefined ? "enable_support" : "",
53
+ overrides?.supportType !== undefined ? "support_type" : "",
54
+ overrides?.supportThresholdAngle !== undefined ? "support_threshold_angle" : "",
55
+ ].filter(Boolean);
39
56
  }
40
57
 
41
58
  /** Orca/Bambu Studio process keys; profile JSON values are strings. */
@@ -44,11 +61,16 @@ export function orcaProcessOverrides(overrides: ProcessOverrides | undefined): R
44
61
  if (overrides?.layerHeight !== undefined) keys.layer_height = String(overrides.layerHeight);
45
62
  if (overrides?.wallLoops !== undefined) keys.wall_loops = String(overrides.wallLoops);
46
63
  if (overrides?.sparseInfillDensity !== undefined) keys.sparse_infill_density = overrides.sparseInfillDensity;
64
+ if (overrides?.enableSupport !== undefined) keys.enable_support = overrides.enableSupport ? "1" : "0";
65
+ if (overrides?.supportType !== undefined) keys.support_type = overrides.supportType;
66
+ if (overrides?.supportThresholdAngle !== undefined) keys.support_threshold_angle = String(overrides.supportThresholdAngle);
47
67
  return keys;
48
68
  }
49
69
 
50
70
  /** PrusaSlicer/Slic3r command-line options, which override the loaded config. */
51
71
  export function prusaSlicerOverrideArgs(overrides: ProcessOverrides | undefined): string[] {
72
+ const unsupported = supportOverrideNames(overrides);
73
+ if (unsupported.length) throw new Error(`${unsupported.join(", ")} apply to Bambu Studio and OrcaSlicer only; set them in the PrusaSlicer config passed as slicer_profile.`);
52
74
  const args: string[] = [];
53
75
  if (overrides?.layerHeight !== undefined) args.push('--layer-height', String(overrides.layerHeight));
54
76
  if (overrides?.wallLoops !== undefined) args.push('--perimeters', String(overrides.wallLoops));
@@ -1295,7 +1317,7 @@ export class STLManipulator extends EventEmitter {
1295
1317
  case 'cura':
1296
1318
  if (Object.keys(orcaProcessOverrides(processOverrides)).length) {
1297
1319
  throw new SlicerError('preparation',
1298
- 'layer_height, wall_loops and sparse_infill_density are not mapped for Cura; set them in the Cura definition passed as slicer_profile.',
1320
+ 'layer_height, wall_loops, sparse_infill_density and the support settings are not mapped for Cura; set them in the Cura definition passed as slicer_profile.',
1299
1321
  { slicerType, slicerPath });
1300
1322
  }
1301
1323
  // CuraEngine CLI args are different, often requiring -s for settings