mcp-3d-printer-server 1.2.28 → 1.2.30

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.30] – 2026-11-04
4
+
5
+ ### Added
6
+
7
+ - `slice_stl` and `slice_with_template` take `quality` (`draft`, `standard`, `fine`) and `strength` (`standard`, `strong`, `max`). The server turns them into the slicer's own settings: Draft and Fine set the layer height for the nozzle in use (0.28 and 0.12 mm on a 0.4 mm nozzle, scaled with it), Strong and Max set walls and infill (4 walls at 25%, 6 at 40%), and Standard leaves the resolved preset alone. An explicit `layer_height`, `wall_loops` or `sparse_infill_density` still wins. Clients such as Counterpart can now send the person's choice instead of carrying slicer numbers. Checked with stand-in slicers only. For PrusaSlicer, Slic3r and generic OrcaSlicer the layer height scales with `nozzle_diameter` (or `NOZZLE_DIAMETER`), which must match the profile's nozzle; Cura takes only Standard. Same change as bambu-printer-mcp 1.1.56.
8
+
9
+ ## [1.2.29] – 2026-11-04
10
+
11
+ ### Changed
12
+
13
+ - 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.
14
+
3
15
  ## [1.2.28] – 2026-11-04
4
16
 
5
17
  ### Added
package/README.md CHANGED
@@ -688,6 +688,8 @@ Options for the Bambu-compatible path:
688
688
  | `scale`, `rotate`, `rotate_x`, `rotate_y` | Transform before slicing (uniform scale; rotations in degrees). For OrcaSlicer STL input the server applies them to a mesh copy in Orca's order to avoid a CLI crash; see [OrcaSlicer CLI limits](https://github.com/DMontgomery40/mcp-3D-printer-server/blob/main/docs/SLICING.md#bambu-compatible-slicing) |
689
689
  | `enable_timelapse`, `allow_mix_temp` | Insert timelapse parking moves; allow filaments with different temperature needs on one plate |
690
690
 
691
+ `slice_stl` and `slice_with_template` take `quality` (`draft`, `standard`, `fine`) and `strength` (`standard`, `strong`, `max`), so a client can send the person's choice without slicer numbers. The server picks the layer height for the nozzle (0.28 and 0.12 mm on a 0.4 mm nozzle; it scales with `nozzle_diameter`, or `NOZZLE_DIAMETER` when that's not given, which for PrusaSlicer, Slic3r and generic OrcaSlicer must match the nozzle in `slicer_profile`) and the walls and infill (4 walls at 25%, 6 at 40%). Standard keeps the preset; explicit values win. Cura takes only Standard.
692
+
691
693
  `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
694
 
693
695
  `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.
@@ -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
@@ -19,6 +19,7 @@ import * as THREE from 'three';
19
19
  import { PrinterFactory } from "./printers/printer-factory.js";
20
20
  import { STLManipulator, SUPPORT_TYPES } from "./stl/stl-manipulator.js";
21
21
  import { parsePauseLayers, readPlateGcode, pausedLayers, machinePauseGcode } from "./slicer/layer-pauses.js";
22
+ import { PRINT_QUALITY_PROPERTIES, withPrintQuality } from "./slicer/print-quality.js";
22
23
  import { CounterpartService } from "./counterpart/service.js";
23
24
  import { CAMERA_STREAM_TOOLS } from "./counterpart/camera-tools.js";
24
25
  import { annotateTools } from "./tool-annotations.js";
@@ -34,7 +35,7 @@ import { BambuNetworkBridge, describeBambuNetworkPrintError } from "./bambu-netw
34
35
  import { PRINT_ROUTES, connectionForRoute, routeOf, routeOptions, writeEnvValues } from "./counterpart/route.js";
35
36
  import { resolveStudioPrintOptions, usedNozzleCount } from "./studio-print-options.js";
36
37
  import { inspectPrintFile } from "./safety/print-file.js";
37
- import { validatePrinterState } from "./safety/printer-state.js";
38
+ import { externalStorageState, validatePrinterState } from "./safety/printer-state.js";
38
39
  import { uniquePrintName, withPrinterOperation, withPrintSnapshot } from "./safety/artifact.js";
39
40
  import { checkedFile } from "./printers/bambu.js";
40
41
  import { CONFIRMATION_UNAVAILABLE, confirmationTimedOut, confirmationTimeoutMs } from "./safety/confirmation.js";
