bambu-printer-mcp 1.1.5 → 1.1.8

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,11 +5,13 @@ 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
17
  import { BambuImplementation } from "./printers/bambu.js";
@@ -18,7 +20,24 @@ const DEFAULT_HOST = process.env.BAMBU_PRINTER_HOST || process.env.PRINTER_HOST
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() ||
@@ -498,6 +517,9 @@ function readRuntimeConfig() {
498
517
  enableJsonResponse: parseBooleanEnv(process.env.MCP_HTTP_JSON_RESPONSE, true),
499
518
  allowedOrigins: parseCsvEnv(process.env.MCP_HTTP_ALLOWED_ORIGINS),
500
519
  blenderBridgeCommand: process.env.BLENDER_MCP_BRIDGE_COMMAND?.trim() || undefined,
520
+ ffmpegPath: process.env.FFMPEG_PATH?.trim() || undefined,
521
+ allowExecutableArg: parseBooleanEnv(process.env.MCP_ALLOW_EXECUTABLE_ARG, false),
522
+ allowBridgeCommandArg: parseBooleanEnv(process.env.MCP_ALLOW_BRIDGE_COMMAND_ARG, false),
501
523
  };
502
524
  }
503
525
  function expandUserPath(rawPath) {
@@ -740,10 +762,12 @@ if (!fs.existsSync(TEMP_DIR)) {
740
762
  }
741
763
  class BambuPrinterMCPServer {
742
764
  constructor() {
765
+ this.blender = new BlenderMcpBridge();
766
+ this.shuttingDown = false;
743
767
  this.runtimeConfig = readRuntimeConfig();
744
768
  this.server = new Server({
745
769
  name: "bambu-printer-mcp",
746
- version: "1.0.0"
770
+ version: JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
747
771
  }, {
748
772
  capabilities: {
749
773
  resources: {},
@@ -821,17 +845,44 @@ class BambuPrinterMCPServer {
821
845
  throw elicitError;
822
846
  }
823
847
  }
824
- bridgeOptionsFromArgs(args) {
848
+ bridgeOptionsFromArgs(args, toolName) {
825
849
  return {
826
- bridgeCommand: args?.bridge_command !== undefined ? String(args.bridge_command) : undefined,
850
+ bridgeCommand: this.resolveExecutableSelectorArg(args?.bridge_command, toolName, "bridge_command", true),
827
851
  configDir: args?.bambu_network_config_dir !== undefined ? String(args.bambu_network_config_dir) : undefined,
828
852
  countryCode: args?.country_code !== undefined ? String(args.country_code) : undefined,
829
853
  userInfo: args?.user_info !== undefined ? String(args.user_info) : undefined,
830
854
  timeoutMs: args?.timeout_ms !== undefined ? Number(args.timeout_ms) : undefined,
831
855
  };
832
856
  }
857
+ resolveExecutableSelectorArg(rawValue, toolName, argumentName, allowLegacyBridgeArg = false) {
858
+ if (rawValue === undefined) {
859
+ return undefined;
860
+ }
861
+ const value = String(rawValue);
862
+ if (value.length === 0) {
863
+ return undefined;
864
+ }
865
+ const isAllowed = this.runtimeConfig.allowExecutableArg ||
866
+ (allowLegacyBridgeArg && this.runtimeConfig.allowBridgeCommandArg);
867
+ if (!isAllowed) {
868
+ throw new Error(`${toolName}: the ${argumentName} argument cannot select an executable by default. ` +
869
+ "Executable paths and commands are read from server environment configuration. Set " +
870
+ `MCP_ALLOW_EXECUTABLE_ARG=1 to accept ${argumentName} for process execution.`);
871
+ }
872
+ return value;
873
+ }
874
+ resolveSlicerConfigFromArgs(args, toolName) {
875
+ const config = resolveSlicerConfig(args);
876
+ if (args?.slicer_path !== undefined) {
877
+ const resolvedSlicerPath = this.resolveExecutableSelectorArg(args.slicer_path, toolName, "slicer_path");
878
+ if (resolvedSlicerPath !== undefined) {
879
+ config.slicerPath = resolvedSlicerPath;
880
+ }
881
+ }
882
+ return config;
883
+ }
833
884
  async ensurePrintableThreeMFPath(args, printModel, printPreset, bedType) {
834
- const { slicerType, slicerPath, slicerProfile } = resolveSlicerConfig(args);
885
+ const { slicerType, slicerPath, slicerProfile } = this.resolveSlicerConfigFromArgs(args, "print_3mf_bambu_network");
835
886
  let threeMFPath = String(args.three_mf_path);
836
887
  const JSZip = (await import('jszip')).default;
837
888
  const zipData = fs.readFileSync(threeMFPath);
@@ -993,7 +1044,7 @@ class BambuPrinterMCPServer {
993
1044
  task_ext_change_assist: args?.external_change_assist !== undefined ? Boolean(args.external_change_assist) : false,
994
1045
  try_emmc_print: args?.try_emmc_print !== undefined ? Boolean(args.try_emmc_print) : false,
995
1046
  };
996
- const bridgeResult = await this.bambuNetwork.callWithAgent(bridgeMethod, { client_job_id: clientJobId, params }, this.bridgeOptionsFromArgs(args));
1047
+ const bridgeResult = await this.bambuNetwork.callWithAgent(bridgeMethod, { client_job_id: clientJobId, params }, this.bridgeOptionsFromArgs(args, "print_3mf_bambu_network"));
997
1048
  if (typeof bridgeResult === "object" && bridgeResult !== null && bridgeResult.ok === false) {
998
1049
  throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} failed: ${String(bridgeResult.error || "unknown bridge error")}`);
999
1050
  }
@@ -1484,7 +1535,7 @@ class BambuPrinterMCPServer {
1484
1535
  enum: SLICER_SCHEMA_VALUES,
1485
1536
  description: "Type of slicer to use. Bambu-compatible choices (bambustudio, orcaslicer, orcaslicer-bambulab) export sliced 3MF; aliases such as fulu-orca and orca-studio are accepted."
1486
1537
  },
1487
- slicer_path: { type: "string", description: "Path to the slicer executable (default: value from env)" },
1538
+ slicer_path: { type: "string", description: "Path to the slicer executable (default: value from env). Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
1488
1539
  slicer_profile: { type: "string", description: "Explicit slicer profile/config file. Overrides the named template only when provided in the tool call." },
1489
1540
  nozzle_diameter: { type: "string", description: "Nozzle diameter in mm (default: 0.4)" },
1490
1541
  bed_type: {
@@ -1530,7 +1581,7 @@ class BambuPrinterMCPServer {
1530
1581
  enum: SLICER_SCHEMA_VALUES,
1531
1582
  description: "Type of slicer to use. Bambu-compatible choices (bambustudio, orcaslicer, orcaslicer-bambulab) export sliced 3MF; aliases such as fulu-orca and orca-studio are accepted."
1532
1583
  },
1533
- slicer_path: { type: "string", description: "Path to the slicer executable (default: value from env)" },
1584
+ slicer_path: { type: "string", description: "Path to the slicer executable (default: value from env). Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
1534
1585
  slicer_profile: { type: "string", description: "Path to the slicer profile/config file (optional, overrides bambu_model preset)" },
1535
1586
  template_3mf_path: { type: "string", description: "Optional template 3MF whose embedded Bambu slicer settings should be reused when slicing a new STL or 3MF." },
1536
1587
  template_name: { type: "string", description: "Optional named template from the local registry. Resolves to template_3mf_path automatically." },
@@ -1584,7 +1635,7 @@ class BambuPrinterMCPServer {
1584
1635
  type: "object",
1585
1636
  properties: {
1586
1637
  connect: { type: "boolean", description: "When true, start the bridge command and run a handshake plus agent initialization probe." },
1587
- bridge_command: { type: "string", description: "Override command for the FULU bridge host or macOS/WSL wrapper; defaults to BAMBU_NETWORK_BRIDGE_COMMAND." },
1638
+ bridge_command: { type: "string", description: "Override command for the FULU bridge host or macOS/WSL wrapper; defaults to BAMBU_NETWORK_BRIDGE_COMMAND. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
1588
1639
  bambu_network_config_dir: { type: "string", description: "Config/log directory used by the BambuNetwork agent; defaults to BAMBU_NETWORK_CONFIG_DIR or a user config directory." },
1589
1640
  country_code: { type: "string", description: "BambuNetwork country code, such as US, used by the agent during startup." },
1590
1641
  user_info: { type: "string", description: "Optional BambuNetwork user_info JSON string to pass to net.change_user after the agent starts." },
@@ -1601,7 +1652,7 @@ class BambuPrinterMCPServer {
1601
1652
  method: { type: "string", description: "FULU bridge method name, for example bridge.handshake, net.is_user_login, or net.get_user_selected_machine." },
1602
1653
  payload: { type: "object", description: "JSON payload passed to the bridge method." },
1603
1654
  with_agent: { type: "boolean", description: "When true, initialize a BambuNetwork agent and add its agent id to the payload before calling the method." },
1604
- bridge_command: { type: "string", description: "Override command for the FULU bridge host or macOS/WSL wrapper; defaults to BAMBU_NETWORK_BRIDGE_COMMAND." },
1655
+ bridge_command: { type: "string", description: "Override command for the FULU bridge host or macOS/WSL wrapper; defaults to BAMBU_NETWORK_BRIDGE_COMMAND. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
1605
1656
  bambu_network_config_dir: { type: "string", description: "Config/log directory used by the BambuNetwork agent; defaults to BAMBU_NETWORK_CONFIG_DIR or a user config directory." },
1606
1657
  country_code: { type: "string", description: "BambuNetwork country code, such as US, used by the agent during startup." },
1607
1658
  user_info: { type: "string", description: "Optional BambuNetwork user_info JSON string to pass to net.change_user after the agent starts." },
@@ -1637,13 +1688,13 @@ class BambuPrinterMCPServer {
1637
1688
  preset_name: { type: "string", description: "Optional preset name sent in FULU PrintParams; defaults to project plus one-based plate index." },
1638
1689
  task_name: { type: "string", description: "Optional BambuNetwork task name; defaults to the project name." },
1639
1690
  config_filename: { type: "string", description: "Optional config 3MF path for cloud print; defaults to the same 3MF path." },
1640
- bridge_command: { type: "string", description: "Override command for the FULU bridge host or macOS/WSL wrapper; defaults to BAMBU_NETWORK_BRIDGE_COMMAND." },
1691
+ bridge_command: { type: "string", description: "Override command for the FULU bridge host or macOS/WSL wrapper; defaults to BAMBU_NETWORK_BRIDGE_COMMAND. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
1641
1692
  bambu_network_config_dir: { type: "string", description: "Config/log directory used by the BambuNetwork agent; defaults to BAMBU_NETWORK_CONFIG_DIR or a user config directory." },
1642
1693
  country_code: { type: "string", description: "BambuNetwork country code, such as US, used by the agent during startup." },
1643
1694
  user_info: { type: "string", description: "Optional BambuNetwork user_info JSON string to pass to net.change_user after the agent starts." },
1644
1695
  timeout_ms: { type: "number", description: "Bridge request timeout in milliseconds." },
1645
1696
  slicer_type: { type: "string", enum: SLICER_SCHEMA_VALUES, description: "Slicer to use only if auto-slicing an unsliced 3MF; use orcaslicer-bambulab for FULU's fork." },
1646
- slicer_path: { type: "string", description: "Path to the slicer executable for auto-slicing; defaults to value from env or a platform default." },
1697
+ slicer_path: { type: "string", description: "Path to the slicer executable for auto-slicing; defaults to value from env or a platform default. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
1647
1698
  slicer_profile: { type: "string", description: "Path to an optional slicer profile/config file for auto-slicing." },
1648
1699
  nozzle_diameter: { type: "string", description: "Nozzle diameter in mm for auto-slicing (default: 0.4)." },
1649
1700
  use_ams: { type: "boolean", description: "Whether to use the AMS; defaults to auto-detect from the 3MF mapping." },
@@ -1679,7 +1730,7 @@ class BambuPrinterMCPServer {
1679
1730
  timeout_ms: { type: "number", description: "Max ms to wait for a full frame (default 8000). Camera may take a few seconds on cold start." },
1680
1731
  bambu_model: { type: "string", description: "Printer model. Used to route to the correct protocol or fail fast on unsupported models. Defaults to BAMBU_MODEL." },
1681
1732
  experimental: { type: "boolean", description: "Deprecated and ignored. Earlier this flag let callers probe H2 series via the A1/P1 TCP-on-6000 path; live testing on an H2S confirmed H2 uses RTSP instead, so the flag has no effect now." },
1682
- ffmpeg_path: { type: "string", description: "Override path to the ffmpeg binary used by the RTSP path. Defaults to ffmpeg via $PATH. Required only for the RTSP transport (X1, P2S, H2 series)." },
1733
+ ffmpeg_path: { type: "string", description: "Override path to the ffmpeg binary used by the RTSP path. Defaults to FFMPEG_PATH or ffmpeg via $PATH. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1. Required only for the RTSP transport (X1, P2S, H2 series)." },
1683
1734
  host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
1684
1735
  bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
1685
1736
  bambu_token: { type: "string", description: "Access token (default: value from env)" }
@@ -1997,7 +2048,7 @@ class BambuPrinterMCPServer {
1997
2048
  bambu_network_method: { type: "string", enum: BAMBU_NETWORK_PRINT_METHODS, description: "FULU print method when connection_mode is bambu_network; defaults to start_print for cloud and start_local_print for lan." },
1998
2049
  dev_id: { type: "string", description: "Bambu device id for FULU BambuNetwork printing; defaults to BAMBU_DEV_ID or BAMBU_SERIAL." },
1999
2050
  dev_ip: { type: "string", description: "Printer IP address for FULU BambuNetwork LAN/local print methods; defaults to host when provided." },
2000
- bridge_command: { type: "string", description: "Override command for the FULU bridge host or macOS/WSL wrapper; defaults to BAMBU_NETWORK_BRIDGE_COMMAND." },
2051
+ bridge_command: { type: "string", description: "Override command for the FULU bridge host or macOS/WSL wrapper; defaults to BAMBU_NETWORK_BRIDGE_COMMAND. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
2001
2052
  bambu_network_config_dir: { type: "string", description: "Config/log directory used by the FULU BambuNetwork agent." },
2002
2053
  country_code: { type: "string", description: "BambuNetwork country code, such as US, used by the FULU bridge agent." },
2003
2054
  user_info: { type: "string", description: "Optional BambuNetwork user_info JSON string passed to net.change_user for the FULU bridge." },
@@ -2015,7 +2066,7 @@ class BambuPrinterMCPServer {
2015
2066
  enum: SLICER_SCHEMA_VALUES,
2016
2067
  description: "Slicer to use only if auto-slicing an unsliced 3MF. Bambu-compatible slicer aliases such as fulu-orca and orca-studio are accepted."
2017
2068
  },
2018
- slicer_path: { type: "string", description: "Path to the slicer executable for auto-slicing (default: value from env or a platform default)" },
2069
+ slicer_path: { type: "string", description: "Path to the slicer executable for auto-slicing (default: value from env or a platform default). Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
2019
2070
  use_ams: { type: "boolean", description: "Whether to use the AMS (default: auto-detect from 3MF)" },
2020
2071
  ams_mapping: {
2021
2072
  type: "array",
@@ -2110,28 +2161,62 @@ class BambuPrinterMCPServer {
2110
2161
  required: ["stl_path"]
2111
2162
  }
2112
2163
  },
2164
+ {
2165
+ name: "blender_mcp_status",
2166
+ 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.",
2167
+ inputSchema: {
2168
+ type: "object",
2169
+ properties: {
2170
+ connect: { type: "boolean", description: "Initialize the configured stdio MCP server and discover its tools (default false)." },
2171
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total connection and discovery deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." }
2172
+ },
2173
+ additionalProperties: false
2174
+ }
2175
+ },
2176
+ {
2177
+ name: "blender_mcp_call",
2178
+ 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.",
2179
+ inputSchema: {
2180
+ type: "object",
2181
+ properties: {
2182
+ tool_name: { type: "string", description: "Exact name advertised by Blender MCP, such as get_scene_info or execute_blender_code." },
2183
+ 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." },
2184
+ 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." }
2185
+ },
2186
+ required: ["tool_name"],
2187
+ additionalProperties: false
2188
+ }
2189
+ },
2113
2190
  {
2114
2191
  name: "blender_mcp_edit_model",
2115
- description: "Send STL-edit instructions to a Blender MCP bridge command for advanced model edits",
2192
+ 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.",
2116
2193
  inputSchema: {
2117
2194
  type: "object",
2118
2195
  properties: {
2119
2196
  stl_path: { type: "string", description: "Path to the local STL file" },
2120
2197
  operations: {
2121
2198
  type: "array",
2122
- description: "Ordered edit operations for Blender (e.g. remesh, boolean, decimate)",
2199
+ 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.",
2200
+ minItems: 1,
2201
+ maxItems: 64,
2123
2202
  items: { type: "string" }
2124
2203
  },
2125
- bridge_command: { type: "string", description: "Override command for invoking Blender MCP bridge" },
2126
- execute: { type: "boolean", description: "Execute bridge command (true) or return payload only (false)" }
2204
+ 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." },
2205
+ user_prompt: { type: "string", description: "The user's own words describing the edit, passed unchanged to Blender MCP." },
2206
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total Blender request deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." },
2207
+ bridge_command: { type: "string", description: "Legacy custom bridge executable override, not a standard MCP command. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
2208
+ execute: { type: "boolean", description: "Apply edits and export (true) or validate and return the prepared request without connecting (false, default)." }
2127
2209
  },
2128
- required: ["stl_path", "operations"]
2210
+ required: process.env.BLENDER_MCP_COMMAND?.trim()
2211
+ ? ["stl_path", "operations", "output_path"]
2212
+ : ["stl_path", "operations"],
2213
+ additionalProperties: false
2129
2214
  }
2130
2215
  }
2131
2216
  ]
2132
2217
  };
2133
2218
  });
2134
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
2219
+ this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
2135
2220
  const { name, arguments: args } = request.params;
2136
2221
  const host = String(args?.host || DEFAULT_HOST);
2137
2222
  const bambuSerial = String(args?.bambu_serial || DEFAULT_BAMBU_SERIAL);
@@ -2195,7 +2280,7 @@ class BambuPrinterMCPServer {
2195
2280
  break;
2196
2281
  case "bambu_network_bridge_status": {
2197
2282
  const bridgeArgs = args;
2198
- const options = this.bridgeOptionsFromArgs(bridgeArgs);
2283
+ const options = this.bridgeOptionsFromArgs(bridgeArgs, "bambu_network_bridge_status");
2199
2284
  result = this.bambuNetwork.getStatus(options);
2200
2285
  if (Boolean(bridgeArgs?.connect)) {
2201
2286
  const probe = await this.bambuNetwork.ensureAgent(options);
@@ -2216,7 +2301,7 @@ class BambuPrinterMCPServer {
2216
2301
  const payload = bridgeArgs.payload && typeof bridgeArgs.payload === "object"
2217
2302
  ? bridgeArgs.payload
2218
2303
  : {};
2219
- const options = this.bridgeOptionsFromArgs(bridgeArgs);
2304
+ const options = this.bridgeOptionsFromArgs(bridgeArgs, "bambu_network_call");
2220
2305
  result = bridgeArgs.with_agent === false
2221
2306
  ? await this.bambuNetwork.request(String(bridgeArgs.method), payload, options)
2222
2307
  : await this.bambuNetwork.callWithAgent(String(bridgeArgs.method), payload, options);
@@ -2233,7 +2318,7 @@ class BambuPrinterMCPServer {
2233
2318
  timeoutMs: args?.timeout_ms !== undefined ? Number(args.timeout_ms) : undefined,
2234
2319
  bambuModel: snapshotModel ? String(snapshotModel) : undefined,
2235
2320
  experimental: Boolean(args?.experimental),
2236
- ffmpegPath: args?.ffmpeg_path ? String(args.ffmpeg_path) : undefined,
2321
+ ffmpegPath: this.resolveExecutableSelectorArg(args?.ffmpeg_path, "camera_snapshot", "ffmpeg_path") ?? this.runtimeConfig.ffmpegPath,
2237
2322
  });
2238
2323
  break;
2239
2324
  }
@@ -2380,7 +2465,7 @@ class BambuPrinterMCPServer {
2380
2465
  if (!args?.template_name) {
2381
2466
  throw new Error("Missing required parameter: template_name");
2382
2467
  }
2383
- const { slicerType, slicerPath, slicerProfile } = resolveSlicerConfig(args);
2468
+ const { slicerType, slicerPath, slicerProfile } = this.resolveSlicerConfigFromArgs(args, "slice_with_template");
2384
2469
  const sliceModel = await this.resolveBambuModel(args?.bambu_model);
2385
2470
  const nozzleDiam = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2386
2471
  const activeSlicerProfile = await resolveTemplateFirstSlicerProfilePath(args, slicerProfile || undefined, resolveTemplatePathFromName(["json", "config", "3mf"]) || explicitTemplatePath || undefined, TEMP_DIR);
@@ -2456,7 +2541,7 @@ class BambuPrinterMCPServer {
2456
2541
  if (!args?.stl_path) {
2457
2542
  throw new Error("Missing required parameter: stl_path");
2458
2543
  }
2459
- const { slicerType, slicerPath, slicerProfile } = resolveSlicerConfig(args);
2544
+ const { slicerType, slicerPath, slicerProfile } = this.resolveSlicerConfigFromArgs(args, "slice_stl");
2460
2545
  const sliceModel = await this.resolveBambuModel(args?.bambu_model);
2461
2546
  const nozzleDiam = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
2462
2547
  const activeSlicerProfile = await resolveSlicerProfilePath(slicerProfile || undefined, resolveTemplatePathFromName(["json", "config", "3mf"]) || explicitTemplatePath || undefined, TEMP_DIR);
@@ -2541,7 +2626,7 @@ class BambuPrinterMCPServer {
2541
2626
  if (!bambuSerial || !bambuToken) {
2542
2627
  throw new Error("Bambu serial number and access token are required for print_3mf.");
2543
2628
  }
2544
- const { slicerType, slicerPath, slicerProfile } = resolveSlicerConfig(args);
2629
+ const { slicerType, slicerPath, slicerProfile } = this.resolveSlicerConfigFromArgs(args, "print_3mf");
2545
2630
  const printModel = await this.resolveBambuModel(args?.bambu_model);
2546
2631
  const printBedType = resolveBedType(args?.bed_type);
2547
2632
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
@@ -2553,45 +2638,37 @@ class BambuPrinterMCPServer {
2553
2638
  throw new Error("plate_index must be a non-negative integer.");
2554
2639
  }
2555
2640
  let threeMFPath = String(args.three_mf_path);
2556
- // Auto-slice if 3MF has no gcode
2557
- try {
2558
- const JSZip = (await import('jszip')).default;
2559
- const zipData = fs.readFileSync(threeMFPath);
2560
- const zip = await JSZip.loadAsync(zipData);
2561
- const hasGcode = Object.keys(zip.files).some(f => f.match(/Metadata\/plate_\d+\.gcode/i) || f.endsWith('.gcode'));
2562
- if (!hasGcode) {
2563
- if (printBedType === "supertack_plate") {
2564
- 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.');
2565
- }
2566
- console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
2567
- const autoSliceOptions = {
2568
- uptodate: true,
2569
- ensureOnBed: true,
2570
- minSave: true,
2571
- skipModifiedGcodes: true,
2572
- bedType: printBedType,
2573
- };
2574
- if (!explicitSlicerProfile) {
2575
- try {
2576
- const liveFilaments = await this.getResolvedPrinterFilamentInventory(host, bambuSerial, bambuToken, printModel, printNozzle);
2577
- if (liveFilaments.recommended?.load_filaments) {
2578
- autoSliceOptions.loadFilaments = liveFilaments.recommended.load_filaments;
2579
- }
2580
- }
2581
- catch (filamentError) {
2582
- console.warn("Could not resolve live printer filaments for auto-slicing:", filamentError);
2641
+ // Inspect and auto-slice before upload. Preserve any failure instead of sending an unsliced project.
2642
+ const JSZip = (await import('jszip')).default;
2643
+ const zipData = fs.readFileSync(threeMFPath);
2644
+ const zip = await JSZip.loadAsync(zipData);
2645
+ const hasGcode = Object.values(zip.files).some(entry => !entry.dir && /\.gcode$/i.test(entry.name));
2646
+ if (!hasGcode) {
2647
+ if (printBedType === "supertack_plate") {
2648
+ 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.');
2649
+ }
2650
+ console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
2651
+ const autoSliceOptions = {
2652
+ uptodate: true,
2653
+ ensureOnBed: true,
2654
+ minSave: true,
2655
+ skipModifiedGcodes: true,
2656
+ bedType: printBedType,
2657
+ };
2658
+ if (!explicitSlicerProfile) {
2659
+ try {
2660
+ const liveFilaments = await this.getResolvedPrinterFilamentInventory(host, bambuSerial, bambuToken, printModel, printNozzle);
2661
+ if (liveFilaments.recommended?.load_filaments) {
2662
+ autoSliceOptions.loadFilaments = liveFilaments.recommended.load_filaments;
2583
2663
  }
2584
2664
  }
2585
- threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, slicerPath, activeSlicerProfile, undefined, // progressCallback
2586
- printPreset, autoSliceOptions);
2587
- console.log("Auto-sliced to: " + threeMFPath);
2588
- }
2589
- }
2590
- catch (sliceCheckErr) {
2591
- if (String(sliceCheckErr?.message || "").includes("SuperTack")) {
2592
- throw sliceCheckErr;
2665
+ catch (filamentError) {
2666
+ console.warn("Could not resolve live printer filaments for auto-slicing:", filamentError);
2667
+ }
2593
2668
  }
2594
- console.warn("Could not check/slice 3MF, proceeding with original:", sliceCheckErr.message);
2669
+ threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, slicerPath, activeSlicerProfile, undefined, // progressCallback
2670
+ printPreset, autoSliceOptions);
2671
+ console.log("Auto-sliced to: " + threeMFPath);
2595
2672
  }
2596
2673
  const parsed3MFData = await parse3MF(threeMFPath);
2597
2674
  const isH2Print = H2_BAMBU_MODELS.has(printModel);
@@ -2689,7 +2766,7 @@ class BambuPrinterMCPServer {
2689
2766
  if (!bambuSerial || !bambuToken) {
2690
2767
  throw new Error("Bambu serial number and access token are required for print_collar_charm.");
2691
2768
  }
2692
- const { slicerType, slicerPath, slicerProfile } = resolveSlicerConfig(args);
2769
+ const { slicerType, slicerPath, slicerProfile } = this.resolveSlicerConfigFromArgs(args, "print_collar_charm");
2693
2770
  const printModel = await this.resolveBambuModel(args?.bambu_model);
2694
2771
  const printBedType = resolveBedType(args?.bed_type);
2695
2772
  const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
@@ -2753,18 +2830,13 @@ class BambuPrinterMCPServer {
2753
2830
  throw new Error("Missing required parameter: stl_path");
2754
2831
  result = await this.stlManipulator.layFlat(String(args.stl_path));
2755
2832
  break;
2833
+ case "blender_mcp_status":
2834
+ result = await this.blender.status(args ?? {}, extra.signal);
2835
+ break;
2836
+ case "blender_mcp_call":
2837
+ return await this.blender.call(args ?? {}, extra.signal);
2756
2838
  case "blender_mcp_edit_model":
2757
- if (!args?.stl_path || !Array.isArray(args.operations)) {
2758
- throw new Error("Missing required parameters: stl_path and operations");
2759
- }
2760
- result = await this.invokeBlenderBridge({
2761
- stlPath: String(args.stl_path),
2762
- operations: args.operations.map((entry) => String(entry)),
2763
- execute: Boolean(args.execute ?? false),
2764
- bridgeCommand: args.bridge_command
2765
- ? String(args.bridge_command)
2766
- : this.runtimeConfig.blenderBridgeCommand,
2767
- });
2839
+ 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);
2768
2840
  break;
2769
2841
  default:
2770
2842
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
@@ -2795,38 +2867,28 @@ class BambuPrinterMCPServer {
2795
2867
  }
2796
2868
  });
2797
2869
  }
2798
- async invokeBlenderBridge(params) {
2799
- const payload = {
2800
- stlPath: params.stlPath,
2801
- operations: params.operations,
2802
- };
2803
- if (!params.execute || !params.bridgeCommand) {
2804
- return {
2805
- status: "prepared",
2806
- payload,
2807
- note: params.bridgeCommand
2808
- ? "Set execute=true to run the Blender bridge command."
2809
- : "No BLENDER_MCP_BRIDGE_COMMAND configured. Set the env var or pass bridge_command.",
2810
- };
2811
- }
2812
- const { execFile } = await import("node:child_process");
2813
- const { promisify } = await import("node:util");
2814
- const execFileAsync = promisify(execFile);
2815
- const { stdout, stderr } = await execFileAsync(params.bridgeCommand, [], {
2816
- env: { ...process.env, MCP_BLENDER_PAYLOAD: JSON.stringify(payload) },
2817
- timeout: 120000,
2818
- });
2819
- return {
2820
- status: "executed",
2821
- stdout: stdout.trim(),
2822
- stderr: stderr.trim(),
2823
- };
2824
- }
2825
2870
  async startStdio() {
2826
2871
  const transport = new StdioServerTransport();
2827
2872
  await this.server.connect(transport);
2873
+ process.stdin.once("end", () => { void this.shutdown(); });
2828
2874
  console.error("Bambu Printer MCP server running on stdio");
2829
2875
  }
2876
+ async shutdown(exitCode = 0) {
2877
+ if (this.shuttingDown)
2878
+ return;
2879
+ this.shuttingDown = true;
2880
+ try {
2881
+ // Closing the MCP transport aborts its active requests before exit cleanup.
2882
+ await this.server.close();
2883
+ this.httpRuntime?.httpServer.close();
2884
+ }
2885
+ catch (error) {
2886
+ console.error("[MCP Shutdown]", error);
2887
+ }
2888
+ finally {
2889
+ process.exit(exitCode);
2890
+ }
2891
+ }
2830
2892
  async startHttp() {
2831
2893
  const { httpHost, httpPort, httpPath, statefulSession, enableJsonResponse, allowedOrigins } = this.runtimeConfig;
2832
2894
  const transport = new StreamableHTTPServerTransport({
@@ -2866,4 +2928,6 @@ class BambuPrinterMCPServer {
2866
2928
  }
2867
2929
  }
2868
2930
  const server = new BambuPrinterMCPServer();
2931
+ process.once("SIGINT", () => { void server.shutdown(130); });
2932
+ process.once("SIGTERM", () => { void server.shutdown(143); });
2869
2933
  server.run().catch(console.error);
@@ -49,6 +49,9 @@ const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s"])
49
49
  function isH2ModelName(model) {
50
50
  return H2_MODEL_NAMES.has(String(model ?? "").trim().toLowerCase().replace(/\s+/g, " "));
51
51
  }
52
+ function isP2SModelName(model) {
53
+ return String(model ?? "").trim().toLowerCase() === "p2s";
54
+ }
52
55
  function sleep(ms) {
53
56
  return new Promise((resolve) => setTimeout(resolve, ms));
54
57
  }
@@ -88,6 +91,10 @@ class TolerantBambuClient extends BambuClient {
88
91
  return "H2S";
89
92
  if (sn.startsWith("094"))
90
93
  return "H2D";
94
+ if (sn.startsWith("239"))
95
+ return "H2C";
96
+ if (sn.startsWith("31B"))
97
+ return "H2DPRO";
91
98
  if (sn.startsWith("00M"))
92
99
  return "X1C";
93
100
  if (sn.startsWith("00W"))
@@ -98,6 +105,8 @@ class TolerantBambuClient extends BambuClient {
98
105
  return "P1P";
99
106
  if (sn.startsWith("01P"))
100
107
  return "P1S";
108
+ if (sn.startsWith("22E"))
109
+ return "P2S";
101
110
  if (sn.startsWith("030"))
102
111
  return "A1";
103
112
  if (sn.startsWith("039"))
@@ -457,24 +466,33 @@ export class BambuImplementation {
457
466
  let remoteFileName = path.basename(options.filePath);
458
467
  remoteFileName = remoteFileName.replace(/\.gcode\.3mf\.gcode\.3mf$/i, ".gcode.3mf");
459
468
  // 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>.
469
+ // Full-size A1 uses SD root/project_file (reported on firmware 01.08.01.00).
470
+ // P1/X1/A1 mini retain their legacy cache/gcode_file route.
471
+ // P2S keeps the /cache/<name> upload but only accepts the H2-style
472
+ // project_file command, referenced via ftp:///cache/<name> (verified on
473
+ // P2S firmware 01.02.00.00; file:///sdcard/... fails with ERROR STATE).
461
474
  const isH2 = serial.startsWith("093") ||
462
475
  serial.startsWith("094") ||
463
476
  isH2ModelName(options.bambuModel);
464
- const remoteProjectPath = isH2 ? remoteFileName : `cache/${remoteFileName}`;
465
- const remoteUploadPath = isH2 ? `/${remoteFileName}` : `/cache/${remoteFileName}`;
466
- const projectUrl = isH2
467
- ? `ftp:///${remoteFileName}`
477
+ const isP2S = serial.startsWith("22E") || isP2SModelName(options.bambuModel);
478
+ const isA1 = String(options.bambuModel ?? "").trim().toLowerCase() === "a1" ||
479
+ (!options.bambuModel && serial.startsWith("030"));
480
+ const usesH2ProjectFile = isH2 || isP2S;
481
+ const remoteProjectPath = isH2 || isA1 ? remoteFileName : `cache/${remoteFileName}`;
482
+ const remoteUploadPath = `/${remoteProjectPath}`;
483
+ const projectUrl = usesH2ProjectFile
484
+ ? `ftp:///${remoteProjectPath}`
468
485
  : `file:///sdcard/${remoteProjectPath}`;
469
486
  // Upload via basic-ftp directly (bypasses bambu-js double-path bug)
470
487
  await this.ftpUpload(host, token, options.filePath, remoteUploadPath);
471
488
  // Pre-sliced .gcode.3mf files: routing depends on firmware generation.
472
- // P1/A1/X1 series: project_file returns 405004002 for .gcode.3mf (firmware
489
+ // Legacy P1/X1/A1 mini: project_file returns 405004002 for .gcode.3mf (firmware
473
490
  // doesn't recognise the container), so use gcode_file instead.
474
- // H2-series: gcode_file is not supported; project_file works because the
491
+ // H2-series and P2S: gcode_file is not supported (P2S answers 0500-4002
492
+ // "Unsupported file path or name"); project_file works because the
475
493
  // firmware can open the zip and find Metadata/plate_<n>.gcode directly.
476
494
  if (options.filePath.toLowerCase().endsWith(".gcode.3mf")) {
477
- if (!isH2) {
495
+ if (!usesH2ProjectFile && !isA1) {
478
496
  const printer = await this.getPrinter(host, serial, token);
479
497
  await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
480
498
  return {
@@ -483,7 +501,7 @@ export class BambuImplementation {
483
501
  remoteProjectPath,
484
502
  };
485
503
  }
486
- // H2-series: fall through to project_file path below
504
+ // H2-series and P2S: fall through to project_file path below
487
505
  }
488
506
  const projectMetadata = await this.resolveProjectFileMetadata(options.filePath, options.plateIndex);
489
507
  // Send project_file command via bambu-node MQTT (bypasses bambu-js
@@ -550,7 +568,7 @@ export class BambuImplementation {
550
568
  }
551
569
  let amsMapping;
552
570
  let amsMapping2;
553
- if (isH2) {
571
+ if (usesH2ProjectFile) {
554
572
  const projLen = Math.max(projectMetadata.projectFilamentCount, baseMapping.length, 1);
555
573
  amsMapping = Array.from({ length: projLen }, (_, i) => i < baseMapping.length ? baseMapping[i] : -1);
556
574
  amsMapping2 = amsMapping.map((v) => {
@@ -564,12 +582,12 @@ export class BambuImplementation {
564
582
  });
565
583
  }
566
584
  else {
567
- amsMapping = Array.from({ length: 5 }, (_, i) => i < baseMapping.length ? baseMapping[i] : -1);
585
+ amsMapping = Array.from({ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) }, (_, i) => i < baseMapping.length ? baseMapping[i] : -1);
568
586
  amsMapping2 = [];
569
587
  }
570
588
  const b = (v) => (v ? 1 : 0);
571
589
  let projectFileCmd;
572
- if (isH2) {
590
+ if (usesH2ProjectFile) {
573
591
  const submissionId = String(Date.now() & 0x7fffffff);
574
592
  projectFileCmd = {
575
593
  print: {
@@ -994,7 +1012,7 @@ export class BambuImplementation {
994
1012
  // integration's models.py shows the printer reports its own
995
1013
  // `ipcam.rtsp_url` for these models, and Parker (H2S) rejects the
996
1014
  // A1/P1 80-byte auth packet on port 6000 (verified 2026-04-27 --
997
- // see PROGRESS.md "H2 probe results").
1015
+ // confirmed by local H2 camera transport probes).
998
1016
  const RTSP_MODELS = new Set([
999
1017
  "x1", "x1c", "x1carbon", "x1e", "p2s",
1000
1018
  "h2", "h2s", "h2d", "h2c", "h2dpro",