mcp-3d-printer-server 1.0.2 → 1.2.1

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
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
5
  import { ListResourcesRequestSchema, ReadResourceRequestSchema, ListToolsRequestSchema, CallToolRequestSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
5
6
  import axios from "axios";
6
7
  import dotenv from "dotenv";
7
8
  import fs from "fs";
8
9
  import path from "path";
10
+ import { createServer as createHttpServer } from "node:http";
11
+ import { randomUUID } from "node:crypto";
9
12
  import * as THREE from 'three';
10
13
  import { PrinterFactory } from "./printers/printer-factory.js";
11
14
  import { STLManipulator } from "./stl/stl-manipulator.js";
@@ -20,21 +23,108 @@ const DEFAULT_API_KEY = process.env.API_KEY || "";
20
23
  const DEFAULT_TYPE = process.env.PRINTER_TYPE || "octoprint"; // Default to OctoPrint
21
24
  const TEMP_DIR = process.env.TEMP_DIR || path.join(process.cwd(), "temp");
22
25
  // Slicer configuration
23
- const DEFAULT_SLICER_TYPE = process.env.SLICER_TYPE || "prusaslicer";
24
- const DEFAULT_SLICER_PATH = process.env.SLICER_PATH || "";
26
+ const DEFAULT_SLICER_TYPE = process.env.SLICER_TYPE || (process.env.PRINTER_TYPE === "bambu" ? "bambustudio" : "prusaslicer");
27
+ const DEFAULT_SLICER_PATH = process.env.SLICER_PATH || (process.env.PRINTER_TYPE === "bambu" ? "/Applications/BambuStudio.app/Contents/MacOS/BambuStudio" : "");
25
28
  const DEFAULT_SLICER_PROFILE = process.env.SLICER_PROFILE || "";
26
29
  // Bambu-specific default values
27
30
  const DEFAULT_BAMBU_SERIAL = process.env.BAMBU_SERIAL || "";
28
31
  const DEFAULT_BAMBU_TOKEN = process.env.BAMBU_TOKEN || "";
32
+ // Printer model and bed type (Bambu safety)
33
+ const DEFAULT_BAMBU_MODEL = process.env.BAMBU_MODEL?.trim().toLowerCase() || "";
34
+ const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
35
+ const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
36
+ const VALID_BAMBU_MODELS = ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"];
37
+ const VALID_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"];
38
+ // Map model IDs to BambuStudio --load-machine preset names
39
+ const BAMBU_MODEL_PRESETS = {
40
+ p1s: (n) => `Bambu Lab P1S ${n} nozzle`,
41
+ p1p: (n) => `Bambu Lab P1P ${n} nozzle`,
42
+ x1c: (n) => `Bambu Lab X1 Carbon ${n} nozzle`,
43
+ x1e: (n) => `Bambu Lab X1E ${n} nozzle`,
44
+ a1: (n) => `Bambu Lab A1 ${n} nozzle`,
45
+ a1mini: (n) => `Bambu Lab A1 mini ${n} nozzle`,
46
+ h2d: (n) => `Bambu Lab H2D ${n} nozzle`,
47
+ };
48
+ function validateBambuModel(model) {
49
+ const normalized = model.trim().toLowerCase();
50
+ if (!VALID_BAMBU_MODELS.includes(normalized)) {
51
+ throw new Error(`Invalid Bambu printer model "${model}". Valid models: ${VALID_BAMBU_MODELS.join(", ")}`);
52
+ }
53
+ return normalized;
54
+ }
55
+ function resolveBedType(bedType) {
56
+ const resolved = (bedType || DEFAULT_BED_TYPE).trim().toLowerCase();
57
+ if (!VALID_BED_TYPES.includes(resolved)) {
58
+ throw new Error(`Invalid bed type "${bedType}". Valid types: ${VALID_BED_TYPES.join(", ")}`);
59
+ }
60
+ return resolved;
61
+ }
62
+ function parseBooleanEnv(rawValue, fallback) {
63
+ if (rawValue === undefined) {
64
+ return fallback;
65
+ }
66
+ const value = rawValue.trim().toLowerCase();
67
+ if (["1", "true", "yes", "on"].includes(value)) {
68
+ return true;
69
+ }
70
+ if (["0", "false", "no", "off"].includes(value)) {
71
+ return false;
72
+ }
73
+ return fallback;
74
+ }
75
+ function parsePort(value, fallback) {
76
+ if (!value) {
77
+ return fallback;
78
+ }
79
+ const parsed = Number(value);
80
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
81
+ throw new Error(`Invalid MCP_HTTP_PORT value: ${value}`);
82
+ }
83
+ return parsed;
84
+ }
85
+ function normalizePath(pathValue) {
86
+ const value = (pathValue ?? "/mcp").trim();
87
+ if (!value) {
88
+ return "/mcp";
89
+ }
90
+ return value.startsWith("/") ? value : `/${value}`;
91
+ }
92
+ function parseCsvEnv(value) {
93
+ if (!value) {
94
+ return new Set();
95
+ }
96
+ const entries = value
97
+ .split(",")
98
+ .map((entry) => entry.trim())
99
+ .filter((entry) => entry.length > 0);
100
+ return new Set(entries);
101
+ }
102
+ function readRuntimeConfig() {
103
+ const rawTransport = process.env.MCP_TRANSPORT?.trim().toLowerCase();
104
+ const transport = rawTransport === "streamable-http" || rawTransport === "http"
105
+ ? "streamable-http"
106
+ : "stdio";
107
+ return {
108
+ transport,
109
+ httpHost: process.env.MCP_HTTP_HOST?.trim() || "127.0.0.1",
110
+ httpPort: parsePort(process.env.MCP_HTTP_PORT, 3000),
111
+ httpPath: normalizePath(process.env.MCP_HTTP_PATH),
112
+ statefulSession: parseBooleanEnv(process.env.MCP_HTTP_STATEFUL, true),
113
+ enableJsonResponse: parseBooleanEnv(process.env.MCP_HTTP_JSON_RESPONSE, true),
114
+ allowedOrigins: parseCsvEnv(process.env.MCP_HTTP_ALLOWED_ORIGINS),
115
+ blenderBridgeCommand: process.env.BLENDER_MCP_BRIDGE_COMMAND?.trim() || undefined,
116
+ };
117
+ }
29
118
  // Ensure temp directory exists
