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
package/dist/index.js CHANGED
@@ -11,11 +11,15 @@ import fs from "fs";
11
11
  import path from "path";
12
12
  import os from "node:os";
13
13
  import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
14
+ import { resolveFilamentProfile } from "./slicer/filament-profiles.js";
15
+ import { FUSION_CALL_OUTPUT_SCHEMA, FUSION_EXPORT_OUTPUT_SCHEMA, FUSION_MCP_PINNED_COMMIT, FUSION_NEW_DESIGN_OUTPUT_SCHEMA, FUSION_STATUS_OUTPUT_SCHEMA, FusionMcpBridge, } from "./fusion-mcp-bridge.js";
14
16
  import { createServer as createHttpServer } from "node:http";
15
17
  import { randomUUID } from "node:crypto";
16
18
  import * as THREE from 'three';
17
19
  import { PrinterFactory } from "./printers/printer-factory.js";
18
20
  import { STLManipulator } from "./stl/stl-manipulator.js";
21
+ import { CounterpartService } from "./counterpart/service.js";
22
+ import { CAMERA_SNAPSHOT_OUTPUT_SCHEMA, CONTROL_OUTPUT_SCHEMA, FILAMENTS_OUTPUT_SCHEMA, PRINT_PROGRESS_OUTPUT_SCHEMA, PRINTER_INFO_OUTPUT_SCHEMA, } from "./counterpart/contract.js";
19
23
  import { SlicerError } from "./slicer/slicer-error.js";
20
24
  import { inspectSliceSettings, listTemplateRegistry, resolveTemplatePath, resolveTemplateSlicerProfilePath, saveTemplate, } from "./slicer/templates.js";
21
25
  import { parse3MF } from './3mf_parser.js';
@@ -25,6 +29,8 @@ import { CONFIRMATION_UNAVAILABLE, confirmationTimedOut, confirmationTimeoutMs }
25
29
  import { validateExpectedPeaks } from "./safety/expected-peaks.js";
26
30
  // Load environment variables from .env file
27
31
  dotenv.config();
32
+ const SERVER_NAME = "mcp-3d-printer-server";
33
+ const SERVER_VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
28
34
  // Default values
29
35
  const DEFAULT_HOST = process.env.PRINTER_HOST || "localhost";
30
36
  const DEFAULT_PORT = process.env.PRINTER_PORT || "80";
@@ -237,7 +243,76 @@ const SLICE_TOOL_PROPERTIES = {
237
243
  min_save: { type: "boolean", description: "Bambu-compatible slicing: write a smaller output 3MF (--min-save)." },
238
244
  skip_modified_gcodes: { type: "boolean", description: "Bambu-compatible slicing: ignore custom G-code embedded in an input 3MF (--skip-modified-gcodes)." },
239
245
  slice_plate: { type: "number", description: "Bambu-compatible slicing: plate number to slice; 0 slices all plates (default)." },
246
+ layer_height: {
247
+ type: "number", exclusiveMinimum: 0, maximum: 2,
248
+ description: "Layer height in mm, written over the selected process profile (Orca/Bambu Studio layer_height; PrusaSlicer --layer-height). Not mapped for Cura.",
249
+ },
250
+ wall_loops: {
251
+ type: "integer", minimum: 0, maximum: 100,
252
+ description: "Number of walls, written over the selected process profile (Orca/Bambu Studio wall_loops; PrusaSlicer --perimeters). Not mapped for Cura.",
253
+ },
254
+ sparse_infill_density: {
255
+ type: "string",
256
+ 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.",
257
+ },
258
+ };
259
+ /**
260
+ * upload_gcode / start_print results name the remote file that was stored or
261
+ * started (start_print starts a uniquely named checked copy), so a monitor can
262
+ * match counterpart_print_progress job.name to it.
263
+ */
264
+ function withJobName(result, requested) {
265
+ if (!result || typeof result !== "object" || Array.isArray(result))
266
+ return result;
267
+ const record = result;
268
+ if (typeof record.jobName === "string" && record.jobName)
269
+ return result;
270
+ const remote = typeof record.remotePath === "string" && record.remotePath ? record.remotePath : requested;
271
+ return { ...record, jobName: path.posix.basename(remote.replace(/\\/g, "/")) };
272
+ }
273
+ /** Connection overrides shared by the counterpart/1 tools. */
274
+ const PRINTER_TARGET_PROPERTIES = {
275
+ host: { type: "string", description: "Hostname or IP address of the printer (default: value from env)" },
276
+ port: { type: "string", description: "Port of the printer API (default: value from env)" },
277
+ type: { type: "string", description: "Type of printer management system (octoprint, klipper, duet, repetier, bambu, prusa, creality) (default: value from env)" },
278
+ api_key: { type: "string", description: "API key for authentication (default: value from env)" },
279
+ bambu_serial: { type: "string", description: "Serial number for Bambu Lab printers (default: value from env)" },
280
+ bambu_token: { type: "string", description: "Access token for Bambu Lab printers (default: value from env)" },
240
281
  };
