bambu-printer-mcp 1.1.6 → 1.1.9

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 CHANGED
@@ -5,27 +5,46 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
5
5
  import { ListResourcesRequestSchema, ReadResourceRequestSchema, ListToolsRequestSchema, CallToolRequestSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
6
6
  import dotenv from "dotenv";
7
7
  import fs from "fs";
8
+ import os from "node:os";
8
9
  import path from "path";
9
10
  import { createServer as createHttpServer } from "node:http";
10
11
  import { randomUUID } from "node:crypto";
11
12
  import { STLManipulator, SLICER_TYPES, normalizeSlicerType, } from "./stl/stl-manipulator.js";
12
13
  import { BambuNetworkBridge } from "./bambu-network-bridge.js";
14
+ import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
13
15
  import { hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
14
16
  import { analyze3MFAmsRequirements, analyze3MFPlateObjects, analyzeCollarCharm3MF, extractBambuTemplateSettings, getCollarCharmRolePolicy, parse3MF } from './3mf_parser.js';
15
- import { BambuImplementation } from "./printers/bambu.js";
17
+ import { BambuImplementation, assertDirectPrintSupported } from "./printers/bambu.js";
16
18
  dotenv.config();
17
19
  const DEFAULT_HOST = process.env.BAMBU_PRINTER_HOST || process.env.PRINTER_HOST || "localhost";
18
20
  const DEFAULT_BAMBU_SERIAL = process.env.BAMBU_PRINTER_SERIAL || process.env.BAMBU_SERIAL || "";
19
21
  const DEFAULT_BAMBU_TOKEN = process.env.BAMBU_PRINTER_ACCESS_TOKEN || process.env.BAMBU_TOKEN || "";
20
22
  const DEFAULT_BAMBU_DEV_ID = process.env.BAMBU_DEV_ID || DEFAULT_BAMBU_SERIAL;
21
- const TEMP_DIR = process.env.TEMP_DIR || path.join(process.cwd(), "temp");
23
+ // os.tmpdir(), not process.cwd() — a packaged Claude Desktop extension is
24
+ // spawned with a cwd we don't control (and may not be writable), so a
25
+ // cwd-relative default here can throw at module load via the mkdirSync
26
+ // below and kill the server before it ever opens the stdio transport.
27
+ const AUTOMATIC_TEMP_DIR = process.env.TEMP_DIR ? undefined : fs.mkdtempSync(path.join(os.tmpdir(), "bambu-printer-mcp-"));
28
+ const TEMP_DIR = process.env.TEMP_DIR || AUTOMATIC_TEMP_DIR;
29
+ if (AUTOMATIC_TEMP_DIR) {
30
+ // The exit event also covers startup failures and normal event-loop exit.
31
+ // Only the directory created above belongs to us; explicit TEMP_DIR is retained.
32
+ process.once("exit", () => {
33
+ try {
34
+ fs.rmSync(AUTOMATIC_TEMP_DIR, { recursive: true, force: true });
35
+ }
36
+ catch (error) {
37
+ console.error("Unable to remove the server's temporary directory:", error);
38
+ }
39
+ });
40
+ }
22
41
  // Printer model and bed type
23
42
  const DEFAULT_BAMBU_MODEL = process.env.BAMBU_PRINTER_MODEL?.trim().toLowerCase() ||
24
43
  process.env.BAMBU_MODEL?.trim().toLowerCase() ||
25
44
  "";
26
45
  const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
27
46
  const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
28
- const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c"];
47
+ const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c", "x2d"];
29
48
  const H2_BAMBU_MODELS = new Set(["h2d", "h2s", "h2c"]);
30
49
  const VALID_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate", "supertack_plate"];
31
50
  const VALID_BAMBUSTUDIO_CLI_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"];
@@ -41,6 +60,7 @@ const BAMBU_MODEL_PRESETS = {
41
60
  h2d: (n) => `Bambu Lab H2D ${n} nozzle`,
42
61
  h2s: (n) => `Bambu Lab H2S ${n} nozzle`,
43
62
  h2c: (n) => `Bambu Lab H2C ${n} nozzle`,
63
+ x2d: (n) => `Bambu Lab X2D ${n} nozzle`,
44
64
  };
45
65
  const FILAMENT_PROFILE_DIR = "/Applications/BambuStudio.app/Contents/Resources/profiles/BBL/filament";
46
66
  const FILAMENT_MODEL_CODES = {
@@ -54,6 +74,7 @@ const FILAMENT_MODEL_CODES = {
54
74
  h2d: "H2D",
55
75
  h2s: "H2S",
56
76
  h2c: "H2C",
77
+ x2d: "X2D",
57
78
  };
58
79
  const COLLAR_CHARM_POLICY = getCollarCharmRolePolicy();
59
80
  let filamentProfileIndexCache = null;
@@ -352,6 +373,19 @@ function extractPrinterDiagnostics(status) {
352
373
  diagnostic_fields: diagnosticFields,
353
374
  };
354
375
  }
376
+ /** Parse "#RRGGBB;#RRGGBB" (leading '#' optional) into positional slot colours. */
377
+ function parseFilamentColours(value) {
378
+ const colours = value.split(";").map((c) => c.trim()).filter(Boolean)
379
+ .map((c) => (c.startsWith("#") ? c : `#${c}`).toUpperCase());
380
+ for (const c of colours) {
381
+ if (!/^#[0-9A-F]{6}([0-9A-F]{2})?$/.test(c)) {
382
+ throw new Error(`Invalid filament colour "${c}"; expected #RRGGBB.`);
383
+ }
384
+ }
385
+ if (colours.length === 0)
386
+ throw new Error("filament_colours must list at least one colour.");
387
+ return colours;
388
+ }
355
389
  function validateBambuModel(model) {
356
390
  const normalized = model.trim().toLowerCase();
357
391
  if (!VALID_BAMBU_MODELS.includes(normalized)) {
@@ -743,10 +777,12 @@ if (!fs.existsSync(TEMP_DIR)) {
743
777
  }
744
778
  class BambuPrinterMCPServer {
745
779
  constructor() {
780
+ this.blender = new BlenderMcpBridge();
781
+ this.shuttingDown = false;
746
782
  this.runtimeConfig = readRuntimeConfig();
747
783
  this.server = new Server({
748
784
  name: "bambu-printer-mcp",
749
- version: "1.0.0"
785
+ version: JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
750
786
  }, {
751
787
  capabilities: {
752
788
  resources: {},
@@ -801,6 +837,7 @@ class BambuPrinterMCPServer {
801
837
  { const: "h2d", title: "H2D" },
802
838
  { const: "h2s", title: "H2S" },
803
839
  { const: "h2c", title: "H2C" },
840
+ { const: "x2d", title: "X2D" },
804
841
  ],
805
842
  },
806
843
  },
@@ -1528,6 +1565,7 @@ class BambuPrinterMCPServer {
1528
1565
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
1529
1566
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs." },
1530
1567
  load_filament_ids: { type: "string", description: "Optional filament-to-object mapping string." },
1568
+ filament_colours: { type: "string", description: "Optional slot colours, one #RRGGBB per filament slot in order, separated by ';' (e.g. '#161616;#FFFFFF'). Defaults to the input 3MF's project colours, else the BambuStudio default." },
1531
1569
  ensure_on_bed: { type: "boolean", description: "Lift floating models onto the bed." },
1532
1570
  arrange: { type: "boolean", description: "Auto-arrange objects on the build plate." },
1533
1571
  orient: { type: "boolean", description: "Auto-orient model for optimal printability." },
@@ -1582,6 +1620,7 @@ class BambuPrinterMCPServer {
1582
1620
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs, e.g. 'pla_basic.json;petg_cf.json'." },
1583
1621
  filament_profile: { type: "string", description: "Compatibility alias for load_filaments. Semicolon-separated Orca/Bambu filament profile JSON paths." },
1584
1622
  load_filament_ids: { type: "string", description: "Map filaments to objects/parts. Comma-separated IDs matching load_filaments order, e.g. '1,2,3,1' assigns filament 1 to objects 0 and 3." },
1623
+ filament_colours: { type: "string", description: "Optional slot colours, one #RRGGBB per filament slot in order, separated by ';' (e.g. '#161616;#FFFFFF'). Defaults to the input 3MF's project colours, else the BambuStudio default." },
1585
1624
  enable_timelapse: { type: "boolean", description: "Insert timelapse parking moves into gcode. The toolhead parks at a fixed position each layer for camera capture. Adds ~10% print time." },
1586
1625
  allow_mix_temp: { type: "boolean", description: "Allow filaments with different temperature requirements on the same plate. Required for multi-material prints mixing e.g. PLA and PETG." },
1587
1626
  scale: { type: "number", description: "Uniform scale factor applied to all axes. 1.0 = original size, 2.0 = double, 0.5 = half. Applied before slicing." },
@@ -2086,7 +2125,7 @@ class BambuPrinterMCPServer {
2086
2125
  bambu_model: {
2087
2126
  type: "string",
2088
2127
  enum: [...VALID_BAMBU_MODELS],
2089
- description: "REQUIRED: Bambu Lab printer model. H2D, H2S, and H2C are the primary intended paths."
2128
+ description: "REQUIRED: Bambu Lab printer model. H2D, H2S, and H2C are the primary intended paths. X2D direct printing is not supported."
2090
2129
  },
2091
2130
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2092
2131
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
@@ -2140,28 +2179,62 @@ class BambuPrinterMCPServer {
2140
2179
  required: ["stl_path"]
2141
2180
  }
2142
2181
  },
2182
+ {
2183
+ name: "blender_mcp_status",
2184
+ description: "Inspect Blender MCP configuration or connect and discover the remote server's tools and schemas. Connecting does not edit the scene; use get_scene_info through blender_mcp_call to check the Blender addon.",
2185
+ inputSchema: {
2186
+ type: "object",
2187
+ properties: {
2188
+ connect: { type: "boolean", description: "Initialize the configured stdio MCP server and discover its tools (default false)." },
2189
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total connection and discovery deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." }
2190
+ },
2191
+ additionalProperties: false
2192
+ }
2193
+ },
2194
+ {
2195
+ name: "blender_mcp_call",
2196
+ description: "Call a discovered tool on the configured Blender MCP server, preserving its full MCP content and errors. Discover tool schemas with blender_mcp_status first; execute_blender_code accepts Python code and user_prompt. Calls can modify the active Blender scene and are never automatically retried.",
2197
+ inputSchema: {
2198
+ type: "object",
2199
+ properties: {
2200
+ tool_name: { type: "string", description: "Exact name advertised by Blender MCP, such as get_scene_info or execute_blender_code." },
2201
+ arguments: { type: "object", description: "Arguments matching the remote tool's discovered input schema. Preserve the user's own words in user_prompt when the remote tool requests it." },
2202
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total connection, discovery, and tool deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." }
2203
+ },
2204
+ required: ["tool_name"],
2205
+ additionalProperties: false
2206
+ }
2207
+ },
2143
2208
  {
2144
2209
  name: "blender_mcp_edit_model",
2145
- description: "Send STL-edit instructions to a Blender MCP bridge command for advanced model edits",
2210
+ description: "Import, edit, and export a local STL through standard Blender MCP with verified output and existing scene objects preserved. Requires a shared local filesystem and Blender Object Mode. Also supports a separately configured legacy executable bridge.",
2146
2211
  inputSchema: {
2147
2212
  type: "object",
2148
2213
  properties: {
2149
2214
  stl_path: { type: "string", description: "Path to the local STL file" },
2150
2215
  operations: {
2151
2216
  type: "array",
2152
- description: "Ordered edit operations for Blender (e.g. remesh, boolean, decimate)",
2217
+ description: "Ordered operations: decimate:<ratio greater than 0 and at most 1>, remesh:<positive voxel size in STL units>, boolean_union:<STL path>. Legacy custom bridges define their own operations.",
2218
+ minItems: 1,
2219
+ maxItems: 64,
2153
2220
  items: { type: "string" }
2154
2221
  },
2155
- bridge_command: { type: "string", description: "Override command for invoking Blender MCP bridge. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
2156
- execute: { type: "boolean", description: "Execute bridge command (true) or return payload only (false)" }
2222
+ output_path: { type: "string", description: "Required for standard MCP previews and execution: new local STL output path whose parent exists. Existing files are never overwritten. Optional for legacy-only bridge configuration." },
2223
+ user_prompt: { type: "string", description: "The user's own words describing the edit, passed unchanged to Blender MCP." },
2224
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total Blender request deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." },
2225
+ bridge_command: { type: "string", description: "Legacy custom bridge executable override, not a standard MCP command. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
2226
+ execute: { type: "boolean", description: "Apply edits and export (true) or validate and return the prepared request without connecting (false, default)." }
2157
2227
  },
2158
- required: ["stl_path", "operations"]
2228
+ required: process.env.BLENDER_MCP_COMMAND?.trim()
2229
+ ? ["stl_path", "operations", "output_path"]
2230
+ : ["stl_path", "operations"],
2231
+ additionalProperties: false
2159
2232
  }
2160
2233
  }
2161
2234
  ]
2162
2235
  };
2163
2236
  });
2164
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
2237
+ this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
2165
2238
  const { name, arguments: args } = request.params;
2166
2239
  const host = String(args?.host || DEFAULT_HOST);
2167
2240
  const bambuSerial = String(args?.bambu_serial || DEFAULT_BAMBU_SERIAL);
@@ -2288,23 +2361,27 @@ class BambuPrinterMCPServer {
2288
2361
  }
2289
2362
  break;
2290
2363
  }
2291
- case "upload_file":
2364
+ case "upload_file": {
2292
2365
  if (!args?.file_path || !args?.filename) {
2293
2366
  throw new Error("Missing required parameters: file_path and filename");
2294
2367
  }
2295
- if (Boolean(args.print ?? false)) {
2296
- await this.resolveBambuModel(args?.bambu_model);
2297
- }
2298
- result = await this.bambu.uploadFile(host, bambuSerial, bambuToken, String(args.file_path), String(args.filename), Boolean(args.print ?? false));
2368
+ const print = Boolean(args.print ?? false);
2369
+ const uploadModel = print ? await this.resolveBambuModel(args?.bambu_model) : undefined;
2370
+ if (print)
2371
+ assertDirectPrintSupported(uploadModel, bambuSerial);
2372
+ result = await this.bambu.uploadFile(host, bambuSerial, bambuToken, String(args.file_path), String(args.filename), print, uploadModel);
2299
2373
  break;
2374
+ }
2300
2375
  case "start_print":
2301
- case "start_print_job":
2376
+ case "start_print_job": {
2302
2377
  if (!args?.filename) {
2303
2378
  throw new Error("Missing required parameter: filename");
2304
2379
  }
2305
- await this.resolveBambuModel(args?.bambu_model);
2306
- result = await this.bambu.startJob(host, bambuSerial, bambuToken, String(args.filename));
2380
+ const startModel = await this.resolveBambuModel(args?.bambu_model);
2381
+ assertDirectPrintSupported(startModel, bambuSerial);
2382
+ result = await this.bambu.startJob(host, bambuSerial, bambuToken, String(args.filename), startModel);
2307
2383
  break;
2384
+ }
2308
2385
  case "cancel_print":
2309
2386
  result = await this.bambu.cancelJob(host, bambuSerial, bambuToken);
2310
2387
  break;
@@ -2444,6 +2521,8 @@ class BambuPrinterMCPServer {
2444
2521
  }
2445
2522
  if (args?.load_filament_ids !== undefined)
2446
2523
  sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
2524
+ if (args?.filament_colours !== undefined)
2525
+ sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
2447
2526
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type);
2448
2527
  if (args?.enable_timelapse !== undefined)
2449
2528
  sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
@@ -2521,6 +2600,8 @@ class BambuPrinterMCPServer {
2521
2600
  }
2522
2601
  if (args?.load_filament_ids !== undefined)
2523
2602
  sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
2603
+ if (args?.filament_colours !== undefined)
2604
+ sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
2524
2605
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type);
2525
2606
  if (args?.enable_timelapse !== undefined)
2526
2607
  sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
@@ -2573,6 +2654,7 @@ class BambuPrinterMCPServer {
2573
2654
  }
2574
2655
  const { slicerType, slicerPath, slicerProfile } = this.resolveSlicerConfigFromArgs(args, "print_3mf");
2575
2656
  const printModel = await this.resolveBambuModel(args?.bambu_model);
2657
+ assertDirectPrintSupported(printModel, bambuSerial);
2576
2658
  const printBedType = resolveBedType(args?.bed_type);
2577
2659
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2578
2660
  const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(args, slicerProfile || undefined, resolveTemplatePathFromName(["json", "config", "3mf"]) || explicitTemplatePath || undefined, TEMP_DIR);
@@ -2583,45 +2665,37 @@ class BambuPrinterMCPServer {
2583
2665
  throw new Error("plate_index must be a non-negative integer.");
2584
2666
  }
2585
2667
  let threeMFPath = String(args.three_mf_path);
2586
- // Auto-slice if 3MF has no gcode
2587
- try {
2588
- const JSZip = (await import('jszip')).default;
2589
- const zipData = fs.readFileSync(threeMFPath);
2590
- const zip = await JSZip.loadAsync(zipData);
2591
- const hasGcode = Object.keys(zip.files).some(f => f.match(/Metadata\/plate_\d+\.gcode/i) || f.endsWith('.gcode'));
2592
- if (!hasGcode) {
2593
- if (printBedType === "supertack_plate") {
2594
- throw new Error('BambuStudio CLI SuperTack bed type is not verified; use a pre-sliced 3MF for SuperTack or choose textured_plate, cool_plate, engineering_plate, or hot_plate.');
2595
- }
2596
- console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
2597
- const autoSliceOptions = {
2598
- uptodate: true,
2599
- ensureOnBed: true,
2600
- minSave: true,
2601
- skipModifiedGcodes: true,
2602
- bedType: printBedType,
2603
- };
2604
- if (!explicitSlicerProfile) {
2605
- try {
2606
- const liveFilaments = await this.getResolvedPrinterFilamentInventory(host, bambuSerial, bambuToken, printModel, printNozzle);
2607
- if (liveFilaments.recommended?.load_filaments) {
2608
- autoSliceOptions.loadFilaments = liveFilaments.recommended.load_filaments;
2609
- }
2610
- }
2611
- catch (filamentError) {
2612
- console.warn("Could not resolve live printer filaments for auto-slicing:", filamentError);
2668
+ // Inspect and auto-slice before upload. Preserve any failure instead of sending an unsliced project.
2669
+ const JSZip = (await import('jszip')).default;
2670
+ const zipData = fs.readFileSync(threeMFPath);
2671
+ const zip = await JSZip.loadAsync(zipData);
2672
+ const hasGcode = Object.values(zip.files).some(entry => !entry.dir && /\.gcode$/i.test(entry.name));
2673
+ if (!hasGcode) {
2674
+ if (printBedType === "supertack_plate") {
2675
+ throw new Error('BambuStudio CLI SuperTack bed type is not verified; use a pre-sliced 3MF for SuperTack or choose textured_plate, cool_plate, engineering_plate, or hot_plate.');
2676
+ }
2677
+ console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
2678
+ const autoSliceOptions = {
2679
+ uptodate: true,
2680
+ ensureOnBed: true,
2681
+ minSave: true,
2682
+ skipModifiedGcodes: true,
2683
+ bedType: printBedType,
2684
+ };
2685
+ if (!explicitSlicerProfile) {
2686
+ try {
2687
+ const liveFilaments = await this.getResolvedPrinterFilamentInventory(host, bambuSerial, bambuToken, printModel, printNozzle);
2688
+ if (liveFilaments.recommended?.load_filaments) {
2689
+ autoSliceOptions.loadFilaments = liveFilaments.recommended.load_filaments;
2613
2690
  }
2614
2691
  }
2615
- threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, slicerPath, activeSlicerProfile, undefined, // progressCallback
2616
- printPreset, autoSliceOptions);
2617
- console.log("Auto-sliced to: " + threeMFPath);
2618
- }
2619
- }
2620
- catch (sliceCheckErr) {
2621
- if (String(sliceCheckErr?.message || "").includes("SuperTack")) {
2622
- throw sliceCheckErr;
2692
+ catch (filamentError) {
2693
+ console.warn("Could not resolve live printer filaments for auto-slicing:", filamentError);
2694
+ }
2623
2695
  }
2624
- console.warn("Could not check/slice 3MF, proceeding with original:", sliceCheckErr.message);
2696
+ threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, slicerPath, activeSlicerProfile, undefined, // progressCallback
2697
+ printPreset, autoSliceOptions);
2698
+ console.log("Auto-sliced to: " + threeMFPath);
2625
2699
  }
2626
2700
  const parsed3MFData = await parse3MF(threeMFPath);
2627
2701
  const isH2Print = H2_BAMBU_MODELS.has(printModel);
@@ -2721,6 +2795,7 @@ class BambuPrinterMCPServer {
2721
2795
  }
2722
2796
  const { slicerType, slicerPath, slicerProfile } = this.resolveSlicerConfigFromArgs(args, "print_collar_charm");
2723
2797
  const printModel = await this.resolveBambuModel(args?.bambu_model);
2798
+ assertDirectPrintSupported(printModel, bambuSerial);
2724
2799
  const printBedType = resolveBedType(args?.bed_type);
2725
2800
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2726
2801
  const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(args, slicerProfile || undefined, resolvedTemplateSourcePath || explicitTemplatePath || undefined, TEMP_DIR);
@@ -2783,16 +2858,13 @@ class BambuPrinterMCPServer {
2783
2858
  throw new Error("Missing required parameter: stl_path");
2784
2859
  result = await this.stlManipulator.layFlat(String(args.stl_path));
2785
2860
  break;
2861
+ case "blender_mcp_status":
2862
+ result = await this.blender.status(args ?? {}, extra.signal);
2863
+ break;
2864
+ case "blender_mcp_call":
2865
+ return await this.blender.call(args ?? {}, extra.signal);
2786
2866
  case "blender_mcp_edit_model":
2787
- if (!args?.stl_path || !Array.isArray(args.operations)) {
2788
- throw new Error("Missing required parameters: stl_path and operations");
2789
- }
2790
- result = await this.invokeBlenderBridge({
2791
- stlPath: String(args.stl_path),
2792
- operations: args.operations.map((entry) => String(entry)),
2793
- execute: Boolean(args.execute ?? false),
2794
- bridgeCommand: this.resolveExecutableSelectorArg(args.bridge_command, "blender_mcp_edit_model", "bridge_command", true) ?? this.runtimeConfig.blenderBridgeCommand,
2795
- });
2867
+ result = await this.blender.edit(args ?? {}, this.resolveExecutableSelectorArg(args?.bridge_command, "blender_mcp_edit_model", "bridge_command", true) ?? (!process.env.BLENDER_MCP_COMMAND?.trim() ? this.runtimeConfig.blenderBridgeCommand : undefined), extra.signal);
2796
2868
  break;
2797
2869
  default:
2798
2870
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
@@ -2823,38 +2895,28 @@ class BambuPrinterMCPServer {
2823
2895
  }
2824
2896
  });
2825
2897
  }
2826
- async invokeBlenderBridge(params) {
2827
- const payload = {
2828
- stlPath: params.stlPath,
2829
- operations: params.operations,
2830
- };
2831
- if (!params.execute || !params.bridgeCommand) {
2832
- return {
2833
- status: "prepared",
2834
- payload,
2835
- note: params.bridgeCommand
2836
- ? "Set execute=true to run the Blender bridge command."
2837
- : "No BLENDER_MCP_BRIDGE_COMMAND configured. Set the env var or pass bridge_command.",
2838
- };
2839
- }
2840
- const { execFile } = await import("node:child_process");
2841
- const { promisify } = await import("node:util");
2842
- const execFileAsync = promisify(execFile);
2843
- const { stdout, stderr } = await execFileAsync(params.bridgeCommand, [], {
2844
- env: { ...process.env, MCP_BLENDER_PAYLOAD: JSON.stringify(payload) },
2845
- timeout: 120000,
2846
- });
2847
- return {
2848
- status: "executed",
2849
- stdout: stdout.trim(),
2850
- stderr: stderr.trim(),
2851
- };
2852
- }
2853
2898
  async startStdio() {
2854
2899
  const transport = new StdioServerTransport();
2855
2900
  await this.server.connect(transport);
2901
+ process.stdin.once("end", () => { void this.shutdown(); });
2856
2902
  console.error("Bambu Printer MCP server running on stdio");
2857
2903
  }
2904
+ async shutdown(exitCode = 0) {
2905
+ if (this.shuttingDown)
2906
+ return;
2907
+ this.shuttingDown = true;
2908
+ try {
2909
+ // Closing the MCP transport aborts its active requests before exit cleanup.
2910
+ await this.server.close();
2911
+ this.httpRuntime?.httpServer.close();
2912
+ }
2913
+ catch (error) {
2914
+ console.error("[MCP Shutdown]", error);
2915
+ }
2916
+ finally {
2917
+ process.exit(exitCode);
2918
+ }
2919
+ }
2858
2920
  async startHttp() {
2859
2921
  const { httpHost, httpPort, httpPath, statefulSession, enableJsonResponse, allowedOrigins } = this.runtimeConfig;
2860
2922
  const transport = new StreamableHTTPServerTransport({
@@ -2894,4 +2956,6 @@ class BambuPrinterMCPServer {
2894
2956
  }
2895
2957
  }
2896
2958
  const server = new BambuPrinterMCPServer();
2959
+ process.once("SIGINT", () => { void server.shutdown(130); });
2960
+ process.once("SIGTERM", () => { void server.shutdown(143); });
2897
2961
  server.run().catch(console.error);
@@ -1,3 +1,4 @@
1
+ export declare function assertDirectPrintSupported(model: string | undefined, serial?: string): void;
1
2
  interface BambuPrintOptionsInternal {
2
3
  projectName: string;
3
4
  filePath: string;
@@ -67,8 +68,8 @@ export declare class BambuImplementation {
67
68
  name: string;
68
69
  exists: boolean;
69
70
  }>;
70
- uploadFile(host: string, serial: string, token: string, filePath: string, filename: string, print: boolean): Promise<Record<string, unknown>>;
71
- startJob(host: string, serial: string, token: string, filename: string): Promise<{
71
+ uploadFile(host: string, serial: string, token: string, filePath: string, filename: string, print: boolean, bambuModel?: string): Promise<Record<string, unknown>>;
72
+ startJob(host: string, serial: string, token: string, filename: string, bambuModel?: string): Promise<{
72
73
  status: string;
73
74
  message: string;
74
75
  file: string;
@@ -37,6 +37,7 @@ const MODEL_ID_TO_NAME = {
37
37
  O1D: "H2D",
38
38
  O1E: "H2D Pro",
39
39
  O1S: "H2S",
40
+ N6: "X2D",
40
41
  N2S: "A1",
41
42
  A1M: "A1 Mini",
42
43
  C11: "P1P",
@@ -46,9 +47,18 @@ const MODEL_ID_TO_NAME = {
46
47
  C13: "X1E",
47
48
  };
48
49
  const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s"]);
50
+ export function assertDirectPrintSupported(model, serial) {
51
+ if (model?.trim().toLowerCase() === "x2d" || serial?.trim().toUpperCase().startsWith("20P")) {
52
+ throw new Error("X2D direct printing is not supported pending native eMMC transport support. " +
53
+ "X2D status and slicing remain available; use a supported slicer to print.");
54
+ }
55
+ }
49
56
  function isH2ModelName(model) {
50
57
  return H2_MODEL_NAMES.has(String(model ?? "").trim().toLowerCase().replace(/\s+/g, " "));
51
58
  }
59
+ function isP2SModelName(model) {
60
+ return String(model ?? "").trim().toLowerCase() === "p2s";
61
+ }
52
62
  function sleep(ms) {
53
63
  return new Promise((resolve) => setTimeout(resolve, ms));
54
64
  }
@@ -88,6 +98,12 @@ class TolerantBambuClient extends BambuClient {
88
98
  return "H2S";
89
99
  if (sn.startsWith("094"))
90
100
  return "H2D";
101
+ if (sn.startsWith("239"))
102
+ return "H2C";
103
+ if (sn.startsWith("31B"))
104
+ return "H2DPRO";
105
+ if (sn.startsWith("20P"))
106
+ return "X2D";
91
107
  if (sn.startsWith("00M"))
92
108
  return "X1C";
93
109
  if (sn.startsWith("00W"))
@@ -98,6 +114,8 @@ class TolerantBambuClient extends BambuClient {
98
114
  return "P1P";
99
115
  if (sn.startsWith("01P"))
100
116
  return "P1S";
117
+ if (sn.startsWith("22E"))
118
+ return "P2S";
101
119
  if (sn.startsWith("030"))
102
120
  return "A1";
103
121
  if (sn.startsWith("039"))
@@ -448,6 +466,7 @@ export class BambuImplementation {
448
466
  }
449
467
  }
450
468
  async print3mf(host, serial, token, options) {
469
+ assertDirectPrintSupported(options.bambuModel, serial);
451
470
  if (!options.filePath.toLowerCase().endsWith(".3mf")) {
452
471
  throw new Error("print3mf requires a .3mf input file.");
453
472
  }
@@ -457,24 +476,33 @@ export class BambuImplementation {
457
476
  let remoteFileName = path.basename(options.filePath);
458
477
  remoteFileName = remoteFileName.replace(/\.gcode\.3mf\.gcode\.3mf$/i, ".gcode.3mf");
459
478
  // H2-series printers land files at the FTP root and reference them via ftp:///<name>.
460
- // P1/A1/X1 use /cache/<name> and file:///sdcard/cache/<name>.
479
+ // Full-size A1 uses SD root/project_file (reported on firmware 01.08.01.00).
480
+ // P1/X1/A1 mini retain their legacy cache/gcode_file route.
481
+ // P2S keeps the /cache/<name> upload but only accepts the H2-style
482
+ // project_file command, referenced via ftp:///cache/<name> (verified on
483
+ // P2S firmware 01.02.00.00; file:///sdcard/... fails with ERROR STATE).
461
484
  const isH2 = serial.startsWith("093") ||
462
485
  serial.startsWith("094") ||
463
486
  isH2ModelName(options.bambuModel);
464
- const remoteProjectPath = isH2 ? remoteFileName : `cache/${remoteFileName}`;
465
- const remoteUploadPath = isH2 ? `/${remoteFileName}` : `/cache/${remoteFileName}`;
466
- const projectUrl = isH2
467
- ? `ftp:///${remoteFileName}`
487
+ const isP2S = serial.startsWith("22E") || isP2SModelName(options.bambuModel);
488
+ const isA1 = String(options.bambuModel ?? "").trim().toLowerCase() === "a1" ||
489
+ (!options.bambuModel && serial.startsWith("030"));
490
+ const usesH2ProjectFile = isH2 || isP2S;
491
+ const remoteProjectPath = isH2 || isA1 ? remoteFileName : `cache/${remoteFileName}`;
492
+ const remoteUploadPath = `/${remoteProjectPath}`;
493
+ const projectUrl = usesH2ProjectFile
494
+ ? `ftp:///${remoteProjectPath}`
468
495
  : `file:///sdcard/${remoteProjectPath}`;
469
496
  // Upload via basic-ftp directly (bypasses bambu-js double-path bug)
470
497
  await this.ftpUpload(host, token, options.filePath, remoteUploadPath);
471
498
  // Pre-sliced .gcode.3mf files: routing depends on firmware generation.
472
- // P1/A1/X1 series: project_file returns 405004002 for .gcode.3mf (firmware
499
+ // Legacy P1/X1/A1 mini: project_file returns 405004002 for .gcode.3mf (firmware
473
500
  // doesn't recognise the container), so use gcode_file instead.
474
- // H2-series: gcode_file is not supported; project_file works because the
501
+ // H2-series and P2S: gcode_file is not supported (P2S answers 0500-4002
502
+ // "Unsupported file path or name"); project_file works because the
475
503
  // firmware can open the zip and find Metadata/plate_<n>.gcode directly.
476
504
  if (options.filePath.toLowerCase().endsWith(".gcode.3mf")) {
477
- if (!isH2) {
505
+ if (!usesH2ProjectFile && !isA1) {
478
506
  const printer = await this.getPrinter(host, serial, token);
479
507
  await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
480
508
  return {
@@ -483,7 +511,7 @@ export class BambuImplementation {
483
511
  remoteProjectPath,
484
512
  };
485
513
  }
486
- // H2-series: fall through to project_file path below
514
+ // H2-series and P2S: fall through to project_file path below
487
515
  }
488
516
  const projectMetadata = await this.resolveProjectFileMetadata(options.filePath, options.plateIndex);
489
517
  // Send project_file command via bambu-node MQTT (bypasses bambu-js
@@ -550,7 +578,7 @@ export class BambuImplementation {
550
578
  }
551
579
  let amsMapping;
552
580
  let amsMapping2;
553
- if (isH2) {
581
+ if (usesH2ProjectFile) {
554
582
  const projLen = Math.max(projectMetadata.projectFilamentCount, baseMapping.length, 1);
555
583
  amsMapping = Array.from({ length: projLen }, (_, i) => i < baseMapping.length ? baseMapping[i] : -1);
556
584
  amsMapping2 = amsMapping.map((v) => {
@@ -564,12 +592,12 @@ export class BambuImplementation {
564
592
  });
565
593
  }
566
594
  else {
567
- amsMapping = Array.from({ length: 5 }, (_, i) => i < baseMapping.length ? baseMapping[i] : -1);
595
+ amsMapping = Array.from({ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) }, (_, i) => i < baseMapping.length ? baseMapping[i] : -1);
568
596
  amsMapping2 = [];
569
597
  }
570
598
  const b = (v) => (v ? 1 : 0);
571
599
  let projectFileCmd;
572
- if (isH2) {
600
+ if (usesH2ProjectFile) {
573
601
  const submissionId = String(Date.now() & 0x7fffffff);
574
602
  projectFileCmd = {
575
603
  print: {
@@ -911,7 +939,9 @@ export class BambuImplementation {
911
939
  exists,
912
940
  };
913
941
  }
914
- async uploadFile(host, serial, token, filePath, filename, print) {
942
+ async uploadFile(host, serial, token, filePath, filename, print, bambuModel) {
943
+ if (print)
944
+ assertDirectPrintSupported(bambuModel, serial);
915
945
  await fs.access(filePath);
916
946
  const normalizedFileName = filename.replace(/^\/+/, "");
917
947
  const remotePath = normalizedFileName.includes("/")
@@ -927,7 +957,7 @@ export class BambuImplementation {
927
957
  };
928
958
  if (print) {
929
959
  if (remotePath.toLowerCase().endsWith(".gcode")) {
930
- response.startResult = await this.startJob(host, serial, token, remotePath);
960
+ response.startResult = await this.startJob(host, serial, token, remotePath, bambuModel);
931
961
  }
932
962
  else if (remotePath.toLowerCase().endsWith(".3mf")) {
933
963
  response.note =
@@ -939,7 +969,8 @@ export class BambuImplementation {
939
969
  }
940
970
  return response;
941
971
  }
942
- async startJob(host, serial, token, filename) {
972
+ async startJob(host, serial, token, filename, bambuModel) {
973
+ assertDirectPrintSupported(bambuModel, serial);
943
974
  const lower = filename.toLowerCase();
944
975
  if (lower.endsWith(".3mf") && !lower.endsWith(".gcode.3mf")) {
945
976
  throw new Error("Use print_3mf for .3mf project files.");
@@ -989,15 +1020,15 @@ export class BambuImplementation {
989
1020
  // P1/A1 series still use the proprietary TCP-on-6000 framed JPEG path
990
1021
  // (per https://github.com/Doridian/OpenBambuAPI/blob/main/video.md).
991
1022
  const TCP_CAMERA_MODELS = new Set(["a1", "a1mini", "p1s", "p1p"]);
992
- // X1, P2S, AND H2 (H2S/H2D/H2C) all use RTSP on port 322. The
1023
+ // X1, P2S, H2 (H2S/H2D/H2C), AND X2D all use RTSP on port 322. The
993
1024
  // OpenBambuAPI doc only mentions X1/P2S, but the HA bambulab
994
1025
  // integration's models.py shows the printer reports its own
995
1026
  // `ipcam.rtsp_url` for these models, and Parker (H2S) rejects the
996
1027
  // A1/P1 80-byte auth packet on port 6000 (verified 2026-04-27 --
997
- // see PROGRESS.md "H2 probe results").
1028
+ // confirmed by local H2 camera transport probes).
998
1029
  const RTSP_MODELS = new Set([
999
1030
  "x1", "x1c", "x1carbon", "x1e", "p2s",
1000
- "h2", "h2s", "h2d", "h2c", "h2dpro",
1031
+ "h2", "h2s", "h2d", "h2c", "h2dpro", "x2d",
1001
1032
  ]);
1002
1033
  if (!model) {
1003
1034
  throw new Error("camera_snapshot requires bambu_model or BAMBU_MODEL so it can choose the correct Bambu camera protocol.");