bambu-printer-mcp 1.0.0

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 ADDED
@@ -0,0 +1,700 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
+ import { ListResourcesRequestSchema, ReadResourceRequestSchema, ListToolsRequestSchema, CallToolRequestSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
6
+ import dotenv from "dotenv";
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { createServer as createHttpServer } from "node:http";
10
+ import { randomUUID } from "node:crypto";
11
+ import { STLManipulator } from "./stl/stl-manipulator.js";
12
+ import { parse3MF } from './3mf_parser.js';
13
+ import { BambuImplementation } from "./printers/bambu.js";
14
+ dotenv.config();
15
+ const DEFAULT_HOST = process.env.PRINTER_HOST || "localhost";
16
+ const DEFAULT_BAMBU_SERIAL = process.env.BAMBU_SERIAL || "";
17
+ const DEFAULT_BAMBU_TOKEN = process.env.BAMBU_TOKEN || "";
18
+ const TEMP_DIR = process.env.TEMP_DIR || path.join(process.cwd(), "temp");
19
+ // Slicer configuration (defaults to bambustudio)
20
+ const DEFAULT_SLICER_TYPE = process.env.SLICER_TYPE || "bambustudio";
21
+ const DEFAULT_SLICER_PATH = process.env.SLICER_PATH || "/Applications/BambuStudio.app/Contents/MacOS/BambuStudio";
22
+ const DEFAULT_SLICER_PROFILE = process.env.SLICER_PROFILE || "";
23
+ function parseBooleanEnv(rawValue, fallback) {
24
+ if (rawValue === undefined)
25
+ return fallback;
26
+ const value = rawValue.trim().toLowerCase();
27
+ if (["1", "true", "yes", "on"].includes(value))
28
+ return true;
29
+ if (["0", "false", "no", "off"].includes(value))
30
+ return false;
31
+ return fallback;
32
+ }
33
+ function parsePort(value, fallback) {
34
+ if (!value)
35
+ return fallback;
36
+ const parsed = Number(value);
37
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
38
+ throw new Error(`Invalid MCP_HTTP_PORT value: ${value}`);
39
+ }
40
+ return parsed;
41
+ }
42
+ function normalizePath(pathValue) {
43
+ const value = (pathValue ?? "/mcp").trim();
44
+ if (!value)
45
+ return "/mcp";
46
+ return value.startsWith("/") ? value : `/${value}`;
47
+ }
48
+ function parseCsvEnv(value) {
49
+ if (!value)
50
+ return new Set();
51
+ return new Set(value.split(",").map((e) => e.trim()).filter((e) => e.length > 0));
52
+ }
53
+ function readRuntimeConfig() {
54
+ const rawTransport = process.env.MCP_TRANSPORT?.trim().toLowerCase();
55
+ const transport = rawTransport === "streamable-http" || rawTransport === "http"
56
+ ? "streamable-http"
57
+ : "stdio";
58
+ return {
59
+ transport,
60
+ httpHost: process.env.MCP_HTTP_HOST?.trim() || "127.0.0.1",
61
+ httpPort: parsePort(process.env.MCP_HTTP_PORT, 3000),
62
+ httpPath: normalizePath(process.env.MCP_HTTP_PATH),
63
+ statefulSession: parseBooleanEnv(process.env.MCP_HTTP_STATEFUL, true),
64
+ enableJsonResponse: parseBooleanEnv(process.env.MCP_HTTP_JSON_RESPONSE, true),
65
+ allowedOrigins: parseCsvEnv(process.env.MCP_HTTP_ALLOWED_ORIGINS),
66
+ blenderBridgeCommand: process.env.BLENDER_MCP_BRIDGE_COMMAND?.trim() || undefined,
67
+ };
68
+ }
69
+ if (!fs.existsSync(TEMP_DIR)) {
70
+ fs.mkdirSync(TEMP_DIR, { recursive: true });
71
+ }
72
+ class BambuPrinterMCPServer {
73
+ constructor() {
74
+ this.runtimeConfig = readRuntimeConfig();
75
+ this.server = new Server({
76
+ name: "bambu-printer-mcp",
77
+ version: "1.0.0"
78
+ }, {
79
+ capabilities: {
80
+ resources: {},
81
+ tools: {}
82
+ }
83
+ });
84
+ this.bambu = new BambuImplementation();
85
+ this.stlManipulator = new STLManipulator(TEMP_DIR);
86
+ this.setupHandlers();
87
+ this.setupErrorHandling();
88
+ }
89
+ setupErrorHandling() {
90
+ this.server.onerror = (error) => {
91
+ console.error("[MCP Error]", error);
92
+ };
93
+ }
94
+ setupHandlers() {
95
+ this.setupResourceHandlers();
96
+ this.setupToolHandlers();
97
+ }
98
+ setupResourceHandlers() {
99
+ this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
100
+ return {
101
+ resources: [
102
+ {
103
+ uri: `printer://${DEFAULT_HOST}/status`,
104
+ name: "Bambu Printer Status",
105
+ mimeType: "application/json",
106
+ description: "Current status of the Bambu Lab printer"
107
+ },
108
+ {
109
+ uri: `printer://${DEFAULT_HOST}/files`,
110
+ name: "Bambu Printer Files",
111
+ mimeType: "application/json",
112
+ description: "List of files on the Bambu Lab printer"
113
+ }
114
+ ],
115
+ templates: [
116
+ {
117
+ uriTemplate: "printer://{host}/status",
118
+ name: "Bambu Printer Status",
119
+ mimeType: "application/json"
120
+ },
121
+ {
122
+ uriTemplate: "printer://{host}/files",
123
+ name: "Bambu Printer Files",
124
+ mimeType: "application/json"
125
+ }
126
+ ]
127
+ };
128
+ });
129
+ this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
130
+ const uri = request.params.uri;
131
+ const match = uri.match(/^printer:\/\/([^\/]+)\/(.+)$/);
132
+ if (!match) {
133
+ throw new McpError(ErrorCode.InvalidRequest, `Invalid resource URI: ${uri}`);
134
+ }
135
+ const [, host, resource] = match;
136
+ const bambuSerial = DEFAULT_BAMBU_SERIAL;
137
+ const bambuToken = DEFAULT_BAMBU_TOKEN;
138
+ let content;
139
+ if (resource === "status") {
140
+ content = await this.bambu.getStatus(host || DEFAULT_HOST, bambuSerial, bambuToken);
141
+ }
142
+ else if (resource === "files") {
143
+ content = await this.bambu.getFiles(host || DEFAULT_HOST, bambuSerial, bambuToken);
144
+ }
145
+ else {
146
+ throw new McpError(ErrorCode.InvalidRequest, `Unknown resource: ${resource}`);
147
+ }
148
+ return {
149
+ contents: [
150
+ {
151
+ uri,
152
+ mimeType: "application/json",
153
+ text: JSON.stringify(content, null, 2)
154
+ }
155
+ ]
156
+ };
157
+ });
158
+ }
159
+ setupToolHandlers() {
160
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
161
+ return {
162
+ tools: [
163
+ {
164
+ name: "get_printer_status",
165
+ description: "Get the current status of the Bambu Lab printer",
166
+ inputSchema: {
167
+ type: "object",
168
+ properties: {
169
+ host: {
170
+ type: "string",
171
+ description: "Hostname or IP address of the printer (default: value from env)"
172
+ },
173
+ bambu_serial: {
174
+ type: "string",
175
+ description: "Serial number for the Bambu Lab printer (default: value from env)"
176
+ },
177
+ bambu_token: {
178
+ type: "string",
179
+ description: "Access token for the Bambu Lab printer (default: value from env)"
180
+ }
181
+ }
182
+ }
183
+ },
184
+ {
185
+ name: "extend_stl_base",
186
+ description: "Extend the base of an STL file by a specified amount",
187
+ inputSchema: {
188
+ type: "object",
189
+ properties: {
190
+ stl_path: { type: "string", description: "Path to the STL file to modify" },
191
+ extension_height: { type: "number", description: "Height in mm to extend the base by" }
192
+ },
193
+ required: ["stl_path", "extension_height"]
194
+ }
195
+ },
196
+ {
197
+ name: "scale_stl",
198
+ description: "Scale an STL file by specified factors",
199
+ inputSchema: {
200
+ type: "object",
201
+ properties: {
202
+ stl_path: { type: "string", description: "Path to the STL file to scale" },
203
+ scale_x: { type: "number", description: "Scale factor for X axis (default: 1.0)" },
204
+ scale_y: { type: "number", description: "Scale factor for Y axis (default: 1.0)" },
205
+ scale_z: { type: "number", description: "Scale factor for Z axis (default: 1.0)" }
206
+ },
207
+ required: ["stl_path"]
208
+ }
209
+ },
210
+ {
211
+ name: "rotate_stl",
212
+ description: "Rotate an STL file by specified angles (degrees)",
213
+ inputSchema: {
214
+ type: "object",
215
+ properties: {
216
+ stl_path: { type: "string", description: "Path to the STL file to rotate" },
217
+ angle_x: { type: "number", description: "Rotation angle for X axis in degrees (default: 0)" },
218
+ angle_y: { type: "number", description: "Rotation angle for Y axis in degrees (default: 0)" },
219
+ angle_z: { type: "number", description: "Rotation angle for Z axis in degrees (default: 0)" }
220
+ },
221
+ required: ["stl_path"]
222
+ }
223
+ },
224
+ {
225
+ name: "get_stl_info",
226
+ description: "Get detailed information about an STL file (bounding box, face count, dimensions)",
227
+ inputSchema: {
228
+ type: "object",
229
+ properties: {
230
+ stl_path: { type: "string", description: "Path to the STL file to analyze" }
231
+ },
232
+ required: ["stl_path"]
233
+ }
234
+ },
235
+ {
236
+ name: "slice_stl",
237
+ description: "Slice an STL or 3MF file using a slicer to generate printable G-code or sliced 3MF",
238
+ inputSchema: {
239
+ type: "object",
240
+ properties: {
241
+ stl_path: { type: "string", description: "Path to the STL or 3MF file to slice" },
242
+ slicer_type: {
243
+ type: "string",
244
+ description: "Type of slicer to use (bambustudio, prusaslicer, cura, slic3r, orcaslicer) (default: bambustudio)"
245
+ },
246
+ slicer_path: { type: "string", description: "Path to the slicer executable (default: value from env)" },
247
+ slicer_profile: { type: "string", description: "Path to the slicer profile/config file (optional)" }
248
+ },
249
+ required: ["stl_path"]
250
+ }
251
+ },
252
+ {
253
+ name: "list_printer_files",
254
+ description: "List files stored on the Bambu Lab printer",
255
+ inputSchema: {
256
+ type: "object",
257
+ properties: {
258
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
259
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
260
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
261
+ }
262
+ }
263
+ },
264
+ {
265
+ name: "upload_gcode",
266
+ description: "Upload a G-code file to the Bambu Lab printer",
267
+ inputSchema: {
268
+ type: "object",
269
+ properties: {
270
+ filename: { type: "string", description: "Name for the file on the printer" },
271
+ gcode: { type: "string", description: "G-code content to upload" },
272
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
273
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
274
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
275
+ },
276
+ required: ["filename", "gcode"]
277
+ }
278
+ },
279
+ {
280
+ name: "upload_file",
281
+ description: "Upload a local file to the Bambu Lab printer",
282
+ inputSchema: {
283
+ type: "object",
284
+ properties: {
285
+ file_path: { type: "string", description: "Local path to the file to upload" },
286
+ filename: { type: "string", description: "Name for the file on the printer" },
287
+ print: { type: "boolean", description: "Start printing after upload (default: false)" },
288
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
289
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
290
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
291
+ },
292
+ required: ["file_path", "filename"]
293
+ }
294
+ },
295
+ {
296
+ name: "start_print_job",
297
+ description: "Start printing a G-code file already on the Bambu Lab printer",
298
+ inputSchema: {
299
+ type: "object",
300
+ properties: {
301
+ filename: { type: "string", description: "Name of the file to print" },
302
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
303
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
304
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
305
+ },
306
+ required: ["filename"]
307
+ }
308
+ },
309
+ {
310
+ name: "cancel_print",
311
+ description: "Cancel the current print job on the Bambu Lab printer",
312
+ inputSchema: {
313
+ type: "object",
314
+ properties: {
315
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
316
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
317
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
318
+ }
319
+ }
320
+ },
321
+ {
322
+ name: "set_temperature",
323
+ description: "Set the temperature of a printer component (bed, nozzle)",
324
+ inputSchema: {
325
+ type: "object",
326
+ properties: {
327
+ component: { type: "string", description: "Component to heat: bed, nozzle, or extruder" },
328
+ temperature: { type: "number", description: "Target temperature in °C" },
329
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
330
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
331
+ bambu_token: { type: "string", description: "Access token (default: value from env)" }
332
+ },
333
+ required: ["component", "temperature"]
334
+ }
335
+ },
336
+ {
337
+ name: "print_3mf",
338
+ description: "Print a 3MF file on a Bambu Lab printer. Auto-slices if the 3MF has no gcode.",
339
+ inputSchema: {
340
+ type: "object",
341
+ properties: {
342
+ three_mf_path: { type: "string", description: "Path to the 3MF file to print" },
343
+ host: { type: "string", description: "Hostname or IP of the printer (default: value from env)" },
344
+ bambu_serial: { type: "string", description: "Serial number (default: value from env)" },
345
+ bambu_token: { type: "string", description: "Access token (default: value from env)" },
346
+ layer_height: { type: "number", description: "Override layer height (mm)" },
347
+ nozzle_temperature: { type: "number", description: "Override nozzle temperature (°C)" },
348
+ bed_temperature: { type: "number", description: "Override bed temperature (°C)" },
349
+ support_enabled: { type: "boolean", description: "Override support generation" },
350
+ ams_mapping: {
351
+ type: "object",
352
+ description: "Override AMS filament mapping (e.g., {\"Generic PLA\": 0})",
353
+ additionalProperties: { type: "number" }
354
+ }
355
+ },
356
+ required: ["three_mf_path"]
357
+ }
358
+ },
359
+ {
360
+ name: "merge_vertices",
361
+ description: "Merge vertices in an STL file closer than the specified tolerance",
362
+ inputSchema: {
363
+ type: "object",
364
+ properties: {
365
+ stl_path: { type: "string", description: "Path to the STL file" },
366
+ tolerance: { type: "number", description: "Max distance to merge (mm, default: 0.01)" }
367
+ },
368
+ required: ["stl_path"]
369
+ }
370
+ },
371
+ {
372
+ name: "center_model",
373
+ description: "Translate the model so its geometric center is at the origin (0,0,0)",
374
+ inputSchema: {
375
+ type: "object",
376
+ properties: {
377
+ stl_path: { type: "string", description: "Path to the STL file" }
378
+ },
379
+ required: ["stl_path"]
380
+ }
381
+ },
382
+ {
383
+ name: "lay_flat",
384
+ description: "Rotate the model so its largest flat face lies on the XY plane (Z=0)",
385
+ inputSchema: {
386
+ type: "object",
387
+ properties: {
388
+ stl_path: { type: "string", description: "Path to the STL file" }
389
+ },
390
+ required: ["stl_path"]
391
+ }
392
+ },
393
+ {
394
+ name: "blender_mcp_edit_model",
395
+ description: "Send STL-edit instructions to a Blender MCP bridge command for advanced model edits",
396
+ inputSchema: {
397
+ type: "object",
398
+ properties: {
399
+ stl_path: { type: "string", description: "Path to the local STL file" },
400
+ operations: {
401
+ type: "array",
402
+ description: "Ordered edit operations for Blender (e.g. remesh, boolean, decimate)",
403
+ items: { type: "string" }
404
+ },
405
+ bridge_command: { type: "string", description: "Override command for invoking Blender MCP bridge" },
406
+ execute: { type: "boolean", description: "Execute bridge command (true) or return payload only (false)" }
407
+ },
408
+ required: ["stl_path", "operations"]
409
+ }
410
+ }
411
+ ]
412
+ };
413
+ });
414
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
415
+ const { name, arguments: args } = request.params;
416
+ const host = String(args?.host || DEFAULT_HOST);
417
+ const bambuSerial = String(args?.bambu_serial || DEFAULT_BAMBU_SERIAL);
418
+ const bambuToken = String(args?.bambu_token || DEFAULT_BAMBU_TOKEN);
419
+ const slicerType = String(args?.slicer_type || DEFAULT_SLICER_TYPE);
420
+ const slicerPath = String(args?.slicer_path || DEFAULT_SLICER_PATH);
421
+ const slicerProfile = String(args?.slicer_profile || DEFAULT_SLICER_PROFILE);
422
+ try {
423
+ let result;
424
+ switch (name) {
425
+ case "get_printer_status":
426
+ result = await this.bambu.getStatus(host, bambuSerial, bambuToken);
427
+ break;
428
+ case "list_printer_files":
429
+ result = await this.bambu.getFiles(host, bambuSerial, bambuToken);
430
+ break;
431
+ case "upload_gcode": {
432
+ if (!args?.filename || !args?.gcode) {
433
+ throw new Error("Missing required parameters: filename and gcode");
434
+ }
435
+ const tmpPath = path.join(TEMP_DIR, String(args.filename));
436
+ fs.writeFileSync(tmpPath, String(args.gcode));
437
+ result = await this.bambu.uploadFile(host, bambuSerial, bambuToken, tmpPath, String(args.filename), false);
438
+ break;
439
+ }
440
+ case "upload_file":
441
+ if (!args?.file_path || !args?.filename) {
442
+ throw new Error("Missing required parameters: file_path and filename");
443
+ }
444
+ result = await this.bambu.uploadFile(host, bambuSerial, bambuToken, String(args.file_path), String(args.filename), Boolean(args.print ?? false));
445
+ break;
446
+ case "start_print_job":
447
+ if (!args?.filename) {
448
+ throw new Error("Missing required parameter: filename");
449
+ }
450
+ result = await this.bambu.startJob(host, bambuSerial, bambuToken, String(args.filename));
451
+ break;
452
+ case "cancel_print":
453
+ result = await this.bambu.cancelJob(host, bambuSerial, bambuToken);
454
+ break;
455
+ case "set_temperature":
456
+ if (!args?.component || args?.temperature === undefined) {
457
+ throw new Error("Missing required parameters: component and temperature");
458
+ }
459
+ result = await this.bambu.setTemperature(host, bambuSerial, bambuToken, String(args.component), Number(args.temperature));
460
+ break;
461
+ case "extend_stl_base":
462
+ if (!args?.stl_path || args?.extension_height === undefined) {
463
+ throw new Error("Missing required parameters: stl_path and extension_height");
464
+ }
465
+ result = await this.stlManipulator.extendBase(String(args.stl_path), Number(args.extension_height));
466
+ break;
467
+ case "scale_stl":
468
+ if (!args?.stl_path) {
469
+ throw new Error("Missing required parameter: stl_path");
470
+ }
471
+ result = await this.stlManipulator.scaleSTL(String(args.stl_path), [
472
+ args.scale_x !== undefined ? Number(args.scale_x) : 1.0,
473
+ args.scale_y !== undefined ? Number(args.scale_y) : 1.0,
474
+ args.scale_z !== undefined ? Number(args.scale_z) : 1.0,
475
+ ]);
476
+ break;
477
+ case "rotate_stl":
478
+ if (!args?.stl_path) {
479
+ throw new Error("Missing required parameter: stl_path");
480
+ }
481
+ result = await this.stlManipulator.rotateSTL(String(args.stl_path), [
482
+ args.angle_x !== undefined ? Number(args.angle_x) : 0,
483
+ args.angle_y !== undefined ? Number(args.angle_y) : 0,
484
+ args.angle_z !== undefined ? Number(args.angle_z) : 0,
485
+ ]);
486
+ break;
487
+ case "get_stl_info":
488
+ if (!args?.stl_path) {
489
+ throw new Error("Missing required parameter: stl_path");
490
+ }
491
+ result = await this.stlManipulator.getSTLInfo(String(args.stl_path));
492
+ break;
493
+ case "slice_stl":
494
+ if (!args?.stl_path) {
495
+ throw new Error("Missing required parameter: stl_path");
496
+ }
497
+ result = await this.stlManipulator.sliceSTL(String(args.stl_path), slicerType, slicerPath, slicerProfile || undefined);
498
+ break;
499
+ case "print_3mf": {
500
+ if (!args?.three_mf_path) {
501
+ throw new Error("Missing required parameter: three_mf_path");
502
+ }
503
+ if (!bambuSerial || !bambuToken) {
504
+ throw new Error("Bambu serial number and access token are required for print_3mf.");
505
+ }
506
+ let threeMFPath = String(args.three_mf_path);
507
+ // Auto-slice if 3MF has no gcode
508
+ try {
509
+ const JSZip = (await import('jszip')).default;
510
+ const zipData = fs.readFileSync(threeMFPath);
511
+ const zip = await JSZip.loadAsync(zipData);
512
+ const hasGcode = Object.keys(zip.files).some(f => f.match(/Metadata\/plate_\d+\.gcode/i) || f.endsWith('.gcode'));
513
+ if (!hasGcode) {
514
+ console.log("3MF has no gcode — auto-slicing with " + slicerType);
515
+ threeMFPath = await this.stlManipulator.sliceSTL(threeMFPath, slicerType, slicerPath, slicerProfile || undefined);
516
+ console.log("Auto-sliced to: " + threeMFPath);
517
+ }
518
+ }
519
+ catch (sliceCheckErr) {
520
+ console.warn("Could not check/slice 3MF, proceeding with original:", sliceCheckErr.message);
521
+ }
522
+ const parsed3MFData = await parse3MF(threeMFPath);
523
+ let parsedAmsMapping;
524
+ if (parsed3MFData.slicerConfig?.ams_mapping) {
525
+ const slots = Object.values(parsed3MFData.slicerConfig.ams_mapping)
526
+ .filter(v => typeof v === 'number');
527
+ if (slots.length > 0) {
528
+ parsedAmsMapping = slots.sort((a, b) => a - b);
529
+ }
530
+ }
531
+ let finalAmsMapping = parsedAmsMapping;
532
+ let useAMS = args?.use_ams !== undefined ? Boolean(args.use_ams) : (!!finalAmsMapping && finalAmsMapping.length > 0);
533
+ if (args?.ams_mapping) {
534
+ let userMappingOverride;
535
+ if (Array.isArray(args.ams_mapping)) {
536
+ userMappingOverride = args.ams_mapping.filter((v) => typeof v === 'number');
537
+ }
538
+ else if (typeof args.ams_mapping === 'object') {
539
+ userMappingOverride = Object.values(args.ams_mapping)
540
+ .filter((v) => typeof v === 'number')
541
+ .sort((a, b) => a - b);
542
+ }
543
+ if (userMappingOverride && userMappingOverride.length > 0) {
544
+ finalAmsMapping = userMappingOverride;
545
+ useAMS = true;
546
+ }
547
+ }
548
+ if (args?.use_ams === false) {
549
+ finalAmsMapping = undefined;
550
+ useAMS = false;
551
+ }
552
+ if (!finalAmsMapping || finalAmsMapping.length === 0) {
553
+ useAMS = false;
554
+ }
555
+ const threeMfFilename = path.basename(threeMFPath);
556
+ const projectName = threeMfFilename.replace(/\.3mf$/i, '');
557
+ result = await this.bambu.print3mf(host, bambuSerial, bambuToken, {
558
+ projectName,
559
+ filePath: threeMFPath,
560
+ plateIndex: 0,
561
+ useAMS: useAMS,
562
+ amsMapping: finalAmsMapping,
563
+ bedLeveling: args?.bed_leveling !== undefined ? Boolean(args.bed_leveling) : undefined,
564
+ flowCalibration: args?.flow_calibration !== undefined ? Boolean(args.flow_calibration) : undefined,
565
+ vibrationCalibration: args?.vibration_calibration !== undefined ? Boolean(args.vibration_calibration) : undefined,
566
+ layerInspect: args?.layer_inspect !== undefined ? Boolean(args.layer_inspect) : undefined,
567
+ timelapse: args?.timelapse !== undefined ? Boolean(args.timelapse) : undefined,
568
+ });
569
+ result = `Print command for ${threeMfFilename} sent successfully.`;
570
+ break;
571
+ }
572
+ case "merge_vertices":
573
+ if (!args?.stl_path)
574
+ throw new Error("Missing required parameter: stl_path");
575
+ result = await this.stlManipulator.mergeVertices(String(args.stl_path), args.tolerance !== undefined ? Number(args.tolerance) : undefined);
576
+ break;
577
+ case "center_model":
578
+ if (!args?.stl_path)
579
+ throw new Error("Missing required parameter: stl_path");
580
+ result = await this.stlManipulator.centerModel(String(args.stl_path));
581
+ break;
582
+ case "lay_flat":
583
+ if (!args?.stl_path)
584
+ throw new Error("Missing required parameter: stl_path");
585
+ result = await this.stlManipulator.layFlat(String(args.stl_path));
586
+ break;
587
+ case "blender_mcp_edit_model":
588
+ if (!args?.stl_path || !Array.isArray(args.operations)) {
589
+ throw new Error("Missing required parameters: stl_path and operations");
590
+ }
591
+ result = await this.invokeBlenderBridge({
592
+ stlPath: String(args.stl_path),
593
+ operations: args.operations.map((entry) => String(entry)),
594
+ execute: Boolean(args.execute ?? false),
595
+ bridgeCommand: args.bridge_command
596
+ ? String(args.bridge_command)
597
+ : this.runtimeConfig.blenderBridgeCommand,
598
+ });
599
+ break;
600
+ default:
601
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
602
+ }
603
+ const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
604
+ if (this.runtimeConfig.enableJsonResponse && typeof result === "object") {
605
+ return {
606
+ content: [{ type: "text", text }],
607
+ structuredContent: result,
608
+ };
609
+ }
610
+ return { content: [{ type: "text", text }] };
611
+ }
612
+ catch (error) {
613
+ const message = error instanceof Error ? error.message : String(error);
614
+ const structured = {
615
+ status: "error",
616
+ retryable: false,
617
+ suggestion: `Check parameters and try again. Error: ${message}`,
618
+ message,
619
+ tool: name,
620
+ };
621
+ return {
622
+ content: [{ type: "text", text: `Error: ${message}` }],
623
+ structuredContent: structured,
624
+ isError: true,
625
+ };
626
+ }
627
+ });
628
+ }
629
+ async invokeBlenderBridge(params) {
630
+ const payload = {
631
+ stlPath: params.stlPath,
632
+ operations: params.operations,
633
+ };
634
+ if (!params.execute || !params.bridgeCommand) {
635
+ return {
636
+ status: "prepared",
637
+ payload,
638
+ note: params.bridgeCommand
639
+ ? "Set execute=true to run the Blender bridge command."
640
+ : "No BLENDER_MCP_BRIDGE_COMMAND configured. Set the env var or pass bridge_command.",
641
+ };
642
+ }
643
+ const { execFile } = await import("node:child_process");
644
+ const { promisify } = await import("node:util");
645
+ const execFileAsync = promisify(execFile);
646
+ const { stdout, stderr } = await execFileAsync(params.bridgeCommand, [], {
647
+ env: { ...process.env, MCP_BLENDER_PAYLOAD: JSON.stringify(payload) },
648
+ timeout: 120000,
649
+ });
650
+ return {
651
+ status: "executed",
652
+ stdout: stdout.trim(),
653
+ stderr: stderr.trim(),
654
+ };
655
+ }
656
+ async startStdio() {
657
+ const transport = new StdioServerTransport();
658
+ await this.server.connect(transport);
659
+ console.error("Bambu Printer MCP server running on stdio");
660
+ }
661
+ async startHttp() {
662
+ const { httpHost, httpPort, httpPath, statefulSession, enableJsonResponse, allowedOrigins } = this.runtimeConfig;
663
+ const transport = new StreamableHTTPServerTransport({
664
+ sessionIdGenerator: statefulSession ? () => randomUUID() : undefined,
665
+ enableJsonResponse,
666
+ });
667
+ await this.server.connect(transport);
668
+ const httpServer = createHttpServer(async (req, res) => {
669
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
670
+ if (url.pathname !== httpPath) {
671
+ res.writeHead(404);
672
+ res.end("Not found");
673
+ return;
674
+ }
675
+ if (allowedOrigins.size > 0) {
676
+ const origin = req.headers.origin ?? "";
677
+ if (origin && !allowedOrigins.has(origin)) {
678
+ res.writeHead(403);
679
+ res.end("Forbidden");
680
+ return;
681
+ }
682
+ }
683
+ await transport.handleRequest(req, res);
684
+ });
685
+ httpServer.listen(httpPort, httpHost, () => {
686
+ console.error(`Bambu Printer MCP server running on http://${httpHost}:${httpPort}${httpPath}`);
687
+ });
688
+ this.httpRuntime = { transport, httpServer };
689
+ }
690
+ async run() {
691
+ if (this.runtimeConfig.transport === "streamable-http") {
692
+ await this.startHttp();
693
+ }
694
+ else {
695
+ await this.startStdio();
696
+ }
697
+ }
698
+ }
699
+ const server = new BambuPrinterMCPServer();
700
+ server.run().catch(console.error);