282
+ /** Tools that declare an outputSchema: their errors carry no structuredContent, which clients would validate. */
283
+ const OUTPUT_SCHEMA_TOOLS = new Set([
284
+ "counterpart_printer_info", "counterpart_print_progress", "camera_snapshot", "get_printer_filaments", "pause_print", "resume_print",
285
+ "fusion_mcp_status", "fusion_mcp_call", "fusion_mcp_new_design", "fusion_mcp_export",
286
+ ]);
287
+ /** layer_height / wall_loops / sparse_infill_density (PRD §8.4), validated before slicing. */
288
+ function parseProcessOverrides(args) {
289
+ const overrides = {};
290
+ if (args?.layer_height !== undefined) {
291
+ const value = args.layer_height;
292
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 2) {
293
+ throw new Error("Invalid layer_height: expected a number of millimetres greater than 0 and at most 2.");
294
+ }
295
+ overrides.layerHeight = value;
296
+ }
297
+ if (args?.wall_loops !== undefined) {
298
+ const value = args.wall_loops;
299
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 100) {
300
+ throw new Error("Invalid wall_loops: expected an integer from 0 to 100.");
301
+ }
302
+ overrides.wallLoops = value;
303
+ }
304
+ if (args?.sparse_infill_density !== undefined) {
305
+ const raw = args.sparse_infill_density;
306
+ const text = typeof raw === "number" ? String(raw) : typeof raw === "string" ? raw.trim() : "";
307
+ const match = text.match(/^(\d+(?:\.\d+)?)\s*%?$/);
308
+ const value = match ? Number(match[1]) : NaN;
309
+ if (!Number.isFinite(value) || value < 0 || value > 100) {
310
+ throw new Error("Invalid sparse_infill_density: expected a percentage such as \"15%\" from 0% to 100%.");
311
+ }
312
+ overrides.sparseInfillDensity = `${value}%`;
313
+ }
314
+ return overrides;
315
+ }
241
316
  function validateSliceBambuModel(model) {
242
317
  const normalized = model.trim().toLowerCase();
243
318
  if (!SLICE_BAMBU_MODELS.includes(normalized)) {
@@ -324,6 +399,20 @@ function buildBambuSliceOptions(args) {
324
399
  options.nozzleType = resolveNozzleType(args?.nozzle_type);
325
400
  return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined));
326
401
  }
