mcp-3d-printer-server 1.2.2 → 1.2.5

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.
@@ -11,13 +11,49 @@ type RequestCandidate = {
11
11
  export class PrusaImplementation extends PrinterImplementation {
12
12
  private buildAuthHeaders(apiKey: string): Record<string, string> {
13
13
  return {
14
+ Accept: "application/json",
14
15
  "X-Api-Key": apiKey,
15
16
  Authorization: `Bearer ${apiKey}`,
16
17
  };
17
18
  }
18
19
 
19
20
  private buildBaseUrl(host: string, port: string): string {
20
- return `http://${host}:${port}`;
21
+ const trimmedHost = host.trim();
22
+ const trimmedPort = port.trim();
23
+ if (!trimmedHost) {
24
+ throw new Error("Prusa host is required.");
25
+ }
26
+
27
+ const rawBaseUrl = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmedHost)
28
+ ? trimmedHost
29
+ : `${this.defaultProtocolFor(trimmedHost, trimmedPort)}://${trimmedHost}`;
30
+ const parsed = new URL(rawBaseUrl);
31
+
32
+ if (trimmedPort && !parsed.port && !this.isDefaultPort(parsed.protocol, trimmedPort)) {
33
+ parsed.port = trimmedPort;
34
+ }
35
+
36
+ return parsed.toString().replace(/\/$/, "");
37
+ }
38
+
39
+ private defaultProtocolFor(host: string, port: string): "http" | "https" {
40
+ if (port === "443" || this.isPrusaConnectCloudHost(host)) {
41
+ return "https";
42
+ }
43
+ return "http";
44
+ }
45
+
46
+ private isDefaultPort(protocol: string, port: string): boolean {
47
+ return (protocol === "https:" && port === "443") || (protocol === "http:" && port === "80");
48
+ }
49
+
50
+ private isPrusaConnectCloudHost(host: string): boolean {
51
+ try {
52
+ const parsed = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(host) ? host : `https://${host}`);
53
+ return parsed.hostname.toLowerCase() === "connect.prusa3d.com";
54
+ } catch {
55
+ return host.toLowerCase().split(":")[0] === "connect.prusa3d.com";
56
+ }
21
57
  }
22
58
 
23
59
  private isFallbackStatus(error: unknown): boolean {
@@ -86,6 +122,7 @@ export class PrusaImplementation extends PrinterImplementation {
86
122
 
87
123
  async getStatus(host: string, port: string, apiKey: string) {
88
124
  const response = await this.getWithFallback(host, port, apiKey, [
125
+ "/api/v1/status",
89
126
  "/api/v1/printer",
90
127
  "/api/printer",
91
128
  ]);
package/src/sse-server.ts CHANGED
@@ -1,409 +1,9 @@
1
1
  #!/usr/bin/env node
2
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";
3
+ process.env.MCP_TRANSPORT ??= "streamable-http";
26
4
 
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);
5
+ if (!process.env.MCP_HTTP_PORT && process.env.SSE_PORT) {
6
+ process.env.MCP_HTTP_PORT = process.env.SSE_PORT;
407
7
  }
408
8
 
409
- export default ThreeDPrinterMCPServerSSE;
9
+ void import("./index.js");