mcp-3d-printer-server 1.2.20 → 1.2.21

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +112 -9
  3. package/dist/blender-mcp-bridge.d.ts +21 -0
  4. package/dist/blender-mcp-bridge.js +3 -3
  5. package/dist/counterpart/contract.d.ts +395 -0
  6. package/dist/counterpart/contract.js +220 -0
  7. package/dist/counterpart/progress.d.ts +32 -0
  8. package/dist/counterpart/progress.js +443 -0
  9. package/dist/counterpart/service.d.ts +56 -0
  10. package/dist/counterpart/service.js +224 -0
  11. package/dist/fusion-mcp-bridge.d.ts +166 -0
  12. package/dist/fusion-mcp-bridge.js +529 -0
  13. package/dist/index.js +272 -8
  14. package/dist/printers/creality.d.ts +26 -1
  15. package/dist/printers/creality.js +75 -2
  16. package/dist/printers/duet.d.ts +16 -0
  17. package/dist/printers/duet.js +64 -0
  18. package/dist/printers/generic-base.d.ts +52 -6
  19. package/dist/printers/generic-base.js +134 -7
  20. package/dist/printers/klipper.d.ts +33 -2
  21. package/dist/printers/klipper.js +106 -0
  22. package/dist/printers/octoprint.d.ts +23 -1
  23. package/dist/printers/octoprint.js +81 -0
  24. package/dist/printers/prusa.d.ts +13 -0
  25. package/dist/printers/prusa.js +59 -0
  26. package/dist/printers/repetier.d.ts +17 -1
  27. package/dist/printers/repetier.js +55 -0
  28. package/dist/slicer/bambu-cli.d.ts +2 -0
  29. package/dist/slicer/bambu-cli.js +11 -0
  30. package/dist/slicer/filament-profiles.d.ts +25 -0
  31. package/dist/slicer/filament-profiles.js +249 -0
  32. package/dist/stl/stl-manipulator.d.ts +21 -1
  33. package/dist/stl/stl-manipulator.js +86 -6
  34. package/package.json +7 -2
  35. package/server.json +2 -2
  36. package/src/blender-mcp-bridge.ts +3 -3
  37. package/src/counterpart/contract.ts +326 -0
  38. package/src/counterpart/progress.ts +470 -0
  39. package/src/counterpart/service.ts +247 -0
  40. package/src/fusion-mcp-bridge.ts +512 -0
  41. package/src/index.ts +314 -10
  42. package/src/printers/creality.ts +84 -1
  43. package/src/printers/duet.ts +67 -0
  44. package/src/printers/generic-base.ts +172 -1
  45. package/src/printers/klipper.ts +118 -2
  46. package/src/printers/octoprint.ts +86 -1
  47. package/src/printers/prusa.ts +60 -0
  48. package/src/printers/repetier.ts +63 -1
  49. package/src/slicer/bambu-cli.ts +13 -0
  50. package/src/slicer/filament-profiles.ts +249 -0
  51. package/src/stl/stl-manipulator.ts +100 -6
