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/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  } from "@modelcontextprotocol/sdk/types.js";
14
14
  import dotenv from "dotenv";
15
15
  import fs from "fs";
16
+ import os from "node:os";
16
17
  import path from "path";
17
18
  import { createServer as createHttpServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from "node:http";
18
19
  import { randomUUID } from "node:crypto";
@@ -24,10 +25,11 @@ import {
24
25
  type SlicerType,
25
26
  } from "./stl/stl-manipulator.js";
26
27
  import { BambuNetworkBridge, type BambuNetworkBridgeOptions } from "./bambu-network-bridge.js";
28
+ import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
27
29
  import { hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
28
30
  import { analyze3MFAmsRequirements, analyze3MFPlateObjects, analyzeCollarCharm3MF, extractBambuTemplateSettings, getCollarCharmRolePolicy, parse3MF } from './3mf_parser.js';
29
31
  import type { ThreeMFAmsRequirements } from "./types.js";
30
- import { BambuImplementation } from "./printers/bambu.js";
32
+ import { BambuImplementation, assertDirectPrintSupported } from "./printers/bambu.js";
31
33
 
32
34
  dotenv.config();
33
35
 
@@ -36,7 +38,23 @@ const DEFAULT_BAMBU_SERIAL = process.env.BAMBU_PRINTER_SERIAL || process.env.BAM
36
38
  const DEFAULT_BAMBU_TOKEN =
37
39
  process.env.BAMBU_PRINTER_ACCESS_TOKEN || process.env.BAMBU_TOKEN || "";
38
40
  const DEFAULT_BAMBU_DEV_ID = process.env.BAMBU_DEV_ID || DEFAULT_BAMBU_SERIAL;
39
- const TEMP_DIR = process.env.TEMP_DIR || path.join(process.cwd(), "temp");
41
+ // os.tmpdir(), not process.cwd() — a packaged Claude Desktop extension is
42
+ // spawned with a cwd we don't control (and may not be writable), so a
43
+ // cwd-relative default here can throw at module load via the mkdirSync
44
+ // below and kill the server before it ever opens the stdio transport.
45
+ const AUTOMATIC_TEMP_DIR = process.env.TEMP_DIR ? undefined : fs.mkdtempSync(path.join(os.tmpdir(), "bambu-printer-mcp-"));
46
+ const TEMP_DIR = process.env.TEMP_DIR || AUTOMATIC_TEMP_DIR!;
47
+ if (AUTOMATIC_TEMP_DIR) {
48
+ // The exit event also covers startup failures and normal event-loop exit.
49
+ // Only the directory created above belongs to us; explicit TEMP_DIR is retained.
50
+ process.once("exit", () => {
51
+ try {
52
+ fs.rmSync(AUTOMATIC_TEMP_DIR, { recursive: true, force: true });
53
+ } catch (error) {
54
+ console.error("Unable to remove the server's temporary directory:", error);
55
+ }
56
+ });
57
+ }
40
58
 
41
59
  // Printer model and bed type
42
60
  const DEFAULT_BAMBU_MODEL =
@@ -46,7 +64,7 @@ const DEFAULT_BAMBU_MODEL =
46
64
  const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
47
65
  const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
48
66
 
49
- const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c"] as const;
67
+ const VALID_BAMBU_MODELS = ["p1s", "p1p", "p2s", "x1c", "x1e", "a1", "a1mini", "h2d", "h2s", "h2c", "x2d"] as const;
50
68
  type BambuModel = typeof VALID_BAMBU_MODELS[number];
51
69
  const H2_BAMBU_MODELS = new Set<string>(["h2d", "h2s", "h2c"]);
52
70
 
@@ -65,6 +83,7 @@ const BAMBU_MODEL_PRESETS: Record<string, (nozzle: string) => string> = {
65
83
  h2d: (n) => `Bambu Lab H2D ${n} nozzle`,
66
84
  h2s: (n) => `Bambu Lab H2S ${n} nozzle`,
67
85
  h2c: (n) => `Bambu Lab H2C ${n} nozzle`,
86
+ x2d: (n) => `Bambu Lab X2D ${n} nozzle`,
68
87
  };
69
88
 
70
89
  const FILAMENT_PROFILE_DIR =
@@ -80,6 +99,7 @@ const FILAMENT_MODEL_CODES: Record<string, string> = {
80
99
  h2d: "H2D",
81
100
  h2s: "H2S",
82
101
  h2c: "H2C",
102
+ x2d: "X2D",
83
103
  };
84
104
 
85
105
  type FilamentProfileIndex = {
@@ -498,6 +518,19 @@ function extractPrinterDiagnostics(status: any): Record<string, unknown> {
498
518
  };
499
519
  }
500
520
 
521
+ /** Parse "#RRGGBB;#RRGGBB" (leading '#' optional) into positional slot colours. */
522
+ function parseFilamentColours(value: string): string[] {
523
+ const colours = value.split(";").map((c) => c.trim()).filter(Boolean)
524
+ .map((c) => (c.startsWith("#") ? c : `#${c}`).toUpperCase());
525
+ for (const c of colours) {
526
+ if (!/^#[0-9A-F]{6}([0-9A-F]{2})?$/.test(c)) {
527
+ throw new Error(`Invalid filament colour "${c}"; expected #RRGGBB.`);
528
+ }
529
+ }
530
+ if (colours.length === 0) throw new Error("filament_colours must list at least one colour.");
531
+ return colours;
532
+ }
533
+
501
534
  function validateBambuModel(model: string): string {
502
535
  const normalized = model.trim().toLowerCase();
503
536
  if (!VALID_BAMBU_MODELS.includes(normalized as BambuModel)) {
@@ -1015,16 +1048,18 @@ class BambuPrinterMCPServer {
1015
1048
  private server: Server;
1016
1049
  private bambu: BambuImplementation;
1017
1050
  private bambuNetwork: BambuNetworkBridge;
1051
+ private readonly blender = new BlenderMcpBridge();
1018
1052
  private stlManipulator: STLManipulator;
1019
1053
  private readonly runtimeConfig: RuntimeConfig;
1020
1054
  private httpRuntime?: { transport: StreamableHTTPServerTransport; httpServer: HttpServer };
1055
+ private shuttingDown = false;
1021
1056
 
1022
1057
  constructor() {
1023
1058
  this.runtimeConfig = readRuntimeConfig();
1024
1059
  this.server = new Server(
1025
1060
  {
1026
1061
  name: "bambu-printer-mcp",
1027
- version: "1.0.0"
1062
+ version: JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
1028
1063
  },
1029
1064
  {
1030
1065
  capabilities: {
@@ -1088,6 +1123,7 @@ class BambuPrinterMCPServer {
1088
1123
  { const: "h2d", title: "H2D" },
1089
1124
  { const: "h2s", title: "H2S" },
1090
1125
  { const: "h2c", title: "H2C" },
1126
+ { const: "x2d", title: "X2D" },
1091
1127
  ],
1092
1128
  },
1093
1129
  },
@@ -2025,6 +2061,7 @@ class BambuPrinterMCPServer {
2025
2061
  bambu_token: { type: "string", description: "Access token (default: value from env)" },
2026
2062
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs." },
2027
2063
  load_filament_ids: { type: "string", description: "Optional filament-to-object mapping string." },
2064
+ 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." },
2028
2065
  ensure_on_bed: { type: "boolean", description: "Lift floating models onto the bed." },
2029
2066
  arrange: { type: "boolean", description: "Auto-arrange objects on the build plate." },
2030
2067
  orient: { type: "boolean", description: "Auto-orient model for optimal printability." },
@@ -2079,6 +2116,7 @@ class BambuPrinterMCPServer {
2079
2116
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs, e.g. 'pla_basic.json;petg_cf.json'." },
2080
2117
  filament_profile: { type: "string", description: "Compatibility alias for load_filaments. Semicolon-separated Orca/Bambu filament profile JSON paths." },
2081
2118
  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." },
2119
+ 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." },
2082
2120
  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." },
2083
2121
  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." },
2084
2122
  scale: { type: "number", description: "Uniform scale factor applied to all axes. 1.0 = original size, 2.0 = double, 0.5 = half. Applied before slicing." },
@@ -2583,7 +2621,7 @@ class BambuPrinterMCPServer {
2583
2621
  bambu_model: {
2584
2622
  type: "string",
2585
2623
  enum: [...VALID_BAMBU_MODELS],
2586
- description: "REQUIRED: Bambu Lab printer model. H2D, H2S, and H2C are the primary intended paths."
2624
+ description: "REQUIRED: Bambu Lab printer model. H2D, H2S, and H2C are the primary intended paths. X2D direct printing is not supported."
2587
2625
  },
2588
2626
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
2589
2627
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
@@ -2637,29 +2675,63 @@ class BambuPrinterMCPServer {
2637
2675
  required: ["stl_path"]
2638
2676
  }
2639
2677
  },
2678
+ {
2679
+ name: "blender_mcp_status",
2680
+ 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.",
2681
+ inputSchema: {
2682
+ type: "object",
2683
+ properties: {
2684
+ connect: { type: "boolean", description: "Initialize the configured stdio MCP server and discover its tools (default false)." },
2685
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total connection and discovery deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." }
2686
+ },
2687
+ additionalProperties: false
2688
+ }
2689
+ },
2690
+ {
2691
+ name: "blender_mcp_call",
2692
+ 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.",
2693
+ inputSchema: {
2694
+ type: "object",
2695
+ properties: {
2696
+ tool_name: { type: "string", description: "Exact name advertised by Blender MCP, such as get_scene_info or execute_blender_code." },
2697
+ 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." },
2698
+ 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." }
2699
+ },
2700
+ required: ["tool_name"],
2701
+ additionalProperties: false
2702
+ }
2703
+ },
2640
2704
  {
2641
2705
  name: "blender_mcp_edit_model",
2642
- description: "Send STL-edit instructions to a Blender MCP bridge command for advanced model edits",
2706
+ 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.",
2643
2707
  inputSchema: {
2644
2708
  type: "object",
2645
2709
  properties: {
2646
2710
  stl_path: { type: "string", description: "Path to the local STL file" },
2647
2711
  operations: {
2648
2712
  type: "array",
2649
- description: "Ordered edit operations for Blender (e.g. remesh, boolean, decimate)",
2713
+ 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.",
2714
+ minItems: 1,
2715
+ maxItems: 64,
2650
2716
  items: { type: "string" }
2651
2717
  },
2652
- bridge_command: { type: "string", description: "Override command for invoking Blender MCP bridge. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
2653
- execute: { type: "boolean", description: "Execute bridge command (true) or return payload only (false)" }
2718
+ 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." },
2719
+ user_prompt: { type: "string", description: "The user's own words describing the edit, passed unchanged to Blender MCP." },
2720
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total Blender request deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." },
2721
+ bridge_command: { type: "string", description: "Legacy custom bridge executable override, not a standard MCP command. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
2722
+ execute: { type: "boolean", description: "Apply edits and export (true) or validate and return the prepared request without connecting (false, default)." }
2654
2723
  },
2655
- required: ["stl_path", "operations"]
2724
+ required: process.env.BLENDER_MCP_COMMAND?.trim()
2725
+ ? ["stl_path", "operations", "output_path"]
2726
+ : ["stl_path", "operations"],
2727
+ additionalProperties: false
2656
2728
  }
2657
2729
  }
2658
2730
  ]
2659
2731
  };
2660
2732
  });
2661
2733
 
2662
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
2734
+ this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
2663
2735
  const { name, arguments: args } = request.params;
2664
2736
 
2665
2737
  const host = String(args?.host || DEFAULT_HOST);
@@ -2841,28 +2913,31 @@ class BambuPrinterMCPServer {
2841
2913
  break;
2842
2914
  }
2843
2915
 
2844
- case "upload_file":
2916
+ case "upload_file": {
2845
2917
  if (!args?.file_path || !args?.filename) {
2846
2918
  throw new Error("Missing required parameters: file_path and filename");
2847
2919
  }
2848
- if (Boolean(args.print ?? false)) {
2849
- await this.resolveBambuModel(args?.bambu_model as string | undefined);
2850
- }
2920
+ const print = Boolean(args.print ?? false);
2921
+ const uploadModel = print ? await this.resolveBambuModel(args?.bambu_model as string | undefined) : undefined;
2922
+ if (print) assertDirectPrintSupported(uploadModel, bambuSerial);
2851
2923
  result = await this.bambu.uploadFile(
2852
2924
  host, bambuSerial, bambuToken,
2853
2925
  String(args.file_path), String(args.filename),
2854
- Boolean(args.print ?? false)
2926
+ print, uploadModel
2855
2927
  );
2856
2928
  break;
2929
+ }
2857
2930
 
2858
2931
  case "start_print":
2859
- case "start_print_job":
2932
+ case "start_print_job": {
2860
2933
  if (!args?.filename) {
2861
2934
  throw new Error("Missing required parameter: filename");
2862
2935
  }
2863
- await this.resolveBambuModel(args?.bambu_model as string | undefined);
2864
- result = await this.bambu.startJob(host, bambuSerial, bambuToken, String(args.filename));
2936
+ const startModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
2937
+ assertDirectPrintSupported(startModel, bambuSerial);
2938
+ result = await this.bambu.startJob(host, bambuSerial, bambuToken, String(args.filename), startModel);
2865
2939
  break;
2940
+ }
2866
2941
 
2867
2942
  case "cancel_print":
2868
2943
  result = await this.bambu.cancelJob(host, bambuSerial, bambuToken);
@@ -3064,6 +3139,7 @@ class BambuPrinterMCPServer {
3064
3139
  sliceBambuOptions.loadFilaments = String(args.filament_profile);
3065
3140
  }
3066
3141
  if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
3142
+ if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3067
3143
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3068
3144
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3069
3145
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
@@ -3149,6 +3225,7 @@ class BambuPrinterMCPServer {
3149
3225
  sliceBambuOptions.loadFilaments = String(args.filament_profile);
3150
3226
  }
3151
3227
  if (args?.load_filament_ids !== undefined) sliceBambuOptions.loadFilamentIds = String(args.load_filament_ids);
3228
+ if (args?.filament_colours !== undefined) sliceBambuOptions.filamentColours = parseFilamentColours(String(args.filament_colours));
3152
3229
  sliceBambuOptions.bedType = resolveBambuStudioCliBedType(args?.bed_type as string | undefined);
3153
3230
  if (args?.enable_timelapse !== undefined) sliceBambuOptions.enableTimelapse = Boolean(args.enable_timelapse);
3154
3231
  if (args?.allow_mix_temp !== undefined) sliceBambuOptions.allowMixTemp = Boolean(args.allow_mix_temp);
@@ -3211,6 +3288,7 @@ class BambuPrinterMCPServer {
3211
3288
  "print_3mf"
3212
3289
  );
3213
3290
  const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
3291
+ assertDirectPrintSupported(printModel, bambuSerial);
3214
3292
  const printBedType = resolveBedType(args?.bed_type as string | undefined);
3215
3293
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
3216
3294
  const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(
@@ -3229,57 +3307,50 @@ class BambuPrinterMCPServer {
3229
3307
 
3230
3308
  let threeMFPath = String(args.three_mf_path);
3231
3309
 
3232
- // Auto-slice if 3MF has no gcode
3233
- try {
3234
- const JSZip = (await import('jszip')).default;
3235
- const zipData = fs.readFileSync(threeMFPath);
3236
- const zip = await JSZip.loadAsync(zipData);
3237
- const hasGcode = Object.keys(zip.files).some(
3238
- f => f.match(/Metadata\/plate_\d+\.gcode/i) || f.endsWith('.gcode')
3239
- );
3240
- if (!hasGcode) {
3241
- if (printBedType === "supertack_plate") {
3242
- throw new Error(
3243
- '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.'
3310
+ // Inspect and auto-slice before upload. Preserve any failure instead of sending an unsliced project.
3311
+ const JSZip = (await import('jszip')).default;
3312
+ const zipData = fs.readFileSync(threeMFPath);
3313
+ const zip = await JSZip.loadAsync(zipData);
3314
+ const hasGcode = Object.values(zip.files).some(
3315
+ entry => !entry.dir && /\.gcode$/i.test(entry.name)
3316
+ );
3317
+ if (!hasGcode) {
3318
+ if (printBedType === "supertack_plate") {
3319
+ throw new Error(
3320
+ '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.'
3321
+ );
3322
+ }
3323
+ console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
3324
+ const autoSliceOptions: BambuSliceOptions = {
3325
+ uptodate: true,
3326
+ ensureOnBed: true,
3327
+ minSave: true,
3328
+ skipModifiedGcodes: true,
3329
+ bedType: printBedType,
3330
+ };
3331
+ if (!explicitSlicerProfile) {
3332
+ try {
3333
+ const liveFilaments = await this.getResolvedPrinterFilamentInventory(
3334
+ host,
3335
+ bambuSerial,
3336
+ bambuToken,
3337
+ printModel,
3338
+ printNozzle
3244
3339
  );
3245
- }
3246
- console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
3247
- const autoSliceOptions: BambuSliceOptions = {
3248
- uptodate: true,
3249
- ensureOnBed: true,
3250
- minSave: true,
3251
- skipModifiedGcodes: true,
3252
- bedType: printBedType,
3253
- };
3254
- if (!explicitSlicerProfile) {
3255
- try {
3256
- const liveFilaments = await this.getResolvedPrinterFilamentInventory(
3257
- host,
3258
- bambuSerial,
3259
- bambuToken,
3260
- printModel,
3261
- printNozzle
3262
- );
3263
- if (liveFilaments.recommended?.load_filaments) {
3264
- autoSliceOptions.loadFilaments = liveFilaments.recommended.load_filaments;
3265
- }
3266
- } catch (filamentError) {
3267
- console.warn("Could not resolve live printer filaments for auto-slicing:", filamentError);
3340
+ if (liveFilaments.recommended?.load_filaments) {
3341
+ autoSliceOptions.loadFilaments = liveFilaments.recommended.load_filaments;
3268
3342
  }
3343
+ } catch (filamentError) {
3344
+ console.warn("Could not resolve live printer filaments for auto-slicing:", filamentError);
3269
3345
  }
3270
- threeMFPath = await this.stlManipulator.sliceSTL(
3271
- threeMFPath, slicerType, slicerPath, activeSlicerProfile,
3272
- undefined, // progressCallback
3273
- printPreset,
3274
- autoSliceOptions
3275
- );
3276
- console.log("Auto-sliced to: " + threeMFPath);
3277
3346
  }
3278
- } catch (sliceCheckErr: any) {
3279
- if (String(sliceCheckErr?.message || "").includes("SuperTack")) {
3280
- throw sliceCheckErr;
3281
- }
3282
- console.warn("Could not check/slice 3MF, proceeding with original:", sliceCheckErr.message);
3347
+ threeMFPath = await this.stlManipulator.sliceSTL(
3348
+ threeMFPath, slicerType, slicerPath, activeSlicerProfile,
3349
+ undefined, // progressCallback
3350
+ printPreset,
3351
+ autoSliceOptions
3352
+ );
3353
+ console.log("Auto-sliced to: " + threeMFPath);
3283
3354
  }
3284
3355
 
3285
3356
  const parsed3MFData = await parse3MF(threeMFPath);
@@ -3403,6 +3474,7 @@ class BambuPrinterMCPServer {
3403
3474
  "print_collar_charm"
3404
3475
  );
3405
3476
  const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
3477
+ assertDirectPrintSupported(printModel, bambuSerial);
3406
3478
  const printBedType = resolveBedType(args?.bed_type as string | undefined);
3407
3479
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
3408
3480
  const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(
@@ -3496,21 +3568,24 @@ class BambuPrinterMCPServer {
3496
3568
  result = await this.stlManipulator.layFlat(String(args.stl_path));
3497
3569
  break;
3498
3570
 
3571
+ case "blender_mcp_status":
3572
+ result = await this.blender.status(args ?? {}, extra.signal);
3573
+ break;
3574
+
3575
+ case "blender_mcp_call":
3576
+ return await this.blender.call(args ?? {}, extra.signal);
3577
+
3499
3578
  case "blender_mcp_edit_model":
3500
- if (!args?.stl_path || !Array.isArray(args.operations)) {
3501
- throw new Error("Missing required parameters: stl_path and operations");
3502
- }
3503
- result = await this.invokeBlenderBridge({
3504
- stlPath: String(args.stl_path),
3505
- operations: args.operations.map((entry: any) => String(entry)),
3506
- execute: Boolean(args.execute ?? false),
3507
- bridgeCommand: this.resolveExecutableSelectorArg(
3508
- args.bridge_command,
3579
+ result = await this.blender.edit(
3580
+ args ?? {},
3581
+ this.resolveExecutableSelectorArg(
3582
+ args?.bridge_command,
3509
3583
  "blender_mcp_edit_model",
3510
3584
  "bridge_command",
3511
3585
  true
3512
- ) ?? this.runtimeConfig.blenderBridgeCommand,
3513
- });
3586
+ ) ?? (!process.env.BLENDER_MCP_COMMAND?.trim() ? this.runtimeConfig.blenderBridgeCommand : undefined),
3587
+ extra.signal
3588
+ );
3514
3589
  break;
3515
3590
 
3516
3591
  default:
@@ -3547,49 +3622,27 @@ class BambuPrinterMCPServer {
3547
3622
  });
3548
3623
  }
3549
3624
 
3550
- private async invokeBlenderBridge(params: {
3551
- stlPath: string;
3552
- operations: string[];
3553
- execute: boolean;
3554
- bridgeCommand?: string;
3555
- }): Promise<any> {
3556
- const payload = {
3557
- stlPath: params.stlPath,
3558
- operations: params.operations,
3559
- };
3560
-
3561
- if (!params.execute || !params.bridgeCommand) {
3562
- return {
3563
- status: "prepared",
3564
- payload,
3565
- note: params.bridgeCommand
3566
- ? "Set execute=true to run the Blender bridge command."
3567
- : "No BLENDER_MCP_BRIDGE_COMMAND configured. Set the env var or pass bridge_command.",
3568
- };
3569
- }
3570
-
3571
- const { execFile } = await import("node:child_process");
3572
- const { promisify } = await import("node:util");
3573
- const execFileAsync = promisify(execFile);
3574
-
3575
- const { stdout, stderr } = await execFileAsync(params.bridgeCommand, [], {
3576
- env: { ...process.env, MCP_BLENDER_PAYLOAD: JSON.stringify(payload) },
3577
- timeout: 120_000,
3578
- });
3579
-
3580
- return {
3581
- status: "executed",
3582
- stdout: stdout.trim(),
3583
- stderr: stderr.trim(),
3584
- };
3585
- }
3586
-
3587
3625
  async startStdio() {
3588
3626
  const transport = new StdioServerTransport();
3589
3627
  await this.server.connect(transport);
3628
+ process.stdin.once("end", () => { void this.shutdown(); });
3590
3629
  console.error("Bambu Printer MCP server running on stdio");
3591
3630
  }
3592
3631
 
3632
+ async shutdown(exitCode = 0) {
3633
+ if (this.shuttingDown) return;
3634
+ this.shuttingDown = true;
3635
+ try {
3636
+ // Closing the MCP transport aborts its active requests before exit cleanup.
3637
+ await this.server.close();
3638
+ this.httpRuntime?.httpServer.close();
3639
+ } catch (error) {
3640
+ console.error("[MCP Shutdown]", error);
3641
+ } finally {
3642
+ process.exit(exitCode);
3643
+ }
3644
+ }
3645
+
3593
3646
  async startHttp() {
3594
3647
  const { httpHost, httpPort, httpPath, statefulSession, enableJsonResponse, allowedOrigins } =
3595
3648
  this.runtimeConfig;
@@ -3640,4 +3693,6 @@ class BambuPrinterMCPServer {
3640
3693
  }
3641
3694
 
3642
3695
  const server = new BambuPrinterMCPServer();
3696
+ process.once("SIGINT", () => { void server.shutdown(130); });
3697
+ process.once("SIGTERM", () => { void server.shutdown(143); });
3643
3698
  server.run().catch(console.error);