@@ -306,6 +307,7 @@ const SLICE_TOOL_PROPERTIES = {
306
307
  min_save: { type: "boolean", description: "Bambu-compatible slicing: write a smaller output 3MF (--min-save)." },
307
308
  skip_modified_gcodes: { type: "boolean", description: "Bambu-compatible slicing: ignore custom G-code embedded in an input 3MF (--skip-modified-gcodes)." },
308
309
  slice_plate: { type: "number", description: "Bambu-compatible slicing: plate number to slice; 0 slices all plates (default)." },
310
+ ...PRINT_QUALITY_PROPERTIES,
309
311
  layer_height: {
310
312
  type: "number", exclusiveMinimum: 0, maximum: 2,
311
313
  description: "Layer height in mm, written over the selected process profile (Orca/Bambu Studio layer_height; PrusaSlicer --layer-height). Not mapped for Cura.",
@@ -748,7 +750,7 @@ class ThreeDPrinterMCPServer {
748
750
  if (!args?.stl_path) {
749
751
  throw new Error("Missing required parameter: stl_path");
750
752
  }
751
- const processOverrides = parseProcessOverrides(args);
753
+ const processOverrides = parseProcessOverrides(withPrintQuality(args, Number(args.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER)));
752
754
  const slicerType = normalizeSlicerType(String(args.slicer_type || DEFAULT_SLICER_TYPE));
753
755
  const explicitModel = typeof args.bambu_model === "string" && args.bambu_model.trim() ? args.bambu_model : undefined;
754
756
  // Generic OrcaSlicer keeps its user-supplied machine profile unless the
@@ -881,8 +883,10 @@ class ThreeDPrinterMCPServer {
881
883
  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.");
882
884
  }
883
885
  const isLocalBridgePrint = connectionType === "lan";
884
- const bridgePrintMethod = isLocalBridgePrint ? "start_local_print" : "start_print";
885
- const bridgeMethod = `net.${bridgePrintMethod}`;
886
+ // Bambu Studio sends a cloud-bound printer it can reach on the LAN with record first, in the cloud
887
+ // session, then start_print (PrintJob.cpp). This server always has the printer's IP and access code.
888
+ let bridgeMethod = isLocalBridgePrint ? "net.start_local_print" : "net.start_local_print_with_record";
889
+ let cloudFallbackMethod = isLocalBridgePrint ? undefined : "net.start_print";
886
890
  const { plateIndex } = options;
887
891
  const threeMfFilename = path.basename(options.threeMFPath);
888
892
  const projectName = threeMfFilename.replace(/\.3mf$/i, "");
@@ -923,6 +927,11 @@ class ThreeDPrinterMCPServer {
923
927
  validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
924
928
  const confirmedStatus = await bambu.getSafetyStatus(devIp, devId, password);
925
929
  validatePrinterState(confirmedStatus, requirements);
930
+ if (cloudFallbackMethod && externalStorageState(confirmedStatus.raw) !== "normal") {
931
+ // Studio sends straight through the cloud when the printer has no storage for the LAN upload.
932
+ bridgeMethod = cloudFallbackMethod;
933
+ cloudFallbackMethod = undefined;
934
+ }
926
935
  const wireMapping = mapping.map((value) => bridgeAmsMappingValue(value, dualNozzle));
927
936
  const remoteFileName = uniquePrintName(options.threeMFPath);
928
937
  const checkedName = remoteFileName.replace(/(?:\.gcode)?\.3mf$/i, "");
@@ -979,23 +988,29 @@ class ThreeDPrinterMCPServer {
979
988
  const bridgeOptions = {
980
989
  // Trusted in-process preflight immediately before the print frame.
981
990
  beforeDispatch: async (method) => {
982
- if (method !== bridgeMethod)
991
+ if (method !== bridgeMethod && method !== cloudFallbackMethod)
983
992
  return;
984
993
  assertActive();
985
994
  validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
986
995
  },
987
- assertDispatchAllowed: (method) => { if (method === bridgeMethod)
996
+ assertDispatchAllowed: (method) => { if (method === bridgeMethod || method === cloudFallbackMethod)
988
997
  assertActive(); },
989
- onDispatched: (method) => { if (method === bridgeMethod)
998
+ onDispatched: (method) => { if (method === bridgeMethod || method === cloudFallbackMethod)
990
999
  retainForDispatchedUpload(); },
991
1000
  };
992
1001
  let jobUpdates = [];
993
1002
  let bridgeResult;
1003
+ let firstAttempt = "";
994
1004
  if (isLocalBridgePrint) {
995
1005
  ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startLocalPrint({ devId, devIp, username: "bblp", password, useSsl: true }, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive));
996
1006
  }
997
1007
  else {
998
- ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startCloudPrint(devId, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive));
1008
+ let firstFailure;
1009
+ ({ result: bridgeResult, jobUpdates, firstFailure } = await this.bambuNetwork.startCloudPrint(devId, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive, undefined, cloudFallbackMethod));
1010
+ if (firstFailure !== undefined) {
1011
+ bridgeMethod = cloudFallbackMethod;
1012
+ firstAttempt = ` It first tried net.start_local_print_with_record, as Bambu Studio does, which returned ${firstFailure}${describeBambuNetworkPrintError(firstFailure)}.`;
1013
+ }
999
1014
  }
1000
1015
  if (typeof bridgeResult === "object" && bridgeResult !== null && bridgeResult.ok === false) {
1001
1016
  throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} failed: ${String(bridgeResult.error || "unknown bridge error")}`);
@@ -1005,14 +1020,14 @@ class ThreeDPrinterMCPServer {
1005
1020
  const reported = jobUpdates.filter((update) => update.code !== 0 || update.msg).slice(-3)
1006
1021
  .map((update) => `status ${update.status}, code ${update.code}${update.msg ? `: ${update.msg}` : ""}`).join("; ");
1007
1022
  throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} returned non-zero result ${value}${describeBambuNetworkPrintError(value)}.` +
1008
- (reported ? ` Plug-in reported: ${reported}.` : ""));
1023
+ (reported ? ` Plug-in reported: ${reported}.` : "") + firstAttempt);
1009
1024
  }
1010
1025
  assertActive();
1011
1026
  // Bambu's plug-in placed the file, so a resume re-reads it if it can and otherwise asks a person.
1012
1027
  bambu.recordCheckedJob(devIp, devId, remoteFileName, requirements, resumeFile, false);
1013
1028
  return {
1014
1029
  status: "success",
1015
- message: `FULU BambuNetwork ${bridgePrintMethod} command for ${threeMfFilename} sent successfully.`,
1030
+ message: `FULU BambuNetwork ${bridgeMethod.replace(/^net\./, "")} command for ${threeMfFilename} sent successfully.${firstAttempt}`,
1016
1031
  connectionMode: "bambu_network",
1017
1032
  connectionType,
1018
1033
  bridgeMethod,
@@ -0,0 +1,26 @@
1
+ /**
2
+ * slice_stl's `quality` and `strength` (PRD §8.4): a client's print-sheet choice, mapped here to the
3
+ * slicer's own numbers so no client carries layer heights, wall counts or infill. Draft and Fine set
4
+ * the layer height for the nozzle in use (0.28 and 0.12 mm on a 0.4 mm nozzle, scaled with it); Strong
5
+ * and Max set walls and infill. Standard sends nothing: the resolved preset decides. An explicit
6
+ * layer_height, wall_loops or sparse_infill_density in the same call wins over these.
7
+ */
8
+ export declare const PRINT_QUALITIES: readonly ["draft", "standard", "fine"];
9
+ export declare const PRINT_STRENGTHS: readonly ["standard", "strong", "max"];
10
+ export type PrintQuality = (typeof PRINT_QUALITIES)[number];
11
+ export type PrintStrength = (typeof PRINT_STRENGTHS)[number];
12
+ /** The call's arguments with `quality` and `strength` written out as slicer settings. */
13
+ export declare function withPrintQuality(args: Record<string, unknown>, nozzleDiameter?: number): Record<string, unknown>;
14
+ /** slice_stl input-schema properties for both arguments. */
15
+ export declare const PRINT_QUALITY_PROPERTIES: {
16
+ readonly quality: {
17
+ readonly type: "string";
18
+ readonly enum: readonly ["draft", "standard", "fine"];
19
+ readonly description: "Print quality: draft (thicker layers, faster), standard (the preset's own layer height) or fine (thinner layers). The server picks the layer height for the nozzle in use; an explicit layer_height wins.";
20
+ };
21
+ readonly strength: {
22
+ readonly type: "string";
23
+ readonly enum: readonly ["standard", "strong", "max"];
24
+ readonly description: "Part strength: standard (the preset's walls and infill), strong or max (more walls and denser infill). The server picks the values; explicit wall_loops or sparse_infill_density win.";
25
+ };
26
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * slice_stl's `quality` and `strength` (PRD §8.4): a client's print-sheet choice, mapped here to the
3
+ * slicer's own numbers so no client carries layer heights, wall counts or infill. Draft and Fine set
4
+ * the layer height for the nozzle in use (0.28 and 0.12 mm on a 0.4 mm nozzle, scaled with it); Strong
5
+ * and Max set walls and infill. Standard sends nothing: the resolved preset decides. An explicit
6
+ * layer_height, wall_loops or sparse_infill_density in the same call wins over these.
7
+ */
8
+ export const PRINT_QUALITIES = ["draft", "standard", "fine"];
9
+ export const PRINT_STRENGTHS = ["standard", "strong", "max"];
10
+ /** Layer heights on the reference 0.4 mm nozzle; other nozzles scale from it. */
11
+ const REFERENCE_NOZZLE_MM = 0.4;
12
+ const LAYER_MM_ON_REFERENCE = { draft: 0.28, fine: 0.12 };
13
+ const STRENGTH = {
14
+ strong: { wall_loops: 4, sparse_infill_density: "25%" },
15
+ max: { wall_loops: 6, sparse_infill_density: "40%" },
16
+ };
17
+ /** The call's arguments with `quality` and `strength` written out as slicer settings. */
18
+ export function withPrintQuality(args, nozzleDiameter) {
19
+ if (args.quality === undefined && args.strength === undefined)
20
+ return args;
21
+ const quality = args.quality === undefined ? "standard" : args.quality;
22
+ const strength = args.strength === undefined ? "standard" : args.strength;
23
+ if (typeof quality !== "string" || !PRINT_QUALITIES.includes(quality)) {
24
+ throw new Error(`quality must be one of ${PRINT_QUALITIES.join(", ")}.`);
25
+ }
26
+ if (typeof strength !== "string" || !PRINT_STRENGTHS.includes(strength)) {
27
+ throw new Error(`strength must be one of ${PRINT_STRENGTHS.join(", ")}.`);
28
+ }
29
+ const out = { ...args };
30
+ const scale = typeof nozzleDiameter === "number" && Number.isFinite(nozzleDiameter) && nozzleDiameter > 0 ? nozzleDiameter / REFERENCE_NOZZLE_MM : 1;
31
+ const layer = LAYER_MM_ON_REFERENCE[quality];
32
+ if (layer !== undefined && out.layer_height === undefined)
33
+ out.layer_height = Math.round(layer * scale * 100) / 100;
34
+ const walls = STRENGTH[strength];
35
+ if (walls) {
36
+ if (out.wall_loops === undefined)
37
+ out.wall_loops = walls.wall_loops;
38
+ if (out.sparse_infill_density === undefined)
39
+ out.sparse_infill_density = walls.sparse_infill_density;
40
+ }
41
+ return out;
42
+ }
43
+ /** slice_stl input-schema properties for both arguments. */
44
+ export const PRINT_QUALITY_PROPERTIES = {
45
+ quality: {
46
+ type: "string", enum: [...PRINT_QUALITIES],
47
+ description: "Print quality: draft (thicker layers, faster), standard (the preset's own layer height) or fine (thinner layers). The server picks the layer height for the nozzle in use; an explicit layer_height wins.",
48
+ },
49
+ strength: {
50
+ type: "string", enum: [...PRINT_STRENGTHS],
51
+ description: "Part strength: standard (the preset's walls and infill), strong or max (more walls and denser infill). The server picks the values; explicit wall_loops or sparse_infill_density win.",
52
+ },
53
+ };
@@ -1019,7 +1019,7 @@ export class STLManipulator extends EventEmitter {
1019
1019
  break;
1020
1020
  case 'cura':
1021
1021
  if (Object.keys(orcaProcessOverrides(processOverrides)).length) {
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 });
1022
+ throw new SlicerError('preparation', 'quality, strength, 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 });
1023
1023
  }
1024
1024
  // CuraEngine CLI args are different, often requiring -s for settings
1025
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.28",
4
+ "version": "1.2.30",
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.28",
10
+ "version": "1.2.30",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "mcp-3d-printer-server",
15
- "version": "1.2.28",
15
+ "version": "1.2.30",
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
@@ -36,6 +36,7 @@ import * as THREE from 'three';
36
36
  import { PrinterFactory } from "./printers/printer-factory.js";
37
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
+ import { PRINT_QUALITY_PROPERTIES, withPrintQuality } from "./slicer/print-quality.js";
39
40
  import { CounterpartService, type BambuControlRoute, type PrinterTarget } from "./counterpart/service.js";
40
41
  import {CAMERA_STREAM_TOOLS} from "./counterpart/camera-tools.js";
41
42
  import { annotateTools } from "./tool-annotations.js";
@@ -75,7 +76,7 @@ import { BambuNetworkBridge, describeBambuNetworkPrintError, type BridgeJobUpdat
75
76
  import { PRINT_ROUTES, connectionForRoute, routeOf, routeOptions, writeEnvValues, type PrintRoute } from "./counterpart/route.js";
76
77
  import { resolveStudioPrintOptions, usedNozzleCount, type CallerPrintOptions } from "./studio-print-options.js";
77
78
  import { inspectPrintFile } from "./safety/print-file.js";
78
- import { validatePrinterState } from "./safety/printer-state.js";
79
+ import { externalStorageState, validatePrinterState } from "./safety/printer-state.js";
79
80
  import { uniquePrintName, withPrinterOperation, withPrintSnapshot } from "./safety/artifact.js";
80
81
  import { checkedFile } from "./printers/bambu.js";
81
82
  import { CONFIRMATION_UNAVAILABLE, confirmationTimedOut, confirmationTimeoutMs } from "./safety/confirmation.js";
@@ -384,6 +385,7 @@ const SLICE_TOOL_PROPERTIES = {
384
385
  min_save: { type: "boolean", description: "Bambu-compatible slicing: write a smaller output 3MF (--min-save)." },
385
386
  skip_modified_gcodes: { type: "boolean", description: "Bambu-compatible slicing: ignore custom G-code embedded in an input 3MF (--skip-modified-gcodes)." },
386
387
  slice_plate: { type: "number", description: "Bambu-compatible slicing: plate number to slice; 0 slices all plates (default)." },
388
+ ...PRINT_QUALITY_PROPERTIES,
387
389
  layer_height: {
388
390
  type: "number", exclusiveMinimum: 0, maximum: 2,
389
391
  description: "Layer height in mm, written over the selected process profile (Orca/Bambu Studio layer_height; PrusaSlicer --layer-height). Not mapped for Cura.",
@@ -904,7 +906,7 @@ class ThreeDPrinterMCPServer {
904
906
  if (!args?.stl_path) {
905
907
  throw new Error("Missing required parameter: stl_path");
906
908
  }
907
- const processOverrides = parseProcessOverrides(args);
909
+ const processOverrides = parseProcessOverrides(withPrintQuality(args, Number(args.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER)));
908
910
  const slicerType = normalizeSlicerType(String(args.slicer_type || DEFAULT_SLICER_TYPE));
909
911
  const explicitModel =
910
912
  typeof args.bambu_model === "string" && args.bambu_model.trim() ? args.bambu_model : undefined;
@@ -1074,8 +1076,10 @@ class ThreeDPrinterMCPServer {
1074
1076
  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.");
1075
1077
  }
1076
1078
  const isLocalBridgePrint = connectionType === "lan";
1077
- const bridgePrintMethod = isLocalBridgePrint ? "start_local_print" : "start_print";
1078
- const bridgeMethod = `net.${bridgePrintMethod}`;
1079
+ // Bambu Studio sends a cloud-bound printer it can reach on the LAN with record first, in the cloud
1080
+ // session, then start_print (PrintJob.cpp). This server always has the printer's IP and access code.
1081
+ let bridgeMethod = isLocalBridgePrint ? "net.start_local_print" : "net.start_local_print_with_record";
1082
+ let cloudFallbackMethod = isLocalBridgePrint ? undefined : "net.start_print";
1079
1083
  const { plateIndex } = options;
1080
1084
  const threeMfFilename = path.basename(options.threeMFPath);
1081
1085
  const projectName = threeMfFilename.replace(/\.3mf$/i, "");
@@ -1116,6 +1120,11 @@ class ThreeDPrinterMCPServer {
1116
1120
  validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
1117
1121
  const confirmedStatus = await bambu.getSafetyStatus(devIp, devId, password);
1118
1122
  validatePrinterState(confirmedStatus, requirements);
1123
+ if (cloudFallbackMethod && externalStorageState(confirmedStatus.raw) !== "normal") {
1124
+ // Studio sends straight through the cloud when the printer has no storage for the LAN upload.
1125
+ bridgeMethod = cloudFallbackMethod;
1126
+ cloudFallbackMethod = undefined;
1127
+ }
1119
1128
  const wireMapping = mapping!.map((value) => bridgeAmsMappingValue(value, dualNozzle));
1120
1129
  const remoteFileName = uniquePrintName(options.threeMFPath);
1121
1130
  const checkedName = remoteFileName.replace(/(?:\.gcode)?\.3mf$/i, "");
@@ -1172,22 +1181,28 @@ class ThreeDPrinterMCPServer {
1172
1181
  const bridgeOptions = {
1173
1182
  // Trusted in-process preflight immediately before the print frame.
1174
1183
  beforeDispatch: async (method: string) => {
1175
- if (method !== bridgeMethod) return;
1184
+ if (method !== bridgeMethod && method !== cloudFallbackMethod) return;
1176
1185
  assertActive();
1177
1186
  validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
1178
1187
  },
1179
- assertDispatchAllowed: (method: string) => { if (method === bridgeMethod) assertActive(); },
1180
- onDispatched: (method: string) => { if (method === bridgeMethod) retainForDispatchedUpload(); },
1188
+ assertDispatchAllowed: (method: string) => { if (method === bridgeMethod || method === cloudFallbackMethod) assertActive(); },
1189
+ onDispatched: (method: string) => { if (method === bridgeMethod || method === cloudFallbackMethod) retainForDispatchedUpload(); },
1181
1190
  };
1182
1191
  let jobUpdates: BridgeJobUpdate[] = [];
1183
1192
  let bridgeResult: unknown;
1193
+ let firstAttempt = "";
1184
1194
  if (isLocalBridgePrint) {
1185
1195
  ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startLocalPrint(
1186
1196
  { devId, devIp, username: "bblp", password, useSsl: true },
1187
1197
  bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive
1188
1198
  ));
1189
1199
  } else {
1190
- ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startCloudPrint(devId, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive));
1200
+ let firstFailure: number | undefined;
1201
+ ({ result: bridgeResult, jobUpdates, firstFailure } = await this.bambuNetwork.startCloudPrint(devId, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive, undefined, cloudFallbackMethod));
1202
+ if (firstFailure !== undefined) {
1203
+ bridgeMethod = cloudFallbackMethod!;
1204
+ firstAttempt = ` It first tried net.start_local_print_with_record, as Bambu Studio does, which returned ${firstFailure}${describeBambuNetworkPrintError(firstFailure)}.`;
1205
+ }
1191
1206
  }
1192
1207
  if (typeof bridgeResult === "object" && bridgeResult !== null && (bridgeResult as { ok?: unknown }).ok === false) {
1193
1208
  throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} failed: ${String((bridgeResult as { error?: unknown }).error || "unknown bridge error")}`);
@@ -1197,14 +1212,14 @@ class ThreeDPrinterMCPServer {
1197
1212
  const reported = jobUpdates.filter((update) => update.code !== 0 || update.msg).slice(-3)
1198
1213
  .map((update) => `status ${update.status}, code ${update.code}${update.msg ? `: ${update.msg}` : ""}`).join("; ");
1199
1214
  throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} returned non-zero result ${value}${describeBambuNetworkPrintError(value)}.` +
1200
- (reported ? ` Plug-in reported: ${reported}.` : ""));
1215
+ (reported ? ` Plug-in reported: ${reported}.` : "") + firstAttempt);
1201
1216
  }
1202
1217
  assertActive();
1203
1218
  // Bambu's plug-in placed the file, so a resume re-reads it if it can and otherwise asks a person.
1204
1219
  bambu.recordCheckedJob(devIp, devId, remoteFileName, requirements, resumeFile, false);
1205
1220
  return {
1206
1221
  status: "success",
1207
- message: `FULU BambuNetwork ${bridgePrintMethod} command for ${threeMfFilename} sent successfully.`,
1222
+ message: `FULU BambuNetwork ${bridgeMethod.replace(/^net\./, "")} command for ${threeMfFilename} sent successfully.${firstAttempt}`,
1208
1223
  connectionMode: "bambu_network",
1209
1224
  connectionType,
1210
1225
  bridgeMethod,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * slice_stl's `quality` and `strength` (PRD §8.4): a client's print-sheet choice, mapped here to the
3
+ * slicer's own numbers so no client carries layer heights, wall counts or infill. Draft and Fine set
4
+ * the layer height for the nozzle in use (0.28 and 0.12 mm on a 0.4 mm nozzle, scaled with it); Strong
5
+ * and Max set walls and infill. Standard sends nothing: the resolved preset decides. An explicit
6
+ * layer_height, wall_loops or sparse_infill_density in the same call wins over these.
7
+ */
8
+ export const PRINT_QUALITIES = ["draft", "standard", "fine"] as const;
9
+ export const PRINT_STRENGTHS = ["standard", "strong", "max"] as const;
10
+
11
+ export type PrintQuality = (typeof PRINT_QUALITIES)[number];
12
+ export type PrintStrength = (typeof PRINT_STRENGTHS)[number];
13
+
14
+ /** Layer heights on the reference 0.4 mm nozzle; other nozzles scale from it. */
15
+ const REFERENCE_NOZZLE_MM = 0.4;
16
+ const LAYER_MM_ON_REFERENCE: Partial<Record<PrintQuality, number>> = { draft: 0.28, fine: 0.12 };
17
+ const STRENGTH: Partial<Record<PrintStrength, { wall_loops: number; sparse_infill_density: string }>> = {
18
+ strong: { wall_loops: 4, sparse_infill_density: "25%" },
19
+ max: { wall_loops: 6, sparse_infill_density: "40%" },
20
+ };
21
+
22
+ /** The call's arguments with `quality` and `strength` written out as slicer settings. */
23
+ export function withPrintQuality(args: Record<string, unknown>, nozzleDiameter?: number): Record<string, unknown> {
24
+ if (args.quality === undefined && args.strength === undefined) return args;
25
+ const quality = args.quality === undefined ? "standard" : args.quality;
26
+ const strength = args.strength === undefined ? "standard" : args.strength;
27
+ if (typeof quality !== "string" || !(PRINT_QUALITIES as readonly string[]).includes(quality)) {
28
+ throw new Error(`quality must be one of ${PRINT_QUALITIES.join(", ")}.`);
29
+ }
30
+ if (typeof strength !== "string" || !(PRINT_STRENGTHS as readonly string[]).includes(strength)) {
31
+ throw new Error(`strength must be one of ${PRINT_STRENGTHS.join(", ")}.`);
32
+ }
33
+ const out: Record<string, unknown> = { ...args };
34
+ const scale = typeof nozzleDiameter === "number" && Number.isFinite(nozzleDiameter) && nozzleDiameter > 0 ? nozzleDiameter / REFERENCE_NOZZLE_MM : 1;
35
+ const layer = LAYER_MM_ON_REFERENCE[quality as PrintQuality];
36
+ if (layer !== undefined && out.layer_height === undefined) out.layer_height = Math.round(layer * scale * 100) / 100;
37
+ const walls = STRENGTH[strength as PrintStrength];
38
+ if (walls) {
39
+ if (out.wall_loops === undefined) out.wall_loops = walls.wall_loops;
40
+ if (out.sparse_infill_density === undefined) out.sparse_infill_density = walls.sparse_infill_density;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** slice_stl input-schema properties for both arguments. */
46
+ export const PRINT_QUALITY_PROPERTIES = {
47
+ quality: {
48
+ type: "string", enum: [...PRINT_QUALITIES],
49
+ description: "Print quality: draft (thicker layers, faster), standard (the preset's own layer height) or fine (thinner layers). The server picks the layer height for the nozzle in use; an explicit layer_height wins.",
50
+ },
51
+ strength: {
52
+ type: "string", enum: [...PRINT_STRENGTHS],
53
+ description: "Part strength: standard (the preset's walls and infill), strong or max (more walls and denser infill). The server picks the values; explicit wall_loops or sparse_infill_density win.",
54
+ },
55
+ } as const;
@@ -1317,7 +1317,7 @@ export class STLManipulator extends EventEmitter {
1317
1317
  case 'cura':
1318
1318
  if (Object.keys(orcaProcessOverrides(processOverrides)).length) {
1319
1319
  throw new SlicerError('preparation',
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.',
1320
+ 'quality, strength, 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.',
1321
1321
  { slicerType, slicerPath });
1322
1322
  }
1323
1323
  // CuraEngine CLI args are different, often requiring -s for settings