mcp-3d-printer-server 1.2.1 → 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.
@@ -11,13 +11,49 @@ type RequestCandidate = {
11
11
  export class PrusaImplementation extends PrinterImplementation {
12
12
  private buildAuthHeaders(apiKey: string): Record<string, string> {
13
13
  return {
14
+ Accept: "application/json",
14
15
  "X-Api-Key": apiKey,
15
16
  Authorization: `Bearer ${apiKey}`,
16
17
  };
17
18
  }
18
19
 
19
20
  private buildBaseUrl(host: string, port: string): string {
20
- return `http://${host}:${port}`;
21
+ const trimmedHost = host.trim();
22
+ const trimmedPort = port.trim();
23
+ if (!trimmedHost) {
24
+ throw new Error("Prusa host is required.");
25
+ }
26
+
27
+ const rawBaseUrl = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmedHost)
28
+ ? trimmedHost
29
+ : `${this.defaultProtocolFor(trimmedHost, trimmedPort)}://${trimmedHost}`;
30
+ const parsed = new URL(rawBaseUrl);
31
+
32
+ if (trimmedPort && !parsed.port && !this.isDefaultPort(parsed.protocol, trimmedPort)) {
33
+ parsed.port = trimmedPort;
34
+ }
35
+
36
+ return parsed.toString().replace(/\/$/, "");
37
+ }
38
+
39
+ private defaultProtocolFor(host: string, port: string): "http" | "https" {
40
+ if (port === "443" || this.isPrusaConnectCloudHost(host)) {
41
+ return "https";
42
+ }
43
+ return "http";
44
+ }
45
+
46
+ private isDefaultPort(protocol: string, port: string): boolean {
47
+ return (protocol === "https:" && port === "443") || (protocol === "http:" && port === "80");
48
+ }
49
+
50
+ private isPrusaConnectCloudHost(host: string): boolean {
51
+ try {
52
+ const parsed = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(host) ? host : `https://${host}`);
53
+ return parsed.hostname.toLowerCase() === "connect.prusa3d.com";
54
+ } catch {
55
+ return host.toLowerCase().split(":")[0] === "connect.prusa3d.com";
56
+ }
21
57
  }
22
58
 
