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.
@@ -0,0 +1,409 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
5
+ import express, { Request, Response } from 'express';
6
+ import cors from 'cors';
7
+ import { v4 as uuidv4 } from 'uuid';
8
+ import { Readable } from 'stream';
9
+ import {
10
+ ListResourcesRequestSchema,
11
+ ReadResourceRequestSchema,
12
+ ListToolsRequestSchema,
13
+ CallToolRequestSchema,
14
+ ErrorCode,
15
+ McpError
16
+ } from "@modelcontextprotocol/sdk/types.js";
17
+ import axios from "axios";
18
+ import dotenv from "dotenv";
19
+ import fs from "fs";
20
+ import path from "path";
21
+ import * as THREE from 'three';
22
+ import { PrinterFactory } from "./printers/printer-factory.js";
23
+ import { STLManipulator } from "./stl/stl-manipulator.js";
24
+ import { parse3MF, ThreeMFData } from './3mf_parser.js';
25
+ import { BambuImplementation } from "./printers/bambu.js";
26
+
27
+ // Load environment variables from .env file
28
+ dotenv.config();
29
+
30
+ // Default values
31
+ const DEFAULT_HOST = process.env.PRINTER_HOST || "localhost";
32
+ const DEFAULT_PORT = process.env.PRINTER_PORT || "80";
33
+ const DEFAULT_API_KEY = process.env.API_KEY || "";
34
+ const DEFAULT_TYPE = process.env.PRINTER_TYPE || "octoprint";
35
+ const TEMP_DIR = process.env.TEMP_DIR || path.join(process.cwd(), "temp");
36
+
37
+ // SSE Server configuration
38
+ const SSE_PORT = parseInt(process.env.SSE_PORT || "3000");
39
+
40
+ // Slicer configuration
41
+ const DEFAULT_SLICER_TYPE = process.env.SLICER_TYPE || "prusaslicer";
42
+ const DEFAULT_SLICER_PATH = process.env.SLICER_PATH || "";
43
+ const DEFAULT_SLICER_PROFILE = process.env.SLICER_PROFILE || "";
44
+
45
+ // Bambu-specific default values
46
+ const DEFAULT_BAMBU_SERIAL = process.env.BAMBU_SERIAL || "";
47
+ const DEFAULT_BAMBU_TOKEN = process.env.BAMBU_TOKEN || "";
48
+
49
+ // Ensure temp directory exists
50
+ if (!fs.existsSync(TEMP_DIR)) {
51
+ fs.mkdirSync(TEMP_DIR, { recursive: true });
52
+ }
53
+
54
+ class ThreeDPrinterMCPServerSSE {
55
+ private server: Server;
56
+ private printerFactory: PrinterFactory;
57
+ private stlManipulator: STLManipulator;
58
+ private app: express.Application;
59
+ private sessions: Map<string, { transport: SSEServerTransport, lastActivity: number }> = new Map();
60
+
61
+ constructor() {
62
+ this.server = new Server(
63
+ {
64
+ name: "mcp-3d-printer-server",
65
+ version: "1.0.0"
66
+ },
67
+ {
68
+ capabilities: {
69
+ resources: {},
70
+ tools: {}
71
+ }
72
+ }
73
+ );
74
+
75
+ this.printerFactory = new PrinterFactory();
76
+ this.stlManipulator = new STLManipulator(TEMP_DIR);
77
+
78
+ this.app = express();
79
+ this.setupExpressApp();
80
+ this.setupHandlers();
81
+ this.setupErrorHandling();
82
+ this.startSessionCleanup();
83
+ }
84
+
85
+ private setupExpressApp() {
86
+ // Enable CORS for all origins
87
+ this.app.use(cors({
88
+ origin: '*',
89
+ methods: ['GET', 'POST', 'OPTIONS'],
90
+ allowedHeaders: ['Content-Type', 'Accept', 'Last-Event-ID'],
91
+ credentials: true
92
+ }));
93
+
94
+ // Parse JSON bodies
95
+ this.app.use(express.json({ limit: '50mb' }));
96
+
97
+ // SSE endpoint - establishes the event stream
98
+ this.app.get('/sse', async (req: Request, res: Response) => {
99
+ const sessionId = uuidv4();
100
+ const messagesPath = `/messages?session_id=${sessionId}`;
101
+
102
+ // Set SSE headers
103
+ res.setHeader('Content-Type', 'text/event-stream');
104
+ res.setHeader('Cache-Control', 'no-cache');
105
+ res.setHeader('Connection', 'keep-alive');
106
+ res.setHeader('X-Accel-Buffering', 'no');
107
+
108
+ // Send initial endpoint event
109
+ res.write(`event: endpoint\ndata: ${messagesPath}\n\n`);
110
+
111
+ // Create transport for this session
112
+ const transport = new SSEServerTransport(messagesPath);
113
+ this.sessions.set(sessionId, { transport, lastActivity: Date.now() });
114
+
115
+ // Send periodic ping events to keep connection alive
116
+ const pingInterval = setInterval(() => {
117
+ if (!res.writableEnded) {
118
+ res.write(`event: ping\ndata: ${new Date().toISOString()}\n\n`);
119
+ }
120
+ }, 30000);
121
+
122
+ // Handle client disconnect
123
+ req.on('close', () => {
124
+ clearInterval(pingInterval);
125
+ this.sessions.delete(sessionId);
126
+ res.end();
127
+ });
128
+
129
+ // Handle transport errors
130
+ transport.onerror = (error) => {
131
+ console.error(`SSE Transport error for session ${sessionId}:`, error);
132
+ clearInterval(pingInterval);
133
+ this.sessions.delete(sessionId);
134
+ res.end();
135
+ };
136
+ });
137
+
138
+ // Messages endpoint - handles JSON-RPC messages
139
+ this.app.post('/messages', async (req: Request, res: Response) => {
140
+ const sessionId = req.query.session_id as string;
141
+
142
+ if (!sessionId || !this.sessions.has(sessionId)) {
143
+ res.status(400).json({
144
+ jsonrpc: "2.0",
145
+ error: {
146
+ code: -32600,
147
+ message: "Invalid or missing session ID"
148
+ }
149
+ });
150
+ return;
151
+ }
152
+
153
+ const session = this.sessions.get(sessionId)!;
154
+ session.lastActivity = Date.now();
155
+
156
+ try {
157
+ // Handle the message through the transport
158
+ const result = await session.transport.handleMessage(req.body);
159
+
160
+ if (result) {
161
+ res.json(result);
162
+ } else {
163
+ res.status(202).send(); // Accepted, no response needed
164
+ }
165
+ } catch (error) {
166
+ console.error('Error handling message:', error);
167
+ res.status(500).json({
168
+ jsonrpc: "2.0",
169
+ error: {
170
+ code: -32603,
171
+ message: "Internal error",
172
+ data: error instanceof Error ? error.message : String(error)
173
+ }
174
+ });
175
+ }
176
+ });
177
+
178
+ // Health check endpoint
179
+ this.app.get('/health', (req: Request, res: Response) => {
180
+ res.json({
181
+ status: 'ok',
182
+ server: 'mcp-3d-printer-server',
183
+ transport: 'sse',
184
+ sessions: this.sessions.size
185
+ });
186
+ });
187
+
188
+ // Root endpoint
189
+ this.app.get('/', (req: Request, res: Response) => {
190
+ res.json({
191
+ name: 'MCP 3D Printer Server (SSE)',
192
+ version: '1.0.0',
193
+ endpoints: {
194
+ sse: '/sse',
195
+ messages: '/messages',
196
+ health: '/health'
197
+ },
198
+ transport: 'sse',
199
+ documentation: 'https://github.com/DMontgomery40/mcp-3D-printer-server'
200
+ });
201
+ });
202
+ }
203
+
204
+ private startSessionCleanup() {
205
+ // Clean up inactive sessions every 5 minutes
206
+ setInterval(() => {
207
+ const now = Date.now();
208
+ const timeout = 10 * 60 * 1000; // 10 minutes
209
+
210
+ for (const [sessionId, session] of this.sessions.entries()) {
211
+ if (now - session.lastActivity > timeout) {
212
+ console.log(`Cleaning up inactive session: ${sessionId}`);
213
+ this.sessions.delete(sessionId);
214
+ }
215
+ }
216
+ }, 5 * 60 * 1000);
217
+ }
218
+
219
+ setupErrorHandling() {
220
+ this.server.onerror = (error) => {
221
+ console.error("[MCP Error]", error);
222
+ };
223
+
224
+ process.on("SIGINT", async () => {
225
+ console.log("\nShutting down SSE server...");
226
+ // Disconnect all printers
227
+ await this.printerFactory.disconnectAll();
228
+ // Close all sessions
229
+ for (const [sessionId, _] of this.sessions.entries()) {
230
+ this.sessions.delete(sessionId);
231
+ }
232
+ await this.server.close();
233
+ process.exit(0);
234
+ });
235
+ }
236
+
237
+ setupHandlers() {
238
+ this.setupResourceHandlers();
239
+ this.setupToolHandlers();
240
+ }
241
+
242
+ setupResourceHandlers() {
243
+ // List available resources
244
+ this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
245
+ return {
246
+ resources: [
247
+ {
248
+ uri: `printer://${DEFAULT_HOST}/status`,
249
+ name: "3D Printer Status",
250
+ mimeType: "application/json",
251
+ description: "Current status of the 3D printer including temperatures, print progress, and more"
252
+ },
253
+ {
254
+ uri: `printer://${DEFAULT_HOST}/files`,
255
+ name: "3D Printer Files",
256
+ mimeType: "application/json",
257
+ description: "List of files available on the 3D printer"
258
+ }
259
+ ],
260
+ templates: [
261
+ {
262
+ uriTemplate: "printer://{host}/status",
263
+ name: "3D Printer Status",
264
+ mimeType: "application/json"
265
+ },
266
+ {
267
+ uriTemplate: "printer://{host}/files",
268
+ name: "3D Printer Files",
269
+ mimeType: "application/json"
270
+ },
271
+ {
272
+ uriTemplate: "printer://{host}/file/{filename}",
273
+ name: "3D Printer File Content",
274
+ mimeType: "application/gcode"
275
+ }
276
+ ]
277
+ };
278
+ });
279
+
280
+ // Read resource
281
+ this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
282
+ const uri = request.params.uri;
283
+ const match = uri.match(/^printer:\/\/([^\/]+)\/(.+)$/);
284
+
285
+ if (!match) {
286
+ throw new McpError(ErrorCode.InvalidRequest, `Invalid resource URI: ${uri}`);
287
+ }
288
+
289
+ const [, host, resource] = match;
290
+ let content;
291
+
292
+ try {
293
+ if (resource === "status") {
294
+ content = await this.getPrinterStatus(host);
295
+ } else if (resource === "files") {
296
+ content = await this.getPrinterFiles(host);
297
+ } else if (resource.startsWith("file/")) {
298
+ const filename = resource.substring(5);
299
+ content = await this.getPrinterFile(host, filename);
300
+ } else {
301
+ throw new McpError(ErrorCode.InvalidRequest, `Unknown resource: ${resource}`);
302
+ }
303
+
304
+ return {
305
+ contents: [
306
+ {
307
+ uri,
308
+ mimeType: resource.startsWith("file/") ? "application/gcode" : "application/json",
309
+ text: typeof content === "string" ? content : JSON.stringify(content, null, 2)
310
+ }
311
+ ]
312
+ };
313
+ } catch (error) {
314
+ if (axios.isAxiosError(error)) {
315
+ throw new McpError(
316
+ ErrorCode.InternalError,
317
+ `API error: ${error.response?.data?.error || error.message}`
318
+ );
319
+ }
320
+ throw error;
321
+ }
322
+ });
323
+ }
324
+
325
+ setupToolHandlers() {
326
+ // Tool handlers remain the same as in the stdio version
327
+ // ... (copy all tool handlers from the original index.ts)
328
+ }
329
+
330
+ // Delegating methods to printer implementations (same as original)
331
+
332
+ async getPrinterStatus(
333
+ host: string,
334
+ port = DEFAULT_PORT,
335
+ type = DEFAULT_TYPE,
336
+ apiKey = DEFAULT_API_KEY,
337
+ bambuSerial = DEFAULT_BAMBU_SERIAL,
338
+ bambuToken = DEFAULT_BAMBU_TOKEN
339
+ ) {
340
+ const implementation = this.printerFactory.getImplementation(type);
341
+
342
+ if (type.toLowerCase() === "bambu") {
343
+ const bambuApiKey = `${bambuSerial}:${bambuToken}`;
344
+ return implementation.getStatus(host, port, bambuApiKey);
345
+ }
346
+
347
+ return implementation.getStatus(host, port, apiKey);
348
+ }
349
+
350
+ async getPrinterFiles(
351
+ host: string,
352
+ port = DEFAULT_PORT,
353
+ type = DEFAULT_TYPE,
354
+ apiKey = DEFAULT_API_KEY,
355
+ bambuSerial = DEFAULT_BAMBU_SERIAL,
356
+ bambuToken = DEFAULT_BAMBU_TOKEN
357
+ ) {
358
+ const implementation = this.printerFactory.getImplementation(type);
359
+
360
+ if (type.toLowerCase() === "bambu") {
361
+ const bambuApiKey = `${bambuSerial}:${bambuToken}`;
362
+ return implementation.getFiles(host, port, bambuApiKey);
363
+ }
364
+
365
+ return implementation.getFiles(host, port, apiKey);
366
+ }
367
+
368
+ async getPrinterFile(
369
+ host: string,
370
+ filename: string,
371
+ port = DEFAULT_PORT,
372
+ type = DEFAULT_TYPE,
373
+ apiKey = DEFAULT_API_KEY,
374
+ bambuSerial = DEFAULT_BAMBU_SERIAL,
375
+ bambuToken = DEFAULT_BAMBU_TOKEN
376
+ ) {
377
+ const implementation = this.printerFactory.getImplementation(type);
378
+
379
+ if (type.toLowerCase() === "bambu") {
380
+ return (implementation as any).getFile(host, port, apiKey, bambuSerial, bambuToken, filename);
381
+ }
382
+
383
+ return implementation.getFile(host, port, apiKey, filename);
384
+ }
385
+
386
+ // ... (copy all other delegating methods from original index.ts)
387
+
388
+ async run() {
389
+ // Connect SSE transports to the server
390
+ for (const [sessionId, session] of this.sessions.entries()) {
391
+ await this.server.connect(session.transport);
392
+ }
393
+
394
+ // Start Express server
395
+ this.app.listen(SSE_PORT, () => {
396
+ console.log(`🚀 MCP 3D Printer Server (SSE) running on http://localhost:${SSE_PORT}`);
397
+ console.log(`📡 SSE endpoint: http://localhost:${SSE_PORT}/sse`);
398
+ console.log(`💬 Messages endpoint: http://localhost:${SSE_PORT}/messages`);
399
+ });
400
+ }
401
+ }
402
+
403
+ // Check if running as main module
404
+ if (import.meta.url === `file://${process.argv[1]}`) {
405
+ const server = new ThreeDPrinterMCPServerSSE();
406
+ server.run().catch(console.error);
407
+ }
408
+
409
+ export default ThreeDPrinterMCPServerSSE;
@@ -79,7 +79,11 @@ export class STLManipulator extends EventEmitter {
79
79
 
80
80
  // Load the STL data into a Three.js geometry
81
81
  const loader = new STLLoader();
82
- const geometry = loader.parse(stlData.buffer);
82
+ const rawArrayBuffer = stlData.buffer.slice(
83
+ stlData.byteOffset,
84
+ stlData.byteOffset + stlData.byteLength
85
+ ) as ArrayBuffer;
86
+ const geometry = loader.parse(rawArrayBuffer);
83
87
 
84
88
  // Create a mesh from the geometry
85
89
  const material = new THREE.MeshStandardMaterial();
@@ -839,20 +843,21 @@ export class STLManipulator extends EventEmitter {
839
843
  }
840
844
 
841
845
  /**
842
- * Slice an STL file using the specified slicer
843
- * @param stlFilePath Path to the input STL file
844
- * @param slicerType Type of slicer (prusaslicer, cura, slic3r, orcaslicer)
846
+ * Slice an STL or 3MF file using the specified slicer
847
+ * @param stlFilePath Path to the input STL or 3MF file
848
+ * @param slicerType Type of slicer (prusaslicer, cura, slic3r, orcaslicer, bambustudio)
845
849
  * @param slicerPath Path to the slicer executable
846
850
  * @param slicerProfile Optional path to the slicer profile/config file
847
851
  * @param progressCallback Optional callback for progress updates
848
- * @returns Path to the generated G-code file
852
+ * @returns Path to the generated G-code or sliced 3MF file
849
853
  */
850
854
  async sliceSTL(
851
855
  stlFilePath: string,
852
- slicerType: 'prusaslicer' | 'cura' | 'slic3r' | 'orcaslicer',
856
+ slicerType: 'prusaslicer' | 'cura' | 'slic3r' | 'orcaslicer' | 'bambustudio',
853
857
  slicerPath: string,
854
858
  slicerProfile?: string,
855
- progressCallback?: ProgressCallback
859
+ progressCallback?: ProgressCallback,
860
+ printerPreset?: string
856
861
  ): Promise<string> {
857
862
  const operationId = this.generateOperationId();
858
863
  this.activeOperations.set(operationId, true);
@@ -866,7 +871,7 @@ export class STLManipulator extends EventEmitter {
866
871
  }
867
872
 
868
873
  const outputFileName = path.basename(stlFilePath, '.stl') + '.gcode';
869
- const outputFilePath = path.join(this.tempDir, outputFileName);
874
+ let outputFilePath = path.join(this.tempDir, outputFileName);
870
875
 
871
876
  let args: string[] = [];
872
877
 
@@ -920,6 +925,31 @@ export class STLManipulator extends EventEmitter {
920
925
  }
921
926
  break;
922
927
 
928
+ case 'bambustudio':
929
+ // Bambu Studio CLI: slice and export as 3MF with embedded gcode
930
+ // For 3MF input: slice in place and export sliced 3MF
931
+ // For STL input: slice and export as 3MF
932
+ {
933
+ const is3mf = stlFilePath.toLowerCase().endsWith('.3mf');
934
+ const outputBase = path.basename(stlFilePath, is3mf ? '.3mf' : '.stl');
935
+ const bambuOutputPath = path.join(this.tempDir, outputBase + '_sliced.3mf');
936
+ args = [
937
+ '--slice', '0', // Slice all plates
938
+ '--export-3mf', bambuOutputPath,
939
+ ];
940
+ // --load-machine ensures correct G-code for the specific printer model
941
+ if (printerPreset && !slicerProfile) {
942
+ args.push('--load-machine', printerPreset);
943
+ }
944
+ if (slicerProfile) {
945
+ args.push('--load-settings', slicerProfile);
946
+ }
947
+ args.push(stlFilePath);
948
+ // Override outputFilePath for bambustudio since it produces 3MF, not gcode
949
+ outputFilePath = bambuOutputPath;
950
+ }
951
+ break;
952
+
923
953
  default:
924
954
  throw new Error(`Unsupported slicer type: ${slicerType}`);
925
955
  }
Binary file
package/src/types.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { AxiosInstance } from "axios";
2
- import { BambuPrinter } from "bambu-js";
3
2
 
4
3
  // Define shared types for the printer implementations
5
4
  export type BambuFTP = {
@@ -25,25 +24,7 @@ export abstract class PrinterImplementation {
25
24
  abstract setTemperature(host: string, port: string, apiKey: string, component: string, temperature: number): Promise<any>;
26
25
  }
27
26
 
28
- // Store for Bambu printers
29
- export class BambuPrinterStore {
30
- private printers: Map<string, InstanceType<typeof BambuPrinter>> = new Map();
31
27
 
32
- get(host: string, serial: string, token: string): InstanceType<typeof BambuPrinter> {
33
- const key = `${host}-${serial}`;
34
- if (!this.printers.has(key)) {
35
- const printer = new BambuPrinter(host, serial, token);
36
- this.printers.set(key, printer);
37
- }
38
- return this.printers.get(key)!;
39
- }
40
-
41
- async disconnectAll(): Promise<void> {
42
- for (const printer of this.printers.values()) {
43
- await printer.disconnect();
44
- }
45
- }
46
- }
47
28
 
48
29
  export interface SectionBounds {
49
30
  minX: number; minY: number; minZ: number;