bambu-printer-mcp 1.1.15 → 1.1.17
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/dist/index.js +40 -7
- package/dist/slicer/profile-flatten.js +16 -4
- package/dist/stl/stl-manipulator.d.ts +10 -0
- package/dist/stl/stl-manipulator.js +56 -10
- package/package.json +1 -1
- package/src/index.ts +47 -9
- package/src/slicer/profile-flatten.ts +19 -5
- package/src/stl/stl-manipulator.ts +62 -15
package/dist/index.js
CHANGED
|
@@ -497,6 +497,30 @@ function parseBooleanEnv(rawValue, fallback) {
|
|
|
497
497
|
return false;
|
|
498
498
|
return fallback;
|
|
499
499
|
}
|
|
500
|
+
// Someone confirming a hardware action may have to walk to the printer, so the
|
|
501
|
+
// SDK's 60-second request default is too short for human confirmation.
|
|
502
|
+
const DEFAULT_CONFIRMATION_TIMEOUT_MS = 600000;
|
|
503
|
+
const MIN_CONFIRMATION_TIMEOUT_MS = 1000;
|
|
504
|
+
const MAX_CONFIRMATION_TIMEOUT_MS = 3600000;
|
|
505
|
+
/** Out-of-range values fall back to the default; Node clamps huge timer delays to 1 ms. */
|
|
506
|
+
function parseConfirmationTimeout(value) {
|
|
507
|
+
const parsed = Number(value?.trim() || NaN);
|
|
508
|
+
return Number.isInteger(parsed) && parsed >= MIN_CONFIRMATION_TIMEOUT_MS && parsed <= MAX_CONFIRMATION_TIMEOUT_MS
|
|
509
|
+
? parsed
|
|
510
|
+
: DEFAULT_CONFIRMATION_TIMEOUT_MS;
|
|
511
|
+
}
|
|
512
|
+
function describeDuration(ms) {
|
|
513
|
+
return ms >= 60000 ? `${Math.round(ms / 6000) / 10} minute(s)` : `${Math.round(ms / 100) / 10} second(s)`;
|
|
514
|
+
}
|
|
515
|
+
function isElicitationTimeout(error) {
|
|
516
|
+
return error?.code === ErrorCode.RequestTimeout;
|
|
517
|
+
}
|
|
518
|
+
/** Only a missing method or capability means the client cannot show the prompt. */
|
|
519
|
+
function isElicitationUnsupported(error) {
|
|
520
|
+
const message = String(error?.message ?? error);
|
|
521
|
+
return error?.code === ErrorCode.MethodNotFound || error?.code === ErrorCode.InvalidRequest ||
|
|
522
|
+
/does not support (form |url )?elicitation/i.test(message);
|
|
523
|
+
}
|
|
500
524
|
function parsePort(value, fallback) {
|
|
501
525
|
if (!value)
|
|
502
526
|
return fallback;
|
|
@@ -822,6 +846,7 @@ class BambuPrinterMCPServer {
|
|
|
822
846
|
constructor() {
|
|
823
847
|
this.blender = new BlenderMcpBridge();
|
|
824
848
|
this.shuttingDown = false;
|
|
849
|
+
this.confirmationTimeoutMs = parseConfirmationTimeout(process.env.BAMBU_CONFIRMATION_TIMEOUT_MS);
|
|
825
850
|
this.runtimeConfig = readRuntimeConfig();
|
|
826
851
|
this.server = new Server({
|
|
827
852
|
name: "bambu-printer-mcp",
|
|
@@ -842,11 +867,17 @@ class BambuPrinterMCPServer {
|
|
|
842
867
|
properties: { confirmed: { type: "boolean", title: "I checked the printer and confirm this operation", default: false } },
|
|
843
868
|
required: ["confirmed"],
|
|
844
869
|
},
|
|
845
|
-
});
|
|
870
|
+
}, { timeout: this.confirmationTimeoutMs });
|
|
846
871
|
return response.action === "accept" && response.content?.confirmed === true;
|
|
847
872
|
}
|
|
848
|
-
catch {
|
|
849
|
-
|
|
873
|
+
catch (error) {
|
|
874
|
+
if (isElicitationTimeout(error)) {
|
|
875
|
+
throw new Error(`No hardware confirmation within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. Retry when someone can check the printer, or raise BAMBU_CONFIRMATION_TIMEOUT_MS.`);
|
|
876
|
+
}
|
|
877
|
+
if (isElicitationUnsupported(error)) {
|
|
878
|
+
throw new Error("Human hardware confirmation requires an MCP client with elicitation support. For deliberately headless operation, BAMBU_REQUIRE_CONFIRMATION=0 disables ordinary print/heat prompts; finished-bed clearance and hardware-error confirmation still require elicitation.");
|
|
879
|
+
}
|
|
880
|
+
throw new Error(`Human hardware confirmation failed: ${error?.message ?? error}. Nothing was sent to the printer.`);
|
|
850
881
|
}
|
|
851
882
|
});
|
|
852
883
|
this.bambuNetwork = new BambuNetworkBridge();
|
|
@@ -902,17 +933,19 @@ class BambuPrinterMCPServer {
|
|
|
902
933
|
},
|
|
903
934
|
required: ["bambu_model"],
|
|
904
935
|
},
|
|
905
|
-
});
|
|
936
|
+
}, { timeout: this.confirmationTimeoutMs });
|
|
906
937
|
if (result.action === "accept" && result.content?.bambu_model) {
|
|
907
938
|
return validateBambuModel(String(result.content.bambu_model));
|
|
908
939
|
}
|
|
909
940
|
throw new Error("Printer model selection was cancelled. Cannot proceed without knowing the printer model.");
|
|
910
941
|
}
|
|
911
942
|
catch (elicitError) {
|
|
943
|
+
if (isElicitationTimeout(elicitError)) {
|
|
944
|
+
throw new Error(`No printer model was selected within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. ` +
|
|
945
|
+
"Set BAMBU_MODEL or pass bambu_model in the tool call.");
|
|
946
|
+
}
|
|
912
947
|
// Elicitation not supported by this client — fall back to a clear error
|
|
913
|
-
|
|
914
|
-
if (elicitError?.code === -32601 || elicitError?.code === -32600 ||
|
|
915
|
-
msg.includes("does not support") || msg.includes("elicitation")) {
|
|
948
|
+
if (isElicitationUnsupported(elicitError)) {
|
|
916
949
|
throw new Error("bambu_model is required but your MCP client does not support elicitation. " +
|
|
917
950
|
`Set the BAMBU_MODEL environment variable or pass bambu_model in the tool call. ` +
|
|
918
951
|
`Valid models: ${VALID_BAMBU_MODELS.join(", ")}`);
|
|
@@ -41,6 +41,7 @@ import crypto from "node:crypto";
|
|
|
41
41
|
*/
|
|
42
42
|
async function buildNameIndex(profilesRoot, vendor, userProfileRoots = []) {
|
|
43
43
|
const index = new Map();
|
|
44
|
+
index.malformed = [];
|
|
44
45
|
const subdirs = ["machine", "process", "filament"];
|
|
45
46
|
const directories = [
|
|
46
47
|
...subdirs.map(sub => path.join(profilesRoot, vendor, sub)),
|
|
@@ -72,11 +73,15 @@ async function buildNameIndex(profilesRoot, vendor, userProfileRoots = []) {
|
|
|
72
73
|
data = JSON.parse(raw);
|
|
73
74
|
}
|
|
74
75
|
catch {
|
|
75
|
-
// Malformed profile -- skip, don't poison the index.
|
|
76
|
+
// Malformed profile -- skip, don't poison the index. A reference to
|
|
77
|
+
// it still fails, and the error names the skipped file.
|
|
78
|
+
index.malformed.push(filePath);
|
|
76
79
|
continue;
|
|
77
80
|
}
|
|
78
|
-
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
81
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
82
|
+
index.malformed.push(filePath);
|
|
79
83
|
continue;
|
|
84
|
+
}
|
|
80
85
|
const name = data["name"];
|
|
81
86
|
if (typeof name !== "string" || name.length === 0)
|
|
82
87
|
continue;
|
|
@@ -106,7 +111,7 @@ function flattenByName(leafName, index, visiting = new Set()) {
|
|
|
106
111
|
}
|
|
107
112
|
const entry = index.get(leafName);
|
|
108
113
|
if (!entry) {
|
|
109
|
-
throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled
|
|
114
|
+
throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.${describeMalformed(index)}`);
|
|
110
115
|
}
|
|
111
116
|
visiting.add(leafName);
|
|
112
117
|
try {
|
|
@@ -116,6 +121,13 @@ function flattenByName(leafName, index, visiting = new Set()) {
|
|
|
116
121
|
visiting.delete(leafName);
|
|
117
122
|
}
|
|
118
123
|
}
|
|
124
|
+
function describeMalformed(index) {
|
|
125
|
+
const malformed = index.malformed ?? [];
|
|
126
|
+
if (malformed.length === 0)
|
|
127
|
+
return "";
|
|
128
|
+
const shown = malformed.slice(0, 5).map((file) => path.basename(file)).join(", ");
|
|
129
|
+
return ` Skipped ${malformed.length} unreadable or malformed profile file(s): ${shown}${malformed.length > 5 ? ", ..." : ""}.`;
|
|
130
|
+
}
|
|
119
131
|
function flattenData(data, index, visiting = new Set()) {
|
|
120
132
|
const parent = data["inherits"];
|
|
121
133
|
const merged = typeof parent === "string" && parent.length > 0
|
|
@@ -157,7 +169,7 @@ function applyIncludes(target, profile, index, visiting) {
|
|
|
157
169
|
const entry = index.get(name);
|
|
158
170
|
if (!entry) {
|
|
159
171
|
throw new Error(`Profile "${String(profile["name"])}" includes "${name}", which is not in the index. ` +
|
|
160
|
-
`Refusing to fall back to inherited defaults (wrong G-code for this printer)
|
|
172
|
+
`Refusing to fall back to inherited defaults (wrong G-code for this printer).${describeMalformed(index)}`);
|
|
161
173
|
}
|
|
162
174
|
for (const [key, value] of Object.entries(flattenByName(name, index, visiting))) {
|
|
163
175
|
if (!INCLUDE_METADATA_KEYS.has(key))
|
|
@@ -62,10 +62,20 @@ export declare class STLManipulator extends EventEmitter {
|
|
|
62
62
|
private readJsonFile;
|
|
63
63
|
private stripAbsoluteExtruderResets;
|
|
64
64
|
private sanitizeProcessForOrca;
|
|
65
|
+
/** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
|
|
66
|
+
private useAbsoluteExtrusionForOrca;
|
|
65
67
|
private writeTempJson;
|
|
66
68
|
private resolveBambuLikeSettingsBundle;
|
|
67
69
|
/** Resolve BBL dependencies before invoking the CLI; failures stop the slice. */
|
|
68
70
|
private maybeFlattenBundle;
|
|
71
|
+
/**
|
|
72
|
+
* The CLI applies every key in every --load-settings/--load-filaments file,
|
|
73
|
+
* so machine settings carried by a process or filament file (for example a
|
|
74
|
+
* template 3MF's project_settings from another printer) would replace the
|
|
75
|
+
* selected preset's start G-code, model, and bed geometry. The machine
|
|
76
|
+
* preset owns those keys; drop them from the other profiles.
|
|
77
|
+
*/
|
|
78
|
+
private isolateMachineSettings;
|
|
69
79
|
/** --load-filaments is positional; a single override must cover every project slot. */
|
|
70
80
|
private expandProjectFilaments;
|
|
71
81
|
private resolveBambuStudioBedType;
|
|
@@ -100,6 +100,11 @@ function configuredBambuProfileDirs() {
|
|
|
100
100
|
];
|
|
101
101
|
return roots.flatMap((root) => appNames.map((appName) => path.join(root, appName, 'system', 'BBL')));
|
|
102
102
|
}
|
|
103
|
+
/** Keys every profile kind carries; they describe the file, not machine hardware. */
|
|
104
|
+
const PROFILE_METADATA_KEYS = new Set([
|
|
105
|
+
'name', 'type', 'from', 'inherits', 'instantiation', 'setting_id', 'version', 'is_custom_defined',
|
|
106
|
+
'compatible_printers', 'compatible_printers_condition', 'compatible_prints', 'compatible_prints_condition',
|
|
107
|
+
]);
|
|
103
108
|
export class STLManipulator extends EventEmitter {
|
|
104
109
|
constructor(tempDir = path.join(process.cwd(), 'temp')) {
|
|
105
110
|
super();
|
|
@@ -193,11 +198,16 @@ export class STLManipulator extends EventEmitter {
|
|
|
193
198
|
else if (sanitized.prime_tower_brim_width !== undefined) {
|
|
194
199
|
sanitized.prime_tower_brim_width = String(sanitized.prime_tower_brim_width);
|
|
195
200
|
}
|
|
196
|
-
sanitized
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
+
return this.useAbsoluteExtrusionForOrca(sanitized);
|
|
202
|
+
}
|
|
203
|
+
/** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
|
|
204
|
+
useAbsoluteExtrusionForOrca(config) {
|
|
205
|
+
const normalized = { ...config };
|
|
206
|
+
normalized.use_relative_e_distances = '0';
|
|
207
|
+
normalized.before_layer_change_gcode = this.stripAbsoluteExtruderResets(normalized.before_layer_change_gcode);
|
|
208
|
+
normalized.layer_gcode = this.stripAbsoluteExtruderResets(normalized.layer_gcode);
|
|
209
|
+
normalized.layer_change_gcode = this.stripAbsoluteExtruderResets(normalized.layer_change_gcode);
|
|
210
|
+
return normalized;
|
|
201
211
|
}
|
|
202
212
|
writeTempJson(outputBase, suffix, value) {
|
|
203
213
|
const serialized = JSON.stringify(value, null, 2);
|
|
@@ -426,6 +436,36 @@ export class STLManipulator extends EventEmitter {
|
|
|
426
436
|
: withCliFields(bundle.filamentPaths[i], profile, flat.filamentPaths[i], ['filament_colour'])),
|
|
427
437
|
};
|
|
428
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* The CLI applies every key in every --load-settings/--load-filaments file,
|
|
441
|
+
* so machine settings carried by a process or filament file (for example a
|
|
442
|
+
* template 3MF's project_settings from another printer) would replace the
|
|
443
|
+
* selected preset's start G-code, model, and bed geometry. The machine
|
|
444
|
+
* preset owns those keys; drop them from the other profiles.
|
|
445
|
+
*/
|
|
446
|
+
isolateMachineSettings(bundle) {
|
|
447
|
+
const [machinePath, processPath] = bundle.settingsArg?.split(';') ?? [];
|
|
448
|
+
if (!machinePath || !processPath)
|
|
449
|
+
return bundle;
|
|
450
|
+
const machineKeys = Object.keys(this.readJsonFile(machinePath)).filter(key => !PROFILE_METADATA_KEYS.has(key));
|
|
451
|
+
const isolate = (filePath, label) => {
|
|
452
|
+
const profile = this.readJsonFile(filePath);
|
|
453
|
+
const foreign = machineKeys.filter(key => Object.prototype.hasOwnProperty.call(profile, key));
|
|
454
|
+
if (foreign.length === 0)
|
|
455
|
+
return filePath;
|
|
456
|
+
const isolated = { ...profile };
|
|
457
|
+
for (const key of foreign)
|
|
458
|
+
delete isolated[key];
|
|
459
|
+
console.error(`Ignoring ${foreign.length} machine setting(s) in ${label} profile ${filePath} ` +
|
|
460
|
+
`(the selected machine preset owns them): ${foreign.slice(0, 8).join(', ')}${foreign.length > 8 ? ', ...' : ''}`);
|
|
461
|
+
return this.writeTempJson(path.basename(filePath, '.json'), `${label}_without_machine`, isolated);
|
|
462
|
+
};
|
|
463
|
+
return {
|
|
464
|
+
...bundle,
|
|
465
|
+
settingsArg: [machinePath, isolate(processPath, 'process')].join(';'),
|
|
466
|
+
filamentPaths: bundle.filamentPaths.map(filePath => isolate(filePath, 'filament')),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
429
469
|
/** --load-filaments is positional; a single override must cover every project slot. */
|
|
430
470
|
async expandProjectFilaments(inputPath, bundle) {
|
|
431
471
|
if (!inputPath.toLowerCase().endsWith('.3mf') || bundle.filamentPaths.length === 0)
|
|
@@ -1244,17 +1284,23 @@ export class STLManipulator extends EventEmitter {
|
|
|
1244
1284
|
const settingsBundle = await this.maybeFlattenBundle(await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, profilesRoot);
|
|
1245
1285
|
// Inherited process G-code is now present; apply Orca's existing
|
|
1246
1286
|
// absolute-extrusion normalization after resolving those ancestors.
|
|
1287
|
+
// The machine owns the extrusion mode and layer G-code once process
|
|
1288
|
+
// files lose machine keys, so normalize it there as well.
|
|
1247
1289
|
if (slicerType === 'orcaslicer' && settingsBundle.settingsArg) {
|
|
1248
1290
|
const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
|
|
1249
|
-
settingsBundle.settingsArg = [
|
|
1291
|
+
settingsBundle.settingsArg = [
|
|
1292
|
+
this.writeTempJson(outputBase, 'machine_orca_resolved', this.useAbsoluteExtrusionForOrca(this.readJsonFile(machinePath))),
|
|
1293
|
+
this.writeTempJson(outputBase, 'process_orca_resolved', this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
|
|
1294
|
+
].join(';');
|
|
1250
1295
|
}
|
|
1296
|
+
const cliBundle = this.isolateMachineSettings(settingsBundle);
|
|
1251
1297
|
args = [
|
|
1252
1298
|
'--slice', String(bambuOptions?.slicePlate ?? 0),
|
|
1253
1299
|
'--outputdir', outputDir,
|
|
1254
1300
|
'--export-3mf', path.basename(bambuOutputPath),
|
|
1255
1301
|
];
|
|
1256
|
-
if (
|
|
1257
|
-
args.push('--load-settings',
|
|
1302
|
+
if (cliBundle.settingsArg) {
|
|
1303
|
+
args.push('--load-settings', cliBundle.settingsArg);
|
|
1258
1304
|
}
|
|
1259
1305
|
// Always allow newer-version 3MF files (the CLI rejects them by default)
|
|
1260
1306
|
args.push('--allow-newer-file');
|
|
@@ -1289,8 +1335,8 @@ export class STLManipulator extends EventEmitter {
|
|
|
1289
1335
|
args.push('--clone-objects', bambuOptions.cloneObjects);
|
|
1290
1336
|
if (bambuOptions?.skipObjects)
|
|
1291
1337
|
args.push('--skip-objects', bambuOptions.skipObjects);
|
|
1292
|
-
if (
|
|
1293
|
-
args.push('--load-filaments',
|
|
1338
|
+
if (cliBundle.filamentPaths.length > 0) {
|
|
1339
|
+
args.push('--load-filaments', cliBundle.filamentPaths.join(';'));
|
|
1294
1340
|
args.push('--load-defaultfila');
|
|
1295
1341
|
}
|
|
1296
1342
|
if (bambuOptions?.loadFilamentIds)
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -689,6 +689,35 @@ function parseBooleanEnv(rawValue: string | undefined, fallback: boolean): boole
|
|
|
689
689
|
return fallback;
|
|
690
690
|
}
|
|
691
691
|
|
|
692
|
+
// Someone confirming a hardware action may have to walk to the printer, so the
|
|
693
|
+
// SDK's 60-second request default is too short for human confirmation.
|
|
694
|
+
const DEFAULT_CONFIRMATION_TIMEOUT_MS = 600_000;
|
|
695
|
+
const MIN_CONFIRMATION_TIMEOUT_MS = 1_000;
|
|
696
|
+
const MAX_CONFIRMATION_TIMEOUT_MS = 3_600_000;
|
|
697
|
+
|
|
698
|
+
/** Out-of-range values fall back to the default; Node clamps huge timer delays to 1 ms. */
|
|
699
|
+
function parseConfirmationTimeout(value: string | undefined): number {
|
|
700
|
+
const parsed = Number(value?.trim() || NaN);
|
|
701
|
+
return Number.isInteger(parsed) && parsed >= MIN_CONFIRMATION_TIMEOUT_MS && parsed <= MAX_CONFIRMATION_TIMEOUT_MS
|
|
702
|
+
? parsed
|
|
703
|
+
: DEFAULT_CONFIRMATION_TIMEOUT_MS;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function describeDuration(ms: number): string {
|
|
707
|
+
return ms >= 60_000 ? `${Math.round(ms / 6_000) / 10} minute(s)` : `${Math.round(ms / 100) / 10} second(s)`;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function isElicitationTimeout(error: any): boolean {
|
|
711
|
+
return error?.code === ErrorCode.RequestTimeout;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Only a missing method or capability means the client cannot show the prompt. */
|
|
715
|
+
function isElicitationUnsupported(error: any): boolean {
|
|
716
|
+
const message = String(error?.message ?? error);
|
|
717
|
+
return error?.code === ErrorCode.MethodNotFound || error?.code === ErrorCode.InvalidRequest ||
|
|
718
|
+
/does not support (form |url )?elicitation/i.test(message);
|
|
719
|
+
}
|
|
720
|
+
|
|
692
721
|
function parsePort(value: string | undefined, fallback: number): number {
|
|
693
722
|
if (!value) return fallback;
|
|
694
723
|
const parsed = Number(value);
|
|
@@ -1105,6 +1134,7 @@ class BambuPrinterMCPServer {
|
|
|
1105
1134
|
private readonly runtimeConfig: RuntimeConfig;
|
|
1106
1135
|
private httpRuntime?: { transport: StreamableHTTPServerTransport; httpServer: HttpServer };
|
|
1107
1136
|
private shuttingDown = false;
|
|
1137
|
+
private readonly confirmationTimeoutMs = parseConfirmationTimeout(process.env.BAMBU_CONFIRMATION_TIMEOUT_MS);
|
|
1108
1138
|
|
|
1109
1139
|
constructor() {
|
|
1110
1140
|
this.runtimeConfig = readRuntimeConfig();
|
|
@@ -1131,10 +1161,16 @@ class BambuPrinterMCPServer {
|
|
|
1131
1161
|
properties: { confirmed: { type: "boolean", title: "I checked the printer and confirm this operation", default: false } },
|
|
1132
1162
|
required: ["confirmed"],
|
|
1133
1163
|
},
|
|
1134
|
-
});
|
|
1164
|
+
}, { timeout: this.confirmationTimeoutMs });
|
|
1135
1165
|
return response.action === "accept" && response.content?.confirmed === true;
|
|
1136
|
-
} catch {
|
|
1137
|
-
|
|
1166
|
+
} catch (error: any) {
|
|
1167
|
+
if (isElicitationTimeout(error)) {
|
|
1168
|
+
throw new Error(`No hardware confirmation within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. Retry when someone can check the printer, or raise BAMBU_CONFIRMATION_TIMEOUT_MS.`);
|
|
1169
|
+
}
|
|
1170
|
+
if (isElicitationUnsupported(error)) {
|
|
1171
|
+
throw new Error("Human hardware confirmation requires an MCP client with elicitation support. For deliberately headless operation, BAMBU_REQUIRE_CONFIRMATION=0 disables ordinary print/heat prompts; finished-bed clearance and hardware-error confirmation still require elicitation.");
|
|
1172
|
+
}
|
|
1173
|
+
throw new Error(`Human hardware confirmation failed: ${error?.message ?? error}. Nothing was sent to the printer.`);
|
|
1138
1174
|
}
|
|
1139
1175
|
});
|
|
1140
1176
|
this.bambuNetwork = new BambuNetworkBridge();
|
|
@@ -1196,7 +1232,7 @@ class BambuPrinterMCPServer {
|
|
|
1196
1232
|
},
|
|
1197
1233
|
required: ["bambu_model"],
|
|
1198
1234
|
},
|
|
1199
|
-
});
|
|
1235
|
+
}, { timeout: this.confirmationTimeoutMs });
|
|
1200
1236
|
|
|
1201
1237
|
if (result.action === "accept" && result.content?.bambu_model) {
|
|
1202
1238
|
return validateBambuModel(String(result.content.bambu_model));
|
|
@@ -1206,12 +1242,14 @@ class BambuPrinterMCPServer {
|
|
|
1206
1242
|
"Printer model selection was cancelled. Cannot proceed without knowing the printer model."
|
|
1207
1243
|
);
|
|
1208
1244
|
} catch (elicitError: any) {
|
|
1245
|
+
if (isElicitationTimeout(elicitError)) {
|
|
1246
|
+
throw new Error(
|
|
1247
|
+
`No printer model was selected within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. ` +
|
|
1248
|
+
"Set BAMBU_MODEL or pass bambu_model in the tool call."
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1209
1251
|
// Elicitation not supported by this client — fall back to a clear error
|
|
1210
|
-
|
|
1211
|
-
if (
|
|
1212
|
-
elicitError?.code === -32601 || elicitError?.code === -32600 ||
|
|
1213
|
-
msg.includes("does not support") || msg.includes("elicitation")
|
|
1214
|
-
) {
|
|
1252
|
+
if (isElicitationUnsupported(elicitError)) {
|
|
1215
1253
|
throw new Error(
|
|
1216
1254
|
"bambu_model is required but your MCP client does not support elicitation. " +
|
|
1217
1255
|
`Set the BAMBU_MODEL environment variable or pass bambu_model in the tool call. ` +
|
|
@@ -95,7 +95,8 @@ interface IndexedProfile {
|
|
|
95
95
|
data: Record<string, unknown>;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
/** Name index plus unreadable JSON files, reported when a name is missing. */
|
|
99
|
+
type NameIndex = Map<string, IndexedProfile> & { malformed?: string[] };
|
|
99
100
|
|
|
100
101
|
/* -------------------------------------------------------------------------- */
|
|
101
102
|
/* Indexing */
|
|
@@ -116,6 +117,7 @@ async function buildNameIndex(
|
|
|
116
117
|
userProfileRoots: string[] = []
|
|
117
118
|
): Promise<NameIndex> {
|
|
118
119
|
const index: NameIndex = new Map();
|
|
120
|
+
index.malformed = [];
|
|
119
121
|
const subdirs: ProfileKind[] = ["machine", "process", "filament"];
|
|
120
122
|
|
|
121
123
|
const directories = [
|
|
@@ -144,10 +146,15 @@ async function buildNameIndex(
|
|
|
144
146
|
try {
|
|
145
147
|
data = JSON.parse(raw) as Record<string, unknown>;
|
|
146
148
|
} catch {
|
|
147
|
-
// Malformed profile -- skip, don't poison the index.
|
|
149
|
+
// Malformed profile -- skip, don't poison the index. A reference to
|
|
150
|
+
// it still fails, and the error names the skipped file.
|
|
151
|
+
index.malformed!.push(filePath);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
155
|
+
index.malformed!.push(filePath);
|
|
148
156
|
continue;
|
|
149
157
|
}
|
|
150
|
-
if (!data || typeof data !== 'object' || Array.isArray(data)) continue;
|
|
151
158
|
const name = data["name"];
|
|
152
159
|
if (typeof name !== "string" || name.length === 0) continue;
|
|
153
160
|
// First-write wins. BBL doesn't have name collisions in practice;
|
|
@@ -183,7 +190,7 @@ function flattenByName(
|
|
|
183
190
|
}
|
|
184
191
|
const entry = index.get(leafName);
|
|
185
192
|
if (!entry) {
|
|
186
|
-
throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled
|
|
193
|
+
throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.${describeMalformed(index)}`);
|
|
187
194
|
}
|
|
188
195
|
visiting.add(leafName);
|
|
189
196
|
try {
|
|
@@ -193,6 +200,13 @@ function flattenByName(
|
|
|
193
200
|
}
|
|
194
201
|
}
|
|
195
202
|
|
|
203
|
+
function describeMalformed(index: NameIndex): string {
|
|
204
|
+
const malformed = index.malformed ?? [];
|
|
205
|
+
if (malformed.length === 0) return "";
|
|
206
|
+
const shown = malformed.slice(0, 5).map((file) => path.basename(file)).join(", ");
|
|
207
|
+
return ` Skipped ${malformed.length} unreadable or malformed profile file(s): ${shown}${malformed.length > 5 ? ", ..." : ""}.`;
|
|
208
|
+
}
|
|
209
|
+
|
|
196
210
|
function flattenData(
|
|
197
211
|
data: Record<string, unknown>,
|
|
198
212
|
index: NameIndex,
|
|
@@ -249,7 +263,7 @@ function applyIncludes(
|
|
|
249
263
|
if (!entry) {
|
|
250
264
|
throw new Error(
|
|
251
265
|
`Profile "${String(profile["name"])}" includes "${name}", which is not in the index. ` +
|
|
252
|
-
`Refusing to fall back to inherited defaults (wrong G-code for this printer)
|
|
266
|
+
`Refusing to fall back to inherited defaults (wrong G-code for this printer).${describeMalformed(index)}`
|
|
253
267
|
);
|
|
254
268
|
}
|
|
255
269
|
for (const [key, value] of Object.entries(flattenByName(name, index, visiting))) {
|
|
@@ -186,6 +186,12 @@ interface BambuSettingsBundle {
|
|
|
186
186
|
processSource?: { filePath: string; profile: Record<string, unknown> };
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/** Keys every profile kind carries; they describe the file, not machine hardware. */
|
|
190
|
+
const PROFILE_METADATA_KEYS = new Set([
|
|
191
|
+
'name', 'type', 'from', 'inherits', 'instantiation', 'setting_id', 'version', 'is_custom_defined',
|
|
192
|
+
'compatible_printers', 'compatible_printers_condition', 'compatible_prints', 'compatible_prints_condition',
|
|
193
|
+
]);
|
|
194
|
+
|
|
189
195
|
export class STLManipulator extends EventEmitter {
|
|
190
196
|
private tempDir: string;
|
|
191
197
|
private activeOperations: Map<string, boolean> = new Map();
|
|
@@ -304,16 +310,22 @@ export class STLManipulator extends EventEmitter {
|
|
|
304
310
|
sanitized.prime_tower_brim_width = String(sanitized.prime_tower_brim_width);
|
|
305
311
|
}
|
|
306
312
|
|
|
307
|
-
sanitized
|
|
308
|
-
|
|
309
|
-
|
|
313
|
+
return this.useAbsoluteExtrusionForOrca(sanitized);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
|
|
317
|
+
private useAbsoluteExtrusionForOrca(config: any): any {
|
|
318
|
+
const normalized = { ...config };
|
|
319
|
+
normalized.use_relative_e_distances = '0';
|
|
320
|
+
normalized.before_layer_change_gcode = this.stripAbsoluteExtruderResets(
|
|
321
|
+
normalized.before_layer_change_gcode
|
|
310
322
|
);
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
323
|
+
normalized.layer_gcode = this.stripAbsoluteExtruderResets(normalized.layer_gcode);
|
|
324
|
+
normalized.layer_change_gcode = this.stripAbsoluteExtruderResets(
|
|
325
|
+
normalized.layer_change_gcode
|
|
314
326
|
);
|
|
315
327
|
|
|
316
|
-
return
|
|
328
|
+
return normalized;
|
|
317
329
|
}
|
|
318
330
|
|
|
319
331
|
private writeTempJson(outputBase: string, suffix: string, value: any): string {
|
|
@@ -584,6 +596,36 @@ export class STLManipulator extends EventEmitter {
|
|
|
584
596
|
};
|
|
585
597
|
}
|
|
586
598
|
|
|
599
|
+
/**
|
|
600
|
+
* The CLI applies every key in every --load-settings/--load-filaments file,
|
|
601
|
+
* so machine settings carried by a process or filament file (for example a
|
|
602
|
+
* template 3MF's project_settings from another printer) would replace the
|
|
603
|
+
* selected preset's start G-code, model, and bed geometry. The machine
|
|
604
|
+
* preset owns those keys; drop them from the other profiles.
|
|
605
|
+
*/
|
|
606
|
+
private isolateMachineSettings(bundle: BambuSettingsBundle): BambuSettingsBundle {
|
|
607
|
+
const [machinePath, processPath] = bundle.settingsArg?.split(';') ?? [];
|
|
608
|
+
if (!machinePath || !processPath) return bundle;
|
|
609
|
+
const machineKeys = Object.keys(this.readJsonFile(machinePath)).filter(key => !PROFILE_METADATA_KEYS.has(key));
|
|
610
|
+
const isolate = (filePath: string, label: string): string => {
|
|
611
|
+
const profile = this.readJsonFile(filePath);
|
|
612
|
+
const foreign = machineKeys.filter(key => Object.prototype.hasOwnProperty.call(profile, key));
|
|
613
|
+
if (foreign.length === 0) return filePath;
|
|
614
|
+
const isolated = { ...profile };
|
|
615
|
+
for (const key of foreign) delete isolated[key];
|
|
616
|
+
console.error(
|
|
617
|
+
`Ignoring ${foreign.length} machine setting(s) in ${label} profile ${filePath} ` +
|
|
618
|
+
`(the selected machine preset owns them): ${foreign.slice(0, 8).join(', ')}${foreign.length > 8 ? ', ...' : ''}`
|
|
619
|
+
);
|
|
620
|
+
return this.writeTempJson(path.basename(filePath, '.json'), `${label}_without_machine`, isolated);
|
|
621
|
+
};
|
|
622
|
+
return {
|
|
623
|
+
...bundle,
|
|
624
|
+
settingsArg: [machinePath, isolate(processPath, 'process')].join(';'),
|
|
625
|
+
filamentPaths: bundle.filamentPaths.map(filePath => isolate(filePath, 'filament')),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
587
629
|
/** --load-filaments is positional; a single override must cover every project slot. */
|
|
588
630
|
private async expandProjectFilaments(
|
|
589
631
|
inputPath: string,
|
|
@@ -1621,20 +1663,25 @@ export class STLManipulator extends EventEmitter {
|
|
|
1621
1663
|
await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, profilesRoot);
|
|
1622
1664
|
// Inherited process G-code is now present; apply Orca's existing
|
|
1623
1665
|
// absolute-extrusion normalization after resolving those ancestors.
|
|
1666
|
+
// The machine owns the extrusion mode and layer G-code once process
|
|
1667
|
+
// files lose machine keys, so normalize it there as well.
|
|
1624
1668
|
if (slicerType === 'orcaslicer' && settingsBundle.settingsArg) {
|
|
1625
1669
|
const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
|
|
1626
|
-
settingsBundle.settingsArg = [
|
|
1627
|
-
outputBase, '
|
|
1628
|
-
|
|
1629
|
-
|
|
1670
|
+
settingsBundle.settingsArg = [
|
|
1671
|
+
this.writeTempJson(outputBase, 'machine_orca_resolved',
|
|
1672
|
+
this.useAbsoluteExtrusionForOrca(this.readJsonFile(machinePath))),
|
|
1673
|
+
this.writeTempJson(outputBase, 'process_orca_resolved',
|
|
1674
|
+
this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
|
|
1675
|
+
].join(';');
|
|
1630
1676
|
}
|
|
1677
|
+
const cliBundle = this.isolateMachineSettings(settingsBundle);
|
|
1631
1678
|
args = [
|
|
1632
1679
|
'--slice', String(bambuOptions?.slicePlate ?? 0),
|
|
1633
1680
|
'--outputdir', outputDir,
|
|
1634
1681
|
'--export-3mf', path.basename(bambuOutputPath),
|
|
1635
1682
|
];
|
|
1636
|
-
if (
|
|
1637
|
-
args.push('--load-settings',
|
|
1683
|
+
if (cliBundle.settingsArg) {
|
|
1684
|
+
args.push('--load-settings', cliBundle.settingsArg);
|
|
1638
1685
|
}
|
|
1639
1686
|
// Always allow newer-version 3MF files (the CLI rejects them by default)
|
|
1640
1687
|
args.push('--allow-newer-file');
|
|
@@ -1655,8 +1702,8 @@ export class STLManipulator extends EventEmitter {
|
|
|
1655
1702
|
if (bambuOptions?.rotateY !== undefined) args.push('--rotate-y', String(bambuOptions.rotateY));
|
|
1656
1703
|
if (bambuOptions?.cloneObjects) args.push('--clone-objects', bambuOptions.cloneObjects);
|
|
1657
1704
|
if (bambuOptions?.skipObjects) args.push('--skip-objects', bambuOptions.skipObjects);
|
|
1658
|
-
if (
|
|
1659
|
-
args.push('--load-filaments',
|
|
1705
|
+
if (cliBundle.filamentPaths.length > 0) {
|
|
1706
|
+
args.push('--load-filaments', cliBundle.filamentPaths.join(';'));
|
|
1660
1707
|
args.push('--load-defaultfila');
|
|
1661
1708
|
}
|
|
1662
1709
|
if (bambuOptions?.loadFilamentIds) args.push('--load-filament-ids', bambuOptions.loadFilamentIds);
|