23
59
  private isFallbackStatus(error: unknown): boolean {
@@ -86,6 +122,7 @@ export class PrusaImplementation extends PrinterImplementation {
86
122
 
87
123
  async getStatus(host: string, port: string, apiKey: string) {
88
124
  const response = await this.getWithFallback(host, port, apiKey, [
125
+ "/api/v1/status",
89
126
  "/api/v1/printer",
90
127
  "/api/printer",
91
128
  ]);
@@ -11,6 +11,7 @@ import { execFile } from 'child_process';
11
11
 
12
12
  const readFileAsync = promisify(fs.readFile);
13
13
  const writeFileAsync = promisify(fs.writeFile);
14
+ const DEFAULT_SLICER_TIMEOUT_MS = 10 * 60 * 1000;
14
15
 
15
16
  // Define types for progress tracking
16
17
  export type ProgressCallback = (progress: number, message?: string) => void;
@@ -21,6 +22,14 @@ export type OperationResult = {
21
22
  operationId: string;
22
23
  };
23
24
 
25
+ export type SlicerType =
26
+ | 'prusaslicer'
27
+ | 'cura'
28
+ | 'slic3r'
29
+ | 'orcaslicer'
30
+ | 'orcaslicer-bambulab'
31
+ | 'bambustudio';
32
+
24
33
  // Define possible transformation operations
25
34
  export type TransformationType = 'scale' | 'rotate' | 'translate' | 'extendBase' | 'customModify';
26
35
  export type TransformationAxis = 'x' | 'y' | 'z' | 'all';
@@ -60,6 +69,138 @@ export class STLManipulator extends EventEmitter {
60
69
  return crypto.randomUUID();
61
70
  }
62
71
 
72
+ private validateSlicerExecutable(slicerPath: string): void {
73
+ if (!slicerPath.trim()) {
74
+ throw new Error("Slicer executable path is required.");
75
+ }
76
+
77
+ const looksLikePath = path.isAbsolute(slicerPath) || /[\\/]/.test(slicerPath);
78
+ if (looksLikePath && !fs.existsSync(slicerPath)) {
79
+ throw new Error(`Slicer executable not found at: ${slicerPath}`);
80
+ }
81
+ }
82
+
83
+ private splitProfileList(profileList?: string): string[] {
84
+ return (profileList || "")
85
+ .split(";")
86
+ .map((profile) => profile.trim())
87
+ .filter((profile) => profile.length > 0);
88
+ }
89
+
90
+ private parseOrcaProfileArgs(
91
+ slicerProfile?: string,
92
+ filamentProfile?: string
93
+ ): { settingsProfiles: string[]; filamentProfiles: string[] } {
94
+ const [settingsSegment = "", filamentSegment = ""] = (slicerProfile || "").split("|", 2);
95
+
96
+ return {
97
+ settingsProfiles: this.splitProfileList(settingsSegment),
98
+ filamentProfiles: [
99
+ ...this.splitProfileList(filamentSegment),
100
+ ...this.splitProfileList(filamentProfile),
101
+ ],
102
+ };
103
+ }
104
+
105
+ private warnForMissingProfiles(profiles: string[], label: string): void {
106
+ for (const profile of profiles) {
107
+ if (!fs.existsSync(profile)) {
108
+ console.warn(`${label} specified but not found: ${profile}. Slicer might use defaults.`);
109
+ }
110
+ }
111
+ }
112
+
113
+ private appendOrcaProfileArgs(
114
+ args: string[],
115
+ profiles: { settingsProfiles: string[]; filamentProfiles: string[] }
116
+ ): void {
117
+ for (const profile of profiles.settingsProfiles) {
118
+ args.push('--load-settings', profile);
119
+ }
120
+ for (const profile of profiles.filamentProfiles) {
121
+ args.push('--load-filaments', profile);
122
+ }
123
+ }
124
+
125
+ private resolveOrcaGcodeOutput(outputDir: string): string {
126
+ const preferredPath = path.join(outputDir, 'plate_1.gcode');
127
+ if (fs.existsSync(preferredPath)) {
128
+ return preferredPath;
129
+ }
130
+
131
+ const plateOutputs = fs.readdirSync(outputDir)
132
+ .filter((entry) => /^plate_\d+\.gcode$/i.test(entry))
133
+ .sort((a, b) => {
134
+ const aNumber = Number(a.match(/\d+/)?.[0] || 0);
135
+ const bNumber = Number(b.match(/\d+/)?.[0] || 0);
136
+ return aNumber - bNumber;
137
+ });
138
+
139
+ if (plateOutputs.length === 0) {
140
+ throw new Error(`OrcaSlicer finished but no plate_N.gcode file was found in: ${outputDir}`);
141
+ }
142
+
143
+ return path.join(outputDir, plateOutputs[0]);
144
+ }
145
+
146
+ private resolveBambuMachinePresetPath(slicerPath: string, printerPreset: string): string {
147
+ if (fs.existsSync(printerPreset)) {
148
+ return printerPreset;
149
+ }
150
+
151
+ const presetFile = printerPreset.endsWith(".json") ? printerPreset : `${printerPreset}.json`;
152
+ const candidateDirs: string[] = [];
153
+ const macAppMarker = `${path.sep}Contents${path.sep}MacOS${path.sep}`;
154
+ const macAppIndex = slicerPath.indexOf(macAppMarker);
155
+
156
+ if (macAppIndex >= 0) {
157
+ candidateDirs.push(
158
+ path.join(
159
+ slicerPath.slice(0, macAppIndex + `${path.sep}Contents`.length),
160
+ "Resources",
161
+ "profiles",
162
+ "BBL",
163
+ "machine"
164
+ )
165
+ );
166
+ }
167
+
168
+ const executableDir = path.dirname(slicerPath);
169
+ candidateDirs.push(
170
+ path.join(executableDir, "..", "Resources", "profiles", "BBL", "machine"),
171
+ path.join(executableDir, "..", "share", "OrcaSlicer", "resources", "profiles", "BBL", "machine"),
172
+ path.join(executableDir, "..", "resources", "profiles", "BBL", "machine"),
173
+ path.join(executableDir, "resources", "profiles", "BBL", "machine")
174
+ );
175
+
176
+ for (const candidateDir of candidateDirs) {
177
+ const candidate = path.resolve(candidateDir, presetFile);
178
+ if (fs.existsSync(candidate)) {
179
+ return candidate;
180
+ }
181
+ }
182
+
183
+ throw new Error(
184
+ `Could not find Bambu machine preset "${presetFile}" near slicer executable ${slicerPath}. ` +
185
+ "Pass slicer_profile with an explicit machine/process settings file, or use a slicer bundle that includes Resources/profiles/BBL/machine."
186
+ );
187
+ }
188
+
189
+ private resolveSlicerTimeoutMs(): number {
190
+ const rawTimeout = process.env.SLICER_TIMEOUT_MS?.trim();
191
+ if (!rawTimeout) {
192
+ return DEFAULT_SLICER_TIMEOUT_MS;
193
+ }
194
+
195
+ const timeoutMs = Number(rawTimeout);
196
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
197
+ console.warn(`Invalid SLICER_TIMEOUT_MS value "${rawTimeout}", using ${DEFAULT_SLICER_TIMEOUT_MS}.`);
198
+ return DEFAULT_SLICER_TIMEOUT_MS;
199
+ }
200
+
201
+ return timeoutMs;
202
+ }
203
+
63
204
  /**
64
205
  * Load STL file and return geometry and bounding box
65
206
  */
@@ -845,7 +986,7 @@ export class STLManipulator extends EventEmitter {
845
986
  /**
846
987
  * Slice an STL or 3MF file using the specified slicer
847
988
  * @param stlFilePath Path to the input STL or 3MF file
848
- * @param slicerType Type of slicer (prusaslicer, cura, slic3r, orcaslicer, bambustudio)
989
+ * @param slicerType Type of slicer (prusaslicer, cura, slic3r, orcaslicer, orcaslicer-bambulab, bambustudio)
849
990
  * @param slicerPath Path to the slicer executable
850
991
  * @param slicerProfile Optional path to the slicer profile/config file
851
992
  * @param progressCallback Optional callback for progress updates
@@ -853,25 +994,32 @@ export class STLManipulator extends EventEmitter {
853
994
  */
854
995
  async sliceSTL(
855
996
  stlFilePath: string,
856
- slicerType: 'prusaslicer' | 'cura' | 'slic3r' | 'orcaslicer' | 'bambustudio',
997
+ slicerType: SlicerType,
857
998
  slicerPath: string,
858
999
  slicerProfile?: string,
859
1000
  progressCallback?: ProgressCallback,
860
- printerPreset?: string
1001
+ printerPreset?: string,
1002
+ filamentProfile?: string
861
1003
  ): Promise<string> {
862
1004
  const operationId = this.generateOperationId();
863
1005
  this.activeOperations.set(operationId, true);
864
1006
 
865
- if (!fs.existsSync(slicerPath)) {
866
- throw new Error(`Slicer executable not found at: ${slicerPath}`);
1007
+ this.validateSlicerExecutable(slicerPath);
1008
+ const orcaProfiles = this.parseOrcaProfileArgs(slicerProfile, filamentProfile);
1009
+ if (slicerType === 'orcaslicer') {
1010
+ this.warnForMissingProfiles(orcaProfiles.settingsProfiles, 'Slicer profile');
1011
+ this.warnForMissingProfiles(orcaProfiles.filamentProfiles, 'Filament profile');
1012
+ } else if (slicerProfile && !fs.existsSync(slicerProfile)) {
1013
+ console.warn(`Slicer profile specified but not found: ${slicerProfile}. Slicer might use defaults.`);
1014
+ // Allow proceeding without profile, slicer might handle it
867
1015
  }
868
- if (slicerProfile && !fs.existsSync(slicerProfile)) {
869
- console.warn(`Slicer profile specified but not found: ${slicerProfile}. Slicer might use defaults.`);
870
- // Allow proceeding without profile, slicer might handle it
1016
+ if (slicerType !== 'orcaslicer' && filamentProfile && !fs.existsSync(filamentProfile)) {
1017
+ console.warn(`Filament profile specified but not found: ${filamentProfile}. Slicer might use defaults.`);
871
1018
  }
872
1019
 
873
1020
  const outputFileName = path.basename(stlFilePath, '.stl') + '.gcode';
874
1021
  let outputFilePath = path.join(this.tempDir, outputFileName);
1022
+ let orcaOutputDir: string | undefined;
875
1023
 
876
1024
  let args: string[] = [];
877
1025
 
@@ -892,22 +1040,14 @@ export class STLManipulator extends EventEmitter {
892
1040
  break;
893
1041
 
894
1042
  case 'orcaslicer': // Add OrcaSlicer case
1043
+ orcaOutputDir = path.join(this.tempDir, `${operationId}-orca-output`);
1044
+ fs.mkdirSync(orcaOutputDir, { recursive: true });
895
1045
  args = [
896
- // OrcaSlicer might use --load-settings like Bambu, or --load like Prusa
897
- // Assuming --load-settings based on discussion, adjust if needed.
898
- // Also assuming profile path points to the main .ini or .json config.
899
- '--load-settings', slicerProfile || '',
900
- '--output', outputFilePath, // Assuming --output works like PrusaSlicer
901
- stlFilePath
1046
+ '--slice', '0',
1047
+ '--outputdir', orcaOutputDir,
902
1048
  ];
903
- // Alternative if --output doesn't specify filename:
904
- // args = [
905
- // '--load-settings', slicerProfile || '',
906
- // '--outputdir', this.tempDir,
907
- // stlFilePath
908
- // ];
909
- // Remove empty profile arg if not provided
910
- args = args.filter(arg => arg !== '');
1049
+ this.appendOrcaProfileArgs(args, orcaProfiles);
1050
+ args.push(stlFilePath);
911
1051
  break;
912
1052
 
913
1053
  case 'cura':
@@ -925,8 +1065,10 @@ export class STLManipulator extends EventEmitter {
925
1065
  }
926
1066
  break;
927
1067
 
1068
+ case 'orcaslicer-bambulab':
928
1069
  case 'bambustudio':
929
- // Bambu Studio CLI: slice and export as 3MF with embedded gcode
1070
+ // Bambu project slicers: slice and export as 3MF with embedded gcode.
1071
+ // orcaslicer-bambulab is the FULU fork that restores BambuNetwork support.
930
1072
  // For 3MF input: slice in place and export sliced 3MF
931
1073
  // For STL input: slice and export as 3MF
932
1074
  {
@@ -937,13 +1079,16 @@ export class STLManipulator extends EventEmitter {
937
1079
  '--slice', '0', // Slice all plates
938
1080
  '--export-3mf', bambuOutputPath,
939
1081
  ];
940
- // --load-machine ensures correct G-code for the specific printer model
1082
+ // FULU Orca/Bambu Studio load machine presets as JSON settings files.
941
1083
  if (printerPreset && !slicerProfile) {
942
- args.push('--load-machine', printerPreset);
1084
+ args.push('--load-settings', this.resolveBambuMachinePresetPath(slicerPath, printerPreset));
943
1085
  }
944
1086
  if (slicerProfile) {
945
1087
  args.push('--load-settings', slicerProfile);
946
1088
  }
1089
+ if (filamentProfile) {
1090
+ args.push('--load-filaments', filamentProfile);
1091
+ }
947
1092
  args.push(stlFilePath);
948
1093
  // Override outputFilePath for bambustudio since it produces 3MF, not gcode
949
1094
  outputFilePath = bambuOutputPath;
@@ -959,20 +1104,49 @@ export class STLManipulator extends EventEmitter {
959
1104
 
960
1105
  // Execute the slicer
961
1106
  await new Promise<void>((resolve, reject) => {
1107
+ const timeoutMs = this.resolveSlicerTimeoutMs();
1108
+ let settled = false;
1109
+ let slicerProcessKilledByTimeout = false;
1110
+ let timeoutHandle: NodeJS.Timeout | undefined;
1111
+
1112
+ const settle = (callback: () => void) => {
1113
+ if (settled) {
1114
+ return;
1115
+ }
1116
+ settled = true;
1117
+ if (timeoutHandle) {
1118
+ clearTimeout(timeoutHandle);
1119
+ }
1120
+ callback();
1121
+ };
1122
+
962
1123
  const process = execFile(slicerPath, args, (error, stdout, stderr) => {
963
1124
  if (error) {
964
1125
  console.error(`Slicer Error: ${error.message}`);
965
1126
  console.error(`Slicer Stderr: ${stderr}`);
966
- reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
1127
+ const timeoutMessage = slicerProcessKilledByTimeout
1128
+ ? ` Slicer exceeded timeout ${timeoutMs}ms.`
1129
+ : "";
1130
+ settle(() => reject(new Error(`Slicer failed: ${error.message}.${timeoutMessage} Stderr: ${stderr}`)));
967
1131
  } else {
968
1132
  console.log(`Slicer Stdout: ${stdout}`);
969
1133
  if (stderr) {
970
1134
  console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
971
1135
  }
972
- resolve();
1136
+ settle(() => resolve());
973
1137
  }
974
1138
  });
975
1139
 
1140
+ timeoutHandle = setTimeout(() => {
1141
+ slicerProcessKilledByTimeout = true;
1142
+ try {
1143
+ process.kill("SIGKILL");
1144
+ } catch (killError) {
1145
+ console.error("Error attempting to kill timed-out slicer process:", killError);
1146
+ }
1147
+ settle(() => reject(new Error(`Slicer exceeded timeout ${timeoutMs}ms.`)));
1148
+ }, timeoutMs);
1149
+
976
1150
  // Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
977
1151
  // process.stdout?.on('data', (data) => { console.log(`Slicer stdout: ${data}`); });
978
1152
  // process.stderr?.on('data', (data) => { console.log(`Slicer stderr: ${data}`); });
@@ -983,10 +1157,10 @@ export class STLManipulator extends EventEmitter {
983
1157
  clearInterval(checkCancel);
984
1158
  try {
985
1159
  process.kill(); // Attempt to kill the slicer process
986
- reject(new Error("Slicing operation cancelled"));
1160
+ settle(() => reject(new Error("Slicing operation cancelled")));
987
1161
  } catch (killError) {
988
1162
  console.error("Error attempting to kill slicer process:", killError);
989
- reject(new Error("Slicing operation cancelled, but failed to kill process."));
1163
+ settle(() => reject(new Error("Slicing operation cancelled, but failed to kill process.")));
990
1164
  }
991
1165
  }
992
1166
  }, 500);
@@ -1000,6 +1174,17 @@ export class STLManipulator extends EventEmitter {
1000
1174
  // This check might be redundant if the promise rejected on kill, but good safety
1001
1175
  throw new Error("Slicing operation cancelled after process finished");
1002
1176
  }
1177
+
1178
+ if (slicerType === 'orcaslicer') {
1179
+ if (!orcaOutputDir) {
1180
+ throw new Error("Internal error: OrcaSlicer output directory was not initialized.");
1181
+ }
1182
+ const orcaGcodePath = this.resolveOrcaGcodeOutput(orcaOutputDir);
1183
+ if (fs.existsSync(outputFilePath)) {
1184
+ fs.unlinkSync(outputFilePath);
1185
+ }
1186
+ fs.renameSync(orcaGcodePath, outputFilePath);
1187
+ }
1003
1188
 
1004
1189
  if (!fs.existsSync(outputFilePath)) {
1005
1190
  throw new Error(`Slicer finished but output file not found: ${outputFilePath}`);
@@ -1024,6 +1209,9 @@ export class STLManipulator extends EventEmitter {
1024
1209
  });
1025
1210
  throw error;
1026
1211
  } finally {
1212
+ if (orcaOutputDir && fs.existsSync(orcaOutputDir)) {
1213
+ fs.rmSync(orcaOutputDir, { recursive: true, force: true });
1214
+ }
1027
1215
  this.activeOperations.delete(operationId);
1028
1216
  }
1029
1217
  }
@@ -1,16 +0,0 @@
1
- #!/usr/bin/env node
2
- declare class ThreeDPrinterMCPServerHTTP {
3
- private server;
4
- private printerFactory;
5
- private stlManipulator;
6
- private app;
7
- constructor();
8
- private setupExpressApp;
9
- private handleJSONRPCRequest;
10
- setupErrorHandling(): void;
11
- setupHandlers(): void;
12
- setupResourceHandlers(): void;
13
- setupToolHandlers(): void;
14
- run(): Promise<void>;
15
- }
16
- export default ThreeDPrinterMCPServerHTTP;