mcp-3d-printer-server 1.2.20 → 1.2.22

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 +27 -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 +232 -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 +253 -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
package/src/index.ts CHANGED
@@ -19,11 +19,28 @@ import fs from "fs";
19
19
  import path from "path";
20
20
  import os from "node:os";
21
21
  import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
22
+ import { resolveFilamentProfile } from "./slicer/filament-profiles.js";
23
+ import {
24
+ FUSION_CALL_OUTPUT_SCHEMA,
25
+ FUSION_EXPORT_OUTPUT_SCHEMA,
26
+ FUSION_MCP_PINNED_COMMIT,
27
+ FUSION_NEW_DESIGN_OUTPUT_SCHEMA,
28
+ FUSION_STATUS_OUTPUT_SCHEMA,
29
+ FusionMcpBridge,
30
+ } from "./fusion-mcp-bridge.js";
22
31
  import { createServer as createHttpServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from "node:http";
23
32
  import { randomUUID } from "node:crypto";
24
33
  import * as THREE from 'three';
25
34
  import { PrinterFactory } from "./printers/printer-factory.js";
26
- import { STLManipulator, type BambuSliceOptions } from "./stl/stl-manipulator.js";
35
+ import { STLManipulator, type BambuSliceOptions, type ProcessOverrides } from "./stl/stl-manipulator.js";
36
+ import { CounterpartService, type PrinterTarget } from "./counterpart/service.js";
37
+ import {
38
+ CAMERA_SNAPSHOT_OUTPUT_SCHEMA,
39
+ CONTROL_OUTPUT_SCHEMA,
40
+ FILAMENTS_OUTPUT_SCHEMA,
41
+ PRINT_PROGRESS_OUTPUT_SCHEMA,
42
+ PRINTER_INFO_OUTPUT_SCHEMA,
43
+ } from "./counterpart/contract.js";
27
44
  import { SlicerError } from "./slicer/slicer-error.js";
28
45
  import {
29
46
  inspectSliceSettings,
@@ -44,6 +61,9 @@ import { validateExpectedPeaks, type ExpectedPeakTemperatures, type PrintSafetyO
44
61
  // Load environment variables from .env file
45
62
  dotenv.config();
46
63
 
64
+ const SERVER_NAME = "mcp-3d-printer-server";
65
+ const SERVER_VERSION: string = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
66
+
47
67
  // Default values
48
68
  const DEFAULT_HOST = process.env.PRINTER_HOST || "localhost";
49
69
  const DEFAULT_PORT = process.env.PRINTER_PORT || "80";
@@ -287,8 +307,79 @@ const SLICE_TOOL_PROPERTIES = {
287
307
  min_save: { type: "boolean", description: "Bambu-compatible slicing: write a smaller output 3MF (--min-save)." },
288
308
  skip_modified_gcodes: { type: "boolean", description: "Bambu-compatible slicing: ignore custom G-code embedded in an input 3MF (--skip-modified-gcodes)." },
289
309
  slice_plate: { type: "number", description: "Bambu-compatible slicing: plate number to slice; 0 slices all plates (default)." },
310
+ layer_height: {
311
+ type: "number", exclusiveMinimum: 0, maximum: 2,
312
+ description: "Layer height in mm, written over the selected process profile (Orca/Bambu Studio layer_height; PrusaSlicer --layer-height). Not mapped for Cura.",
313
+ },
314
+ wall_loops: {
315
+ type: "integer", minimum: 0, maximum: 100,
316
+ description: "Number of walls, written over the selected process profile (Orca/Bambu Studio wall_loops; PrusaSlicer --perimeters). Not mapped for Cura.",
317
+ },
318
+ sparse_infill_density: {
319
+ type: "string",
320
+ description: "Sparse infill density such as \"15%\" (0–100%), written over the selected process profile (Orca/Bambu Studio sparse_infill_density; PrusaSlicer --fill-density). Not mapped for Cura.",
321
+ },
290
322
  };
291
323
 
324
+ /**
325
+ * upload_gcode / start_print results name the remote file that was stored or
326
+ * started (start_print starts a uniquely named checked copy), so a monitor can
327
+ * match counterpart_print_progress job.name to it.
328
+ */
329
+ function withJobName(result: unknown, requested: string): unknown {
330
+ if (!result || typeof result !== "object" || Array.isArray(result)) return result;
331
+ const record = result as Record<string, unknown>;
332
+ if (typeof record.jobName === "string" && record.jobName) return result;
333
+ const remote = typeof record.remotePath === "string" && record.remotePath ? record.remotePath : requested;
334
+ return { ...record, jobName: path.posix.basename(remote.replace(/\\/g, "/")) };
335
+ }
336
+
337
+ /** Connection overrides shared by the counterpart/1 tools. */
338
+ const PRINTER_TARGET_PROPERTIES = {
339
+ host: { type: "string", description: "Hostname or IP address of the printer (default: value from env)" },
340
+ port: { type: "string", description: "Port of the printer API (default: value from env)" },
341
+ type: { type: "string", description: "Type of printer management system (octoprint, klipper, duet, repetier, bambu, prusa, creality) (default: value from env)" },
342
+ api_key: { type: "string", description: "API key for authentication (default: value from env)" },
343
+ bambu_serial: { type: "string", description: "Serial number for Bambu Lab printers (default: value from env)" },
344
+ bambu_token: { type: "string", description: "Access token for Bambu Lab printers (default: value from env)" },
345
+ };
346
+
347
+ /** Tools that declare an outputSchema: their errors carry no structuredContent, which clients would validate. */
348
+ const OUTPUT_SCHEMA_TOOLS = new Set([
349
+ "counterpart_printer_info", "counterpart_print_progress", "camera_snapshot", "get_printer_filaments", "pause_print", "resume_print",
350
+ "fusion_mcp_status", "fusion_mcp_call", "fusion_mcp_new_design", "fusion_mcp_export",
351
+ ]);
352
+
353
+ /** layer_height / wall_loops / sparse_infill_density (PRD §8.4), validated before slicing. */
354
+ function parseProcessOverrides(args: Record<string, unknown> | undefined): ProcessOverrides {
355
+ const overrides: ProcessOverrides = {};
356
+ if (args?.layer_height !== undefined) {
357
+ const value = args.layer_height;
358
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 2) {
359
+ throw new Error("Invalid layer_height: expected a number of millimetres greater than 0 and at most 2.");
360
+ }
361
+ overrides.layerHeight = value;
362
+ }
363
+ if (args?.wall_loops !== undefined) {
364
+ const value = args.wall_loops;
365
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 100) {
366
+ throw new Error("Invalid wall_loops: expected an integer from 0 to 100.");
367
+ }
368
+ overrides.wallLoops = value;
369
+ }
370
+ if (args?.sparse_infill_density !== undefined) {
371
+ const raw = args.sparse_infill_density;
372
+ const text = typeof raw === "number" ? String(raw) : typeof raw === "string" ? raw.trim() : "";
373
+ const match = text.match(/^(\d+(?:\.\d+)?)\s*%?$/);
374
+ const value = match ? Number(match[1]) : NaN;
375
+ if (!Number.isFinite(value) || value < 0 || value > 100) {
376
+ throw new Error("Invalid sparse_infill_density: expected a percentage such as \"15%\" from 0% to 100%.");
377
+ }
378
+ overrides.sparseInfillDensity = `${value}%`;
379
+ }
380
+ return overrides;
381
+ }
382
+
292
383
  function validateSliceBambuModel(model: string): string {
293
384
  const normalized = model.trim().toLowerCase();
294
385
  if (!(SLICE_BAMBU_MODELS as readonly string[]).includes(normalized)) {
@@ -376,6 +467,21 @@ function buildBambuSliceOptions(args: Record<string, unknown> | undefined): Bamb
376
467
  return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) as BambuSliceOptions;
377
468
  }
378
469
 
470
+ /** The slicer, printer and filament slice_stl uses when a call names none (get_printer_filaments' profile). */
471
+ function defaultFilamentProfileContext() {
472
+ const splitList = (value: string) => value.split(";").map((item) => item.trim()).filter(Boolean);
473
+ const pipeFilaments = DEFAULT_SLICER_PROFILE.includes("|") ? splitList(DEFAULT_SLICER_PROFILE.split("|", 2)[1]) : [];
474
+ const configuredFilaments = DEFAULT_SLICER_TYPE === "cura" ? []
475
+ : DEFAULT_SLICER_TYPE === "prusaslicer" || DEFAULT_SLICER_TYPE === "slic3r" ? splitList(DEFAULT_FILAMENT_PROFILE)
476
+ : [...pipeFilaments, ...splitList(DEFAULT_FILAMENT_PROFILE)];
477
+ return {
478
+ slicerType: DEFAULT_SLICER_TYPE,
479
+ slicerPath: defaultSlicerPathFor(DEFAULT_SLICER_TYPE) || undefined,
480
+ slicerProfile: DEFAULT_SLICER_PROFILE || undefined,
481
+ configuredFilaments,
482
+ };
483
+ }
484
+
379
485
  function defaultSlicerPathFor(slicerType: SlicerType): string {
380
486
  if (process.env.SLICER_PATH) return process.env.SLICER_PATH;
381
487
  if (slicerType === DEFAULT_SLICER_TYPE) return DEFAULT_SLICER_PATH;
@@ -545,7 +651,9 @@ function credentialSuggestion(): string {
545
651
  class ThreeDPrinterMCPServer {
546
652
  private server: Server;
547
653
  private readonly blender = new BlenderMcpBridge();
654
+ private readonly fusion = new FusionMcpBridge();
548
655
  private printerFactory: PrinterFactory;
656
+ private readonly counterpart: CounterpartService;
549
657
  private stlManipulator: STLManipulator;
550
658
  private readonly runtimeConfig: RuntimeConfig;
551
659
  private httpRuntime?: { transport: StreamableHTTPServerTransport; httpServer: HttpServer };
@@ -554,8 +662,8 @@ class ThreeDPrinterMCPServer {
554
662
  this.runtimeConfig = readRuntimeConfig();
555
663
  this.server = new Server(
556
664
  {
557
- name: "mcp-3d-printer-server",
558
- version: JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
665
+ name: SERVER_NAME,
666
+ version: SERVER_VERSION
559
667
  },
560
668
  {
561
669
  capabilities: {
@@ -566,6 +674,12 @@ class ThreeDPrinterMCPServer {
566
674
  );
567
675
 
568
676
  this.printerFactory = new PrinterFactory((message) => this.confirmHardware(message));
677
+ this.counterpart = new CounterpartService(
678
+ (printerType) => this.printerFactory.getImplementation(printerType),
679
+ axios.create({ timeout: 10000 }),
680
+ { name: SERVER_NAME, version: SERVER_VERSION },
681
+ (material) => resolveFilamentProfile(material, defaultFilamentProfileContext())
682
+ );
569
683
  this.stlManipulator = new STLManipulator(TEMP_DIR);
570
684
 
571
685
  this.setupHandlers();
@@ -674,6 +788,7 @@ class ThreeDPrinterMCPServer {
674
788
  if (!args?.stl_path) {
675
789
  throw new Error("Missing required parameter: stl_path");
676
790
  }
791
+ const processOverrides = parseProcessOverrides(args);
677
792
  const slicerType = normalizeSlicerType(String(args.slicer_type || DEFAULT_SLICER_TYPE));
678
793
  const explicitModel =
679
794
  typeof args.bambu_model === "string" && args.bambu_model.trim() ? args.bambu_model : undefined;
@@ -717,7 +832,18 @@ class ThreeDPrinterMCPServer {
717
832
 
718
833
  const explicitFilament =
719
834
  typeof args.filament_profile === "string" && args.filament_profile.trim() ? args.filament_profile.trim() : undefined;
720
- const filamentProfile = options?.loadFilaments ?? explicitFilament ?? (DEFAULT_FILAMENT_PROFILE || undefined);
835
+ // Generic slicers take load_filaments too (filament_profile is its alias), as the Bambu CLI path does.
836
+ const explicitLoad =
837
+ !bambuCli && typeof args.load_filaments === "string" && args.load_filaments.trim() ? args.load_filaments.trim() : undefined;
838
+ if (explicitLoad && explicitFilament && explicitLoad !== explicitFilament) {
839
+ throw new Error("Provide either load_filaments or filament_profile, not conflicting values.");
840
+ }
841
+ const filamentProfile = options?.loadFilaments ?? explicitLoad ?? explicitFilament ?? (DEFAULT_FILAMENT_PROFILE || undefined);
842
+ // A filament chosen for this call replaces the default riding in "machine;process|filament",
843
+ // or Orca would keep that default as slot 1 and add the chosen one as slot 2.
844
+ if (!bambuCli && (explicitLoad ?? explicitFilament) && slicerProfile?.includes("|")) {
845
+ slicerProfile = slicerProfile.split("|", 1)[0];
846
+ }
721
847
  const slicerPath =
722
848
  this.resolveExecutableSelectorArg(args.slicer_path, toolName, "slicer_path") ?? defaultSlicerPathFor(slicerType);
723
849
 
@@ -729,7 +855,8 @@ class ThreeDPrinterMCPServer {
729
855
  undefined, // progressCallback
730
856
  printerPreset,
731
857
  filamentProfile,
732
- options
858
+ options,
859
+ processOverrides
733
860
  );
734
861
  }
735
862
 
@@ -1088,6 +1215,55 @@ class ThreeDPrinterMCPServer {
1088
1215
  }
1089
1216
  }
1090
1217
  },
1218
+ {
1219
+ name: "counterpart_printer_info",
1220
+ description: "Describe this printer for the counterpart/1 contract: server name and version, printer kind and identity, and which features work (progress, layers, camera, filaments, pause, resume, cancel, print_3mf, G-code), detected from the firmware. Read-only.",
1221
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1222
+ outputSchema: PRINTER_INFO_OUTPUT_SCHEMA,
1223
+ annotations: { readOnlyHint: true, openWorldHint: true },
1224
+ },
1225
+ {
1226
+ name: "counterpart_print_progress",
1227
+ description: "Read the printer's state and current job in the counterpart/1 shape: state, job name, progress (0-100), elapsed and remaining seconds, layers when the firmware reports them, temperatures and problems. job.name matches the jobName that upload_gcode and start_print return. Read-only.",
1228
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1229
+ outputSchema: PRINT_PROGRESS_OUTPUT_SCHEMA,
1230
+ annotations: { readOnlyHint: true, openWorldHint: true },
1231
+ },
1232
+ {
1233
+ name: "camera_snapshot",
1234
+ description: "Capture one JPEG frame from the printer's camera: OctoPrint's configured snapshot URL (Classic Webcam on 1.9+, webcam.snapshotUrl on 1.8, then /webcam/?action=snapshot), Moonraker's first enabled webcam, or Repetier's webcam staticUrl. PrusaLink and Duet have no local snapshot API. PRINTER_CAMERA_SNAPSHOT_URL overrides the source for any printer. Returns the JPEG as base64; pass save_path to also write it to disk.",
1235
+ inputSchema: {
1236
+ type: "object",
1237
+ properties: {
1238
+ ...PRINTER_TARGET_PROPERTIES,
1239
+ save_path: { type: "string", description: "Optional absolute path to write the JPEG to disk. If omitted, only the base64 payload is returned." },
1240
+ timeout_ms: { type: "number", description: "Max ms to wait for a frame (default 8000, 100 to 120000)." },
1241
+ },
1242
+ },
1243
+ outputSchema: CAMERA_SNAPSHOT_OUTPUT_SCHEMA,
1244
+ annotations: { readOnlyHint: true, openWorldHint: true },
1245
+ },
1246
+ {
1247
+ name: "get_printer_filaments",
1248
+ description: "Report the loaded filament as one external slot (id \"ext\"). Moonraker printers with Spoolman report the active spool's material, colour and name; other printers report source \"unknown\". Read-only.",
1249
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1250
+ outputSchema: FILAMENTS_OUTPUT_SCHEMA,
1251
+ annotations: { readOnlyHint: true, openWorldHint: true },
1252
+ },
1253
+ {
1254
+ name: "pause_print",
1255
+ description: "Pause the current print (OctoPrint, Klipper/Moonraker, PrusaLink, Duet M25, Repetier, and Creality printers that run Moonraker). Pause is never gated.",
1256
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1257
+ outputSchema: CONTROL_OUTPUT_SCHEMA,
1258
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1259
+ },
1260
+ {
1261
+ name: "resume_print",
1262
+ description: "Resume a paused print. Resume reheats and moves the printer, so it only resumes a job this server instance inspected and started, and only when the printer freshly reports that job paused. Resume other jobs at the printer.",
1263
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1264
+ outputSchema: CONTROL_OUTPUT_SCHEMA,
1265
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1266
+ },
1091
1267
  {
1092
1268
  name: "set_printer_temperature",
1093
1269
  description: "Set the temperature of a printer component. Temperature 0 switches a heater off and is never gated. Positive targets are validated before connecting, limited by independent hardware and material ceilings, require a ready printer and a human confirmation.",
@@ -1823,6 +1999,62 @@ class ThreeDPrinterMCPServer {
1823
1999
  required: ["stl_path", "operations"],
1824
2000
  additionalProperties: false
1825
2001
  }
2002
+ },
2003
+ {
2004
+ name: "fusion_mcp_status",
2005
+ description: `Check the Autodesk Fusion bridge: whether the Fusion MCP server is configured (faust-machines/fusion360-mcp-server@${FUSION_MCP_PINNED_COMMIT}), whether Fusion is running, whether its Fusion360MCP add-in answers on its port, and the server's tools and schemas. connected is true only when the add-in answered. With launch: true and Fusion not running on this Mac, opens Autodesk Fusion and waits up to 90 s for the add-in.`,
2006
+ inputSchema: {
2007
+ type: "object",
2008
+ properties: {
2009
+ launch: { type: "boolean", description: "Open Autodesk Fusion when it is not running (macOS), then wait up to 90 s for the add-in (default false)." },
2010
+ timeout_ms: { type: "integer", minimum: 100, maximum: 600000, description: "Deadline in milliseconds; defaults to FUSION_MCP_TIMEOUT_MS or 180000. The first start downloads the pinned server with uvx." }
2011
+ },
2012
+ additionalProperties: false
2013
+ },
2014
+ outputSchema: FUSION_STATUS_OUTPUT_SCHEMA,
2015
+ },
2016
+ {
2017
+ name: "fusion_mcp_call",
2018
+ description: "Call a discovered tool on the Fusion MCP server (create_sketch, extrude, fillet, set_parameter, export_stl, execute_code, and others), preserving its full MCP content and errors. Discover tool schemas with fusion_mcp_status first. Calls change the active Fusion design and are never automatically retried. Most tools need an active design: call fusion_mcp_new_design first.",
2019
+ inputSchema: {
2020
+ type: "object",
2021
+ properties: {
2022
+ tool_name: { type: "string", description: "Exact name advertised by the Fusion MCP server." },
2023
+ arguments: { type: "object", description: "Arguments matching the remote tool's discovered input schema. Fusion lengths are in centimetres unless a tool says otherwise." },
2024
+ timeout_ms: { type: "integer", minimum: 100, maximum: 600000, description: "Deadline in milliseconds; defaults to FUSION_MCP_TIMEOUT_MS or 180000." }
2025
+ },
2026
+ required: ["tool_name"],
2027
+ additionalProperties: false
2028
+ },
2029
+ outputSchema: FUSION_CALL_OUTPUT_SCHEMA,
2030
+ },
2031
+ {
2032
+ name: "fusion_mcp_new_design",
2033
+ description: "Open a new parametric design document in Fusion, so the Fusion tools have an active design. Fusion must already have some document open: at the pinned faust-machines commit no tool works from Fusion's home screen (issue #12).",
2034
+ inputSchema: {
2035
+ type: "object",
2036
+ properties: {
2037
+ timeout_ms: { type: "integer", minimum: 100, maximum: 600000, description: "Deadline in milliseconds; defaults to FUSION_MCP_TIMEOUT_MS or 180000." }
2038
+ },
2039
+ additionalProperties: false
2040
+ },
2041
+ outputSchema: FUSION_NEW_DESIGN_OUTPUT_SCHEMA,
2042
+ },
2043
+ {
2044
+ name: "fusion_mcp_export",
2045
+ description: "Export the active Fusion design into output_dir as <name>.stl (export_stl), <name>.f3d (export_f3d) and <name>.parameters.json (the design's user parameters), and return their absolute paths. The STL is checked as a finite triangle mesh and its size compared with the Fusion body to report its units. Existing files are never overwritten. Fusion and this server must share the computer's file system.",
2046
+ inputSchema: {
2047
+ type: "object",
2048
+ properties: {
2049
+ output_dir: { type: "string", description: "Existing absolute directory for the three files." },
2050
+ name: { type: "string", description: "Base file name without a path or extension, such as \"bracket\"." },
2051
+ body_name: { type: "string", description: "Body to export as the STL. Optional when the design has exactly one visible solid body." },
2052
+ timeout_ms: { type: "integer", minimum: 100, maximum: 600000, description: "Deadline in milliseconds; defaults to FUSION_MCP_TIMEOUT_MS or 180000." }
2053
+ },
2054
+ required: ["output_dir", "name"],
2055
+ additionalProperties: false
2056
+ },
2057
+ outputSchema: FUSION_EXPORT_OUTPUT_SCHEMA,
1826
2058
  }
1827
2059
  ]
1828
2060
  };
@@ -1846,7 +2078,40 @@ class ThreeDPrinterMCPServer {
1846
2078
  try {
1847
2079
  let result;
1848
2080
 
2081
+ const target: PrinterTarget = {
2082
+ type, host, port,
2083
+ apiKey: type.toLowerCase() === "bambu" ? `${bambuSerial}:${bambuToken}` : apiKey,
2084
+ };
2085
+ const structured = (value: object) => ({
2086
+ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
2087
+ structuredContent: value as Record<string, unknown>,
2088
+ });
2089
+
1849
2090
  switch (name) {
2091
+ case "counterpart_printer_info":
2092
+ return structured(await this.counterpart.printerInfo(target));
2093
+
2094
+ case "counterpart_print_progress":
2095
+ return structured(await this.counterpart.printProgress(target));
2096
+
2097
+ case "camera_snapshot": {
2098
+ if (args?.save_path !== undefined && typeof args.save_path !== "string") throw new Error("Invalid save_path: expected a string.");
2099
+ if (args?.timeout_ms !== undefined && typeof args.timeout_ms !== "number") throw new Error("Invalid timeout_ms: expected a number.");
2100
+ return structured(await this.counterpart.cameraSnapshot(target, {
2101
+ savePath: args?.save_path as string | undefined,
2102
+ timeoutMs: args?.timeout_ms as number | undefined,
2103
+ }));
2104
+ }
2105
+
2106
+ case "get_printer_filaments":
2107
+ return structured(await this.counterpart.filaments(target));
2108
+
2109
+ case "pause_print":
2110
+ return structured(await this.counterpart.pause(target));
2111
+
2112
+ case "resume_print":
2113
+ return structured(await this.counterpart.resume(target));
2114
+
1850
2115
  case "get_printer_status":
1851
2116
  result = await this.getPrinterStatus(host, port, type, apiKey, bambuSerial, bambuToken);
1852
2117
  break;
@@ -1874,13 +2139,13 @@ class ThreeDPrinterMCPServer {
1874
2139
  }
1875
2140
  const uploadFilename = String(args?.filename || path.basename(String(args.gcode_path)));
1876
2141
  const uploadGcodeSource = String(args?.gcode_path || args.gcode);
1877
- result = await this.uploadGcode(
2142
+ result = withJobName(await this.uploadGcode(
1878
2143
  host, port, type, apiKey, bambuSerial, bambuToken,
1879
2144
  uploadFilename,
1880
2145
  uploadGcodeSource,
1881
2146
  Boolean(args.print || false),
1882
2147
  uploadOptions
1883
- );
2148
+ ), uploadFilename);
1884
2149
  break;
1885
2150
 
1886
2151
  case "start_print":
@@ -1891,7 +2156,10 @@ class ThreeDPrinterMCPServer {
1891
2156
  if (type.toLowerCase() === "bambu") {
1892
2157
  startOptions.bambuModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1893
2158
  }
1894
- result = await this.startPrint(host, port, type, apiKey, bambuSerial, bambuToken, String(args.filename), startOptions);
2159
+ result = withJobName(
2160
+ await this.startPrint(host, port, type, apiKey, bambuSerial, bambuToken, String(args.filename), startOptions),
2161
+ String(args.filename)
2162
+ );
1895
2163
  break;
1896
2164
 
1897
2165
  case "cancel_print":
@@ -2534,6 +2802,18 @@ class ThreeDPrinterMCPServer {
2534
2802
  result = await this.stlManipulator.layFlat(String(args.stl_path));
2535
2803
  break;
2536
2804
 
2805
+ case "fusion_mcp_status":
2806
+ return structured(await this.fusion.status(args ?? {}, extra.signal));
2807
+
2808
+ case "fusion_mcp_call":
2809
+ return await this.fusion.call(args ?? {}, extra.signal);
2810
+
2811
+ case "fusion_mcp_new_design":
2812
+ return structured(await this.fusion.newDesign(args ?? {}, extra.signal));
2813
+
2814
+ case "fusion_mcp_export":
2815
+ return structured(await this.fusion.export(args ?? {}, extra.signal));
2816
+
2537
2817
  case "blender_mcp_status":
2538
2818
  result = await this.blender.status(args ?? {}, extra.signal);
2539
2819
  break;
@@ -2584,7 +2864,8 @@ class ThreeDPrinterMCPServer {
2584
2864
  text: `Error: ${errorMessage}\nSuggestion: ${structuredError.suggestion}`
2585
2865
  }
2586
2866
  ],
2587
- structuredContent: structuredError,
2867
+ // Clients validate structuredContent against a declared outputSchema even on errors.
2868
+ ...(OUTPUT_SCHEMA_TOOLS.has(name) ? {} : { structuredContent: structuredError }),
2588
2869
  isError: true
2589
2870
  };
2590
2871
  }
@@ -2765,6 +3046,15 @@ class ThreeDPrinterMCPServer {
2765
3046
  tool,
2766
3047
  };
2767
3048
  }
3049
+ if (tool.startsWith("fusion_mcp_")) {
3050
+ return {
3051
+ status: "error",
3052
+ retryable: false,
3053
+ suggestion: "Check that Autodesk Fusion is open with the Fusion360MCP add-in running and the FUSION_MCP_* settings, then ask again. Fusion requests are never replayed; inspect the design before repeating a change.",
3054
+ message,
3055
+ tool,
3056
+ };
3057
+ }
2768
3058
  if (isCredentialRejection(message)) {
2769
3059
  return { status: "error", retryable: false, suggestion: credentialSuggestion(), message, tool };
2770
3060
  }
@@ -2828,6 +3118,11 @@ class ThreeDPrinterMCPServer {
2828
3118
  private async connectStdio(): Promise<void> {
2829
3119
  const transport = new StdioServerTransport();
2830
3120
  await this.server.connect(transport);
3121
+ // A host that disconnects by closing stdin sends no signal. Shut down then,
3122
+ // so the server and its persistent Fusion MCP child do not outlive it.
3123
+ const disconnected = () => { void this.close().finally(() => process.exit(0)); };
3124
+ process.stdin.once("end", disconnected);
3125
+ process.stdin.once("close", disconnected);
2831
3126
  console.error("3D Printer MCP server running on stdio transport");
2832
3127
  }
2833
3128
 
@@ -2953,8 +3248,17 @@ class ThreeDPrinterMCPServer {
2953
3248
  return value;
2954
3249
  }
2955
3250
 
2956
- async close(): Promise<void> {
3251
+ private closing?: Promise<void>;
3252
+
3253
+ /** Idempotent: a signal and stdin EOF can both arrive. */
3254
+ close(): Promise<void> {
3255
+ this.closing ??= this.closeOnce();
3256
+ return this.closing;
3257
+ }
3258
+
3259
+ private async closeOnce(): Promise<void> {
2957
3260
  await this.printerFactory.disconnectAll();
3261
+ await this.fusion.close();
2958
3262
 
2959
3263
  if (this.httpRuntime) {
2960
3264
  await this.httpRuntime.transport.close();
@@ -1,7 +1,20 @@
1
- import { GenericPrinterImplementation, type GenericPrinterState } from "./generic-base.js";
1
+ import type { AxiosInstance } from "axios";
2
+ import { GenericPrinterImplementation, type GenericPrinterState, type SnapshotCandidate } from "./generic-base.js";
3
+ import { KlipperImplementation, MACRO_TIMEOUT_MS } from "./klipper.js";
4
+ import type { ConfirmHardware } from "../safety/confirmation.js";
5
+ import type { CounterpartProgress, FilamentSlot, PrinterIdentity } from "../counterpart/contract.js";
6
+ import { mapCrealityDevice } from "../counterpart/progress.js";
2
7
  import fs from "fs";
3
8
  import FormData from "form-data";
4
9
 
10
+ /**
11
+ * K1/K2-series printers can run Moonraker: 7125 is Moonraker's own port and
12
+ * 4408 the Fluidd nginx front end that proxies its API. Neither is documented
13
+ * by Creality; stock firmware may run neither, so this is probed, never assumed.
14
+ */
15
+ export const CREALITY_MOONRAKER_PORTS = ["7125", "4408"];
16
+ const MOONRAKER_PROBE_TTL_MS = 5 * 60_000;
17
+
5
18
  const IDLE_STATES = new Set(["idle", "ready", "standby", "operational"]);
6
19
  const FINISHED_STATES = new Set(["complete", "completed", "finished", "finish", "cancelled", "canceled", "stopped"]);
7
20
 
@@ -13,6 +26,76 @@ const FINISHED_STATES = new Set(["complete", "completed", "finished", "finish",
13
26
  */
14
27
  export class CrealityImplementation extends GenericPrinterImplementation {
15
28
  protected readonly printerLabel = "Creality";
29
+ private readonly moonraker: KlipperImplementation;
30
+ private readonly probes = new Map<string, { port?: string; at: number }>();
31
+ /** Ports probed for Moonraker, in order. */
32
+ moonrakerPorts: string[] = [...CREALITY_MOONRAKER_PORTS];
33
+
34
+ constructor(apiClient: AxiosInstance, confirm?: ConfirmHardware) {
35
+ super(apiClient, confirm);
36
+ this.moonraker = new KlipperImplementation(apiClient, confirm);
37
+ }
38
+
39
+ /** The Moonraker port that answers /server/info on this host, if any. */
40
+ async moonrakerPort(host: string): Promise<string | undefined> {
41
+ const cached = this.probes.get(host);
42
+ if (cached && Date.now() - cached.at < MOONRAKER_PROBE_TTL_MS) return cached.port;
43
+ let found: string | undefined;
44
+ for (const port of this.moonrakerPorts) {
45
+ try {
46
+ const info = (await this.apiClient.get(`http://${host}:${port}/server/info`, { timeout: 2_000 })).data;
47
+ if (info?.result && typeof info.result === "object") { found = port; break; }
48
+ } catch {
49
+ // Try the next port.
50
+ }
51
+ }
52
+ this.probes.set(host, { port: found, at: Date.now() });
53
+ return found;
54
+ }
55
+
56
+ async counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress> {
57
+ const moonraker = await this.moonrakerPort(host);
58
+ if (moonraker) return this.moonraker.counterpartProgress(host, moonraker, "");
59
+ const data = (await this.apiClient.get(`http://${host}:${port}/api/device/status`, { headers: { "Authorization": `Bearer ${apiKey}` } })).data;
60
+ return mapCrealityDevice(data, new Date().toISOString());
61
+ }
62
+
63
+ async reportsLayers(host: string): Promise<boolean> {
64
+ return (await this.moonrakerPort(host)) !== undefined;
65
+ }
66
+
67
+ async supportsPauseResume(host: string): Promise<boolean> {
68
+ return (await this.moonrakerPort(host)) !== undefined;
69
+ }
70
+
71
+ async snapshotCandidates(host: string): Promise<SnapshotCandidate[]> {
72
+ const moonraker = await this.moonrakerPort(host);
73
+ return moonraker ? this.moonraker.snapshotCandidates(host, moonraker, "") : [];
74
+ }
75
+
76
+ async counterpartFilaments(host: string, port: string, apiKey: string): Promise<FilamentSlot[]> {
77
+ const moonraker = await this.moonrakerPort(host);
78
+ return moonraker ? this.moonraker.counterpartFilaments(host, moonraker, "") : super.counterpartFilaments(host, port, apiKey);
79
+ }
80
+
81
+ async counterpartIdentity(host: string): Promise<PrinterIdentity> {
82
+ const moonraker = await this.moonrakerPort(host);
83
+ return moonraker ? this.moonraker.counterpartIdentity(host, moonraker, "") : {};
84
+ }
85
+
86
+ /** Pause and resume only through a detected Moonraker; the device API has no verified route. */
87
+ protected async rawPauseJob(host: string) {
88
+ const moonraker = await this.moonrakerPort(host);
89
+ if (!moonraker) throw new Error("Unsupported: this Creality printer has no Moonraker API for pause.");
90
+ return (await this.apiClient.post(`http://${host}:${moonraker}/printer/print/pause`, null as any, { timeout: MACRO_TIMEOUT_MS })).data;
91
+ }
92
+
93
+ protected async rawResumeJob(host: string) {
94
+ const moonraker = await this.moonrakerPort(host);
95
+ if (!moonraker) throw new Error("Unsupported: this Creality printer has no Moonraker API for resume.");
96
+ // The Creality adapter already checked the job identity; send the raw resume.
97
+ return (await this.apiClient.post(`http://${host}:${moonraker}/printer/print/resume`, null as any, { timeout: MACRO_TIMEOUT_MS })).data;
98
+ }
16
99
 
17
100
  protected async readPrinterState(host: string, port: string, apiKey: string): Promise<GenericPrinterState> {
18
101
  let data: any;
@@ -1,5 +1,13 @@
1
1
  import fs from "fs";
2
+ import { isAxiosError } from "axios";
2
3
  import { GenericPrinterImplementation, type GenericPrinterState } from "./generic-base.js";
4
+ import type { CounterpartProgress, PrinterIdentity } from "../counterpart/contract.js";
5
+ import { nonEmptyText } from "../counterpart/contract.js";
6
+ import { mapDuet } from "../counterpart/progress.js";
7
+
8
+ function notFound(error: unknown): boolean {
9
+ return isAxiosError(error) && (error.response?.status === 404 || error.response?.status === 405);
10
+ }
3
11
 
4
12
  /**
5
13
  * Duet Software Framework (DuetWebServer) REST routes, verified against
@@ -25,6 +33,65 @@ export class DuetImplementation extends GenericPrinterImplementation {
25
33
  return response.data;
26
34
  }
27
35
 
36
+ /**
37
+ * Object model keys for counterpart_print_progress and identity. Standalone
38
+ * RepRapFirmware and DSF 3.5+ answer rr_model; older DSF only /machine/status
39
+ * (an alias of /machine/model). 'v' (verbose) is required: 'f' omits job.file.
40
+ * Without a machine password no rr_connect is needed; with one, requests get 401.
41
+ * https://github.com/Duet3D/RepRapFirmware/wiki/HTTP-requests
42
+ */
43
+ private async objectModel(host: string, port: string, keys: string[]): Promise<Record<string, any>> {
44
+ const base = `http://${host}:${port}`;
45
+ const model: Record<string, any> = {};
46
+ try {
47
+ // Sequential: standalone boards serve few concurrent HTTP connections.
48
+ for (const key of keys) {
49
+ model[key] = (await this.apiClient.get(`${base}/rr_model?key=${encodeURIComponent(key)}&flags=d99v`)).data?.result;
50
+ }
51
+ return model;
52
+ } catch (error) {
53
+ if (!notFound(error)) throw error;
54
+ }
55
+ const full = (await this.apiClient.get(`${base}/machine/status`)).data;
56
+ const root = full?.result && typeof full.result === "object" ? full.result : full;
57
+ for (const key of keys) model[key] = root?.[key];
58
+ return model;
59
+ }
60
+
61
+ async counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress> {
62
+ const model = await this.objectModel(host, port, ["state", "job"]);
63
+ if (!model.state || typeof model.state !== "object") throw new Error("Duet did not return state from its object model.");
64
+ return mapDuet(model.state, model.job ?? {}, new Date().toISOString());
65
+ }
66
+
67
+ async reportsLayers(): Promise<boolean> {
68
+ return true;
69
+ }
70
+
71
+ async counterpartIdentity(host: string, port: string): Promise<PrinterIdentity> {
72
+ const model = await this.objectModel(host, port, ["network", "boards"]);
73
+ const board = Array.isArray(model.boards) ? model.boards[0] : undefined;
74
+ return { name: nonEmptyText(model.network?.name), model: nonEmptyText(board?.name) ?? nonEmptyText(board?.shortName) };
75
+ }
76
+
77
+ /** M25 pauses and M24 resumes: rr_gcode on standalone/DSF 3.5+, POST /machine/code on older DSF. */
78
+ protected async rawPauseJob(host: string, port: string) {
79
+ return this.sendGcode(host, port, "M25");
80
+ }
81
+
82
+ protected async rawResumeJob(host: string, port: string) {
83
+ return this.sendGcode(host, port, "M24");
84
+ }
85
+
86
+ private async sendGcode(host: string, port: string, gcode: string) {
87
+ try {
88
+ return (await this.apiClient.get(`http://${host}:${port}/rr_gcode?gcode=${encodeURIComponent(gcode)}`)).data;
89
+ } catch (error) {
90
+ if (!notFound(error)) throw error;
91
+ }
92
+ return this.code(host, port, gcode);
93
+ }
94
+
28
95
  async getStatus(host: string, port: string, apiKey: string) {
29
96
  const url = `http://${host}:${port}/machine/status`;
30
97
  const response = await this.apiClient.get(url);