mcp-3d-printer-server 1.2.2 → 1.2.4

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.
@@ -2,6 +2,9 @@ import { PrinterImplementation } from "../types.js";
2
2
  export declare class PrusaImplementation extends PrinterImplementation {
3
3
  private buildAuthHeaders;
4
4
  private buildBaseUrl;
5
+ private defaultProtocolFor;
6
+ private isDefaultPort;
7
+ private isPrusaConnectCloudHost;
5
8
  private isFallbackStatus;
6
9
  private getWithFallback;
7
10
  private postWithFallback;
@@ -5,12 +5,43 @@ import FormData from "form-data";
5
5
  export class PrusaImplementation extends PrinterImplementation {
6
6
  buildAuthHeaders(apiKey) {
7
7
  return {
8
+ Accept: "application/json",
8
9
  "X-Api-Key": apiKey,
9
10
  Authorization: `Bearer ${apiKey}`,
10
11
  };
11
12
  }
12
13
  buildBaseUrl(host, port) {
13
- return `http://${host}:${port}`;
14
+ const trimmedHost = host.trim();
15
+ const trimmedPort = port.trim();
16
+ if (!trimmedHost) {
17
+ throw new Error("Prusa host is required.");
18
+ }
19
+ const rawBaseUrl = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmedHost)
20
+ ? trimmedHost
21
+ : `${this.defaultProtocolFor(trimmedHost, trimmedPort)}://${trimmedHost}`;
22
+ const parsed = new URL(rawBaseUrl);
23
+ if (trimmedPort && !parsed.port && !this.isDefaultPort(parsed.protocol, trimmedPort)) {
24
+ parsed.port = trimmedPort;
25
+ }
26
+ return parsed.toString().replace(/\/$/, "");
27
+ }
28
+ defaultProtocolFor(host, port) {
29
+ if (port === "443" || this.isPrusaConnectCloudHost(host)) {
30
+ return "https";
31
+ }
32
+ return "http";
33
+ }
34
+ isDefaultPort(protocol, port) {
35
+ return (protocol === "https:" && port === "443") || (protocol === "http:" && port === "80");
36
+ }
37
+ isPrusaConnectCloudHost(host) {
38
+ try {
39
+ const parsed = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(host) ? host : `https://${host}`);
40
+ return parsed.hostname.toLowerCase() === "connect.prusa3d.com";
41
+ }
42
+ catch {
43
+ return host.toLowerCase().split(":")[0] === "connect.prusa3d.com";
44
+ }
14
45
  }