30
119
  if (!fs.existsSync(TEMP_DIR)) {
31
120
  fs.mkdirSync(TEMP_DIR, { recursive: true });
32
121
  }
33
122
  class ThreeDPrinterMCPServer {
34
123
  constructor() {
124
+ this.runtimeConfig = readRuntimeConfig();
35
125
  this.server = new Server({
36
126
  name: "mcp-3d-printer-server",
37
- version: "1.0.0"
127
+ version: "1.2.0"
38
128
  }, {
39
129
  capabilities: {
40
130
  resources: {},
@@ -46,16 +136,58 @@ class ThreeDPrinterMCPServer {
46
136
  this.setupHandlers();
47
137
  this.setupErrorHandling();
48
138
  }
139
+ async resolveBambuModel(argsModel) {
140
+ const fromArgs = (argsModel || DEFAULT_BAMBU_MODEL).trim().toLowerCase();
141
+ if (fromArgs) {
142
+ return validateBambuModel(fromArgs);
143
+ }
144
+ // No model from args or env — ask the user via elicitation
145
+ try {
146
+ const result = await this.server.elicitInput({
147
+ mode: "form",
148
+ message: "Your Bambu Lab printer model is required for safe operation. " +
149
+ "Using the wrong model can cause the bed to crash into the nozzle and damage the printer.",
150
+ requestedSchema: {
151
+ type: "object",
152
+ properties: {
153
+ bambu_model: {
154
+ type: "string",
155
+ title: "Printer Model",
156
+ description: "Which Bambu Lab printer do you have?",
157
+ oneOf: [
158
+ { const: "p1s", title: "P1S" },
159
+ { const: "p1p", title: "P1P" },
160
+ { const: "x1c", title: "X1 Carbon" },
161
+ { const: "x1e", title: "X1E" },
162
+ { const: "a1", title: "A1" },
163
+ { const: "a1mini", title: "A1 Mini" },
164
+ { const: "h2d", title: "H2D" },
165
+ ],
166
+ },
167
+ },
168
+ required: ["bambu_model"],
169
+ },
170
+ });
171
+ if (result.action === "accept" && result.content?.bambu_model) {
172
+ return validateBambuModel(String(result.content.bambu_model));
173
+ }
174
+ throw new Error("Printer model selection was cancelled. Cannot proceed without knowing the printer model.");
175
+ }
176
+ catch (elicitError) {
177
+ const msg = elicitError?.message || String(elicitError);
178
+ if (elicitError?.code === -32601 || elicitError?.code === -32600 ||
179
+ msg.includes("does not support") || msg.includes("elicitation")) {
180
+ throw new Error("bambu_model is required but your MCP client does not support elicitation. " +
181
+ `Set the BAMBU_MODEL environment variable or pass bambu_model in the tool call. ` +
182
+ `Valid models: ${VALID_BAMBU_MODELS.join(", ")}`);
183
+ }
184
+ throw elicitError;
185
+ }
186
+ }
49
187
  setupErrorHandling() {
50
188
  this.server.onerror = (error) => {
51
189
  console.error("[MCP Error]", error);
52
190
  };
53
- process.on("SIGINT", async () => {
54
- // Disconnect all printers
55
- await this.printerFactory.disconnectAll();
56
- await this.server.close();
57
- process.exit(0);
58
- });
59
191
  }
60
192
  setupHandlers() {
61
193
  this.setupResourceHandlers();
@@ -198,17 +330,26 @@ class ThreeDPrinterMCPServer {
198
330
  },
199
331
  {
200
332
  name: "slice_stl",
201
- description: "Slice an STL file to generate G-code",
333
+ description: "Slice an STL or 3MF file to generate G-code or sliced 3MF",
202
334
  inputSchema: {
203
335
  type: "object",
204
336
  properties: {
205
337
  stl_path: {
206
338
  type: "string",
207
- description: "Path to the STL file to slice"
339
+ description: "Path to the STL or 3MF file to slice"
340
+ },
341
+ bambu_model: {
342
+ type: "string",
343
+ enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
344
+ description: "Bambu Lab printer model — required when using bambustudio slicer to ensure correct G-code."
345
+ },
346
+ nozzle_diameter: {
347
+ type: "string",
348
+ description: "Nozzle diameter in mm (default: 0.4)."
208
349
  },
209
350
  slicer_type: {
210
351
  type: "string",
211
- description: "Type of slicer to use (prusaslicer, cura, slic3r, orcaslicer) (default: value from env)"
352
+ description: "Type of slicer to use (prusaslicer, cura, slic3r, orcaslicer, bambustudio) (default: value from env)"
212
353
  },
213
354
  slicer_path: {
214
355
  type: "string",
@@ -475,6 +616,20 @@ class ThreeDPrinterMCPServer {
475
616
  type: "string",
476
617
  description: "Path to the 3MF file to print."
477
618
  },
619
+ bambu_model: {
620
+ type: "string",
621
+ enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
622
+ description: "REQUIRED: Bambu Lab printer model. Ensures correct G-code generation — wrong model can crash the bed into the nozzle."
623
+ },
624
+ bed_type: {
625
+ type: "string",
626
+ enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"],
627
+ description: "Bed/plate type installed on the printer (default: textured_plate)."
628
+ },
629
+ nozzle_diameter: {
630
+ type: "string",
631
+ description: "Nozzle diameter in mm (default: 0.4)."
632
+ },
478
633
  host: {
479
634
  type: "string",
480
635
  description: "Hostname or IP address of the Bambu printer (default: value from env)"
@@ -497,7 +652,7 @@ class ThreeDPrinterMCPServer {
497
652
  additionalProperties: { type: "number" }
498
653
  }
499
654
  },
500
- required: ["three_mf_path"]
655
+ required: ["three_mf_path", "bambu_model"]
501
656
  }
502
657
  },
503
658
  {
@@ -545,6 +700,33 @@ class ThreeDPrinterMCPServer {
545
700
  },
546
701
  required: ["stl_path"]
547
702
  }
703
+ },
704
+ {
705
+ name: "blender_mcp_edit_model",
706
+ description: "Optionally send STL-edit instructions to a Blender MCP bridge command. Use for advanced model edits outside built-in STL tools.",
707
+ inputSchema: {
708
+ type: "object",
709
+ properties: {
710
+ stl_path: {
711
+ type: "string",
712
+ description: "Path to the local STL file."
713
+ },
714
+ operations: {
715
+ type: "array",
716
+ description: "Ordered edit operations for Blender (e.g. remesh, boolean, decimate).",
717
+ items: { type: "string" }
718
+ },
719
+ bridge_command: {
720
+ type: "string",
721
+ description: "Optional override command for invoking a local Blender MCP bridge."
722
+ },
723
+ execute: {
724
+ type: "boolean",
725
+ description: "When true, attempts to execute bridge command; otherwise returns a prepared payload only."
726
+ }
727
+ },
728
+ required: ["stl_path", "operations"]
729
+ }
548
730
  }
549
731
  ]
550
732
  };
@@ -599,12 +781,21 @@ class ThreeDPrinterMCPServer {
599
781
  }
600
782
  result = await this.stlManipulator.extendBase(String(args.stl_path), Number(args.extension_inches));
601
783
  break;
602
- case "slice_stl":
784
+ case "slice_stl": {
603
785
  if (!args?.stl_path) {
604
786
  throw new Error("Missing required parameter: stl_path");
605
787
  }
606
- result = await this.stlManipulator.sliceSTL(String(args.stl_path), slicerType, slicerPath, slicerProfile || undefined);
788
+ // Resolve printer model for BambuStudio slicer
789
+ let slicePreset;
790
+ if (slicerType === 'bambustudio') {
791
+ const sliceModel = await this.resolveBambuModel(args?.bambu_model);
792
+ const nozzleDiam = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
793
+ slicePreset = BAMBU_MODEL_PRESETS[sliceModel]?.(nozzleDiam);
794
+ }
795
+ result = await this.stlManipulator.sliceSTL(String(args.stl_path), slicerType, slicerPath, slicerProfile || undefined, undefined, // progressCallback
796
+ slicePreset);
607
797
  break;
798
+ }
608
799
  case "confirm_temperatures":
609
800
  if (!args?.gcode_path) {
610
801
  throw new Error("Missing required parameter: gcode_path");
@@ -625,7 +816,13 @@ class ThreeDPrinterMCPServer {
625
816
  // 1. Extend the base of the STL file
626
817
  const extendedStlPath = await this.stlManipulator.extendBase(String(args.stl_path), Number(args.extension_inches), processProgressCallback);
627
818
  // 2. Slice the extended STL file
628
- const gcodePath = await this.stlManipulator.sliceSTL(extendedStlPath, slicerType, slicerPath, slicerProfile || undefined, processProgressCallback);
819
+ let processPreset;
820
+ if (slicerType === 'bambustudio') {
821
+ const processModel = await this.resolveBambuModel(args?.bambu_model);
822
+ const processNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
823
+ processPreset = BAMBU_MODEL_PRESETS[processModel]?.(processNozzle);
824
+ }
825
+ const gcodePath = await this.stlManipulator.sliceSTL(extendedStlPath, slicerType, slicerPath, slicerProfile || undefined, processProgressCallback, processPreset);
629
826
  // 3. Confirm temperatures if specified
630
827
  if (args.extruder_temp !== undefined || args.bed_temp !== undefined) {
631
828
  const tempConfirmation = await this.stlManipulator.confirmTemperatures(gcodePath, {
@@ -781,7 +978,7 @@ class ThreeDPrinterMCPServer {
781
978
  const height = args.height !== undefined ? Number(args.height) : 300;
782
979
  result = await this.stlManipulator.generateVisualization(String(args.stl_path), width, height, visualizationProgressCallback);
783
980
  break;
784
- case "print_3mf":
981
+ case "print_3mf": {
785
982
  if (!args?.three_mf_path) {
786
983
  throw new Error("Missing required parameter: three_mf_path");
787
984
  }
@@ -791,7 +988,27 @@ class ThreeDPrinterMCPServer {
791
988
  if (!bambuSerial || !bambuToken) {
792
989
  throw new Error("Bambu serial number and access token are required for print_3mf.");
793
990
  }
794
- const threeMFPath = String(args.three_mf_path);
991
+ const printModel = await this.resolveBambuModel(args?.bambu_model);
992
+ const printBedType = resolveBedType(args?.bed_type);
993
+ const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
994
+ const printPreset = BAMBU_MODEL_PRESETS[printModel]?.(printNozzle);
995
+ let threeMFPath = String(args.three_mf_path);
996
+ // Auto-slice if the 3MF doesn't contain gcode
997
+ try {
998
+ const JSZip = (await import('jszip')).default;
999
+ const zipData = fs.readFileSync(threeMFPath);
1000
+ const zip = await JSZip.loadAsync(zipData);
1001
+ const hasGcode = Object.keys(zip.files).some(f => f.match(/Metadata\/plate_\d+\.gcode/i) || f.endsWith('.gcode'));
1002
+ if (!hasGcode) {
1003
+ console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
1004
+ threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, slicerPath, slicerProfile || undefined, undefined, // progressCallback
1005
+ printPreset);
1006
+ console.log("Auto-sliced to: " + threeMFPath);
1007
+ }
1008
+ }
1009
+ catch (sliceCheckErr) {
1010
+ console.warn("Could not check/slice 3MF, proceeding with original:", sliceCheckErr.message);
1011
+ }
795
1012
  // Define variables needed outside the parse try block
796
1013
  let implementation;
797
1014
  let threeMfFilename;
@@ -862,12 +1079,13 @@ class ThreeDPrinterMCPServer {
862
1079
  printOptions = {
863
1080
  useAMS: useAMS,
864
1081
  amsMapping: finalAmsMapping,
1082
+ bedType: printBedType,
865
1083
  bedLeveling: args?.bed_leveling !== undefined ? Boolean(args.bed_leveling) : undefined,
866
1084
  flowCalibration: args?.flow_calibration !== undefined ? Boolean(args.flow_calibration) : undefined,
867
1085
  vibrationCalibration: args?.vibration_calibration !== undefined ? Boolean(args.vibration_calibration) : undefined,
868
1086
  layerInspect: args?.layer_inspect !== undefined ? Boolean(args.layer_inspect) : undefined,
869
1087
  timelapse: args?.timelapse !== undefined ? Boolean(args.timelapse) : undefined,
870
- // md5: parsed3MFData?.metadata?.md5
1088
+ // md5: parsed3MFData?.metadata?.md5
871
1089
  };
872
1090
  }
873
1091
  catch (error) { // Catch parsing or setup errors
@@ -889,6 +1107,7 @@ class ThreeDPrinterMCPServer {
889
1107
  throw new Error(`Failed to start print: ${printError.message}`);
890
1108
  }
891
1109
  break;
1110
+ }
892
1111
  case "merge_vertices":
893
1112
  if (!args?.stl_path) {
894
1113
  throw new Error("Missing required parameter: stl_path");
@@ -908,6 +1127,17 @@ class ThreeDPrinterMCPServer {
908
1127
  }
909
1128
  result = await this.stlManipulator.layFlat(String(args.stl_path));
910
1129
  break;
1130
+ case "blender_mcp_edit_model":
1131
+ if (!args?.stl_path || !Array.isArray(args.operations)) {
1132
+ throw new Error("Missing required parameters: stl_path and operations");
1133
+ }
1134
+ result = await this.invokeBlenderBridge({
1135
+ stlPath: String(args.stl_path),
1136
+ operations: args.operations.map((entry) => String(entry)),
1137
+ execute: Boolean(args.execute ?? false),
1138
+ bridgeCommand: args.bridge_command !== undefined ? String(args.bridge_command) : undefined,
1139
+ });
1140
+ break;
911
1141
  default:
912
1142
  throw new Error(`Unknown tool: ${name}`);
913
1143
  }
@@ -923,13 +1153,15 @@ class ThreeDPrinterMCPServer {
923
1153
  catch (error) {
924
1154
  console.error(`Error calling tool ${name}:`, error);
925
1155
  const errorMessage = error instanceof Error ? error.message : String(error);
1156
+ const structuredError = this.toStructuredToolError(name, errorMessage);
926
1157
  return {
927
1158
  content: [
928
1159
  {
929
1160
  type: "text",
930
- text: `Error: ${errorMessage}`
1161
+ text: `Error: ${errorMessage}\nSuggestion: ${structuredError.suggestion}`
931
1162
  }
932
1163
  ],
1164
+ structuredContent: structuredError,
933
1165
  isError: true
934
1166
  };
935
1167
  }
@@ -955,7 +1187,8 @@ class ThreeDPrinterMCPServer {
955
1187
  async getPrinterFile(host, filename, port = DEFAULT_PORT, type = DEFAULT_TYPE, apiKey = DEFAULT_API_KEY, bambuSerial = DEFAULT_BAMBU_SERIAL, bambuToken = DEFAULT_BAMBU_TOKEN) {
956
1188
  const implementation = this.printerFactory.getImplementation(type);
957
1189
  if (type.toLowerCase() === "bambu") {
958
- return implementation.getFile(host, port, apiKey, bambuSerial, bambuToken, filename);
1190
+ const bambuApiKey = `${bambuSerial}:${bambuToken}`;
1191
+ return implementation.getFile(host, port, bambuApiKey, filename);
959
1192
  }
960
1193
  return implementation.getFile(host, port, apiKey, filename);
961
1194
  }
@@ -966,7 +1199,8 @@ class ThreeDPrinterMCPServer {
966
1199
  try {
967
1200
  const implementation = this.printerFactory.getImplementation(type);
968
1201
  if (type.toLowerCase() === "bambu") {
969
- return await implementation.uploadFile(host, port, apiKey, bambuSerial, bambuToken, tempFilePath, filename, print);
1202
+ const bambuApiKey = `${bambuSerial}:${bambuToken}`;
1203
+ return await implementation.uploadFile(host, port, bambuApiKey, tempFilePath, filename, print);
970
1204
  }
971
1205
  return await implementation.uploadFile(host, port, apiKey, tempFilePath, filename, print);
972
1206
  }
@@ -980,39 +1214,230 @@ class ThreeDPrinterMCPServer {
980
1214
  async startPrint(host, port, type, apiKey, bambuSerial, bambuToken, gcodeFilename) {
981
1215
  const implementation = this.printerFactory.getImplementation(type);
982
1216
  if (type.toLowerCase() === "bambu") {
983
- // Bambu startJob likely uses G-code filename, not 3MF.
984
- // Keep this as is for starting pre-sliced G-code files.
985
- // The print_3mf tool handles starting 3MF prints.
986
- return await implementation.startJob(host, port, apiKey, bambuSerial, bambuToken, gcodeFilename);
1217
+ const bambuApiKey = `${bambuSerial}:${bambuToken}`;
1218
+ return await implementation.startJob(host, port, bambuApiKey, gcodeFilename);
987
1219
  }
988
1220
  return await implementation.startJob(host, port, apiKey, gcodeFilename);
989
1221
  }
990
1222
  async cancelPrint(host, port, type, apiKey, bambuSerial, bambuToken) {
991
1223
  const implementation = this.printerFactory.getImplementation(type);
992
1224
  if (type.toLowerCase() === "bambu") {
993
- return await implementation.cancelJob(host, port, apiKey, bambuSerial, bambuToken);
1225
+ const bambuApiKey = `${bambuSerial}:${bambuToken}`;
1226
+ return await implementation.cancelJob(host, port, bambuApiKey);
994
1227
  }
995
1228
  return await implementation.cancelJob(host, port, apiKey);
996
1229
  }
997
1230
  async setPrinterTemperature(host, port, type, apiKey, bambuSerial, bambuToken, component, temperature) {
998
1231
  const implementation = this.printerFactory.getImplementation(type);
999
1232
  if (type.toLowerCase() === "bambu") {
1000
- const bambuImplementation = this.printerFactory.getImplementation('bambu');
1001
- if (bambuImplementation instanceof BambuImplementation && typeof bambuImplementation.setTemperature === 'function') {
1002
- return await bambuImplementation.setTemperature(host, bambuSerial, bambuToken, component, temperature);
1003
- }
1004
- else {
1005
- console.warn('setTemperature not fully implemented for Bambu via direct commands yet.');
1006
- return { status: 'Command sent (implementation pending)' }; // Avoid throwing error if method doesn't exist
1007
- }
1233
+ const bambuApiKey = `${bambuSerial}:${bambuToken}`;
1234
+ return implementation.setTemperature(host, port, bambuApiKey, component, temperature);
1008
1235
  }
1009
1236
  return implementation.setTemperature(host, port, apiKey, component, temperature);
1010
1237
  }
1011
- async run() {
1238
+ toStructuredToolError(tool, message) {
1239
+ const isInputError = message.toLowerCase().includes("missing required") ||
1240
+ message.toLowerCase().includes("invalid") ||
1241
+ message.toLowerCase().includes("unsupported");
1242
+ return {
1243
+ status: "error",
1244
+ retryable: !isInputError,
1245
+ suggestion: isInputError
1246
+ ? "Fix tool arguments and retry."
1247
+ : "Retry the call. If it keeps failing, verify printer connectivity and credentials.",
1248
+ message,
1249
+ tool,
1250
+ };
1251
+ }
1252
+ async parseHttpRequestBody(req) {
1253
+ const chunks = [];
1254
+ for await (const chunk of req) {
1255
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1256
+ }
1257
+ if (chunks.length === 0) {
1258
+ return undefined;
1259
+ }
1260
+ const rawBody = Buffer.concat(chunks).toString("utf8").trim();
1261
+ if (!rawBody) {
1262
+ return undefined;
1263
+ }
1264
+ return JSON.parse(rawBody);
1265
+ }
1266
+ isAllowedOrigin(req) {
1267
+ const origin = req.headers.origin;
1268
+ if (!origin) {
1269
+ return true;
1270
+ }
1271
+ if (this.runtimeConfig.allowedOrigins.size === 0) {
1272
+ return false;
1273
+ }
1274
+ return this.runtimeConfig.allowedOrigins.has(origin);
1275
+ }
1276
+ async connectStdio() {
1012
1277
  const transport = new StdioServerTransport();
1013
1278
  await this.server.connect(transport);
1014
1279
  console.error("3D Printer MCP server running on stdio transport");
1015
1280
  }
1281
+ async connectStreamableHttp() {
1282
+ if (this.httpRuntime) {
1283
+ return;
1284
+ }
1285
+ const transport = new StreamableHTTPServerTransport({
1286
+ sessionIdGenerator: this.runtimeConfig.statefulSession ? () => randomUUID() : undefined,
1287
+ enableJsonResponse: this.runtimeConfig.enableJsonResponse
1288
+ });
1289
+ await this.server.connect(transport);
1290
+ const requestHandler = async (req, res) => {
1291
+ try {
1292
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1293
+ if (url.pathname !== this.runtimeConfig.httpPath) {
1294
+ res.statusCode = 404;
1295
+ res.setHeader("content-type", "application/json");
1296
+ res.end(JSON.stringify({ error: "Not Found" }));
1297
+ return;
1298
+ }
1299
+ if (!this.isAllowedOrigin(req)) {
1300
+ res.statusCode = 403;
1301
+ res.setHeader("content-type", "application/json");
1302
+ res.end(JSON.stringify({ error: "Forbidden origin" }));
1303
+ return;
1304
+ }
1305
+ const method = req.method?.toUpperCase() ?? "GET";
1306
+ if (!["POST", "GET", "DELETE"].includes(method)) {
1307
+ res.statusCode = 405;
1308
+ res.setHeader("allow", "POST, GET, DELETE");
1309
+ res.setHeader("content-type", "application/json");
1310
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
1311
+ return;
1312
+ }
1313
+ let parsedBody;
1314
+ if (method === "POST") {
1315
+ try {
1316
+ parsedBody = await this.parseHttpRequestBody(req);
1317
+ }
1318
+ catch {
1319
+ res.statusCode = 400;
1320
+ res.setHeader("content-type", "application/json");
1321
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
1322
+ return;
1323
+ }
1324
+ }
1325
+ await transport.handleRequest(req, res, parsedBody);
1326
+ }
1327
+ catch (error) {
1328
+ console.error("Error handling streamable-http request:", error);
1329
+ if (!res.headersSent) {
1330
+ res.statusCode = 500;
1331
+ res.setHeader("content-type", "application/json");
1332
+ res.end(JSON.stringify({
1333
+ jsonrpc: "2.0",
1334
+ error: { code: -32603, message: "Internal server error" },
1335
+ id: null
1336
+ }));
1337
+ }
1338
+ }
1339
+ };
1340
+ const httpServer = createHttpServer((req, res) => {
1341
+ void requestHandler(req, res);
1342
+ });
1343
+ await new Promise((resolve, reject) => {
1344
+ httpServer.once("error", reject);
1345
+ httpServer.listen(this.runtimeConfig.httpPort, this.runtimeConfig.httpHost, () => resolve());
1346
+ });
1347
+ this.httpRuntime = { transport, httpServer };
1348
+ console.error(`3D Printer MCP server running on streamable-http at http://${this.runtimeConfig.httpHost}:${this.runtimeConfig.httpPort}${this.runtimeConfig.httpPath}`);
1349
+ }
1350
+ async invokeBlenderBridge(params) {
1351
+ const command = params.bridgeCommand ?? this.runtimeConfig.blenderBridgeCommand;
1352
+ const payload = {
1353
+ modelPath: params.stlPath,
1354
+ operations: params.operations,
1355
+ source: "mcp-3d-printer-server"
1356
+ };
1357
+ if (!params.execute) {
1358
+ return {
1359
+ status: "prepared",
1360
+ message: "Prepared Blender MCP payload. Set execute=true to run bridge command.",
1361
+ bridgeCommand: command ?? null,
1362
+ payload
1363
+ };
1364
+ }
1365
+ if (!command) {
1366
+ throw new Error("execute=true requires bridge_command or BLENDER_MCP_BRIDGE_COMMAND env var.");
1367
+ }
1368
+ const { spawn } = await import("node:child_process");
1369
+ const result = await new Promise((resolve, reject) => {
1370
+ const child = spawn(command, {
1371
+ shell: true,
1372
+ stdio: ["pipe", "pipe", "pipe"]
1373
+ });
1374
+ let stdout = "";
1375
+ let stderr = "";
1376
+ child.stdout.setEncoding("utf8");
1377
+ child.stderr.setEncoding("utf8");
1378
+ child.stdout.on("data", (chunk) => {
1379
+ stdout += chunk;
1380
+ });
1381
+ child.stderr.on("data", (chunk) => {
1382
+ stderr += chunk;
1383
+ });
1384
+ child.on("error", reject);
1385
+ child.on("close", (code) => {
1386
+ if (code === 0) {
1387
+ resolve({ stdout, stderr });
1388
+ return;
1389
+ }
1390
+ reject(new Error(`Blender bridge command exited with code ${code ?? "unknown"}${stderr.trim() ? `: ${stderr.trim()}` : ""}`));
1391
+ });
1392
+ child.stdin.write(JSON.stringify(payload));
1393
+ child.stdin.end();
1394
+ });
1395
+ return {
1396
+ status: "executed",
1397
+ bridgeCommand: command,
1398
+ stdout: result.stdout?.toString() ?? "",
1399
+ stderr: result.stderr?.toString() ?? ""
1400
+ };
1401
+ }
1402
+ async close() {
1403
+ await this.printerFactory.disconnectAll();
1404
+ if (this.httpRuntime) {
1405
+ await this.httpRuntime.transport.close();
1406
+ await new Promise((resolve, reject) => {
1407
+ this.httpRuntime?.httpServer.close((error) => {
1408
+ if (error) {
1409
+ reject(error);
1410
+ return;
1411
+ }
1412
+ resolve();
1413
+ });
1414
+ });
1415
+ this.httpRuntime = undefined;
1416
+ }
1417
+ else {
1418
+ await this.server.close();
1419
+ }
1420
+ }
1421
+ async run() {
1422
+ if (this.runtimeConfig.transport === "streamable-http") {
1423
+ await this.connectStreamableHttp();
1424
+ return;
1425
+ }
1426
+ await this.connectStdio();
1427
+ }
1016
1428
  }
1017
1429
  const server = new ThreeDPrinterMCPServer();
1018
- server.run().catch(console.error);
1430
+ const shutdown = async () => {
1431
+ await server.close();
1432
+ process.exit(0);
1433
+ };
1434
+ process.once("SIGINT", () => {
1435
+ void shutdown();
1436
+ });
1437
+ process.once("SIGTERM", () => {
1438
+ void shutdown();
1439
+ });
1440
+ server.run().catch((error) => {
1441
+ console.error("[mcp-3d-printer-server] startup failed:", error);
1442
+ process.exit(1);
1443
+ });