402
+ /** The slicer, printer and filament slice_stl uses when a call names none (get_printer_filaments' profile). */
403
+ function defaultFilamentProfileContext() {
404
+ const splitList = (value) => value.split(";").map((item) => item.trim()).filter(Boolean);
405
+ const pipeFilaments = DEFAULT_SLICER_PROFILE.includes("|") ? splitList(DEFAULT_SLICER_PROFILE.split("|", 2)[1]) : [];
406
+ const configuredFilaments = DEFAULT_SLICER_TYPE === "cura" ? []
407
+ : DEFAULT_SLICER_TYPE === "prusaslicer" || DEFAULT_SLICER_TYPE === "slic3r" ? splitList(DEFAULT_FILAMENT_PROFILE)
408
+ : [...pipeFilaments, ...splitList(DEFAULT_FILAMENT_PROFILE)];
409
+ return {
410
+ slicerType: DEFAULT_SLICER_TYPE,
411
+ slicerPath: defaultSlicerPathFor(DEFAULT_SLICER_TYPE) || undefined,
412
+ slicerProfile: DEFAULT_SLICER_PROFILE || undefined,
413
+ configuredFilaments,
414
+ };
415
+ }
327
416
  function defaultSlicerPathFor(slicerType) {
328
417
  if (process.env.SLICER_PATH)
329
418
  return process.env.SLICER_PATH;
@@ -453,10 +542,11 @@ function credentialSuggestion() {
453
542
  class ThreeDPrinterMCPServer {
454
543
  constructor() {
455
544
  this.blender = new BlenderMcpBridge();
545
+ this.fusion = new FusionMcpBridge();
456
546
  this.runtimeConfig = readRuntimeConfig();
457
547
  this.server = new Server({
458
- name: "mcp-3d-printer-server",
459
- version: JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
548
+ name: SERVER_NAME,
549
+ version: SERVER_VERSION
460
550
  }, {
461
551
  capabilities: {
462
552
  resources: {},
@@ -464,6 +554,7 @@ class ThreeDPrinterMCPServer {
464
554
  }
465
555
  });
466
556
  this.printerFactory = new PrinterFactory((message) => this.confirmHardware(message));
557
+ this.counterpart = new CounterpartService((printerType) => this.printerFactory.getImplementation(printerType), axios.create({ timeout: 10000 }), { name: SERVER_NAME, version: SERVER_VERSION }, (material) => resolveFilamentProfile(material, defaultFilamentProfileContext()));
467
558
  this.stlManipulator = new STLManipulator(TEMP_DIR);
468
559
  this.setupHandlers();
469
560
  this.setupErrorHandling();
@@ -558,6 +649,7 @@ class ThreeDPrinterMCPServer {
558
649
  if (!args?.stl_path) {
559
650
  throw new Error("Missing required parameter: stl_path");
560
651
  }
652
+ const processOverrides = parseProcessOverrides(args);
561
653
  const slicerType = normalizeSlicerType(String(args.slicer_type || DEFAULT_SLICER_TYPE));
562
654
  const explicitModel = typeof args.bambu_model === "string" && args.bambu_model.trim() ? args.bambu_model : undefined;
563
655
  // Generic OrcaSlicer keeps its user-supplied machine profile unless the
@@ -588,10 +680,20 @@ class ThreeDPrinterMCPServer {
588
680
  }
589
681
  slicerProfile ?? (slicerProfile = DEFAULT_SLICER_PROFILE || undefined);
590
682
  const explicitFilament = typeof args.filament_profile === "string" && args.filament_profile.trim() ? args.filament_profile.trim() : undefined;
591
- const filamentProfile = options?.loadFilaments ?? explicitFilament ?? (DEFAULT_FILAMENT_PROFILE || undefined);
683
+ // Generic slicers take load_filaments too (filament_profile is its alias), as the Bambu CLI path does.
684
+ const explicitLoad = !bambuCli && typeof args.load_filaments === "string" && args.load_filaments.trim() ? args.load_filaments.trim() : undefined;
685
+ if (explicitLoad && explicitFilament && explicitLoad !== explicitFilament) {
686
+ throw new Error("Provide either load_filaments or filament_profile, not conflicting values.");
687
+ }
688
+ const filamentProfile = options?.loadFilaments ?? explicitLoad ?? explicitFilament ?? (DEFAULT_FILAMENT_PROFILE || undefined);
689
+ // A filament chosen for this call replaces the default riding in "machine;process|filament",
690
+ // or Orca would keep that default as slot 1 and add the chosen one as slot 2.
691
+ if (!bambuCli && (explicitLoad ?? explicitFilament) && slicerProfile?.includes("|")) {
692
+ slicerProfile = slicerProfile.split("|", 1)[0];
693
+ }
592
694
  const slicerPath = this.resolveExecutableSelectorArg(args.slicer_path, toolName, "slicer_path") ?? defaultSlicerPathFor(slicerType);
593
695
  return this.stlManipulator.sliceSTL(String(args.stl_path), slicerType, slicerPath, slicerProfile, undefined, // progressCallback
594
- printerPreset, filamentProfile, options);
696
+ printerPreset, filamentProfile, options, processOverrides);
595
697
  }
596
698
  setupErrorHandling() {
597
699
  this.server.onerror = (error) => {
@@ -926,6 +1028,55 @@ class ThreeDPrinterMCPServer {
926
1028
  }
927
1029
  }
928
1030
  },
1031
+ {
1032
+ name: "counterpart_printer_info",
1033
+ 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.",
1034
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1035
+ outputSchema: PRINTER_INFO_OUTPUT_SCHEMA,
1036
+ annotations: { readOnlyHint: true, openWorldHint: true },
1037
+ },
1038
+ {
1039
+ name: "counterpart_print_progress",
1040
+ 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.",
1041
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1042
+ outputSchema: PRINT_PROGRESS_OUTPUT_SCHEMA,
1043
+ annotations: { readOnlyHint: true, openWorldHint: true },
1044
+ },
1045
+ {
1046
+ name: "camera_snapshot",
1047
+ 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.",
1048
+ inputSchema: {
1049
+ type: "object",
1050
+ properties: {
1051
+ ...PRINTER_TARGET_PROPERTIES,
1052
+ save_path: { type: "string", description: "Optional absolute path to write the JPEG to disk. If omitted, only the base64 payload is returned." },
1053
+ timeout_ms: { type: "number", description: "Max ms to wait for a frame (default 8000, 100 to 120000)." },
1054
+ },
1055
+ },
1056
+ outputSchema: CAMERA_SNAPSHOT_OUTPUT_SCHEMA,
1057
+ annotations: { readOnlyHint: true, openWorldHint: true },
1058
+ },
1059
+ {
1060
+ name: "get_printer_filaments",
1061
+ 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.",
1062
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1063
+ outputSchema: FILAMENTS_OUTPUT_SCHEMA,
1064
+ annotations: { readOnlyHint: true, openWorldHint: true },
1065
+ },
1066
+ {
1067
+ name: "pause_print",
1068
+ description: "Pause the current print (OctoPrint, Klipper/Moonraker, PrusaLink, Duet M25, Repetier, and Creality printers that run Moonraker). Pause is never gated.",
1069
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1070
+ outputSchema: CONTROL_OUTPUT_SCHEMA,
1071
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1072
+ },
1073
+ {
1074
+ name: "resume_print",
1075
+ 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.",
1076
+ inputSchema: { type: "object", properties: { ...PRINTER_TARGET_PROPERTIES } },
1077
+ outputSchema: CONTROL_OUTPUT_SCHEMA,
1078
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1079
+ },
929
1080
  {
930
1081
  name: "set_printer_temperature",
931
1082
  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.",
@@ -1652,6 +1803,62 @@ class ThreeDPrinterMCPServer {
1652
1803
  required: ["stl_path", "operations"],
1653
1804
  additionalProperties: false
1654
1805
  }
1806
+ },
1807
+ {
1808
+ name: "fusion_mcp_status",
1809
+ 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.`,
1810
+ inputSchema: {
1811
+ type: "object",
1812
+ properties: {
1813
+ 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)." },
1814
+ 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." }
1815
+ },
1816
+ additionalProperties: false
1817
+ },
1818
+ outputSchema: FUSION_STATUS_OUTPUT_SCHEMA,
1819
+ },
1820
+ {
1821
+ name: "fusion_mcp_call",
1822
+ 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.",
1823
+ inputSchema: {
1824
+ type: "object",
1825
+ properties: {
1826
+ tool_name: { type: "string", description: "Exact name advertised by the Fusion MCP server." },
1827
+ arguments: { type: "object", description: "Arguments matching the remote tool's discovered input schema. Fusion lengths are in centimetres unless a tool says otherwise." },
1828
+ timeout_ms: { type: "integer", minimum: 100, maximum: 600000, description: "Deadline in milliseconds; defaults to FUSION_MCP_TIMEOUT_MS or 180000." }
1829
+ },
1830
+ required: ["tool_name"],
1831
+ additionalProperties: false
1832
+ },
1833
+ outputSchema: FUSION_CALL_OUTPUT_SCHEMA,
1834
+ },
1835
+ {
1836
+ name: "fusion_mcp_new_design",
1837
+ 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).",
1838
+ inputSchema: {
1839
+ type: "object",
1840
+ properties: {
1841
+ timeout_ms: { type: "integer", minimum: 100, maximum: 600000, description: "Deadline in milliseconds; defaults to FUSION_MCP_TIMEOUT_MS or 180000." }
1842
+ },
1843
+ additionalProperties: false
1844
+ },
1845
+ outputSchema: FUSION_NEW_DESIGN_OUTPUT_SCHEMA,
1846
+ },
1847
+ {
1848
+ name: "fusion_mcp_export",
1849
+ 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.",
1850
+ inputSchema: {
1851
+ type: "object",
1852
+ properties: {
1853
+ output_dir: { type: "string", description: "Existing absolute directory for the three files." },
1854
+ name: { type: "string", description: "Base file name without a path or extension, such as \"bracket\"." },
1855
+ body_name: { type: "string", description: "Body to export as the STL. Optional when the design has exactly one visible solid body." },
1856
+ timeout_ms: { type: "integer", minimum: 100, maximum: 600000, description: "Deadline in milliseconds; defaults to FUSION_MCP_TIMEOUT_MS or 180000." }
1857
+ },
1858
+ required: ["output_dir", "name"],
1859
+ additionalProperties: false
1860
+ },
1861
+ outputSchema: FUSION_EXPORT_OUTPUT_SCHEMA,
1655
1862
  }
1656
1863
  ]
1657
1864
  };
@@ -1671,7 +1878,35 @@ class ThreeDPrinterMCPServer {
1671
1878
  const filamentProfile = String(args?.filament_profile || DEFAULT_FILAMENT_PROFILE);
1672
1879
  try {
1673
1880
  let result;
1881
+ const target = {
1882
+ type, host, port,
1883
+ apiKey: type.toLowerCase() === "bambu" ? `${bambuSerial}:${bambuToken}` : apiKey,
1884
+ };
1885
+ const structured = (value) => ({
1886
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
1887
+ structuredContent: value,
1888
+ });
1674
1889
  switch (name) {
1890
+ case "counterpart_printer_info":
1891
+ return structured(await this.counterpart.printerInfo(target));
1892
+ case "counterpart_print_progress":
1893
+ return structured(await this.counterpart.printProgress(target));
1894
+ case "camera_snapshot": {
1895
+ if (args?.save_path !== undefined && typeof args.save_path !== "string")
1896
+ throw new Error("Invalid save_path: expected a string.");
1897
+ if (args?.timeout_ms !== undefined && typeof args.timeout_ms !== "number")
1898
+ throw new Error("Invalid timeout_ms: expected a number.");
1899
+ return structured(await this.counterpart.cameraSnapshot(target, {
1900
+ savePath: args?.save_path,
1901
+ timeoutMs: args?.timeout_ms,
1902
+ }));
1903
+ }
1904
+ case "get_printer_filaments":
1905
+ return structured(await this.counterpart.filaments(target));
1906
+ case "pause_print":
1907
+ return structured(await this.counterpart.pause(target));
1908
+ case "resume_print":
1909
+ return structured(await this.counterpart.resume(target));
1675
1910
  case "get_printer_status":
1676
1911
  result = await this.getPrinterStatus(host, port, type, apiKey, bambuSerial, bambuToken);
1677
1912
  break;
@@ -1697,7 +1932,7 @@ class ThreeDPrinterMCPServer {
1697
1932
  }
1698
1933
  const uploadFilename = String(args?.filename || path.basename(String(args.gcode_path)));
1699
1934
  const uploadGcodeSource = String(args?.gcode_path || args.gcode);
1700
- result = await this.uploadGcode(host, port, type, apiKey, bambuSerial, bambuToken, uploadFilename, uploadGcodeSource, Boolean(args.print || false), uploadOptions);
1935
+ result = withJobName(await this.uploadGcode(host, port, type, apiKey, bambuSerial, bambuToken, uploadFilename, uploadGcodeSource, Boolean(args.print || false), uploadOptions), uploadFilename);
1701
1936
  break;
1702
1937
  case "start_print":
1703
1938
  if (!args?.filename) {
@@ -1707,7 +1942,7 @@ class ThreeDPrinterMCPServer {
1707
1942
  if (type.toLowerCase() === "bambu") {
1708
1943
  startOptions.bambuModel = await this.resolveBambuModel(args?.bambu_model);
1709
1944
  }
1710
- result = await this.startPrint(host, port, type, apiKey, bambuSerial, bambuToken, String(args.filename), startOptions);
1945
+ result = withJobName(await this.startPrint(host, port, type, apiKey, bambuSerial, bambuToken, String(args.filename), startOptions), String(args.filename));
1711
1946
  break;
1712
1947
  case "cancel_print":
1713
1948
  result = await this.cancelPrint(host, port, type, apiKey, bambuSerial, bambuToken);
@@ -2155,6 +2390,14 @@ class ThreeDPrinterMCPServer {
2155
2390
  }
2156
2391
  result = await this.stlManipulator.layFlat(String(args.stl_path));
2157
2392
  break;
2393
+ case "fusion_mcp_status":
2394
+ return structured(await this.fusion.status(args ?? {}, extra.signal));
2395
+ case "fusion_mcp_call":
2396
+ return await this.fusion.call(args ?? {}, extra.signal);
2397
+ case "fusion_mcp_new_design":
2398
+ return structured(await this.fusion.newDesign(args ?? {}, extra.signal));
2399
+ case "fusion_mcp_export":
2400
+ return structured(await this.fusion.export(args ?? {}, extra.signal));
2158
2401
  case "blender_mcp_status":
2159
2402
  result = await this.blender.status(args ?? {}, extra.signal);
2160
2403
  break;
@@ -2189,7 +2432,8 @@ class ThreeDPrinterMCPServer {
2189
2432
  text: `Error: ${errorMessage}\nSuggestion: ${structuredError.suggestion}`
2190
2433
  }
2191
2434
  ],
2192
- structuredContent: structuredError,
2435
+ // Clients validate structuredContent against a declared outputSchema even on errors.
2436
+ ...(OUTPUT_SCHEMA_TOOLS.has(name) ? {} : { structuredContent: structuredError }),
2193
2437
  isError: true
2194
2438
  };
2195
2439
  }
@@ -2290,6 +2534,15 @@ class ThreeDPrinterMCPServer {
2290
2534
  tool,
2291
2535
  };
2292
2536
  }
2537
+ if (tool.startsWith("fusion_mcp_")) {
2538
+ return {
2539
+ status: "error",
2540
+ retryable: false,
2541
+ 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.",
2542
+ message,
2543
+ tool,
2544
+ };
2545
+ }
2293
2546
  if (isCredentialRejection(message)) {
2294
2547
  return { status: "error", retryable: false, suggestion: credentialSuggestion(), message, tool };
2295
2548
  }
@@ -2343,6 +2596,11 @@ class ThreeDPrinterMCPServer {
2343
2596
  async connectStdio() {
2344
2597
  const transport = new StdioServerTransport();
2345
2598
  await this.server.connect(transport);
2599
+ // A host that disconnects by closing stdin sends no signal. Shut down then,
2600
+ // so the server and its persistent Fusion MCP child do not outlive it.
2601
+ const disconnected = () => { void this.close().finally(() => process.exit(0)); };
2602
+ process.stdin.once("end", disconnected);
2603
+ process.stdin.once("close", disconnected);
2346
2604
  console.error("3D Printer MCP server running on stdio transport");
2347
2605
  }
2348
2606
  async connectStreamableHttp() {
@@ -2441,8 +2699,14 @@ class ThreeDPrinterMCPServer {
2441
2699
  }
2442
2700
  return value;
2443
2701
  }
2444
- async close() {
2702
+ /** Idempotent: a signal and stdin EOF can both arrive. */
2703
+ close() {
2704
+ this.closing ?? (this.closing = this.closeOnce());
2705
+ return this.closing;
2706
+ }
2707
+ async closeOnce() {
2445
2708
  await this.printerFactory.disconnectAll();
2709
+ await this.fusion.close();
2446
2710
  if (this.httpRuntime) {
2447
2711
  await this.httpRuntime.transport.close();
2448
2712
  await new Promise((resolve, reject) => {
@@ -1,4 +1,13 @@
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 type { ConfirmHardware } from "../safety/confirmation.js";
4
+ import type { CounterpartProgress, FilamentSlot, PrinterIdentity } from "../counterpart/contract.js";
5
+ /**
6
+ * K1/K2-series printers can run Moonraker: 7125 is Moonraker's own port and
7
+ * 4408 the Fluidd nginx front end that proxies its API. Neither is documented
8
+ * by Creality; stock firmware may run neither, so this is probed, never assumed.
9
+ */
10
+ export declare const CREALITY_MOONRAKER_PORTS: string[];
2
11
  /**
3
12
  * Note: this adapter's /api/device, /api/storage and /api/job routes are not a
4
13
  * documented Creality API. Readiness fails closed unless the response carries
@@ -7,6 +16,22 @@ import { GenericPrinterImplementation, type GenericPrinterState } from "./generi
7
16
  */
8
17
  export declare class CrealityImplementation extends GenericPrinterImplementation {
9
18
  protected readonly printerLabel = "Creality";
19
+ private readonly moonraker;
20
+ private readonly probes;
21
+ /** Ports probed for Moonraker, in order. */
22
+ moonrakerPorts: string[];
23
+ constructor(apiClient: AxiosInstance, confirm?: ConfirmHardware);
24
+ /** The Moonraker port that answers /server/info on this host, if any. */
25
+ moonrakerPort(host: string): Promise<string | undefined>;
26
+ counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress>;
27
+ reportsLayers(host: string): Promise<boolean>;
28
+ supportsPauseResume(host: string): Promise<boolean>;
29
+ snapshotCandidates(host: string): Promise<SnapshotCandidate[]>;
30
+ counterpartFilaments(host: string, port: string, apiKey: string): Promise<FilamentSlot[]>;
31
+ counterpartIdentity(host: string): Promise<PrinterIdentity>;
32
+ /** Pause and resume only through a detected Moonraker; the device API has no verified route. */
33
+ protected rawPauseJob(host: string): Promise<any>;
34
+ protected rawResumeJob(host: string): Promise<any>;
10
35
  protected readPrinterState(host: string, port: string, apiKey: string): Promise<GenericPrinterState>;
11
36
  getStatus(host: string, port: string, apiKey: string): Promise<any>;
12
37
  getFiles(host: string, port: string, apiKey: string): Promise<any>;
@@ -1,6 +1,15 @@
1
1
  import { GenericPrinterImplementation } from "./generic-base.js";
2
+ import { KlipperImplementation, MACRO_TIMEOUT_MS } from "./klipper.js";
3
+ import { mapCrealityDevice } from "../counterpart/progress.js";
2
4
  import fs from "fs";
3
5
  import FormData from "form-data";
6
+ /**
7
+ * K1/K2-series printers can run Moonraker: 7125 is Moonraker's own port and
8
+ * 4408 the Fluidd nginx front end that proxies its API. Neither is documented
9
+ * by Creality; stock firmware may run neither, so this is probed, never assumed.
10
+ */
11
+ export const CREALITY_MOONRAKER_PORTS = ["7125", "4408"];
12
+ const MOONRAKER_PROBE_TTL_MS = 5 * 60000;
4
13
  const IDLE_STATES = new Set(["idle", "ready", "standby", "operational"]);
5
14
  const FINISHED_STATES = new Set(["complete", "completed", "finished", "finish", "cancelled", "canceled", "stopped"]);
6
15
  /**
@@ -10,9 +19,73 @@ const FINISHED_STATES = new Set(["complete", "completed", "finished", "finish",
10
19
  * route is verified.
11
20
  */
12
21
  export class CrealityImplementation extends GenericPrinterImplementation {
13
- constructor() {
14
- super(...arguments);
22
+ constructor(apiClient, confirm) {
23
+ super(apiClient, confirm);
15
24
  this.printerLabel = "Creality";
25
+ this.probes = new Map();
26
+ /** Ports probed for Moonraker, in order. */
27
+ this.moonrakerPorts = [...CREALITY_MOONRAKER_PORTS];
28
+ this.moonraker = new KlipperImplementation(apiClient, confirm);
29
+ }
30
+ /** The Moonraker port that answers /server/info on this host, if any. */
31
+ async moonrakerPort(host) {
32
+ const cached = this.probes.get(host);
33
+ if (cached && Date.now() - cached.at < MOONRAKER_PROBE_TTL_MS)
34
+ return cached.port;
35
+ let found;
36
+ for (const port of this.moonrakerPorts) {
37
+ try {
38
+ const info = (await this.apiClient.get(`http://${host}:${port}/server/info`, { timeout: 2000 })).data;
39
+ if (info?.result && typeof info.result === "object") {
40
+ found = port;
41
+ break;
42
+ }
43
+ }
44
+ catch {
45
+ // Try the next port.
46
+ }
47
+ }
48
+ this.probes.set(host, { port: found, at: Date.now() });
49
+ return found;
50
+ }
51
+ async counterpartProgress(host, port, apiKey) {
52
+ const moonraker = await this.moonrakerPort(host);
53
+ if (moonraker)
54
+ return this.moonraker.counterpartProgress(host, moonraker, "");
55
+ const data = (await this.apiClient.get(`http://${host}:${port}/api/device/status`, { headers: { "Authorization": `Bearer ${apiKey}` } })).data;
56
+ return mapCrealityDevice(data, new Date().toISOString());
57
+ }
58
+ async reportsLayers(host) {
59
+ return (await this.moonrakerPort(host)) !== undefined;
60
+ }
61
+ async supportsPauseResume(host) {
62
+ return (await this.moonrakerPort(host)) !== undefined;
63
+ }
64
+ async snapshotCandidates(host) {
65
+ const moonraker = await this.moonrakerPort(host);
66
+ return moonraker ? this.moonraker.snapshotCandidates(host, moonraker, "") : [];
67
+ }
68
+ async counterpartFilaments(host, port, apiKey) {
69
+ const moonraker = await this.moonrakerPort(host);
70
+ return moonraker ? this.moonraker.counterpartFilaments(host, moonraker, "") : super.counterpartFilaments(host, port, apiKey);
71
+ }
72
+ async counterpartIdentity(host) {
73
+ const moonraker = await this.moonrakerPort(host);
74
+ return moonraker ? this.moonraker.counterpartIdentity(host, moonraker, "") : {};
75
+ }
76
+ /** Pause and resume only through a detected Moonraker; the device API has no verified route. */
77
+ async rawPauseJob(host) {
78
+ const moonraker = await this.moonrakerPort(host);
79
+ if (!moonraker)
80
+ throw new Error("Unsupported: this Creality printer has no Moonraker API for pause.");
81
+ return (await this.apiClient.post(`http://${host}:${moonraker}/printer/print/pause`, null, { timeout: MACRO_TIMEOUT_MS })).data;
82
+ }
83
+ async rawResumeJob(host) {
84
+ const moonraker = await this.moonrakerPort(host);
85
+ if (!moonraker)
86
+ throw new Error("Unsupported: this Creality printer has no Moonraker API for resume.");
87
+ // The Creality adapter already checked the job identity; send the raw resume.
88
+ return (await this.apiClient.post(`http://${host}:${moonraker}/printer/print/resume`, null, { timeout: MACRO_TIMEOUT_MS })).data;
16
89
  }
17
90
  async readPrinterState(host, port, apiKey) {
18
91
  let data;
@@ -1,7 +1,23 @@
1
1
  import { GenericPrinterImplementation, type GenericPrinterState } from "./generic-base.js";
2
+ import type { CounterpartProgress, PrinterIdentity } from "../counterpart/contract.js";
2
3
  export declare class DuetImplementation extends GenericPrinterImplementation {
3
4
  protected readonly printerLabel = "Duet";
4
5
  private code;
6
+ /**
7
+ * Object model keys for counterpart_print_progress and identity. Standalone
8
+ * RepRapFirmware and DSF 3.5+ answer rr_model; older DSF only /machine/status
9
+ * (an alias of /machine/model). 'v' (verbose) is required: 'f' omits job.file.
10
+ * Without a machine password no rr_connect is needed; with one, requests get 401.
11
+ * https://github.com/Duet3D/RepRapFirmware/wiki/HTTP-requests
12
+ */
13
+ private objectModel;
14
+ counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress>;
15
+ reportsLayers(): Promise<boolean>;
16
+ counterpartIdentity(host: string, port: string): Promise<PrinterIdentity>;
17
+ /** M25 pauses and M24 resumes: rr_gcode on standalone/DSF 3.5+, POST /machine/code on older DSF. */
18
+ protected rawPauseJob(host: string, port: string): Promise<any>;
19
+ protected rawResumeJob(host: string, port: string): Promise<any>;
20
+ private sendGcode;
5
21
  getStatus(host: string, port: string, apiKey: string): Promise<any>;
6
22
  getFiles(host: string, port: string, apiKey: string): Promise<any>;
7
23
  getFile(host: string, port: string, apiKey: string, filename: string): Promise<any>;
@@ -1,5 +1,11 @@
1
1
  import fs from "fs";
2
+ import { isAxiosError } from "axios";
2
3
  import { GenericPrinterImplementation } from "./generic-base.js";
4
+ import { nonEmptyText } from "../counterpart/contract.js";
5
+ import { mapDuet } from "../counterpart/progress.js";
6
+ function notFound(error) {
7
+ return isAxiosError(error) && (error.response?.status === 404 || error.response?.status === 405);
8
+ }
3
9
  /**
4
10
  * Duet Software Framework (DuetWebServer) REST routes, verified against
5
11
  * DuetSoftwareFramework src/DuetWebServer/Controllers/MachineController.cs:
@@ -26,6 +32,64 @@ export class DuetImplementation extends GenericPrinterImplementation {
26
32
  });
27
33
  return response.data;
28
34
  }
35
+ /**
36
+ * Object model keys for counterpart_print_progress and identity. Standalone
37
+ * RepRapFirmware and DSF 3.5+ answer rr_model; older DSF only /machine/status
38
+ * (an alias of /machine/model). 'v' (verbose) is required: 'f' omits job.file.
39
+ * Without a machine password no rr_connect is needed; with one, requests get 401.
40
+ * https://github.com/Duet3D/RepRapFirmware/wiki/HTTP-requests
41
+ */
42
+ async objectModel(host, port, keys) {
43
+ const base = `http://${host}:${port}`;
44
+ const model = {};
45
+ try {
46
+ // Sequential: standalone boards serve few concurrent HTTP connections.
47
+ for (const key of keys) {
48
+ model[key] = (await this.apiClient.get(`${base}/rr_model?key=${encodeURIComponent(key)}&flags=d99v`)).data?.result;
49
+ }
50
+ return model;
51
+ }
52
+ catch (error) {
53
+ if (!notFound(error))
54
+ throw error;
55
+ }
56
+ const full = (await this.apiClient.get(`${base}/machine/status`)).data;
57
+ const root = full?.result && typeof full.result === "object" ? full.result : full;
58
+ for (const key of keys)
59
+ model[key] = root?.[key];
60
+ return model;
61
+ }
62
+ async counterpartProgress(host, port, apiKey) {
63
+ const model = await this.objectModel(host, port, ["state", "job"]);
64
+ if (!model.state || typeof model.state !== "object")
65
+ throw new Error("Duet did not return state from its object model.");
66
+ return mapDuet(model.state, model.job ?? {}, new Date().toISOString());
67
+ }
68
+ async reportsLayers() {
69
+ return true;
70
+ }
71
+ async counterpartIdentity(host, port) {
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
+ /** M25 pauses and M24 resumes: rr_gcode on standalone/DSF 3.5+, POST /machine/code on older DSF. */
77
+ async rawPauseJob(host, port) {
78
+ return this.sendGcode(host, port, "M25");
79
+ }
80
+ async rawResumeJob(host, port) {
81
+ return this.sendGcode(host, port, "M24");
82
+ }
83
+ async sendGcode(host, port, gcode) {
84
+ try {
85
+ return (await this.apiClient.get(`http://${host}:${port}/rr_gcode?gcode=${encodeURIComponent(gcode)}`)).data;
86
+ }
87
+ catch (error) {
88
+ if (!notFound(error))
89
+ throw error;
90
+ }
91
+ return this.code(host, port, gcode);
92
+ }
29
93
  async getStatus(host, port, apiKey) {
30
94
  const url = `http://${host}:${port}/machine/status`;
31
95
  const response = await this.apiClient.get(url);