15
46
  isFallbackStatus(error) {
16
47
  if (!isAxiosError(error)) {
@@ -61,6 +92,7 @@ export class PrusaImplementation extends PrinterImplementation {
61
92
  }
62
93
  async getStatus(host, port, apiKey) {
63
94
  const response = await this.getWithFallback(host, port, apiKey, [
95
+ "/api/v1/status",
64
96
  "/api/v1/printer",
65
97
  "/api/printer",
66
98
  ]);
@@ -7,6 +7,7 @@ export type OperationResult = {
7
7
  error?: string;
8
8
  operationId: string;
9
9
  };
10
+ export type SlicerType = 'prusaslicer' | 'cura' | 'slic3r' | 'orcaslicer' | 'orcaslicer-bambulab' | 'bambustudio';
10
11
  export type TransformationType = 'scale' | 'rotate' | 'translate' | 'extendBase' | 'customModify';
11
12
  export type TransformationAxis = 'x' | 'y' | 'z' | 'all';
12
13
  export type BoundingBox = {
@@ -30,6 +31,14 @@ export declare class STLManipulator extends EventEmitter {
30
31
  * Generate a unique operation ID
31
32
  */
32
33
  private generateOperationId;
34
+ private validateSlicerExecutable;
35
+ private splitProfileList;
36
+ private parseOrcaProfileArgs;
37
+ private warnForMissingProfiles;
38
+ private appendOrcaProfileArgs;
39
+ private resolveOrcaGcodeOutput;
40
+ private resolveBambuMachinePresetPath;
41
+ private resolveSlicerTimeoutMs;
33
42
  /**
34
43
  * Load STL file and return geometry and bounding box
35
44
  */
@@ -92,13 +101,13 @@ export declare class STLManipulator extends EventEmitter {
92
101
  /**
93
102
  * Slice an STL or 3MF file using the specified slicer
94
103
  * @param stlFilePath Path to the input STL or 3MF file
95
- * @param slicerType Type of slicer (prusaslicer, cura, slic3r, orcaslicer, bambustudio)
104
+ * @param slicerType Type of slicer (prusaslicer, cura, slic3r, orcaslicer, orcaslicer-bambulab, bambustudio)
96
105
  * @param slicerPath Path to the slicer executable
97
106
  * @param slicerProfile Optional path to the slicer profile/config file
98
107
  * @param progressCallback Optional callback for progress updates
99
108
  * @returns Path to the generated G-code or sliced 3MF file
100
109
  */
101
- sliceSTL(stlFilePath: string, slicerType: 'prusaslicer' | 'cura' | 'slic3r' | 'orcaslicer' | 'bambustudio', slicerPath: string, slicerProfile?: string, progressCallback?: ProgressCallback, printerPreset?: string): Promise<string>;
110
+ sliceSTL(stlFilePath: string, slicerType: SlicerType, slicerPath: string, slicerProfile?: string, progressCallback?: ProgressCallback, printerPreset?: string, filamentProfile?: string): Promise<string>;
102
111
  /**
103
112
  * Enhanced version of confirmTemperatures with better error handling
104
113
  * @param gcodePath Path to the G-code file
@@ -10,6 +10,7 @@ import * as crypto from 'crypto';
10
10
  import { execFile } from 'child_process';
11
11
  const readFileAsync = promisify(fs.readFile);
12
12
  const writeFileAsync = promisify(fs.writeFile);
13
+ const DEFAULT_SLICER_TIMEOUT_MS = 10 * 60 * 1000;
13
14
  export class STLManipulator extends EventEmitter {
14
15
  constructor(tempDir = path.join(process.cwd(), 'temp')) {
15
16
  super();
@@ -26,6 +27,97 @@ export class STLManipulator extends EventEmitter {
26
27
  generateOperationId() {
27
28
  return crypto.randomUUID();
28
29
  }
30
+ validateSlicerExecutable(slicerPath) {
31
+ if (!slicerPath.trim()) {
32
+ throw new Error("Slicer executable path is required.");
33
+ }
34
+ const looksLikePath = path.isAbsolute(slicerPath) || /[\\/]/.test(slicerPath);
35
+ if (looksLikePath && !fs.existsSync(slicerPath)) {
36
+ throw new Error(`Slicer executable not found at: ${slicerPath}`);
37
+ }
38
+ }
39
+ splitProfileList(profileList) {
40
+ return (profileList || "")
41
+ .split(";")
42
+ .map((profile) => profile.trim())
43
+ .filter((profile) => profile.length > 0);
44
+ }
45
+ parseOrcaProfileArgs(slicerProfile, filamentProfile) {
46
+ const [settingsSegment = "", filamentSegment = ""] = (slicerProfile || "").split("|", 2);
47
+ return {
48
+ settingsProfiles: this.splitProfileList(settingsSegment),
49
+ filamentProfiles: [
50
+ ...this.splitProfileList(filamentSegment),
51
+ ...this.splitProfileList(filamentProfile),
52
+ ],
53
+ };
54
+ }
55
+ warnForMissingProfiles(profiles, label) {
56
+ for (const profile of profiles) {
57
+ if (!fs.existsSync(profile)) {
58
+ console.warn(`${label} specified but not found: ${profile}. Slicer might use defaults.`);
59
+ }
60
+ }
61
+ }
62
+ appendOrcaProfileArgs(args, profiles) {
63
+ for (const profile of profiles.settingsProfiles) {
64
+ args.push('--load-settings', profile);
65
+ }
66
+ for (const profile of profiles.filamentProfiles) {
67
+ args.push('--load-filaments', profile);
68
+ }
69
+ }
70
+ resolveOrcaGcodeOutput(outputDir) {
71
+ const preferredPath = path.join(outputDir, 'plate_1.gcode');
72
+ if (fs.existsSync(preferredPath)) {
73
+ return preferredPath;
74
+ }
75
+ const plateOutputs = fs.readdirSync(outputDir)
76
+ .filter((entry) => /^plate_\d+\.gcode$/i.test(entry))
77
+ .sort((a, b) => {
78
+ const aNumber = Number(a.match(/\d+/)?.[0] || 0);
79
+ const bNumber = Number(b.match(/\d+/)?.[0] || 0);
80
+ return aNumber - bNumber;
81
+ });
82
+ if (plateOutputs.length === 0) {
83
+ throw new Error(`OrcaSlicer finished but no plate_N.gcode file was found in: ${outputDir}`);
84
+ }
85
+ return path.join(outputDir, plateOutputs[0]);
86
+ }
87
+ resolveBambuMachinePresetPath(slicerPath, printerPreset) {
88
+ if (fs.existsSync(printerPreset)) {
89
+ return printerPreset;
90
+ }
91
+ const presetFile = printerPreset.endsWith(".json") ? printerPreset : `${printerPreset}.json`;
92
+ const candidateDirs = [];
93
+ const macAppMarker = `${path.sep}Contents${path.sep}MacOS${path.sep}`;
94
+ const macAppIndex = slicerPath.indexOf(macAppMarker);
95
+ if (macAppIndex >= 0) {
96
+ candidateDirs.push(path.join(slicerPath.slice(0, macAppIndex + `${path.sep}Contents`.length), "Resources", "profiles", "BBL", "machine"));
97
+ }
98
+ const executableDir = path.dirname(slicerPath);
99
+ candidateDirs.push(path.join(executableDir, "..", "Resources", "profiles", "BBL", "machine"), path.join(executableDir, "..", "share", "OrcaSlicer", "resources", "profiles", "BBL", "machine"), path.join(executableDir, "..", "resources", "profiles", "BBL", "machine"), path.join(executableDir, "resources", "profiles", "BBL", "machine"));
100
+ for (const candidateDir of candidateDirs) {
101
+ const candidate = path.resolve(candidateDir, presetFile);
102
+ if (fs.existsSync(candidate)) {
103
+ return candidate;
104
+ }
105
+ }
106
+ throw new Error(`Could not find Bambu machine preset "${presetFile}" near slicer executable ${slicerPath}. ` +
107
+ "Pass slicer_profile with an explicit machine/process settings file, or use a slicer bundle that includes Resources/profiles/BBL/machine.");
108
+ }
109
+ resolveSlicerTimeoutMs() {
110
+ const rawTimeout = process.env.SLICER_TIMEOUT_MS?.trim();
111
+ if (!rawTimeout) {
112
+ return DEFAULT_SLICER_TIMEOUT_MS;
113
+ }
114
+ const timeoutMs = Number(rawTimeout);
115
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
116
+ console.warn(`Invalid SLICER_TIMEOUT_MS value "${rawTimeout}", using ${DEFAULT_SLICER_TIMEOUT_MS}.`);
117
+ return DEFAULT_SLICER_TIMEOUT_MS;
118
+ }
119
+ return timeoutMs;
120
+ }
29
121
  /**
30
122
  * Load STL file and return geometry and bounding box
31
123
  */
@@ -646,24 +738,31 @@ export class STLManipulator extends EventEmitter {
646
738
  /**
647
739
  * Slice an STL or 3MF file using the specified slicer
648
740
  * @param stlFilePath Path to the input STL or 3MF file
649
- * @param slicerType Type of slicer (prusaslicer, cura, slic3r, orcaslicer, bambustudio)
741
+ * @param slicerType Type of slicer (prusaslicer, cura, slic3r, orcaslicer, orcaslicer-bambulab, bambustudio)
650
742
  * @param slicerPath Path to the slicer executable
651
743
  * @param slicerProfile Optional path to the slicer profile/config file
652
744
  * @param progressCallback Optional callback for progress updates
653
745
  * @returns Path to the generated G-code or sliced 3MF file
654
746
  */
655
- async sliceSTL(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset) {
747
+ async sliceSTL(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile) {
656
748
  const operationId = this.generateOperationId();
657
749
  this.activeOperations.set(operationId, true);
658
- if (!fs.existsSync(slicerPath)) {
659
- throw new Error(`Slicer executable not found at: ${slicerPath}`);
750
+ this.validateSlicerExecutable(slicerPath);
751
+ const orcaProfiles = this.parseOrcaProfileArgs(slicerProfile, filamentProfile);
752
+ if (slicerType === 'orcaslicer') {
753
+ this.warnForMissingProfiles(orcaProfiles.settingsProfiles, 'Slicer profile');
754
+ this.warnForMissingProfiles(orcaProfiles.filamentProfiles, 'Filament profile');
660
755
  }
661
- if (slicerProfile && !fs.existsSync(slicerProfile)) {
756
+ else if (slicerProfile && !fs.existsSync(slicerProfile)) {
662
757
  console.warn(`Slicer profile specified but not found: ${slicerProfile}. Slicer might use defaults.`);
663
758
  // Allow proceeding without profile, slicer might handle it
664
759
  }
760
+ if (slicerType !== 'orcaslicer' && filamentProfile && !fs.existsSync(filamentProfile)) {
761
+ console.warn(`Filament profile specified but not found: ${filamentProfile}. Slicer might use defaults.`);
762
+ }
665
763
  const outputFileName = path.basename(stlFilePath, '.stl') + '.gcode';
666
764
  let outputFilePath = path.join(this.tempDir, outputFileName);
765
+ let orcaOutputDir;
667
766
  let args = [];
668
767
  try {
669
768
  if (progressCallback)
@@ -681,22 +780,14 @@ export class STLManipulator extends EventEmitter {
681
780
  args = args.filter(arg => arg !== '');
682
781
  break;
683
782
  case 'orcaslicer': // Add OrcaSlicer case
783
+ orcaOutputDir = path.join(this.tempDir, `${operationId}-orca-output`);
784
+ fs.mkdirSync(orcaOutputDir, { recursive: true });
684
785
  args = [
685
- // OrcaSlicer might use --load-settings like Bambu, or --load like Prusa
686
- // Assuming --load-settings based on discussion, adjust if needed.
687
- // Also assuming profile path points to the main .ini or .json config.
688
- '--load-settings', slicerProfile || '',
689
- '--output', outputFilePath, // Assuming --output works like PrusaSlicer
690
- stlFilePath
786
+ '--slice', '0',
787
+ '--outputdir', orcaOutputDir,
691
788
  ];
692
- // Alternative if --output doesn't specify filename:
693
- // args = [
694
- // '--load-settings', slicerProfile || '',
695
- // '--outputdir', this.tempDir,
696
- // stlFilePath
697
- // ];
698
- // Remove empty profile arg if not provided
699
- args = args.filter(arg => arg !== '');
789
+ this.appendOrcaProfileArgs(args, orcaProfiles);
790
+ args.push(stlFilePath);
700
791
  break;
701
792
  case 'cura':
702
793
  // CuraEngine CLI args are different, often requiring -s for settings
@@ -712,8 +803,10 @@ export class STLManipulator extends EventEmitter {
712
803
  args.push('-j', slicerProfile); // Load settings from profile definition file
713
804
  }
714
805
  break;
806
+ case 'orcaslicer-bambulab':
715
807
  case 'bambustudio':
716
- // Bambu Studio CLI: slice and export as 3MF with embedded gcode
808
+ // Bambu project slicers: slice and export as 3MF with embedded gcode.
809
+ // orcaslicer-bambulab is the FULU fork that restores BambuNetwork support.
717
810
  // For 3MF input: slice in place and export sliced 3MF
718
811
  // For STL input: slice and export as 3MF
719
812
  {
@@ -724,13 +817,16 @@ export class STLManipulator extends EventEmitter {
724
817
  '--slice', '0', // Slice all plates
725
818
  '--export-3mf', bambuOutputPath,
726
819
  ];
727
- // --load-machine ensures correct G-code for the specific printer model
820
+ // FULU Orca/Bambu Studio load machine presets as JSON settings files.
728
821
  if (printerPreset && !slicerProfile) {
729
- args.push('--load-machine', printerPreset);
822
+ args.push('--load-settings', this.resolveBambuMachinePresetPath(slicerPath, printerPreset));
730
823
  }
731
824
  if (slicerProfile) {
732
825
  args.push('--load-settings', slicerProfile);
733
826
  }
827
+ if (filamentProfile) {
828
+ args.push('--load-filaments', filamentProfile);
829
+ }
734
830
  args.push(stlFilePath);
735
831
  // Override outputFilePath for bambustudio since it produces 3MF, not gcode
736
832
  outputFilePath = bambuOutputPath;
@@ -744,20 +840,47 @@ export class STLManipulator extends EventEmitter {
744
840
  console.log(`Executing: ${slicerPath} ${args.join(' ')}`);
745
841
  // Execute the slicer
746
842
  await new Promise((resolve, reject) => {
843
+ const timeoutMs = this.resolveSlicerTimeoutMs();
844
+ let settled = false;
845
+ let slicerProcessKilledByTimeout = false;
846
+ let timeoutHandle;
847
+ const settle = (callback) => {
848
+ if (settled) {
849
+ return;
850
+ }
851
+ settled = true;
852
+ if (timeoutHandle) {
853
+ clearTimeout(timeoutHandle);
854
+ }
855
+ callback();
856
+ };
747
857
  const process = execFile(slicerPath, args, (error, stdout, stderr) => {
748
858
  if (error) {
749
859
  console.error(`Slicer Error: ${error.message}`);
750
860
  console.error(`Slicer Stderr: ${stderr}`);
751
- reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
861
+ const timeoutMessage = slicerProcessKilledByTimeout
862
+ ? ` Slicer exceeded timeout ${timeoutMs}ms.`
863
+ : "";
864
+ settle(() => reject(new Error(`Slicer failed: ${error.message}.${timeoutMessage} Stderr: ${stderr}`)));
752
865
  }
753
866
  else {
754
867
  console.log(`Slicer Stdout: ${stdout}`);
755
868
  if (stderr) {
756
869
  console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
757
870
  }
758
- resolve();
871
+ settle(() => resolve());
759
872
  }
760
873
  });
874
+ timeoutHandle = setTimeout(() => {
875
+ slicerProcessKilledByTimeout = true;
876
+ try {
877
+ process.kill("SIGKILL");
878
+ }
879
+ catch (killError) {
880
+ console.error("Error attempting to kill timed-out slicer process:", killError);
881
+ }
882
+ settle(() => reject(new Error(`Slicer exceeded timeout ${timeoutMs}ms.`)));
883
+ }, timeoutMs);
761
884
  // Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
762
885
  // process.stdout?.on('data', (data) => { console.log(`Slicer stdout: ${data}`); });
763
886
  // process.stderr?.on('data', (data) => { console.log(`Slicer stderr: ${data}`); });
@@ -767,11 +890,11 @@ export class STLManipulator extends EventEmitter {
767
890
  clearInterval(checkCancel);
768
891
  try {
769
892
  process.kill(); // Attempt to kill the slicer process
770
- reject(new Error("Slicing operation cancelled"));
893
+ settle(() => reject(new Error("Slicing operation cancelled")));
771
894
  }
772
895
  catch (killError) {
773
896
  console.error("Error attempting to kill slicer process:", killError);
774
- reject(new Error("Slicing operation cancelled, but failed to kill process."));
897
+ settle(() => reject(new Error("Slicing operation cancelled, but failed to kill process.")));
775
898
  }
776
899
  }
777
900
  }, 500);
@@ -782,6 +905,16 @@ export class STLManipulator extends EventEmitter {
782
905
  // This check might be redundant if the promise rejected on kill, but good safety
783
906
  throw new Error("Slicing operation cancelled after process finished");
784
907
  }
908
+ if (slicerType === 'orcaslicer') {
909
+ if (!orcaOutputDir) {
910
+ throw new Error("Internal error: OrcaSlicer output directory was not initialized.");
911
+ }
912
+ const orcaGcodePath = this.resolveOrcaGcodeOutput(orcaOutputDir);
913
+ if (fs.existsSync(outputFilePath)) {
914
+ fs.unlinkSync(outputFilePath);
915
+ }
916
+ fs.renameSync(orcaGcodePath, outputFilePath);
917
+ }
785
918
  if (!fs.existsSync(outputFilePath)) {
786
919
  throw new Error(`Slicer finished but output file not found: ${outputFilePath}`);
787
920
  }
@@ -805,6 +938,9 @@ export class STLManipulator extends EventEmitter {
805
938
  throw error;
806
939
  }
807
940
  finally {
941
+ if (orcaOutputDir && fs.existsSync(orcaOutputDir)) {
942
+ fs.rmSync(orcaOutputDir, { recursive: true, force: true });
943
+ }
808
944
  this.activeOperations.delete(operationId);
809
945
  }
810
946
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-3d-printer-server",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "MCP server for connecting Claude with 3D printer management systems",
5
5
  "main": "dist/index.js",
6
6
  "repository": "https://github.com/DMontgomery40/mcp-3D-printer-server",
@@ -18,8 +18,28 @@
18
18
  "mcp-3d-printer-server": "./dist/index.js"
19
19
  },
20
20
  "files": [
21
- "dist",
22
- "src",
21
+ "dist/3mf_parser.d.ts",
22
+ "dist/3mf_parser.js",
23
+ "dist/fulu-orca.d.ts",
24
+ "dist/fulu-orca.js",
25
+ "dist/index.d.ts",
26
+ "dist/index.js",
27
+ "dist/index.js.map",
28
+ "dist/printers",
29
+ "dist/stl",
30
+ "dist/test",
31
+ "dist/types.d.ts",
32
+ "dist/types.js",
33
+ "src/3mf_parser.ts",
34
+ "src/fulu-orca.ts",
35
+ "src/index.ts",
36
+ "src/printers",
37
+ "src/stl",
38
+ "src/test/3DBenchy.3mf",
39
+ "src/test/stl-manipulator-test.js",
40
+ "src/test/stl-manipulator-test.ts",
41
+ "src/test/tsconfig.json",
42
+ "src/types.ts",
23
43
  "README.md",
24
44
  "LICENSE"
25
45
  ],