mcp-3d-printer-server 1.0.3 → 1.2.2

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
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
6
  import {
6
7
  ListResourcesRequestSchema,
7
8
  ReadResourceRequestSchema,
@@ -14,6 +15,8 @@ import axios from "axios";
14
15
  import dotenv from "dotenv";
15
16
  import fs from "fs";
16
17
  import path from "path";
18
+ import { createServer as createHttpServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from "node:http";
19
+ import { randomUUID } from "node:crypto";
17
20
  import * as THREE from 'three';
18
21
  import { PrinterFactory } from "./printers/printer-factory.js";
19
22
  import { STLManipulator } from "./stl/stl-manipulator.js";
@@ -31,14 +34,139 @@ const DEFAULT_TYPE = process.env.PRINTER_TYPE || "octoprint"; // Default to Octo
31
34
  const TEMP_DIR = process.env.TEMP_DIR || path.join(process.cwd(), "temp");
32
35
 
33
36
  // Slicer configuration
34
- const DEFAULT_SLICER_TYPE = process.env.SLICER_TYPE || "prusaslicer";
35
- const DEFAULT_SLICER_PATH = process.env.SLICER_PATH || "";
37
+ const DEFAULT_SLICER_TYPE = process.env.SLICER_TYPE || (process.env.PRINTER_TYPE === "bambu" ? "bambustudio" : "prusaslicer");
38
+ const DEFAULT_SLICER_PATH = process.env.SLICER_PATH || (process.env.PRINTER_TYPE === "bambu" ? "/Applications/BambuStudio.app/Contents/MacOS/BambuStudio" : "");
36
39
  const DEFAULT_SLICER_PROFILE = process.env.SLICER_PROFILE || "";
37
40
 
38
41
  // Bambu-specific default values
39
42
  const DEFAULT_BAMBU_SERIAL = process.env.BAMBU_SERIAL || "";
40
43
  const DEFAULT_BAMBU_TOKEN = process.env.BAMBU_TOKEN || "";
41
44
 
45
+ // Printer model and bed type (Bambu safety)
46
+ const DEFAULT_BAMBU_MODEL = process.env.BAMBU_MODEL?.trim().toLowerCase() || "";
47
+ const DEFAULT_BED_TYPE = process.env.BED_TYPE?.trim().toLowerCase() || "textured_plate";
48
+ const DEFAULT_NOZZLE_DIAMETER = process.env.NOZZLE_DIAMETER?.trim() || "0.4";
49
+
50
+ const VALID_BAMBU_MODELS = ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"] as const;
51
+ type BambuModel = typeof VALID_BAMBU_MODELS[number];
52
+
53
+ const VALID_BED_TYPES = ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"] as const;
54
+
55
+ // Map model IDs to BambuStudio --load-machine preset names
56
+ const BAMBU_MODEL_PRESETS: Record<string, (nozzle: string) => string> = {
57
+ p1s: (n) => `Bambu Lab P1S ${n} nozzle`,
58
+ p1p: (n) => `Bambu Lab P1P ${n} nozzle`,
59
+ x1c: (n) => `Bambu Lab X1 Carbon ${n} nozzle`,
60
+ x1e: (n) => `Bambu Lab X1E ${n} nozzle`,
61
+ a1: (n) => `Bambu Lab A1 ${n} nozzle`,
62
+ a1mini: (n) => `Bambu Lab A1 mini ${n} nozzle`,
63
+ h2d: (n) => `Bambu Lab H2D ${n} nozzle`,
64
+ };
65
+
66
+ function validateBambuModel(model: string): BambuModel {
67
+ const normalized = model.trim().toLowerCase();
68
+ if (!(VALID_BAMBU_MODELS as readonly string[]).includes(normalized)) {
69
+ throw new Error(
70
+ `Invalid Bambu printer model "${model}". Valid models: ${VALID_BAMBU_MODELS.join(", ")}`
71
+ );
72
+ }
73
+ return normalized as BambuModel;
74
+ }
75
+
76
+ function resolveBedType(bedType: string | undefined): string {
77
+ const resolved = (bedType || DEFAULT_BED_TYPE).trim().toLowerCase();
78
+ if (!(VALID_BED_TYPES as readonly string[]).includes(resolved)) {
79
+ throw new Error(
80
+ `Invalid bed type "${bedType}". Valid types: ${VALID_BED_TYPES.join(", ")}`
81
+ );
82
+ }
83
+ return resolved;
84
+ }
85
+
86
+ type RuntimeConfig = {
87
+ transport: "stdio" | "streamable-http";
88
+ httpHost: string;
89
+ httpPort: number;
90
+ httpPath: string;
91
+ statefulSession: boolean;
92
+ enableJsonResponse: boolean;
93
+ allowedOrigins: Set<string>;
94
+ blenderBridgeCommand?: string;
95
+ };
96
+
97
+ function parseBooleanEnv(rawValue: string | undefined, fallback: boolean): boolean {
98
+ if (rawValue === undefined) {
99
+ return fallback;
100
+ }
101
+
102
+ const value = rawValue.trim().toLowerCase();
103
+ if (["1", "true", "yes", "on"].includes(value)) {
104
+ return true;
105
+ }
106
+ if (["0", "false", "no", "off"].includes(value)) {
107
+ return false;
108
+ }
109
+ return fallback;
110
+ }
111
+
112
+ function parsePort(value: string | undefined, fallback: number): number {
113
+ if (!value) {
114
+ return fallback;
115
+ }
116
+
117
+ const parsed = Number(value);
118
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
119
+ throw new Error(`Invalid MCP_HTTP_PORT value: ${value}`);
120
+ }
121
+ return parsed;
122
+ }
123
+
124
+ function normalizePath(pathValue: string | undefined): string {
125
+ const value = (pathValue ?? "/mcp").trim();
126
+ if (!value) {
127
+ return "/mcp";
128
+ }
129
+ return value.startsWith("/") ? value : `/${value}`;
130
+ }
131
+
132
+ function parseCsvEnv(value: string | undefined): Set<string> {
133
+ if (!value) {
134
+ return new Set();
135
+ }
136
+ const entries = value
137
+ .split(",")
138
+ .map((entry) => entry.trim())
139
+ .filter((entry) => entry.length > 0);
140
+ return new Set(entries);
141
+ }
142
+
143
+ function readRuntimeConfig(): RuntimeConfig {
144
+ const rawTransport = process.env.MCP_TRANSPORT?.trim().toLowerCase();
145
+ const transport =
146
+ rawTransport === "streamable-http" || rawTransport === "http"
147
+ ? "streamable-http"
148
+ : "stdio";
149
+
150
+ return {
151
+ transport,
152
+ httpHost: process.env.MCP_HTTP_HOST?.trim() || "127.0.0.1",
153
+ httpPort: parsePort(process.env.MCP_HTTP_PORT, 3000),
154
+ httpPath: normalizePath(process.env.MCP_HTTP_PATH),
155
+ statefulSession: parseBooleanEnv(process.env.MCP_HTTP_STATEFUL, true),
156
+ enableJsonResponse: parseBooleanEnv(process.env.MCP_HTTP_JSON_RESPONSE, true),
157
+ allowedOrigins: parseCsvEnv(process.env.MCP_HTTP_ALLOWED_ORIGINS),
158
+ blenderBridgeCommand: process.env.BLENDER_MCP_BRIDGE_COMMAND?.trim() || undefined,
159
+ };
160
+ }
161
+
162
+ type StructuredToolError = {
163
+ status: "error";
164
+ retryable: boolean;
165
+ suggestion: string;
166
+ message: string;
167
+ tool: string;
168
+ };
169
+
42
170
  // Ensure temp directory exists
43
171
  if (!fs.existsSync(TEMP_DIR)) {
44
172
  fs.mkdirSync(TEMP_DIR, { recursive: true });
@@ -48,12 +176,15 @@ class ThreeDPrinterMCPServer {
48
176
  private server: Server;
49
177
  private printerFactory: PrinterFactory;
50
178
  private stlManipulator: STLManipulator;
179
+ private readonly runtimeConfig: RuntimeConfig;
180
+ private httpRuntime?: { transport: StreamableHTTPServerTransport; httpServer: HttpServer };
51
181
 
52
182
  constructor() {
183
+ this.runtimeConfig = readRuntimeConfig();
53
184
  this.server = new Server(
54
185
  {
55
186
  name: "mcp-3d-printer-server",
56
- version: "1.0.0"
187
+ version: "1.2.0"
57
188
  },
58
189
  {
59
190
  capabilities: {
@@ -70,17 +201,68 @@ class ThreeDPrinterMCPServer {
70
201
  this.setupErrorHandling();
71
202
  }
72
203
 
204
+ private async resolveBambuModel(argsModel: string | undefined): Promise<string> {
205
+ const fromArgs = (argsModel || DEFAULT_BAMBU_MODEL).trim().toLowerCase();
206
+ if (fromArgs) {
207
+ return validateBambuModel(fromArgs);
208
+ }
209
+
210
+ // No model from args or env — ask the user via elicitation
211
+ try {
212
+ const result = await this.server.elicitInput({
213
+ mode: "form" as const,
214
+ message:
215
+ "Your Bambu Lab printer model is required for safe operation. " +
216
+ "Using the wrong model can cause the bed to crash into the nozzle and damage the printer.",
217
+ requestedSchema: {
218
+ type: "object",
219
+ properties: {
220
+ bambu_model: {
221
+ type: "string",
222
+ title: "Printer Model",
223
+ description: "Which Bambu Lab printer do you have?",
224
+ oneOf: [
225
+ { const: "p1s", title: "P1S" },
226
+ { const: "p1p", title: "P1P" },
227
+ { const: "x1c", title: "X1 Carbon" },
228
+ { const: "x1e", title: "X1E" },
229
+ { const: "a1", title: "A1" },
230
+ { const: "a1mini", title: "A1 Mini" },
231
+ { const: "h2d", title: "H2D" },
232
+ ],
233
+ },
234
+ },
235
+ required: ["bambu_model"],
236
+ },
237
+ });
238
+
239
+ if (result.action === "accept" && result.content?.bambu_model) {
240
+ return validateBambuModel(String(result.content.bambu_model));
241
+ }
242
+
243
+ throw new Error(
244
+ "Printer model selection was cancelled. Cannot proceed without knowing the printer model."
245
+ );
246
+ } catch (elicitError: any) {
247
+ const msg = elicitError?.message || String(elicitError);
248
+ if (
249
+ elicitError?.code === -32601 || elicitError?.code === -32600 ||
250
+ msg.includes("does not support") || msg.includes("elicitation")
251
+ ) {
252
+ throw new Error(
253
+ "bambu_model is required but your MCP client does not support elicitation. " +
254
+ `Set the BAMBU_MODEL environment variable or pass bambu_model in the tool call. ` +
255
+ `Valid models: ${VALID_BAMBU_MODELS.join(", ")}`
256
+ );
257
+ }
258
+ throw elicitError;
259
+ }
260
+ }
261
+
73
262
  setupErrorHandling() {
74
263
  this.server.onerror = (error) => {
75
264
  console.error("[MCP Error]", error);
76
265
  };
77
-
78
- process.on("SIGINT", async () => {
79
- // Disconnect all printers
80
- await this.printerFactory.disconnectAll();
81
- await this.server.close();
82
- process.exit(0);
83
- });
84
266
  }
85
267
 
86
268
  setupHandlers() {
@@ -230,17 +412,26 @@ class ThreeDPrinterMCPServer {
230
412
  },
231
413
  {
232
414
  name: "slice_stl",
233
- description: "Slice an STL file to generate G-code",
415
+ description: "Slice an STL or 3MF file to generate G-code or sliced 3MF",
234
416
  inputSchema: {
235
417
  type: "object",
236
418
  properties: {
237
419
  stl_path: {
238
420
  type: "string",
239
- description: "Path to the STL file to slice"
421
+ description: "Path to the STL or 3MF file to slice"
422
+ },
423
+ bambu_model: {
424
+ type: "string",
425
+ enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
426
+ description: "Bambu Lab printer model — required when using bambustudio slicer to ensure correct G-code."
427
+ },
428
+ nozzle_diameter: {
429
+ type: "string",
430
+ description: "Nozzle diameter in mm (default: 0.4)."
240
431
  },
241
432
  slicer_type: {
242
433
  type: "string",
243
- description: "Type of slicer to use (prusaslicer, cura, slic3r, orcaslicer) (default: value from env)"
434
+ description: "Type of slicer to use (prusaslicer, cura, slic3r, orcaslicer, bambustudio) (default: value from env)"
244
435
  },
245
436
  slicer_path: {
246
437
  type: "string",
@@ -507,6 +698,20 @@ class ThreeDPrinterMCPServer {
507
698
  type: "string",
508
699
  description: "Path to the 3MF file to print."
509
700
  },
701
+ bambu_model: {
702
+ type: "string",
703
+ enum: ["p1s", "p1p", "x1c", "x1e", "a1", "a1mini", "h2d"],
704
+ description: "REQUIRED: Bambu Lab printer model. Ensures correct G-code generation — wrong model can crash the bed into the nozzle."
705
+ },
706
+ bed_type: {
707
+ type: "string",
708
+ enum: ["textured_plate", "cool_plate", "engineering_plate", "hot_plate"],
709
+ description: "Bed/plate type installed on the printer (default: textured_plate)."
710
+ },
711
+ nozzle_diameter: {
712
+ type: "string",
713
+ description: "Nozzle diameter in mm (default: 0.4)."
714
+ },
510
715
  host: {
511
716
  type: "string",
512
717
  description: "Hostname or IP address of the Bambu printer (default: value from env)"
@@ -529,7 +734,7 @@ class ThreeDPrinterMCPServer {
529
734
  additionalProperties: { type: "number" }
530
735
  }
531
736
  },
532
- required: ["three_mf_path"]
737
+ required: ["three_mf_path", "bambu_model"]
533
738
  }
534
739
  },
535
740
  {
@@ -577,6 +782,33 @@ class ThreeDPrinterMCPServer {
577
782
  },
578
783
  required: ["stl_path"]
579
784
  }
785
+ },
786
+ {
787
+ name: "blender_mcp_edit_model",
788
+ description: "Optionally send STL-edit instructions to a Blender MCP bridge command. Use for advanced model edits outside built-in STL tools.",
789
+ inputSchema: {
790
+ type: "object",
791
+ properties: {
792
+ stl_path: {
793
+ type: "string",
794
+ description: "Path to the local STL file."
795
+ },
796
+ operations: {
797
+ type: "array",
798
+ description: "Ordered edit operations for Blender (e.g. remesh, boolean, decimate).",
799
+ items: { type: "string" }
800
+ },
801
+ bridge_command: {
802
+ type: "string",
803
+ description: "Optional override command for invoking a local Blender MCP bridge."
804
+ },
805
+ execute: {
806
+ type: "boolean",
807
+ description: "When true, attempts to execute bridge command; otherwise returns a prepared payload only."
808
+ }
809
+ },
810
+ required: ["stl_path", "operations"]
811
+ }
580
812
  }
581
813
  ]
582
814
  };
@@ -593,7 +825,7 @@ class ThreeDPrinterMCPServer {
593
825
  const apiKey = String(args?.api_key || DEFAULT_API_KEY);
594
826
  const bambuSerial = String(args?.bambu_serial || DEFAULT_BAMBU_SERIAL);
595
827
  const bambuToken = String(args?.bambu_token || DEFAULT_BAMBU_TOKEN);
596
- const slicerType = String(args?.slicer_type || DEFAULT_SLICER_TYPE) as 'prusaslicer' | 'cura' | 'slic3r';
828
+ const slicerType = String(args?.slicer_type || DEFAULT_SLICER_TYPE) as 'prusaslicer' | 'cura' | 'slic3r' | 'orcaslicer' | 'bambustudio';
597
829
  const slicerPath = String(args?.slicer_path || DEFAULT_SLICER_PATH);
598
830
  const slicerProfile = String(args?.slicer_profile || DEFAULT_SLICER_PROFILE);
599
831
 
@@ -654,17 +886,27 @@ class ThreeDPrinterMCPServer {
654
886
  );
655
887
  break;
656
888
 
657
- case "slice_stl":
889
+ case "slice_stl": {
658
890
  if (!args?.stl_path) {
659
891
  throw new Error("Missing required parameter: stl_path");
660
892
  }
893
+ // Resolve printer model for BambuStudio slicer
894
+ let slicePreset: string | undefined;
895
+ if (slicerType === 'bambustudio') {
896
+ const sliceModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
897
+ const nozzleDiam = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
898
+ slicePreset = BAMBU_MODEL_PRESETS[sliceModel]?.(nozzleDiam);
899
+ }
661
900
  result = await this.stlManipulator.sliceSTL(
662
901
  String(args.stl_path),
663
902
  slicerType,
664
903
  slicerPath,
665
- slicerProfile || undefined
904
+ slicerProfile || undefined,
905
+ undefined, // progressCallback
906
+ slicePreset
666
907
  );
667
908
  break;
909
+ }
668
910
 
669
911
  case "confirm_temperatures":
670
912
  if (!args?.gcode_path) {
@@ -697,12 +939,19 @@ class ThreeDPrinterMCPServer {
697
939
  );
698
940
 
699
941
  // 2. Slice the extended STL file
942
+ let processPreset: string | undefined;
943
+ if (slicerType === 'bambustudio') {
944
+ const processModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
945
+ const processNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
946
+ processPreset = BAMBU_MODEL_PRESETS[processModel]?.(processNozzle);
947
+ }
700
948
  const gcodePath = await this.stlManipulator.sliceSTL(
701
949
  extendedStlPath,
702
950
  slicerType,
703
951
  slicerPath,
704
952
  slicerProfile || undefined,
705
- processProgressCallback
953
+ processProgressCallback,
954
+ processPreset
706
955
  );
707
956
 
708
957
  // 3. Confirm temperatures if specified
@@ -928,7 +1177,7 @@ class ThreeDPrinterMCPServer {
928
1177
  );
929
1178
  break;
930
1179
 
931
- case "print_3mf":
1180
+ case "print_3mf": {
932
1181
  if (!args?.three_mf_path) {
933
1182
  throw new Error("Missing required parameter: three_mf_path");
934
1183
  }
@@ -938,9 +1187,38 @@ class ThreeDPrinterMCPServer {
938
1187
  if (!bambuSerial || !bambuToken) {
939
1188
  throw new Error("Bambu serial number and access token are required for print_3mf.");
940
1189
  }
941
-
942
- const threeMFPath = String(args.three_mf_path);
943
-
1190
+
1191
+ const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1192
+ const printBedType = resolveBedType(args?.bed_type as string | undefined);
1193
+ const printNozzle = String(args?.nozzle_diameter || DEFAULT_NOZZLE_DIAMETER);
1194
+ const printPreset = BAMBU_MODEL_PRESETS[printModel]?.(printNozzle);
1195
+
1196
+ let threeMFPath = String(args.three_mf_path);
1197
+
1198
+ // Auto-slice if the 3MF doesn't contain gcode
1199
+ try {
1200
+ const JSZip = (await import('jszip')).default;
1201
+ const zipData = fs.readFileSync(threeMFPath);
1202
+ const zip = await JSZip.loadAsync(zipData);
1203
+ const hasGcode = Object.keys(zip.files).some(
1204
+ f => f.match(/Metadata\/plate_\d+\.gcode/i) || f.endsWith('.gcode')
1205
+ );
1206
+ if (!hasGcode) {
1207
+ console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
1208
+ threeMFPath = await this.stlManipulator.sliceSTL(
1209
+ threeMFPath,
1210
+ slicerType,
1211
+ slicerPath,
1212
+ slicerProfile || undefined,
1213
+ undefined, // progressCallback
1214
+ printPreset
1215
+ );
1216
+ console.log("Auto-sliced to: " + threeMFPath);
1217
+ }
1218
+ } catch (sliceCheckErr: any) {
1219
+ console.warn("Could not check/slice 3MF, proceeding with original:", sliceCheckErr.message);
1220
+ }
1221
+
944
1222
  // Define variables needed outside the parse try block
945
1223
  let implementation: BambuImplementation;
946
1224
  let threeMfFilename: string;
@@ -1013,12 +1291,13 @@ class ThreeDPrinterMCPServer {
1013
1291
  printOptions = { // Assign to outer scope variable
1014
1292
  useAMS: useAMS,
1015
1293
  amsMapping: finalAmsMapping,
1294
+ bedType: printBedType,
1016
1295
  bedLeveling: args?.bed_leveling !== undefined ? Boolean(args.bed_leveling) : undefined,
1017
1296
  flowCalibration: args?.flow_calibration !== undefined ? Boolean(args.flow_calibration) : undefined,
1018
1297
  vibrationCalibration: args?.vibration_calibration !== undefined ? Boolean(args.vibration_calibration) : undefined,
1019
1298
  layerInspect: args?.layer_inspect !== undefined ? Boolean(args.layer_inspect) : undefined,
1020
1299
  timelapse: args?.timelapse !== undefined ? Boolean(args.timelapse) : undefined,
1021
- // md5: parsed3MFData?.metadata?.md5
1300
+ // md5: parsed3MFData?.metadata?.md5
1022
1301
  };
1023
1302
 
1024
1303
  } catch (error) { // Catch parsing or setup errors
@@ -1041,7 +1320,8 @@ class ThreeDPrinterMCPServer {
1041
1320
  }
1042
1321
 
1043
1322
  break;
1044
-
1323
+ }
1324
+
1045
1325
  case "merge_vertices":
1046
1326
  if (!args?.stl_path) {
1047
1327
  throw new Error("Missing required parameter: stl_path");
@@ -1065,6 +1345,19 @@ class ThreeDPrinterMCPServer {
1065
1345
  }
1066
1346
  result = await this.stlManipulator.layFlat(String(args.stl_path));
1067
1347
  break;
1348
+
1349
+ case "blender_mcp_edit_model":
1350
+ if (!args?.stl_path || !Array.isArray(args.operations)) {
1351
+ throw new Error("Missing required parameters: stl_path and operations");
1352
+ }
1353
+ result = await this.invokeBlenderBridge({
1354
+ stlPath: String(args.stl_path),
1355
+ operations: args.operations.map((entry) => String(entry)),
1356
+ execute: Boolean(args.execute ?? false),
1357
+ bridgeCommand:
1358
+ args.bridge_command !== undefined ? String(args.bridge_command) : undefined,
1359
+ });
1360
+ break;
1068
1361
 
1069
1362
  default:
1070
1363
  throw new Error(`Unknown tool: ${name}`);
@@ -1082,14 +1375,16 @@ class ThreeDPrinterMCPServer {
1082
1375
  console.error(`Error calling tool ${name}:`, error);
1083
1376
 
1084
1377
  const errorMessage = error instanceof Error ? error.message : String(error);
1378
+ const structuredError = this.toStructuredToolError(name, errorMessage);
1085
1379
 
1086
1380
  return {
1087
1381
  content: [
1088
1382
  {
1089
1383
  type: "text",
1090
- text: `Error: ${errorMessage}`
1384
+ text: `Error: ${errorMessage}\nSuggestion: ${structuredError.suggestion}`
1091
1385
  }
1092
1386
  ],
1387
+ structuredContent: structuredError,
1093
1388
  isError: true
1094
1389
  };
1095
1390
  }
@@ -1146,7 +1441,8 @@ class ThreeDPrinterMCPServer {
1146
1441
  const implementation = this.printerFactory.getImplementation(type);
1147
1442
 
1148
1443
  if (type.toLowerCase() === "bambu") {
1149
- return (implementation as any).getFile(host, port, apiKey, bambuSerial, bambuToken, filename);
1444
+ const bambuApiKey = `${bambuSerial}:${bambuToken}`;
1445
+ return implementation.getFile(host, port, bambuApiKey, filename);
1150
1446
  }
1151
1447
 
1152
1448
  return implementation.getFile(host, port, apiKey, filename);
@@ -1172,9 +1468,8 @@ class ThreeDPrinterMCPServer {
1172
1468
  const implementation = this.printerFactory.getImplementation(type);
1173
1469
 
1174
1470
  if (type.toLowerCase() === "bambu") {
1175
- return await (implementation as any).uploadFile(
1176
- host, port, apiKey, bambuSerial, bambuToken, tempFilePath, filename, print
1177
- );
1471
+ const bambuApiKey = `${bambuSerial}:${bambuToken}`;
1472
+ return await implementation.uploadFile(host, port, bambuApiKey, tempFilePath, filename, print);
1178
1473
  }
1179
1474
 
1180
1475
  return await implementation.uploadFile(host, port, apiKey, tempFilePath, filename, print);
@@ -1198,12 +1493,8 @@ class ThreeDPrinterMCPServer {
1198
1493
  const implementation = this.printerFactory.getImplementation(type);
1199
1494
 
1200
1495
  if (type.toLowerCase() === "bambu") {
1201
- // Bambu startJob likely uses G-code filename, not 3MF.
1202
- // Keep this as is for starting pre-sliced G-code files.
1203
- // The print_3mf tool handles starting 3MF prints.
1204
- return await (implementation as any).startJob(
1205
- host, port, apiKey, bambuSerial, bambuToken, gcodeFilename
1206
- );
1496
+ const bambuApiKey = `${bambuSerial}:${bambuToken}`;
1497
+ return await implementation.startJob(host, port, bambuApiKey, gcodeFilename);
1207
1498
  }
1208
1499
 
1209
1500
  return await implementation.startJob(host, port, apiKey, gcodeFilename);
@@ -1220,9 +1511,8 @@ class ThreeDPrinterMCPServer {
1220
1511
  const implementation = this.printerFactory.getImplementation(type);
1221
1512
 
1222
1513
  if (type.toLowerCase() === "bambu") {
1223
- return await (implementation as any).cancelJob(
1224
- host, port, apiKey, bambuSerial, bambuToken
1225
- );
1514
+ const bambuApiKey = `${bambuSerial}:${bambuToken}`;
1515
+ return await implementation.cancelJob(host, port, bambuApiKey);
1226
1516
  }
1227
1517
 
1228
1518
  return await implementation.cancelJob(host, port, apiKey);
@@ -1241,27 +1531,269 @@ class ThreeDPrinterMCPServer {
1241
1531
  const implementation = this.printerFactory.getImplementation(type);
1242
1532
 
1243
1533
  if (type.toLowerCase() === "bambu") {
1244
- const bambuImplementation = this.printerFactory.getImplementation('bambu');
1245
-
1246
- if (bambuImplementation instanceof BambuImplementation && typeof bambuImplementation.setTemperature === 'function') {
1247
- return await bambuImplementation.setTemperature(
1248
- host, bambuSerial, bambuToken, component, temperature
1249
- );
1250
- } else {
1251
- console.warn('setTemperature not fully implemented for Bambu via direct commands yet.');
1252
- return { status: 'Command sent (implementation pending)'}; // Avoid throwing error if method doesn't exist
1253
- }
1534
+ const bambuApiKey = `${bambuSerial}:${bambuToken}`;
1535
+ return implementation.setTemperature(host, port, bambuApiKey, component, temperature);
1254
1536
  }
1255
1537
 
1256
1538
  return implementation.setTemperature(host, port, apiKey, component, temperature);
1257
1539
  }
1258
1540
 
1259
- async run() {
1541
+ private toStructuredToolError(tool: string, message: string): StructuredToolError {
1542
+ const isInputError =
1543
+ message.toLowerCase().includes("missing required") ||
1544
+ message.toLowerCase().includes("invalid") ||
1545
+ message.toLowerCase().includes("unsupported");
1546
+
1547
+ return {
1548
+ status: "error",
1549
+ retryable: !isInputError,
1550
+ suggestion: isInputError
1551
+ ? "Fix tool arguments and retry."
1552
+ : "Retry the call. If it keeps failing, verify printer connectivity and credentials.",
1553
+ message,
1554
+ tool,
1555
+ };
1556
+ }
1557
+
1558
+ private async parseHttpRequestBody(req: IncomingMessage): Promise<unknown> {
1559
+ const chunks: Buffer[] = [];
1560
+ for await (const chunk of req) {
1561
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1562
+ }
1563
+
1564
+ if (chunks.length === 0) {
1565
+ return undefined;
1566
+ }
1567
+
1568
+ const rawBody = Buffer.concat(chunks).toString("utf8").trim();
1569
+ if (!rawBody) {
1570
+ return undefined;
1571
+ }
1572
+
1573
+ return JSON.parse(rawBody);
1574
+ }
1575
+
1576
+ private isAllowedOrigin(req: IncomingMessage): boolean {
1577
+ const origin = req.headers.origin;
1578
+ if (!origin) {
1579
+ return true;
1580
+ }
1581
+
1582
+ if (this.runtimeConfig.allowedOrigins.size === 0) {
1583
+ return false;
1584
+ }
1585
+
1586
+ return this.runtimeConfig.allowedOrigins.has(origin);
1587
+ }
1588
+
1589
+ private async connectStdio(): Promise<void> {
1260
1590
  const transport = new StdioServerTransport();
1261
1591
  await this.server.connect(transport);
1262
1592
  console.error("3D Printer MCP server running on stdio transport");
1263
1593
  }
1594
+
1595
+ private async connectStreamableHttp(): Promise<void> {
1596
+ if (this.httpRuntime) {
1597
+ return;
1598
+ }
1599
+
1600
+ const transport = new StreamableHTTPServerTransport({
1601
+ sessionIdGenerator: this.runtimeConfig.statefulSession ? () => randomUUID() : undefined,
1602
+ enableJsonResponse: this.runtimeConfig.enableJsonResponse
1603
+ });
1604
+
1605
+ await this.server.connect(transport);
1606
+
1607
+ const requestHandler = async (req: IncomingMessage, res: ServerResponse) => {
1608
+ try {
1609
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1610
+ if (url.pathname !== this.runtimeConfig.httpPath) {
1611
+ res.statusCode = 404;
1612
+ res.setHeader("content-type", "application/json");
1613
+ res.end(JSON.stringify({ error: "Not Found" }));
1614
+ return;
1615
+ }
1616
+
1617
+ if (!this.isAllowedOrigin(req)) {
1618
+ res.statusCode = 403;
1619
+ res.setHeader("content-type", "application/json");
1620
+ res.end(JSON.stringify({ error: "Forbidden origin" }));
1621
+ return;
1622
+ }
1623
+
1624
+ const method = req.method?.toUpperCase() ?? "GET";
1625
+ if (!["POST", "GET", "DELETE"].includes(method)) {
1626
+ res.statusCode = 405;
1627
+ res.setHeader("allow", "POST, GET, DELETE");
1628
+ res.setHeader("content-type", "application/json");
1629
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
1630
+ return;
1631
+ }
1632
+
1633
+ let parsedBody: unknown;
1634
+ if (method === "POST") {
1635
+ try {
1636
+ parsedBody = await this.parseHttpRequestBody(req);
1637
+ } catch {
1638
+ res.statusCode = 400;
1639
+ res.setHeader("content-type", "application/json");
1640
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
1641
+ return;
1642
+ }
1643
+ }
1644
+
1645
+ await transport.handleRequest(req, res, parsedBody);
1646
+ } catch (error) {
1647
+ console.error("Error handling streamable-http request:", error);
1648
+ if (!res.headersSent) {
1649
+ res.statusCode = 500;
1650
+ res.setHeader("content-type", "application/json");
1651
+ res.end(
1652
+ JSON.stringify({
1653
+ jsonrpc: "2.0",
1654
+ error: { code: -32603, message: "Internal server error" },
1655
+ id: null
1656
+ })
1657
+ );
1658
+ }
1659
+ }
1660
+ };
1661
+
1662
+ const httpServer = createHttpServer((req, res) => {
1663
+ void requestHandler(req, res);
1664
+ });
1665
+
1666
+ await new Promise<void>((resolve, reject) => {
1667
+ httpServer.once("error", reject);
1668
+ httpServer.listen(this.runtimeConfig.httpPort, this.runtimeConfig.httpHost, () => resolve());
1669
+ });
1670
+
1671
+ this.httpRuntime = { transport, httpServer };
1672
+ console.error(
1673
+ `3D Printer MCP server running on streamable-http at http://${this.runtimeConfig.httpHost}:${this.runtimeConfig.httpPort}${this.runtimeConfig.httpPath}`
1674
+ );
1675
+ }
1676
+
1677
+ private async invokeBlenderBridge(params: {
1678
+ stlPath: string;
1679
+ operations: string[];
1680
+ execute: boolean;
1681
+ bridgeCommand?: string;
1682
+ }): Promise<Record<string, unknown>> {
1683
+ const command = params.bridgeCommand ?? this.runtimeConfig.blenderBridgeCommand;
1684
+ const payload = {
1685
+ modelPath: params.stlPath,
1686
+ operations: params.operations,
1687
+ source: "mcp-3d-printer-server"
1688
+ };
1689
+
1690
+ if (!params.execute) {
1691
+ return {
1692
+ status: "prepared",
1693
+ message: "Prepared Blender MCP payload. Set execute=true to run bridge command.",
1694
+ bridgeCommand: command ?? null,
1695
+ payload
1696
+ };
1697
+ }
1698
+
1699
+ if (!command) {
1700
+ throw new Error(
1701
+ "execute=true requires bridge_command or BLENDER_MCP_BRIDGE_COMMAND env var."
1702
+ );
1703
+ }
1704
+
1705
+ const { spawn } = await import("node:child_process");
1706
+ const result = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
1707
+ const child = spawn(command, {
1708
+ shell: true,
1709
+ stdio: ["pipe", "pipe", "pipe"]
1710
+ });
1711
+
1712
+ let stdout = "";
1713
+ let stderr = "";
1714
+
1715
+ child.stdout.setEncoding("utf8");
1716
+ child.stderr.setEncoding("utf8");
1717
+ child.stdout.on("data", (chunk: string) => {
1718
+ stdout += chunk;
1719
+ });
1720
+ child.stderr.on("data", (chunk: string) => {
1721
+ stderr += chunk;
1722
+ });
1723
+ child.on("error", reject);
1724
+ child.on("close", (code) => {
1725
+ if (code === 0) {
1726
+ resolve({ stdout, stderr });
1727
+ return;
1728
+ }
1729
+
1730
+ reject(
1731
+ new Error(
1732
+ `Blender bridge command exited with code ${code ?? "unknown"}${
1733
+ stderr.trim() ? `: ${stderr.trim()}` : ""
1734
+ }`
1735
+ )
1736
+ );
1737
+ });
1738
+
1739
+ child.stdin.write(JSON.stringify(payload));
1740
+ child.stdin.end();
1741
+ });
1742
+
1743
+ return {
1744
+ status: "executed",
1745
+ bridgeCommand: command,
1746
+ stdout: result.stdout?.toString() ?? "",
1747
+ stderr: result.stderr?.toString() ?? ""
1748
+ };
1749
+ }
1750
+
1751
+ async close(): Promise<void> {
1752
+ await this.printerFactory.disconnectAll();
1753
+
1754
+ if (this.httpRuntime) {
1755
+ await this.httpRuntime.transport.close();
1756
+ await new Promise<void>((resolve, reject) => {
1757
+ this.httpRuntime?.httpServer.close((error) => {
1758
+ if (error) {
1759
+ reject(error);
1760
+ return;
1761
+ }
1762
+ resolve();
1763
+ });
1764
+ });
1765
+ this.httpRuntime = undefined;
1766
+ } else {
1767
+ await this.server.close();
1768
+ }
1769
+ }
1770
+
1771
+ async run(): Promise<void> {
1772
+ if (this.runtimeConfig.transport === "streamable-http") {
1773
+ await this.connectStreamableHttp();
1774
+ return;
1775
+ }
1776
+
1777
+ await this.connectStdio();
1778
+ }
1264
1779
  }
1265
1780
 
1266
1781
  const server = new ThreeDPrinterMCPServer();
1267
- server.run().catch(console.error);
1782
+
1783
+ const shutdown = async () => {
1784
+ await server.close();
1785
+ process.exit(0);
1786
+ };
1787
+
1788
+ process.once("SIGINT", () => {
1789
+ void shutdown();
1790
+ });
1791
+
1792
+ process.once("SIGTERM", () => {
1793
+ void shutdown();
1794
+ });
1795
+
1796
+ server.run().catch((error) => {
1797
+ console.error("[mcp-3d-printer-server] startup failed:", error);
1798
+ process.exit(1);
1799
+ });