bambu-printer-mcp 1.1.26 → 1.1.27

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/README.md CHANGED
@@ -878,6 +878,24 @@ To stop drying:
878
878
  }
879
879
  ```
880
880
 
881
+ #### inspect_print_file
882
+
883
+ Inspect a prepared sliced 3MF or textual G-code file offline before deciding what to export or print. This tool copies the file into a private read-only snapshot and reuses the same archive, selected-plate, model/nozzle/material and temperature checks used by print preflight. It never contacts a printer or auto-slices an unsliced project.
884
+
885
+ ```json
886
+ {
887
+ "file_path": "/path/to/bracket.gcode.3mf",
888
+ "bambu_model": "p1s",
889
+ "plate_index": 0,
890
+ "nozzle_diameters": [0.4],
891
+ "bed_type": "textured_plate"
892
+ }
893
+ ```
894
+
895
+ `file_path` and explicit `bambu_model` are required; the model is never inferred from configuration. `plate_index` is zero-based and defaults to 0. Nozzle/bed checks are optional comparisons against file declarations, not observations of installed hardware. Only these five argument names are accepted; credentials, host and executable/bridge overrides are not part of this tool.
896
+
897
+ The generic result has `receipt_version: "1"`, `evidence: "file-only"`, `dispatch_authorized: false`, `artifact` (`sha256`, `byte_length`, `file_format`), the requested `selection`, and the original `inspection` including declared model, nozzles, materials/used filament positions, internal plate path and checked temperature peaks. Hash and byte length identify the exact inspected snapshot; a consumer must compare them with its own artifact and selected plate. The temporary snapshot is removed, and its path is not a dispatch capability. The receipt does not prove printer readiness, physical material/nozzle contents, fit, full motion simulation or permission to print. Actual print routes still independently inspect the dispatched snapshot and require fresh hardware state and human preflight.
898
+
881
899
  #### print_3mf
882
900
 
883
901
  The primary tool for starting a Bambu print. **Recommended input: a pre-sliced `.gcode.3mf` exported from FULU OrcaSlicer-bambulab, OrcaSlicer, or Bambu Studio** — see [slicing guide](https://github.com/DMontgomery40/bambu-printer-mcp/blob/main/docs/SLICING.md). This tool handles the complete workflow:
package/dist/index.js CHANGED
@@ -2377,6 +2377,23 @@ class BambuPrinterMCPServer {
2377
2377
  required: ["action", "ams_id"]
2378
2378
  }
2379
2379
  },
2380
+ {
2381
+ name: "inspect_print_file",
2382
+ description: "Inspect an exact private snapshot of a prepared sliced 3MF or textual G-code file and selected plate without contacting a printer. Returns versioned file-only evidence; no printer readiness or permission to dispatch is established. Does not auto-slice.",
2383
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2384
+ inputSchema: {
2385
+ type: "object",
2386
+ additionalProperties: false,
2387
+ properties: {
2388
+ file_path: { type: "string", minLength: 1, description: "Local prepared .3mf or textual .gcode artifact to inspect; checksum-only and unsliced projects reject." },
2389
+ bambu_model: { type: "string", enum: [...VALID_BAMBU_MODELS], description: "Explicit file-inspection target model; never inferred from environment or live state." },
2390
+ plate_index: { type: "integer", minimum: 0, description: "Zero-based selected plate; defaults to 0. Raw G-code only supports 0." },
2391
+ nozzle_diameters: { type: "array", minItems: 1, maxItems: 2, items: { type: "number", enum: [0.2, 0.4, 0.6, 0.8] }, description: "Optional requested nozzle diameters to compare with file declarations; this does not observe installed hardware." },
2392
+ bed_type: { type: "string", enum: [...VALID_BED_TYPES], description: "Optional declared bed type to compare with file metadata." },
2393
+ },
2394
+ required: ["file_path", "bambu_model"],
2395
+ },
2396
+ },
2380
2397
  {
2381
2398
  name: "print_3mf",
2382
2399
  description: "Print a 3MF file on a Bambu Lab printer. Auto-slices if the 3MF has no gcode. IMPORTANT: bambu_model must be specified to ensure safe printer operation.",
@@ -2613,6 +2630,42 @@ class BambuPrinterMCPServer {
2613
2630
  try {
2614
2631
  let result;
2615
2632
  switch (name) {
2633
+ case "inspect_print_file": {
2634
+ const allowed = new Set(["file_path", "bambu_model", "plate_index", "nozzle_diameters", "bed_type"]);
2635
+ if (!args || Object.keys(args).some(key => !allowed.has(key)))
2636
+ throw new Error("inspect_print_file accepts only file/model/plate/nozzle/bed arguments.");
2637
+ if (typeof args.file_path !== "string" || !args.file_path.trim() || args.file_path.includes("\0"))
2638
+ throw new Error("file_path must name a local prepared artifact.");
2639
+ if (typeof args.bambu_model !== "string" || !args.bambu_model.trim())
2640
+ throw new Error("An explicit bambu_model is required for file inspection.");
2641
+ const model = validateBambuModel(args.bambu_model);
2642
+ const plateIndex = args.plate_index === undefined ? 0 : args.plate_index;
2643
+ if (typeof plateIndex !== "number" || !Number.isSafeInteger(plateIndex) || plateIndex < 0)
2644
+ throw new Error("plate_index must be a nonnegative safe integer.");
2645
+ if (args.nozzle_diameters !== undefined && (!Array.isArray(args.nozzle_diameters) || args.nozzle_diameters.some(value => typeof value !== "number")))
2646
+ throw new Error("nozzle_diameters must be numeric sizes.");
2647
+ const nozzles = requestedPrintNozzles(args);
2648
+ if (args.bed_type !== undefined && (typeof args.bed_type !== "string" || !args.bed_type.trim()))
2649
+ throw new Error("bed_type must be a supported plate type.");
2650
+ const bedType = args.bed_type === undefined ? undefined : resolveBedType(args.bed_type);
2651
+ if (extra.signal.aborted)
2652
+ throw new Error("File inspection cancelled.");
2653
+ result = await withPrintSnapshot(args.file_path, async (snapshot) => {
2654
+ const inspection = await inspectPrintFile(snapshot, { model, nozzleDiameters: nozzles, plateIndex, bedType });
2655
+ if (extra.signal.aborted)
2656
+ throw new Error("File inspection cancelled.");
2657
+ const byteLength = (await fs.promises.stat(snapshot)).size;
2658
+ return {
2659
+ receipt_version: "1",
2660
+ evidence: "file-only",
2661
+ dispatch_authorized: false,
2662
+ artifact: { sha256: inspection.sha256, byte_length: byteLength, file_format: path.extname(snapshot).toLowerCase() === ".3mf" ? "3mf" : "gcode" },
2663
+ selection: { bambu_model: model, plate_index: plateIndex, ...(nozzles === undefined ? {} : { nozzle_diameters: nozzles }), ...(bedType === undefined ? {} : { bed_type: bedType }) },
2664
+ inspection,
2665
+ };
2666
+ });
2667
+ break;
2668
+ }
2616
2669
  case "get_printer_status":
2617
2670
  result = await this.bambu.getStatus(host, bambuSerial, bambuToken);
2618
2671
  break;
@@ -372,10 +372,14 @@ export async function inspectPrintFile(filePath, options) {
372
372
  if (/[{}\[\]#]/.test(line))
373
373
  fail('unresolved dynamic command syntax');
374
374
  if (/^SYNC(?=$|\s)/i.test(line)) {
375
- // H2D change_filament_gcode emits SYNC T{ceil(flush_length / 125) * 5}.
376
- // T is a synchronization duration here, not a nozzle temperature. The
377
- // vendor template establishes no independent maximum duration.
375
+ // Vendor SYNC T is a duration, not a heater target. X2D and P2S
376
+ // startup templates additionally emit R0; no other R form is supported.
378
377
  const args = parameters(line.slice(4));
378
+ if (args.has('R')) {
379
+ if (!['x2d', 'p2s'].includes(model) || args.get('R') !== 0)
380
+ fail('unsupported SYNC reset parameters');
381
+ args.delete('R');
382
+ }
379
383
  if (args.size !== 1 || !args.has('T') || !Number.isFinite(args.get('T')) || args.get('T') < 0)
380
384
  fail('unsupported SYNC duration parameters');
381
385
  commandCount++;
@@ -0,0 +1,2 @@
1
+ /** Discover vendor subfolders without following links into another profile tree. */
2
+ export declare function profileJsonFiles(directory: string): string[];
@@ -0,0 +1,24 @@
1
+ import { lstatSync, readdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ /** Discover vendor subfolders without following links into another profile tree. */
4
+ export function profileJsonFiles(directory) {
5
+ let entries;
6
+ try {
7
+ if (lstatSync(directory).isSymbolicLink())
8
+ return [];
9
+ entries = readdirSync(directory, { withFileTypes: true });
10
+ }
11
+ catch (error) {
12
+ if (error.code === 'ENOENT')
13
+ return [];
14
+ throw error;
15
+ }
16
+ entries.sort((a, b) => a.name.localeCompare(b.name));
17
+ const files = entries.filter(entry => entry.isFile() && entry.name.endsWith('.json'))
18
+ .map(entry => path.join(directory, entry.name));
19
+ for (const entry of entries) {
20
+ if (entry.isDirectory())
21
+ files.push(...profileJsonFiles(path.join(directory, entry.name)));
22
+ }
23
+ return files;
24
+ }
@@ -27,6 +27,7 @@ import fs from "node:fs/promises";
27
27
  import { existsSync, realpathSync } from "node:fs";
28
28
  import path from "node:path";
29
29
  import crypto from "node:crypto";
30
+ import { profileJsonFiles } from './profile-files.js';
30
31
  /* -------------------------------------------------------------------------- */
31
32
  /* Indexing */
32
33
  /* -------------------------------------------------------------------------- */
@@ -48,19 +49,7 @@ async function buildNameIndex(profilesRoot, vendor, userProfileRoots = []) {
48
49
  ...userProfileRoots.flatMap(root => ['process', 'filament'].map(sub => path.join(root, sub))),
49
50
  ];
50
51
  for (const dir of directories) {
51
- let entries;
52
- try {
53
- entries = await fs.readdir(dir);
54
- }
55
- catch {
56
- // Vendor or subdir missing -- skip silently; flatten will fail later
57
- // with a precise "name not found" error.
58
- continue;
59
- }
60
- for (const entry of entries) {
61
- if (!entry.endsWith(".json"))
62
- continue;
63
- const filePath = path.join(dir, entry);
52
+ for (const filePath of profileJsonFiles(dir)) {
64
53
  let raw;
65
54
  try {
66
55
  raw = await fs.readFile(filePath, "utf8");
@@ -10,6 +10,7 @@ import { EventEmitter } from 'events';
10
10
  import * as crypto from 'crypto';
11
11
  import { execFile } from 'child_process';
12
12
  import { flattenForCli, detectProfilesRoot, resolveBblMachineProfile } from '../slicer/profile-flatten.js';
13
+ import { profileJsonFiles } from '../slicer/profile-files.js';
13
14
  const readFileAsync = promisify(fs.readFile);
14
15
  const writeFileAsync = promisify(fs.writeFile);
15
16
  const BAMBU_PROFILE_ROOTS = [
@@ -134,10 +135,10 @@ export class STLManipulator extends EventEmitter {
134
135
  return undefined;
135
136
  }
136
137
  for (const root of roots) {
137
- const candidate = path.join(root, category, `${profileName}.json`);
138
- if (fs.existsSync(candidate)) {
138
+ const candidate = profileJsonFiles(path.join(root, category))
139
+ .find(file => path.basename(file) === `${profileName}.json`);
140
+ if (candidate)
139
141
  return candidate;
140
- }
141
142
  }
142
143
  return undefined;
143
144
  }
@@ -148,11 +149,7 @@ export class STLManipulator extends EventEmitter {
148
149
  if (!fs.existsSync(filamentDir)) {
149
150
  continue;
150
151
  }
151
- for (const entry of fs.readdirSync(filamentDir)) {
152
- if (!entry.endsWith('.json')) {
153
- continue;
154
- }
155
- const filePath = path.join(filamentDir, entry);
152
+ for (const filePath of profileJsonFiles(filamentDir)) {
156
153
  try {
157
154
  const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
158
155
  const filamentId = typeof parsed?.filament_id === 'string' ? parsed.filament_id.trim() : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bambu-printer-mcp",
3
- "version": "1.1.26",
3
+ "version": "1.1.27",
4
4
  "description": "MCP server for Bambu Lab 3D printers with STL manipulation, slicing, and print management",
5
5
  "main": "dist/index.js",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -2900,6 +2900,23 @@ class BambuPrinterMCPServer {
2900
2900
  required: ["action", "ams_id"]
2901
2901
  }
2902
2902
  },
2903
+ {
2904
+ name: "inspect_print_file",
2905
+ description: "Inspect an exact private snapshot of a prepared sliced 3MF or textual G-code file and selected plate without contacting a printer. Returns versioned file-only evidence; no printer readiness or permission to dispatch is established. Does not auto-slice.",
2906
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2907
+ inputSchema: {
2908
+ type: "object",
2909
+ additionalProperties: false,
2910
+ properties: {
2911
+ file_path: { type: "string", minLength: 1, description: "Local prepared .3mf or textual .gcode artifact to inspect; checksum-only and unsliced projects reject." },
2912
+ bambu_model: { type: "string", enum: [...VALID_BAMBU_MODELS], description: "Explicit file-inspection target model; never inferred from environment or live state." },
2913
+ plate_index: { type: "integer", minimum: 0, description: "Zero-based selected plate; defaults to 0. Raw G-code only supports 0." },
2914
+ nozzle_diameters: { type: "array", minItems: 1, maxItems: 2, items: { type: "number", enum: [0.2, 0.4, 0.6, 0.8] }, description: "Optional requested nozzle diameters to compare with file declarations; this does not observe installed hardware." },
2915
+ bed_type: { type: "string", enum: [...VALID_BED_TYPES], description: "Optional declared bed type to compare with file metadata." },
2916
+ },
2917
+ required: ["file_path", "bambu_model"],
2918
+ },
2919
+ },
2903
2920
  {
2904
2921
  name: "print_3mf",
2905
2922
  description: "Print a 3MF file on a Bambu Lab printer. Auto-slices if the 3MF has no gcode. IMPORTANT: bambu_model must be specified to ensure safe printer operation.",
@@ -3147,6 +3164,34 @@ class BambuPrinterMCPServer {
3147
3164
  let result;
3148
3165
 
3149
3166
  switch (name) {
3167
+ case "inspect_print_file": {
3168
+ const allowed = new Set(["file_path", "bambu_model", "plate_index", "nozzle_diameters", "bed_type"]);
3169
+ if (!args || Object.keys(args).some(key => !allowed.has(key))) throw new Error("inspect_print_file accepts only file/model/plate/nozzle/bed arguments.");
3170
+ if (typeof args.file_path !== "string" || !args.file_path.trim() || args.file_path.includes("\0")) throw new Error("file_path must name a local prepared artifact.");
3171
+ if (typeof args.bambu_model !== "string" || !args.bambu_model.trim()) throw new Error("An explicit bambu_model is required for file inspection.");
3172
+ const model = validateBambuModel(args.bambu_model);
3173
+ const plateIndex = args.plate_index === undefined ? 0 : args.plate_index;
3174
+ if (typeof plateIndex !== "number" || !Number.isSafeInteger(plateIndex) || plateIndex < 0) throw new Error("plate_index must be a nonnegative safe integer.");
3175
+ if (args.nozzle_diameters !== undefined && (!Array.isArray(args.nozzle_diameters) || args.nozzle_diameters.some(value => typeof value !== "number"))) throw new Error("nozzle_diameters must be numeric sizes.");
3176
+ const nozzles = requestedPrintNozzles(args);
3177
+ if (args.bed_type !== undefined && (typeof args.bed_type !== "string" || !args.bed_type.trim())) throw new Error("bed_type must be a supported plate type.");
3178
+ const bedType = args.bed_type === undefined ? undefined : resolveBedType(args.bed_type);
3179
+ if (extra.signal.aborted) throw new Error("File inspection cancelled.");
3180
+ result = await withPrintSnapshot(args.file_path, async snapshot => {
3181
+ const inspection = await inspectPrintFile(snapshot, { model, nozzleDiameters: nozzles, plateIndex, bedType });
3182
+ if (extra.signal.aborted) throw new Error("File inspection cancelled.");
3183
+ const byteLength = (await fs.promises.stat(snapshot)).size;
3184
+ return {
3185
+ receipt_version: "1",
3186
+ evidence: "file-only",
3187
+ dispatch_authorized: false,
3188
+ artifact: { sha256: inspection.sha256, byte_length: byteLength, file_format: path.extname(snapshot).toLowerCase() === ".3mf" ? "3mf" : "gcode" },
3189
+ selection: { bambu_model: model, plate_index: plateIndex, ...(nozzles === undefined ? {} : { nozzle_diameters: nozzles }), ...(bedType === undefined ? {} : { bed_type: bedType }) },
3190
+ inspection,
3191
+ };
3192
+ });
3193
+ break;
3194
+ }
3150
3195
  case "get_printer_status":
3151
3196
  result = await this.bambu.getStatus(host, bambuSerial, bambuToken);
3152
3197
  break;
@@ -278,10 +278,13 @@ export async function inspectPrintFile(filePath: string, options: {model:string;
278
278
  line=line.replace(/^N\d+\s*/i,'').replace(/\*\d+\s*$/,'');
279
279
  if(/[{}\[\]#]/.test(line)) fail('unresolved dynamic command syntax');
280
280
  if(/^SYNC(?=$|\s)/i.test(line)) {
281
- // H2D change_filament_gcode emits SYNC T{ceil(flush_length / 125) * 5}.
282
- // T is a synchronization duration here, not a nozzle temperature. The
283
- // vendor template establishes no independent maximum duration.
281
+ // Vendor SYNC T is a duration, not a heater target. X2D and P2S
282
+ // startup templates additionally emit R0; no other R form is supported.
284
283
  const args=parameters(line.slice(4));
284
+ if(args.has('R')) {
285
+ if(!['x2d','p2s'].includes(model) || args.get('R')!==0) fail('unsupported SYNC reset parameters');
286
+ args.delete('R');
287
+ }
285
288
  if(args.size!==1 || !args.has('T') || !Number.isFinite(args.get('T')) || args.get('T')!<0) fail('unsupported SYNC duration parameters');
286
289
  commandCount++;continue;
287
290
  }
@@ -0,0 +1,21 @@
1
+ import { lstatSync, readdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /** Discover vendor subfolders without following links into another profile tree. */
5
+ export function profileJsonFiles(directory: string): string[] {
6
+ let entries;
7
+ try {
8
+ if (lstatSync(directory).isSymbolicLink()) return [];
9
+ entries = readdirSync(directory, { withFileTypes: true });
10
+ } catch (error) {
11
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
12
+ throw error;
13
+ }
14
+ entries.sort((a, b) => a.name.localeCompare(b.name));
15
+ const files = entries.filter(entry => entry.isFile() && entry.name.endsWith('.json'))
16
+ .map(entry => path.join(directory, entry.name));
17
+ for (const entry of entries) {
18
+ if (entry.isDirectory()) files.push(...profileJsonFiles(path.join(directory, entry.name)));
19
+ }
20
+ return files;
21
+ }
@@ -28,6 +28,7 @@ import fs from "node:fs/promises";
28
28
  import { existsSync, realpathSync } from "node:fs";
29
29
  import path from "node:path";
30
30
  import crypto from "node:crypto";
31
+ import { profileJsonFiles } from './profile-files.js';
31
32
 
32
33
  export type ProfileKind = "machine" | "process" | "filament";
33
34
 
@@ -125,17 +126,7 @@ async function buildNameIndex(
125
126
  ...userProfileRoots.flatMap(root => ['process', 'filament'].map(sub => path.join(root, sub))),
126
127
  ];
127
128
  for (const dir of directories) {
128
- let entries: string[];
129
- try {
130
- entries = await fs.readdir(dir);
131
- } catch {
132
- // Vendor or subdir missing -- skip silently; flatten will fail later
133
- // with a precise "name not found" error.
134
- continue;
135
- }
136
- for (const entry of entries) {
137
- if (!entry.endsWith(".json")) continue;
138
- const filePath = path.join(dir, entry);
129
+ for (const filePath of profileJsonFiles(dir)) {
139
130
  let raw: string;
140
131
  try {
141
132
  raw = await fs.readFile(filePath, "utf8");
@@ -10,6 +10,7 @@ import { EventEmitter } from 'events';
10
10
  import * as crypto from 'crypto';
11
11
  import { execFile } from 'child_process';
12
12
  import { flattenForCli, detectProfilesRoot, resolveBblMachineProfile } from '../slicer/profile-flatten.js';
13
+ import { profileJsonFiles } from '../slicer/profile-files.js';
13
14
 
14
15
  const readFileAsync = promisify(fs.readFile);
15
16
  const writeFileAsync = promisify(fs.writeFile);
@@ -232,10 +233,9 @@ export class STLManipulator extends EventEmitter {
232
233
  }
233
234
 
234
235
  for (const root of roots) {
235
- const candidate = path.join(root, category, `${profileName}.json`);
236
- if (fs.existsSync(candidate)) {
237
- return candidate;
238
- }
236
+ const candidate = profileJsonFiles(path.join(root, category))
237
+ .find(file => path.basename(file) === `${profileName}.json`);
238
+ if (candidate) return candidate;
239
239
  }
240
240
 
241
241
  return undefined;
@@ -250,12 +250,7 @@ export class STLManipulator extends EventEmitter {
250
250
  continue;
251
251
  }
252
252
 
253
- for (const entry of fs.readdirSync(filamentDir)) {
254
- if (!entry.endsWith('.json')) {
255
- continue;
256
- }
257
-
258
- const filePath = path.join(filamentDir, entry);
253
+ for (const filePath of profileJsonFiles(filamentDir)) {
259
254
  try {
260
255
  const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
261
256
  const filamentId = typeof parsed?.filament_id === 'string' ? parsed.filament_id.trim() : '';