@@ -0,0 +1,247 @@
1
+ /**
2
+ * counterpart/1 tools for every printer type this server drives. Generic
3
+ * adapters supply firmware reads; this module turns them into the shared
4
+ * results, detects capabilities, and fetches camera snapshots.
5
+ */
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { isAxiosError, type AxiosInstance } from "axios";
9
+ import type { PrinterImplementation } from "../types.js";
10
+ import { GenericPrinterImplementation, type SnapshotCandidate } from "../printers/generic-base.js";
11
+ import { BambuImplementation } from "../printers/bambu.js";
12
+ import {
13
+ COUNTERPART_CONTRACT,
14
+ PRINTER_KINDS,
15
+ compact,
16
+ progressResult,
17
+ type CameraSnapshotResult,
18
+ type CounterpartCapabilities,
19
+ type CounterpartPrinterInfo,
20
+ type CounterpartProgress,
21
+ type FilamentSlot,
22
+ type PrinterIdentity,
23
+ type PrinterKind,
24
+ } from "./contract.js";
25
+ import { mapBambu } from "./progress.js";
26
+
27
+ export const DEFAULT_SNAPSHOT_TIMEOUT_MS = 8_000;
28
+ const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024;
29
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]);
30
+
31
+ export interface PrinterTarget {
32
+ type: string;
33
+ host: string;
34
+ port: string;
35
+ /** API key for generic printers, "serial:token" for Bambu. */
36
+ apiKey: string;
37
+ }
38
+
39
+ export interface SnapshotOptions {
40
+ savePath?: string;
41
+ timeoutMs?: number;
42
+ }
43
+
44
+ /** No HTTP response at all (refused, timed out, unreachable): the printer is offline, not erroring. */
45
+ function unreachable(error: unknown): boolean {
46
+ return isAxiosError(error) && !error.response;
47
+ }
48
+
49
+ function kindOf(type: string): PrinterKind {
50
+ const kind = type.trim().toLowerCase();
51
+ if (!(PRINTER_KINDS as readonly string[]).includes(kind)) throw new Error(`Unsupported printer type: ${type}`);
52
+ return kind as PrinterKind;
53
+ }
54
+
55
+ function overrideSnapshotUrl(): string | undefined {
56
+ return process.env.PRINTER_CAMERA_SNAPSHOT_URL?.trim() || undefined;
57
+ }
58
+
59
+ export class CounterpartService {
60
+ constructor(
61
+ private readonly implementation: (type: string) => PrinterImplementation,
62
+ private readonly http: AxiosInstance,
63
+ private readonly server: { name: string; version: string },
64
+ /** The slicer filament profile for a slot's material (undefined: the configured default). */
65
+ private readonly filamentProfile?: (material: string | undefined) => Promise<string | undefined>
66
+ ) {}
67
+
68
+ private generic(target: PrinterTarget): GenericPrinterImplementation | undefined {
69
+ const implementation = this.implementation(target.type);
70
+ return implementation instanceof GenericPrinterImplementation ? implementation : undefined;
71
+ }
72
+
73
+ private bambu(target: PrinterTarget): BambuImplementation {
74
+ const implementation = this.implementation(target.type);
75
+ if (!(implementation instanceof BambuImplementation)) throw new Error(`Unsupported printer type: ${target.type}`);
76
+ return implementation;
77
+ }
78
+
79
+ async printProgress(target: PrinterTarget): Promise<CounterpartProgress> {
80
+ const generic = this.generic(target);
81
+ if (!generic) {
82
+ const status = await this.bambu(target).getStatus(target.host, target.port, target.apiKey);
83
+ return mapBambu(status?.raw, new Date().toISOString(), { connected: status?.connected === true, model: process.env.BAMBU_MODEL });
84
+ }
85
+ try {
86
+ return await generic.observedProgress(target.host, target.port, target.apiKey);
87
+ } catch (error) {
88
+ if (unreachable(error)) return progressResult(new Date().toISOString(), "offline", { problems: [] });
89
+ throw error;
90
+ }
91
+ }
92
+
93
+ async printerInfo(target: PrinterTarget): Promise<CounterpartPrinterInfo> {
94
+ const kind = kindOf(target.type);
95
+ const generic = this.generic(target);
96
+ let identity: PrinterIdentity = {};
97
+ let capabilities: CounterpartCapabilities;
98
+ if (!generic) {
99
+ const status = await this.bambu(target).getStatus(target.host, target.port, target.apiKey).catch(() => undefined);
100
+ const model = typeof status?.model === "string" && status.model !== "Unknown" ? status.model : process.env.BAMBU_MODEL?.trim();
101
+ identity = { model: model || undefined, serial: typeof status?.serial === "string" && status.serial ? status.serial : undefined };
102
+ // This server has no Bambu camera, pause or resume path; bambu-printer-mcp does.
103
+ capabilities = {
104
+ progress: true, layers: true, camera: overrideSnapshotUrl() ? "http-snapshot" : "none", filaments: "none",
105
+ pause: false, resume: false, cancel: true, print3mf: true, gcode: true,
106
+ };
107
+ } else {
108
+ const { host, port, apiKey } = target;
109
+ identity = await generic.counterpartIdentity(host, port, apiKey).catch(() => ({}));
110
+ const [layers, pauseResume, camera] = await Promise.all([
111
+ generic.reportsLayers(host, port, apiKey).catch(() => false),
112
+ generic.supportsPauseResume(host, port, apiKey).catch(() => false),
113
+ this.cameraCapability(generic, target),
114
+ ]);
115
+ capabilities = {
116
+ progress: true, layers, camera, filaments: "single",
117
+ pause: pauseResume, resume: pauseResume, cancel: true, print3mf: false, gcode: true,
118
+ };
119
+ }
120
+ return compact({
121
+ contract: COUNTERPART_CONTRACT,
122
+ server: this.server,
123
+ printer: { kind, host: target.host, ...identity },
124
+ capabilities,
125
+ }) as CounterpartPrinterInfo;
126
+ }
127
+
128
+ /** http-snapshot when the firmware names a snapshot URL, or a conventional fallback answers with a JPEG. */
129
+ private async cameraCapability(generic: GenericPrinterImplementation, target: PrinterTarget): Promise<"http-snapshot" | "none"> {
130
+ if (overrideSnapshotUrl()) return "http-snapshot";
131
+ const candidates = await generic.snapshotCandidates(target.host, target.port, target.apiKey).catch(() => []);
132
+ if (candidates.some((candidate) => candidate.configured)) return "http-snapshot";
133
+ for (const candidate of candidates) {
134
+ try {
135
+ await this.fetchJpeg(this.resolve(candidate.url, target)[0], target, 3_000);
136
+ return "http-snapshot";
137
+ } catch {
138
+ // Not a camera.
139
+ }
140
+ }
141
+ return "none";
142
+ }
143
+
144
+ async filaments(target: PrinterTarget): Promise<{ slots: FilamentSlot[] }> {
145
+ const generic = this.generic(target);
146
+ if (!generic) {
147
+ throw new Error("Unsupported: Bambu AMS and external-spool inventory is read by bambu-printer-mcp's get_printer_filaments.");
148
+ }
149
+ const slots = await generic.counterpartFilaments(target.host, target.port, target.apiKey);
150
+ const profiled = await Promise.all(slots.map(async (slot) => {
151
+ const profile = await this.filamentProfile?.(slot.material).catch(() => undefined);
152
+ return profile ? { ...slot, profile } : slot;
153
+ }));
154
+ return compact({ slots: profiled });
155
+ }
156
+
157
+ async pause(target: PrinterTarget) {
158
+ const generic = this.generic(target);
159
+ if (!generic) throw new Error("Unsupported: pause_print for Bambu printers is provided by bambu-printer-mcp.");
160
+ return generic.pauseJob(target.host, target.port, target.apiKey);
161
+ }
162
+
163
+ async resume(target: PrinterTarget) {
164
+ const generic = this.generic(target);
165
+ if (!generic) throw new Error("Unsupported: resume_print for Bambu printers is provided by bambu-printer-mcp.");
166
+ return generic.resumeJob(target.host, target.port, target.apiKey);
167
+ }
168
+
169
+ async cameraSnapshot(target: PrinterTarget, options: SnapshotOptions = {}): Promise<CameraSnapshotResult> {
170
+ const timeoutMs = options.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS;
171
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 100 || timeoutMs > 120_000) throw new Error("Invalid timeout_ms: expected 100 to 120000.");
172
+ if (options.savePath !== undefined && (!path.isAbsolute(options.savePath) || options.savePath.includes("\0"))) {
173
+ throw new Error("Invalid save_path: expected an absolute file path.");
174
+ }
175
+ const deadline = Date.now() + timeoutMs;
176
+ const override = overrideSnapshotUrl();
177
+ let candidates: SnapshotCandidate[];
178
+ if (override) {
179
+ candidates = [{ url: override, source: "env-override", configured: true }];
180
+ } else {
181
+ const generic = this.generic(target);
182
+ if (!generic) throw new Error("Unsupported: this server has no Bambu camera path. Use bambu-printer-mcp's camera_snapshot, or set PRINTER_CAMERA_SNAPSHOT_URL.");
183
+ candidates = await generic.snapshotCandidates(target.host, target.port, target.apiKey);
184
+ if (!candidates.length) {
185
+ throw new Error(`Unsupported: ${kindOf(target.type)} exposes no camera snapshot URL. Set PRINTER_CAMERA_SNAPSHOT_URL to a JPEG snapshot URL for this printer.`);
186
+ }
187
+ }
188
+ const urls = [...new Set(candidates.flatMap((candidate) => this.resolve(candidate.url, target).map((url) => `${candidate.source}\n${url}`)))];
189
+ const failures: string[] = [];
190
+ for (const entry of urls) {
191
+ const [source, url] = entry.split("\n");
192
+ const remaining = deadline - Date.now();
193
+ if (remaining <= 0) break;
194
+ try {
195
+ const jpeg = await this.fetchJpeg(url, target, remaining);
196
+ const capturedAt = new Date().toISOString();
197
+ let savedTo: string | undefined;
198
+ if (options.savePath) {
199
+ await fs.writeFile(options.savePath, jpeg);
200
+ savedTo = options.savePath;
201
+ }
202
+ return compact({ status: "success", format: "image/jpeg", sizeBytes: jpeg.length, base64: jpeg.toString("base64"), savedTo, capturedAt, source }) as CameraSnapshotResult;
203
+ } catch (error) {
204
+ failures.push(`${new URL(url).origin}${new URL(url).pathname}: ${(error as Error).message}`);
205
+ }
206
+ }
207
+ throw new Error(`No camera snapshot within ${timeoutMs} ms. Tried ${failures.length ? failures.join("; ") : "no URL before the deadline"}.`);
208
+ }
209
+
210
+ /**
211
+ * Snapshot URLs are written from the printer host's point of view: relative
212
+ * paths and loopback hosts mean the printer itself.
213
+ */
214
+ resolve(raw: string, target: PrinterTarget): string[] {
215
+ const host = target.host.includes(":") && !target.host.startsWith("[") ? `[${target.host}]` : target.host;
216
+ let parsed: URL;
217
+ try {
218
+ parsed = new URL(raw);
219
+ } catch {
220
+ // Relative to the printer host: the web front end on port 80, then the API port.
221
+ const pathPart = raw.startsWith("/") ? raw : `/${raw}`;
222
+ return [...new Set([`http://${host}${pathPart}`, `http://${host}:${target.port}${pathPart}`])];
223
+ }
224
+ if (!["http:", "https:"].includes(parsed.protocol)) throw new Error(`Unsupported snapshot URL scheme: ${parsed.protocol}`);
225
+ if (LOOPBACK.has(parsed.hostname) && !LOOPBACK.has(target.host)) parsed.hostname = target.host;
226
+ return [parsed.toString()];
227
+ }
228
+
229
+ private async fetchJpeg(url: string, target: PrinterTarget, timeout: number): Promise<Buffer> {
230
+ const parsed = new URL(url);
231
+ // The API key goes only to the printer's own API origin, never to another host.
232
+ const sameOrigin = parsed.origin === new URL(`http://${target.host}:${target.port}`).origin;
233
+ const headers: Record<string, string> = sameOrigin && target.apiKey && target.type.toLowerCase() !== "bambu" ? { "X-Api-Key": target.apiKey } : {};
234
+ const response = await this.http.get(url, {
235
+ headers,
236
+ responseType: "arraybuffer",
237
+ timeout: Math.max(1, Math.round(timeout)),
238
+ maxContentLength: MAX_SNAPSHOT_BYTES,
239
+ maxRedirects: Object.keys(headers).length ? 0 : 3,
240
+ });
241
+ const bytes = Buffer.from(response.data as ArrayBuffer);
242
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes[2] !== 0xff) {
243
+ throw new Error(`not a JPEG (${String(response.headers?.["content-type"] ?? "unknown type")}, ${bytes.length} bytes)`);
244
+ }
245
+ return bytes;
246
+ }
247
+ }